Dungeon Master Devlog - Mouse Over 3D Sprites in Godot (with Transparency)
Week 4
Hello everyone! Today I want to discuss a technical challenge I overcame with this project, in the hopes that someone else can use what I’ve learned in their own work. That problem is mousing over transparent 3D sprites in Godot 4.
The Problem
Godot has methods for detecting mouse input and testing transparency on Sprite2D nodes, but no such equivalent methods for Sprite3D nodes. Godot uses CollisionShapes for 3D mouse interactions, but that limits the detectable area to primitive shapes such as squares.
The Solution
The method I’ve come up with allows for mouse input to be detected on Sprite3D nodes in 3D Space, using only the opaque regions of the sprite.
This solution relies on 2 scripts for both the Camera node, and the Sprite3D node. Furthermore, the Sprite3D needs to be configured in a specific way for this solution to work.
The Sprite3D
For starters, here is how the Sprite3D node is organized. It consists of a parent Node3D with a Sprite3D child. That Sprite3D has a Sprite2D child (we’ll call this our Virtual Sprite going forwards), and an Area3D with a BoxShape3D child. (The UnitOutline node can be ignored, as it’s not part of this solution.)
The important configurations here are that the BoxShape3D has a Z Size of 0.01m (the smallest size for a CollisionShape), and the Area3D has Ray Pickable set to False (we’ll be handling this manually instead), and the Collision Layer set to whichever layer is dedicated to mouse selectable Sprite3Ds (I used layer 2).
The script for our Sprite3D scene is as follows:
First, we define our global variables and signals.
extends Node3D
signal input_event(camera: Node, event: InputEvent, position: Vector3, normal: Vector3)
signal mouse_entered()
signal mouse_exited()
@onready var _sprite: Sprite3D = $UnitSprite
@onready var _virtual_sprite: Sprite2D = $UnitSprite/VirtualSprite
@onready var _collision_shape: CollisionShape3D = $UnitSprite/ClickableArea/CollisionShape3D
var mouse_over: bool = false
var _mouse_input_received: bool = false
Next we define a method to set the texture of our Sprite. This will ensure our nodes all have the same texture, and maintain the correct scale.
func set_sprite(texture: Texture2D) -> void:
# Update Sprite3D Texture
_sprite.texture = texture
# Update Virtual Sprite
_virtual_sprite.texture = texture
# Update CollisionShape size and position to match the Sprite3D
_collision_shape.shape.size.x = texture.get_width() * _sprite.pixel_size
_collision_shape.shape.size.y = texture.get_height() * _sprite.pixel_size
_collision_shape.position.x = texture.get_width() / 2.0 * _sprite.pixel_size
_collision_shape.position.y = texture.get_height() / 2.0 * _sprite.pixel_size
Next, a method to test if the pixel at a given position is considered opaque. This will take the position of our input event, and return true if the position is opaque.
func _is_pixel_opaque(input_position: Vector3) -> bool:
# Convert input position to Sprite local position.
var _pixel_position = (input_position - global_position) / (_sprite.pixel_size * scale)
# We offset these positions because of different centering between the 2D and 3D sprites.
var _texture_local_x = _pixel_position.x - (_sprite.texture.get_width() / 2.0)
# The Y Position has to be inverted, because the Y-Axis is flipped in 2D and 3D space.
var _texture_local_y = (_pixel_position.y - _sprite.texture.get_height() / 2.0) * -1
var _texture_local_vec2 = Vector2(_texture_local_x, _texture_local_y)
# We call the built-in method on Sprite2D to determine whether the 2D position is an opaque pixel.
return _virtual_sprite.is_pixel_opaque(_texture_local_vec2)
Next, we declare a method to test a mouse input, and return true if it is valid (meaning it is over an opaque portion of our sprite)
func try_mouse_input(camera: Node, event: InputEvent, input_position: Vector3, normal: Vector3) -> bool:
if _is_pixel_opaque(input_position):
# We set that a mouse input has been received, and emit our signal for an input event
_mouse_input_received = true
input_event.emit(camera, event, input_position, normal)
return true
else:
return false
And lastly, a method that is called whenever the camera processes a mouse ray. We use this to update whether or not the mouse is over the sprite, and call any appropriate signals.
func _on_3dspace_mouse_ray_processed() -> void:
if _mouse_input_received:
if !mouse_over:
# This case indicates the mouse has entered this Sprite
mouse_over = true
mouse_entered.emit(self)
else:
if mouse_over:
# This case indicates the mouse has exited this Sprite
mouse_over = false
mouse_exited.emit(self)
# Lastly, we reset the _mouse_input_received variable back to false
_mouse_input_received = false
From here, one can easily configure functions for mouse_entered, mouse_exited, and input_event to handle those scenarios with whatever is needed.
The Camera
Next we have the script for the Camera node we’re using to test mouse inputs from:
extends Camera3D
# This can be adjusted for however far you want to test mouse inputs.
const RAY_LENGTH = 1000.0
# This is the signal our Sprites listen for. It's up to the user to ensure the Sprites are properly connected to this signal.
signal mouse_ray_processed()
# The physics layers for mouse interactible Sprite3Ds
@export_flags_3d_physics var _sprite_layers
var _query_mouse: bool = false
var _mouse_event: InputEventMouse
Next we declare the built-in unhandled_input and physics_process functions. This solution exploits the fact that Godot handles inputs before the physics process.
func _unhandled_input(event):
if event is InputEventMouse:
_query_mouse = true
_mouse_event = event
func _physics_process(_delta):
if _query_mouse:
_check_sprite_input()
_query_mouse = false
mouse_ray_processed.emit()
And lastly, our function to check for inputs.
func _check_sprite_input() -> bool:
var not_hits = []
var space_state = get_world_3d().direct_space_state
var from = project_ray_origin(_mouse_event.position)
var to = from + project_ray_normal(_mouse_event.position) * RAY_LENGTH
# We loop continuously loop through all our collided areas until there is either a successful hit, or no more areas to query
while true:
# We declare a RayQuery using the parameters we defined, excluding all the Sprites we've already tested.
var query = PhysicsRayQueryParameters3D.create(from, to, _sprite_layers, not_hits)
query.collide_with_areas = true
var result = space_state.intersect_ray(query)
if result.is_empty():
return false
else:
# We call get_parent twice to get the root of our Sprite3D from the Area3D, then call the function we declared earlier.
if result.collider.get_parent().get_parent().try_mouse_input(_camera, _mouse_event, result.position, result.normal):
return true
else:
not_hits.append(result.collider)
return true
And that’s it! Once again it’s up to the user to connect the mouse_ray_processed signal to the Sprite3Ds, I do it in my main script when instantiating the Sprites, but the best method may vary depending on your project.
Get Dungeon Master
Dungeon Master
A reverse-roguelike about building encounters
Status | In development |
Author | Nowhere Games |
Genre | Card Game, Strategy |
Tags | Deck Building, Management, Roguelike |
Languages | English |
More posts
- Update Beta 1.2.0.2Jul 24, 2024
- Update Beta 1.2.0.1Jul 22, 2024
- Major Update - Version Beta 1.2Jul 18, 2024
- Update Beta 1.1.5.1Apr 14, 2024
- Update Beta 1.1.5Apr 12, 2024
- Update Beta 1.1.4Apr 08, 2024
- Update Beta 1.1.3Apr 04, 2024
- Update Beta 1.1.2Mar 29, 2024
- Update Beta 1.1.1Mar 27, 2024
- Update Beta 1.1Mar 24, 2024
Leave a comment
Log in with itch.io to leave a comment.