Particles¶
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())