"""GPU-accelerated particle emitter nodes (2D and 3D).
Scene-node wrappers for GPU compute-shader particle simulation. Particle
state lives entirely on the GPU -- positions, velocities, colours, and
lifetimes are updated each frame by a compute dispatch, avoiding per-frame
CPU-to-GPU uploads.
Both ``GPUParticles2D`` and ``GPUParticles3D`` expose ``Property`` descriptors
so the editor inspector can tweak parameters at design time. At runtime the
graphics backend reads ``emitter_config`` each frame and feeds it into the
compute shader push constants.
"""
import logging
import numpy as np
from .descriptors import Property
from .nodes_2d.node2d import Node2D
from .nodes_3d.node3d import Node3D
from .properties import Colour
from .signals import Signal
log = logging.getLogger(__name__)
__all__ = ["GPUParticles2D", "GPUParticles3D"]
# ============================================================================
# Shared mixin -- common Property declarations and helpers
# ============================================================================
class _GPUParticlesBase:
"""Mixin providing shared GPU particle properties and helpers.
Not instantiated directly -- mixed into ``GPUParticles2D`` and
``GPUParticles3D`` alongside their respective spatial base classes.
"""
amount = Property(1024, range=(1, 1_000_000), hint="Maximum particle count (GPU buffer size)", group="Emission")
lifetime = Property(2.0, range=(0.01, 60.0), hint="Particle lifetime in seconds", group="Emission")
emitting = Property(True, hint="Whether the emitter is actively spawning particles", group="Emission")
one_shot = Property(False, hint="Stop emitting after one full cycle", group="Emission")
# speed/speed_variance are magnitudes in the emitter's space -- world units/sec
# in 3D, screen pixels/sec in 2D -- so there is no single hard ceiling. The range
# is a soft editor-slider hint (clamp=False): a 2D fountain may legitimately run
# at several hundred px/sec, well past a 3D-sized cap.
speed = Property(5.0, range=(0.0, 1000.0), clamp=False, hint="Initial speed magnitude", group="Movement")
speed_variance = Property(0.0, range=(0.0, 1000.0), clamp=False, hint="Random speed variation", group="Movement")
direction = Property((0.0, 1.0, 0.0), hint="Emission direction (normalised internally)", group="Movement")
spread = Property(0.3, range=(0.0, 10.0), hint="Velocity spread (randomisation)", group="Movement")
# How the launch velocity is sampled within ``spread``:
# box - independent uniform per axis (a square/cube envelope; legacy default)
# disc - uniform circular cone of half-angle ``spread`` (radians, 2D)
# gaussian - normal-distributed cone + slight speed variation (a soft circle)
# ring - omnidirectional, uniform on a circle (ignores ``direction``)
# star - omnidirectional, speed follows an N-point star curve (``spread_points``)
# ring/star are radial bursts (use the launch speed, ignore the aim direction).
spread_pattern = Property(
"box",
enum=["box", "disc", "gaussian", "ring", "star"],
hint="Velocity sampling shape",
group="Movement",
)
spread_points = Property(5, range=(2, 12), hint="Point count for ring/star patterns", group="Movement")
gravity = Property((0.0, -9.8, 0.0), hint="Gravity vector applied each frame", group="Movement")
damping = Property(0.0, range=(0.0, 10.0), hint="Velocity damping per second", group="Movement")
emission_shape = Property("point", enum=["point", "sphere", "box"], hint="Emission shape", group="Emission")
emission_radius = Property(1.0, range=(0.0, 100.0), hint="Sphere emission radius", group="Emission")
emission_box = Property((1.0, 1.0, 1.0), hint="Box emission half-extents", group="Emission")
start_colour = Colour((1.0, 1.0, 1.0, 1.0), group="Appearance")
end_colour = Colour((1.0, 1.0, 1.0, 0.0), group="Appearance")
start_scale = Property(1.0, range=(0.0, 50.0), hint="Particle scale at birth", group="Appearance")
end_scale = Property(0.0, range=(0.0, 50.0), hint="Particle scale at death", group="Appearance")
# Velocity-stretched (streak) billboards. 0.0 (default) keeps the round
# camera-facing sprite every emitter has always drawn; a positive value
# stretches each sprite along its screen-space velocity by that many
# seconds of travel, so a fast-moving grain reads as a motion streak rather
# than a round blob (used by Rain3D to draw rain as streaks, not snow).
streak = Property(
0.0,
range=(0.0, 1.0),
clamp=False,
hint="Velocity stretch: 0 = round sprite, >0 = streak length in seconds of motion",
group="Appearance",
)
# Camera-proximity shrink. 0.0 (default) draws every sprite at its true
# perspective size. A positive value is a distance in metres within which a
# sprite shrinks toward the camera, capping its on-screen size so grains
# passing close to the lens do not balloon into big blobs (used by Rain3D so
# near-camera drops stay slim). Beyond the distance sprites are untouched.
near_fade = Property(
0.0,
range=(0.0, 50.0),
clamp=False,
hint="Camera-proximity shrink distance in metres: 0 = off, >0 caps near-camera sprite size",
group="Appearance",
)
explosiveness = Property(0.0, range=(0.0, 1.0), hint="0 = steady stream, 1 = all at once", group="Emission")
randomness = Property(0.0, range=(0.0, 1.0), hint="Lifetime randomness factor", group="Emission")
fixed_fps = Property(0, range=(0, 120), hint="Lock simulation to N FPS (0 = unlocked)")
preprocess = Property(0.0, range=(0.0, 10.0), hint="Seconds to pre-simulate on ready")
local_coords = Property(False, hint="Simulate in local space (True) or world space (False)")
finished = Signal() # Emitted when one_shot completes
_emitter_dims = 3 # 3D by default; GPUParticles2D overrides to 2
def _gpu_particles_init(self):
"""Initialise internal state (called from ``__init__``)."""
self._gpu_ready = False
self._elapsed = 0.0
self._cycle_complete = False
@property
def emitter_config(self) -> dict:
"""Build the config dict consumed by ``ParticleCompute.dispatch()``.
The graphics backend reads this every frame. Keys match the push
constant layout defined in ``particle_sim.comp``.
"""
# Resolve emitter position from the spatial node
pos = tuple(float(v) for v in self.world_position) # type: ignore[attr-defined]
# Pad 2D positions to 3D. ``dims`` (2 for GPUParticles2D) tells the sampler
# to keep bursts in the XY plane -- a z-component would push grains off-screen.
dims = self._emitter_dims
if len(pos) == 2:
pos = (pos[0], pos[1], 0.0)
_PATTERNS = ("box", "disc", "gaussian", "ring", "star")
spread_pattern = _PATTERNS.index(self.spread_pattern)
# Build initial_velocity from direction + speed
d = tuple(self.direction)
dx = float(d[0])
dy = float(d[1])
dz = float(d[2]) if len(d) > 2 else 0.0
mag = (dx * dx + dy * dy + dz * dz) ** 0.5
if mag > 1e-6:
dx, dy, dz = dx / mag, dy / mag, dz / mag
spd = float(self.speed)
initial_velocity = (dx * spd, dy * spd, dz * spd)
return {
"emitter_pos": pos,
"gravity": tuple(float(v) for v in self.gravity),
"damping": float(self.damping),
"initial_velocity": initial_velocity,
"velocity_spread": float(self.spread),
"start_colour": tuple(float(v) for v in self.start_colour),
"end_colour": tuple(float(v) for v in self.end_colour),
"start_scale": float(self.start_scale),
"end_scale": float(self.end_scale),
# Render-stage only: the compute sim ignores it, the billboard
# vertex shader stretches the quad along velocity when > 0.
"streak": float(self.streak),
# Render-stage only: the billboard vertex shader shrinks the quad
# toward the camera within this distance so near sprites do not balloon.
"near_fade": float(self.near_fade),
"emission_radius": float(self.emission_radius),
"max_particles": int(self.amount),
"spread_pattern": spread_pattern,
"spread_points": int(self.spread_points),
"dims": dims,
"emitting": bool(self.emitting),
# Lifetime drives both the age clock and the initial-phase stagger:
# the shader seeds each grain partway through a randomised life so a
# continuous emitter fills with a full spread of ages at once (a
# stream), while explosiveness=1 spawns them together (a burst).
"lifetime": float(self.lifetime),
"randomness": float(self.randomness),
"explosiveness": float(self.explosiveness),
# Per-grain launch-speed jitter (added to the aim magnitude before the
# spread pattern is sampled) so a fountain shows a spread of arc heights.
"speed_variance": float(self.speed_variance),
}
def restart(self):
"""Reset the emitter cycle (re-arms one_shot, resets elapsed time)."""
self._elapsed = 0.0
self._cycle_complete = False
self.emitting = True
def _gpu_process(self, dt: float):
"""Per-frame bookkeeping (one_shot tracking, fixed_fps accumulation)."""
if not self.emitting:
return
self._elapsed += dt
if self.one_shot and self._elapsed >= float(self.lifetime):
if not self._cycle_complete:
self._cycle_complete = True
self.emitting = False
self.finished.emit()
# ============================================================================
# GPUParticles2D
# ============================================================================
def _merge_properties(cls):
"""Merge __properties__ from all bases in MRO (handles diamond/mixin inheritance)."""
merged = {}
for base in reversed(cls.__mro__):
if "__properties__" in base.__dict__:
merged.update(base.__properties__)
cls.__properties__ = merged
return cls
[docs]
@_merge_properties
class GPUParticles2D(_GPUParticlesBase, Node2D):
"""GPU-accelerated 2D particle emitter.
Particle simulation runs entirely on the GPU via a compute shader.
The node exposes editor-visible ``Property`` descriptors for all
emitter parameters (amount, lifetime, speed, colours, etc.).
The graphics backend collects ``GPUParticles2D`` nodes during scene
traversal, reads ``emitter_config``, and dispatches the compute
shader each frame.
Example::
particles = GPUParticles2D(
amount=2048,
lifetime=1.5,
speed=8.0,
start_colour=(1.0, 0.8, 0.2, 1.0),
end_colour=(1.0, 0.0, 0.0, 0.0),
)
scene.add_child(particles)
"""
_emitter_dims = 2 # keeps spread patterns in the XY plane (see emitter_config)
def __init__(self, **kwargs):
super().__init__(**kwargs)
self._gpu_particles_init()
[docs]
@property
def world_position(self):
"""Return 3D position for the compute shader (z=0 for 2D)."""
pos2d = super().world_position
return np.array([float(pos2d[0]), float(pos2d[1]), 0.0], dtype=np.float32)
[docs]
def on_update(self, dt: float):
self._gpu_process(dt)
# ============================================================================
# GPUParticles3D
# ============================================================================
[docs]
@_merge_properties
class GPUParticles3D(_GPUParticlesBase, Node3D):
"""GPU-accelerated 3D particle emitter.
Particle simulation runs entirely on the GPU via a compute shader.
The node exposes editor-visible ``Property`` descriptors for all
emitter parameters (amount, lifetime, speed, colours, etc.).
The graphics backend collects ``GPUParticles3D`` nodes during scene
traversal, reads ``emitter_config``, and dispatches the compute
shader each frame.
Example::
particles = GPUParticles3D(
amount=4096,
lifetime=2.0,
speed=10.0,
gravity=(0.0, -9.8, 0.0),
start_colour=(0.2, 0.6, 1.0, 1.0),
end_colour=(0.0, 0.2, 0.8, 0.0),
)
scene.add_child(particles)
"""
def __init__(self, **kwargs):
super().__init__(**kwargs)
self._gpu_particles_init()
[docs]
def on_update(self, dt: float):
self._gpu_process(dt)