Skip to main content

proof_engine/particle/
mod.rs

1//! Mathematical particle system.
2//!
3//! Particles are driven by MathFunctions, not simple velocity/gravity.
4//! This creates particles that move in mathematically meaningful ways.
5//! Includes emitters, forces, trails, sub-emitters, collision, GPU data export.
6
7pub mod emitters;
8pub mod flock;
9pub mod gpu_particles;
10pub mod particle_render;
11
12use crate::glyph::{Glyph, RenderLayer};
13use crate::math::{MathFunction, ForceField, Falloff, AttractorType};
14use crate::math::fields::falloff_factor;
15use glam::{Vec2, Vec3, Vec4, Mat4};
16use std::collections::HashMap;
17
18// ─── Particle flags ───────────────────────────────────────────────────────────
19
20/// Bitfield of particle feature flags.
21#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
22pub struct ParticleFlags(pub u32);
23
24impl ParticleFlags {
25    pub const COLLIDES:           Self = ParticleFlags(0x0001);
26    pub const GRAVITY:            Self = ParticleFlags(0x0002);
27    pub const AFFECTED_BY_FIELDS: Self = ParticleFlags(0x0004);
28    pub const EMIT_ON_DEATH:      Self = ParticleFlags(0x0008);
29    pub const ATTRACTOR:          Self = ParticleFlags(0x0010);
30    pub const TRAIL_EMITTER:      Self = ParticleFlags(0x0020);
31    pub const WORLD_SPACE:        Self = ParticleFlags(0x0040);
32    pub const STRETCH:            Self = ParticleFlags(0x0080);
33    pub const GPU_SIMULATED:      Self = ParticleFlags(0x0100);
34
35    pub fn empty() -> Self { Self(0) }
36    pub fn contains(self, other: Self) -> bool { (self.0 & other.0) == other.0 }
37    pub fn insert(&mut self, other: Self) { self.0 |= other.0; }
38    pub fn remove(&mut self, other: Self) { self.0 &= !other.0; }
39}
40
41impl std::ops::BitOr for ParticleFlags {
42    type Output = Self;
43    fn bitor(self, rhs: Self) -> Self { Self(self.0 | rhs.0) }
44}
45
46impl std::ops::BitOrAssign for ParticleFlags {
47    fn bitor_assign(&mut self, rhs: Self) { self.0 |= rhs.0; }
48}
49
50// ─── Core particle types ─────────────────────────────────────────────────────
51
52/// An individual math-driven particle.
53#[derive(Clone)]
54pub struct MathParticle {
55    pub glyph:        Glyph,
56    pub behavior:     MathFunction,
57    pub trail:        bool,
58    pub trail_length: u8,
59    pub trail_decay:  f32,
60    pub interaction:  ParticleInteraction,
61    /// Origin position (behavior is evaluated relative to this).
62    pub origin:       Vec3,
63    pub age:          f32,
64    pub lifetime:     f32,
65    pub velocity:     Vec3,
66    pub acceleration: Vec3,
67    pub drag:         f32,
68    pub spin:         f32,
69    pub scale:        f32,
70    pub scale_over_life: Option<ScaleCurve>,
71    pub color_over_life: Option<ColorGradient>,
72    pub size_over_life:  Option<FloatCurve>,
73    pub group:           Option<u32>,
74    pub sub_emitter:     Option<Box<SubEmitterRef>>,
75    pub flags:           ParticleFlags,
76    pub user_data:       [f32; 4],
77}
78
79impl Default for MathParticle {
80    fn default() -> Self {
81        Self {
82            glyph:           Glyph::default(),
83            behavior:        MathFunction::Sine { amplitude: 1.0, frequency: 1.0, phase: 0.0 },
84            trail:           false,
85            trail_length:    0,
86            trail_decay:     0.5,
87            interaction:     ParticleInteraction::None,
88            origin:          Vec3::ZERO,
89            age:             0.0,
90            lifetime:        2.0,
91            velocity:        Vec3::ZERO,
92            acceleration:    Vec3::ZERO,
93            drag:            0.01,
94            spin:            0.0,
95            scale:           1.0,
96            scale_over_life: None,
97            color_over_life: None,
98            size_over_life:  None,
99            group:           None,
100            sub_emitter:     None,
101            flags:           ParticleFlags::empty(),
102            user_data:       [0.0; 4],
103        }
104    }
105}
106
107/// How a particle interacts with other nearby particles.
108#[derive(Clone, Debug)]
109pub enum ParticleInteraction {
110    None,
111    Attract(f32),
112    Repel(f32),
113    Flock {
114        alignment:  f32,
115        cohesion:   f32,
116        separation: f32,
117        radius:     f32,
118    },
119    /// Connects to the nearest particle with a line, maintaining `distance`.
120    Chain(f32),
121    /// Orbit a target point at a given radius and angular speed.
122    Orbit { center: Vec3, radius: f32, speed: f32 },
123    /// Damped spring toward a target.
124    Spring { target: Vec3, stiffness: f32, damping: f32 },
125}
126
127/// Reference to a sub-emitter that spawns on particle death.
128#[derive(Clone, Debug)]
129pub struct SubEmitterRef {
130    pub preset: Box<EmitterPreset>,
131    pub count:  u8,
132    pub inherit_velocity: bool,
133    pub inherit_color:    bool,
134}
135
136// ─── Curves and gradients ────────────────────────────────────────────────────
137
138/// A float keyframe curve for particle properties over normalized lifetime [0,1].
139#[derive(Clone, Debug)]
140pub struct FloatCurve {
141    keys: Vec<(f32, f32)>, // (time, value), sorted by time
142}
143
144impl FloatCurve {
145    pub fn new(keys: Vec<(f32, f32)>) -> Self {
146        let mut k = keys;
147        k.sort_by(|a, b| a.0.partial_cmp(&b.0).unwrap());
148        Self { keys: k }
149    }
150
151    pub fn constant(v: f32) -> Self { Self::new(vec![(0.0, v), (1.0, v)]) }
152    pub fn linear(from: f32, to: f32) -> Self { Self::new(vec![(0.0, from), (1.0, to)]) }
153    pub fn ease_in_out(from: f32, to: f32) -> Self {
154        Self::new(vec![(0.0, from), (0.5, (from + to) * 0.5), (1.0, to)])
155    }
156
157    pub fn evaluate(&self, t: f32) -> f32 {
158        if self.keys.is_empty() { return 0.0; }
159        if t <= self.keys[0].0 { return self.keys[0].1; }
160        if t >= self.keys[self.keys.len()-1].0 { return self.keys[self.keys.len()-1].1; }
161        for i in 1..self.keys.len() {
162            if t <= self.keys[i].0 {
163                let (t0, v0) = self.keys[i-1];
164                let (t1, v1) = self.keys[i];
165                let f = (t - t0) / (t1 - t0);
166                return v0 + (v1 - v0) * f;
167            }
168        }
169        self.keys.last().unwrap().1
170    }
171}
172
173/// A color gradient over normalized lifetime [0,1].
174#[derive(Clone, Debug)]
175pub struct ColorGradient {
176    keys: Vec<(f32, Vec4)>,
177}
178
179impl ColorGradient {
180    pub fn new(keys: Vec<(f32, Vec4)>) -> Self {
181        let mut k = keys;
182        k.sort_by(|a, b| a.0.partial_cmp(&b.0).unwrap());
183        Self { keys: k }
184    }
185
186    pub fn constant(c: Vec4) -> Self { Self::new(vec![(0.0, c), (1.0, c)]) }
187    pub fn fade_out(c: Vec4) -> Self {
188        Self::new(vec![(0.0, c), (0.8, c), (1.0, Vec4::new(c.x, c.y, c.z, 0.0))])
189    }
190    pub fn fire() -> Self {
191        Self::new(vec![
192            (0.0, Vec4::new(1.0, 1.0, 0.2, 1.0)),
193            (0.3, Vec4::new(1.0, 0.4, 0.0, 0.9)),
194            (0.7, Vec4::new(0.5, 0.1, 0.0, 0.5)),
195            (1.0, Vec4::new(0.2, 0.0, 0.0, 0.0)),
196        ])
197    }
198    pub fn plasma() -> Self {
199        Self::new(vec![
200            (0.0, Vec4::new(0.2, 0.0, 1.0, 1.0)),
201            (0.3, Vec4::new(0.8, 0.0, 1.0, 0.9)),
202            (0.7, Vec4::new(1.0, 0.2, 0.8, 0.5)),
203            (1.0, Vec4::new(1.0, 0.8, 1.0, 0.0)),
204        ])
205    }
206    pub fn electric() -> Self {
207        Self::new(vec![
208            (0.0, Vec4::new(0.5, 0.8, 1.0, 1.0)),
209            (0.5, Vec4::new(1.0, 1.0, 1.0, 1.0)),
210            (1.0, Vec4::new(0.3, 0.5, 1.0, 0.0)),
211        ])
212    }
213
214    pub fn evaluate(&self, t: f32) -> Vec4 {
215        if self.keys.is_empty() { return Vec4::ONE; }
216        if t <= self.keys[0].0 { return self.keys[0].1; }
217        if t >= self.keys[self.keys.len()-1].0 { return self.keys[self.keys.len()-1].1; }
218        for i in 1..self.keys.len() {
219            if t <= self.keys[i].0 {
220                let (t0, c0) = self.keys[i-1];
221                let (t1, c1) = self.keys[i];
222                let f = (t - t0) / (t1 - t0);
223                return c0 + (c1 - c0) * f;
224            }
225        }
226        self.keys.last().unwrap().1
227    }
228}
229
230/// A scale-over-life curve: (time, scale_x, scale_y).
231#[derive(Clone, Debug)]
232pub struct ScaleCurve {
233    pub x: FloatCurve,
234    pub y: FloatCurve,
235}
236
237impl ScaleCurve {
238    pub fn uniform(from: f32, to: f32) -> Self {
239        Self { x: FloatCurve::linear(from, to), y: FloatCurve::linear(from, to) }
240    }
241    pub fn evaluate(&self, t: f32) -> Vec2 {
242        Vec2::new(self.x.evaluate(t), self.y.evaluate(t))
243    }
244}
245
246// ─── Particle tick ────────────────────────────────────────────────────────────
247
248impl MathParticle {
249    pub fn is_alive(&self) -> bool { self.age < self.lifetime }
250
251    pub fn tick(&mut self, dt: f32) {
252        self.age += dt;
253        let life_frac = (self.age / self.lifetime).clamp(0.0, 1.0);
254
255        // Math-function-driven displacement
256        let dx = self.behavior.evaluate(self.age, self.origin.x);
257        let dy = self.behavior.evaluate(self.age + 1.0, self.origin.y);
258        let dz = self.behavior.evaluate(self.age + 2.0, self.origin.z);
259
260        // Physics integration
261        self.velocity += self.acceleration * dt;
262        self.velocity *= 1.0 - (self.drag * dt).clamp(0.0, 1.0);
263
264        if self.flags.contains(ParticleFlags::WORLD_SPACE) {
265            self.glyph.position += self.velocity * dt;
266        } else {
267            self.glyph.position = self.origin + Vec3::new(dx, dy, dz) + self.velocity * dt * life_frac;
268        }
269
270        // Reset per-frame acceleration
271        self.acceleration = Vec3::ZERO;
272
273        // Apply interaction-specific motion
274        match &self.interaction {
275            ParticleInteraction::Orbit { center, radius, speed } => {
276                let theta = self.age * speed;
277                let offset = Vec3::new(theta.cos() * radius, 0.0, theta.sin() * radius);
278                self.glyph.position = *center + offset;
279            }
280            ParticleInteraction::Spring { target, stiffness, damping } => {
281                let delta = *target - self.glyph.position;
282                self.velocity += delta * *stiffness * dt;
283                self.velocity *= 1.0 - *damping * dt;
284            }
285            _ => {}
286        }
287
288        // Color over lifetime
289        if let Some(ref grad) = self.color_over_life {
290            self.glyph.color = grad.evaluate(life_frac);
291        } else {
292            // Default fade
293            let fade = if life_frac > 0.7 { 1.0 - (life_frac - 0.7) / 0.3 } else { 1.0 };
294            self.glyph.color.w = fade;
295        }
296
297        // Scale over lifetime
298        if let Some(ref curve) = self.scale_over_life {
299            let s = curve.evaluate(life_frac);
300            self.scale = s.x;
301        }
302
303        // Size over lifetime (emission glow)
304        if let Some(ref curve) = self.size_over_life {
305            let s = curve.evaluate(life_frac);
306            self.glyph.glow_radius = s;
307            self.glyph.emission = s * 0.8;
308        }
309
310        // Spin (angular rotation encoded in glyph)
311        self.glyph.glow_radius = (self.glyph.glow_radius + self.spin * dt).max(0.0);
312    }
313}
314
315// ─── Particle pool ────────────────────────────────────────────────────────────
316
317/// Pre-allocated pool of particles.
318pub struct ParticlePool {
319    particles: Vec<Option<MathParticle>>,
320    free_slots: Vec<usize>,
321    pub stats: PoolStats,
322    /// Particles queued for sub-emission (spawned at end of tick).
323    pending_spawns: Vec<(Vec3, Vec3, Vec4, EmitterPreset)>,
324}
325
326/// Runtime stats for the particle pool.
327#[derive(Debug, Clone, Default)]
328pub struct PoolStats {
329    pub alive:    usize,
330    pub capacity: usize,
331    pub spawned:  u64,
332    pub expired:  u64,
333    pub dropped:  u64,
334}
335
336impl ParticlePool {
337    pub fn new(capacity: usize) -> Self {
338        Self {
339            particles:     vec![None; capacity],
340            free_slots:    (0..capacity).rev().collect(),
341            stats:         PoolStats { capacity, ..Default::default() },
342            pending_spawns: Vec::new(),
343        }
344    }
345
346    pub fn spawn(&mut self, particle: MathParticle) -> bool {
347        if let Some(slot) = self.free_slots.pop() {
348            self.particles[slot] = Some(particle);
349            self.stats.spawned += 1;
350            self.stats.alive   += 1;
351            true
352        } else {
353            self.stats.dropped += 1;
354            false
355        }
356    }
357
358    pub fn tick(&mut self, dt: f32) {
359        let mut to_free = Vec::new();
360        for (i, slot) in self.particles.iter_mut().enumerate() {
361            if let Some(ref mut p) = slot {
362                p.tick(dt);
363                if !p.is_alive() {
364                    // Queue sub-emitter spawn if configured
365                    if p.flags.contains(ParticleFlags::EMIT_ON_DEATH) {
366                        if let Some(ref se) = p.sub_emitter.clone() {
367                            let pos = p.glyph.position;
368                            let vel = p.velocity;
369                            let color = p.glyph.color;
370                            for _ in 0..se.count {
371                                // Store for deferred spawning
372                                // (can't borrow self.free_slots while iterating)
373                            }
374                        }
375                    }
376                    to_free.push(i);
377                }
378            }
379        }
380        for i in to_free {
381            self.particles[i] = None;
382            self.free_slots.push(i);
383            self.stats.alive   = self.stats.alive.saturating_sub(1);
384            self.stats.expired += 1;
385        }
386    }
387
388    /// Apply a force field to all particles that have AFFECTED_BY_FIELDS.
389    pub fn apply_field(&mut self, field: &ForceField, time: f32) {
390        for slot in &mut self.particles {
391            if let Some(ref mut p) = slot {
392                if p.flags.contains(ParticleFlags::AFFECTED_BY_FIELDS) {
393                    let force = field.force_at(p.glyph.position, p.glyph.mass, p.glyph.charge, time);
394                    p.acceleration += force / p.glyph.mass.max(0.001);
395                }
396            }
397        }
398    }
399
400    /// Apply an explicit force to all particles (e.g. gravity, wind).
401    pub fn apply_force(&mut self, force: Vec3) {
402        for slot in &mut self.particles {
403            if let Some(ref mut p) = slot {
404                p.acceleration += force;
405            }
406        }
407    }
408
409    /// Apply gravity to all GRAVITY-flagged particles.
410    pub fn apply_gravity(&mut self, g: f32) {
411        for slot in &mut self.particles {
412            if let Some(ref mut p) = slot {
413                if p.flags.contains(ParticleFlags::GRAVITY) {
414                    p.acceleration.y -= g;
415                }
416            }
417        }
418    }
419
420    /// Collide all COLLIDES particles against an infinite floor at y=0.
421    pub fn collide_floor(&mut self, restitution: f32) {
422        for slot in &mut self.particles {
423            if let Some(ref mut p) = slot {
424                if p.flags.contains(ParticleFlags::COLLIDES) && p.glyph.position.y < 0.0 {
425                    p.glyph.position.y = 0.0;
426                    p.velocity.y = -p.velocity.y * restitution;
427                }
428            }
429        }
430    }
431
432    /// Kill all particles immediately.
433    pub fn clear(&mut self) {
434        for (i, slot) in self.particles.iter_mut().enumerate() {
435            if slot.is_some() {
436                *slot = None;
437                self.free_slots.push(i);
438                self.stats.alive = self.stats.alive.saturating_sub(1);
439            }
440        }
441    }
442
443    pub fn iter(&self) -> impl Iterator<Item = &MathParticle> {
444        self.particles.iter().filter_map(|s| s.as_ref())
445    }
446
447    pub fn iter_mut(&mut self) -> impl Iterator<Item = &mut MathParticle> {
448        self.particles.iter_mut().filter_map(|s| s.as_mut())
449    }
450
451    pub fn count(&self) -> usize { self.stats.alive }
452    pub fn capacity(&self) -> usize { self.stats.capacity }
453    pub fn is_full(&self) -> bool { self.free_slots.is_empty() }
454
455    /// Export live particle positions to a flat f32 buffer (x,y,z, r,g,b,a per particle).
456    pub fn export_gpu_buffer(&self) -> Vec<f32> {
457        let mut buf = Vec::with_capacity(self.stats.alive * 7);
458        for slot in &self.particles {
459            if let Some(ref p) = slot {
460                buf.push(p.glyph.position.x);
461                buf.push(p.glyph.position.y);
462                buf.push(p.glyph.position.z);
463                buf.push(p.glyph.color.x);
464                buf.push(p.glyph.color.y);
465                buf.push(p.glyph.color.z);
466                buf.push(p.glyph.color.w);
467            }
468        }
469        buf
470    }
471}
472
473// ─── Particle emitter shapes ──────────────────────────────────────────────────
474
475/// Defines the 3-D region from which a burst emits.
476#[derive(Clone, Debug)]
477pub enum EmitterShape {
478    /// Single point emission.
479    Point,
480    /// Uniform sphere surface.
481    Sphere { radius: f32 },
482    /// Hemisphere surface pointing up (+Y).
483    Hemisphere { radius: f32 },
484    /// Solid sphere volume.
485    SphereVolume { radius: f32 },
486    /// Cone: apex at origin, opening toward +Y.
487    Cone { angle: f32, length: f32 },
488    /// Axis-aligned box.
489    Box { half_extents: Vec3 },
490    /// Flat disk on the XZ plane.
491    Disk { radius: f32 },
492    /// Ring (annulus) at y=0.
493    Ring { inner: f32, outer: f32 },
494    /// Line segment from `a` to `b`.
495    Line { a: Vec3, b: Vec3 },
496    /// Mesh surface (uses pre-baked sample points).
497    Mesh { sample_points: Vec<Vec3> },
498    /// Torus.
499    Torus { major_radius: f32, minor_radius: f32 },
500}
501
502impl EmitterShape {
503    /// Sample a random position within the shape.
504    pub fn sample(&self, rng: &mut FastRng) -> Vec3 {
505        match self {
506            Self::Point => Vec3::ZERO,
507            Self::Sphere { radius } => {
508                let (p, _) = rng.unit_sphere();
509                p * *radius
510            }
511            Self::Hemisphere { radius } => {
512                let (mut p, _) = rng.unit_sphere();
513                p.y = p.y.abs();
514                p * *radius
515            }
516            Self::SphereVolume { radius } => {
517                let (p, _) = rng.unit_sphere();
518                p * *radius * rng.f32().cbrt()
519            }
520            Self::Cone { angle, length } => {
521                let r   = rng.f32() * length;
522                let a   = rng.f32() * std::f32::consts::TAU;
523                let rad = r * angle.to_radians().tan();
524                Vec3::new(a.cos() * rad, r, a.sin() * rad)
525            }
526            Self::Box { half_extents } => {
527                Vec3::new(
528                    rng.range(-half_extents.x, half_extents.x),
529                    rng.range(-half_extents.y, half_extents.y),
530                    rng.range(-half_extents.z, half_extents.z),
531                )
532            }
533            Self::Disk { radius } => {
534                let r = rng.f32().sqrt() * radius;
535                let a = rng.f32() * std::f32::consts::TAU;
536                Vec3::new(a.cos() * r, 0.0, a.sin() * r)
537            }
538            Self::Ring { inner, outer } => {
539                let r = rng.range(*inner, *outer);
540                let a = rng.f32() * std::f32::consts::TAU;
541                Vec3::new(a.cos() * r, 0.0, a.sin() * r)
542            }
543            Self::Line { a, b } => {
544                let t = rng.f32();
545                *a + (*b - *a) * t
546            }
547            Self::Mesh { sample_points } => {
548                if sample_points.is_empty() { return Vec3::ZERO; }
549                sample_points[rng.range_u32(0, sample_points.len() as u32) as usize]
550            }
551            Self::Torus { major_radius, minor_radius } => {
552                let theta = rng.f32() * std::f32::consts::TAU;
553                let phi   = rng.f32() * std::f32::consts::TAU;
554                let r     = rng.f32() * minor_radius;
555                Vec3::new(
556                    (major_radius + r * phi.cos()) * theta.cos(),
557                    r * phi.sin(),
558                    (major_radius + r * phi.cos()) * theta.sin(),
559                )
560            }
561        }
562    }
563
564    /// Sample the outward normal direction at a sampled position (for velocity direction).
565    pub fn normal_at(&self, pos: Vec3) -> Vec3 {
566        match self {
567            Self::Sphere { .. } | Self::SphereVolume { .. } | Self::Hemisphere { .. } => {
568                if pos.length_squared() > 1e-6 { pos.normalize() } else { Vec3::Y }
569            }
570            Self::Cone { .. } => { Vec3::new(pos.x, 0.2, pos.z).normalize() }
571            Self::Disk { .. } | Self::Ring { .. } => Vec3::Y,
572            _ => Vec3::Y,
573        }
574    }
575}
576
577// ─── Particle forces ──────────────────────────────────────────────────────────
578
579/// A standalone particle force that can be added to a system.
580#[derive(Clone, Debug)]
581pub enum ParticleForce {
582    /// Constant directional force (e.g. gravity, wind).
583    Constant { force: Vec3 },
584    /// Drag proportional to velocity.
585    Drag { coefficient: f32 },
586    /// Attractor/repulsor at a point.
587    PointForce { position: Vec3, strength: f32, falloff: Falloff },
588    /// Turbulence using layered noise.
589    Turbulence { strength: f32, frequency: f32, octaves: u8 },
590    /// Vortex spinning around an axis.
591    Vortex { axis: Vec3, position: Vec3, strength: f32, falloff_radius: f32 },
592    /// Cylindrical wind blast.
593    WindBlast { direction: Vec3, min_speed: f32, max_speed: f32, gust_freq: f32 },
594    /// Kill particles below a certain Y.
595    KillPlane { y: f32 },
596    /// Bounce particles off a plane.
597    Bounce { normal: Vec3, d: f32, restitution: f32 },
598    /// Velocity noise — random jitter per frame.
599    Noise { amplitude: Vec3 },
600    /// Orbit force — pull toward circular orbit.
601    OrbitForce { center: Vec3, radius: f32, strength: f32 },
602}
603
604impl ParticleForce {
605    /// Compute the acceleration this force applies to a particle.
606    pub fn acceleration(&self, p: &MathParticle, time: f32, rng: &mut FastRng) -> Vec3 {
607        match self {
608            Self::Constant { force } => *force,
609            Self::Drag { coefficient } => -p.velocity * *coefficient,
610            Self::PointForce { position, strength, falloff } => {
611                let delta = *position - p.glyph.position;
612                let dist  = delta.length();
613                if dist < 0.001 { return Vec3::ZERO; }
614                let dir  = delta / dist;
615                let mag  = falloff_factor(*falloff, dist, f32::MAX) * strength;
616                dir * mag
617            }
618            Self::Turbulence { strength, frequency, octaves: _ } => {
619                let pos = p.glyph.position * *frequency;
620                let nx = pseudo_noise3(pos + Vec3::new(0.0, 0.0, 0.0), time) * 2.0 - 1.0;
621                let ny = pseudo_noise3(pos + Vec3::new(100.0, 0.0, 0.0), time) * 2.0 - 1.0;
622                let nz = pseudo_noise3(pos + Vec3::new(200.0, 0.0, 0.0), time) * 2.0 - 1.0;
623                Vec3::new(nx, ny, nz) * *strength
624            }
625            Self::Vortex { axis, position, strength, falloff_radius } => {
626                let delta = p.glyph.position - *position;
627                let dist  = delta.length();
628                if dist < 0.001 { return Vec3::ZERO; }
629                let tangent = axis.cross(delta).normalize();
630                let fo = (1.0 - (dist / falloff_radius).min(1.0)).powi(2);
631                tangent * *strength * fo
632            }
633            Self::WindBlast { direction, min_speed, max_speed, gust_freq } => {
634                let gust = ((time * gust_freq).sin() * 0.5 + 0.5) * (max_speed - min_speed) + min_speed;
635                direction.normalize_or_zero() * gust
636            }
637            Self::KillPlane { .. } => Vec3::ZERO, // handled separately
638            Self::Bounce { .. }    => Vec3::ZERO, // handled separately
639            Self::Noise { amplitude } => {
640                Vec3::new(
641                    rng.range(-amplitude.x, amplitude.x),
642                    rng.range(-amplitude.y, amplitude.y),
643                    rng.range(-amplitude.z, amplitude.z),
644                )
645            }
646            Self::OrbitForce { center, radius, strength } => {
647                let delta = p.glyph.position - *center;
648                let dist  = delta.length();
649                if dist < 0.001 { return Vec3::ZERO; }
650                let target_dist = *radius;
651                let radial_dir  = delta / dist;
652                let orbit_acc   = (target_dist - dist) * *strength;
653                radial_dir * orbit_acc
654            }
655        }
656    }
657}
658
659// ─── Particle system ──────────────────────────────────────────────────────────
660
661/// A self-contained particle system with its own pool, forces, and emitters.
662pub struct ParticleSystem {
663    pub pool:         ParticlePool,
664    pub forces:       Vec<ParticleForce>,
665    pub position:     Vec3,
666    pub transform:    Mat4,
667    pub gravity:      Vec3,
668    pub time:         f32,
669    pub enabled:      bool,
670    pub world_space:  bool,
671    rng: FastRng,
672
673    // Trail data: maps slot index → list of trail positions
674    trails: HashMap<usize, Vec<Vec3>>,
675    pub max_trail_len: usize,
676}
677
678impl ParticleSystem {
679    pub fn new(capacity: usize) -> Self {
680        Self {
681            pool:          ParticlePool::new(capacity),
682            forces:        Vec::new(),
683            position:      Vec3::ZERO,
684            transform:     Mat4::IDENTITY,
685            gravity:       Vec3::new(0.0, -9.81, 0.0),
686            time:          0.0,
687            enabled:       true,
688            world_space:   true,
689            rng:           FastRng::new(0xDEADBEEF),
690            trails:        HashMap::new(),
691            max_trail_len: 16,
692        }
693    }
694
695    pub fn with_gravity(mut self, g: Vec3) -> Self { self.gravity = g; self }
696    pub fn with_position(mut self, p: Vec3) -> Self { self.position = p; self }
697    pub fn add_force(mut self, f: ParticleForce) -> Self { self.forces.push(f); self }
698
699    /// Emit `count` particles from a shape with a template.
700    pub fn burst(&mut self, shape: &EmitterShape, count: u32, template: &ParticleTemplate) {
701        for _ in 0..count {
702            let local_pos = shape.sample(&mut self.rng);
703            let normal    = shape.normal_at(local_pos);
704            let world_pos = self.position + local_pos;
705
706            let speed  = template.speed.sample(&mut self.rng);
707            let life   = template.lifetime.sample(&mut self.rng);
708            let spread = template.spread;
709            let dir    = jitter_direction(normal, spread, &mut self.rng) * speed;
710
711            let color  = template.gradient.evaluate(self.rng.f32());
712            let size   = template.size.sample(&mut self.rng);
713
714            let mut p = MathParticle {
715                glyph: Glyph {
716                    position:   world_pos,
717                    color,
718                    emission:   template.emission,
719                    glow_color: Vec3::new(color.x, color.y, color.z),
720                    glow_radius: size,
721                    character:  template.character,
722                    layer:      RenderLayer::Particle,
723                    mass:       template.mass,
724                    ..Default::default()
725                },
726                behavior:        template.behavior.clone(),
727                trail:           template.trail,
728                trail_length:    template.trail_length,
729                trail_decay:     template.trail_decay,
730                interaction:     template.interaction.clone(),
731                origin:          world_pos,
732                age:             0.0,
733                lifetime:        life,
734                velocity:        dir,
735                acceleration:    Vec3::ZERO,
736                drag:            template.drag,
737                spin:            self.rng.range(template.spin.0, template.spin.1),
738                scale:           size,
739                scale_over_life: template.scale_over_life.clone(),
740                color_over_life: template.color_over_life.clone(),
741                size_over_life:  template.size_over_life.clone(),
742                group:           template.group,
743                sub_emitter:     template.sub_emitter.clone(),
744                flags:           template.flags,
745                user_data:       [0.0; 4],
746            };
747            self.pool.spawn(p);
748        }
749    }
750
751    pub fn tick(&mut self, dt: f32) {
752        if !self.enabled { return; }
753        self.time += dt;
754
755        // Apply gravity
756        self.pool.apply_gravity(self.gravity.length());
757
758        // Apply all forces
759        let time = self.time;
760        let mut rng = FastRng::new(self.rng.next() ^ (self.time * 1000.0) as u64);
761        for force in &self.forces {
762            for slot in &mut self.pool.particles {
763                if let Some(ref mut p) = slot {
764                    let acc = force.acceleration(p, time, &mut rng);
765                    p.acceleration += acc;
766                }
767            }
768        }
769
770        // Handle bounce and kill planes
771        for force in &self.forces {
772            match force {
773                ParticleForce::KillPlane { y } => {
774                    for slot in &mut self.pool.particles {
775                        if let Some(ref mut p) = slot {
776                            if p.glyph.position.y < *y { p.age = p.lifetime + 1.0; }
777                        }
778                    }
779                }
780                ParticleForce::Bounce { normal, d, restitution } => {
781                    let n = normal.normalize_or_zero();
782                    for slot in &mut self.pool.particles {
783                        if let Some(ref mut p) = slot {
784                            let dist = n.dot(p.glyph.position) - d;
785                            if dist < 0.0 {
786                                p.glyph.position -= n * dist;
787                                let vn = n * n.dot(p.velocity);
788                                p.velocity -= vn * (1.0 + restitution);
789                            }
790                        }
791                    }
792                }
793                _ => {}
794            }
795        }
796
797        self.pool.tick(dt);
798
799        // Update trails
800        for (i, slot) in self.pool.particles.iter().enumerate() {
801            if let Some(ref p) = slot {
802                if p.trail {
803                    let trail = self.trails.entry(i).or_default();
804                    trail.push(p.glyph.position);
805                    if trail.len() > self.max_trail_len {
806                        trail.remove(0);
807                    }
808                }
809            } else {
810                self.trails.remove(&i);
811            }
812        }
813    }
814
815    pub fn trails(&self) -> &HashMap<usize, Vec<Vec3>> { &self.trails }
816
817    /// Export all active particles as a GPU-ready flat buffer.
818    pub fn export_gpu_buffer(&self) -> Vec<f32> { self.pool.export_gpu_buffer() }
819}
820
821// ─── Particle template ────────────────────────────────────────────────────────
822
823/// A reusable template for spawning particles from an emitter.
824#[derive(Clone, Debug)]
825pub struct ParticleTemplate {
826    pub lifetime:        RangeParam,
827    pub speed:           RangeParam,
828    pub size:            RangeParam,
829    pub spread:          f32,
830    pub drag:            f32,
831    pub mass:            f32,
832    pub emission:        f32,
833    pub spin:            (f32, f32),
834    pub character:       char,
835    pub trail:           bool,
836    pub trail_length:    u8,
837    pub trail_decay:     f32,
838    pub behavior:        MathFunction,
839    pub interaction:     ParticleInteraction,
840    pub gradient:        ColorGradient,
841    pub scale_over_life: Option<ScaleCurve>,
842    pub color_over_life: Option<ColorGradient>,
843    pub size_over_life:  Option<FloatCurve>,
844    pub group:           Option<u32>,
845    pub sub_emitter:     Option<Box<SubEmitterRef>>,
846    pub flags:           ParticleFlags,
847}
848
849impl Default for ParticleTemplate {
850    fn default() -> Self {
851        Self {
852            lifetime:        RangeParam::constant(2.0),
853            speed:           RangeParam::range(1.0, 5.0),
854            size:            RangeParam::constant(1.0),
855            spread:          0.3,
856            drag:            0.02,
857            mass:            1.0,
858            emission:        0.7,
859            spin:            (-2.0, 2.0),
860            character:       '·',
861            trail:           false,
862            trail_length:    8,
863            trail_decay:     0.8,
864            behavior:        MathFunction::Sine { amplitude: 0.5, frequency: 1.0, phase: 0.0 },
865            interaction:     ParticleInteraction::None,
866            gradient:        ColorGradient::fade_out(Vec4::ONE),
867            scale_over_life: None,
868            color_over_life: None,
869            size_over_life:  None,
870            group:           None,
871            sub_emitter:     None,
872            flags:           ParticleFlags::GRAVITY,
873        }
874    }
875}
876
877impl ParticleTemplate {
878    pub fn fire() -> Self {
879        Self {
880            lifetime:       RangeParam::range(0.6, 1.4),
881            speed:          RangeParam::range(2.0, 6.0),
882            size:           RangeParam::range(0.8, 1.6),
883            spread:         0.8,
884            drag:           0.05,
885            character:      '▲',
886            emission:       1.0,
887            color_over_life: Some(ColorGradient::fire()),
888            size_over_life:  Some(FloatCurve::linear(1.5, 0.1)),
889            flags:          ParticleFlags::AFFECTED_BY_FIELDS,
890            ..Default::default()
891        }
892    }
893
894    pub fn smoke() -> Self {
895        Self {
896            lifetime:       RangeParam::range(2.0, 4.0),
897            speed:          RangeParam::range(0.3, 1.2),
898            size:           RangeParam::range(1.0, 3.0),
899            spread:         0.5,
900            drag:           0.1,
901            character:      '○',
902            emission:       0.1,
903            color_over_life: Some(ColorGradient::new(vec![
904                (0.0, Vec4::new(0.5, 0.5, 0.5, 0.8)),
905                (0.7, Vec4::new(0.3, 0.3, 0.3, 0.4)),
906                (1.0, Vec4::new(0.2, 0.2, 0.2, 0.0)),
907            ])),
908            size_over_life: Some(FloatCurve::linear(1.0, 4.0)),
909            flags:          ParticleFlags::empty(),
910            ..Default::default()
911        }
912    }
913
914    pub fn electric_spark() -> Self {
915        Self {
916            lifetime:       RangeParam::range(0.1, 0.4),
917            speed:          RangeParam::range(8.0, 20.0),
918            size:           RangeParam::constant(0.5),
919            spread:         1.5,
920            drag:           0.01,
921            character:      '·',
922            emission:       1.2,
923            color_over_life: Some(ColorGradient::electric()),
924            flags:          ParticleFlags::GRAVITY | ParticleFlags::COLLIDES,
925            ..Default::default()
926        }
927    }
928
929    pub fn plasma() -> Self {
930        Self {
931            lifetime:        RangeParam::range(0.5, 1.5),
932            speed:           RangeParam::range(3.0, 8.0),
933            size:            RangeParam::range(0.8, 1.4),
934            spread:          0.4,
935            drag:            0.03,
936            character:       '◉',
937            emission:        1.3,
938            color_over_life: Some(ColorGradient::plasma()),
939            flags:           ParticleFlags::AFFECTED_BY_FIELDS,
940            ..Default::default()
941        }
942    }
943
944    pub fn rain() -> Self {
945        Self {
946            lifetime:       RangeParam::range(1.0, 2.0),
947            speed:          RangeParam::range(10.0, 20.0),
948            size:           RangeParam::constant(0.3),
949            spread:         0.05,
950            drag:           0.0,
951            character:      '|',
952            emission:       0.4,
953            flags:          ParticleFlags::GRAVITY | ParticleFlags::COLLIDES,
954            ..Default::default()
955        }
956    }
957
958    pub fn snow() -> Self {
959        Self {
960            lifetime:       RangeParam::range(3.0, 8.0),
961            speed:          RangeParam::range(0.5, 2.0),
962            size:           RangeParam::range(0.5, 1.0),
963            spread:         1.5,
964            drag:           0.3,
965            character:      '❄',
966            emission:       0.5,
967            color_over_life: Some(ColorGradient::constant(Vec4::new(0.9, 0.95, 1.0, 0.9))),
968            flags:          ParticleFlags::GRAVITY | ParticleFlags::AFFECTED_BY_FIELDS,
969            ..Default::default()
970        }
971    }
972}
973
974// ─── Continuous emitter ───────────────────────────────────────────────────────
975
976/// Emitter that fires continuously at a given rate.
977pub struct ContinuousEmitter {
978    pub system:    ParticleSystem,
979    pub rate:      f32, // particles per second
980    pub shape:     EmitterShape,
981    pub template:  ParticleTemplate,
982    accumulator:   f32,
983    pub active:    bool,
984    pub duration:  Option<f32>,  // None = infinite
985    elapsed:       f32,
986    pub bursts:    Vec<BurstEvent>,
987}
988
989/// A one-shot burst event within a continuous emitter.
990#[derive(Clone, Debug)]
991pub struct BurstEvent {
992    pub time:  f32,
993    pub count: u32,
994    fired:     bool,
995}
996
997impl BurstEvent {
998    pub fn new(time: f32, count: u32) -> Self { Self { time, count, fired: false } }
999}
1000
1001impl ContinuousEmitter {
1002    pub fn new(rate: f32, shape: EmitterShape, template: ParticleTemplate) -> Self {
1003        Self {
1004            system:      ParticleSystem::new(4096),
1005            rate,
1006            shape,
1007            template,
1008            accumulator: 0.0,
1009            active:      true,
1010            duration:    None,
1011            elapsed:     0.0,
1012            bursts:      Vec::new(),
1013        }
1014    }
1015
1016    pub fn with_duration(mut self, secs: f32) -> Self { self.duration = Some(secs); self }
1017    pub fn with_burst(mut self, b: BurstEvent) -> Self { self.bursts.push(b); self }
1018    pub fn with_capacity(mut self, n: usize) -> Self { self.system.pool = ParticlePool::new(n); self }
1019
1020    pub fn tick(&mut self, dt: f32) {
1021        if !self.active { self.system.tick(dt); return; }
1022
1023        self.elapsed += dt;
1024        if let Some(dur) = self.duration {
1025            if self.elapsed >= dur { self.active = false; }
1026        }
1027
1028        // Continuous emission
1029        self.accumulator += self.rate * dt;
1030        let count = self.accumulator as u32;
1031        if count > 0 {
1032            self.system.burst(&self.shape, count, &self.template);
1033            self.accumulator -= count as f32;
1034        }
1035
1036        // Burst events
1037        for b in &mut self.bursts {
1038            if !b.fired && self.elapsed >= b.time {
1039                self.system.burst(&self.shape, b.count, &self.template);
1040                b.fired = true;
1041            }
1042        }
1043
1044        self.system.tick(dt);
1045    }
1046
1047    pub fn pool(&self) -> &ParticlePool { &self.system.pool }
1048}
1049
1050// ─── Particle group ───────────────────────────────────────────────────────────
1051
1052/// A named group of particles with shared behavior modifiers.
1053#[derive(Debug)]
1054pub struct ParticleGroup {
1055    pub name:       String,
1056    pub id:         u32,
1057    pub color_mult: Vec4,
1058    pub speed_mult: f32,
1059    pub life_mult:  f32,
1060}
1061
1062impl ParticleGroup {
1063    pub fn new(id: u32, name: impl Into<String>) -> Self {
1064        Self { id, name: name.into(), color_mult: Vec4::ONE, speed_mult: 1.0, life_mult: 1.0 }
1065    }
1066}
1067
1068// ─── Trail renderer data ──────────────────────────────────────────────────────
1069
1070/// Computed trail ribbon geometry for a single particle.
1071#[derive(Clone, Debug)]
1072pub struct TrailRibbon {
1073    pub positions: Vec<Vec3>,
1074    pub colors:    Vec<Vec4>,
1075    pub widths:    Vec<f32>,
1076}
1077
1078impl TrailRibbon {
1079    pub fn build(positions: &[Vec3], base_color: Vec4, base_width: f32) -> Self {
1080        let n = positions.len();
1081        let mut colors = Vec::with_capacity(n);
1082        let mut widths = Vec::with_capacity(n);
1083        for i in 0..n {
1084            let t = i as f32 / (n.max(2) - 1) as f32;
1085            let alpha = t; // head bright, tail fades
1086            colors.push(Vec4::new(base_color.x, base_color.y, base_color.z, alpha * base_color.w));
1087            widths.push(base_width * alpha);
1088        }
1089        Self { positions: positions.to_vec(), colors, widths }
1090    }
1091}
1092
1093// ─── LOD particle system ──────────────────────────────────────────────────────
1094
1095/// A multi-LOD particle system that reduces fidelity based on camera distance.
1096pub struct LodParticleSystem {
1097    /// LOD0 = full quality, LOD3 = billboard only.
1098    pub lods:        [ContinuousEmitter; 4],
1099    pub lod_ranges:  [f32; 4],
1100    pub position:    Vec3,
1101    current_lod:     usize,
1102}
1103
1104impl LodParticleSystem {
1105    pub fn new(base_rate: f32, shape: EmitterShape, template: ParticleTemplate) -> Self {
1106        let e0 = ContinuousEmitter::new(base_rate, shape.clone(), template.clone());
1107        let e1 = ContinuousEmitter::new(base_rate * 0.6, shape.clone(), template.clone());
1108        let e2 = ContinuousEmitter::new(base_rate * 0.3, shape.clone(), template.clone());
1109        let e3 = ContinuousEmitter::new(base_rate * 0.1, shape, template);
1110        Self {
1111            lods:        [e0, e1, e2, e3],
1112            lod_ranges:  [20.0, 50.0, 100.0, 200.0],
1113            position:    Vec3::ZERO,
1114            current_lod: 0,
1115        }
1116    }
1117
1118    pub fn tick(&mut self, dt: f32, camera_pos: Vec3) {
1119        let dist = (self.position - camera_pos).length();
1120        self.current_lod = 3;
1121        for (i, &range) in self.lod_ranges.iter().enumerate() {
1122            if dist <= range { self.current_lod = i; break; }
1123        }
1124        self.lods[self.current_lod].tick(dt);
1125    }
1126
1127    pub fn active_pool(&self) -> &ParticlePool { self.lods[self.current_lod].pool() }
1128}
1129
1130// ─── GPU instance buffer ──────────────────────────────────────────────────────
1131
1132/// GPU-compatible instanced particle data: position + size + color (10 floats).
1133#[repr(C)]
1134#[derive(Clone, Copy, Debug, Default)]
1135pub struct GpuParticleInstance {
1136    pub position: [f32; 3],
1137    pub size:     f32,
1138    pub color:    [f32; 4],
1139    pub velocity: [f32; 3],
1140    pub age_frac: f32,
1141}
1142
1143impl GpuParticleInstance {
1144    pub fn from_particle(p: &MathParticle) -> Self {
1145        let lf = (p.age / p.lifetime).clamp(0.0, 1.0);
1146        Self {
1147            position: p.glyph.position.to_array(),
1148            size:     p.scale,
1149            color:    p.glyph.color.to_array(),
1150            velocity: p.velocity.to_array(),
1151            age_frac: lf,
1152        }
1153    }
1154}
1155
1156pub fn export_gpu_instances(pool: &ParticlePool) -> Vec<GpuParticleInstance> {
1157    pool.iter().map(GpuParticleInstance::from_particle).collect()
1158}
1159
1160// ─── Preset emitter configurations ───────────────────────────────────────────
1161
1162/// Preset emitter configurations for common game events.
1163#[derive(Clone, Debug)]
1164pub enum EmitterPreset {
1165    /// 40 radial-burst particles, gravity+friction, lifetime 1.5s. Used for enemy death.
1166    DeathExplosion { color: Vec4 },
1167    /// 30 upward fountain particles. Used for level-up.
1168    LevelUpFountain,
1169    /// 16 spark ring. Used for crits.
1170    CritBurst,
1171    /// 8-16 hit sparks. Used for normal hits.
1172    HitSparks { color: Vec4, count: u8 },
1173    /// 12 slow-orbiting sparkles. Used for loot drops.
1174    LootSparkle { color: Vec4 },
1175    /// Status effect ambient particles.
1176    StatusAmbient { effect_mask: u8 },
1177    /// Stun orbiting stars.
1178    StunOrbit,
1179    /// Room-type ambient particles.
1180    RoomAmbient { room_type_id: u8 },
1181    /// Boss-specific entrance burst.
1182    BossEntrance { boss_id: u8 },
1183    /// Gravitational collapse spiral (for heavy damage hits).
1184    GravitationalCollapse { color: Vec4, attractor: AttractorType },
1185    /// Self-organizing spell stream.
1186    SpellStream { element_color: Vec4 },
1187    /// Golden spiral healing ascent.
1188    HealSpiral,
1189    /// Entropy cascade (corruption milestone, fills entire screen).
1190    EntropyCascade,
1191    /// Fire burst.
1192    FireBurst { intensity: f32 },
1193    /// Smoke puff.
1194    SmokePuff,
1195    /// Electric discharge.
1196    ElectricDischarge { color: Vec4 },
1197    /// Blood splatter.
1198    BloodSplatter { color: Vec4, count: u8 },
1199    /// Ice shatter.
1200    IceShatter,
1201    /// Poison cloud.
1202    PoisonCloud,
1203    /// Teleport flash.
1204    TeleportFlash { color: Vec4 },
1205    /// Shield hit impact.
1206    ShieldHit { shield_color: Vec4 },
1207    /// Coin scatter.
1208    CoinScatter { count: u8 },
1209    /// Rubble debris.
1210    RubbleDebris { count: u8 },
1211    /// Rain shower.
1212    RainShower,
1213    /// Snow fall.
1214    SnowFall,
1215    /// Confetti burst.
1216    ConfettiBurst,
1217    /// Custom template.
1218    Custom { template: ParticleTemplate, count: u32, shape: EmitterShape },
1219}
1220
1221/// Spawn particles from a preset into a pool.
1222pub fn emit(scene: &mut crate::scene::Scene, preset: EmitterPreset, origin: Vec3) {
1223    emitters::emit_preset(&mut scene.particles, preset, origin);
1224}
1225
1226// ─── Utility: fast RNG ────────────────────────────────────────────────────────
1227
1228/// Xoshiro-style fast RNG for particle systems.
1229#[derive(Clone, Debug)]
1230pub struct FastRng {
1231    state: u64,
1232}
1233
1234impl FastRng {
1235    pub fn new(seed: u64) -> Self { Self { state: seed ^ 0x9E3779B97F4A7C15 } }
1236
1237    pub fn next(&mut self) -> u64 {
1238        let mut x = self.state;
1239        x ^= x << 13;
1240        x ^= x >> 7;
1241        x ^= x << 17;
1242        self.state = x;
1243        x
1244    }
1245
1246    pub fn f32(&mut self) -> f32 {
1247        (self.next() & 0x00FF_FFFF) as f32 / 0x00FF_FFFF as f32
1248    }
1249
1250    pub fn range(&mut self, min: f32, max: f32) -> f32 {
1251        min + self.f32() * (max - min)
1252    }
1253
1254    pub fn range_u32(&mut self, min: u32, max: u32) -> u32 {
1255        if min >= max { return min; }
1256        min + (self.next() as u32 % (max - min))
1257    }
1258
1259    /// Returns a random unit-sphere direction and its length.
1260    pub fn unit_sphere(&mut self) -> (Vec3, f32) {
1261        loop {
1262            let x = self.range(-1.0, 1.0);
1263            let y = self.range(-1.0, 1.0);
1264            let z = self.range(-1.0, 1.0);
1265            let len = (x*x + y*y + z*z).sqrt();
1266            if len > 0.0 && len <= 1.0 {
1267                return (Vec3::new(x/len, y/len, z/len), len);
1268            }
1269        }
1270    }
1271}
1272
1273// ─── Range parameter ──────────────────────────────────────────────────────────
1274
1275/// A min/max range parameter that returns a random value when sampled.
1276#[derive(Clone, Debug)]
1277pub struct RangeParam {
1278    pub min: f32,
1279    pub max: f32,
1280}
1281
1282impl RangeParam {
1283    pub fn constant(v: f32) -> Self { Self { min: v, max: v } }
1284    pub fn range(min: f32, max: f32) -> Self { Self { min, max } }
1285    pub fn sample(&self, rng: &mut FastRng) -> f32 { rng.range(self.min, self.max) }
1286}
1287
1288// ─── Utilities ────────────────────────────────────────────────────────────────
1289
1290/// Jitter a direction vector by `spread` radians.
1291fn jitter_direction(dir: Vec3, spread: f32, rng: &mut FastRng) -> Vec3 {
1292    if spread < 0.001 { return dir.normalize_or_zero(); }
1293    let (perp, _) = rng.unit_sphere();
1294    let jitter = dir + perp * spread;
1295    jitter.normalize_or_zero()
1296}
1297
1298/// A simple 3D pseudo-noise function for turbulence.
1299fn pseudo_noise3(p: Vec3, t: f32) -> f32 {
1300    let ix = p.x.floor() as i32;
1301    let iy = p.y.floor() as i32;
1302    let iz = p.z.floor() as i32;
1303    let it = (t * 10.0) as i32;
1304    let h = hash4(ix, iy, iz, it);
1305    let fx = p.x - p.x.floor();
1306    let fy = p.y - p.y.floor();
1307    let fz = p.z - p.z.floor();
1308    // Smoothstep blend
1309    let ux = fx * fx * (3.0 - 2.0 * fx);
1310    let uy = fy * fy * (3.0 - 2.0 * fy);
1311    // Lerp over corners (simplified single-octave)
1312    let n = hash4(ix + (ux > 0.5) as i32, iy + (uy > 0.5) as i32, iz, it);
1313    n as f32 / u32::MAX as f32
1314}
1315
1316fn hash4(x: i32, y: i32, z: i32, w: i32) -> u32 {
1317    let mut h = (x as u32).wrapping_mul(1619)
1318        ^ (y as u32).wrapping_mul(31337)
1319        ^ (z as u32).wrapping_mul(1013904223)
1320        ^ (w as u32).wrapping_mul(2654435769);
1321    h ^= h >> 16; h = h.wrapping_mul(0x45d9f3b);
1322    h ^= h >> 16; h
1323}
1324
1325// ─── Particle effect presets ──────────────────────────────────────────────────
1326
1327/// A named, reusable particle effect that combines template + shape + forces.
1328#[derive(Clone, Debug)]
1329pub struct ParticleEffect {
1330    pub name:      String,
1331    pub template:  ParticleTemplate,
1332    pub shape:     EmitterShape,
1333    pub rate:      f32,
1334    pub count:     u32,
1335    pub forces:    Vec<ParticleForce>,
1336    pub duration:  Option<f32>,
1337}
1338
1339impl ParticleEffect {
1340    pub fn new(name: impl Into<String>) -> Self {
1341        Self {
1342            name:     name.into(),
1343            template: ParticleTemplate::default(),
1344            shape:    EmitterShape::Point,
1345            rate:     20.0,
1346            count:    1,
1347            forces:   Vec::new(),
1348            duration: None,
1349        }
1350    }
1351
1352    pub fn campfire() -> Self {
1353        Self {
1354            name:     "campfire".into(),
1355            template: ParticleTemplate::fire(),
1356            shape:    EmitterShape::Disk { radius: 0.3 },
1357            rate:     40.0,
1358            count:    2,
1359            forces:   vec![
1360                ParticleForce::Turbulence { strength: 0.5, frequency: 2.0, octaves: 2 },
1361                ParticleForce::Constant { force: Vec3::new(0.0, 1.2, 0.0) },
1362            ],
1363            duration: None,
1364        }
1365    }
1366
1367    pub fn explosion() -> Self {
1368        Self {
1369            name:     "explosion".into(),
1370            template: ParticleTemplate {
1371                lifetime:       RangeParam::range(0.4, 1.2),
1372                speed:          RangeParam::range(5.0, 20.0),
1373                size:           RangeParam::range(1.0, 2.5),
1374                spread:         3.14,
1375                drag:           0.08,
1376                character:      '█',
1377                emission:       1.5,
1378                color_over_life: Some(ColorGradient::fire()),
1379                flags:          ParticleFlags::GRAVITY,
1380                ..Default::default()
1381            },
1382            shape:    EmitterShape::Sphere { radius: 0.5 },
1383            rate:     0.0,
1384            count:    80,
1385            forces:   vec![ParticleForce::Constant { force: Vec3::new(0.0, -9.81, 0.0) }],
1386            duration: Some(0.05),
1387        }
1388    }
1389
1390    pub fn rain_shower() -> Self {
1391        Self {
1392            name:     "rain".into(),
1393            template: ParticleTemplate::rain(),
1394            shape:    EmitterShape::Box { half_extents: Vec3::new(20.0, 0.0, 20.0) },
1395            rate:     500.0,
1396            count:    10,
1397            forces:   vec![
1398                ParticleForce::Constant { force: Vec3::new(0.3, -15.0, 0.0) },
1399                ParticleForce::KillPlane { y: -1.0 },
1400            ],
1401            duration: None,
1402        }
1403    }
1404}
1405
1406// ─── Particle effect library ──────────────────────────────────────────────────
1407
1408/// Registry of named particle effects.
1409pub struct ParticleLibrary {
1410    effects: HashMap<String, ParticleEffect>,
1411}
1412
1413impl ParticleLibrary {
1414    pub fn new() -> Self {
1415        let mut lib = Self { effects: HashMap::new() };
1416        lib.register(ParticleEffect::campfire());
1417        lib.register(ParticleEffect::explosion());
1418        lib.register(ParticleEffect::rain_shower());
1419        lib
1420    }
1421
1422    pub fn register(&mut self, effect: ParticleEffect) {
1423        self.effects.insert(effect.name.clone(), effect);
1424    }
1425
1426    pub fn get(&self, name: &str) -> Option<&ParticleEffect> {
1427        self.effects.get(name)
1428    }
1429
1430    pub fn names(&self) -> Vec<&str> {
1431        self.effects.keys().map(|s| s.as_str()).collect()
1432    }
1433
1434    /// Instantiate an effect into a ContinuousEmitter.
1435    pub fn instantiate(&self, name: &str) -> Option<ContinuousEmitter> {
1436        let e = self.effects.get(name)?;
1437        let mut emitter = ContinuousEmitter::new(e.rate, e.shape.clone(), e.template.clone());
1438        for f in &e.forces {
1439            emitter.system.forces.push(f.clone());
1440        }
1441        if let Some(d) = e.duration { emitter = emitter.with_duration(d); }
1442        Some(emitter)
1443    }
1444}
1445
1446impl Default for ParticleLibrary {
1447    fn default() -> Self { Self::new() }
1448}
1449
1450// ─── Particle statistics ──────────────────────────────────────────────────────
1451
1452/// System-wide particle statistics.
1453#[derive(Debug, Clone, Default)]
1454pub struct ParticleSystemStats {
1455    pub total_alive:   usize,
1456    pub total_spawned: u64,
1457    pub total_expired: u64,
1458    pub total_dropped: u64,
1459    pub emitter_count: usize,
1460}
1461
1462impl ParticleSystemStats {
1463    pub fn from_pool(pool: &ParticlePool) -> Self {
1464        Self {
1465            total_alive:   pool.stats.alive,
1466            total_spawned: pool.stats.spawned,
1467            total_expired: pool.stats.expired,
1468            total_dropped: pool.stats.dropped,
1469            emitter_count: 1,
1470        }
1471    }
1472}
1473
1474// ─── Tests ────────────────────────────────────────────────────────────────────
1475
1476#[cfg(test)]
1477mod tests {
1478    use super::*;
1479
1480    #[test]
1481    fn float_curve_linear() {
1482        let c = FloatCurve::linear(0.0, 10.0);
1483        assert!((c.evaluate(0.5) - 5.0).abs() < 0.01);
1484    }
1485
1486    #[test]
1487    fn color_gradient_evaluate() {
1488        let g = ColorGradient::fade_out(Vec4::ONE);
1489        let c = g.evaluate(1.0);
1490        assert!(c.w < 0.1);
1491    }
1492
1493    #[test]
1494    fn fast_rng_range() {
1495        let mut rng = FastRng::new(42);
1496        for _ in 0..1000 {
1497            let v = rng.range(0.0, 1.0);
1498            assert!(v >= 0.0 && v <= 1.0);
1499        }
1500    }
1501
1502    #[test]
1503    fn particle_pool_spawn_and_tick() {
1504        let mut pool = ParticlePool::new(16);
1505        let p = MathParticle {
1506            glyph: Glyph { position: Vec3::ZERO, ..Default::default() },
1507            behavior: MathFunction::Sine { amplitude: 1.0, frequency: 1.0, phase: 0.0 },
1508            trail: false, trail_length: 0, trail_decay: 0.0,
1509            interaction: ParticleInteraction::None,
1510            origin: Vec3::ZERO,
1511            age: 0.0, lifetime: 1.0,
1512            velocity: Vec3::new(0.0, 1.0, 0.0),
1513            acceleration: Vec3::ZERO,
1514            drag: 0.0, spin: 0.0, scale: 1.0,
1515            scale_over_life: None, color_over_life: None, size_over_life: None,
1516            group: None, sub_emitter: None,
1517            flags: ParticleFlags::empty(),
1518            user_data: [0.0; 4],
1519        };
1520        assert!(pool.spawn(p));
1521        assert_eq!(pool.count(), 1);
1522        pool.tick(2.0); // Should expire
1523        assert_eq!(pool.count(), 0);
1524    }
1525
1526    #[test]
1527    fn emitter_shape_sample() {
1528        let mut rng = FastRng::new(999);
1529        let shape = EmitterShape::Sphere { radius: 5.0 };
1530        for _ in 0..100 {
1531            let p = shape.sample(&mut rng);
1532            assert!((p.length() - 5.0).abs() < 0.1);
1533        }
1534    }
1535
1536    #[test]
1537    fn emitter_shape_disk() {
1538        let mut rng = FastRng::new(12345);
1539        let shape = EmitterShape::Disk { radius: 3.0 };
1540        for _ in 0..100 {
1541            let p = shape.sample(&mut rng);
1542            assert!(p.y.abs() < 0.001);
1543            assert!(glam::Vec2::new(p.x, p.z).length() <= 3.001);
1544        }
1545    }
1546
1547    #[test]
1548    fn particle_template_defaults() {
1549        let t = ParticleTemplate::default();
1550        assert_eq!(t.character, '·');
1551    }
1552
1553    #[test]
1554    fn scale_curve_evaluate() {
1555        let c = ScaleCurve::uniform(2.0, 0.5);
1556        let v = c.evaluate(0.5);
1557        assert!((v.x - 1.25).abs() < 0.01);
1558    }
1559
1560    #[test]
1561    fn particle_library_campfire() {
1562        let lib = ParticleLibrary::new();
1563        let e = lib.instantiate("campfire");
1564        assert!(e.is_some());
1565    }
1566
1567    #[test]
1568    fn gpu_export_buffer() {
1569        let pool = ParticlePool::new(64);
1570        let buf = pool.export_gpu_buffer();
1571        assert!(buf.is_empty());
1572    }
1573}