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