Particles

Play Demo

Particle Effects Demo — Showcases sub-emitters, collision, trails, GPU particles, and deterministic seeding.

Demonstrates:

  • Firework: upward burst, sub_emitter_death creates sparkle explosion

  • Waterfall: particles fall and bounce off ground plane

  • Comet: moving emitter with trail rendering

  • Deterministic toggle: press R to restart with same seed, proving identical replay

Run: uv run python packages/graphics/examples/3d_particles.py

Controls: 1 - Firework burst 2 - Toggle waterfall 3 - Toggle comet R - Restart all (deterministic replay) A / D - Orbit camera W / S - Zoom in / out

Source Code

  1"""
  2Particle Effects Demo — Showcases sub-emitters, collision, trails, GPU particles, and deterministic seeding.
  3
  4Demonstrates:
  5  - Firework: upward burst, sub_emitter_death creates sparkle explosion
  6  - Waterfall: particles fall and bounce off ground plane
  7  - Comet: moving emitter with trail rendering
  8  - Deterministic toggle: press R to restart with same seed, proving identical replay
  9
 10Run: uv run python packages/graphics/examples/3d_particles.py
 11
 12Controls:
 13    1       - Firework burst
 14    2       - Toggle waterfall
 15    3       - Toggle comet
 16    R       - Restart all (deterministic replay)
 17    A / D   - Orbit camera
 18    W / S   - Zoom in / out
 19"""
 20
 21
 22import math
 23
 24import numpy as np
 25
 26from simvx.core import (
 27    Camera3D,
 28    DirectionalLight3D,
 29    Input,
 30    InputMap,
 31    Key,
 32    Material,
 33    Mesh,
 34    MeshInstance3D,
 35    Node3D,
 36    ParticleEmitter,
 37    Text2D,
 38    Vec3,
 39)
 40from simvx.graphics import App
 41
 42WIDTH, HEIGHT = 1024, 768
 43GROUND_Y = 0.0
 44
 45
 46# --- Ground plane ---
 47
 48class Ground(MeshInstance3D):
 49    def ready(self):
 50        self.mesh = Mesh.cube()
 51        self.material = Material(colour=(0.25, 0.25, 0.3), roughness=0.9)
 52        self.scale = np.array([20.0, 0.1, 20.0], dtype=np.float32)
 53        self.position = Vec3(0, GROUND_Y - 0.05, 0)
 54
 55
 56# --- Firework ---
 57
 58class Firework(Node3D):
 59    """Press 1 to launch. Particles fly up, then sub_emitter_death creates a sparkle burst."""
 60
 61    def ready(self):
 62        # Launch emitter — shoots particles upward
 63        self.launcher = ParticleEmitter(name="Launcher", seed=100)
 64        self.launcher.amount = 30
 65        self.launcher.emission_rate = 200.0
 66        self.launcher.lifetime = 0.8
 67        self.launcher.one_shot = True
 68        self.launcher.emitting = False
 69        self.launcher.initial_velocity = (0, 18, 0)
 70        self.launcher.velocity_spread = 0.3
 71        self.launcher.gravity = (0, -9.8, 0)
 72        self.launcher.start_colour = (1.0, 0.8, 0.2, 1.0)
 73        self.launcher.end_colour = (1.0, 0.4, 0.0, 0.8)
 74        self.launcher.start_scale = 0.3
 75        self.launcher.end_scale = 0.1
 76        self.add_child(self.launcher)
 77
 78        # Sparkle sub-emitter — triggered on particle death
 79        self.sparkle = ParticleEmitter(name="Sparkle", seed=200)
 80        self.sparkle.amount = 500
 81        self.sparkle.emission_rate = 8.0  # particles per burst
 82        self.sparkle.lifetime = 1.5
 83        self.sparkle.initial_velocity = (0, 2, 0)
 84        self.sparkle.velocity_spread = 3.0
 85        self.sparkle.gravity = (0, -5.0, 0)
 86        self.sparkle.start_colour = (1.0, 0.6, 0.1, 1.0)
 87        self.sparkle.end_colour = (1.0, 0.2, 0.0, 0.0)
 88        self.sparkle.start_scale = 0.15
 89        self.sparkle.end_scale = 0.0
 90        self.sparkle.emission_shape = "sphere"
 91        self.sparkle.emission_radius = 0.3
 92        self.add_child(self.sparkle)
 93
 94        self.launcher.sub_emitter_death = self.sparkle
 95
 96    def process(self, dt: float):
 97        if Input.is_action_just_pressed("firework"):
 98            self.launcher.restart()
 99            self.launcher.emitting = True
