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
167#[allow(dead_code)]
168fn dot3(a: [f64; 3], b: [f64; 3]) -> f64 {
169    a[0] * b[0] + a[1] * b[1] + a[2] * b[2]
170}
171
172// ── Tests ─────────────────────────────────────────────────────────────────────
173
174#[cfg(test)]
175mod tests {
176    use super::*;
177
178    fn default_system(max: usize) -> GpuParticleSystem {
179        GpuParticleSystem::new(EmitterConfig::default(), max)
180    }
181
182    // --- GpuParticle ---
183
184    #[test]
185    fn test_particle_is_alive_positive_lifetime() {
186        let p = GpuParticle::new([0.0; 3], [0.0; 3], 1.0, [1.0; 4]);
187        assert!(p.is_alive());
188    }
189
190    #[test]
191    fn test_particle_is_dead_zero_lifetime() {
192        let p = GpuParticle::new([0.0; 3], [0.0; 3], 0.0, [1.0; 4]);
193        assert!(!p.is_alive());
194    }
195
196    #[test]
197    fn test_particle_is_dead_negative_lifetime() {
198        let p = GpuParticle::new([0.0; 3], [0.0; 3], -1.0, [1.0; 4]);
199        assert!(!p.is_alive());
200    }
201
202    #[test]
203    fn test_particle_new_fields() {
204        let pos = [1.0, 2.0, 3.0];
205        let vel = [0.1, 0.2, 0.3];
206        let lt = 5.0;
207        let col = [0.5, 0.5, 0.5, 1.0];
208        let p = GpuParticle::new(pos, vel, lt, col);
209        assert_eq!(p.position, pos);
210        assert_eq!(p.velocity, vel);
211        assert!((p.lifetime - lt).abs() < 1e-12);
212        assert_eq!(p.color, col);
213    }
214
215    // --- EmitterConfig ---
216
217    #[test]
218    fn test_emitter_default_speed_positive() {
219        let cfg = EmitterConfig::default();
220        assert!(cfg.initial_speed > 0.0);
221    }
222
223    #[test]
224    fn test_emitter_default_lifetime_positive() {
225        let cfg = EmitterConfig::default();
226        assert!(cfg.particle_lifetime > 0.0);
227    }
228
229    // --- GpuParticleSystem ---
230
231    #[test]
232    fn test_system_starts_empty() {
233        let sys = default_system(100);
234        assert_eq!(sys.active_count(), 0);
235    }
236
237    #[test]
238    fn test_system_max_particles_stored() {
239        let sys = default_system(42);
240        assert_eq!(sys.max_particles, 42);
241    }
242
243    // --- gpu_emit_particles ---
244
245    #[test]
246    fn test_emit_spawns_n_particles() {
247        let mut sys = default_system(100);
248        gpu_emit_particles(&mut sys, 10);
249        assert_eq!(sys.active_count(), 10);
250    }
251
252    #[test]
253    fn test_emit_respects_max_particles() {
254        let mut sys = default_system(5);
255        gpu_emit_particles(&mut sys, 100);
256        assert_eq!(sys.active_count(), 5);
257    }
258
259    #[test]
260    fn test_emit_zero_particles() {
261        let mut sys = default_system(100);
262        gpu_emit_particles(&mut sys, 0);
263        assert_eq!(sys.active_count(), 0);
264    }
265
266    #[test]
267    fn test_emit_single_particle_at_origin() {
268        let mut sys = default_system(10);
269        gpu_emit_particles(&mut sys, 1);
270        assert_eq!(sys.particles[0].position, [0.0; 3]);
271    }
272
273    #[test]
274    fn test_emit_particles_have_positive_lifetime() {
275        let mut sys = default_system(10);
276        gpu_emit_particles(&mut sys, 5);
277        for p in &sys.particles {
278            assert!(p.lifetime > 0.0);
279        }
280    }
281
282    // --- gpu_integrate_particles ---
283
284    #[test]
285    fn test_integrate_moves_position() {
286        let mut sys = default_system(10);
287        sys.particles
288            .push(GpuParticle::new([0.0; 3], [1.0, 0.0, 0.0], 5.0, [1.0; 4]));
289        gpu_integrate_particles(&mut sys, 1.0);
290        assert!((sys.particles[0].position[0] - 1.0).abs() < 1e-12);
291    }
292
293    #[test]
294    fn test_integrate_decrements_lifetime() {
295        let mut sys = default_system(10);
296        sys.particles
297            .push(GpuParticle::new([0.0; 3], [0.0; 3], 3.0, [1.0; 4]));
298        gpu_integrate_particles(&mut sys, 1.0);
299        assert!((sys.particles[0].lifetime - 2.0).abs() < 1e-12);
300    }
301
302    #[test]
303    fn test_integrate_zero_dt_no_movement() {
304        let mut sys = default_system(10);
305        sys.particles.push(GpuParticle::new(
306            [1.0, 2.0, 3.0],
307            [5.0, 5.0, 5.0],
308            1.0,
309            [1.0; 4],
310        ));
311        gpu_integrate_particles(&mut sys, 0.0);
312        assert_eq!(sys.particles[0].position, [1.0, 2.0, 3.0]);
313    }
314
315    #[test]
316    fn test_integrate_multiple_steps() {
317        let mut sys = default_system(10);
318        sys.particles
319            .push(GpuParticle::new([0.0; 3], [2.0, 0.0, 0.0], 10.0, [1.0; 4]));
320        gpu_integrate_particles(&mut sys, 0.5);
321        gpu_integrate_particles(&mut sys, 0.5);
322        assert!((sys.particles[0].position[0] - 2.0).abs() < 1e-10);
323    }
324
325    // --- gpu_kill_dead_particles ---
326
327    #[test]
328    fn test_kill_removes_dead_particles() {
329        let mut sys = default_system(10);
330        sys.particles
331            .push(GpuParticle::new([0.0; 3], [0.0; 3], -1.0, [1.0; 4]));
332        sys.particles
333            .push(GpuParticle::new([0.0; 3], [0.0; 3], 1.0, [1.0; 4]));
334        gpu_kill_dead_particles(&mut sys);
335        assert_eq!(sys.active_count(), 1);
336        assert!(sys.particles[0].is_alive());
337    }
338
339    #[test]
340    fn test_kill_all_dead() {
341        let mut sys = default_system(10);
342        for _ in 0..5 {
343            sys.particles
344                .push(GpuParticle::new([0.0; 3], [0.0; 3], -1.0, [1.0; 4]));
345        }
346        gpu_kill_dead_particles(&mut sys);
347        assert_eq!(sys.active_count(), 0);
348    }
349
350    #[test]
351    fn test_kill_none_dead() {
352        let mut sys = default_system(10);
353        gpu_emit_particles(&mut sys, 5);
354        gpu_kill_dead_particles(&mut sys);
355        assert_eq!(sys.active_count(), 5);
356    }
357
358    #[test]
359    fn test_integrate_then_kill() {
360        let mut sys = default_system(10);
361        sys.particles
362            .push(GpuParticle::new([0.0; 3], [1.0, 0.0, 0.0], 0.5, [1.0; 4]));
363        gpu_integrate_particles(&mut sys, 1.0); // lifetime becomes -0.5
364        gpu_kill_dead_particles(&mut sys);
365        assert_eq!(sys.active_count(), 0);
366    }
367
368    // --- gpu_sort_by_depth ---
369
370    #[test]
371    fn test_sort_by_depth_back_to_front() {
372        let mut sys = default_system(10);
373        // Two particles along Z axis
374        sys.particles
375            .push(GpuParticle::new([0.0, 0.0, 1.0], [0.0; 3], 1.0, [1.0; 4]));
376        sys.particles
377            .push(GpuParticle::new([0.0, 0.0, 5.0], [0.0; 3], 1.0, [1.0; 4]));
378        let cam = [0.0, 0.0, 1.0]; // camera looks along +Z
379        gpu_sort_by_depth(&mut sys, cam);
380        // Particle at z=5 should come first (further from camera = drawn first)
381        assert!((sys.particles[0].position[2] - 5.0).abs() < 1e-12);
382    }
383
384    #[test]
385    fn test_sort_by_depth_single_particle() {
386        let mut sys = default_system(10);
387        sys.particles
388            .push(GpuParticle::new([1.0, 2.0, 3.0], [0.0; 3], 1.0, [1.0; 4]));
389        gpu_sort_by_depth(&mut sys, [0.0, 0.0, 1.0]);
390        assert_eq!(sys.active_count(), 1);
391    }
392
393    #[test]
394    fn test_sort_by_depth_preserves_count() {
395        let mut sys = default_system(20);
396        gpu_emit_particles(&mut sys, 10);
397        gpu_sort_by_depth(&mut sys, [1.0, 0.0, 0.0]);
398        assert_eq!(sys.active_count(), 10);
399    }
400
401    // --- spawn_burst ---
402
403    #[test]
404    fn test_spawn_burst_emits_all_at_once() {
405        let mut sys = default_system(50);
406        spawn_burst(&mut sys, 20);
407        assert_eq!(sys.active_count(), 20);
408    }
409
410    #[test]
411    fn test_spawn_burst_respects_max() {
412        let mut sys = default_system(5);
413        spawn_burst(&mut sys, 100);
414        assert_eq!(sys.active_count(), 5);
415    }
416
417    // --- Integration scenario ---
418
419    #[test]
420    fn test_full_lifecycle() {
421        let mut sys = default_system(100);
422        spawn_burst(&mut sys, 30);
423        assert_eq!(sys.active_count(), 30);
424        // Integrate past the lifetime
425        let dt = EmitterConfig::default().particle_lifetime + 0.1;
426        gpu_integrate_particles(&mut sys, dt);
427        gpu_kill_dead_particles(&mut sys);
428        assert_eq!(sys.active_count(), 0);
429    }
430
431    #[test]
432    fn test_emission_incremental() {
433        let mut sys = default_system(100);
434        gpu_emit_particles(&mut sys, 10);
435        gpu_emit_particles(&mut sys, 10);
436        assert_eq!(sys.active_count(), 20);
437    }
438
439    #[test]
440    fn test_particle_color_propagated() {
441        let cfg = EmitterConfig {
442            color: [1.0, 0.0, 0.0, 1.0],
443            ..Default::default()
444        };
445        let mut sys = GpuParticleSystem::new(cfg, 10);
446        gpu_emit_particles(&mut sys, 1);
447        assert_eq!(sys.particles[0].color, [1.0, 0.0, 0.0, 1.0]);
448    }
449}