Skip to main content

oxiphysics_gpu/particle_system/
functions.rs

1//! Auto-generated module
2//!
3//! 🤖 Generated with [SplitRS](https://github.com/cool-japan/splitrs)
4
5use super::types::{ParticleBuffer, ParticleRenderData, SimpleRng, SortedParticleRenderData};
6
7/// Extract rendering data from a particle buffer.
8///
9/// Returns only alive particles, with color interpolated based on age.
10pub fn extract_render_data(
11    buffer: &ParticleBuffer,
12    color_young: [f32; 4],
13    color_old: [f32; 4],
14    base_size: f32,
15) -> Vec<ParticleRenderData> {
16    let mut data = Vec::new();
17    for i in 0..buffer.count {
18        if !buffer.is_alive(i) {
19            continue;
20        }
21        let age = buffer.ages[i];
22        let initial_lifetime = age + buffer.lifetimes[i];
23        let age_norm = if initial_lifetime > 0.0 {
24            (age / initial_lifetime).clamp(0.0, 1.0)
25        } else {
26            1.0
27        };
28        let color = [
29            color_young[0] + (color_old[0] - color_young[0]) * age_norm,
30            color_young[1] + (color_old[1] - color_young[1]) * age_norm,
31            color_young[2] + (color_old[2] - color_young[2]) * age_norm,
32            color_young[3] + (color_old[3] - color_young[3]) * age_norm,
33        ];
34        let size = base_size * (1.0 - 0.5 * age_norm);
35        data.push(ParticleRenderData {
36            position: buffer.get_position(i),
37            color,
38            size,
39            age_normalized: age_norm,
40        });
41    }
42    data
43}
44/// Compute total momentum of all alive particles: sum(m * v).
45pub fn compute_total_momentum(buffer: &ParticleBuffer) -> [f32; 3] {
46    let mut px = 0.0f32;
47    let mut py = 0.0f32;
48    let mut pz = 0.0f32;
49    for i in 0..buffer.count {
50        if !buffer.is_alive(i) {
51            continue;
52        }
53        let m = buffer.masses[i];
54        px += m * buffer.velocities_x[i];
55        py += m * buffer.velocities_y[i];
56        pz += m * buffer.velocities_z[i];
57    }
58    [px, py, pz]
59}
60/// Morton code (Z-order curve) interleave for a 3D position.
61///
62/// Quantizes each coordinate to 10 bits, then interleaves.
63pub fn morton_encode(x: u32, y: u32, z: u32) -> u32 {
64    let xm = morton_part1by2(x);
65    let ym = morton_part1by2(y);
66    let zm = morton_part1by2(z);
67    xm | (ym << 1) | (zm << 2)
68}
69pub(super) fn morton_part1by2(mut x: u32) -> u32 {
70    x &= 0x000003ff;
71    x = (x ^ (x << 16)) & 0xff0000ff;
72    x = (x ^ (x << 8)) & 0x0300f00f;
73    x = (x ^ (x << 4)) & 0x030c30c3;
74    x = (x ^ (x << 2)) & 0x09249249;
75    x
76}
77/// Compute Morton codes for all alive particles in a buffer.
78///
79/// Particles are sorted into a `[0, grid_cells)^3` grid using their positions.
80pub fn compute_morton_codes(
81    buffer: &ParticleBuffer,
82    origin: [f32; 3],
83    extent: [f32; 3],
84    grid_cells: u32,
85) -> Vec<(u32, usize)> {
86    let mut codes: Vec<(u32, usize)> = Vec::new();
87    let gc = grid_cells as f32;
88    for i in 0..buffer.count {
89        if !buffer.is_alive(i) {
90            continue;
91        }
92        let px = ((buffer.positions_x[i] - origin[0]) / extent[0] * gc).clamp(0.0, gc - 1.0) as u32;
93        let py = ((buffer.positions_y[i] - origin[1]) / extent[1] * gc).clamp(0.0, gc - 1.0) as u32;
94        let pz = ((buffer.positions_z[i] - origin[2]) / extent[2] * gc).clamp(0.0, gc - 1.0) as u32;
95        codes.push((morton_encode(px, py, pz), i));
96    }
97    codes.sort_unstable_by_key(|&(code, _)| code);
98    codes
99}
100/// Reorder a particle buffer according to Morton-sorted indices.
101///
102/// Returns a new buffer with particles ordered by z-curve for better
103/// cache locality.
104pub fn sort_particles_morton(
105    buffer: &ParticleBuffer,
106    origin: [f32; 3],
107    extent: [f32; 3],
108    grid_cells: u32,
109) -> ParticleBuffer {
110    let sorted = compute_morton_codes(buffer, origin, extent, grid_cells);
111    let mut new_buf = ParticleBuffer::new(buffer.count);
112    for (slot, &(_, old_idx)) in sorted.iter().enumerate() {
113        new_buf.positions_x[slot] = buffer.positions_x[old_idx];
114        new_buf.positions_y[slot] = buffer.positions_y[old_idx];
115        new_buf.positions_z[slot] = buffer.positions_z[old_idx];
116        new_buf.velocities_x[slot] = buffer.velocities_x[old_idx];
117        new_buf.velocities_y[slot] = buffer.velocities_y[old_idx];
118        new_buf.velocities_z[slot] = buffer.velocities_z[old_idx];
119        new_buf.masses[slot] = buffer.masses[old_idx];
120        new_buf.lifetimes[slot] = buffer.lifetimes[old_idx];
121        new_buf.ages[slot] = buffer.ages[old_idx];
122    }
123    new_buf
124}
125/// Prepare GPU particle rendering data sorted back-to-front for transparency.
126///
127/// `camera_forward` is the camera's forward direction (normalized).
128pub fn prepare_sorted_render_data(
129    buffer: &ParticleBuffer,
130    color_young: [f32; 4],
131    color_old: [f32; 4],
132    base_size: f32,
133    camera_pos: [f32; 3],
134    camera_forward: [f32; 3],
135) -> Vec<SortedParticleRenderData> {
136    let mut result = Vec::new();
137    for i in 0..buffer.count {
138        if !buffer.is_alive(i) {
139            continue;
140        }
141        let age = buffer.ages[i];
142        let initial_lifetime = age + buffer.lifetimes[i];
143        let age_norm = if initial_lifetime > 0.0 {
144            (age / initial_lifetime).clamp(0.0, 1.0)
145        } else {
146            1.0
147        };
148        let color = [
149            color_young[0] + (color_old[0] - color_young[0]) * age_norm,
150            color_young[1] + (color_old[1] - color_young[1]) * age_norm,
151            color_young[2] + (color_old[2] - color_young[2]) * age_norm,
152            color_young[3] + (color_old[3] - color_young[3]) * age_norm,
153        ];
154        let size = base_size * (1.0 - 0.5 * age_norm);
155        let dx = buffer.positions_x[i] - camera_pos[0];
156        let dy = buffer.positions_y[i] - camera_pos[1];
157        let dz = buffer.positions_z[i] - camera_pos[2];
158        let depth = dx * camera_forward[0] + dy * camera_forward[1] + dz * camera_forward[2];
159        result.push(SortedParticleRenderData {
160            render_data: ParticleRenderData {
161                position: [
162                    buffer.positions_x[i],
163                    buffer.positions_y[i],
164                    buffer.positions_z[i],
165                ],
166                color,
167                size,
168                age_normalized: age_norm,
169            },
170            sort_key: depth,
171            buffer_index: i,
172        });
173    }
174    result.sort_unstable_by(|a, b| {
175        b.sort_key
176            .partial_cmp(&a.sort_key)
177            .unwrap_or(std::cmp::Ordering::Equal)
178    });
179    result
180}
181/// Compute center of mass of all alive particles.
182pub fn compute_center_of_mass(buffer: &ParticleBuffer) -> [f32; 3] {
183    let mut total_mass = 0.0f32;
184    let mut cx = 0.0f32;
185    let mut cy = 0.0f32;
186    let mut cz = 0.0f32;
187    for i in 0..buffer.count {
188        if !buffer.is_alive(i) {
189            continue;
190        }
191        let m = buffer.masses[i];
192        cx += m * buffer.positions_x[i];
193        cy += m * buffer.positions_y[i];
194        cz += m * buffer.positions_z[i];
195        total_mass += m;
196    }
197    if total_mass > 0.0 {
198        [cx / total_mass, cy / total_mass, cz / total_mass]
199    } else {
200        [0.0, 0.0, 0.0]
201    }
202}
203/// Compute a velocity-speed histogram for all alive particles.
204///
205/// Speed bins are `[0, dv)`, `[dv, 2*dv)`, … up to `max_speed`.
206/// Returns a `Vec`usize` of length `ceil(max_speed / dv)`.
207pub fn compute_velocity_histogram(buffer: &ParticleBuffer, max_speed: f32, dv: f32) -> Vec<usize> {
208    let dv = dv.max(1e-12);
209    let n_bins = (max_speed / dv).ceil() as usize;
210    let mut hist = vec![0usize; n_bins.max(1)];
211    for i in 0..buffer.count {
212        if !buffer.is_alive(i) {
213            continue;
214        }
215        let vx = buffer.velocities_x[i];
216        let vy = buffer.velocities_y[i];
217        let vz = buffer.velocities_z[i];
218        let speed = (vx * vx + vy * vy + vz * vz).sqrt();
219        if speed < max_speed {
220            let bin = (speed / dv) as usize;
221            if bin < n_bins {
222                hist[bin] += 1;
223            }
224        }
225    }
226    hist
227}
228/// Compute the total angular momentum of all alive particles about the origin.
229///
230/// `L = sum_i m_i * (r_i × v_i)`
231///
232/// Returns `\[Lx, Ly, Lz\]`.
233pub fn compute_angular_momentum(buffer: &ParticleBuffer) -> [f32; 3] {
234    let mut lx = 0.0f32;
235    let mut ly = 0.0f32;
236    let mut lz = 0.0f32;
237    for i in 0..buffer.count {
238        if !buffer.is_alive(i) {
239            continue;
240        }
241        let m = buffer.masses[i];
242        let rx = buffer.positions_x[i];
243        let ry = buffer.positions_y[i];
244        let rz = buffer.positions_z[i];
245        let vx = buffer.velocities_x[i];
246        let vy = buffer.velocities_y[i];
247        let vz = buffer.velocities_z[i];
248        lx += m * (ry * vz - rz * vy);
249        ly += m * (rz * vx - rx * vz);
250        lz += m * (rx * vy - ry * vx);
251    }
252    [lx, ly, lz]
253}
254/// Emit a burst of `count` particles from `origin` with the given velocity
255/// and lifetime, using an internal LCG for per-particle spread.
256///
257/// Spread magnitude `spread` adds a random offset to velocity.
258/// Returns the number of particles actually emitted (may be less than `count`
259/// if the buffer is full).
260pub fn emit_burst(
261    buffer: &mut ParticleBuffer,
262    origin: [f32; 3],
263    velocity: [f32; 3],
264    spread: f32,
265    lifetime: f32,
266    mass: f32,
267    count: usize,
268    seed: u64,
269) -> usize {
270    let mut rng = SimpleRng::new(seed);
271    let mut spawned = 0usize;
272    for _ in 0..count {
273        let dir = rng.next_unit_sphere();
274        let vel = [
275            velocity[0] + dir[0] * spread,
276            velocity[1] + dir[1] * spread,
277            velocity[2] + dir[2] * spread,
278        ];
279        if buffer.add_particle(origin, vel, mass, lifetime).is_some() {
280            spawned += 1;
281        }
282    }
283    spawned
284}
285#[cfg(test)]
286mod tests {
287    use super::*;
288    use crate::BoundingBoxKill;
289    use crate::DragForce;
290    use crate::EmitterShape;
291    use crate::FloorCollision;
292    use crate::GpuParticleEmitter;
293    use crate::GpuParticleLayout;
294    use crate::GravityForce;
295    use crate::GridParticleCollision;
296    use crate::ParticleEmitter;
297    use crate::ParticleIntegrator;
298    use crate::ParticleLifetimeManager;
299    use crate::ParticleRepulsion;
300    use crate::ParticleStats;
301    use crate::ParticleSystem;
302    use crate::ParticleSystemStats;
303    use crate::RadialForceField;
304    use crate::VortexForceField;
305    #[test]
306    fn test_particle_buffer_add_and_get_position() {
307        let mut buf = ParticleBuffer::new(4);
308        let idx = buf.add_particle([1.0, 2.0, 3.0], [0.0, 0.0, 0.0], 1.0, 5.0);
309        assert!(idx.is_some());
310        let i = idx.unwrap();
311        let pos = buf.get_position(i);
312        assert!((pos[0] - 1.0).abs() < 1e-6);
313        assert!((pos[1] - 2.0).abs() < 1e-6);
314        assert!((pos[2] - 3.0).abs() < 1e-6);
315    }
316    #[test]
317    fn test_particle_buffer_is_alive_after_kill() {
318        let mut buf = ParticleBuffer::new(4);
319        let i = buf.add_particle([0.0; 3], [0.0; 3], 1.0, 5.0).unwrap();
320        assert!(buf.is_alive(i));
321        buf.kill(i);
322        assert!(!buf.is_alive(i));
323    }
324    #[test]
325    fn test_gravity_force_increases_downward_velocity() {
326        let mut buf = ParticleBuffer::new(1);
327        buf.add_particle([0.0; 3], [0.0, 0.0, 0.0], 1.0, 10.0)
328            .unwrap();
329        let grav = GravityForce {
330            g: [0.0, -9.81, 0.0],
331        };
332        grav.apply(&mut buf, 1.0);
333        let vel = buf.get_velocity(0);
334        assert!(vel[1] < 0.0, "vy should be negative after gravity");
335        assert!((vel[1] + 9.81).abs() < 1e-4);
336    }
337    #[test]
338    fn test_integrator_moves_particles() {
339        let mut buf = ParticleBuffer::new(1);
340        buf.add_particle([0.0, 0.0, 0.0], [1.0, 0.0, 0.0], 1.0, 10.0)
341            .unwrap();
342        ParticleIntegrator::integrate(&mut buf, 1.0);
343        let pos = buf.get_position(0);
344        assert!(
345            (pos[0] - 1.0).abs() < 1e-6,
346            "particle should move 1 unit in x"
347        );
348    }
349    #[test]
350    fn test_floor_collision_reflects_particle() {
351        let mut buf = ParticleBuffer::new(1);
352        buf.add_particle([0.0, -0.5, 0.0], [0.0, -3.0, 0.0], 1.0, 10.0)
353            .unwrap();
354        let floor = FloorCollision {
355            y: 0.0,
356            restitution: 0.8,
357        };
358        floor.apply(&mut buf);
359        let pos = buf.get_position(0);
360        let vel = buf.get_velocity(0);
361        assert!(pos[1] >= 0.0, "particle should be at or above floor");
362        assert!(
363            vel[1] > 0.0,
364            "velocity y should be positive after reflection"
365        );
366        assert!(
367            (vel[1] - 2.4).abs() < 1e-5,
368            "reflected vy = 3.0 * 0.8 = 2.4"
369        );
370    }
371    #[test]
372    fn test_bounding_box_kill_removes_out_of_bounds() {
373        let mut buf = ParticleBuffer::new(3);
374        buf.add_particle([0.0, 0.0, 0.0], [0.0; 3], 1.0, 10.0)
375            .unwrap();
376        buf.add_particle([100.0, 0.0, 0.0], [0.0; 3], 1.0, 10.0)
377            .unwrap();
378        buf.add_particle([0.0, -50.0, 0.0], [0.0; 3], 1.0, 10.0)
379            .unwrap();
380        let kill = BoundingBoxKill {
381            min: [-10.0; 3],
382            max: [10.0; 3],
383        };
384        kill.apply(&mut buf);
385        assert!(buf.is_alive(0), "particle inside box should survive");
386        assert!(!buf.is_alive(1), "particle outside x should be killed");
387        assert!(!buf.is_alive(2), "particle outside y should be killed");
388    }
389    #[test]
390    fn test_particle_system_step_no_panic() {
391        let mut sys = ParticleSystem::new(64);
392        let emitter = ParticleEmitter::new([0.0; 3], 10.0, [0.0, 1.0, 0.0], 3.0);
393        sys.add_emitter(emitter);
394        sys.floor = Some(FloorCollision {
395            y: -5.0,
396            restitution: 0.5,
397        });
398        for _ in 0..30 {
399            sys.step(1.0 / 60.0);
400        }
401    }
402    #[test]
403    fn test_gpu_particle_layout_round_trip() {
404        let mut buf = ParticleBuffer::new(4);
405        buf.add_particle([1.0, 2.0, 3.0], [4.0, 5.0, 6.0], 0.5, 7.0)
406            .unwrap();
407        buf.add_particle([-1.0, 0.5, 2.0], [0.1, 0.2, 0.3], 2.0, 3.5)
408            .unwrap();
409        let flat = GpuParticleLayout::to_f32_buffer(&buf);
410        assert_eq!(flat.len(), 4 * GpuParticleLayout::stride());
411        let restored = GpuParticleLayout::from_f32_buffer(&flat, 4);
412        let p0 = restored.get_position(0);
413        assert!((p0[0] - 1.0).abs() < 1e-6);
414        assert!((p0[1] - 2.0).abs() < 1e-6);
415        assert!((p0[2] - 3.0).abs() < 1e-6);
416        let v1 = restored.get_velocity(1);
417        assert!((v1[0] - 0.1).abs() < 1e-6);
418        assert!((v1[1] - 0.2).abs() < 1e-6);
419        assert!((v1[2] - 0.3).abs() < 1e-6);
420        assert!(!restored.is_alive(2));
421        assert!(!restored.is_alive(3));
422    }
423    #[test]
424    fn test_radial_force_attraction() {
425        let mut buf = ParticleBuffer::new(1);
426        buf.add_particle([5.0, 0.0, 0.0], [0.0, 0.0, 0.0], 1.0, 10.0)
427            .unwrap();
428        let field = RadialForceField {
429            center: [0.0, 0.0, 0.0],
430            strength: 10.0,
431            falloff: 1.0,
432            min_distance: 0.01,
433        };
434        field.apply(&mut buf, 1.0);
435        let vel = buf.get_velocity(0);
436        assert!(
437            vel[0] < 0.0,
438            "should attract toward center, got vx={}",
439            vel[0]
440        );
441    }
442    #[test]
443    fn test_radial_force_repulsion() {
444        let mut buf = ParticleBuffer::new(1);
445        buf.add_particle([5.0, 0.0, 0.0], [0.0, 0.0, 0.0], 1.0, 10.0)
446            .unwrap();
447        let field = RadialForceField {
448            center: [0.0, 0.0, 0.0],
449            strength: -10.0,
450            falloff: 1.0,
451            min_distance: 0.01,
452        };
453        field.apply(&mut buf, 1.0);
454        let vel = buf.get_velocity(0);
455        assert!(vel[0] > 0.0, "should repel from center, got vx={}", vel[0]);
456    }
457    #[test]
458    fn test_radial_force_dead_particle_ignored() {
459        let mut buf = ParticleBuffer::new(2);
460        buf.add_particle([5.0, 0.0, 0.0], [0.0, 0.0, 0.0], 1.0, 10.0)
461            .unwrap();
462        buf.add_particle([3.0, 0.0, 0.0], [0.0, 0.0, 0.0], 1.0, 10.0)
463            .unwrap();
464        buf.kill(1);
465        let field = RadialForceField {
466            center: [0.0, 0.0, 0.0],
467            strength: 10.0,
468            falloff: 1.0,
469            min_distance: 0.01,
470        };
471        field.apply(&mut buf, 1.0);
472        let vel1 = buf.get_velocity(1);
473        assert!((vel1[0]).abs() < 1e-6);
474    }
475    #[test]
476    fn test_vortex_force_field() {
477        let mut buf = ParticleBuffer::new(1);
478        buf.add_particle([1.0, 0.0, 0.0], [0.0, 0.0, 0.0], 1.0, 10.0)
479            .unwrap();
480        let vortex = VortexForceField {
481            center: [0.0, 0.0],
482            angular_velocity: 10.0,
483            radius: 5.0,
484        };
485        vortex.apply(&mut buf, 1.0);
486        let vel = buf.get_velocity(0);
487        assert!(
488            vel[2].abs() > 0.0 || vel[0].abs() > 0.0,
489            "vortex should add tangential velocity"
490        );
491    }
492    #[test]
493    fn test_vortex_outside_radius() {
494        let mut buf = ParticleBuffer::new(1);
495        buf.add_particle([100.0, 0.0, 0.0], [0.0, 0.0, 0.0], 1.0, 10.0)
496            .unwrap();
497        let vortex = VortexForceField {
498            center: [0.0, 0.0],
499            angular_velocity: 10.0,
500            radius: 5.0,
501        };
502        vortex.apply(&mut buf, 1.0);
503        let vel = buf.get_velocity(0);
504        assert!((vel[0]).abs() < 1e-6);
505        assert!((vel[2]).abs() < 1e-6);
506    }
507    #[test]
508    fn test_particle_repulsion_two_particles() {
509        let mut buf = ParticleBuffer::new(2);
510        buf.add_particle([0.0, 0.0, 0.0], [0.0, 0.0, 0.0], 1.0, 10.0)
511            .unwrap();
512        buf.add_particle([0.5, 0.0, 0.0], [0.0, 0.0, 0.0], 1.0, 10.0)
513            .unwrap();
514        let repulsion = ParticleRepulsion {
515            strength: 10.0,
516            radius: 1.0,
517        };
518        repulsion.apply(&mut buf, 1.0);
519        let v0 = buf.get_velocity(0);
520        let v1 = buf.get_velocity(1);
521        assert!(v0[0] < 0.0, "particle 0 should move left");
522        assert!(v1[0] > 0.0, "particle 1 should move right");
523        let total = v0[0] + v1[0];
524        assert!(total.abs() < 1e-5, "momentum not conserved: {total}");
525    }
526    #[test]
527    fn test_particle_repulsion_outside_radius() {
528        let mut buf = ParticleBuffer::new(2);
529        buf.add_particle([0.0, 0.0, 0.0], [0.0, 0.0, 0.0], 1.0, 10.0)
530            .unwrap();
531        buf.add_particle([10.0, 0.0, 0.0], [0.0, 0.0, 0.0], 1.0, 10.0)
532            .unwrap();
533        let repulsion = ParticleRepulsion {
534            strength: 10.0,
535            radius: 1.0,
536        };
537        repulsion.apply(&mut buf, 1.0);
538        let v0 = buf.get_velocity(0);
539        let v1 = buf.get_velocity(1);
540        assert!((v0[0]).abs() < 1e-6);
541        assert!((v1[0]).abs() < 1e-6);
542    }
543    #[test]
544    fn test_extract_render_data_empty_buffer() {
545        let buf = ParticleBuffer::new(4);
546        let data = extract_render_data(&buf, [1.0; 4], [0.0; 4], 1.0);
547        assert!(data.is_empty());
548    }
549    #[test]
550    fn test_extract_render_data_alive_only() {
551        let mut buf = ParticleBuffer::new(4);
552        buf.add_particle([1.0, 2.0, 3.0], [0.0; 3], 1.0, 5.0)
553            .unwrap();
554        buf.add_particle([4.0, 5.0, 6.0], [0.0; 3], 1.0, 5.0)
555            .unwrap();
556        let data = extract_render_data(&buf, [1.0, 0.0, 0.0, 1.0], [0.0, 0.0, 1.0, 0.0], 2.0);
557        assert_eq!(data.len(), 2);
558        assert!((data[0].position[0] - 1.0).abs() < 1e-6);
559        assert!(data[0].size > 0.0);
560        assert!(data[0].age_normalized >= 0.0 && data[0].age_normalized <= 1.0);
561    }
562    #[test]
563    fn test_extract_render_data_color_interpolation() {
564        let mut buf = ParticleBuffer::new(1);
565        buf.add_particle([0.0; 3], [0.0; 3], 1.0, 2.0).unwrap();
566        buf.ages[0] = 1.0;
567        buf.lifetimes[0] = 1.0;
568        let data = extract_render_data(&buf, [1.0, 0.0, 0.0, 1.0], [0.0, 0.0, 1.0, 0.0], 1.0);
569        assert_eq!(data.len(), 1);
570        assert!((data[0].color[0] - 0.5).abs() < 1e-4);
571        assert!((data[0].color[2] - 0.5).abs() < 1e-4);
572    }
573    #[test]
574    fn test_compute_total_momentum() {
575        let mut buf = ParticleBuffer::new(2);
576        buf.add_particle([0.0; 3], [1.0, 0.0, 0.0], 2.0, 10.0)
577            .unwrap();
578        buf.add_particle([0.0; 3], [-1.0, 0.0, 0.0], 3.0, 10.0)
579            .unwrap();
580        let p = compute_total_momentum(&buf);
581        assert!((p[0] - (-1.0)).abs() < 1e-5);
582    }
583    #[test]
584    fn test_compute_total_momentum_dead_ignored() {
585        let mut buf = ParticleBuffer::new(2);
586        buf.add_particle([0.0; 3], [1.0, 0.0, 0.0], 2.0, 10.0)
587            .unwrap();
588        buf.add_particle([0.0; 3], [10.0, 0.0, 0.0], 3.0, 10.0)
589            .unwrap();
590        buf.kill(1);
591        let p = compute_total_momentum(&buf);
592        assert!((p[0] - 2.0).abs() < 1e-5);
593    }
594    #[test]
595    fn test_compute_center_of_mass() {
596        let mut buf = ParticleBuffer::new(2);
597        buf.add_particle([0.0, 0.0, 0.0], [0.0; 3], 1.0, 10.0)
598            .unwrap();
599        buf.add_particle([10.0, 0.0, 0.0], [0.0; 3], 1.0, 10.0)
600            .unwrap();
601        let com = compute_center_of_mass(&buf);
602        assert!((com[0] - 5.0).abs() < 1e-5);
603    }
604    #[test]
605    fn test_compute_center_of_mass_weighted() {
606        let mut buf = ParticleBuffer::new(2);
607        buf.add_particle([0.0, 0.0, 0.0], [0.0; 3], 1.0, 10.0)
608            .unwrap();
609        buf.add_particle([10.0, 0.0, 0.0], [0.0; 3], 3.0, 10.0)
610            .unwrap();
611        let com = compute_center_of_mass(&buf);
612        assert!((com[0] - 7.5).abs() < 1e-5);
613    }
614    #[test]
615    fn test_compute_center_of_mass_empty() {
616        let buf = ParticleBuffer::new(4);
617        let com = compute_center_of_mass(&buf);
618        assert!((com[0]).abs() < 1e-6);
619    }
620    #[test]
621    fn test_particle_stats_basic() {
622        let mut buf = ParticleBuffer::new(3);
623        buf.add_particle([1.0, 2.0, 3.0], [1.0, 0.0, 0.0], 1.0, 5.0)
624            .unwrap();
625        buf.add_particle([4.0, 5.0, 6.0], [0.0, 2.0, 0.0], 2.0, 5.0)
626            .unwrap();
627        let stats = ParticleStats::compute(&buf);
628        assert_eq!(stats.active, 2);
629        assert!(stats.avg_speed > 0.0);
630        assert!(stats.total_kinetic_energy > 0.0);
631    }
632    #[test]
633    fn test_particle_stats_no_alive() {
634        let buf = ParticleBuffer::new(4);
635        let stats = ParticleStats::compute(&buf);
636        assert_eq!(stats.active, 0);
637        assert!((stats.avg_speed).abs() < 1e-6);
638    }
639    #[test]
640    fn test_emitter_inactive_no_emission() {
641        let mut emitter = ParticleEmitter::new([0.0; 3], 1000.0, [0.0; 3], 1.0);
642        emitter.active = false;
643        let mut buf = ParticleBuffer::new(100);
644        let spawned = emitter.emit(&mut buf, 1.0, 42);
645        assert_eq!(spawned, 0);
646    }
647    #[test]
648    fn test_emitter_emits_particles() {
649        let mut emitter = ParticleEmitter::new([0.0; 3], 100.0, [0.0, 1.0, 0.0], 5.0);
650        let mut buf = ParticleBuffer::new(200);
651        let spawned = emitter.emit(&mut buf, 1.0, 42);
652        assert!(spawned > 0, "should emit particles");
653    }
654    #[test]
655    fn test_emitter_with_spread() {
656        let mut emitter = ParticleEmitter::new([0.0; 3], 10.0, [0.0, 1.0, 0.0], 5.0);
657        emitter.velocity_spread = 0.5;
658        let mut buf = ParticleBuffer::new(20);
659        emitter.emit(&mut buf, 1.0, 42);
660        assert!(buf.active_count() > 0);
661    }
662    #[test]
663    fn test_drag_reduces_speed() {
664        let mut buf = ParticleBuffer::new(1);
665        buf.add_particle([0.0; 3], [10.0, 0.0, 0.0], 1.0, 10.0)
666            .unwrap();
667        let drag = DragForce { coefficient: 0.5 };
668        drag.apply(&mut buf, 1.0);
669        let vel = buf.get_velocity(0);
670        assert!(vel[0] < 10.0, "drag should reduce speed");
671        assert!(vel[0] > 0.0, "speed should stay positive");
672    }
673    #[test]
674    fn test_active_count() {
675        let mut buf = ParticleBuffer::new(5);
676        buf.add_particle([0.0; 3], [0.0; 3], 1.0, 5.0).unwrap();
677        buf.add_particle([0.0; 3], [0.0; 3], 1.0, 5.0).unwrap();
678        buf.add_particle([0.0; 3], [0.0; 3], 1.0, 5.0).unwrap();
679        assert_eq!(buf.active_count(), 3);
680        buf.kill(1);
681        assert_eq!(buf.active_count(), 2);
682    }
683    #[test]
684    fn test_gpu_emitter_continuous_burst_count_zero() {
685        let emitter = GpuParticleEmitter::new_continuous([0.0; 3], 50.0, 2.0);
686        assert_eq!(emitter.burst_count(), 0);
687    }
688    #[test]
689    fn test_gpu_emitter_burst_count() {
690        let emitter = GpuParticleEmitter::new_burst([0.0; 3], 100, 2.0);
691        assert_eq!(emitter.burst_count(), 100);
692    }
693    #[test]
694    fn test_gpu_emitter_burst_fires_once() {
695        let mut emitter = GpuParticleEmitter::new_burst([0.0; 3], 5, 1.5);
696        let mut buf = ParticleBuffer::new(32);
697        let first = emitter.emit(&mut buf, 0.016);
698        assert_eq!(first, 5);
699        let second = emitter.emit(&mut buf, 0.016);
700        assert_eq!(second, 0);
701    }
702    #[test]
703    fn test_gpu_emitter_continuous_accumulates() {
704        let mut emitter = GpuParticleEmitter::new_continuous([0.0; 3], 100.0, 2.0);
705        let mut buf = ParticleBuffer::new(64);
706        let spawned = emitter.emit(&mut buf, 0.1);
707        assert_eq!(spawned, 10);
708    }
709    #[test]
710    fn test_gpu_emitter_sphere_shape_positions_in_radius() {
711        let mut emitter = GpuParticleEmitter::new_continuous([0.0; 3], 100.0, 2.0);
712        emitter.shape = EmitterShape::Sphere { radius: 3.0 };
713        let mut buf = ParticleBuffer::new(64);
714        emitter.emit(&mut buf, 1.0);
715        let count = buf.active_count();
716        assert!(count > 0);
717        for i in 0..count {
718            let px = buf.positions_x[i];
719            let py = buf.positions_y[i];
720            let pz = buf.positions_z[i];
721            let r = (px * px + py * py + pz * pz).sqrt();
722            assert!(r <= 3.0 + 1e-3, "sphere sample outside radius: r={r}");
723        }
724    }
725    #[test]
726    fn test_gpu_emitter_box_shape_positions_in_bounds() {
727        let mut emitter = GpuParticleEmitter::new_continuous([0.0; 3], 50.0, 2.0);
728        emitter.shape = EmitterShape::Box {
729            half_extents: [1.0, 2.0, 0.5],
730        };
731        let mut buf = ParticleBuffer::new(64);
732        emitter.emit(&mut buf, 1.0);
733        let count = buf.active_count();
734        assert!(count > 0);
735        for i in 0..count {
736            assert!(buf.positions_x[i].abs() <= 1.0 + 1e-3);
737            assert!(buf.positions_y[i].abs() <= 2.0 + 1e-3);
738            assert!(buf.positions_z[i].abs() <= 0.5 + 1e-3);
739        }
740    }
741    #[test]
742    fn test_lifetime_manager_no_spawn_fraction() {
743        let mgr = ParticleLifetimeManager::new();
744        let buf = ParticleBuffer::new(4);
745        let frac = mgr.alive_fraction(&buf);
746        assert!((frac).abs() < 1e-5, "no spawns → fraction=0");
747    }
748    #[test]
749    fn test_lifetime_manager_spawn_records_count() {
750        let mut mgr = ParticleLifetimeManager::new();
751        mgr.record_spawn(1.0);
752        mgr.record_spawn(2.0);
753        mgr.record_spawn(3.0);
754        assert_eq!(mgr.total_spawned, 3);
755        assert!((mgr.min_observed_lifetime - 1.0).abs() < 1e-5);
756        assert!((mgr.max_observed_lifetime - 3.0).abs() < 1e-5);
757    }
758    #[test]
759    fn test_lifetime_manager_expiration_count() {
760        let mut mgr = ParticleLifetimeManager::new();
761        mgr.record_spawn(5.0);
762        mgr.record_spawn(5.0);
763        mgr.record_expiration();
764        assert_eq!(mgr.total_expired, 1);
765    }
766    #[test]
767    fn test_lifetime_manager_alive_fraction_with_active() {
768        let mut mgr = ParticleLifetimeManager::new();
769        for _ in 0..4 {
770            mgr.record_spawn(5.0);
771        }
772        let mut buf = ParticleBuffer::new(4);
773        buf.add_particle([0.0; 3], [0.0; 3], 1.0, 5.0).unwrap();
774        buf.add_particle([1.0; 3], [0.0; 3], 1.0, 5.0).unwrap();
775        let frac = mgr.alive_fraction(&buf);
776        assert!((frac - 0.5).abs() < 1e-5, "expected 0.5 got {frac}");
777    }
778    #[test]
779    fn test_morton_encode_zeros() {
780        assert_eq!(morton_encode(0, 0, 0), 0);
781    }
782    #[test]
783    fn test_morton_encode_axes_distinct() {
784        let cx = morton_encode(1, 0, 0);
785        let cy = morton_encode(0, 1, 0);
786        let cz = morton_encode(0, 0, 1);
787        assert_ne!(cx, 0);
788        assert_ne!(cy, 0);
789        assert_ne!(cz, 0);
790        assert_ne!(cx, cy);
791        assert_ne!(cy, cz);
792        assert_ne!(cx, cz);
793    }
794    #[test]
795    fn test_compute_morton_codes_length() {
796        let mut buf = ParticleBuffer::new(4);
797        buf.add_particle([0.0, 0.0, 0.0], [0.0; 3], 1.0, 5.0)
798            .unwrap();
799        buf.add_particle([1.0, 0.0, 0.0], [0.0; 3], 1.0, 5.0)
800            .unwrap();
801        buf.add_particle([0.0, 1.0, 0.0], [0.0; 3], 1.0, 5.0)
802            .unwrap();
803        buf.add_particle([0.0, 0.0, 1.0], [0.0; 3], 1.0, 5.0)
804            .unwrap();
805        let codes = compute_morton_codes(&buf, [0.0; 3], [2.0; 3], 16);
806        assert_eq!(codes.len(), 4);
807    }
808    #[test]
809    fn test_compute_morton_codes_sorted_ascending() {
810        let mut buf = ParticleBuffer::new(3);
811        buf.add_particle([1.0, 1.0, 1.0], [0.0; 3], 1.0, 5.0)
812            .unwrap();
813        buf.add_particle([0.0, 0.0, 0.0], [0.0; 3], 1.0, 5.0)
814            .unwrap();
815        buf.add_particle([0.5, 0.5, 0.5], [0.0; 3], 1.0, 5.0)
816            .unwrap();
817        let codes = compute_morton_codes(&buf, [0.0; 3], [2.0; 3], 16);
818        for w in codes.windows(2) {
819            assert!(
820                w[0].0 <= w[1].0,
821                "codes not sorted: {} > {}",
822                w[0].0,
823                w[1].0
824            );
825        }
826    }
827    #[test]
828    fn test_sort_particles_morton_origin_first() {
829        let mut buf = ParticleBuffer::new(3);
830        buf.add_particle([1.0, 1.0, 1.0], [0.0; 3], 1.0, 5.0)
831            .unwrap();
832        buf.add_particle([0.0, 0.0, 0.0], [0.0; 3], 1.0, 5.0)
833            .unwrap();
834        buf.add_particle([0.5, 0.5, 0.5], [0.0; 3], 1.0, 5.0)
835            .unwrap();
836        let sorted = sort_particles_morton(&buf, [0.0; 3], [2.0; 3], 16);
837        assert!(
838            (sorted.positions_x[0]).abs() < 1e-5,
839            "origin should be first"
840        );
841    }
842    #[test]
843    fn test_grid_collision_no_overlap() {
844        let mut buf = ParticleBuffer::new(3);
845        buf.add_particle([0.0, 0.0, 0.0], [0.0; 3], 1.0, 5.0)
846            .unwrap();
847        buf.add_particle([10.0, 0.0, 0.0], [0.0; 3], 1.0, 5.0)
848            .unwrap();
849        buf.add_particle([0.0, 10.0, 0.0], [0.0; 3], 1.0, 5.0)
850            .unwrap();
851        let col = GridParticleCollision::new(2.0, 0.3, 0.8);
852        col.resolve(&mut buf);
853        for i in 0..3 {
854            let v = buf.get_velocity(i);
855            assert!((v[0]).abs() < 1e-5, "particle {i} should have zero vx");
856        }
857    }
858    #[test]
859    fn test_grid_collision_overlapping_pair_momentum_conserved() {
860        let mut buf = ParticleBuffer::new(2);
861        buf.add_particle([0.0, 0.0, 0.0], [0.0; 3], 1.0, 5.0)
862            .unwrap();
863        buf.add_particle([0.5, 0.0, 0.0], [0.0; 3], 1.0, 5.0)
864            .unwrap();
865        let col = GridParticleCollision::new(1.0, 0.4, 0.5);
866        col.resolve(&mut buf);
867        let v0 = buf.get_velocity(0);
868        let v1 = buf.get_velocity(1);
869        let total_px = v0[0] * buf.masses[0] + v1[0] * buf.masses[1];
870        assert!(total_px.abs() < 1e-4, "momentum not conserved: {total_px}");
871    }
872    #[test]
873    fn test_prepare_sorted_render_data_empty() {
874        let buf = ParticleBuffer::new(4);
875        let data =
876            prepare_sorted_render_data(&buf, [1.0; 4], [0.5; 4], 1.0, [0.0; 3], [0.0, 0.0, 1.0]);
877        assert!(data.is_empty());
878    }
879    #[test]
880    fn test_prepare_sorted_render_data_back_to_front() {
881        let mut buf = ParticleBuffer::new(3);
882        buf.add_particle([0.0, 0.0, 1.0], [0.0; 3], 1.0, 5.0)
883            .unwrap();
884        buf.add_particle([0.0, 0.0, 5.0], [0.0; 3], 1.0, 5.0)
885            .unwrap();
886        buf.add_particle([0.0, 0.0, 3.0], [0.0; 3], 1.0, 5.0)
887            .unwrap();
888        let camera_fwd = [0.0_f32, 0.0, 1.0];
889        let data = prepare_sorted_render_data(&buf, [1.0; 4], [0.0; 4], 1.0, [0.0; 3], camera_fwd);
890        assert_eq!(data.len(), 3);
891        assert!(
892            data[0].sort_key >= data[1].sort_key,
893            "first entry should be farthest: {} >= {}",
894            data[0].sort_key,
895            data[1].sort_key
896        );
897        assert!(
898            data[1].sort_key >= data[2].sort_key,
899            "second entry should be middle: {} >= {}",
900            data[1].sort_key,
901            data[2].sort_key
902        );
903    }
904    #[test]
905    fn test_prepare_sorted_render_data_position_preserved() {
906        let mut buf = ParticleBuffer::new(1);
907        buf.add_particle([1.0, 2.0, 3.0], [0.0; 3], 1.0, 5.0)
908            .unwrap();
909        let data = prepare_sorted_render_data(
910            &buf,
911            [1.0_f32, 0.0, 0.0, 1.0],
912            [0.0_f32, 1.0, 0.0, 0.5],
913            2.0,
914            [0.0; 3],
915            [0.0, 0.0, 1.0],
916        );
917        assert_eq!(data.len(), 1);
918        assert!((data[0].render_data.position[0] - 1.0).abs() < 1e-5);
919        assert!((data[0].render_data.position[1] - 2.0).abs() < 1e-5);
920        assert!((data[0].render_data.position[2] - 3.0).abs() < 1e-5);
921        assert!(data[0].render_data.size > 0.0);
922        assert!(data[0].sort_key.abs() > 0.0);
923    }
924    #[test]
925    fn test_particle_system_stats_extended_empty() {
926        let buf = ParticleBuffer::new(8);
927        let stats = ParticleSystemStats::compute_extended(&buf);
928        assert_eq!(stats.basic.active, 0);
929        assert_eq!(stats.capacity, 8);
930        assert!(!stats.is_near_capacity(0.9));
931    }
932    #[test]
933    fn test_particle_system_stats_near_capacity() {
934        let mut buf = ParticleBuffer::new(4);
935        buf.add_particle([0.0; 3], [1.0, 0.0, 0.0], 1.0, 5.0)
936            .unwrap();
937        buf.add_particle([1.0; 3], [0.0, 1.0, 0.0], 1.0, 5.0)
938            .unwrap();
939        buf.add_particle([2.0; 3], [0.0, 0.0, 1.0], 1.0, 5.0)
940            .unwrap();
941        buf.add_particle([3.0; 3], [1.0, 1.0, 0.0], 1.0, 5.0)
942            .unwrap();
943        let stats = ParticleSystemStats::compute_extended(&buf);
944        assert!(
945            stats.is_near_capacity(0.9),
946            "4/4 active should be near capacity"
947        );
948    }
949    #[test]
950    fn test_particle_system_stats_fill_ratio() {
951        let mut buf = ParticleBuffer::new(4);
952        buf.add_particle([0.0; 3], [0.0; 3], 1.0, 5.0).unwrap();
953        buf.add_particle([0.0; 3], [0.0; 3], 1.0, 5.0).unwrap();
954        let stats = ParticleSystemStats::compute_extended(&buf);
955        assert!(
956            (stats.fill_ratio - 0.5).abs() < 1e-5,
957            "expected 0.5 got {}",
958            stats.fill_ratio
959        );
960    }
961    #[test]
962    fn test_particle_system_stats_kinetic_energy() {
963        let mut buf = ParticleBuffer::new(2);
964        buf.add_particle([0.0; 3], [2.0, 0.0, 0.0], 1.0, 5.0)
965            .unwrap();
966        buf.add_particle([0.0; 3], [0.0, 4.0, 0.0], 2.0, 5.0)
967            .unwrap();
968        let stats = ParticleSystemStats::compute_extended(&buf);
969        assert!(
970            (stats.total_kinetic_energy - 18.0).abs() < 1e-3,
971            "expected KE=18 got {}",
972            stats.total_kinetic_energy
973        );
974    }
975    #[test]
976    fn test_particle_system_stats_mean_age_zero_at_spawn() {
977        let mut buf = ParticleBuffer::new(2);
978        buf.add_particle([0.0; 3], [1.0, 0.0, 0.0], 1.0, 5.0)
979            .unwrap();
980        buf.add_particle([0.0; 3], [0.0, 1.0, 0.0], 1.0, 5.0)
981            .unwrap();
982        let stats = ParticleSystemStats::compute_extended(&buf);
983        assert!(
984            (stats.mean_age).abs() < 1e-5,
985            "fresh particles should have mean_age=0"
986        );
987    }
988    #[test]
989    fn test_velocity_histogram_basic_bin() {
990        let mut buf = ParticleBuffer::new(2);
991        buf.add_particle([0.0; 3], [1.0, 0.0, 0.0], 1.0, 5.0)
992            .unwrap();
993        buf.add_particle([0.0; 3], [3.0, 0.0, 0.0], 1.0, 5.0)
994            .unwrap();
995        let hist = compute_velocity_histogram(&buf, 5.0, 1.0);
996        assert!(hist[1] >= 1, "bin 1 should contain the speed=1.0 particle");
997        assert!(hist[3] >= 1, "bin 3 should contain the speed=3.0 particle");
998    }
999    #[test]
1000    fn test_velocity_histogram_empty_buffer() {
1001        let buf = ParticleBuffer::new(4);
1002        let hist = compute_velocity_histogram(&buf, 5.0, 1.0);
1003        let total: usize = hist.iter().sum();
1004        assert_eq!(total, 0, "empty buffer yields zero histogram");
1005    }
1006    #[test]
1007    fn test_velocity_histogram_length() {
1008        let buf = ParticleBuffer::new(1);
1009        let hist = compute_velocity_histogram(&buf, 10.0, 2.0);
1010        assert_eq!(hist.len(), 5, "ceil(10/2)=5 bins");
1011    }
1012    #[test]
1013    fn test_angular_momentum_z_axis() {
1014        let mut buf = ParticleBuffer::new(1);
1015        buf.add_particle([1.0, 0.0, 0.0], [0.0, 2.0, 0.0], 1.0, 5.0)
1016            .unwrap();
1017        let l = compute_angular_momentum(&buf);
1018        assert!((l[2] - 2.0).abs() < 1e-5, "Lz should be 2, got {}", l[2]);
1019        assert!(l[0].abs() < 1e-5);
1020        assert!(l[1].abs() < 1e-5);
1021    }
1022    #[test]
1023    fn test_angular_momentum_zero_for_radial_motion() {
1024        let mut buf = ParticleBuffer::new(1);
1025        buf.add_particle([1.0, 0.0, 0.0], [1.0, 0.0, 0.0], 1.0, 5.0)
1026            .unwrap();
1027        let l = compute_angular_momentum(&buf);
1028        assert!(l[0].abs() < 1e-6);
1029        assert!(l[1].abs() < 1e-6);
1030        assert!(l[2].abs() < 1e-6);
1031    }
1032    #[test]
1033    fn test_angular_momentum_empty_buffer() {
1034        let buf = ParticleBuffer::new(4);
1035        let l = compute_angular_momentum(&buf);
1036        assert!(l[0].abs() < 1e-6 && l[1].abs() < 1e-6 && l[2].abs() < 1e-6);
1037    }
1038    #[test]
1039    fn test_emit_burst_count() {
1040        let mut buf = ParticleBuffer::new(10);
1041        let spawned = emit_burst(&mut buf, [0.0; 3], [0.0, 1.0, 0.0], 0.1, 5.0, 1.0, 5, 42);
1042        assert_eq!(spawned, 5, "should emit exactly 5 particles");
1043    }
1044    #[test]
1045    fn test_emit_burst_respects_buffer_capacity() {
1046        let mut buf = ParticleBuffer::new(3);
1047        let spawned = emit_burst(&mut buf, [0.0; 3], [0.0, 1.0, 0.0], 0.0, 5.0, 1.0, 100, 99);
1048        assert_eq!(spawned, 3, "cannot exceed buffer capacity");
1049    }
1050    #[test]
1051    fn test_emit_burst_particles_are_alive() {
1052        let mut buf = ParticleBuffer::new(5);
1053        emit_burst(&mut buf, [1.0, 2.0, 3.0], [0.0; 3], 0.0, 5.0, 1.0, 3, 7);
1054        let alive: usize = (0..buf.count).filter(|&i| buf.is_alive(i)).count();
1055        assert_eq!(alive, 3);
1056    }
1057}