100
101
102# --- Waterfall ---
103
104class Waterfall(Node3D):
105    """Continuous stream of particles that bounce off the ground."""
106
107    def ready(self):
108        self.emitter = ParticleEmitter(name="Water", seed=300)
109        self.emitter.amount = 200
110        self.emitter.emission_rate = 80.0
111        self.emitter.lifetime = 3.0
112        self.emitter.initial_velocity = (2, 0, 0)
113        self.emitter.velocity_spread = 0.2
114        self.emitter.gravity = (0, -12.0, 0)
115        self.emitter.start_colour = (0.3, 0.6, 1.0, 0.9)
116        self.emitter.end_colour = (0.1, 0.3, 0.8, 0.3)
117        self.emitter.start_scale = 0.2
118        self.emitter.end_scale = 0.1
119        self.emitter.emission_shape = "box"
120        self.emitter.emission_box = (0.5, 0.1, 0.5)
121
122        # Collision: bounce off ground
123        self.emitter.collision_enabled = True
124        self.emitter.collision_mode = "bounce"
125        self.emitter.collision_bounce = 0.3
126        self.emitter.collision_friction = 0.4
127        self.emitter.collision_plane_y = GROUND_Y
128        # On by default so the demo looks populated at launch; [2] toggles it.
129        self.emitter.emitting = True
130
131        self.add_child(self.emitter)
132        self.position = Vec3(-5, 6, 0)
133
134    def process(self, dt: float):
135        if Input.is_action_just_pressed("waterfall"):
136            self.emitter.emitting = not self.emitter.emitting
137
138
139# --- Comet (trail demo) ---
140
141class Comet(Node3D):
142    """Moving emitter with particle trails."""
143
144    def ready(self):
145        self.emitter = ParticleEmitter(name="CometTrail", seed=400)
146        self.emitter.amount = 100
147        self.emitter.emission_rate = 40.0
148        self.emitter.lifetime = 1.5
149        self.emitter.initial_velocity = (0, 0.5, 0)
150        self.emitter.velocity_spread = 0.1
151        self.emitter.gravity = (0, -1.0, 0)
152        self.emitter.start_colour = (0.2, 0.8, 1.0, 1.0)
153        self.emitter.end_colour = (0.0, 0.3, 0.8, 0.0)
154        self.emitter.start_scale = 0.25
155        self.emitter.end_scale = 0.05
156        self.emitter.trail_enabled = True
157        self.emitter.trail_length = 6
158        self.emitter.trail_width = 0.08
159        # On by default to showcase trails; [3] toggles it.
160        self.emitter.emitting = True
161
162        self.add_child(self.emitter)
163        self._time = 0.0
164        self._active = True
165
166    def process(self, dt: float):
167        if Input.is_action_just_pressed("comet"):
168            self._active = not self._active
169            self.emitter.emitting = self._active
170
171        if self._active:
172            self._time += dt
173            r = 5.0
174            self.position = Vec3(
175                math.cos(self._time * 1.2) * r,
176                3.0 + math.sin(self._time * 2.0),
177                math.sin(self._time * 1.2) * r,
178            )
179
180
181# --- Scene root ---
182# NOTE: GPUParticles3D end-to-end rendering is not yet implemented. The
183# compute dispatch runs but no draw step consumes the computed buffer.
184# See TODO.md / BUGS.md ("GPUParticles3D compute-shader output is never
185# rendered"). This demo intentionally covers CPU ParticleEmitter only.
186
187class DemoRoot(Node3D):
188    def ready(self):
189        InputMap.add_action("firework", [Key.KEY_1])
190        InputMap.add_action("waterfall", [Key.KEY_2])
191        InputMap.add_action("comet", [Key.KEY_3])
192        InputMap.add_action("restart", [Key.R])
193        InputMap.add_action("quit", [Key.ESCAPE])
194
195        # Camera
196        cam = Camera3D(name="Camera")
197        cam.position = Vec3(0, 8, 18)
198        cam.look_at(Vec3(0, 3, 0))
199        self.add_child(cam)
200
201        # Light
202        light = DirectionalLight3D(name="Sun")
203        light.direction = Vec3(-0.3, -1, -0.5)
204        self.add_child(light)
205
206        # Ground
207        self.add_child(Ground(name="Ground"))
208
209        # Effects
210        self.add_child(Firework(name="Firework"))
211        self.add_child(Waterfall(name="Waterfall"))
212        self.add_child(Comet(name="Comet"))
213
214        # HUD
215        hud = Text2D(name="HUD")
216        hud.text = "[1] Firework  [2] Waterfall  [3] Comet  [R] Restart  [A/D] Orbit  [W/S] Zoom  [Esc] Quit"
217        hud.position = Vec3(10, 10, 0)
218        self.add_child(hud)
219
220        self._orbit_angle = 0.0
221        self._cam = cam
222
223    def process(self, dt: float):
224        if Input.is_action_just_pressed("quit"):
225            self.app.quit()
226            return
227        # Orbit
228        speed = 0.0
229        if Input.is_key_pressed(Key.A):
230            speed = -1.0
231        elif Input.is_key_pressed(Key.D):
232            speed = 1.0
233        self._orbit_angle += speed * dt
234
235        zoom = 18.0
236        if Input.is_key_pressed(Key.W):
237            zoom -= 5.0
238        elif Input.is_key_pressed(Key.S):
239            zoom += 5.0
240
241        self._cam.position = Vec3(
242            math.sin(self._orbit_angle) * zoom,
243            8.0,
244            math.cos(self._orbit_angle) * zoom,
245        )
246        self._cam.look_at(Vec3(0, 3, 0))
247
248        # Deterministic restart
249        if Input.is_action_just_pressed("restart"):
250            for emitter in self.find_all(ParticleEmitter):
251                emitter.restart()
252
253
254if __name__ == "__main__":
255    App(width=WIDTH, height=HEIGHT, title="Particle Effects Demo").run(DemoRoot())