Skip to content

Commit

Permalink
Rewrite Gui in 3D to use Physics Picking for mouse events
Browse files Browse the repository at this point in the history
Rework Gui in 3D Demo to handle mouse events via
Physics Picking instead of in _unhandled_input.

This brings several benefits:
- Correctly handle cases, where the 3D-GUI is located behind other
collision objects.
- Proper passive hovering support

This allows also to make simplifications in the code, because
3D-mouse position no longer needs to be calculated manually.
  • Loading branch information
Sauermann committed Feb 21, 2024
1 parent 722bd11 commit 1a4c423
Show file tree
Hide file tree
Showing 2 changed files with 67 additions and 110 deletions.
175 changes: 66 additions & 109 deletions viewport/gui_in_3d/gui_3d.gd
Original file line number Diff line number Diff line change
@@ -1,22 +1,20 @@
extends Node3D

# The size of the quad mesh itself.
var quad_mesh_size
# Used for checking if the mouse is inside the Area3D
# Used for checking if the mouse is inside the Area3D.
var is_mouse_inside = false
# Used for checking if the mouse was pressed inside the Area3D
var is_mouse_held = false
# The last non-empty mouse position. Used when dragging outside of the box.
var last_mouse_pos3D = null
# The last processed input touch/mouse event. To calculate relative movement.
var last_mouse_pos2D = null
var last_event_pos2D = null
# The time of the last event in seconds since engine start.
var last_event_time: float = -1.0

@onready var node_viewport = $SubViewport
@onready var node_quad = $Quad
@onready var node_area = $Quad/Area3D

func _ready():
node_area.mouse_entered.connect(self._mouse_entered_area)
node_area.mouse_exited.connect(self._mouse_exited_area)
node_area.input_event.connect(self._mouse_input_event)

# If the material is NOT set to use billboard settings, then avoid running billboard specific code
if node_quad.get_surface_override_material(0).billboard_mode == BaseMaterial3D.BillboardMode.BILLBOARD_DISABLED:
Expand All @@ -32,134 +30,93 @@ func _mouse_entered_area():
is_mouse_inside = true


func _mouse_exited_area():
is_mouse_inside = false


func _unhandled_input(event):
# Check if the event is a non-mouse/non-touch event
var is_mouse_event = false
for mouse_event in [InputEventMouseButton, InputEventMouseMotion, InputEventScreenDrag, InputEventScreenTouch]:
if is_instance_of(event, mouse_event):
is_mouse_event = true
break

# If the event is a mouse/touch event and/or the mouse is either held or inside the area, then
# we need to do some additional processing in the handle_mouse function before passing the event to the viewport.
# If the event is not a mouse/touch event, then we can just pass the event directly to the viewport.
if is_mouse_event and (is_mouse_inside or is_mouse_held):
handle_mouse(event)
elif not is_mouse_event:
node_viewport.push_input(event)
# If the event is a mouse/touch event, then we can ignore it here, because it will be
# handled via Physics Picking.
return
node_viewport.push_input(event)


# Handle mouse events inside Area3D. (Area3D.input_event had many issues with dragging)
func handle_mouse(event):
func _mouse_input_event(_camera: Camera3D, event: InputEvent, event_position: Vector3, _normal: Vector3, _shape_idx: int):
# Get mesh size to detect edges and make conversions. This code only support PlaneMesh and QuadMesh.
quad_mesh_size = node_quad.mesh.size
var quad_mesh_size = node_quad.mesh.size

# Event position in Area3D in world coordinate space.
var event_pos3D = event_position

# Detect mouse being held to mantain event while outside of bounds. Avoid orphan clicks
if event is InputEventMouseButton or event is InputEventScreenTouch:
is_mouse_held = event.pressed
# Current time in seconds since engine start.
var now: float = Time.get_ticks_msec() / 1000.0

# Find mouse position in Area3D
var mouse_pos3D = find_mouse(event.global_position)
# Convert position to a coordinate space relative to the Area3D node.
# NOTE: affine_inverse accounts for the Area3D node's scale, rotation, and position in the scene!
event_pos3D = node_quad.global_transform.affine_inverse() * event_pos3D

# TODO: Adapt to bilboard mode or avoid completely.

var event_pos2D: Vector2 = Vector2()

# Check if the mouse is outside of bounds, use last position to avoid errors
# NOTE: mouse_exited signal was unrealiable in this situation
is_mouse_inside = mouse_pos3D != null
if is_mouse_inside:
# Convert click_pos from world coordinate space to a coordinate space relative to the Area3D node.
# NOTE: affine_inverse accounts for the Area3D node's scale, rotation, and position in the scene!
mouse_pos3D = node_area.global_transform.affine_inverse() * mouse_pos3D
last_mouse_pos3D = mouse_pos3D
else:
mouse_pos3D = last_mouse_pos3D
if mouse_pos3D == null:
mouse_pos3D = Vector3.ZERO

# TODO: adapt to bilboard mode or avoid completely

# convert the relative event position from 3D to 2D
var mouse_pos2D = Vector2(mouse_pos3D.x, -mouse_pos3D.y)

# 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
mouse_pos2D.x += quad_mesh_size.x / 2
mouse_pos2D.y += quad_mesh_size.y / 2
# Then we need to convert it into the following range: 0 -> 1
mouse_pos2D.x = mouse_pos2D.x / quad_mesh_size.x
mouse_pos2D.y = mouse_pos2D.y / quad_mesh_size.y

