Skip to main content

oxiphysics_gpu/
gpu_particle_system.rs

1// Copyright 2026 COOLJAPAN OU (Team KitaSan)
2// SPDX-License-Identifier: Apache-2.0
3
4//! GPU-accelerated particle system (CPU mock implementation).
5//!
6//! Provides a particle emitter, Euler integrator, and depth-sort for
7//! alpha-blended rendering.  All operations are implemented on the CPU as a
8//! reference / fallback backend.
9
10/// A single particle in the GPU particle system.
11#[derive(Debug, Clone, PartialEq)]
12pub struct GpuParticle {
13    /// World-space position `[x, y, z]`.
14    pub position: [f64; 3],
15    /// Velocity `[vx, vy, vz]` in world units per second.
16    pub velocity: [f64; 3],
17    /// Remaining lifetime in seconds (`<= 0` means dead).
18    pub lifetime: f64,
19    /// RGBA colour, each component in `[0, 1]`.
20    pub color: [f32; 4],
21}
22
23impl GpuParticle {
24    /// Create a new particle at the given position.
25    pub fn new(position: [f64; 3], velocity: [f64; 3], lifetime: f64, color: [f32; 4]) -> Self {
26        Self {
27            position,
28            velocity,
29            lifetime,
30            color,
31        }
32    }
33
34    /// Returns `true` if the particle is still alive.
35    pub fn is_alive(&self) -> bool {
36        self.lifetime > 0.0
37    }
38}
39
40/// Configuration for a point-emitter.
41#[derive(Debug, Clone)]
42pub struct EmitterConfig {
43    /// World-space origin of the emitter.
44    pub origin: [f64; 3],
45    /// Initial speed applied along the emission direction.
46    pub initial_speed: f64,
47    /// Spread half-angle in radians (0 = directional).
48    pub spread_radians: f64,
49    /// Lifetime in seconds for freshly spawned particles.
50    pub particle_lifetime: f64,
51    /// RGBA colour assigned to every new particle.
52    pub color: [f32; 4],
53}
54
55impl Default for EmitterConfig {
56    fn default() -> Self {
57        Self {
58            origin: [0.0; 3],
59            initial_speed: 1.0,
60            spread_radians: 0.3,
61            particle_lifetime: 2.0,
62            color: [1.0, 1.0, 1.0, 1.0],
63        }
64    }
65}
66
67/// GPU particle system managing emission and integration of many particles.
68#[derive(Debug, Clone)]
69pub struct GpuParticleSystem {
70    /// Emitter configuration.
71    pub config: EmitterConfig,
72    /// Currently active particles.
73    pub particles: Vec<GpuParticle>,
74    /// Maximum number of particles allowed simultaneously.
75    pub max_particles: usize,
76}
77
78impl GpuParticleSystem {
79    /// Create a new particle system with the given emitter config and capacity.
80    pub fn new(config: EmitterConfig, max_particles: usize) -> Self {
81        Self {
82            config,
83            particles: Vec::with_capacity(max_particles),
84            max_particles,
85        }
86    }
87
88    /// Return the number of currently alive particles.
89    pub fn active_count(&self) -> usize {
90        self.particles.len()
91    }
92}
93
94// ── Core GPU-mock operations ──────────────────────────────────────────────────
95
96/// Spawn `n` particles from the emitter into the system.
97///
98/// Uses a deterministic direction fan spread around `+Z` so results are
99/// reproducible without a random number generator.  Respects `max_particles`.
100pub fn gpu_emit_particles(system: &mut GpuParticleSystem, n: usize) {
101    let cfg = &system.config;
102    let slots = system.max_particles.saturating_sub(system.particles.len());
103    let to_spawn = n.min(slots);
104
105    for i in 0..to_spawn {
106        // Deterministic spread: fan angle around +Z axis.
107        let angle = if to_spawn > 1 {
108            let t = i as f64 / (to_spawn - 1) as f64;
109            (t - 0.5) * 2.0 * cfg.spread_radians
110        } else {
111            0.0
112        };
113        let vx = angle.sin() * cfg.initial_speed;
114        let vz = angle.cos() * cfg.initial_speed;
115        let velocity = [vx, 0.0, vz];
116        system.particles.push(GpuParticle::new(
117            cfg.origin,
118            velocity,
119            cfg.particle_lifetime,
120            cfg.color,
121        ));
122    }
123}
124
125/// Advance all particles by `dt` seconds using symplectic Euler integration.
126///
127/// Also decrements each particle's `lifetime` by `dt`.
128pub fn gpu_integrate_particles(system: &mut GpuParticleSystem, dt: f64) {
129    for p in &mut system.particles {
130        p.position[0] += p.velocity[0] * dt;
131        p.position[1] += p.velocity[1] * dt;
132        p.position[2] += p.velocity[2] * dt;
133        p.lifetime -= dt;
134    }
135}
136
137/// Remove particles whose `lifetime <= 0`, compacting the particle list.
138///
139/// This mirrors the GPU stream-compaction pattern.
140pub fn gpu_kill_dead_particles(system: &mut GpuParticleSystem) {
141    system.particles.retain(|p| p.is_alive());
142}
143
144/// Sort active particles by depth along `camera_dir` (back-to-front) for
145/// correct alpha blending.
146///
147/// `camera_dir` should be the normalised view direction (world space).  The
148/// sort is stable so that ties preserve the original emission order.
149pub fn gpu_sort_by_depth(system: &mut GpuParticleSystem, camera_dir: [f64; 3]) {
150    system.particles.sort_by(|a, b| {
151        let da = dot3(a.position, camera_dir);
152        let db = dot3(b.position, camera_dir);
153        // Back-to-front: largest depth first.
154        db.partial_cmp(&da).unwrap_or(std::cmp::Ordering::Equal)
155    });
156}
157
158/// Emit all `n` particles at once (burst emission).
159///
160/// Equivalent to calling `gpu_emit_particles` once with `n`.
161pub fn spawn_burst(system: &mut GpuParticleSystem, n: usize) {
162    gpu_emit_particles(system, n);
163}
164
165// ── Internal helpers ──────────────────────────────────────────────────────────
166
167fn dot3(a: [f64; 3], b: [f64; 3]) -> f64 {
168    a[0] * b[0] + a[1] * b[1] + a[2] * b[2]
169}
170
171// ── Tests ─────────────────────────────────────────────────────────────────────
172
173#[cfg(test)]
174mod tests {
175    use super::*;
176
177    fn default_system(max: usize) -> GpuParticleSystem {
178        GpuParticleSystem::new(EmitterConfig::default(), max)
179    }
180
181    // --- GpuParticle ---
182
183    #[test]
184    fn test_particle_is_alive_positive_lifetime() {
185        let p = GpuParticle::new([0.0; 3], [0.0; 3], 1.0, [1.0; 4]);
186        assert!(p.is_alive());
187    }
188
189    #[test]
190    fn test_particle_is_dead_zero_lifetime() {
191        let p = GpuParticle::new([0.0; 3], [0.0; 3], 0.0, [1.0; 4]);
192        assert!(!p.is_alive());
193    }
194
195    #[test]
196    fn test_particle_is_dead_negative_lifetime() {
197        let p = GpuParticle::new([0.0; 3], [0.0; 3], -1.0, [1.0; 4]);
198        assert!(!p.is_alive());
199    }
200
201    #[test]
202    fn test_particle_new_fields() {
203        let pos = [1.0, 2.0, 3.0];
204        let vel = [0.1, 0.2, 0.3];
205        let lt = 5.0;
206        let col = [0.5, 0.5, 0.5, 1.0];
207        let p = GpuParticle::new(pos, vel, lt, col);
208        assert_eq!(p.position, pos);
209        assert_eq!(p.velocity, vel);
210        assert!((p.lifetime - lt).abs() < 1e-12);
211        assert_eq!(p.color, col);
212    }
213
214    // --- EmitterConfig ---
215
216    #[test]
217    fn test_emitter_default_speed_positive() {
218        let cfg = EmitterConfig::default();
219        assert!(cfg.initial_speed > 0.0);
220    }
221
222    #[test]
223    fn test_emitter_default_lifetime_positive() {
224        let cfg = EmitterConfig::default();
225        assert!(cfg.particle_lifetime > 0.0);
226    }
227
228    // --- GpuParticleSystem ---
229
230    #[test]
231    fn test_system_starts_empty() {
232        let sys = default_system(100);
233        assert_eq!(sys.active_count(), 0);
234    }
235
236    #[test]
237    fn test_system_max_particles_stored() {
238        let sys = default_system(42);
239        assert_eq!(sys.max_particles, 42);
240    }
241
242    // --- gpu_emit_particles ---
243
244    #[test]
245    fn test_emit_spawns_n_particles() {
246        let mut sys = default_system(100);
247        gpu_emit_particles(&mut sys, 10);
248        assert_eq!(sys.active_count(), 10);
249    }
250
251    #[test]
252    fn test_emit_respects_max_particles() {
253        let mut sys = default_system(5);
254        gpu_emit_particles(&mut sys, 100);
255        assert_eq!(sys.active_count(), 5);
256    }
257
258    #[test]
259    fn test_emit_zero_particles() {
260        let mut sys = default_system(100);
261        gpu_emit_particles(&mut sys, 0);
262        assert_eq!(sys.active_count(), 0);
263    }
264
265    #[test]
266    fn test_emit_single_particle_at_origin() {
267        let mut sys = default_system(10);
268        gpu_emit_particles(&mut sys, 1);
269        assert_eq!(sys.particles[0].position, [0.0; 3]);
270    }
271
272    #[test]
273    fn test_emit_particles_have_positive_lifetime() {
274        let mut sys = default_system(10);
275        gpu_emit_particles(&mut sys, 5);
276        for p in &sys.particles {
277            assert!(p.lifetime > 0.0);
278        }
279    }
280
281    // --- gpu_integrate_particles ---
282
283    #[test]
284    fn test_integrate_moves_position() {
285        let mut sys = default_system(10);
286        sys.particles
287            .push(GpuParticle::new([0.0; 3], [1.0, 0.0, 0.0], 5.0, [1.0; 4]));
288        gpu_integrate_particles(&mut sys, 1.0);
289        assert!((sys.particles[0].position[0] - 1.0).abs() < 1e-12);
290    }
291
292    #[test]
293    fn test_integrate_decrements_lifetime() {
294        let mut sys = default_system(10);
295        sys.particles
296            .push(GpuParticle::new([0.0; 3], [0.0; 3], 3.0, [1.0; 4]));
297        gpu_integrate_particles(&mut sys, 1.0);
298        assert!((sys.particles[0].lifetime - 2.0).abs() < 1e-12);
299    }
300
301    #[test]
302    fn test_integrate_zero_dt_no_movement() {
303        let mut sys = default_system(10);
304        sys.particles.push(GpuParticle::new(
305            [1.0, 2.0, 3.0],
306            [5.0, 5.0, 5.0],
307            1.0,
308            [1.0; 4],
309        ));
310        gpu_integrate_particles(&mut sys, 0.0);
311        assert_eq!(sys.particles[0].position, [1.0, 2.0, 3.0]);
312    }
313
314    #[test]
315    fn test_integrate_multiple_steps() {
316        let mut sys = default_system(10);
317        sys.particles
318            .push(GpuParticle::new([0.0; 3], [2.0, 0.0, 0.0], 10.0, [1.0; 4]));
319        gpu_integrate_particles(&mut sys, 0.5);
320        gpu_integrate_particles(&mut sys, 0.5);
321        assert!((sys.particles[0].position[0] - 2.0).abs() < 1e-10);
322    }
323
324    // --- gpu_kill_dead_particles ---
325
326    #[test]
327    fn test_kill_removes_dead_particles() {
328        let mut sys = default_system(10);
329        sys.particles
330            .push(GpuParticle::new([0.0; 3], [0.0; 3], -1.0, [1.0; 4]));
331        sys.particles
332            .push(GpuParticle::new([0.0; 3], [0.0; 3], 1.0, [1.0; 4]));
333        gpu_kill_dead_particles(&mut sys);
334        assert_eq!(sys.active_count(), 1);
335        assert!(sys.particles[0].is_alive());
336    }
337
338    #[test]
339    fn test_kill_all_dead() {
340        let mut sys = default_system(10);
341        for _ in 0..5 {
342            sys.particles
343                .push(GpuParticle::new([0.0; 3], [0.0; 3], -1.0, [1.0; 4]));
344        }
345        gpu_kill_dead_particles(&mut sys);
346        assert_eq!(sys.active_count(), 0);
347    }
348
349    #[test]
350    fn test_kill_none_dead() {
351        let mut sys = default_system(10);
352        gpu_emit_particles(&mut sys, 5);
353        gpu_kill_dead_particles(&mut sys);
354        assert_eq!(sys.active_count(), 5);
355    }
356
357    #[test]
358    fn test_integrate_then_kill() {
359        let mut sys = default_system(10);
360        sys.particles
361            .push(GpuParticle::new([0.0; 3], [1.0, 0.0, 0.0], 0.5, [1.0; 4]));
362        gpu_integrate_particles(&mut sys, 1.0); // lifetime becomes -0.5
363        gpu_kill_dead_particles(&mut sys);
364        assert_eq!(sys.active_count(), 0);
365    }
366
367    // --- gpu_sort_by_depth ---
368
369    #[test]
370    fn test_sort_by_depth_back_to_front() {
371        let mut sys = default_system(10);
372        // Two particles along Z axis
373        sys.particles
374            .push(GpuParticle::new([0.0, 0.0, 1.0], [0.0; 3], 1.0, [1.0; 4]));
375        sys.particles
376            .push(GpuParticle::new([0.0, 0.0, 5.0], [0.0; 3], 1.0, [1.0; 4]));
377        let cam = [0.0, 0.0, 1.0]; // camera looks along +Z
378        gpu_sort_by_depth(&mut sys, cam);
379        // Particle at z=5 should come first (further from camera = drawn first)
380        assert!((sys.particles[0].position[2] - 5.0).abs() < 1e-12);
381    }
382
383    #[test]
384    fn test_sort_by_depth_single_particle() {
385        let mut sys = default_system(10);
386        sys.particles
387            .push(GpuParticle::new([1.0, 2.0, 3.0], [0.0; 3], 1.0, [1.0; 4]));
388        gpu_sort_by_depth(&mut sys, [0.0, 0.0, 1.0]);
389        assert_eq!(sys.active_count(), 1);
390    }
391
392    #[test]
393    fn test_sort_by_depth_preserves_count() {
394        let mut sys = default_system(20);
395        gpu_emit_particles(&mut sys, 10);
396        gpu_sort_by_depth(&mut sys, [1.0, 0.0, 0.0]);
397        assert_eq!(sys.active_count(), 10);
398    }
399
400    // --- spawn_burst ---
401
402    #[test]
403    fn test_spawn_burst_emits_all_at_once() {
404        let mut sys = default_system(50);
405        spawn_burst(&mut sys, 20);
406        assert_eq!(sys.active_count(), 20);
407    }
408
409    #[test]
410    fn test_spawn_burst_respects_max() {
411        let mut sys = default_system(5);
412        spawn_burst(&mut sys, 100);
413        assert_eq!(sys.active_count(), 5);
414    }
415
416    // --- Integration scenario ---
417
418    #[test]
419    fn test_full_lifecycle() {
420        let mut sys = default_system(100);
421        spawn_burst(&mut sys, 30);
422        assert_eq!(sys.active_count(), 30);
423        // Integrate past the lifetime
424        let dt = EmitterConfig::default().particle_lifetime + 0.1;
425        gpu_integrate_particles(&mut sys, dt);
426        gpu_kill_dead_particles(&mut sys);
427        assert_eq!(sys.active_count(), 0);
428    }
429
430    #[test]
431    fn test_emission_incremental() {
432        let mut sys = default_system(100);
433        gpu_emit_particles(&mut sys, 10);
434        gpu_emit_particles(&mut sys, 10);
435        assert_eq!(sys.active_count(), 20);
436    }
437
438    #[test]
439    fn test_particle_color_propagated() {
440        let cfg = EmitterConfig {
441            color: [1.0, 0.0, 0.0, 1.0],
442            ..Default::default()
443        };
444        let mut sys = GpuParticleSystem::new(cfg, 10);
445        gpu_emit_particles(&mut sys, 1);
446        assert_eq!(sys.particles[0].color, [1.0, 0.0, 0.0, 1.0]);
447    }
448}