Skip to main content

oxiphysics_gpu/particle_system/
types.rs

1//! Auto-generated module
2//!
3//! 🤖 Generated with [SplitRS](https://github.com/cool-japan/splitrs)
4
5#![allow(clippy::needless_range_loop)]
6#[allow(unused_imports)]
7use super::functions::*;
8/// Emitter shape that controls where and how particles are emitted.
9#[derive(Debug, Clone, Copy)]
10pub enum EmitterShape {
11    /// Point emitter: all particles originate from a single point.
12    Point,
13    /// Cone emitter: particles emitted within a cone of given half-angle (radians).
14    Cone {
15        /// Half-angle of the cone in radians.
16        half_angle: f32,
17    },
18    /// Sphere emitter: particles start at random positions on a sphere surface.
19    Sphere {
20        /// Radius of the sphere.
21        radius: f32,
22    },
23    /// Box emitter: particles emitted from random positions inside an AABB.
24    Box {
25        /// Half-extents of the AABB on each axis.
26        half_extents: [f32; 3],
27    },
28}
29/// GPU-style particle collision using a grid-based broad phase.
30///
31/// Assigns each particle to a grid cell, then checks only neighbours.
32pub struct GridParticleCollision {
33    /// Cell size.
34    pub cell_size: f32,
35    /// Radius for collision detection.
36    pub particle_radius: f32,
37    /// Restitution coefficient.
38    pub restitution: f32,
39}
40impl GridParticleCollision {
41    /// Create a new grid collision handler.
42    pub fn new(cell_size: f32, particle_radius: f32, restitution: f32) -> Self {
43        Self {
44            cell_size,
45            particle_radius,
46            restitution,
47        }
48    }
49    /// Resolve collisions between all alive particles (O(n) with grid).
50    ///
51    /// Uses a simple hash-grid approach: build cell lists, then check
52    /// same-cell and adjacent-cell pairs only.
53    pub fn resolve(&self, buffer: &mut ParticleBuffer) {
54        let n = buffer.count;
55        let alive: Vec<usize> = (0..n).filter(|&i| buffer.is_alive(i)).collect();
56        let na = alive.len();
57        if na < 2 {
58            return;
59        }
60        let mut grid: std::collections::HashMap<(i32, i32, i32), Vec<usize>> =
61            std::collections::HashMap::new();
62        for &i in &alive {
63            let cx = (buffer.positions_x[i] / self.cell_size).floor() as i32;
64            let cy = (buffer.positions_y[i] / self.cell_size).floor() as i32;
65            let cz = (buffer.positions_z[i] / self.cell_size).floor() as i32;
66            grid.entry((cx, cy, cz)).or_default().push(i);
67        }
68        let diameter = 2.0 * self.particle_radius;
69        let mut dvx = vec![0.0f32; n];
70        let mut dvy = vec![0.0f32; n];
71        let mut dvz = vec![0.0f32; n];
72        for (&(cx, cy, cz), cell) in &grid {
73            for dx in -1i32..=1 {
74                for dy in -1i32..=1 {
75                    for dz in -1i32..=1 {
76                        let nb_key = (cx + dx, cy + dy, cz + dz);
77                        if let Some(nb_cell) = grid.get(&nb_key) {
78                            for &i in cell {
79                                for &j in nb_cell {
80                                    if j <= i {
81                                        continue;
82                                    }
83                                    let dx_p = buffer.positions_x[j] - buffer.positions_x[i];
84                                    let dy_p = buffer.positions_y[j] - buffer.positions_y[i];
85                                    let dz_p = buffer.positions_z[j] - buffer.positions_z[i];
86                                    let dist = (dx_p * dx_p + dy_p * dy_p + dz_p * dz_p).sqrt();
87                                    if dist < diameter && dist > 1e-6 {
88                                        let overlap = diameter - dist;
89                                        let nx = dx_p / dist;
90                                        let ny = dy_p / dist;
91                                        let nz = dz_p / dist;
92                                        let rvx = buffer.velocities_x[j] - buffer.velocities_x[i];
93                                        let rvy = buffer.velocities_y[j] - buffer.velocities_y[i];
94                                        let rvz = buffer.velocities_z[j] - buffer.velocities_z[i];
95                                        let rv_n = rvx * nx + rvy * ny + rvz * nz;
96                                        if rv_n < 0.0 {
97                                            let j_impulse = -(1.0 + self.restitution) * rv_n
98                                                / (1.0 / buffer.masses[i] + 1.0 / buffer.masses[j]);
99                                            let inv_mi = 1.0 / buffer.masses[i];
100                                            let inv_mj = 1.0 / buffer.masses[j];
101                                            dvx[i] -= j_impulse * inv_mi * nx;
102                                            dvy[i] -= j_impulse * inv_mi * ny;
103                                            dvz[i] -= j_impulse * inv_mi * nz;
104                                            dvx[j] += j_impulse * inv_mj * nx;
105                                            dvy[j] += j_impulse * inv_mj * ny;
106                                            dvz[j] += j_impulse * inv_mj * nz;
107                                        }
108                                        let push = overlap * 0.5;
109                                        buffer.positions_x[i] -= push * nx;
110                                        buffer.positions_y[i] -= push * ny;
111                                        buffer.positions_z[i] -= push * nz;
112                                        buffer.positions_x[j] += push * nx;
113                                        buffer.positions_y[j] += push * ny;
114                                        buffer.positions_z[j] += push * nz;
115                                    }
116                                }
117                            }
118                        }
119                    }
120                }
121            }
122        }
123        for &i in &alive {
124            buffer.velocities_x[i] += dvx[i];
125            buffer.velocities_y[i] += dvy[i];
126            buffer.velocities_z[i] += dvz[i];
127        }
128    }
129}
130/// Summary statistics computed from a `ParticleBuffer`.
131#[derive(Debug, Clone)]
132pub struct ParticleStats {
133    /// Number of alive particles.
134    pub active: usize,
135    /// Minimum position across all alive particles.
136    pub min_pos: [f32; 3],
137    /// Maximum position across all alive particles.
138    pub max_pos: [f32; 3],
139    /// Mean speed of all alive particles.
140    pub avg_speed: f32,
141    /// Total ½mv² kinetic energy.
142    pub total_kinetic_energy: f32,
143}
144impl ParticleStats {
145    /// Compute statistics from the given buffer.
146    pub fn compute(buffer: &ParticleBuffer) -> Self {
147        let mut active = 0usize;
148        let mut min_pos = [f32::MAX; 3];
149        let mut max_pos = [f32::MIN; 3];
150        let mut sum_speed = 0.0f32;
151        let mut total_ke = 0.0f32;
152        for i in 0..buffer.count {
153            if !buffer.is_alive(i) {
154                continue;
155            }
156            active += 1;
157            let x = buffer.positions_x[i];
158            let y = buffer.positions_y[i];
159            let z = buffer.positions_z[i];
160            min_pos[0] = min_pos[0].min(x);
161            min_pos[1] = min_pos[1].min(y);
162            min_pos[2] = min_pos[2].min(z);
163            max_pos[0] = max_pos[0].max(x);
164            max_pos[1] = max_pos[1].max(y);
165            max_pos[2] = max_pos[2].max(z);
166            let vx = buffer.velocities_x[i];
167            let vy = buffer.velocities_y[i];
168            let vz = buffer.velocities_z[i];
169            let speed = (vx * vx + vy * vy + vz * vz).sqrt();
170            sum_speed += speed;
171            total_ke += 0.5 * buffer.masses[i] * speed * speed;
172        }
173        let avg_speed = if active > 0 {
174            sum_speed / active as f32
175        } else {
176            0.0
177        };
178        if active == 0 {
179            min_pos = [0.0; 3];
180            max_pos = [0.0; 3];
181        }
182        Self {
183            active,
184            min_pos,
185            max_pos,
186            avg_speed,
187            total_kinetic_energy: total_ke,
188        }
189    }
190}
191/// Euler integrator for particle positions and lifetimes.
192pub struct ParticleIntegrator;
193impl ParticleIntegrator {
194    /// Advance all alive particles by `dt` seconds.
195    ///
196    /// * `pos += vel * dt`
197    /// * `age += dt`
198    /// * `lifetime -= dt`  (particle dies naturally when lifetime < 0)
199    pub fn integrate(buffer: &mut ParticleBuffer, dt: f32) {
200        for i in 0..buffer.count {
201            if !buffer.is_alive(i) {
202                continue;
203            }
204            buffer.positions_x[i] += buffer.velocities_x[i] * dt;
205            buffer.positions_y[i] += buffer.velocities_y[i] * dt;
206            buffer.positions_z[i] += buffer.velocities_z[i] * dt;
207            buffer.ages[i] += dt;
208            buffer.lifetimes[i] -= dt;
209        }
210    }
211}
212/// Simple pairwise repulsion between particles.
213pub struct ParticleRepulsion {
214    /// Repulsion strength.
215    pub strength: f32,
216    /// Interaction radius.
217    pub radius: f32,
218}
219impl ParticleRepulsion {
220    /// Apply pairwise repulsion between all alive particles.
221    ///
222    /// This is O(n^2) and suitable only for small particle counts.
223    pub fn apply(&self, buffer: &mut ParticleBuffer, dt: f32) {
224        let n = buffer.count;
225        let alive: Vec<usize> = (0..n).filter(|&i| buffer.is_alive(i)).collect();
226        let na = alive.len();
227        let mut fx = vec![0.0f32; n];
228        let mut fy = vec![0.0f32; n];
229        let mut fz = vec![0.0f32; n];
230        for ai in 0..na {
231            let i = alive[ai];
232            for aj in (ai + 1)..na {
233                let j = alive[aj];
234                let dx = buffer.positions_x[j] - buffer.positions_x[i];
235                let dy = buffer.positions_y[j] - buffer.positions_y[i];
236                let dz = buffer.positions_z[j] - buffer.positions_z[i];
237                let dist2 = dx * dx + dy * dy + dz * dz;
238                let dist = dist2.sqrt();
239                if dist >= self.radius || dist < 1e-6 {
240                    continue;
241                }
242                let overlap = self.radius - dist;
243                let f = self.strength * overlap / dist;
244                fx[i] -= f * dx;
245                fy[i] -= f * dy;
246                fz[i] -= f * dz;
247                fx[j] += f * dx;
248                fy[j] += f * dy;
249                fz[j] += f * dz;
250            }
251        }
252        for &i in &alive {
253            buffer.velocities_x[i] += fx[i] * dt / buffer.masses[i];
254            buffer.velocities_y[i] += fy[i] * dt / buffer.masses[i];
255            buffer.velocities_z[i] += fz[i] * dt / buffer.masses[i];
256        }
257    }
258}
259/// Emission mode.
260#[derive(Debug, Clone, Copy)]
261pub enum EmissionMode {
262    /// All particles emitted at once.
263    Burst {
264        /// Number of particles to emit in the burst.
265        count: usize,
266    },
267    /// Continuous emission at a given rate (particles/sec).
268    Continuous {
269        /// Emission rate in particles per second.
270        rate: f32,
271    },
272}
273/// Extended rendering data for a particle including sort key.
274#[derive(Debug, Clone)]
275pub struct SortedParticleRenderData {
276    /// Particle render data.
277    pub render_data: ParticleRenderData,
278    /// Depth sort key (negative z in view space for back-to-front).
279    pub sort_key: f32,
280    /// Original buffer index.
281    pub buffer_index: usize,
282}
283/// Linear drag force that damps particle velocity each step.
284pub struct DragForce {
285    /// Drag coefficient.  Applied as `v *= (1 - coefficient * dt)`.
286    pub coefficient: f32,
287}
288impl DragForce {
289    /// Apply drag to all alive particles.
290    pub fn apply(&self, buffer: &mut ParticleBuffer, dt: f32) {
291        let factor = (1.0 - self.coefficient * dt).max(0.0);
292        for i in 0..buffer.count {
293            if buffer.is_alive(i) {
294                buffer.velocities_x[i] *= factor;
295                buffer.velocities_y[i] *= factor;
296                buffer.velocities_z[i] *= factor;
297            }
298        }
299    }
300}
301/// Utilities for converting between the SoA `ParticleBuffer` and an
302/// interleaved flat `f32` slice suitable for GPU upload.
303pub struct GpuParticleLayout;
304impl GpuParticleLayout {
305    /// Number of `f32` values per particle in the interleaved layout.
306    ///
307    /// Layout: `[x, y, z, vx, vy, vz, mass, lifetime]`
308    pub fn stride() -> usize {
309        8
310    }
311    /// Produce an interleaved `Vec`f32` containing all slots (alive and dead).
312    pub fn to_f32_buffer(buffer: &ParticleBuffer) -> Vec<f32> {
313        let stride = Self::stride();
314        let mut out = Vec::with_capacity(buffer.count * stride);
315        for i in 0..buffer.count {
316            out.push(buffer.positions_x[i]);
317            out.push(buffer.positions_y[i]);
318            out.push(buffer.positions_z[i]);
319            out.push(buffer.velocities_x[i]);
320            out.push(buffer.velocities_y[i]);
321            out.push(buffer.velocities_z[i]);
322            out.push(buffer.masses[i]);
323            out.push(buffer.lifetimes[i]);
324        }
325        out
326    }
327    /// Parse an interleaved buffer back into a `ParticleBuffer`.
328    ///
329    /// `count` must equal the number of particle slots encoded in `data`.
330    pub fn from_f32_buffer(data: &[f32], count: usize) -> ParticleBuffer {
331        let stride = Self::stride();
332        assert_eq!(data.len(), count * stride, "data length mismatch");
333        let mut buf = ParticleBuffer::new(count);
334        for i in 0..count {
335            let base = i * stride;
336            buf.positions_x[i] = data[base];
337            buf.positions_y[i] = data[base + 1];
338            buf.positions_z[i] = data[base + 2];
339            buf.velocities_x[i] = data[base + 3];
340            buf.velocities_y[i] = data[base + 4];
341            buf.velocities_z[i] = data[base + 5];
342            buf.masses[i] = data[base + 6];
343            buf.lifetimes[i] = data[base + 7];
344        }
345        buf
346    }
347}
348/// Extended particle system statistics.
349#[derive(Debug, Clone)]
350pub struct ParticleSystemStats {
351    /// Basic stats.
352    pub basic: ParticleStats,
353    /// Total buffer capacity.
354    pub capacity: usize,
355    /// Fill ratio (active / capacity).
356    pub fill_ratio: f32,
357    /// Total kinetic energy.
358    pub total_kinetic_energy: f32,
359    /// Mean age of alive particles.
360    pub mean_age: f32,
361    /// Maximum age of alive particles.
362    pub max_age: f32,
363    /// Velocity standard deviation.
364    pub velocity_std_dev: f32,
365}
366impl ParticleSystemStats {
367    /// Compute extended statistics from a buffer.
368    pub fn compute_extended(buffer: &ParticleBuffer) -> Self {
369        let basic = ParticleStats::compute(buffer);
370        let capacity = buffer.count;
371        let fill_ratio = if capacity > 0 {
372            basic.active as f32 / capacity as f32
373        } else {
374            0.0
375        };
376        let mut sum_age = 0.0f32;
377        let mut max_age = 0.0f32;
378        let mut sum_v2 = 0.0f32;
379        let active = basic.active;
380        for i in 0..buffer.count {
381            if !buffer.is_alive(i) {
382                continue;
383            }
384            sum_age += buffer.ages[i];
385            max_age = max_age.max(buffer.ages[i]);
386            let vx = buffer.velocities_x[i];
387            let vy = buffer.velocities_y[i];
388            let vz = buffer.velocities_z[i];
389            sum_v2 += vx * vx + vy * vy + vz * vz;
390        }
391        let mean_age = if active > 0 {
392            sum_age / active as f32
393        } else {
394            0.0
395        };
396        let mean_v2 = if active > 0 {
397            sum_v2 / active as f32
398        } else {
399            0.0
400        };
401        let velocity_std_dev = (mean_v2 - basic.avg_speed * basic.avg_speed)
402            .max(0.0)
403            .sqrt();
404        Self {
405            total_kinetic_energy: basic.total_kinetic_energy,
406            basic,
407            capacity,
408            fill_ratio,
409            mean_age,
410            max_age,
411            velocity_std_dev,
412        }
413    }
414    /// Whether the buffer is at or near capacity.
415    pub fn is_near_capacity(&self, threshold: f32) -> bool {
416        self.fill_ratio >= threshold
417    }
418}
419/// Vortex (rotational) force field around a Y-axis.
420pub struct VortexForceField {
421    /// Center of the vortex on the XZ plane.
422    pub center: [f32; 2],
423    /// Angular velocity (radians per second).
424    pub angular_velocity: f32,
425    /// Radius of influence.
426    pub radius: f32,
427}
428impl VortexForceField {
429    /// Apply vortex force to alive particles.
430    pub fn apply(&self, buffer: &mut ParticleBuffer, dt: f32) {
431        for i in 0..buffer.count {
432            if !buffer.is_alive(i) {
433                continue;
434            }
435            let dx = buffer.positions_x[i] - self.center[0];
436            let dz = buffer.positions_z[i] - self.center[1];
437            let dist = (dx * dx + dz * dz).sqrt();
438            if dist > self.radius || dist < 1e-6 {
439                continue;
440            }
441            let factor = self.angular_velocity * (1.0 - dist / self.radius) * dt;
442            buffer.velocities_x[i] += -dz / dist * factor;
443            buffer.velocities_z[i] += dx / dist * factor;
444        }
445    }
446}
447/// Reflect particles off a horizontal floor plane at a fixed Y height.
448pub struct FloorCollision {
449    /// Y coordinate of the floor.
450    pub y: f32,
451    /// Coefficient of restitution (0 = fully inelastic, 1 = fully elastic).
452    pub restitution: f32,
453}
454impl FloorCollision {
455    /// Push particles above the floor and reflect downward velocities.
456    pub fn apply(&self, buffer: &mut ParticleBuffer) {
457        for i in 0..buffer.count {
458            if !buffer.is_alive(i) {
459                continue;
460            }
461            if buffer.positions_y[i] < self.y {
462                buffer.positions_y[i] = self.y;
463                if buffer.velocities_y[i] < 0.0 {
464                    buffer.velocities_y[i] = -buffer.velocities_y[i] * self.restitution;
465                }
466            }
467        }
468    }
469}
470/// A minimal Linear Congruential Generator used internally.
471pub struct SimpleRng {
472    pub(super) state: u64,
473}
474impl SimpleRng {
475    /// Create a new RNG from a seed.
476    pub fn new(seed: u64) -> Self {
477        Self {
478            state: seed ^ 0x853c_49e6_748f_ea9b,
479        }
480    }
481    /// Return the next raw 64-bit value.
482    pub fn next_u64(&mut self) -> u64 {
483        self.state = self
484            .state
485            .wrapping_mul(6_364_136_223_846_793_005)
486            .wrapping_add(1_442_695_040_888_963_407);
487        self.state
488    }
489    /// Return a uniform float in [0, 1).
490    pub fn next_f32(&mut self) -> f32 {
491        let bits = (self.next_u64() >> 40) as u32;
492        (bits as f32) / (1u32 << 24) as f32
493    }
494    /// Return a uniform float in [min, max).
495    pub fn next_f32_range(&mut self, min: f32, max: f32) -> f32 {
496        min + self.next_f32() * (max - min)
497    }
498    /// Return a random direction on the unit sphere (uniform).
499    pub fn next_unit_sphere(&mut self) -> [f32; 3] {
500        loop {
501            let x = self.next_f32_range(-1.0, 1.0);
502            let y = self.next_f32_range(-1.0, 1.0);
503            let z = self.next_f32_range(-1.0, 1.0);
504            let len2 = x * x + y * y + z * z;
505            if len2 > 1e-10 && len2 <= 1.0 {
506                let inv = 1.0 / len2.sqrt();
507                return [x * inv, y * inv, z * inv];
508            }
509        }
510    }
511}
512/// Kill particles that leave an axis-aligned bounding box.
513pub struct BoundingBoxKill {
514    /// Minimum corner of the kill-box.
515    pub min: [f32; 3],
516    /// Maximum corner of the kill-box.
517    pub max: [f32; 3],
518}
519impl BoundingBoxKill {
520    /// Kill any alive particle whose position is outside `\[min, max\]`.
521    pub fn apply(&self, buffer: &mut ParticleBuffer) {
522        for i in 0..buffer.count {
523            if !buffer.is_alive(i) {
524                continue;
525            }
526            let x = buffer.positions_x[i];
527            let y = buffer.positions_y[i];
528            let z = buffer.positions_z[i];
529            if x < self.min[0]
530                || x > self.max[0]
531                || y < self.min[1]
532                || y > self.max[1]
533                || z < self.min[2]
534                || z > self.max[2]
535            {
536                buffer.kill(i);
537            }
538        }
539    }
540}
541/// High-level particle system that owns a buffer, emitters, and forces.
542pub struct ParticleSystem {
543    /// The SoA particle data.
544    pub buffer: ParticleBuffer,
545    /// All registered emitters.
546    pub emitters: Vec<ParticleEmitter>,
547    /// Global gravity.
548    pub gravity: GravityForce,
549    /// Global drag.
550    pub drag: DragForce,
551    /// Optional floor collision plane.
552    pub floor: Option<FloorCollision>,
553    /// Internal RNG for emitter seeding.
554    pub rng: SimpleRng,
555    /// Elapsed simulation time.
556    pub time: f32,
557}
558impl ParticleSystem {
559    /// Create a new particle system with the given buffer capacity.
560    pub fn new(capacity: usize) -> Self {
561        Self {
562            buffer: ParticleBuffer::new(capacity),
563            emitters: Vec::new(),
564            gravity: GravityForce {
565                g: [0.0, -9.81, 0.0],
566            },
567            drag: DragForce { coefficient: 0.01 },
568            floor: None,
569            rng: SimpleRng::new(12345),
570            time: 0.0,
571        }
572    }
573    /// Register an emitter and return its index.
574    pub fn add_emitter(&mut self, emitter: ParticleEmitter) -> usize {
575        let idx = self.emitters.len();
576        self.emitters.push(emitter);
577        idx
578    }
579    /// Advance the simulation by `dt` seconds.
580    ///
581    /// Order: emit → gravity → drag → floor → integrate.
582    pub fn step(&mut self, dt: f32) {
583        let seed_base = self.rng.next_u64();
584        for (idx, emitter) in self.emitters.iter_mut().enumerate() {
585            let seed = seed_base ^ (idx as u64).wrapping_mul(0x9e37_79b9_7f4a_7c15);
586            emitter.emit(&mut self.buffer, dt, seed);
587        }
588        self.gravity.apply(&mut self.buffer, dt);
589        self.drag.apply(&mut self.buffer, dt);
590        if let Some(ref floor) = self.floor {
591            floor.apply(&mut self.buffer);
592        }
593        ParticleIntegrator::integrate(&mut self.buffer, dt);
594        self.time += dt;
595    }
596}
597/// Radial force field: attracts or repels particles from a point.
598pub struct RadialForceField {
599    /// Center of the force field.
600    pub center: [f32; 3],
601    /// Strength of the field. Positive = attraction, negative = repulsion.
602    pub strength: f32,
603    /// Falloff exponent (1 = linear, 2 = inverse-square).
604    pub falloff: f32,
605    /// Minimum distance to avoid singularity.
606    pub min_distance: f32,
607}
608impl RadialForceField {
609    /// Apply this radial force to all alive particles.
610    pub fn apply(&self, buffer: &mut ParticleBuffer, dt: f32) {
611        for i in 0..buffer.count {
612            if !buffer.is_alive(i) {
613                continue;
614            }
615            let dx = self.center[0] - buffer.positions_x[i];
616            let dy = self.center[1] - buffer.positions_y[i];
617            let dz = self.center[2] - buffer.positions_z[i];
618            let dist = (dx * dx + dy * dy + dz * dz).sqrt().max(self.min_distance);
619            let force = self.strength / dist.powf(self.falloff);
620            let inv_dist = 1.0 / dist;
621            buffer.velocities_x[i] += force * dx * inv_dist * dt;
622            buffer.velocities_y[i] += force * dy * inv_dist * dt;
623            buffer.velocities_z[i] += force * dz * inv_dist * dt;
624        }
625    }
626}
627/// Per-particle rendering data for a GPU particle renderer.
628#[derive(Debug, Clone)]
629pub struct ParticleRenderData {
630    /// Position as [x, y, z].
631    pub position: [f32; 3],
632    /// Color as [r, g, b, a].
633    pub color: [f32; 4],
634    /// Size (radius or diameter depending on renderer).
635    pub size: f32,
636    /// Normalized age (0 = just spawned, 1 = about to die).
637    pub age_normalized: f32,
638}
639/// Constant gravitational acceleration applied to all alive particles.
640pub struct GravityForce {
641    /// Gravitational acceleration vector, e.g. `\[0.0, -9.81, 0.0\]`.
642    pub g: [f32; 3],
643}
644impl GravityForce {
645    /// Apply gravity: `v += g * dt` for every alive particle.
646    pub fn apply(&self, buffer: &mut ParticleBuffer, dt: f32) {
647        for i in 0..buffer.count {
648            if buffer.is_alive(i) {
649                buffer.velocities_x[i] += self.g[0] * dt;
650                buffer.velocities_y[i] += self.g[1] * dt;
651                buffer.velocities_z[i] += self.g[2] * dt;
652            }
653        }
654    }
655}
656/// Structure-of-Arrays particle buffer, optimised for GPU upload.
657pub struct ParticleBuffer {
658    /// X positions of each particle slot.
659    pub positions_x: Vec<f32>,
660    /// Y positions of each particle slot.
661    pub positions_y: Vec<f32>,
662    /// Z positions of each particle slot.
663    pub positions_z: Vec<f32>,
664    /// X velocities.
665    pub velocities_x: Vec<f32>,
666    /// Y velocities.
667    pub velocities_y: Vec<f32>,
668    /// Z velocities.
669    pub velocities_z: Vec<f32>,
670    /// Per-particle mass.
671    pub masses: Vec<f32>,
672    /// Remaining lifetime; negative means the slot is dead.
673    pub lifetimes: Vec<f32>,
674    /// Elapsed age since spawn.
675    pub ages: Vec<f32>,
676    /// Number of slots allocated (alive + dead).
677    pub count: usize,
678}
679impl ParticleBuffer {
680    /// Allocate a buffer with `capacity` slots (all dead initially).
681    pub fn new(capacity: usize) -> Self {
682        Self {
683            positions_x: vec![0.0; capacity],
684            positions_y: vec![0.0; capacity],
685            positions_z: vec![0.0; capacity],
686            velocities_x: vec![0.0; capacity],
687            velocities_y: vec![0.0; capacity],
688            velocities_z: vec![0.0; capacity],
689            masses: vec![1.0; capacity],
690            lifetimes: vec![-1.0; capacity],
691            ages: vec![0.0; capacity],
692            count: capacity,
693        }
694    }
695    /// Spawn a new particle in the first dead slot.  Returns its index or
696    /// `None` if the buffer is full.
697    pub fn add_particle(
698        &mut self,
699        pos: [f32; 3],
700        vel: [f32; 3],
701        mass: f32,
702        lifetime: f32,
703    ) -> Option<usize> {
704        for i in 0..self.count {
705            if self.lifetimes[i] < 0.0 {
706                self.positions_x[i] = pos[0];
707                self.positions_y[i] = pos[1];
708                self.positions_z[i] = pos[2];
709                self.velocities_x[i] = vel[0];
710                self.velocities_y[i] = vel[1];
711                self.velocities_z[i] = vel[2];
712                self.masses[i] = mass;
713                self.lifetimes[i] = lifetime;
714                self.ages[i] = 0.0;
715                return Some(i);
716            }
717        }
718        None
719    }
720    /// Get the position of slot `i` as `\[x, y, z\]`.
721    pub fn get_position(&self, i: usize) -> [f32; 3] {
722        [
723            self.positions_x[i],
724            self.positions_y[i],
725            self.positions_z[i],
726        ]
727    }
728    /// Get the velocity of slot `i` as `\[vx, vy, vz\]`.
729    pub fn get_velocity(&self, i: usize) -> [f32; 3] {
730        [
731            self.velocities_x[i],
732            self.velocities_y[i],
733            self.velocities_z[i],
734        ]
735    }
736    /// Set the position of slot `i`.
737    pub fn set_position(&mut self, i: usize, p: [f32; 3]) {
738        self.positions_x[i] = p[0];
739        self.positions_y[i] = p[1];
740        self.positions_z[i] = p[2];
741    }
742    /// Set the velocity of slot `i`.
743    pub fn set_velocity(&mut self, i: usize, v: [f32; 3]) {
744        self.velocities_x[i] = v[0];
745        self.velocities_y[i] = v[1];
746        self.velocities_z[i] = v[2];
747    }
748    /// Return `true` if the slot at `i` holds a live particle.
749    pub fn is_alive(&self, i: usize) -> bool {
750        self.lifetimes[i] >= 0.0
751    }
752    /// Kill the particle at slot `i` by setting its lifetime negative.
753    pub fn kill(&mut self, i: usize) {
754        self.lifetimes[i] = -1.0;
755    }
756    /// Count how many slots are currently alive.
757    pub fn active_count(&self) -> usize {
758        (0..self.count).filter(|&i| self.is_alive(i)).count()
759    }
760}
761/// Lifetime manager that also records spawn events for analysis.
762pub struct ParticleLifetimeManager {
763    /// Total particles ever spawned.
764    pub total_spawned: usize,
765    /// Total particles that have died naturally.
766    pub total_expired: usize,
767    /// Minimum observed lifetime seen.
768    pub min_observed_lifetime: f32,
769    /// Maximum observed lifetime seen.
770    pub max_observed_lifetime: f32,
771}
772impl ParticleLifetimeManager {
773    /// Create a new lifetime manager.
774    pub fn new() -> Self {
775        Self {
776            total_spawned: 0,
777            total_expired: 0,
778            min_observed_lifetime: f32::MAX,
779            max_observed_lifetime: 0.0,
780        }
781    }
782    /// Record a spawn event with initial lifetime.
783    pub fn record_spawn(&mut self, lifetime: f32) {
784        self.total_spawned += 1;
785        self.min_observed_lifetime = self.min_observed_lifetime.min(lifetime);
786        self.max_observed_lifetime = self.max_observed_lifetime.max(lifetime);
787    }
788    /// Record an expiration (natural death).
789    pub fn record_expiration(&mut self) {
790        self.total_expired += 1;
791    }
792    /// Scan a buffer and retire particles that have expired.
793    /// Returns the number retired.
794    pub fn retire_expired(&mut self, buffer: &mut ParticleBuffer) -> usize {
795        let mut count = 0;
796        for i in 0..buffer.count {
797            if buffer.lifetimes[i] < 0.0 && buffer.ages[i] > 0.0 {
798                let _ = i;
799            }
800            if buffer.is_alive(i) && buffer.lifetimes[i] < 0.0 {
801                count += 1;
802                self.record_expiration();
803            }
804        }
805        count
806    }
807    /// Alive fraction: alive / total_spawned.
808    pub fn alive_fraction(&self, buffer: &ParticleBuffer) -> f32 {
809        if self.total_spawned == 0 {
810            return 0.0;
811        }
812        buffer.active_count() as f32 / self.total_spawned as f32
813    }
814}
815/// Emits new particles into a `ParticleBuffer` over time.
816pub struct ParticleEmitter {
817    /// World-space spawn origin.
818    pub position: [f32; 3],
819    /// Target emission rate in particles per second.
820    pub emit_rate: f32,
821    /// Fractional particle accumulator (sub-frame carry).
822    pub emit_accumulator: f32,
823    /// Base initial velocity of emitted particles.
824    pub initial_velocity: [f32; 3],
825    /// Cone half-angle (radians) for random velocity spread.
826    pub velocity_spread: f32,
827    /// Minimum particle lifetime in seconds.
828    pub lifetime_min: f32,
829    /// Maximum particle lifetime in seconds.
830    pub lifetime_max: f32,
831    /// Mass assigned to emitted particles.
832    pub mass: f32,
833    /// Whether this emitter is currently active.
834    pub active: bool,
835}
836impl ParticleEmitter {
837    /// Create a new emitter.  `lifetime` is used as both min and max.
838    pub fn new(pos: [f32; 3], rate: f32, vel: [f32; 3], lifetime: f32) -> Self {
839        Self {
840            position: pos,
841            emit_rate: rate,
842            emit_accumulator: 0.0,
843            initial_velocity: vel,
844            velocity_spread: 0.0,
845            lifetime_min: lifetime,
846            lifetime_max: lifetime,
847            mass: 1.0,
848            active: true,
849        }
850    }
851    /// Emit particles for a time-step `dt`.  Returns how many were spawned.
852    pub fn emit(&mut self, buffer: &mut ParticleBuffer, dt: f32, rng_seed: u64) -> usize {
853        if !self.active {
854            return 0;
855        }
856        let mut rng = SimpleRng::new(rng_seed);
857        self.emit_accumulator += self.emit_rate * dt;
858        let to_emit = self.emit_accumulator.floor() as usize;
859        self.emit_accumulator -= to_emit as f32;
860        let mut spawned = 0usize;
861        for _ in 0..to_emit {
862            let spread_dir = rng.next_unit_sphere();
863            let vel = [
864                self.initial_velocity[0] + spread_dir[0] * self.velocity_spread,
865                self.initial_velocity[1] + spread_dir[1] * self.velocity_spread,
866                self.initial_velocity[2] + spread_dir[2] * self.velocity_spread,
867            ];
868            let lt = rng.next_f32_range(self.lifetime_min, self.lifetime_max);
869            if buffer
870                .add_particle(self.position, vel, self.mass, lt)
871                .is_some()
872            {
873                spawned += 1;
874            }
875        }
876        spawned
877    }
878}
879/// GPU particle emitter with configurable shape and mode.
880pub struct GpuParticleEmitter {
881    /// World-space spawn origin.
882    pub position: [f32; 3],
883    /// Emitter shape.
884    pub shape: EmitterShape,
885    /// Emission mode.
886    pub mode: EmissionMode,
887    /// Base initial velocity.
888    pub initial_velocity: [f32; 3],
889    /// Particle lifetime (seconds).
890    pub lifetime: f32,
891    /// Particle mass.
892    pub mass: f32,
893    /// Whether emitter is active.
894    pub active: bool,
895    /// Fractional accumulator for continuous mode.
896    pub accumulator: f32,
897    /// Internal RNG state.
898    pub(super) rng: SimpleRng,
899}
900impl GpuParticleEmitter {
901    /// Create a new emitter with a point shape and continuous mode.
902    pub fn new_continuous(position: [f32; 3], rate: f32, lifetime: f32) -> Self {
903        Self {
904            position,
905            shape: EmitterShape::Point,
906            mode: EmissionMode::Continuous { rate },
907            initial_velocity: [0.0, 1.0, 0.0],
908            lifetime,
909            mass: 1.0,
910            active: true,
911            accumulator: 0.0,
912            rng: SimpleRng::new(0xdeadbeef),
913        }
914    }
915    /// Create a burst emitter.
916    pub fn new_burst(position: [f32; 3], count: usize, lifetime: f32) -> Self {
917        Self {
918            position,
919            shape: EmitterShape::Point,
920            mode: EmissionMode::Burst { count },
921            initial_velocity: [0.0, 1.0, 0.0],
922            lifetime,
923            mass: 1.0,
924            active: true,
925            accumulator: 0.0,
926            rng: SimpleRng::new(0xcafebabe),
927        }
928    }
929    /// Emit particles into a buffer for one timestep `dt`.
930    pub fn emit(&mut self, buffer: &mut ParticleBuffer, dt: f32) -> usize {
931        if !self.active {
932            return 0;
933        }
934        let to_emit = match self.mode {
935            EmissionMode::Burst { count } => {
936                self.active = false;
937                count
938            }
939            EmissionMode::Continuous { rate } => {
940                self.accumulator += rate * dt;
941                let n = self.accumulator.floor() as usize;
942                self.accumulator -= n as f32;
943                n
944            }
945        };
946        let mut spawned = 0;
947        for _ in 0..to_emit {
948            let pos = self.sample_position();
949            let vel = self.initial_velocity;
950            if buffer
951                .add_particle(pos, vel, self.mass, self.lifetime)
952                .is_some()
953            {
954                spawned += 1;
955            }
956        }
957        spawned
958    }
959    /// Sample a spawn position according to the emitter shape.
960    fn sample_position(&mut self) -> [f32; 3] {
961        match self.shape {
962            EmitterShape::Point => self.position,
963            EmitterShape::Cone { half_angle } => {
964                let _ = half_angle;
965                self.position
966            }
967            EmitterShape::Sphere { radius } => {
968                let dir = self.rng.next_unit_sphere();
969                [
970                    self.position[0] + dir[0] * radius,
971                    self.position[1] + dir[1] * radius,
972                    self.position[2] + dir[2] * radius,
973                ]
974            }
975            EmitterShape::Box { half_extents } => {
976                let x = self.rng.next_f32_range(-half_extents[0], half_extents[0]);
977                let y = self.rng.next_f32_range(-half_extents[1], half_extents[1]);
978                let z = self.rng.next_f32_range(-half_extents[2], half_extents[2]);
979                [
980                    self.position[0] + x,
981                    self.position[1] + y,
982                    self.position[2] + z,
983                ]
984            }
985        }
986    }
987    /// Number of particles this emitter will emit in one burst (or 0 if continuous).
988    pub fn burst_count(&self) -> usize {
989        match self.mode {
990            EmissionMode::Burst { count } => count,
991            EmissionMode::Continuous { .. } => 0,
992        }
993    }
994}