Best way to handle raycasting?

In my game no signal, I do a lot of raycasting to find out what the user has clicked on. Generally, my hierarchies look something like this:

ClickableObject
├ ... # Some arbitrary level of nesting
│   └ StaticBody3D
│     └ CollisionShape3D
└ OtherStuff

Since all of the logic for what I want to do is on the ClickableObject, I’ve gotten into this pattern of recursively searching upwards for the thing that should handle the click, which looks something like this:

@onready var camera: Camera3D = $Camera3D

func parent_where(node: Node, filter) -> Node:
    if node == null:
        return null
    elif filter.call(node):
        return node
    else:
        return parent_where(node.get_parent(), filter)

func do_raycast() -> void:
    var mouse_pos := get_mouse_position()
    var space_state := get_world_3d().direct_space_state

    var origin := camera.project_ray_origin(mouse_pos)
    var end := origin + camera.project_ray_normal(mouse_pos) * 100
    var query := PhysicsRayQueryParameters3D.create(origin, end)

    var raycast := space_state.intersect_ray(query)
    if raycast.has("collider"):
        var collider: Node = raycast.collider
        var clicked_object := parent_where(
            collider, func(a: Node) -> bool: return a is ClickableObject
        )

        # Do something with clicked_object

There are some problems with this approach, but I’m not worried about performance here. Instead, I’ve run into situations where this results in the wrong behavior. For example, it might look up the tree and happen to find a parent that is clickable, but I don’t actually want to click on it. So, that leads to me having to rearrange how everything is parented.


Lately, I’ve been thinking that there must be a better way. Something I’ve been considering is to extend StaticBody3D, like this:

class_name ClickableStaticBody3D
extends StaticBody3D

signal clicked_on() # Emit this signal from the raycasting code.

Another is to connect to the mouse_entered and mouse_exited signals on the StaticBody3D to the ClickableObject, but the docs suggest that these signals are not reliable.

I don’t find either solutions particularly satisfying, so I’m curious how y’all do it.

1 Like

I used the mouse signals and didn’t find any bad stuff happening, except, it won’t work well if you happen to have another body in between the camera and the object you wanna touch (let’s say an invisible wall).

You then have to play with mask and stuff.

I happen to have removed it for my purpose.

I think emitting the signal from the static body is the thing that is the most “Godot” way.

Some people would have this on the static body:

@export stuff_i_need_to_click: ClickableObject

func raycast_touched_me():
    stuff_i_need_to_click.click()

I like it less personally.

1 Like

The way I’ve implemented it in Re:Placement is basically the “extending the CollisionObject” approach. (Godot 3 code follows)

A Clickable object in my case is an Area.

class_name Clickable
extends Area

signal hovered()
signal unhovered()
signal pressed()

export var enabled: bool = true setget set_enabled

onready var shape: CollisionShape = $CollisionShape

func _ready() -> void:
	shape.disabled = !enabled

func hover() -> void:
	emit_signal("hovered")

func unhover() -> void:
	emit_signal("unhovered")

func press() -> void:
	emit_signal("pressed")

func set_enabled(v: bool) -> void:
	enabled = v
	if shape:
		shape.disabled = !v

These come into contact with a RayCast node, which updates automatically every physics frame. I’m using a FPP camera, so the raycast always points at what’s in the middle of the screen (under the crosshair).

extends RayCast

signal pressed(target)

var target: Clickable = null
var frozen: = false

func _process(delta: float) -> void:
	if frozen:
		return

	if Input.is_action_just_pressed("use") && is_instance_valid(target) && target.enabled:
		target.press()
		emit_signal("pressed", target)

func _physics_process(delta: float) -> void:
	if frozen:
		return

	var collider: = get_collider() as Clickable
	if collider != target && !Input.is_action_pressed("use"):
		if is_instance_valid(target):
			target.unhover()
		if collider:
			collider.hover()
		target = collider

As you can see, I basically created my own version of mouse_entered and mouse_exited. I keep track of what the raycast is touching, but don’t update it while player is holding Mouse1.

Because of the mess my interaction code is, the raycast also emits a pressed signal, as a shortcut to have the player controller do something with the Clickable.

1 Like

I’ve been thinking about this again, because while I was finishing up development tasks for no signal I soured on the recursive search strategy I originally suggested in the original post. So, I want to take some time to explain why.

I ended up finding out that I would have very many different kinds of clickable objects in my game, some of which I found could not be parent nodes at all due to technical limitations, and ended up with this pair of functions in my code quite often:

var raycast: RayCast3D

