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