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