# Find the clickable object
func find_clickable() -> Node:
	var collider: Node = raycast.get_collider()

	var parent_clickable := parent_where(
		raycast.get_collider() as Node,
		func(a: Node) -> bool:
			return \
				a is Item or \
				a is SlidingPuzzle # etc...
	)
	if is_instance_valid(parent_clickable):
		return parent_clickable

	return child_where(
		collider,
		func(a: Node) -> bool:
			return \
				a is AnimatedButton or \
				a is AnimatedDoor #etc...
	)

# Click on a clickable object
func handle_click() -> void:
	var clickable = find_clickable()
	if clickable is Item:
		pass # elided
	elif clickable is SlidingPuzzle:
		pass # elided
	elif clickable is AnimatedButton:
		pass # elided
	elif clickable is AnimatedDoor:
		pass # elided
	# etc...

There were two problems with this approach:

  • I needed to manually make sure that find_clickable and handle_click were always in sync. Sometimes I would miss this, causing bugs that I wouldn’t notice until much later.
  • Since my input handling code is closely coupled with specific clickable classes and I have multiple input handlers, there were sometimes subtle differences between the different input handlers that made bugs difficult to track down.

I think it’s pretty obvious here that I should have used a signal, but it’s not clear to me exactly how I would set that up.

You could make the argument that I could extend the EditorScenePostImport class to automatically add a script which uses the collider extension pattern as mentioned above by @ColdEmber and @outfrost to every collider in every imported scene. But, this still wouldn’t make it easy for me to set up because I would need to set every imported scene as editable in order to access the signal on the collider. @Titanseeker created hundreds of models for no signal that I wanted to be able to drag directly into whatever scene I wanted them to be included in, so this kind of hitch in the workflow would have been unacceptable.

I also can’t relay the signal to a custom script on the root node to avoid needing to mark the scene as editable because I already have a custom script on the root node that has its own separate responsibility.

Hopefully this explains why I’ve soured on the recursive search strategy for this after working on no signal. I’m not really sure what I would prefer instead, yet. Maybe collider extension would work well if I just tried it out in practice and saw how I went… or maybe there’s some other secret way to handle the situation.

I got similar problems with 3D objects in my game. I feel like I would want to import a 3D model, then automatically build the game object associated with it, and have it updated when the 3D model is updated.

Right now the workflow was:

  • Import 3D model
  • Save as mesh resource
  • Create game object (maybe inherited from general one,maybe new)
  • Use mesh resource in game object

This is really time consuming. I need to redo it almost entirely if I need to change the 3D model. Updating resources in the editor is very error prone, either human, or weird godot editor cache accidents.

I did not use the 3D model directly because it would just duplicate materials for me (X items would have X materials using their own version of the same texture so it would take a lot of space in the end, when my whole game is maybe 10 materials).

Maybe it could have been resolved by setting up a clear pipeline that build and update objects, looking at the directory folder. Could be just a python script or even a Editor Plugin probably.

You can actually do this with EditorScenePostImport! Here’s what using that looks like in no signal:

@tool
extends EditorScenePostImport

const SHADER := preload("res://addons/coloring/target_dither.gdshader")
const BRUSH_SCRIPT := preload("res://addons/coloring/brush.gd")

func _post_import(scene: Node) -> Object:
	scene.set_script(BRUSH_SCRIPT)

	# Set a new default material
	var mesh_instances := children_where(
		scene, func(a: Node) -> bool: return a is MeshInstance3D
	)
	for mesh_instance: MeshInstance3D in mesh_instances:
		for i in mesh_instance.mesh.get_surface_count():
			var material := ShaderMaterial.new()
			material.resource_local_to_scene = true
			material.shader = SHADER

			# Copy over the non-albedo properties
			var old_mat: BaseMaterial3D = mesh_instance.mesh.surface_get_material(i)
			material.set_shader_parameter("roughness", old_mat.roughness)
			material.set_shader_parameter("metallic", old_mat.metallic)
			material.set_shader_parameter("specular", old_mat.metallic_specular)
			mesh_instance.mesh.surface_set_material(i, material)

	return scene

func children_where(node: Node, filter: Callable) -> Array[Node]:
	if node == null:
		return []

	var results: Array[Node] = []
	for child in node.get_children(true):
		results.append_array(children_where(child, filter))
	if filter.call(node):
		results.push_back(node)
	return results

This script attaches a script to the root node and then modifies every mesh instance to use a ShaderMaterial instead of the default imported BaseMaterial3D. I think you can also change the root node using this approach, but I’m not 100% sure. Any changes to the original 3d model asset will get run through the import process automatically, so everything stays up to date.

2 Likes

Oh okay, didn’t know about that! That could be handy for the future!