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