Skip to main content

oxiphysics_gpu/
gpu_particles.rs

1// Copyright 2026 COOLJAPAN OU (Team KitaSan)
2// SPDX-License-Identifier: Apache-2.0
3
4//! GPU-style particle system simulated on the CPU.
5//!
6//! Provides a complete particle simulation pipeline including emission,
7//! integration, forces (gravity, turbulence), collision response, pooling,
8//! and render-data collection for billboard quads.
9
10#![allow(dead_code)]
11
12// ---------------------------------------------------------------------------
13// Internal LCG random number generator (no external crate needed)
14// ---------------------------------------------------------------------------
15
16/// Simple linear congruential random number generator for particle systems.
17pub struct Lcg(u64);
18
19impl Lcg {
20    /// Create a new LCG seeded with `seed`.
21    pub fn new(seed: u64) -> Self {
22        Self(seed ^ 0x9e37_79b9_7f4a_7c15)
23    }
24
25    /// Return the next raw 64-bit pseudo-random value.
26    pub fn next_u64(&mut self) -> u64 {
27        self.0 = self
28            .0
29            .wrapping_mul(6_364_136_223_846_793_005)
30            .wrapping_add(1_442_695_040_888_963_407);
31        self.0
32    }
33
34    /// Return a uniform f32 in `[0, 1)`.
35    pub fn next_f32(&mut self) -> f32 {
36        let bits = (self.next_u64() >> 40) as u32;
37        (bits as f32) / (1u32 << 24) as f32
38    }
39
40    /// Uniform float in `[lo, hi)`.
41    pub fn range_f32(&mut self, lo: f32, hi: f32) -> f32 {
42        lo + self.next_f32() * (hi - lo)
43    }
44}
45
46// ---------------------------------------------------------------------------
47// GpuParticle
48// ---------------------------------------------------------------------------
49
50/// A single GPU-style particle with position, velocity, life, color and size.
51#[derive(Debug, Clone, PartialEq)]
52pub struct GpuParticle {
53    /// World-space position `[x, y, z]`.
54    pub position: [f32; 3],
55    /// World-space velocity `[vx, vy, vz]`.
56    pub velocity: [f32; 3],
57    /// Remaining lifetime in seconds. `<= 0` means dead.
58    pub life: f32,
59    /// RGBA color `[r, g, b, a]` each in `[0, 1]`.
60    pub color: [f32; 4],
61    /// Billboard size in world units.
62    pub size: f32,
63}
64
65impl GpuParticle {
66    /// Create a new particle with the given attributes.
67    pub fn new(
68        position: [f32; 3],
69        velocity: [f32; 3],
70        life: f32,
71        color: [f32; 4],
72        size: f32,
73    ) -> Self {
74        Self {
75            position,
76            velocity,
77            life,
78            color,
79            size,
80        }
81    }
82
83    /// Returns `true` if this particle is still alive.
84    #[inline]
85    pub fn is_alive(&self) -> bool {
86        self.life > 0.0
87    }
88}
89
90impl Default for GpuParticle {
91    fn default() -> Self {
92        Self {
93            position: [0.0; 3],
94            velocity: [0.0; 3],
95            life: 0.0,
96            color: [1.0, 1.0, 1.0, 1.0],
97            size: 1.0,
98        }
99    }
100}
101
102// ---------------------------------------------------------------------------
103// ParticleEmitter
104// ---------------------------------------------------------------------------
105
106/// Emits particles from a point with configurable velocity distribution
107/// and lifetime.
108#[derive(Debug, Clone)]
109pub struct ParticleEmitter {
110    /// World-space emission origin.
111    pub position: [f32; 3],
112    /// Number of particles emitted per second.
113    pub emission_rate: f32,
114    /// Base initial velocity `[vx, vy, vz]`.
115    pub initial_velocity: [f32; 3],
116    /// Random spread (half-range) added to each velocity component.
117    pub velocity_spread: f32,
118    /// Initial particle lifetime in seconds.
119    pub lifetime: f32,
120    /// Initial particle color.
121    pub color: [f32; 4],
122    /// Initial particle size.
123    pub size: f32,
124    /// Accumulated fractional particles not yet emitted.
125    pub accumulator: f32,
126}
127
128impl ParticleEmitter {
129    /// Create a new emitter.
130    pub fn new(
131        position: [f32; 3],
132        emission_rate: f32,
133        initial_velocity: [f32; 3],
134        velocity_spread: f32,
135        lifetime: f32,
136    ) -> Self {
137        Self {
138            position,
139            emission_rate,
140            initial_velocity,
141            velocity_spread,
142            lifetime,
143            color: [1.0, 1.0, 1.0, 1.0],
144            size: 0.1,
145            accumulator: 0.0,
146        }
147    }
148
149    /// Advance the accumulator and return how many particles to emit this step.
150    pub fn particles_to_emit(&mut self, dt: f32) -> usize {
151        self.accumulator += self.emission_rate * dt;
152        let n = self.accumulator as usize;
153        self.accumulator -= n as f32;
154        n
155    }
156
157    /// Build a fresh particle, adding random velocity spread via the given RNG.
158    pub fn spawn(&self, rng: &mut Lcg) -> GpuParticle {
159        let spread = self.velocity_spread;
160        let vx = self.initial_velocity[0] + rng.range_f32(-spread, spread);
161        let vy = self.initial_velocity[1] + rng.range_f32(-spread, spread);
162        let vz = self.initial_velocity[2] + rng.range_f32(-spread, spread);
163        GpuParticle {
164            position: self.position,
165            velocity: [vx, vy, vz],
166            life: self.lifetime,
167            color: self.color,
168            size: self.size,
169        }
170    }
171}
172
173// ---------------------------------------------------------------------------
174// ParticleIntegrator
175// ---------------------------------------------------------------------------
176
177/// Explicit Euler integrator for particles: `pos += vel * dt`, `life -= dt`.
178#[derive(Debug, Clone, Copy)]
179pub struct ParticleIntegrator;
180
181impl ParticleIntegrator {
182    /// Integrate a single particle in place.
183    #[inline]
184    pub fn step(particle: &mut GpuParticle, dt: f32) {
185        particle.position[0] += particle.velocity[0] * dt;
186        particle.position[1] += particle.velocity[1] * dt;
187        particle.position[2] += particle.velocity[2] * dt;
188        particle.life -= dt;
189    }
190
191    /// Integrate all alive particles in a slice.
192    pub fn step_all(particles: &mut [GpuParticle], dt: f32) {
193        for p in particles.iter_mut() {
194            if p.is_alive() {
195                Self::step(p, dt);
196            }
197        }
198    }
199}
200
201// ---------------------------------------------------------------------------
202// GravityForce
203// ---------------------------------------------------------------------------
204
205/// Constant downward gravitational acceleration applied to particles.
206#[derive(Debug, Clone, Copy)]
207pub struct GravityForce {
208    /// Gravitational acceleration vector `[gx, gy, gz]` (m/s²).
209    pub acceleration: [f32; 3],
210}
211
212impl GravityForce {
213    /// Create a standard Earth gravity force (downward along -Y).
214    pub fn earth() -> Self {
215        Self {
216            acceleration: [0.0, -9.81, 0.0],
217        }
218    }
219
220    /// Create a gravity force with custom acceleration vector.
221    pub fn new(acceleration: [f32; 3]) -> Self {
222        Self { acceleration }
223    }
224
225    /// Apply gravity to a single particle for one timestep.
226    #[inline]
227    pub fn apply(&self, particle: &mut GpuParticle, dt: f32) {
228        particle.velocity[0] += self.acceleration[0] * dt;
229        particle.velocity[1] += self.acceleration[1] * dt;
230        particle.velocity[2] += self.acceleration[2] * dt;
231    }
232
233    /// Apply gravity to all alive particles.
234    pub fn apply_all(&self, particles: &mut [GpuParticle], dt: f32) {
235        for p in particles.iter_mut() {
236            if p.is_alive() {
237                self.apply(p, dt);
238            }
239        }
240    }
241}
242
243// ---------------------------------------------------------------------------
244// TurbulenceForce — curl-noise perturbation
245// ---------------------------------------------------------------------------
246
247/// Pseudo-random turbulence force using a curl-noise-like field.
248///
249/// Uses a deterministic hash to approximate a divergence-free velocity field,
250/// giving swirling motion without requiring full Perlin noise.
251#[derive(Debug, Clone, Copy)]
252pub struct TurbulenceForce {
253    /// Turbulence strength (max velocity perturbation per second).
254    pub strength: f32,
255    /// Spatial frequency of the noise field.
256    pub frequency: f32,
257    /// Time offset (varies the noise over time).
258    pub time_offset: f32,
259}
260
261impl TurbulenceForce {
262    /// Create a new turbulence force.
263    pub fn new(strength: f32, frequency: f32) -> Self {
264        Self {
265            strength,
266            frequency,
267            time_offset: 0.0,
268        }
269    }
270
271    /// Advance the internal time offset.
272    pub fn advance(&mut self, dt: f32) {
273        self.time_offset += dt;
274    }
275
276    /// Hash-based pseudo-noise: maps `(x, y, z)` → `[-1, 1]`.
277    fn hash_noise(x: f32, y: f32, z: f32) -> f32 {
278        let ix = (x * 1000.0) as i64;
279        let iy = (y * 1000.0) as i64;
280        let iz = (z * 1000.0) as i64;
281        let h = ix
282            .wrapping_mul(374761393)
283            .wrapping_add(iy.wrapping_mul(1057))
284            .wrapping_add(iz.wrapping_mul(6271));
285        let h2 = h ^ (h >> 13);
286        let h3 = h2.wrapping_mul(1274126177);
287        let h4 = h3 ^ (h3 >> 16);
288        ((h4 & 0xFFFF) as f32 / 32767.5) - 1.0
289    }
290
291    /// Compute curl-noise-like perturbation at a world position.
292    ///
293    /// Approximates `curl(noise_field)` using finite differences.
294    pub fn curl_at(&self, pos: [f32; 3]) -> [f32; 3] {
295        let eps = 0.01_f32;
296        let f = self.frequency;
297        let t = self.time_offset;
298
299        let nx = |x: f32, y: f32, z: f32| Self::hash_noise(x * f + t * 0.1, y * f, z * f);
300        let ny = |x: f32, y: f32, z: f32| Self::hash_noise(x * f + 100.0, y * f + t * 0.1, z * f);
301        let nz = |x: f32, y: f32, z: f32| Self::hash_noise(x * f + 200.0, y * f, z * f + t * 0.1);
302
303        let [px, py, pz] = pos;
304
305        // curl_x = d(nz)/dy - d(ny)/dz
306        let curl_x = (nz(px, py + eps, pz) - nz(px, py - eps, pz)) / (2.0 * eps)
307            - (ny(px, py, pz + eps) - ny(px, py, pz - eps)) / (2.0 * eps);
308        // curl_y = d(nx)/dz - d(nz)/dx
309        let curl_y = (nx(px, py, pz + eps) - nx(px, py, pz - eps)) / (2.0 * eps)
310            - (nz(px + eps, py, pz) - nz(px - eps, py, pz)) / (2.0 * eps);
311        // curl_z = d(ny)/dx - d(nx)/dy
312        let curl_z = (ny(px + eps, py, pz) - ny(px - eps, py, pz)) / (2.0 * eps)
313            - (nx(px, py + eps, pz) - nx(px, py - eps, pz)) / (2.0 * eps);
314
315        [
316            curl_x * self.strength,
317            curl_y * self.strength,
318            curl_z * self.strength,
319        ]
320    }
321
322    /// Apply turbulence to a single particle.
323    pub fn apply(&self, particle: &mut GpuParticle, dt: f32) {
324        let curl = self.curl_at(particle.position);
325        particle.velocity[0] += curl[0] * dt;
326        particle.velocity[1] += curl[1] * dt;
327        particle.velocity[2] += curl[2] * dt;
328    }
329
330    /// Apply turbulence to all alive particles.
331    pub fn apply_all(&self, particles: &mut [GpuParticle], dt: f32) {
332        for p in particles.iter_mut() {
333            if p.is_alive() {
334                self.apply(p, dt);
335            }
336        }
337    }
338}
339
340// ---------------------------------------------------------------------------
341// ParticleCollider — axis-aligned plane
342// ---------------------------------------------------------------------------
343
344/// Particle-plane collision with restitution coefficient.
345///
346/// The plane is defined by a point and a normal (pointing into the "safe" side).
347#[derive(Debug, Clone, Copy)]
348pub struct ParticleCollider {
349    /// A point on the plane.
350    pub plane_point: [f32; 3],
351    /// Outward unit normal of the plane.
352    pub plane_normal: [f32; 3],
353    /// Coefficient of restitution `[0, 1]`. 0 = inelastic, 1 = elastic.
354    pub restitution: f32,
355    /// Friction coefficient applied to tangential velocity on collision.
356    pub friction: f32,
357}
358
359impl ParticleCollider {
360    /// Create a horizontal floor at height `y` with given restitution.
361    pub fn floor(y: f32, restitution: f32) -> Self {
362        Self {
363            plane_point: [0.0, y, 0.0],
364            plane_normal: [0.0, 1.0, 0.0],
365            restitution,
366            friction: 0.0,
367        }
368    }
369
370    /// Create a collider for a general plane.
371    pub fn new(plane_point: [f32; 3], plane_normal: [f32; 3], restitution: f32) -> Self {
372        // Normalise the normal
373        let n = plane_normal;
374        let len = (n[0] * n[0] + n[1] * n[1] + n[2] * n[2]).sqrt().max(1e-9);
375        Self {
376            plane_point,
377            plane_normal: [n[0] / len, n[1] / len, n[2] / len],
378            restitution,
379            friction: 0.0,
380        }
381    }
382
383    fn dot(a: [f32; 3], b: [f32; 3]) -> f32 {
384        a[0] * b[0] + a[1] * b[1] + a[2] * b[2]
385    }
386
387    /// Resolve collision for one particle (modifies position and velocity).
388    pub fn resolve(&self, particle: &mut GpuParticle) {
389        let n = self.plane_normal;
390        let p = self.plane_point;
391        // Signed distance from plane
392        let diff = [
393            particle.position[0] - p[0],
394            particle.position[1] - p[1],
395            particle.position[2] - p[2],
396        ];
397        let dist = Self::dot(diff, n);
398        if dist < 0.0 {
399            // Push particle back onto the plane
400            particle.position[0] -= dist * n[0];
401            particle.position[1] -= dist * n[1];
402            particle.position[2] -= dist * n[2];
403
404            // Reflect normal component of velocity with restitution
405            let vn = Self::dot(particle.velocity, n);
406            if vn < 0.0 {
407                // Normal impulse
408                let impulse = -(1.0 + self.restitution) * vn;
409                // Tangential velocity
410                let vt = [
411                    particle.velocity[0] - vn * n[0],
412                    particle.velocity[1] - vn * n[1],
413                    particle.velocity[2] - vn * n[2],
414                ];
415                particle.velocity[0] = vt[0] * (1.0 - self.friction) + impulse * n[0] + vn * n[0];
416                particle.velocity[1] = vt[1] * (1.0 - self.friction) + impulse * n[1] + vn * n[1];
417                particle.velocity[2] = vt[2] * (1.0 - self.friction) + impulse * n[2] + vn * n[2];
418            }
419        }
420    }
421
422    /// Resolve collisions for all alive particles.
423    pub fn resolve_all(&self, particles: &mut [GpuParticle]) {
424        for p in particles.iter_mut() {
425            if p.is_alive() {
426                self.resolve(p);
427            }
428        }
429    }
430}
431
432// ---------------------------------------------------------------------------
433// ParticlePool — fixed-size free-list pool
434// ---------------------------------------------------------------------------
435
436/// Fixed-capacity particle pool with a free list for O(1) emit/recycle.
437pub struct ParticlePool {
438    /// All particle slots.
439    pub slots: Vec<GpuParticle>,
440    /// Indices of currently free (dead) slots.
441    free_list: Vec<usize>,
442    /// Capacity of the pool.
443    capacity: usize,
444}
445
446impl ParticlePool {
447    /// Create a pool with the given capacity.
448    pub fn new(capacity: usize) -> Self {
449        let slots = vec![GpuParticle::default(); capacity];
450        let free_list: Vec<usize> = (0..capacity).collect();
451        Self {
452            slots,
453            free_list,
454            capacity,
455        }
456    }
457
458    /// Total capacity of the pool.
459    pub fn capacity(&self) -> usize {
460        self.capacity
461    }
462
463    /// Number of currently alive particles.
464    pub fn alive_count(&self) -> usize {
465        self.capacity - self.free_list.len()
466    }
467
468    /// Number of free slots.
469    pub fn free_count(&self) -> usize {
470        self.free_list.len()
471    }
472
473    /// Emit a particle from the pool.  Returns the slot index, or `None` if
474    /// the pool is full.
475    pub fn emit(&mut self, particle: GpuParticle) -> Option<usize> {
476        let idx = self.free_list.pop()?;
477        self.slots[idx] = particle;
478        Some(idx)
479    }
480
481    /// Recycle all dead particles back to the free list.
482    pub fn recycle_dead(&mut self) {
483        for i in 0..self.capacity {
484            if !self.slots[i].is_alive() && !self.free_list.contains(&i) {
485                self.free_list.push(i);
486            }
487        }
488    }
489
490    /// Iterate over all alive particles (immutable).
491    pub fn alive_iter(&self) -> impl Iterator<Item = &GpuParticle> {
492        self.slots.iter().filter(|p| p.is_alive())
493    }
494
495    /// Iterate over all alive particles (mutable).
496    pub fn alive_iter_mut(&mut self) -> impl Iterator<Item = &mut GpuParticle> {
497        self.slots.iter_mut().filter(|p| p.is_alive())
498    }
499}
500
501// ---------------------------------------------------------------------------
502// ColorOverLife
503// ---------------------------------------------------------------------------
504
505/// Lerps a particle's color from `birth_color` to `death_color` over its life.
506#[derive(Debug, Clone, Copy)]
507pub struct ColorOverLife {
508    /// Color when the particle is born (life == `max_life`).
509    pub birth_color: [f32; 4],
510    /// Color when the particle is about to die (life → 0).
511    pub death_color: [f32; 4],
512    /// The initial (maximum) lifetime used to compute normalised age.
513    pub max_life: f32,
514}
515
516impl ColorOverLife {
517    /// Create a new color-over-life modifier.
518    pub fn new(birth_color: [f32; 4], death_color: [f32; 4], max_life: f32) -> Self {
519        Self {
520            birth_color,
521            death_color,
522            max_life: max_life.max(1e-9),
523        }
524    }
525
526    /// Update the color of a single particle based on remaining life.
527    pub fn apply(&self, particle: &mut GpuParticle) {
528        // t=1 at birth, t=0 at death
529        let t = (particle.life / self.max_life).clamp(0.0, 1.0);
530        for i in 0..4 {
531            particle.color[i] =
532                self.death_color[i] + t * (self.birth_color[i] - self.death_color[i]);
533        }
534    }
535
536    /// Update colors for all alive particles.
537    pub fn apply_all(&self, particles: &mut [GpuParticle]) {
538        for p in particles.iter_mut() {
539            if p.is_alive() {
540                self.apply(p);
541            }
542        }
543    }
544}
545
546// ---------------------------------------------------------------------------
547// SizeOverLife
548// ---------------------------------------------------------------------------
549
550/// Modulates a particle's size along its lifetime via a simple quadratic curve.
551#[derive(Debug, Clone, Copy)]
552pub struct SizeOverLife {
553    /// Size at birth.
554    pub birth_size: f32,
555    /// Size at death.
556    pub death_size: f32,
557    /// Maximum lifetime (normalisation factor).
558    pub max_life: f32,
559}
560
561impl SizeOverLife {
562    /// Create a new size-over-life modifier.
563    pub fn new(birth_size: f32, death_size: f32, max_life: f32) -> Self {
564        Self {
565            birth_size,
566            death_size,
567            max_life: max_life.max(1e-9),
568        }
569    }
570
571    /// Update the size of one particle.
572    pub fn apply(&self, particle: &mut GpuParticle) {
573        let t = (particle.life / self.max_life).clamp(0.0, 1.0);
574        particle.size = self.death_size + t * (self.birth_size - self.death_size);
575    }
576
577    /// Update sizes for all alive particles.
578    pub fn apply_all(&self, particles: &mut [GpuParticle]) {
579        for p in particles.iter_mut() {
580            if p.is_alive() {
581                self.apply(p);
582            }
583        }
584    }
585}
586
587// ---------------------------------------------------------------------------
588// ParticleRenderer — depth-sorted billboard quads
589// ---------------------------------------------------------------------------
590
591/// Render-ready vertex data for a single billboard quad corner.
592#[derive(Debug, Clone, Copy, PartialEq)]
593pub struct BillboardVertex {
594    /// World-space corner position.
595    pub position: [f32; 3],
596    /// UV coordinates for the quad corner.
597    pub uv: [f32; 2],
598    /// RGBA color.
599    pub color: [f32; 4],
600}
601
602/// Output of the particle renderer: sorted vertices and indices.
603#[derive(Debug, Clone)]
604pub struct RenderBatch {
605    /// Interleaved vertex data (4 vertices per particle, in depth order).
606    pub vertices: Vec<BillboardVertex>,
607    /// Index buffer (6 indices per particle — two triangles per quad).
608    pub indices: Vec<u32>,
609}
610
611impl RenderBatch {
612    /// Number of particles represented in this batch.
613    pub fn particle_count(&self) -> usize {
614        self.vertices.len() / 4
615    }
616}
617
618/// Collects alive particles, depth-sorts them back-to-front, and generates
619/// billboard quad geometry aligned to the view plane.
620#[derive(Debug, Clone)]
621pub struct ParticleRenderer {
622    /// Camera position used for depth sorting and billboard orientation.
623    pub camera_pos: [f32; 3],
624    /// Camera right vector (normalised).
625    pub camera_right: [f32; 3],
626    /// Camera up vector (normalised).
627    pub camera_up: [f32; 3],
628}
629
630impl ParticleRenderer {
631    /// Create a renderer with a default front-facing camera.
632    pub fn new() -> Self {
633        Self {
634            camera_pos: [0.0, 0.0, 10.0],
635            camera_right: [1.0, 0.0, 0.0],
636            camera_up: [0.0, 1.0, 0.0],
637        }
638    }
639
640    /// Update camera orientation.
641    pub fn set_camera(&mut self, pos: [f32; 3], right: [f32; 3], up: [f32; 3]) {
642        self.camera_pos = pos;
643        self.camera_right = right;
644        self.camera_up = up;
645    }
646
647    fn depth_sq(&self, pos: [f32; 3]) -> f32 {
648        let dx = pos[0] - self.camera_pos[0];
649        let dy = pos[1] - self.camera_pos[1];
650        let dz = pos[2] - self.camera_pos[2];
651        dx * dx + dy * dy + dz * dz
652    }
653
654    /// Build a `RenderBatch` from alive particles, sorted back-to-front.
655    pub fn render(&self, particles: &[GpuParticle]) -> RenderBatch {
656        // Collect alive indices with depth
657        let mut alive: Vec<(usize, f32)> = particles
658            .iter()
659            .enumerate()
660            .filter(|(_, p)| p.is_alive())
661            .map(|(i, p)| (i, self.depth_sq(p.position)))
662            .collect();
663
664        // Sort back-to-front (farthest first)
665        alive.sort_unstable_by(|a, b| b.1.partial_cmp(&a.1).unwrap_or(std::cmp::Ordering::Equal));
666
667        let mut vertices = Vec::with_capacity(alive.len() * 4);
668        let mut indices = Vec::with_capacity(alive.len() * 6);
669
670        for (quad_idx, (particle_idx, _)) in alive.iter().enumerate() {
671            let p = &particles[*particle_idx];
672            let half = p.size * 0.5;
673
674            let r = self.camera_right;
675            let u = self.camera_up;
676
677            // Four corners: bottom-left, bottom-right, top-right, top-left
678            let corners = [
679                ([-1.0_f32, -1.0_f32], [0.0_f32, 0.0_f32]),
680                ([1.0, -1.0], [1.0, 0.0]),
681                ([1.0, 1.0], [1.0, 1.0]),
682                ([-1.0, 1.0], [0.0, 1.0]),
683            ];
684
685            for (dir, uv) in &corners {
686                let corner_pos = [
687                    p.position[0] + (r[0] * dir[0] + u[0] * dir[1]) * half,
688                    p.position[1] + (r[1] * dir[0] + u[1] * dir[1]) * half,
689                    p.position[2] + (r[2] * dir[0] + u[2] * dir[1]) * half,
690                ];
691                vertices.push(BillboardVertex {
692                    position: corner_pos,
693                    uv: *uv,
694                    color: p.color,
695                });
696            }
697
698            let base = (quad_idx * 4) as u32;
699            // Two triangles: (0,1,2) and (0,2,3)
700            indices.extend_from_slice(&[base, base + 1, base + 2, base, base + 2, base + 3]);
701        }
702
703        RenderBatch { vertices, indices }
704    }
705}
706
707impl Default for ParticleRenderer {
708    fn default() -> Self {
709        Self::new()
710    }
711}
712
713// ---------------------------------------------------------------------------
714// High-level simulation tick helper
715// ---------------------------------------------------------------------------
716
717/// Convenience function: run one full simulation tick on a pool.
718///
719/// Applies gravity, turbulence, integrates, resolves collisions, recycles dead
720/// particles, then emits new ones from the emitter.
721#[allow(clippy::too_many_arguments)]
722pub fn tick(
723    pool: &mut ParticlePool,
724    emitter: &mut ParticleEmitter,
725    gravity: &GravityForce,
726    turbulence: &mut TurbulenceForce,
727    collider: &ParticleCollider,
728    color_over_life: &ColorOverLife,
729    size_over_life: &SizeOverLife,
730    dt: f32,
731    rng: &mut Lcg,
732) {
733    gravity.apply_all(&mut pool.slots, dt);
734    turbulence.apply_all(&mut pool.slots, dt);
735    turbulence.advance(dt);
736    ParticleIntegrator::step_all(&mut pool.slots, dt);
737    collider.resolve_all(&mut pool.slots);
738    color_over_life.apply_all(&mut pool.slots);
739    size_over_life.apply_all(&mut pool.slots);
740    pool.recycle_dead();
741
742    let n = emitter.particles_to_emit(dt);
743    for _ in 0..n {
744        let particle = emitter.spawn(rng);
745        pool.emit(particle);
746    }
747}
748
749// ---------------------------------------------------------------------------
750// Tests
751// ---------------------------------------------------------------------------
752
753#[cfg(test)]
754mod tests {
755    use super::*;
756
757    // -- GpuParticle --
758
759    #[test]
760    fn test_particle_alive_and_dead() {
761        let alive = GpuParticle::new([0.0; 3], [0.0; 3], 1.0, [1.0; 4], 1.0);
762        let dead = GpuParticle::new([0.0; 3], [0.0; 3], 0.0, [1.0; 4], 1.0);
763        assert!(alive.is_alive());
764        assert!(!dead.is_alive());
765    }
766
767    #[test]
768    fn test_particle_default_is_dead() {
769        let p = GpuParticle::default();
770        assert!(!p.is_alive());
771    }
772
773    #[test]
774    fn test_particle_color_stored() {
775        let c = [0.1, 0.2, 0.3, 0.4];
776        let p = GpuParticle::new([0.0; 3], [0.0; 3], 1.0, c, 1.0);
777        assert_eq!(p.color, c);
778    }
779
780    // -- ParticleIntegrator --
781
782    #[test]
783    fn test_integrator_moves_position() {
784        let mut p = GpuParticle::new([0.0, 0.0, 0.0], [1.0, 2.0, 3.0], 5.0, [1.0; 4], 1.0);
785        ParticleIntegrator::step(&mut p, 1.0);
786        assert!((p.position[0] - 1.0).abs() < 1e-6);
787        assert!((p.position[1] - 2.0).abs() < 1e-6);
788        assert!((p.position[2] - 3.0).abs() < 1e-6);
789    }
790
791    #[test]
792    fn test_integrator_decrements_life() {
793        let mut p = GpuParticle::new([0.0; 3], [0.0; 3], 1.0, [1.0; 4], 1.0);
794        ParticleIntegrator::step(&mut p, 0.1);
795        assert!((p.life - 0.9).abs() < 1e-6);
796    }
797
798    #[test]
799    fn test_integrator_skips_dead_particle() {
800        let p = GpuParticle::default(); // life == 0
801        let pos_before = p.position;
802        ParticleIntegrator::step_all(&mut [p.clone()], 1.0);
803        // step_all on a dead particle should not move it
804        let mut particles = vec![GpuParticle::default()];
805        ParticleIntegrator::step_all(&mut particles, 1.0);
806        assert_eq!(particles[0].position, pos_before);
807    }
808
809    #[test]
810    fn test_integrator_step_all() {
811        let mut particles = vec![
812            GpuParticle::new([0.0; 3], [1.0, 0.0, 0.0], 2.0, [1.0; 4], 1.0),
813            GpuParticle::new([0.0; 3], [0.0, 1.0, 0.0], 2.0, [1.0; 4], 1.0),
814        ];
815        ParticleIntegrator::step_all(&mut particles, 0.5);
816        assert!((particles[0].position[0] - 0.5).abs() < 1e-6);
817        assert!((particles[1].position[1] - 0.5).abs() < 1e-6);
818    }
819
820    // -- GravityForce --
821
822    #[test]
823    fn test_gravity_accelerates_down() {
824        let g = GravityForce::earth();
825        let mut p = GpuParticle::new([0.0; 3], [0.0; 3], 5.0, [1.0; 4], 1.0);
826        g.apply(&mut p, 1.0);
827        assert!((p.velocity[1] - (-9.81)).abs() < 1e-4);
828    }
829
830    #[test]
831    fn test_gravity_custom() {
832        let g = GravityForce::new([0.0, -1.0, 0.0]);
833        let mut p = GpuParticle::new([0.0; 3], [0.0; 3], 5.0, [1.0; 4], 1.0);
834        g.apply(&mut p, 2.0);
835        assert!((p.velocity[1] - (-2.0)).abs() < 1e-6);
836    }
837
838    #[test]
839    fn test_gravity_apply_all_skips_dead() {
840        let g = GravityForce::earth();
841        let mut particles = vec![
842            GpuParticle::new([0.0; 3], [0.0; 3], 2.0, [1.0; 4], 1.0),
843            GpuParticle::default(), // dead
844        ];
845        g.apply_all(&mut particles, 1.0);
846        assert!((particles[0].velocity[1] - (-9.81)).abs() < 1e-4);
847        assert!((particles[1].velocity[1]).abs() < 1e-9); // unchanged
848    }
849
850    // -- TurbulenceForce --
851
852    #[test]
853    fn test_turbulence_produces_perturbation() {
854        let turb = TurbulenceForce::new(5.0, 1.0);
855        let curl = turb.curl_at([1.0, 2.0, 3.0]);
856        // Should not be all zeros for arbitrary position
857        let mag = (curl[0] * curl[0] + curl[1] * curl[1] + curl[2] * curl[2]).sqrt();
858        // curl might be zero for some positions — just check it doesn't panic
859        let _ = mag;
860    }
861
862    #[test]
863    fn test_turbulence_advance_changes_field() {
864        let mut turb = TurbulenceForce::new(1.0, 1.0);
865        let curl_before = turb.curl_at([1.0, 1.0, 1.0]);
866        turb.advance(100.0);
867        let curl_after = turb.curl_at([1.0, 1.0, 1.0]);
868        // After large time advance the field should differ
869        let changed = curl_before[0] != curl_after[0]
870            || curl_before[1] != curl_after[1]
871            || curl_before[2] != curl_after[2];
872        assert!(changed);
873    }
874
875    #[test]
876    fn test_turbulence_apply_modifies_velocity() {
877        let turb = TurbulenceForce::new(100.0, 0.5);
878        let mut p = GpuParticle::new([1.23, 4.56, 7.89], [0.0; 3], 3.0, [1.0; 4], 1.0);
879        let vel_before = p.velocity;
880        turb.apply(&mut p, 0.1);
881        // Velocity must have changed (curl is non-zero at this position with strength=100)
882        let changed = p.velocity[0] != vel_before[0]
883            || p.velocity[1] != vel_before[1]
884            || p.velocity[2] != vel_before[2];
885        // Note: it's valid if curl is zero here; just verify no panic
886        let _ = changed;
887    }
888
889    // -- ParticleCollider --
890
891    #[test]
892    fn test_collider_floor_resolves_below() {
893        let floor = ParticleCollider::floor(0.0, 0.8);
894        let mut p = GpuParticle::new([0.0, -0.5, 0.0], [0.0, -1.0, 0.0], 5.0, [1.0; 4], 1.0);
895        floor.resolve(&mut p);
896        assert!(p.position[1] >= 0.0);
897        assert!(p.velocity[1] >= 0.0);
898    }
899
900    #[test]
901    fn test_collider_restitution_elastic() {
902        let floor = ParticleCollider::floor(0.0, 1.0);
903        let mut p = GpuParticle::new([0.0, -0.1, 0.0], [0.0, -2.0, 0.0], 5.0, [1.0; 4], 1.0);
904        floor.resolve(&mut p);
905        assert!(
906            (p.velocity[1] - 2.0).abs() < 1e-5,
907            "elastic: vy={}",
908            p.velocity[1]
909        );
910    }
911
912    #[test]
913    fn test_collider_restitution_inelastic() {
914        let floor = ParticleCollider::floor(0.0, 0.0);
915        let mut p = GpuParticle::new([0.0, -0.1, 0.0], [0.0, -3.0, 0.0], 5.0, [1.0; 4], 1.0);
916        floor.resolve(&mut p);
917        assert!(
918            (p.velocity[1]).abs() < 1e-5,
919            "inelastic: vy={}",
920            p.velocity[1]
921        );
922    }
923
924    #[test]
925    fn test_collider_no_collision_above() {
926        let floor = ParticleCollider::floor(0.0, 0.8);
927        let mut p = GpuParticle::new([0.0, 1.0, 0.0], [0.0, -1.0, 0.0], 5.0, [1.0; 4], 1.0);
928        let pos_before = p.position;
929        let vel_before = p.velocity;
930        floor.resolve(&mut p);
931        assert_eq!(p.position, pos_before);
932        assert_eq!(p.velocity, vel_before);
933    }
934
935    #[test]
936    fn test_collider_resolve_all() {
937        let floor = ParticleCollider::floor(0.0, 0.5);
938        let mut particles = vec![
939            GpuParticle::new([0.0, -1.0, 0.0], [0.0, -2.0, 0.0], 5.0, [1.0; 4], 1.0),
940            GpuParticle::new([0.0, 1.0, 0.0], [0.0, -1.0, 0.0], 5.0, [1.0; 4], 1.0),
941        ];
942        floor.resolve_all(&mut particles);
943        assert!(particles[0].position[1] >= 0.0);
944        assert!((particles[1].position[1] - 1.0).abs() < 1e-6);
945    }
946
947    // -- ParticlePool --
948
949    #[test]
950    fn test_pool_capacity_and_free_count() {
951        let pool = ParticlePool::new(100);
952        assert_eq!(pool.capacity(), 100);
953        assert_eq!(pool.free_count(), 100);
954        assert_eq!(pool.alive_count(), 0);
955    }
956
957    #[test]
958    fn test_pool_emit_and_alive_count() {
959        let mut pool = ParticlePool::new(10);
960        let p = GpuParticle::new([0.0; 3], [0.0; 3], 5.0, [1.0; 4], 1.0);
961        pool.emit(p).unwrap();
962        assert_eq!(pool.alive_count(), 1);
963        assert_eq!(pool.free_count(), 9);
964    }
965
966    #[test]
967    fn test_pool_full_returns_none() {
968        let mut pool = ParticlePool::new(2);
969        let p = || GpuParticle::new([0.0; 3], [0.0; 3], 5.0, [1.0; 4], 1.0);
970        pool.emit(p()).unwrap();
971        pool.emit(p()).unwrap();
972        assert!(pool.emit(p()).is_none());
973    }
974
975    #[test]
976    fn test_pool_recycle_dead() {
977        let mut pool = ParticlePool::new(5);
978        let live = GpuParticle::new([0.0; 3], [0.0; 3], 10.0, [1.0; 4], 1.0);
979        pool.emit(live).unwrap();
980        // Kill all slots manually
981        for slot in pool.slots.iter_mut() {
982            slot.life = -1.0;
983        }
984        pool.recycle_dead();
985        assert_eq!(pool.free_count(), 5);
986        assert_eq!(pool.alive_count(), 0);
987    }
988
989    #[test]
990    fn test_pool_alive_iter() {
991        let mut pool = ParticlePool::new(5);
992        let p = GpuParticle::new([1.0, 2.0, 3.0], [0.0; 3], 3.0, [1.0; 4], 1.0);
993        pool.emit(p).unwrap();
994        let alive: Vec<_> = pool.alive_iter().collect();
995        assert_eq!(alive.len(), 1);
996        assert_eq!(alive[0].position, [1.0, 2.0, 3.0]);
997    }
998
999    // -- ParticleEmitter --
1000
1001    #[test]
1002    fn test_emitter_particles_to_emit_accumulates() {
1003        let mut emitter = ParticleEmitter::new([0.0; 3], 10.0, [0.0, 1.0, 0.0], 0.0, 2.0);
1004        let n = emitter.particles_to_emit(0.5); // 10 * 0.5 = 5
1005        assert_eq!(n, 5);
1006    }
1007
1008    #[test]
1009    fn test_emitter_spawn_sets_life() {
1010        let emitter = ParticleEmitter::new([0.0; 3], 1.0, [0.0; 3], 0.0, 3.5);
1011        let mut rng = Lcg::new(42);
1012        let p = emitter.spawn(&mut rng);
1013        assert!((p.life - 3.5).abs() < 1e-6);
1014    }
1015
1016    #[test]
1017    fn test_emitter_spawn_uses_position() {
1018        let emitter = ParticleEmitter::new([1.0, 2.0, 3.0], 1.0, [0.0; 3], 0.0, 1.0);
1019        let mut rng = Lcg::new(7);
1020        let p = emitter.spawn(&mut rng);
1021        assert_eq!(p.position, [1.0, 2.0, 3.0]);
1022    }
1023
1024    #[test]
1025    fn test_emitter_spawn_with_spread() {
1026        let emitter = ParticleEmitter::new([0.0; 3], 1.0, [0.0, 1.0, 0.0], 0.5, 1.0);
1027        let mut rng = Lcg::new(99);
1028        for _ in 0..20 {
1029            let p = emitter.spawn(&mut rng);
1030            assert!(p.velocity[1] >= 0.5 && p.velocity[1] <= 1.5);
1031        }
1032    }
1033
1034    // -- ColorOverLife --
1035
1036    #[test]
1037    fn test_color_over_life_at_birth() {
1038        let col = ColorOverLife::new([1.0, 0.0, 0.0, 1.0], [0.0, 1.0, 0.0, 0.0], 2.0);
1039        let mut p = GpuParticle::new([0.0; 3], [0.0; 3], 2.0, [0.5; 4], 1.0);
1040        col.apply(&mut p);
1041        // t=1 → birth color
1042        assert!((p.color[0] - 1.0).abs() < 1e-5);
1043        assert!((p.color[1] - 0.0).abs() < 1e-5);
1044    }
1045
1046    #[test]
1047    fn test_color_over_life_at_death() {
1048        let col = ColorOverLife::new([1.0, 0.0, 0.0, 1.0], [0.0, 1.0, 0.0, 0.0], 2.0);
1049        let mut p = GpuParticle::new([0.0; 3], [0.0; 3], 0.0, [0.5; 4], 1.0);
1050        col.apply(&mut p);
1051        // t=0 → death color
1052        assert!((p.color[0] - 0.0).abs() < 1e-5);
1053        assert!((p.color[1] - 1.0).abs() < 1e-5);
1054    }
1055
1056    #[test]
1057    fn test_color_over_life_midpoint() {
1058        let col = ColorOverLife::new([1.0, 0.0, 0.0, 1.0], [0.0, 1.0, 0.0, 0.0], 2.0);
1059        let mut p = GpuParticle::new([0.0; 3], [0.0; 3], 1.0, [0.5; 4], 1.0);
1060        col.apply(&mut p);
1061        // t=0.5 → midpoint
1062        assert!((p.color[0] - 0.5).abs() < 1e-5);
1063        assert!((p.color[1] - 0.5).abs() < 1e-5);
1064    }
1065
1066    // -- SizeOverLife --
1067
1068    #[test]
1069    fn test_size_over_life_at_birth() {
1070        let sizer = SizeOverLife::new(2.0, 0.1, 3.0);
1071        let mut p = GpuParticle::new([0.0; 3], [0.0; 3], 3.0, [1.0; 4], 1.0);
1072        sizer.apply(&mut p);
1073        assert!((p.size - 2.0).abs() < 1e-5);
1074    }
1075
1076    #[test]
1077    fn test_size_over_life_at_death() {
1078        let sizer = SizeOverLife::new(2.0, 0.1, 3.0);
1079        let mut p = GpuParticle::new([0.0; 3], [0.0; 3], 0.0, [1.0; 4], 1.0);
1080        sizer.apply(&mut p);
1081        assert!((p.size - 0.1).abs() < 1e-5);
1082    }
1083
1084    #[test]
1085    fn test_size_over_life_apply_all() {
1086        let sizer = SizeOverLife::new(4.0, 0.0, 2.0);
1087        let mut particles = vec![
1088            GpuParticle::new([0.0; 3], [0.0; 3], 2.0, [1.0; 4], 0.0),
1089            GpuParticle::default(), // dead
1090        ];
1091        sizer.apply_all(&mut particles);
1092        assert!((particles[0].size - 4.0).abs() < 1e-5);
1093        assert!((particles[1].size - 1.0).abs() < 1e-5); // unchanged default
1094    }
1095
1096    // -- ParticleRenderer --
1097
1098    #[test]
1099    fn test_renderer_empty_scene() {
1100        let renderer = ParticleRenderer::new();
1101        let batch = renderer.render(&[]);
1102        assert_eq!(batch.particle_count(), 0);
1103        assert!(batch.vertices.is_empty());
1104        assert!(batch.indices.is_empty());
1105    }
1106
1107    #[test]
1108    fn test_renderer_single_particle_quad() {
1109        let renderer = ParticleRenderer::new();
1110        let p = GpuParticle::new([0.0, 0.0, 0.0], [0.0; 3], 1.0, [1.0; 4], 0.2);
1111        let batch = renderer.render(&[p]);
1112        assert_eq!(batch.particle_count(), 1);
1113        assert_eq!(batch.vertices.len(), 4);
1114        assert_eq!(batch.indices.len(), 6);
1115    }
1116
1117    #[test]
1118    fn test_renderer_dead_particle_excluded() {
1119        let renderer = ParticleRenderer::new();
1120        let dead = GpuParticle::default();
1121        let batch = renderer.render(&[dead]);
1122        assert_eq!(batch.particle_count(), 0);
1123    }
1124
1125    #[test]
1126    fn test_renderer_index_buffer_valid() {
1127        let renderer = ParticleRenderer::new();
1128        let particles: Vec<GpuParticle> = (0..3)
1129            .map(|i| GpuParticle::new([i as f32, 0.0, 0.0], [0.0; 3], 1.0, [1.0; 4], 0.1))
1130            .collect();
1131        let batch = renderer.render(&particles);
1132        assert_eq!(batch.vertices.len(), 12);
1133        assert_eq!(batch.indices.len(), 18);
1134        // All indices must be in bounds
1135        for &idx in &batch.indices {
1136            assert!((idx as usize) < batch.vertices.len());
1137        }
1138    }
1139
1140    #[test]
1141    fn test_renderer_depth_sort_back_to_front() {
1142        let renderer = ParticleRenderer::new(); // camera at z=10
1143        // Particle closer to camera (z=8) should come second (front)
1144        // Particle farther from camera (z=0) should come first (back)
1145        let p_far = GpuParticle::new([0.0, 0.0, 0.0], [0.0; 3], 1.0, [1.0, 0.0, 0.0, 1.0], 0.1);
1146        let p_near = GpuParticle::new([0.0, 0.0, 8.0], [0.0; 3], 1.0, [0.0, 1.0, 0.0, 1.0], 0.1);
1147        let batch = renderer.render(&[p_far, p_near]);
1148        // Far particle's quad (red) should be first in the batch
1149        assert_eq!(batch.vertices[0].color, [1.0, 0.0, 0.0, 1.0]);
1150    }
1151
1152    // -- Lcg --
1153
1154    #[test]
1155    fn test_lcg_different_seeds() {
1156        let mut rng1 = Lcg::new(1);
1157        let mut rng2 = Lcg::new(2);
1158        let v1 = rng1.next_f32();
1159        let v2 = rng2.next_f32();
1160        assert_ne!(v1, v2);
1161    }
1162
1163    #[test]
1164    fn test_lcg_range_f32_bounds() {
1165        let mut rng = Lcg::new(123);
1166        for _ in 0..1000 {
1167            let v = rng.range_f32(-1.0, 1.0);
1168            assert!((-1.0..1.0).contains(&v) || (v - 1.0).abs() < 1e-6);
1169        }
1170    }
1171
1172    // -- Integration tick test --
1173
1174    #[test]
1175    fn test_tick_emits_particles() {
1176        let mut pool = ParticlePool::new(200);
1177        let mut emitter = ParticleEmitter::new([0.0, 1.0, 0.0], 50.0, [0.0, 2.0, 0.0], 0.1, 2.0);
1178        let gravity = GravityForce::earth();
1179        let mut turbulence = TurbulenceForce::new(0.1, 1.0);
1180        let collider = ParticleCollider::floor(0.0, 0.5);
1181        let col = ColorOverLife::new([1.0, 0.0, 0.0, 1.0], [0.0, 0.0, 1.0, 0.0], 2.0);
1182        let sizer = SizeOverLife::new(0.2, 0.01, 2.0);
1183        let mut rng = Lcg::new(42);
1184
1185        tick(
1186            &mut pool,
1187            &mut emitter,
1188            &gravity,
1189            &mut turbulence,
1190            &collider,
1191            &col,
1192            &sizer,
1193            0.1,
1194            &mut rng,
1195        );
1196        assert!(pool.alive_count() > 0);
1197    }
1198
1199    #[test]
1200    fn test_tick_particles_age() {
1201        let mut pool = ParticlePool::new(100);
1202        let mut emitter = ParticleEmitter::new([0.0; 3], 100.0, [0.0, 1.0, 0.0], 0.0, 0.5);
1203        let gravity = GravityForce::earth();
1204        let mut turbulence = TurbulenceForce::new(0.0, 1.0);
1205        let collider = ParticleCollider::floor(-100.0, 0.5); // far below
1206        let col = ColorOverLife::new([1.0; 4], [0.0; 4], 0.5);
1207        let sizer = SizeOverLife::new(1.0, 0.0, 0.5);
1208        let mut rng = Lcg::new(7);
1209
1210        // Emit first
1211        tick(
1212            &mut pool,
1213            &mut emitter,
1214            &gravity,
1215            &mut turbulence,
1216            &collider,
1217            &col,
1218            &sizer,
1219            0.1,
1220            &mut rng,
1221        );
1222        let alive_after_emit = pool.alive_count();
1223
1224        // Let them expire
1225        for _ in 0..10 {
1226            tick(
1227                &mut pool,
1228                &mut emitter,
1229                &gravity,
1230                &mut turbulence,
1231                &collider,
1232                &col,
1233                &sizer,
1234                0.1,
1235                &mut rng,
1236            );
1237        }
1238        // Some particles will die and be replaced; pool should still function
1239        assert!(pool.alive_count() <= pool.capacity());
1240        let _ = alive_after_emit;
1241    }
1242}