Particles

CPU particle emitters with sub-emitters, collision, trails and seeds.

▶ Run in browser

Tags: 3d

A ParticleEmitter spawns emission_rate particles a second, up to a pool of amount, and each one lives lifetime seconds. Particles start at initial_velocity (scattered by velocity_spread), fall under gravity and fade from start_colour and start_scale to end_colour and end_scale. Every setting is a Property, so an emitter is built in one call:

ParticleEmitter(seed=300, amount=200, emission_rate=80.0, lifetime=3.0, gravity=(0, -12, 0))

Three effects show the rest of the emitter:

  • Firework, sub-emitters. A one_shot launcher fires its pool upward and stops. launcher.sub_emitter_death = sparkle makes every launcher particle that dies burst into sparkles where it died; for a sub-emitter, emission_rate is the number of particles per burst. The sparkle emitter has emitting=False so it spawns only on those bursts.

  • Waterfall, collision. collision_enabled stops particles at the horizontal plane collision_plane_y. collision_mode is "bounce" here, with collision_bounce and collision_friction; "stick" and "destroy" are the other modes. The stream pours from a box, emission_shape="box", where the sparkles are born in a "sphere".

  • Comet, trails. trail_enabled draws a ribbon of trail_length segments and trail_width behind each particle; the emitter circles the scene, so the trails stream out behind it.

Each emitter has a seed. restart() clears the live particles and rewinds the random generator to that seed, so an emitter restarted with the same settings sprays exactly the same particles again.

These particles are simulated on the CPU in on_fixed_update; for compute-shader particles see gpu_particles.py. The camera is an OrbitCamera3D, so dragging orbits it and the wheel or a pinch zooms.

Controls: 1 / Firework - Launch a firework 2 / Waterfall - Waterfall on or off 3 / Comet - Comet on or off R / Restart - Restart every emitter from its seed Drag / A, D - Orbit the camera Scroll, pinch / W, S - Zoom