# Finally, we convert the position to the following range: 0 -> viewport.size
mouse_pos2D.x = mouse_pos2D.x * node_viewport.size.x
mouse_pos2D.y = mouse_pos2D.y * node_viewport.size.y
# We need to do these conversions so the event's position is in the viewport's coordinate system.
# Convert the relative event position from 3D to 2D.
event_pos2D = Vector2(event_pos3D.x, -event_pos3D.y)

# 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.5 -> 0.5
event_pos2D.x = event_pos2D.x / quad_mesh_size.x
event_pos2D.y = event_pos2D.y / quad_mesh_size.y
# Then we need to convert it into the following range: 0 -> 1
event_pos2D.x += 0.5
event_pos2D.y += 0.5

# Finally, we convert the position to the following range: 0 -> viewport.size
event_pos2D.x *= node_viewport.size.x
event_pos2D.y *= node_viewport.size.y
# We need to do these conversions so the event's position is in the viewport's coordinate system.

elif last_event_pos2D != null:
# Fall back to the last known event position.
event_pos2D = last_event_pos2D

# Set the event's position and global position.
event.position = mouse_pos2D
event.global_position = mouse_pos2D
event.position = event_pos2D
if event is InputEventMouse:
event.global_position = event_pos2D

# If the event is a mouse motion event...
if event is InputEventMouseMotion:
# Calculate the relative event distance.
if event is InputEventMouseMotion or event is InputEventScreenDrag:
# If there is not a stored previous position, then we'll assume there is no relative motion.
if last_mouse_pos2D == null:
if last_event_pos2D == 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
# the previous position from the new position. This will give us the distance the event traveled from prev_pos.
else:
event.relative = mouse_pos2D - last_mouse_pos2D
# Update last_mouse_pos2D with the position we just calculated.
last_mouse_pos2D = mouse_pos2D

# Finally, send the processed input event to the viewport.
node_viewport.push_input(event)


func find_mouse(global_position):
var camera = get_viewport().get_camera_3d()
var dist = find_further_distance_to(camera.transform.origin)

# From camera center to the mouse position in the Area3D.
var parameters = PhysicsRayQueryParameters3D.new()
parameters.from = camera.project_ray_origin(global_position)
parameters.to = parameters.from + camera.project_ray_normal(global_position) * dist

# Manually raycasts the area to find the mouse position.
parameters.collision_mask = node_area.collision_layer
parameters.collide_with_bodies = false
parameters.collide_with_areas = true
var result = get_world_3d().direct_space_state.intersect_ray(parameters)
event.relative = event_pos2D - last_event_pos2D
event.velocity = event.relative / (now - last_event_time)

if result.size() > 0:
return result.position
else:
return null
# Update last_event_pos2D with the position we just calculated.
last_event_pos2D = event_pos2D

# Update last_event_time to current time.
last_event_time = now

func find_further_distance_to(origin):
# Find edges of collision and change to global positions
var edges = []
edges.append(node_area.to_global(Vector3(quad_mesh_size.x / 2, quad_mesh_size.y / 2, 0)))
edges.append(node_area.to_global(Vector3(quad_mesh_size.x / 2, -quad_mesh_size.y / 2, 0)))
edges.append(node_area.to_global(Vector3(-quad_mesh_size.x / 2, quad_mesh_size.y / 2, 0)))
edges.append(node_area.to_global(Vector3(-quad_mesh_size.x / 2, -quad_mesh_size.y / 2, 0)))

# Get the furthest distance between the camera and collision to avoid raycasting too far or too short
var far_dist = 0
var temp_dist
for edge in edges:
temp_dist = origin.distance_to(edge)
if temp_dist > far_dist:
far_dist = temp_dist

return far_dist
# Finally, send the processed input event to the viewport.
node_viewport.push_input(event)


func rotate_area_to_billboard():
var billboard_mode = node_quad.get_surface_override_material(0).params_billboard_mode

# Try to match the area with the material's billboard setting, if enabled
# Try to match the area with the material's billboard setting, if enabled.
if billboard_mode > 0:
# Get the camera
# Get the camera.
var camera = get_viewport().get_camera_3d()
# Look in the same direction as the camera
# Look in the same direction as the camera.
var look = camera.to_global(Vector3(0, 0, -100)) - camera.global_transform.origin
look = node_area.position + look

Expand All @@ -169,5 +126,5 @@ func rotate_area_to_billboard():

node_area.look_at(look, Vector3.UP)

# Rotate in the Z axis to compensate camera tilt
# Rotate in the Z axis to compensate camera tilt.
node_area.rotate_object_local(Vector3.BACK, camera.rotation.z)
2 changes: 1 addition & 1 deletion viewport/gui_in_3d/gui_in_3d.tscn
Original file line number Diff line number Diff line change
Expand Up @@ -63,10 +63,10 @@ shadow_blur = 3.0
omni_range = 10.0

[node name="Camera_Move" type="AnimationPlayer" parent="."]
autoplay = "Move_camera"
libraries = {
"": SubResource("AnimationLibrary_uw4n0")
}
autoplay = "Move_camera"

[node name="Background" type="Node3D" parent="."]

Expand Down

0 comments on commit 1a4c423

Please sign in to comment.