GPU Particles 3D

compute-shader-driven particle simulation.

▶ Run in browser

Tags: 3d

Showcases GPUParticles3D: position, velocity, colour and lifetime live entirely on the GPU; the compute shader (particle_sim.comp) runs once per frame and the same billboard pipeline that draws CPU particles renders the result. No per-frame CPU-to-GPU upload of particle state.

For CPU emitters (sub-emitters, collision, trails) see particles.py.

Run: uv run python examples/features/3d/gpu_particles.py

Controls: Click / tap - Toggle the nearest emitter Drag - Orbit camera (horizontal) / zoom (vertical) Scroll wheel - Zoom in / out 1 / 2 - Toggle fountain / ember emitter R - Restart all emitters A / D - Orbit camera W / S - Zoom in / out ESC - Quit

Source

  1"""GPU Particles 3D: compute-shader-driven particle simulation.
  2
  3Showcases ``GPUParticles3D``: position, velocity, colour and lifetime live
  4entirely on the GPU; the compute shader (``particle_sim.comp``) runs once
  5per frame and the same billboard pipeline that draws CPU particles renders
  6the result. No per-frame CPU-to-GPU upload of particle state.
  7
  8For CPU emitters (sub-emitters, collision, trails) see ``particles.py``.
  9
 10Run:
 11    uv run python examples/features/3d/gpu_particles.py
 12
 13Controls:
 14    Click / tap  - Toggle the nearest emitter
 15    Drag         - Orbit camera (horizontal) / zoom (vertical)
 16    Scroll wheel - Zoom in / out
 17    1 / 2        - Toggle fountain / ember emitter
 18    R            - Restart all emitters
 19    A / D        - Orbit camera
 20    W / S        - Zoom in / out
 21    ESC          - Quit
 22"""
 23
 24import math
 25
 26import numpy as np
 27
 28from simvx.core import (
 29    Camera3D,
 30    DirectionalLight3D,
 31    GPUParticles3D,
 32    Input,
 33    Key,
 34    Material,
 35    Mesh,
 36    MeshInstance3D,
 37    MouseButton,
 38    Node3D,
 39    Text2D,
 40    Vec3,
 41    screen_to_ray,
 42)
 43from simvx.graphics import App
 44
 45WIDTH, HEIGHT = 1024, 768
 46GROUND_Y = 0.0
 47
 48
 49class Ground(MeshInstance3D):
 50    def on_ready(self):
 51        self.mesh = Mesh.cube()
 52        self.material = Material(colour=(0.22, 0.22, 0.28), roughness=0.9)
 53        self.scale = np.array([20.0, 0.1, 20.0], dtype=np.float32)
 54        self.position = Vec3(0, GROUND_Y - 0.05, 0)
 55
 56
 57class Fountain(GPUParticles3D):
 58    """Narrow jet: point emission, high speed, full gravity."""
 59
 60    def on_ready(self):
 61        self.position = Vec3(-3.0, 0.2, 0.0)
 62        self.amount = 2048
 63        self.lifetime = 2.0
 64        self.emitting = True
 65        self.direction = (0.0, 1.0, 0.0)
 66        self.speed = 6.0
 67        self.spread = 0.4
 68        self.gravity = (0.0, -9.8, 0.0)
 69        self.damping = 0.0
 70        self.start_colour = (0.4, 0.7, 1.0, 1.0)
 71        self.end_colour = (0.1, 0.2, 0.6, 0.2)
 72        self.start_scale = 0.5
 73        self.end_scale = 0.1
 74        self.emission_shape = "point"
 75
 76
 77class Ember(GPUParticles3D):
 78    """Drifting cloud: sphere emission, wide spread, weak gravity and damping.
 79
 80    The contrast with ``Fountain`` is the point of the pair: same simulation,
 81    opposite ends of the spread / speed / gravity / damping ranges.
 82    """
 83
 84    def on_ready(self):
 85        self.position = Vec3(3.0, 2.0, 0.0)
 86        self.amount = 4096
 87        self.lifetime = 3.0
 88        self.emitting = True
 89        self.direction = (0.0, 0.5, 0.0)
 90        self.speed = 1.0
 91        self.spread = 1.5
 92        self.gravity = (0.0, -0.4, 0.0)
 93        self.damping = 0.1
 94        self.start_colour = (1.0, 0.45, 0.15, 1.0)
 95        self.end_colour = (0.6, 0.0, 0.5, 0.2)
 96        self.start_scale = 0.6
 97        self.end_scale = 0.15
 98        self.emission_shape = "sphere"
 99        self.emission_radius = 0.8
