Particles

CPU ParticleEmitter: sub-emitters, collision, trails, and deterministic seeding.

▶ Run in browser

Tags: 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())