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