GPU Particles 3D

two emitters simulated entirely on the GPU.

▶ Run in browser

Tags: 3d

A GPUParticles3D node is a particle emitter whose particles never touch the CPU: position, velocity, colour and age live in GPU buffers, a compute shader advances them once per frame, and the billboard pipeline that draws CPU particles renders the result. The node itself only holds the emitter’s settings, as Properties, so it is built like any other node:

self.add_child(GPUParticles3D(amount=2048, lifetime=2.0, speed=6.0, gravity=(0, -9.8, 0)))

amount is the particle budget, lifetime how long each particle lives, and direction, speed and spread aim the launch. gravity and damping act on the particles in flight, emission_shape ("point", "sphere" or "box") says where they are born, and each particle fades from start_colour and start_scale to end_colour and end_scale. Setting emitting to False stops new particles while the live ones finish; restart() starts the cycle again.

The two emitters here are the same simulation at opposite ends of those ranges: a blue fountain launched fast from a point under full gravity, and an ember cloud born in a sphere, drifting slowly under weak gravity and damping.

Tap or click an emitter to switch it on or off; the tap goes to the emitter nearest the ray from camera.ray_at() through that point. The camera is an OrbitCamera3D, so dragging orbits it and the wheel or a pinch zooms. For CPU particles, with sub-emitters, collision and trails, see particles.py.

Controls: Click / tap - Switch the nearest emitter on or off 1 / 2 - Switch the fountain / the embers on or off R - Restart both emitters Drag / A, D - Orbit the camera Scroll, pinch / W, S - Zoom

