GPU Particles 3D Demo¶
compute-shader-driven particle simulation.
▶ Run in browserTags: 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())