100
101
102class Hud(Text2D):
103    def on_ready(self):
104        self.text = "Click emitter: toggle   Drag: orbit/zoom   1/2: toggle   R: restart   ESC: quit"
105        self.font_scale = 1.5
106        self.colour = (1.0, 1.0, 1.0, 1.0)
107
108    def on_update(self, dt: float):
109        # Pin to the bottom-left of the live window so the hint survives resizes.
110        self.position = (20, self.app.height - 40)
111
112
113class DemoRoot(Node3D):
114    input_actions = {
115        "fountain": [Key.KEY_1],
116        "ember": [Key.KEY_2],
117        "restart": [Key.R],
118        "orbit_left": [Key.A],
119        "orbit_right": [Key.D],
120        "zoom_in": [Key.W],
121        "zoom_out": [Key.S],
122        "quit": [Key.ESCAPE],
123    }
124
125    def on_ready(self):
126        self.camera = Camera3D(name="Camera")
127        self.camera.position = Vec3(0, 6, 14)
128        self.camera.look_at(Vec3(0, 2, 0))
129        self.add_child(self.camera)
130
131        sun = DirectionalLight3D(name="Sun")
132        sun.direction = Vec3(-0.3, -1.0, -0.5)
133        self.add_child(sun)
134
135        self.add_child(Ground(name="Ground"))
136
137        self.fountain = self.add_child(Fountain(name="Fountain"))
138        self.ember = self.add_child(Ember(name="Ember"))
139
140        self.add_child(Hud(name="HUD"))
141
142        self._orbit = 0.0
143        self._radius = 14.0
144        self._drag_dist = 0.0
145
146    def on_update(self, dt: float):
147        if Input.is_action_just_pressed("fountain"):
148            self.fountain.emitting = not self.fountain.emitting
149        if Input.is_action_just_pressed("ember"):
150            self.ember.emitting = not self.ember.emitting
151        if Input.is_action_just_pressed("restart"):
152            self.fountain.restart()
153            self.ember.restart()
154        if Input.is_action_just_pressed("quit"):
155            self.app.quit()
156
157        if Input.is_action_pressed("orbit_left"):
158            self._orbit -= dt * 1.2
159        if Input.is_action_pressed("orbit_right"):
160            self._orbit += dt * 1.2
161        if Input.is_action_pressed("zoom_in"):
162            self._radius = max(4.0, self._radius - dt * 6.0)
163        if Input.is_action_pressed("zoom_out"):
164            self._radius = min(30.0, self._radius + dt * 6.0)
165
166        # Mouse / touch: horizontal drag orbits, vertical drag zooms, wheel
167        # zooms, and a click (or tap) with little movement toggles the
168        # emitter nearest the cursor.
169        if Input.is_mouse_button_just_pressed(MouseButton.LEFT):
170            self._drag_dist = 0.0
171        if Input.is_mouse_button_pressed(MouseButton.LEFT):
172            delta = Input.mouse_delta
173            dx, dy = float(delta.x), float(delta.y)
174            self._drag_dist += abs(dx) + abs(dy)
175            self._orbit -= dx * 0.01
176            self._radius = min(30.0, max(4.0, self._radius + dy * 0.05))
177        if Input.is_mouse_button_just_released(MouseButton.LEFT) and self._drag_dist < 6.0:
178            self._toggle_nearest_emitter()
179        scroll = Input.scroll_delta
180        if scroll[1] != 0.0:
181            self._radius = min(30.0, max(4.0, self._radius - scroll[1] * 1.5))
182
183        self.camera.position = Vec3(
184            math.sin(self._orbit) * self._radius,
185            6.0,
186            math.cos(self._orbit) * self._radius,
187        )
188        self.camera.look_at(Vec3(0, 2, 0))
189
190    def _toggle_nearest_emitter(self):
191        """Toggle whichever emitter lies closest to the ray under the cursor."""
192        w, h = self.app.width, self.app.height
193        origin, direction = screen_to_ray(
194            Input.mouse_position, (w, h), self.camera.view_matrix, self.camera.projection_matrix(w / h)
195        )
196        o = np.asarray(origin, dtype=np.float32)
197        d = np.asarray(direction, dtype=np.float32)
198
199        def ray_distance(emitter) -> float:
200            rel = np.asarray(emitter.position, dtype=np.float32) - o
201            return float(np.linalg.norm(rel - np.dot(rel, d) * d))
202
203        target = min((self.fountain, self.ember), key=ray_distance)
204        target.emitting = not target.emitting
205
206
207if __name__ == "__main__":
208    App(width=WIDTH, height=HEIGHT, title="GPU Particles 3D").run(DemoRoot())