Search code examples
game-physicsgodotgdscript

When raycasting forms multiple intersections, which point does it return? Can which point is returned be controlled?


I'm working on a chess "game" in Godot where players can import 3D models for the board and wrap a checkerboard around it with uv coordinates. All the conversions between world, local, and uv space required for this project have been crazy. But, one of the more unexpected caveats with complex board shapes, particularly my freshly imported klein bottle, is that raycasting a line from the camera will theoretically have multiple solutions, but only return one.

When the player clicks on the screen, I think raycasting should find the intersection closest to them, but Godot's PhysicsDirectSpaceState.intersect_ray() method is returning whichever intersection it finds first. This intersection could either be the correct one or not, and with up to 4 intersection points with the klein bottle, the chances are slim that the correct intersection is returned. The RayCast object does the same thing, only storing one intersection point.

Is there a way to return all the intersection points of a ray/line in Godot? That way I could sort through them to find the closest intersection point to the Player node. I also feel like this shouldn't be an issue in the first place. Godot's Ray-Casting tutorial describes the ray as "hitting something" when it returns a values, and that intuitively tells me that the closest intersection to the "ray origin" argument will be returned.

If you have the stomach for work-in-progress spaghetti code, I have a GitHub project with all the source code on it. And here is the code I use whenever I raycast in the game:

#cast out a ray from the camera, given a physics state s
static func raycast(var p:Vector2, var c:Camera, 
    var w:World, var v:float = INF, var mask:int = 0x7FFFFFFF):
    
    #get physics state of the current scene
    var s = w.direct_space_state
    #origin and normal of the camera
    var o:Vector3 = c.project_ray_origin(p)
    var n:Vector3 = c.project_ray_normal(p)
    #get ray intersection with that scene
    var r = s.intersect_ray(o, n * v, [], mask)
    #if the intersection lands, return r
    if !r.empty():
        return r
    return null

Above from BoardConverter.gd, line 472.


Solution

  • We could render to a hidden Viewport and query that.

    So let us start by adding a Viewport to your scene. Make sure it has a size set. In fact, we can resize to match the main Viewport with a script. For example:

    extends Viewport
    
    
    func _ready() -> void:
        # warning-ignore:return_value_discarded
        get_tree().connect("screen_resized", self, "resize")
    
    
    func resize() -> void:
        size = get_viewport().size
    

    Alternatively:

    extends Viewport
    
    
    func _ready() -> void:
        # warning-ignore:return_value_discarded
        get_viewport().connect("size_changed", self, "resize")
    
    
    func resize() -> void:
        size = get_viewport().size
    

    Also make sure to set render_target_update_mode to UPDATE_ALWAYS. Set render_target_v_flip to true, and Set transparent_bg to true.

    Then you can add a Camera child with a script that copies the global_transform of the main Camera, and make it current (under the Viewport node). For example:

    extends Camera
    
    
    func _ready() -> void:
        current = true
    
    
    func _process(_delta: float) -> void:
        global_transform = get_tree().root.get_camera().global_transform
    

    You might also want to copy all the relevant properties of the camera to make sure the view matches:

        var tracked_cam:Camera = get_tree().root.get_camera()
        global_transform = tracked_cam.global_transform
        fov = tracked_cam.fov
        keep_aspect = tracked_cam.keep_aspect
        cull_mask = tracked_cam.cull_mask
        environment = tracked_cam.environment
        h_offset = tracked_cam.h_offset
        v_offset = tracked_cam.v_offset
        doppler_tracking = tracked_cam.doppler_tracking
        projection = tracked_cam.projection
        fov = tracked_cam.fov
        size = tracked_cam.size
        frustum_offset = tracked_cam.frustum_offset
        near = tracked_cam.near
        far = tracked_cam.far
    

    And what you want to render in that Viewport is your mesh, but with a different material. So you are making a duplicate of your MeshInstance, and you can either:

    • Add it as a child of the original, and use layers (under "VisualInstance" in the Inspector Panel) of the MeshInstance and cull_masks of the Camera to make sure only the Camera in the Viewport can see it.
    • Or, add it as a child of the Viewport, do something similar to what we did for the Camera, so it copies the position of the original, and set own_world to true on the Viewport, so it only renders what is inside of it.

    About the material, it will be a ShaderMaterial that outputs the UV as color (write ALBEDO, and set the shader as unshaded):

    shader_type spatial;
    render_mode unshaded;
    
    void fragment()
    {
        ALBEDO = vec3(UV, 0.0);
    }
    

    Then in a script attached to some other Node. It will have a export var of type Texture and set it to a new ViewportTexture from the Viewport you defined it:

    export var texture:Texture
    

    We want to make sure that the Viewport is already in the scene tree when this Node enters. And it can find it. Thus, this Node should not be the Viewport, nor a parent of the Viewport. Also avoid using a child of the Viewport. Thus, we want a sibling. And a sibling that is before the Viewport in the scene tree.

    Now, to read a pixel from the Texture, you can get an Image from it with get_data, then call lock on it, and then use get_pixel or get_pixelv. Don't forget to unlock it.

    This code should work for both mouse or touch input:

    func _input(event: InputEvent) -> void:
        if not (
            event is InputEventScreenDrag
            or event is InputEventScreenTouch
            or event is InputEventMouse
        ):
            return
    
        var image:Image = texture.get_data()
        image.lock()
        var color := image.get_pixelv(event.position)
        if color.a != 0.0:
            print(Vector2(color.r, color.g))
    
        image.unlock()