Source

  1"""GPU Particles 3D: two emitters simulated entirely on the GPU.
  2
  3A ``GPUParticles3D`` node is a particle emitter whose particles never touch the
  4CPU: position, velocity, colour and age live in GPU buffers, a compute shader
  5advances them once per frame, and the billboard pipeline that draws CPU
  6particles renders the result. The node itself only holds the emitter's
  7settings, as Properties, so it is built like any other node:
  8
  9    self.add_child(GPUParticles3D(amount=2048, lifetime=2.0, speed=6.0, gravity=(0, -9.8, 0)))
 10
 11``amount`` is the particle budget, ``lifetime`` how long each particle lives,
 12and ``direction``, ``speed`` and ``spread`` aim the launch. ``gravity`` and
 13``damping`` act on the particles in flight, ``emission_shape`` (``"point"``,
 14``"sphere"`` or ``"box"``) says where they are born, and each particle fades
 15from ``start_colour`` and ``start_scale`` to ``end_colour`` and ``end_scale``.
 16Setting ``emitting`` to False stops new particles while the live ones finish;
 17``restart()`` starts the cycle again.
 18
 19The two emitters here are the same simulation at opposite ends of those
 20ranges: a blue fountain launched fast from a point under full gravity, and an
 21ember cloud born in a sphere, drifting slowly under weak gravity and damping.
 22
 23Tap or click an emitter to switch it on or off; the tap goes to the emitter
 24nearest the ray from ``camera.ray_at()`` through that point. The camera is an
 25``OrbitCamera3D``, so dragging orbits it and the wheel or a pinch zooms. For
 26CPU particles, with sub-emitters, collision and trails, see ``particles.py``.
 27
 28Controls:
 29    Click / tap          - Switch the nearest emitter on or off
 30    1 / 2                - Switch the fountain / the embers on or off
 31    R                    - Restart both emitters
 32    Drag / A, D          - Orbit the camera
 33    Scroll, pinch / W, S - Zoom
 34"""
 35
 36import math
 37
 38from simvx.core import (
 39    Anchor,
 40    Axis1D,
 41    Button,
 42    CanvasLayer,
 43    DirectionalLight3D,
 44    GPUParticles3D,
 45    HBoxContainer,
 46    Input,
 47    Key,
 48    Label,
 49    Material,
 50    Mesh,
 51    MeshInstance3D,
 52    Node3D,
 53    OrbitCamera3D,
 54    Pointer,
 55    Vec3,
 56    on_input,
 57)
 58from simvx.graphics import App
 59
 60
 61class GPUParticlesDemo(Node3D):
 62    input_context = {
 63        "fountain": [Key.KEY_1],
 64        "embers": [Key.KEY_2],
 65        "restart": [Key.R],
 66        "orbit": [Axis1D(low=Key.A, high=Key.D)],
 67        "zoom": [Axis1D(low=Key.S, high=Key.W)],
 68    }
 69
 70    def on_ready(self):
 71        self.camera = self.add_child(
 72            OrbitCamera3D(
 73                pivot=Vec3(0, 2, 0), distance=14.5, yaw=0.0, pitch=math.radians(-16), min_distance=4, max_distance=30
 74            )
 75        )
 76        self.add_child(DirectionalLight3D(direction=(-0.3, -1.0, -0.5)))
 77        self.add_child(
 78            MeshInstance3D(
 79                mesh=Mesh.box((20, 0.1, 20)),
 80                material=Material(colour=(0.22, 0.22, 0.28), roughness=0.9),
 81                position=(0, -0.05, 0),
 82            )
 83        )
 84
 85        self.fountain = self.add_child(
 86            GPUParticles3D(
 87                name="Fountain",
 88                position=(-3.0, 0.2, 0.0),
 89                amount=2048,
 90                lifetime=2.0,
 91                direction=(0.0, 1.0, 0.0),
 92                speed=6.0,
 93                spread=0.4,
 94                gravity=(0.0, -9.8, 0.0),
 95                start_colour=(0.4, 0.7, 1.0, 1.0),
 96                end_colour=(0.1, 0.2, 0.6, 0.2),
 97                start_scale=0.5,
 98                end_scale=0.1,
 99                emission_shape="point",
100            )
101        )
102        self.embers = self.add_child(
103            GPUParticles3D(
104                name="Embers",
105                position=(3.0, 2.0, 0.0),
106                amount=4096,
107                lifetime=3.0,
108                direction=(0.0, 0.5, 0.0),
109                speed=1.0,
110                spread=1.5,
111                gravity=(0.0, -0.4, 0.0),
112                damping=0.1,
113                start_colour=(1.0, 0.45, 0.15, 1.0),
114                end_colour=(0.6, 0.0, 0.5, 0.2),
115                start_scale=0.6,
116                end_scale=0.15,
117                emission_shape="sphere",
118                emission_radius=0.8,
119            )
120        )
121
122        # Example help overlay: not part of the technique shown.
123        hud = self.add_child(CanvasLayer())
124        hud.add_child(
125            Label(
126                "Tap an emitter: on / off   Drag / A, D: orbit   Scroll / W, S: zoom", anchor=Anchor.TOP_LEFT, inset=12
127            )
128        )
129        bar = hud.add_child(HBoxContainer(anchor=Anchor.BOTTOM_LEFT, inset=12))
130        for text, action in [("Fountain", "fountain"), ("Embers", "embers"), ("Restart", "restart")]:
131            bar.add_child(Button(text, action=action))
132
133    def on_update(self, dt):
134        self.camera.orbit(Input.value("orbit") * 1.2 * dt, 0.0)
135        self.camera.zoom(Input.value("zoom") * 6.0 * dt)
136
137    @on_input(Pointer.PRIMARY, edge="release")
138    def _on_tap(self, event):
139        # A drag orbits the camera; only a press that never became a drag toggles.
140        if event.dragging:
141            return
142        ray = self.camera.ray_at(event.position)
143
144        def distance_to_ray(emitter):
145            point = emitter.world_position
146            return ray.at((point - ray.origin).dot(ray.direction)).distance_to(point)
147
148        emitter = min((self.fountain, self.embers), key=distance_to_ray)
149        emitter.emitting = not emitter.emitting
150
151    @on_input("fountain")
152    def _on_fountain(self, _event):
153        self.fountain.emitting = not self.fountain.emitting
154
155    @on_input("embers")
156    def _on_embers(self, _event):
157        self.embers.emitting = not self.embers.emitting
158
159    @on_input("restart")
160    def _on_restart(self, _event):
161        self.fountain.restart()
162        self.embers.restart()
163
164
165if __name__ == "__main__":
166    App(title="GPU Particles 3D", width=1024, height=768).run(GPUParticlesDemo())