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.