GPU Particles 3D¶
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 / 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())