GPU Particles 3D Demo

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 / vortex R - Restart all emitters A / D - Orbit camera W / S - Zoom in / out ESC - Quit

Source

  1"""GPU Particles 3D Demo: 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 / vortex
 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    InputMap,
 34    Key,
 35    Material,
 36    Mesh,
 37    MeshInstance3D,
 38    MouseButton,
 39    Node3D,
 40    Text2D,
 41    Vec3,
 42    screen_to_ray,
 43)
 44from simvx.graphics import App
 45
 46WIDTH, HEIGHT = 1024, 768
 47GROUND_Y = 0.0
 48
 49
 50class Ground(MeshInstance3D):
 51    def on_ready(self):
 52        self.mesh = Mesh.cube()
 53        self.material = Material(colour=(0.22, 0.22, 0.28), roughness=0.9)
 54        self.scale = np.array([20.0, 0.1, 20.0], dtype=np.float32)
 55        self.position = Vec3(0, GROUND_Y - 0.05, 0)
 56
 57
 58class Fountain(GPUParticles3D):
 59    def on_ready(self):
 60        self.position = Vec3(-3.0, 0.2, 0.0)
 61        self.amount = 2048
 62        self.lifetime = 2.0
 63        self.emitting = True
 64        self.direction = (0.0, 1.0, 0.0)
 65        self.speed = 6.0
 66        self.spread = 0.4
 67        self.gravity = (0.0, -9.8, 0.0)
 68        self.damping = 0.0
 69        self.start_colour = (0.4, 0.7, 1.0, 1.0)
 70        self.end_colour = (0.1, 0.2, 0.6, 0.2)
 71        self.start_scale = 0.5
 72        self.end_scale = 0.1
 73        self.emission_shape = "point"
 74
 75
 76class Vortex(GPUParticles3D):
 77    def on_ready(self):
 78        self.position = Vec3(3.0, 2.0, 0.0)
 79        self.amount = 4096
 80        self.lifetime = 3.0
 81        self.emitting = True
 82        self.direction = (0.0, 0.5, 0.0)
 83        self.speed = 1.0
 84        self.spread = 1.5
 85        self.gravity = (0.0, -0.4, 0.0)
 86        self.damping = 0.1
 87        self.start_colour = (1.0, 0.45, 0.15, 1.0)
 88        self.end_colour = (0.6, 0.0, 0.5, 0.2)
 89        self.start_scale = 0.6
 90        self.end_scale = 0.15
 91        self.emission_shape = "sphere"
 92        self.emission_radius = 0.8
 93
 94
 95class Hud(Text2D):
 96    def on_ready(self):
 97        self.text = "Click emitter: toggle   Drag: orbit/zoom   1/2: toggle   R: restart   ESC: quit"
 98        self.font_scale = 1.5
 99        self.colour = (1.0, 1.0, 1.0, 1.0)
100
101    def on_update(self, dt: float):
102        # Pin to the bottom-left of the live window so the hint survives resizes.
103        self.position = (20, self.app.height - 40)
104
105
106class DemoRoot(Node3D):
107    input_actions = {
108        "fountain": [Key.KEY_1],
109        "vortex": [Key.KEY_2],
110        "restart": [Key.R],
111        "orbit_left": [Key.A],
112        "orbit_right": [Key.D],
113        "zoom_in": [Key.W],
114        "zoom_out": [Key.S],
115        "quit": [Key.ESCAPE],
116    }
117
118    def on_ready(self):
119        self.camera = Camera3D(name="Camera")
120        self.camera.position = Vec3(0, 6, 14)
121        self.camera.look_at(Vec3(0, 2, 0))
122        self.add_child(self.camera)
123
124        sun = DirectionalLight3D(name="Sun")
125        sun.direction = Vec3(-0.3, -1.0, -0.5)
126        self.add_child(sun)
127
128        self.add_child(Ground(name="Ground"))
129
130        self.fountain = self.add_child(Fountain(name="Fountain"))
131        self.vortex = self.add_child(Vortex(name="Vortex"))
132
133        self.add_child(Hud(name="HUD"))
134
135        self._orbit = 0.0
136        self._radius = 14.0
137        self._drag_dist = 0.0
138
139    def on_update(self, dt: float):
140        if Input.is_action_just_pressed("fountain"):
141            self.fountain.emitting = not self.fountain.emitting
142        if Input.is_action_just_pressed("vortex"):
143            self.vortex.emitting = not self.vortex.emitting
144        if Input.is_action_just_pressed("restart"):
145            self.fountain.restart()
146            self.vortex.restart()
147        if Input.is_action_just_pressed("quit"):
148            self.app.quit()
149
150        if Input.is_action_pressed("orbit_left"):
151            self._orbit -= dt * 1.2
152        if Input.is_action_pressed("orbit_right"):
153            self._orbit += dt * 1.2
154        if Input.is_action_pressed("zoom_in"):
155            self._radius = max(4.0, self._radius - dt * 6.0)
156        if Input.is_action_pressed("zoom_out"):
157            self._radius = min(30.0, self._radius + dt * 6.0)
158
159        # Mouse / touch: horizontal drag orbits, vertical drag zooms, wheel
160        # zooms, and a click (or tap) with little movement toggles the
161        # emitter nearest the cursor.
162        if Input.is_mouse_button_just_pressed(MouseButton.LEFT):
163            self._drag_dist = 0.0
164        if Input.is_mouse_button_pressed(MouseButton.LEFT):
165            delta = Input.mouse_delta
166            dx, dy = float(delta.x), float(delta.y)
167            self._drag_dist += abs(dx) + abs(dy)
168            self._orbit -= dx * 0.01
169            self._radius = min(30.0, max(4.0, self._radius + dy * 0.05))
170        if Input.is_mouse_button_just_released(MouseButton.LEFT) and self._drag_dist < 6.0:
171            self._toggle_nearest_emitter()
172        scroll = Input.scroll_delta
173        if scroll[1] != 0.0:
174            self._radius = min(30.0, max(4.0, self._radius - scroll[1] * 1.5))
175
176        self.camera.position = Vec3(
177            math.sin(self._orbit) * self._radius,
178            6.0,
179            math.cos(self._orbit) * self._radius,
180        )
181        self.camera.look_at(Vec3(0, 2, 0))
182
183    def _toggle_nearest_emitter(self):
184        """Toggle whichever emitter lies closest to the ray under the cursor."""
185        w, h = self.app.width, self.app.height
186        origin, direction = screen_to_ray(
187            Input.mouse_position, (w, h), self.camera.view_matrix, self.camera.projection_matrix(w / h)
188        )
189        o = np.asarray(origin, dtype=np.float32)
190        d = np.asarray(direction, dtype=np.float32)
191
192        def ray_distance(emitter) -> float:
193            rel = np.asarray(emitter.position, dtype=np.float32) - o
194            return float(np.linalg.norm(rel - np.dot(rel, d) * d))
195
196        target = min((self.fountain, self.vortex), key=ray_distance)
197        target.emitting = not target.emitting
198
199
200if __name__ == "__main__":
201    App(width=WIDTH, height=HEIGHT, title="GPU Particles 3D").run(DemoRoot())