Mouse input pushed to viewports cannot reach physics bodies

Good day!

Godot Version

4.6.3.stable

Question

I am trying to make a generic component for making interactable screens in 3D using viewports. I’m retooling a previous hardcoded solution I put together based on the official GUI in 3D demo.

The component targets the necessary nodes through export properties, pushes a default mesh and viewport texture to a MeshInstance3D, creates a corresponding Area3D and CollisionShape3D and pushes input events received by that area to the viewport target, with some translating for positional inputs. As of now, key inputs work just fine, mouse movements too; but I’ve noticed mouse clicks are only received on controls and not physics bodies, which is a problem for me, as I have code that depends on detecting clicks on Area2Ds.

This is the component’s current code:

@tool
extends Node3D
class_name InteractableMeshComponent
## Turns the mesh_instance_target_path MeshInstance3D into a clickable screen in 3D space.
## Replaces its mesh and material with the default provided through export,
## and adds the ViewportTexture.
## Much of it is taken from the "GUI in 3D Viewport" demo project:
## https://godotengine.org/asset-library/asset/2807

@export var mesh_instance_target: MeshInstance3D: 
	set(x):
		if x != mesh_instance_target:
			mesh_instance_target = x
			if is_node_ready():
				setup_mesh()
				setup_collision_shape()
@export var viewport_target: Viewport:
	set(x):
		if x != viewport_target:
			viewport_target = x
			if is_node_ready():
				setup_mesh()
@export var area_3d_target: Area3D:
	set(x):
		if x != area_3d_target:
			area_3d_target = x
			if is_node_ready():
				setup_collision_shape()
				connect_area()
@export var collision_shape_target: CollisionShape3D:
	set(x):
		if x != collision_shape_target:
			collision_shape_target = x
			if is_node_ready():
				setup_collision_shape()
@export_subgroup("Default resources")
@export var default_material: StandardMaterial3D = preload("uid://gv78yrqwu8nh") :
	set(x):
		if x != default_material:
			default_material = x
			setup_mesh()
@export var default_mesh: Mesh = preload("uid://cg74gxa1qvikb") :
	set(x):
		if x != default_mesh:
			default_mesh = x
			setup_mesh()
var last_viewport_mouse_pos: Vector2 

signal input_pushed(event: InputEvent)

func _ready() -> void:
	#if mesh_instance_target:
		#mesh_instance_target = get_node(mesh_instance_target_path)
		#viewport_target = get_node(viewport_target_path)
	setup_mesh()
	setup_collision_shape()
	connect_area()

func setup_mesh() -> void:
	if (not is_node_ready()) or (mesh_instance_target == null):
		return 
	mesh_instance_target.mesh = default_mesh.duplicate()
	mesh_instance_target.mesh.material = default_material.duplicate()

	# create viewport texture
	if viewport_target != null:
		mesh_instance_target.mesh.material.emission_texture = viewport_target.get_texture()
	Log.pr("%s : Mesh recreated" % self)

func setup_collision_shape() -> void:
	if (not is_node_ready()) or (mesh_instance_target == null) or (collision_shape_target == null):
		return 
	
	collision_shape_target.shape = mesh_instance_target.mesh.create_convex_shape(false, true)

func connect_area() -> void:
	area_3d_target.input_event.connect(_on_viewport_area_input_event)

func _unhandled_input(event: InputEvent) -> void:
	# This pushes key inputs to the SubViewport.
	# Mouse/touchscreen inputs are ignored and handled by _on_viewport_area_input_event
	# instead.

	# TODO: try any() instead, with profiler
	var mouse_types = [InputEventMouseButton, InputEventMouseMotion, InputEventScreenDrag, InputEventScreenTouch]

	if mouse_types.any(func(t): return is_instance_of(event, t)):
		return

	viewport_target.push_input(event,false)
	input_pushed.emit(event)
	Log.pr("unhandled_input", event)

func _on_viewport_area_input_event(camera: Node, event: InputEvent, event_position: Vector3, normal: Vector3, shape_idx: int) -> void:
	# input_event only triggers on mouse inputs that are above our Area3D.
	
	# Transform world space event position to area relative space
	var area_mouse_pos : Vector3 = mesh_instance_target.global_transform.affine_inverse() * event_position
	#Debug.print(Debug.VerbLevel.VERBOSE, "[Input] Mouse input in computer viewport area; event_position=%s - area_mouse_pos=%s" % [event_position, area_mouse_pos])
	
	# Convert 3D position to 2D
	var viewport_mouse_pos : Vector2 = Vector2(
		area_mouse_pos.x,
		-area_mouse_pos.y,
	)
	
	# Convert to viewport relative coordinate
	var screen_size = mesh_instance_target.mesh.size
	var viewport_size = viewport_target.size
	# "Right now the event position's range is the following: (-quad_size/2) -> (quad_size/2)"
	# "We need to convert it into the following range: 0 -> quad_size"
	viewport_mouse_pos.x += screen_size.x / 2
	viewport_mouse_pos.y += screen_size.y / 2
	# "Then we need to convert it into the following range: 0 -> 1"
	viewport_mouse_pos.x = viewport_mouse_pos.x / screen_size.x
	viewport_mouse_pos.y = viewport_mouse_pos.y / screen_size.y

	# "Finally, we convert the position to the following range: 0 -> viewport.size"
	viewport_mouse_pos.x = viewport_mouse_pos.x * viewport_size.x
	viewport_mouse_pos.y = viewport_mouse_pos.y * viewport_size.y
	
	# Modify the event
	event.position = viewport_mouse_pos
	event.global_position = viewport_mouse_pos
	
	# "If the event is a mouse motion event...
	if event is InputEventMouseMotion:
		# "If there is not a stored previous position, then we'll assume there is no relative motion."
		if last_viewport_mouse_pos == null:
			event.relative = Vector2(0, 0)
		# "If there is a stored previous position, then we'll calculate the relative position by subtracting"
		# "the previous position from the new position. This will give us the distance the event traveled from prev_pos"
		else:
			event.relative = viewport_mouse_pos - last_viewport_mouse_pos
	# "Update last_mouse_pos2D with the position we just calculated."
	last_viewport_mouse_pos = viewport_mouse_pos
	
	# push the event
	viewport_target.push_input(event, false)
	
	# extra: move mouse model accordingly
	input_pushed.emit(event)

And here are a couple demo videos that show how input events are received on different physics bodies. Oddly enough, with no SubViewport involved, StaticBody2D does not receive the input, but Area2D does. Yet, when projected inside the viewport, it doesn’t.

This one shows that controls work fine. I am able to press a button and not only trigger the pressed signal but also get the input from it plainly.

What am I missing to get this to work? I don’t understand why it would be treated differently between types.

Did you enable Viewport.physics_object_picking in each SubViewport?