GPU Particle Fireworks

a sequential burst show of every spread_pattern.

▶ Run in browser

Tags: 2d particles gpu effects fireworks

What it demonstrates

  • GPUParticles2D.spread_pattern: how the launch velocity is sampled, which is what gives each burst its silhouette – star - speed follows an N-point star curve (spread_points) ring - omnidirectional, uniform on a circle gaussian - a soft normal-distributed spray around the aim direction disc - a uniform circular cone

  • one_shot: the emitter runs for exactly one lifetime then stops itself, so a burst spawns, arcs out and fades without ever respawning a dead grain.

  • restart(): re-arms a spent shell and fires it again from its new position.

  • The finished signal: it fires the moment a one_shot cycle ends, which is precisely when that shell is free to be launched again.

A small pool of shells is recycled off finished, so several bursts hang in the air at once like a real fireworks show. With explosiveness = 1.0 every grain of a shell spawns on the same frame -> one clean burst that drifts under gravity and fades. speed/gravity are in screen pixels (per second).

Controls: SPACE - fire an extra burst at a random spot click / tap - fire an extra burst at the pointer ESC - Quit

Source

  1"""GPU Particle Fireworks: a sequential burst show of every spread_pattern.
  2
  3# /// simvx
  4# tags = ["particles", "gpu", "effects", "fireworks"]
  5# web = { root = "FireworksScene", width = 800, height = 600, responsive = true }
  6# ///
  7
  8## What it demonstrates
  9  - GPUParticles2D.spread_pattern: how the launch velocity is sampled, which is
 10    what gives each burst its silhouette --
 11      star     - speed follows an N-point star curve (spread_points)
 12      ring     - omnidirectional, uniform on a circle
 13      gaussian - a soft normal-distributed spray around the aim direction
 14      disc     - a uniform circular cone
 15  - one_shot: the emitter runs for exactly one lifetime then stops itself, so a
 16    burst spawns, arcs out and fades without ever respawning a dead grain.
 17  - restart(): re-arms a spent shell and fires it again from its new position.
 18  - The finished signal: it fires the moment a one_shot cycle ends, which is
 19    precisely when that shell is free to be launched again.
 20
 21A small pool of shells is recycled off ``finished``, so several bursts hang in
 22the air at once like a real fireworks show. With ``explosiveness = 1.0`` every
 23grain of a shell spawns on the same frame -> one clean burst that drifts under
 24gravity and fades. speed/gravity are in screen pixels (per second).
 25
 26Controls:
 27  SPACE       - fire an extra burst at a random spot
 28  click / tap - fire an extra burst at the pointer
 29  ESC         - Quit
 30"""
 31
 32import random
 33from collections import deque
 34from functools import partial
 35
 36from simvx.core import GPUParticles2D, Input, InputMap, Key, MouseButton, Node2D, Text2D, Vec2
 37from simvx.graphics import App
 38
 39WIDTH, HEIGHT = 800, 600
 40
 41# Each shell in the show: (pattern, label, start_colour, end_colour). star/ring
 42# are radial bursts (aim direction ignored); gaussian/disc spray along the aim.
 43SHELLS = [
 44    ("star", "star", (1.0, 0.85, 0.30, 1.0), (1.0, 0.30, 0.10, 0.0)),
 45    ("ring", "ring", (0.45, 0.95, 1.0, 1.0), (0.10, 0.35, 0.85, 0.0)),
 46    ("gaussian", "gaussian", (1.0, 0.45, 0.85, 1.0), (0.55, 0.10, 0.55, 0.0)),
 47    ("star", "star", (0.65, 1.0, 0.45, 1.0), (0.15, 0.55, 0.20, 0.0)),
 48    ("disc", "disc", (1.0, 0.75, 0.30, 1.0), (0.8, 0.25, 0.05, 0.0)),
 49    ("ring", "ring", (0.9, 0.6, 1.0, 1.0), (0.4, 0.2, 0.7, 0.0)),
 50]
 51
 52POOL_SIZE = 5  # bursts that can hang in the air at once
 53BEAT = 0.45  # seconds between launches
 54
 55
 56class FireworksScene(Node2D):
 57    """A pool of one_shot GPU shells, restarted for each crisp burst."""
 58
 59    def on_ready(self):
 60        InputMap.add_action("fire", [Key.SPACE])
 61        InputMap.add_action("fire_at", [MouseButton.LEFT])
 62        InputMap.add_action("quit", [Key.ESCAPE])
 63
 64        self._rng = random.Random(7)
 65        # one_shot + emitting=False: each shell is armed but silent until restart().
 66        self._pool = [
 67            self.add_child(
 68                GPUParticles2D(
 69                    amount=1300,
 70                    lifetime=1.0,
 71                    speed=300.0,
 72                    speed_variance=35.0,
 73                    spread=0.5,
 74                    spread_points=5,
 75                    direction=(0.0, -1.0, 0.0),
 76                    gravity=(0.0, 340.0, 0.0),
 77                    emission_radius=3.0,
 78                    start_scale=3.0,
 79                    end_scale=0.4,
 80                    one_shot=True,
 81                    emitting=False,
 82                    explosiveness=1.0,  # all grains spawn together -> one crisp shell
 83                    position=Vec2(-200, -200),
 84                    name=f"Shell{i}",
 85                )
 86            )
 87            for i in range(POOL_SIZE)
 88        ]
 89        # finished carries no payload, so bind the shell it belongs to.
 90        for shell in self._pool:
 91            shell.finished.connect(partial(self._on_shell_finished, shell))
 92        self._idle = deque(self._pool)  # spent, ready to fire
 93        self._burning: deque[GPUParticles2D] = deque()  # in flight, oldest first
 94        self._next_shell_def = 0
 95        self._timer = 0.0
 96
 97        self.add_child(Text2D(text="GPU Particle Fireworks", position=(10, 10), font_scale=1.5, name="Title"))
 98        self._label = self.add_child(Text2D(text="", position=(10, 40), name="Hud"))
 99        self.add_child(
100            Text2D(
101                text="SPACE / click = extra burst    ESC = quit",
102                position=(10, HEIGHT - 28),
103                colour=(0.7, 0.7, 0.7, 1.0),
104                name="Help",
105            )
106        )
107        self._fire()
108
109    def _on_shell_finished(self, shell: GPUParticles2D):
110        """A one_shot cycle ended: every grain is dead, so the shell can fire again."""
111        self._burning.remove(shell)
112        self._idle.append(shell)
113
114    def _fire(self, at: Vec2 | None = None):
115        """Relaunch a shell as a fresh burst at `at`, or a random sky position."""
116        pattern, label, start_c, end_c = SHELLS[self._next_shell_def % len(SHELLS)]
117        # Prefer a spent shell; if every one is still burning, cut the oldest short.
118        shell = self._idle.popleft() if self._idle else self._burning.popleft()
119        if at is None:
120            at = Vec2(self._rng.uniform(150, WIDTH - 150), self._rng.uniform(140, 340))
121        shell.position = at
122        shell.spread_pattern = pattern
123        shell.spread_points = self._rng.choice((5, 6))
124        shell.start_colour = start_c
125        shell.end_colour = end_c
126        shell.restart()  # re-arms one_shot: every grain respawns at once, then the shell stops itself
127        self._burning.append(shell)
128        self._label.text = f"pattern: {label}"
129        self._next_shell_def += 1
130
131    def on_update(self, dt: float):
132        if Input.is_action_just_pressed("quit"):
133            self.app.quit()
134        if Input.is_action_just_pressed("fire"):
135            self._fire()
136        if Input.is_action_just_pressed("fire_at"):  # touch on web maps to a left click
137            self._fire(at=Vec2(Input.mouse_position))
138
139        self._timer += dt
140        if self._timer >= BEAT:
141            self._timer -= BEAT
142            self._fire()
143
144
145if __name__ == "__main__":
146    App(width=WIDTH, height=HEIGHT, title="GPU Particle Fireworks").run(FireworksScene())