105 lines
4.9 KiB
GDScript
105 lines
4.9 KiB
GDScript
# This script is designed to drive gameplay logic based on the number of GPUParticles3D
|
||
# that “hit” various static target areas in 3D space. A cloned particle system (rendered
|
||
# in a dedicated SubViewport on layer 20) uses a special testing shader that, for each particle,
|
||
# checks its 3D world-space position against up to eight static target bounding boxes.
|
||
#
|
||
# The testing shader maps particles that hit a target into a designated horizontal slice of clip
|
||
# space so that the SubViewport (sized 8×1 pixels) accumulates red intensity in each column. Higher
|
||
# red intensity means more particles hit that target.
|
||
#
|
||
# In this version, the targets are static so their parameters (center, extents, and the inverse of
|
||
# their 3×3 rotation basis) are computed once and sent to the shader. The attractors are shared
|
||
# (set up in the main scene and used by the cloned particle system) rather than cloned.
|
||
#
|
||
# Other game scripts can connect to the "target_occupancy_changed" signal, which is emitted each frame
|
||
# for every target (0–7) with the measured red intensity (occupancy).
|
||
|
||
extends Node3D
|
||
|
||
# Signal emitted when the occupancy of a target changes. Sent every frame.
|
||
# occupancy is a float between 0 and 1 corresponding to how many particles are in the target.
|
||
signal target_occupancy_changed(target_index: int, occupancy: float)
|
||
# Optional debug mesh to visualize the viewport texture
|
||
@export var debug_mesh_path: NodePath
|
||
var debug_mesh: MeshInstance3D
|
||
|
||
# Exported node paths.
|
||
@export var source_particles_path: NodePath # The original GPUParticles3D to clone for collision detection.
|
||
@export var target_collision_shapes_paths: Array[NodePath] = [] # Array of CollisionShape3D nodes (each with a BoxShape3D).
|
||
|
||
# Path to the testing shader resource.
|
||
const shader = preload("res://scenes/particle_test.gdshader")
|
||
|
||
# Maximum number of target areas supported.
|
||
const MAX_TARGETS: int = 8
|
||
|
||
# References.
|
||
var clone_particles: GPUParticles3D
|
||
@onready var sub_viewport: SubViewport = $SubViewport
|
||
var target_collision_shapes: Array[CollisionShape3D] = []
|
||
|
||
func _ready():
|
||
# Get references from the scene.
|
||
var source_particles = get_node(source_particles_path) as GPUParticles3D
|
||
for path in target_collision_shapes_paths:
|
||
var shape = get_node(path) as CollisionShape3D
|
||
target_collision_shapes.append(shape)
|
||
# Pad to MAX_TARGETS if necessary.
|
||
while target_collision_shapes.size() < MAX_TARGETS:
|
||
target_collision_shapes.append(null)
|
||
|
||
# Set up debug mesh if path provided
|
||
if not debug_mesh_path.is_empty():
|
||
debug_mesh = get_node(debug_mesh_path)
|
||
if debug_mesh:
|
||
var debug_material = debug_mesh.get_surface_override_material(0)
|
||
debug_material.albedo_texture = sub_viewport.get_texture()
|
||
debug_mesh.set_surface_override_material(0, debug_material)
|
||
|
||
# Load the testing shader and assign it to the cloned particle system’s draw pass.
|
||
var shader_material = ShaderMaterial.new()
|
||
shader_material.shader = shader
|
||
|
||
# Clone the source particles and set the shader material.
|
||
clone_particles = source_particles.duplicate()
|
||
# duplicate the draw pass so we don't affect the original.
|
||
clone_particles.draw_pass_1 = clone_particles.draw_pass_1.duplicate()
|
||
# replace with the special material
|
||
clone_particles.draw_pass_1.surface_set_material(0, shader_material)
|
||
# set visual layer to 20 so it doesn't draw on main camera
|
||
clone_particles.layers = 0
|
||
clone_particles.set_layer_mask_value(20, true)
|
||
# add clone as child of SubViewport
|
||
sub_viewport.add_child(clone_particles)
|
||
|
||
# Precompute static target parameters and set them in the shader.
|
||
var centers: Array = [] # Array of Vector3 centers.
|
||
var extents: Array = [] # Array of Vector3 half-sizes.
|
||
var inv_bases: Array = [] # Array of mat3 (precomputed inverse bases).
|
||
|
||
for i in range(MAX_TARGETS):
|
||
if i < target_collision_shapes.size() and target_collision_shapes[i] and (target_collision_shapes[i].shape is BoxShape3D):
|
||
var box: BoxShape3D = target_collision_shapes[i].shape as BoxShape3D
|
||
var gxf: Transform3D = target_collision_shapes[i].global_transform
|
||
centers.append(gxf.origin)
|
||
extents.append(box.extents)
|
||
inv_bases.append(gxf.basis.inverse()) # Precompute the inverse once.
|
||
else:
|
||
centers.append(Vector3.ZERO)
|
||
extents.append(Vector3.ZERO) # Zero extents means this target is disabled.
|
||
inv_bases.append(Basis.IDENTITY)
|
||
|
||
shader_material.set_shader_parameter("target_center", centers)
|
||
shader_material.set_shader_parameter("target_extents", extents)
|
||
shader_material.set_shader_parameter("target_inv_basis", inv_bases)
|
||
|
||
func _process(_delta):
|
||
# Each frame, read back the 8 pixel wide image from the SubViewport.
|
||
var viewport_tex = sub_viewport.get_texture()
|
||
if viewport_tex:
|
||
var img: Image = viewport_tex.get_image()
|
||
if img:
|
||
for x in range(target_collision_shapes.size()):
|
||
var col: Color = img.get_pixel(x, 0)
|
||
var occupancy: float = col.r # The red channel intensity.
|
||
target_occupancy_changed.emit(x, occupancy)
|