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