Source

  1# /// simvx
  2# web = { width = 1024, height = 768 }
  3# ///
  4"""Particles: CPU particle emitters with sub-emitters, collision, trails and seeds.
  5
  6A ``ParticleEmitter`` spawns ``emission_rate`` particles a second, up to a pool
  7of ``amount``, and each one lives ``lifetime`` seconds. Particles start at
  8``initial_velocity`` (scattered by ``velocity_spread``), fall under ``gravity``
  9and fade from ``start_colour`` and ``start_scale`` to ``end_colour`` and
 10``end_scale``. Every setting is a Property, so an emitter is built in one call:
 11
 12    ParticleEmitter(seed=300, amount=200, emission_rate=80.0, lifetime=3.0, gravity=(0, -12, 0))
 13
 14Three effects show the rest of the emitter:
 15
 16- **Firework, sub-emitters.** A ``one_shot`` launcher fires its pool upward
 17  and stops. ``launcher.sub_emitter_death = sparkle`` makes every launcher
 18  particle that dies burst into sparkles where it died; for a sub-emitter,
 19  ``emission_rate`` is the number of particles per burst. The sparkle emitter
 20  has ``emitting=False`` so it spawns only on those bursts.
 21- **Waterfall, collision.** ``collision_enabled`` stops particles at the
 22  horizontal plane ``collision_plane_y``. ``collision_mode`` is ``"bounce"``
 23  here, with ``collision_bounce`` and ``collision_friction``; ``"stick"`` and
 24  ``"destroy"`` are the other modes. The stream pours from a box,
 25  ``emission_shape="box"``, where the sparkles are born in a ``"sphere"``.
 26- **Comet, trails.** ``trail_enabled`` draws a ribbon of ``trail_length``
 27  segments and ``trail_width`` behind each particle; the emitter circles the
 28  scene, so the trails stream out behind it.
 29
 30Each emitter has a ``seed``. ``restart()`` clears the live particles and rewinds
 31the random generator to that seed, so an emitter restarted with the same
 32settings sprays exactly the same particles again.
 33
 34These particles are simulated on the CPU in ``on_fixed_update``; for
 35compute-shader particles see ``gpu_particles.py``. The camera is an
 36``OrbitCamera3D``, so dragging orbits it and the wheel or a pinch zooms.
 37
 38Controls:
 39    1 / Firework         - Launch a firework
 40    2 / Waterfall        - Waterfall on or off
 41    3 / Comet            - Comet on or off
 42    R / Restart          - Restart every emitter from its seed
 43    Drag / A, D          - Orbit the camera
 44    Scroll, pinch / W, S - Zoom
 45"""
 46
 47import math
 48
 49from simvx.core import (
 50    Anchor,
 51    Axis1D,
 52    Button,
 53    CanvasLayer,
 54    DirectionalLight3D,
 55    HBoxContainer,
 56    Input,
 57    Key,
 58    Label,
 59    Material,
 60    Mesh,
 61    MeshInstance3D,
 62    Node3D,
 63    OrbitCamera3D,
 64    ParticleEmitter,
 65    Vec3,
 66    on_input,
 67)
 68from simvx.graphics import App
 69
 70COMET_RADIUS = 5.0
 71
 72
 73class ParticlesDemo(Node3D):
 74    input_context = {
 75        "firework": [Key.KEY_1],
 76        "waterfall": [Key.KEY_2],
 77        "comet": [Key.KEY_3],
 78        "restart": [Key.R],
 79        "orbit": [Axis1D(low=Key.A, high=Key.D)],
 80        "zoom": [Axis1D(low=Key.S, high=Key.W)],
 81    }
 82
 83    def on_ready(self):
 84        self.camera = self.add_child(
 85            OrbitCamera3D(
 86                pivot=(0, 3, 0), distance=18.7, yaw=0.0, pitch=math.radians(-15), min_distance=6, max_distance=40
 87            )
 88        )
 89        self.add_child(DirectionalLight3D(direction=(-0.3, -1, -0.5)))
 90        ground = Material(colour=(0.25, 0.25, 0.3), roughness=0.9)
 91        self.add_child(MeshInstance3D(mesh=Mesh.box((20, 0.1, 20)), material=ground, position=(0, -0.05, 0)))
 92
 93        self.launcher = self.add_child(
 94            ParticleEmitter(
 95                name="Launcher",
 96                seed=100,
 97                amount=30,
 98                emission_rate=200.0,
 99                lifetime=0.8,
100                one_shot=True,
101                emitting=False,
102                initial_velocity=(0, 18, 0),
103                velocity_spread=0.3,
104                start_colour=(1.0, 0.8, 0.2, 1.0),
105                end_colour=(1.0, 0.4, 0.0, 0.8),
106                start_scale=0.3,
107                end_scale=0.1,
108            )
109        )
110        self.launcher.sub_emitter_death = self.add_child(
111            ParticleEmitter(
112                name="Sparkle",
113                seed=200,
114                amount=500,
115                emission_rate=8.0,
116                emitting=False,
117                lifetime=1.5,
118                initial_velocity=(0, 2, 0),
119                velocity_spread=3.0,
120                gravity=(0, -5, 0),
121                start_colour=(1.0, 0.6, 0.1, 1.0),
122                end_colour=(1.0, 0.2, 0.0, 0.0),
123                start_scale=0.15,
124                end_scale=0.0,
125                emission_shape="sphere",
126                emission_radius=0.3,
127            )
128        )
129
130        self.waterfall = self.add_child(
131            ParticleEmitter(
132                name="Waterfall",
133                seed=300,
134                position=(-5, 6, 0),
135                amount=200,
136                emission_rate=80.0,
137                lifetime=3.0,
138                initial_velocity=(2, 0, 0),
139                velocity_spread=0.2,
140                gravity=(0, -12, 0),
141                start_colour=(0.3, 0.6, 1.0, 0.9),
142                end_colour=(0.1, 0.3, 0.8, 0.3),
143                start_scale=0.2,
144                end_scale=0.1,
145                emission_shape="box",
146                emission_box=(0.5, 0.1, 0.5),
147                collision_enabled=True,
148                collision_mode="bounce",
149                collision_bounce=0.3,
150                collision_friction=0.4,
151                collision_plane_y=0.0,
152            )
153        )
154
155        self.comet = self.add_child(
156            ParticleEmitter(
157                name="Comet",
158                seed=400,
159                amount=100,
160                emission_rate=40.0,
161                lifetime=1.5,
162                initial_velocity=(0, 0.5, 0),
163                velocity_spread=0.1,
164                gravity=(0, -1, 0),
165                start_colour=(0.2, 0.8, 1.0, 1.0),
166                end_colour=(0.0, 0.3, 0.8, 0.0),
167                start_scale=0.25,
168                end_scale=0.05,
169                trail_enabled=True,
170                trail_length=6,
171                trail_width=0.08,
172            )
173        )
174        self.comet_time = 0.0
175
176        # Example help overlay: not part of the technique shown.
177        hud = self.add_child(CanvasLayer())
178        hud.add_child(Label("Drag / A, D: orbit   Scroll / W, S: zoom", anchor=Anchor.TOP_LEFT, inset=12))
179        bar = hud.add_child(HBoxContainer(anchor=Anchor.BOTTOM_LEFT, inset=12))
180        for text, action in [("Firework", "firework"), ("Waterfall", "waterfall"), ("Comet", "comet")]:
181            bar.add_child(Button(text, action=action))
182        bar.add_child(Button("Restart", action="restart"))
183
184    def on_update(self, dt):
185        self.camera.orbit(Input.value("orbit") * dt, 0.0)
186        self.camera.zoom(Input.value("zoom") * 8.0 * dt)
187
188        # The comet circles the scene while it is on and holds still while it is off.
189        if self.comet.emitting:
190            self.comet_time += dt
191            t = self.comet_time
192            self.comet.position = Vec3(
193                math.cos(t * 1.2) * COMET_RADIUS, 3.0 + math.sin(t * 2.0), math.sin(t * 1.2) * COMET_RADIUS
194            )
195
196    @on_input("firework")
197    def launch_firework(self, _event):
198        self.launcher.restart()
199        self.launcher.emitting = True
200
201    @on_input("waterfall")
202    def toggle_waterfall(self, _event):
203        self.waterfall.emitting = not self.waterfall.emitting
204
205    @on_input("comet")
206    def toggle_comet(self, _event):
207        self.comet.emitting = not self.comet.emitting
208
209    @on_input("restart")
210    def restart_all(self, _event):
211        for emitter in self.find_all(ParticleEmitter):
212            emitter.restart()
213
214
215if __name__ == "__main__":
216    App(title="Particle Effects", width=1024, height=768).run(ParticlesDemo())