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