Skip to main content

proof_engine/vfx/
emitter.rs

1//! Particle emitter system: shapes, spawn modes, rate curves, transform animation, LOD.
2
3use glam::{Vec2, Vec3, Vec4, Quat, Mat4};
4use std::collections::HashMap;
5
6// ─── Tag system ───────────────────────────────────────────────────────────────
7
8/// Bitmask tag applied to particles for force-field masking and categorisation.
9#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
10pub struct ParticleTag(pub u32);
11
12impl ParticleTag {
13    pub const NONE:     ParticleTag = ParticleTag(0);
14    pub const FIRE:     ParticleTag = ParticleTag(1 << 0);
15    pub const SMOKE:    ParticleTag = ParticleTag(1 << 1);
16    pub const SPARK:    ParticleTag = ParticleTag(1 << 2);
17    pub const MAGIC:    ParticleTag = ParticleTag(1 << 3);
18    pub const WATER:    ParticleTag = ParticleTag(1 << 4);
19    pub const DUST:     ParticleTag = ParticleTag(1 << 5);
20    pub const BLOOD:    ParticleTag = ParticleTag(1 << 6);
21    pub const DEBRIS:   ParticleTag = ParticleTag(1 << 7);
22    pub const ENERGY:   ParticleTag = ParticleTag(1 << 8);
23    pub const ALL:      ParticleTag = ParticleTag(u32::MAX);
24
25    pub fn contains(self, other: ParticleTag) -> bool {
26        self.0 & other.0 == other.0
27    }
28    pub fn union(self, other: ParticleTag) -> ParticleTag {
29        ParticleTag(self.0 | other.0)
30    }
31}
32
33// ─── Spawn shape ──────────────────────────────────────────────────────────────
34
35/// The geometric shape from which particles are born.
36#[derive(Debug, Clone)]
37pub enum EmitterShape {
38    /// Single world-space point.
39    Point,
40
41    /// Line segment from `start` to `end`; particles spawn at random positions along it.
42    Line {
43        start: Vec3,
44        end:   Vec3,
45        /// If true, emit only from the two end points alternately.
46        endpoints_only: bool,
47    },
48
49    /// Rectangular box volume.
50    Box {
51        half_extents: Vec3,
52        /// Spawn only on the surface of the box, not the interior.
53        surface_only: bool,
54    },
55
56    /// Sphere or hemisphere.
57    Sphere {
58        radius:      f32,
59        inner_radius: f32,    // hollow core; 0 = solid
60        hemisphere:  bool,    // upper hemisphere only
61    },
62
63    /// Circle / disc in the XZ plane.
64    Disc {
65        radius:      f32,
66        inner_radius: f32,
67        arc_degrees: f32,     // 360 = full disc
68    },
69
70    /// Cone opening along +Y.
71    Cone {
72        angle_degrees: f32,
73        height:        f32,
74        base_radius:   f32,
75    },
76
77    /// Torus (donut) in the XZ plane.
78    Torus {
79        major_radius: f32,
80        minor_radius: f32,
81    },
82
83    /// Triangle mesh surface — particles spawn at random barycentric coords on random triangles.
84    MeshSurface {
85        /// Flat list of triangle vertices, groups of 3.
86        vertices:       Vec<Vec3>,
87        /// Per-vertex normals; must match `vertices` length.
88        normals:        Vec<Vec3>,
89        /// Cumulative area weights for importance sampling.
90        area_weights:   Vec<f32>,
91        /// Emit from inner volume using centroid + random offset.
92        volume_fill:    bool,
93    },
94}
95
96impl EmitterShape {
97    /// Sample a position and outward normal from this shape using a simple LCG state.
98    pub fn sample(&self, rng: &mut u64) -> (Vec3, Vec3) {
99        match self {
100            EmitterShape::Point => (Vec3::ZERO, Vec3::Y),
101
102            EmitterShape::Line { start, end, endpoints_only } => {
103                let t = if *endpoints_only {
104                    if lcg_f32(rng) > 0.5 { 0.0 } else { 1.0 }
105                } else {
106                    lcg_f32(rng)
107                };
108                let pos = *start + (*end - *start) * t;
109                let normal = (*end - *start).normalize_or_zero().cross(Vec3::Y).normalize_or_zero();
110                (pos, normal)
111            }
112
113            EmitterShape::Box { half_extents, surface_only } => {
114                if *surface_only {
115                    // Pick a random face
116                    let face = (lcg_f32(rng) * 6.0) as usize % 6;
117                    let axis = face / 2;
118                    let sign = if face % 2 == 0 { 1.0_f32 } else { -1.0 };
119                    let u = lcg_f32(rng) * 2.0 - 1.0;
120                    let v = lcg_f32(rng) * 2.0 - 1.0;
121                    let mut pos = Vec3::ZERO;
122                    let mut normal = Vec3::ZERO;
123                    match axis {
124                        0 => { pos = Vec3::new(sign * half_extents.x, u * half_extents.y, v * half_extents.z); normal = Vec3::new(sign, 0.0, 0.0); }
125                        1 => { pos = Vec3::new(u * half_extents.x, sign * half_extents.y, v * half_extents.z); normal = Vec3::new(0.0, sign, 0.0); }
126                        _ => { pos = Vec3::new(u * half_extents.x, v * half_extents.y, sign * half_extents.z); normal = Vec3::new(0.0, 0.0, sign); }
127                    }
128                    (pos, normal)
129                } else {
130                    let pos = Vec3::new(
131                        (lcg_f32(rng) * 2.0 - 1.0) * half_extents.x,
132                        (lcg_f32(rng) * 2.0 - 1.0) * half_extents.y,
133                        (lcg_f32(rng) * 2.0 - 1.0) * half_extents.z,
134                    );
135                    (pos, Vec3::Y)
136                }
137            }
138
139            EmitterShape::Sphere { radius, inner_radius, hemisphere } => {
140                let theta = lcg_f32(rng) * std::f32::consts::TAU;
141                let phi = if *hemisphere {
142                    lcg_f32(rng) * std::f32::consts::FRAC_PI_2
143                } else {
144                    (lcg_f32(rng) * 2.0 - 1.0).acos()
145                };
146                let r = inner_radius + (radius - inner_radius) * lcg_f32(rng);
147                let normal = Vec3::new(phi.sin() * theta.cos(), phi.cos(), phi.sin() * theta.sin());
148                (normal * r, normal)
149            }
150
151            EmitterShape::Disc { radius, inner_radius, arc_degrees } => {
152                let arc = arc_degrees.to_radians();
153                let angle = lcg_f32(rng) * arc;
154                let r = (inner_radius + (radius - inner_radius) * lcg_f32(rng).sqrt()).max(0.0);
155                let pos = Vec3::new(angle.cos() * r, 0.0, angle.sin() * r);
156                (pos, Vec3::Y)
157            }
158
159            EmitterShape::Cone { angle_degrees, height, base_radius } => {
160                let h = lcg_f32(rng) * height;
161                let max_r_at_h = base_radius * (h / height.max(0.001));
162                let angle = lcg_f32(rng) * std::f32::consts::TAU;
163                let r = max_r_at_h * lcg_f32(rng).sqrt();
164                let half_angle = angle_degrees.to_radians() * 0.5;
165                let normal = Vec3::new(
166                    half_angle.sin() * angle.cos(),
167                    half_angle.cos(),
168                    half_angle.sin() * angle.sin(),
169                ).normalize_or_zero();
170                let pos = Vec3::new(angle.cos() * r, h, angle.sin() * r);
171                (pos, normal)
172            }
173
174            EmitterShape::Torus { major_radius, minor_radius } => {
175                let theta = lcg_f32(rng) * std::f32::consts::TAU;
176                let phi   = lcg_f32(rng) * std::f32::consts::TAU;
177                let center = Vec3::new(theta.cos() * major_radius, 0.0, theta.sin() * major_radius);
178                let normal = Vec3::new(theta.cos() * phi.cos(), phi.sin(), theta.sin() * phi.cos());
179                let pos = center + normal * *minor_radius;
180                (pos, normal)
181            }
182
183            EmitterShape::MeshSurface { vertices, normals, area_weights, volume_fill } => {
184                if vertices.len() < 3 || area_weights.is_empty() {
185                    return (Vec3::ZERO, Vec3::Y);
186                }
187                let target = lcg_f32(rng) * area_weights.last().copied().unwrap_or(1.0);
188                let tri_idx = area_weights.partition_point(|&w| w < target).min(area_weights.len() - 1);
189                let base = tri_idx * 3;
190                if base + 2 >= vertices.len() {
191                    return (Vec3::ZERO, Vec3::Y);
192                }
193                let a = vertices[base];
194                let b = vertices[base + 1];
195                let c = vertices[base + 2];
196                let na = normals.get(base).copied().unwrap_or(Vec3::Y);
197                let nb = normals.get(base + 1).copied().unwrap_or(Vec3::Y);
198                let nc = normals.get(base + 2).copied().unwrap_or(Vec3::Y);
199                let u = lcg_f32(rng);
200                let v = lcg_f32(rng) * (1.0 - u);
201                let w = 1.0 - u - v;
202                let pos = a * u + b * v + c * w;
203                let normal = (na * u + nb * v + nc * w).normalize_or_zero();
204                if *volume_fill {
205                    let centroid = (a + b + c) / 3.0;
206                    let offset = (pos - centroid) * lcg_f32(rng);
207                    (centroid + offset, normal)
208                } else {
209                    (pos, normal)
210                }
211            }
212        }
213    }
214}
215
216// ─── LCG helpers ──────────────────────────────────────────────────────────────
217
218#[inline]
219pub fn lcg_next(state: &mut u64) -> u64 {
220    *state = state.wrapping_mul(6364136223846793005).wrapping_add(1442695040888963407);
221    *state
222}
223
224#[inline]
225pub fn lcg_f32(state: &mut u64) -> f32 {
226    (lcg_next(state) >> 33) as f32 / (1u64 << 31) as f32
227}
228
229#[inline]
230pub fn lcg_range(state: &mut u64, min: f32, max: f32) -> f32 {
231    min + lcg_f32(state) * (max - min)
232}
233
234// ─── Spawn curve ──────────────────────────────────────────────────────────────
235
236/// How spawn rate is scheduled over the emitter's lifetime.
237#[derive(Debug, Clone)]
238pub enum SpawnCurve {
239    /// Constant particles per second.
240    Constant(f32),
241    /// Linear ramp from `start` to `end` rate over lifetime.
242    Linear { start: f32, end: f32 },
243    /// Smooth ease-in-out.
244    SmoothStep { start: f32, peak: f32, end: f32 },
245    /// Keyframe table: list of (normalised_time 0..1, rate) pairs.
246    Keyframes(Vec<(f32, f32)>),
247    /// Burst every N seconds: `burst_count` particles at once.
248    PeriodBurst { period: f32, burst_count: u32, timer: f32 },
249}
250
251impl SpawnCurve {
252    /// Evaluate continuous spawn rate at normalised lifetime `t` (0..1).
253    pub fn rate_at(&self, t: f32) -> f32 {
254        match self {
255            SpawnCurve::Constant(r) => *r,
256            SpawnCurve::Linear { start, end } => start + t * (end - start),
257            SpawnCurve::SmoothStep { start, peak, end } => {
258                if t < 0.5 {
259                    let s = t * 2.0;
260                    start + s * s * (3.0 - 2.0 * s) * (peak - start)
261                } else {
262                    let s = (t - 0.5) * 2.0;
263                    peak + s * s * (3.0 - 2.0 * s) * (end - peak)
264                }
265            }
266            SpawnCurve::Keyframes(kf) => {
267                if kf.is_empty() { return 0.0; }
268                if kf.len() == 1 { return kf[0].1; }
269                let i = kf.partition_point(|(kt, _)| *kt <= t);
270                if i == 0 { return kf[0].1; }
271                if i >= kf.len() { return kf.last().unwrap().1; }
272                let (t0, r0) = kf[i - 1];
273                let (t1, r1) = kf[i];
274                let frac = (t - t0) / (t1 - t0).max(1e-6);
275                r0 + frac * (r1 - r0)
276            }
277            SpawnCurve::PeriodBurst { period: _, burst_count, timer: _ } => *burst_count as f32,
278        }
279    }
280}
281
282// ─── Spawn mode ───────────────────────────────────────────────────────────────
283
284/// Whether the emitter emits continuously or fires a one-shot burst.
285#[derive(Debug, Clone, PartialEq)]
286pub enum SpawnMode {
287    /// Emit continuously as long as the emitter is active.
288    Continuous,
289    /// Fire exactly `count` particles at once then become inactive.
290    Burst { count: u32 },
291    /// Fire `count` particles spread over `duration` seconds then stop.
292    BurstOverTime { count: u32, duration: f32, emitted: u32 },
293}
294
295// ─── LOD system ───────────────────────────────────────────────────────────────
296
297/// A single LOD level mapping a view distance to a particle count multiplier.
298#[derive(Debug, Clone)]
299pub struct LodLevel {
300    /// Distance from camera at which this level activates.
301    pub distance: f32,
302    /// Fraction of nominal particle count (0.0 = disabled, 1.0 = full).
303    pub count_scale: f32,
304    /// Fraction of spawn rate (can differ from count_scale).
305    pub rate_scale: f32,
306    /// Override particle size scale at this LOD.
307    pub size_scale: f32,
308}
309
310impl LodLevel {
311    pub fn new(distance: f32, count_scale: f32) -> Self {
312        Self { distance, count_scale, rate_scale: count_scale, size_scale: 1.0 }
313    }
314    pub fn with_size_scale(mut self, s: f32) -> Self { self.size_scale = s; self }
315}
316
317/// LOD controller attached to an emitter.
318#[derive(Debug, Clone)]
319pub struct LodController {
320    /// Levels sorted ascending by distance. Last level covers ∞.
321    pub levels:           Vec<LodLevel>,
322    pub current_distance: f32,
323    pub enabled:          bool,
324}
325
326impl LodController {
327    pub fn new() -> Self {
328        Self {
329            levels: vec![
330                LodLevel::new(0.0,  1.0),
331                LodLevel::new(20.0, 0.7),
332                LodLevel::new(50.0, 0.4),
333                LodLevel::new(100.0, 0.15),
334                LodLevel::new(200.0, 0.0),
335            ],
336            current_distance: 0.0,
337            enabled: true,
338        }
339    }
340
341    pub fn with_levels(mut self, levels: Vec<LodLevel>) -> Self {
342        self.levels = levels;
343        self.levels.sort_by(|a, b| a.distance.partial_cmp(&b.distance).unwrap());
344        self
345    }
346
347    pub fn update_distance(&mut self, emitter_pos: Vec3, camera_pos: Vec3) {
348        self.current_distance = (emitter_pos - camera_pos).length();
349    }
350
351    fn active_level(&self) -> &LodLevel {
352        if !self.enabled || self.levels.is_empty() {
353            return &LodLevel { distance: 0.0, count_scale: 1.0, rate_scale: 1.0, size_scale: 1.0 };
354        }
355        // Walk from the end to find the last level where distance >= level.distance
356        let mut best = &self.levels[0];
357        for lv in &self.levels {
358            if self.current_distance >= lv.distance {
359                best = lv;
360            }
361        }
362        best
363    }
364
365    pub fn count_scale(&self) -> f32 { self.active_level().count_scale }
366    pub fn rate_scale(&self)  -> f32 { self.active_level().rate_scale }
367    pub fn size_scale(&self)  -> f32 { self.active_level().size_scale }
368    pub fn is_culled(&self)   -> bool { self.active_level().count_scale <= 0.0 }
369}
370
371impl Default for LodController {
372    fn default() -> Self { Self::new() }
373}
374
375// ─── Transform animation ──────────────────────────────────────────────────────
376
377/// A keyframe for emitter transform animation.
378#[derive(Debug, Clone)]
379pub struct TransformKeyframe {
380    pub time:        f32,
381    pub position:    Vec3,
382    pub rotation:    Quat,
383    pub scale:       Vec3,
384}
385
386/// Animates an emitter's transform over time.
387#[derive(Debug, Clone)]
388pub struct EmitterTransformAnim {
389    pub keyframes: Vec<TransformKeyframe>,
390    pub looping:   bool,
391    pub time:      f32,
392    pub duration:  f32,
393    pub playing:   bool,
394}
395
396impl EmitterTransformAnim {
397    pub fn new(duration: f32) -> Self {
398        Self { keyframes: Vec::new(), looping: false, time: 0.0, duration, playing: true }
399    }
400
401    pub fn add_keyframe(&mut self, kf: TransformKeyframe) {
402        self.keyframes.push(kf);
403        self.keyframes.sort_by(|a, b| a.time.partial_cmp(&b.time).unwrap());
404    }
405
406    pub fn tick(&mut self, dt: f32) {
407        if !self.playing { return; }
408        self.time += dt;
409        if self.looping {
410            self.time %= self.duration.max(0.001);
411        } else {
412            self.time = self.time.min(self.duration);
413        }
414    }
415
416    /// Sample interpolated transform matrix at the current time.
417    pub fn sample(&self) -> Mat4 {
418        if self.keyframes.is_empty() {
419            return Mat4::IDENTITY;
420        }
421        if self.keyframes.len() == 1 {
422            let kf = &self.keyframes[0];
423            return Mat4::from_scale_rotation_translation(kf.scale, kf.rotation, kf.position);
424        }
425
426        let t = self.time;
427        let i = self.keyframes.partition_point(|kf| kf.time <= t);
428
429        if i == 0 {
430            let kf = &self.keyframes[0];
431            return Mat4::from_scale_rotation_translation(kf.scale, kf.rotation, kf.position);
432        }
433        if i >= self.keyframes.len() {
434            let kf = self.keyframes.last().unwrap();
435            return Mat4::from_scale_rotation_translation(kf.scale, kf.rotation, kf.position);
436        }
437
438        let a = &self.keyframes[i - 1];
439        let b = &self.keyframes[i];
440        let span = (b.time - a.time).max(1e-6);
441        let f = (t - a.time) / span;
442
443        let pos   = a.position.lerp(b.position, f);
444        let rot   = a.rotation.slerp(b.rotation, f);
445        let scale = a.scale.lerp(b.scale, f);
446        Mat4::from_scale_rotation_translation(scale, rot, pos)
447    }
448
449    pub fn is_done(&self) -> bool { !self.looping && self.time >= self.duration }
450}
451
452// ─── Velocity init modes ──────────────────────────────────────────────────────
453
454/// How the initial velocity of a spawned particle is computed.
455#[derive(Debug, Clone)]
456pub enum VelocityMode {
457    /// Radially outward from the emitter origin, with random spread.
458    Radial {
459        speed_min: f32,
460        speed_max: f32,
461    },
462    /// Along the emitter's local +Y axis, with a cone spread.
463    Directional {
464        direction:     Vec3,
465        speed_min:     f32,
466        speed_max:     f32,
467        spread_radians: f32,
468    },
469    /// Along the surface normal at the spawn point.
470    Normal {
471        speed_min: f32,
472        speed_max: f32,
473        inward:    bool,
474    },
475    /// Completely random direction in a sphere.
476    Random {
477        speed_min: f32,
478        speed_max: f32,
479    },
480    /// Orbital around the emitter Y axis.
481    Orbital {
482        tangent_speed: f32,
483        upward_speed:  f32,
484    },
485    /// Fixed velocity vector.
486    Fixed(Vec3),
487}
488
489impl VelocityMode {
490    pub fn sample(&self, spawn_pos: Vec3, spawn_normal: Vec3, rng: &mut u64) -> Vec3 {
491        match self {
492            VelocityMode::Radial { speed_min, speed_max } => {
493                let dir = spawn_pos.normalize_or_zero();
494                let dir = if dir.length_squared() < 0.001 {
495                    random_unit_sphere(rng)
496                } else {
497                    dir
498                };
499                dir * lcg_range(rng, *speed_min, *speed_max)
500            }
501            VelocityMode::Directional { direction, speed_min, speed_max, spread_radians } => {
502                let base = direction.normalize_or_zero();
503                let perp = cone_spread(base, *spread_radians, rng);
504                perp * lcg_range(rng, *speed_min, *speed_max)
505            }
506            VelocityMode::Normal { speed_min, speed_max, inward } => {
507                let n = if *inward { -spawn_normal } else { spawn_normal };
508                n * lcg_range(rng, *speed_min, *speed_max)
509            }
510            VelocityMode::Random { speed_min, speed_max } => {
511                random_unit_sphere(rng) * lcg_range(rng, *speed_min, *speed_max)
512            }
513            VelocityMode::Orbital { tangent_speed, upward_speed } => {
514                let radial = Vec3::new(spawn_pos.x, 0.0, spawn_pos.z).normalize_or_zero();
515                let tangent = Vec3::Y.cross(radial).normalize_or_zero();
516                tangent * *tangent_speed + Vec3::Y * *upward_speed
517            }
518            VelocityMode::Fixed(v) => *v,
519        }
520    }
521}
522
523fn random_unit_sphere(rng: &mut u64) -> Vec3 {
524    loop {
525        let x = lcg_f32(rng) * 2.0 - 1.0;
526        let y = lcg_f32(rng) * 2.0 - 1.0;
527        let z = lcg_f32(rng) * 2.0 - 1.0;
528        let v = Vec3::new(x, y, z);
529        if v.length_squared() <= 1.0 && v.length_squared() > 1e-8 {
530            return v.normalize();
531        }
532    }
533}
534
535fn cone_spread(dir: Vec3, half_angle: f32, rng: &mut u64) -> Vec3 {
536    if half_angle <= 0.0 { return dir; }
537    let theta = lcg_f32(rng) * std::f32::consts::TAU;
538    let phi   = lcg_f32(rng) * half_angle;
539    let up = if dir.dot(Vec3::Y).abs() < 0.99 { Vec3::Y } else { Vec3::Z };
540    let right = dir.cross(up).normalize_or_zero();
541    let up2   = dir.cross(right).normalize_or_zero();
542    (dir * phi.cos() + (right * theta.cos() + up2 * theta.sin()) * phi.sin()).normalize_or_zero()
543}
544
545// ─── Colour over lifetime ─────────────────────────────────────────────────────
546
547/// A colour/alpha gradient evaluated over a particle's normalised lifetime (0..1).
548#[derive(Debug, Clone)]
549pub struct ColorOverLifetime {
550    /// Sorted list of (t, colour) stops.
551    pub stops: Vec<(f32, Vec4)>,
552}
553
554impl ColorOverLifetime {
555    pub fn constant(color: Vec4) -> Self {
556        Self { stops: vec![(0.0, color), (1.0, color)] }
557    }
558
559    pub fn two_stop(start: Vec4, end: Vec4) -> Self {
560        Self { stops: vec![(0.0, start), (1.0, end)] }
561    }
562
563    pub fn fire() -> Self {
564        Self { stops: vec![
565            (0.0, Vec4::new(1.0, 0.9, 0.2, 1.0)),
566            (0.4, Vec4::new(1.0, 0.4, 0.05, 1.0)),
567            (0.7, Vec4::new(0.5, 0.1, 0.0, 0.6)),
568            (1.0, Vec4::new(0.2, 0.1, 0.05, 0.0)),
569        ]}
570    }
571
572    pub fn smoke() -> Self {
573        Self { stops: vec![
574            (0.0, Vec4::new(0.6, 0.6, 0.6, 0.0)),
575            (0.1, Vec4::new(0.5, 0.5, 0.5, 0.7)),
576            (0.6, Vec4::new(0.3, 0.3, 0.3, 0.5)),
577            (1.0, Vec4::new(0.1, 0.1, 0.1, 0.0)),
578        ]}
579    }
580
581    pub fn sample(&self, t: f32) -> Vec4 {
582        if self.stops.is_empty() { return Vec4::ONE; }
583        if self.stops.len() == 1 { return self.stops[0].1; }
584        let i = self.stops.partition_point(|(st, _)| *st <= t);
585        if i == 0 { return self.stops[0].1; }
586        if i >= self.stops.len() { return self.stops.last().unwrap().1; }
587        let (t0, c0) = self.stops[i - 1];
588        let (t1, c1) = self.stops[i];
589        let f = (t - t0) / (t1 - t0).max(1e-6);
590        c0.lerp(c1, f)
591    }
592}
593
594// ─── Size over lifetime ───────────────────────────────────────────────────────
595
596#[derive(Debug, Clone)]
597pub struct SizeOverLifetime {
598    pub stops: Vec<(f32, f32)>,
599}
600
601impl SizeOverLifetime {
602    pub fn constant(size: f32) -> Self { Self { stops: vec![(0.0, size), (1.0, size)] } }
603    pub fn shrink(start: f32) -> Self { Self { stops: vec![(0.0, start), (1.0, 0.0)] } }
604    pub fn grow_shrink(peak: f32) -> Self {
605        Self { stops: vec![(0.0, 0.0), (0.3, peak), (1.0, 0.0)] }
606    }
607
608    pub fn sample(&self, t: f32) -> f32 {
609        if self.stops.is_empty() { return 1.0; }
610        if self.stops.len() == 1 { return self.stops[0].1; }
611        let i = self.stops.partition_point(|(st, _)| *st <= t);
612        if i == 0 { return self.stops[0].1; }
613        if i >= self.stops.len() { return self.stops.last().unwrap().1; }
614        let (t0, s0) = self.stops[i - 1];
615        let (t1, s1) = self.stops[i];
616        let f = (t - t0) / (t1 - t0).max(1e-6);
617        s0 + f * (s1 - s0)
618    }
619}
620
621// ─── Particle ─────────────────────────────────────────────────────────────────
622
623/// A live particle instance managed by an emitter.
624#[derive(Debug, Clone)]
625pub struct Particle {
626    pub id:            u64,
627    pub position:      Vec3,
628    pub velocity:      Vec3,
629    pub acceleration:  Vec3,
630    pub color:         Vec4,
631    pub size:          f32,
632    pub rotation:      f32,    // z-axis spin in radians
633    pub angular_vel:   f32,
634    pub age:           f32,
635    pub lifetime:      f32,
636    pub mass:          f32,
637    pub tag:           ParticleTag,
638    pub emitter_id:    u32,
639    pub custom:        [f32; 4],  // user-defined per-particle data
640}
641
642impl Particle {
643    pub fn normalized_age(&self) -> f32 {
644        (self.age / self.lifetime.max(1e-6)).min(1.0)
645    }
646
647    pub fn is_dead(&self) -> bool { self.age >= self.lifetime }
648
649    pub fn tick(&mut self, dt: f32) {
650        self.velocity   += self.acceleration * dt;
651        self.position   += self.velocity * dt;
652        self.rotation   += self.angular_vel * dt;
653        self.age        += dt;
654    }
655}
656
657// ─── Emitter config ───────────────────────────────────────────────────────────
658
659/// Full configuration for a particle emitter.
660#[derive(Debug, Clone)]
661pub struct EmitterConfig {
662    pub shape:            EmitterShape,
663    pub spawn_mode:       SpawnMode,
664    pub spawn_curve:      SpawnCurve,
665    pub velocity_mode:    VelocityMode,
666    pub color_over_life:  ColorOverLifetime,
667    pub size_over_life:   SizeOverLifetime,
668    pub lifetime_min:     f32,
669    pub lifetime_max:     f32,
670    pub size_min:         f32,
671    pub size_max:         f32,
672    pub mass_min:         f32,
673    pub mass_max:         f32,
674    pub angular_vel_min:  f32,
675    pub angular_vel_max:  f32,
676    pub max_particles:    usize,
677    pub tag:              ParticleTag,
678    pub inherit_velocity: f32,   // fraction of emitter's velocity to add
679    pub world_space:      bool,  // if false, particles are in emitter-local space
680    pub simulation_speed: f32,
681}
682
683impl Default for EmitterConfig {
684    fn default() -> Self {
685        Self {
686            shape:           EmitterShape::Point,
687            spawn_mode:      SpawnMode::Continuous,
688            spawn_curve:     SpawnCurve::Constant(10.0),
689            velocity_mode:   VelocityMode::Radial { speed_min: 1.0, speed_max: 3.0 },
690            color_over_life: ColorOverLifetime::two_stop(Vec4::ONE, Vec4::new(1.0, 1.0, 1.0, 0.0)),
691            size_over_life:  SizeOverLifetime::shrink(0.1),
692            lifetime_min:    1.0,
693            lifetime_max:    2.0,
694            size_min:        0.05,
695            size_max:        0.1,
696            mass_min:        1.0,
697            mass_max:        1.0,
698            angular_vel_min: -1.0,
699            angular_vel_max:  1.0,
700            max_particles:   256,
701            tag:             ParticleTag::NONE,
702            inherit_velocity: 0.0,
703            world_space:     true,
704            simulation_speed: 1.0,
705        }
706    }
707}
708
709// ─── Emitter ──────────────────────────────────────────────────────────────────
710
711/// A running particle emitter instance.
712pub struct Emitter {
713    pub id:            u32,
714    pub config:        EmitterConfig,
715    pub position:      Vec3,
716    pub rotation:      Quat,
717    pub scale:         Vec3,
718    pub velocity:      Vec3,       // world-space velocity of the emitter itself
719    pub particles:     Vec<Particle>,
720    pub active:        bool,
721    pub age:           f32,
722    pub duration:      f32,        // total lifetime; -1 = infinite
723    pub lod:           LodController,
724    pub transform_anim: Option<EmitterTransformAnim>,
725    spawn_accumulator: f32,
726    next_particle_id:  u64,
727    rng:               u64,
728}
729
730impl Emitter {
731    pub fn new(id: u32, config: EmitterConfig) -> Self {
732        Self {
733            id,
734            position:     Vec3::ZERO,
735            rotation:     Quat::IDENTITY,
736            scale:        Vec3::ONE,
737            velocity:     Vec3::ZERO,
738            particles:    Vec::with_capacity(config.max_particles.min(1024)),
739            active:       true,
740            age:          0.0,
741            duration:     -1.0,
742            lod:          LodController::new(),
743            transform_anim: None,
744            spawn_accumulator: 0.0,
745            next_particle_id: 1,
746            rng:          id as u64 ^ 0xDEAD_BEEF_1234_5678,
747            config,
748        }
749    }
750
751    pub fn at(mut self, pos: Vec3) -> Self { self.position = pos; self }
752    pub fn with_duration(mut self, secs: f32) -> Self { self.duration = secs; self }
753    pub fn with_lod(mut self, lod: LodController) -> Self { self.lod = lod; self }
754
755    /// Local-to-world transform matrix.
756    pub fn transform(&self) -> Mat4 {
757        Mat4::from_scale_rotation_translation(self.scale, self.rotation, self.position)
758    }
759
760    fn alloc_id(&mut self) -> u64 {
761        let id = self.next_particle_id;
762        self.next_particle_id += 1;
763        id
764    }
765
766    fn spawn_one(&mut self, spawn_normal_hint: Vec3) {
767        if self.particles.len() >= self.config.max_particles { return; }
768
769        // Allocate particle ID before borrowing rng
770        let particle_id = self.alloc_id();
771        let emitter_id  = self.id;
772        let position    = self.position;
773        let rotation    = self.rotation;
774        let scale       = self.scale;
775        let velocity    = self.velocity;
776        let world_space = self.config.world_space;
777        let inherit_vel = self.config.inherit_velocity;
778        let lod_size    = self.lod.size_scale();
779
780        let rng = &mut self.rng;
781        let (local_pos, normal) = self.config.shape.sample(rng);
782        let effective_normal = if normal.length_squared() > 0.5 { normal } else { spawn_normal_hint };
783        let world_pos = if world_space {
784            position + rotation * (local_pos * scale)
785        } else {
786            local_pos
787        };
788        let vel = self.config.velocity_mode.sample(local_pos, effective_normal, rng);
789        let world_vel = if world_space {
790            rotation * vel + velocity * inherit_vel
791        } else {
792            vel
793        };
794
795        let lifetime  = lcg_range(rng, self.config.lifetime_min, self.config.lifetime_max);
796        let size      = lcg_range(rng, self.config.size_min, self.config.size_max) * lod_size;
797        let mass      = lcg_range(rng, self.config.mass_min, self.config.mass_max);
798        let ang_vel   = lcg_range(rng, self.config.angular_vel_min, self.config.angular_vel_max);
799        let spin      = lcg_f32(rng) * std::f32::consts::TAU;
800        let color0    = self.config.color_over_life.sample(0.0);
801        let tag       = self.config.tag;
802
803        self.particles.push(Particle {
804            id:           particle_id,
805            position:     world_pos,
806            velocity:     world_vel,
807            acceleration: Vec3::ZERO,
808            color:        color0,
809            size,
810            rotation:     spin,
811            angular_vel:  ang_vel,
812            age:          0.0,
813            lifetime,
814            mass,
815            tag,
816            emitter_id,
817            custom:       [0.0; 4],
818        });
819    }
820
821    pub fn tick(&mut self, dt: f32, camera_pos: Vec3) {
822        if !self.active { return; }
823
824        let eff_dt = dt * self.config.simulation_speed;
825        self.age += eff_dt;
826
827        // Update LOD
828        self.lod.update_distance(self.position, camera_pos);
829        if self.lod.is_culled() {
830            // Still tick age, just don't update/spawn
831            return;
832        }
833
834        // Update transform animation
835        if let Some(ref mut anim) = self.transform_anim {
836            anim.tick(eff_dt);
837            let mat = anim.sample();
838            let (scale, rot, trans) = mat.to_scale_rotation_translation();
839            self.position = trans;
840            self.rotation = rot;
841            self.scale    = scale;
842        }
843
844        // Tick existing particles
845        for p in &mut self.particles {
846            p.tick(eff_dt);
847            let t = p.normalized_age();
848            p.color = self.config.color_over_life.sample(t);
849            p.size  = self.config.size_over_life.sample(t) * self.lod.size_scale();
850        }
851        self.particles.retain(|p| !p.is_dead());
852
853        // Check duration
854        if self.duration > 0.0 && self.age >= self.duration {
855            self.active = false;
856            return;
857        }
858
859        // Spawn new particles
860        let t_norm = if self.duration > 0.0 { self.age / self.duration } else { 0.5 };
861
862        match &mut self.config.spawn_mode {
863            SpawnMode::Burst { count } => {
864                let n = *count;
865                for _ in 0..n { self.spawn_one(Vec3::Y); }
866                self.active = false;
867            }
868            SpawnMode::BurstOverTime { count, duration, emitted } => {
869                let total    = *count;
870                let dur      = *duration;
871                let progress = (self.age / dur.max(1e-6)).min(1.0);
872                let target   = (progress * total as f32) as u32;
873                let to_spawn = target.saturating_sub(*emitted);
874                let em       = emitted as *mut u32;
875                for _ in 0..to_spawn.min(64) {
876                    self.spawn_one(Vec3::Y);
877                }
878                unsafe { *em += to_spawn.min(64); }
879                if self.age >= dur { self.active = false; }
880            }
881            SpawnMode::Continuous => {
882                let rate  = self.config.spawn_curve.rate_at(t_norm) * self.lod.rate_scale();
883                self.spawn_accumulator += rate * eff_dt;
884                let to_spawn = self.spawn_accumulator as u32;
885                self.spawn_accumulator -= to_spawn as f32;
886                for _ in 0..to_spawn { self.spawn_one(Vec3::Y); }
887            }
888        }
889    }
890
891    pub fn particle_count(&self) -> usize { self.particles.len() }
892    pub fn is_dead(&self) -> bool { !self.active && self.particles.is_empty() }
893}
894
895// ─── Emitter pool ─────────────────────────────────────────────────────────────
896
897/// Manages a collection of active emitters.
898pub struct EmitterPool {
899    emitters:   HashMap<u32, Emitter>,
900    next_id:    u32,
901    camera_pos: Vec3,
902}
903
904impl EmitterPool {
905    pub fn new() -> Self {
906        Self { emitters: HashMap::new(), next_id: 1, camera_pos: Vec3::ZERO }
907    }
908
909    pub fn set_camera(&mut self, pos: Vec3) { self.camera_pos = pos; }
910
911    pub fn spawn(&mut self, config: EmitterConfig) -> u32 {
912        let id = self.next_id; self.next_id += 1;
913        self.emitters.insert(id, Emitter::new(id, config));
914        id
915    }
916
917    pub fn spawn_at(&mut self, config: EmitterConfig, pos: Vec3) -> u32 {
918        let id = self.spawn(config);
919        if let Some(e) = self.emitters.get_mut(&id) { e.position = pos; }
920        id
921    }
922
923    pub fn get(&self,     id: u32) -> Option<&Emitter>     { self.emitters.get(&id) }
924    pub fn get_mut(&mut self, id: u32) -> Option<&mut Emitter> { self.emitters.get_mut(&id) }
925    pub fn remove(&mut self, id: u32) { self.emitters.remove(&id); }
926
927    pub fn tick(&mut self, dt: f32) {
928        let cam = self.camera_pos;
929        for e in self.emitters.values_mut() { e.tick(dt, cam); }
930        self.emitters.retain(|_, e| !e.is_dead());
931    }
932
933    pub fn all_emitters(&self) -> impl Iterator<Item = &Emitter> {
934        self.emitters.values()
935    }
936
937    pub fn all_particles(&self) -> impl Iterator<Item = &Particle> {
938        self.emitters.values().flat_map(|e| e.particles.iter())
939    }
940
941    pub fn total_particles(&self) -> usize {
942        self.emitters.values().map(|e| e.particle_count()).sum()
943    }
944
945    pub fn emitter_count(&self) -> usize { self.emitters.len() }
946}
947
948impl Default for EmitterPool {
949    fn default() -> Self { Self::new() }
950}
951
952// ─── Emitter builder ──────────────────────────────────────────────────────────
953
954/// Fluent builder for EmitterConfig.
955pub struct EmitterBuilder {
956    cfg: EmitterConfig,
957}
958
959impl EmitterBuilder {
960    pub fn new() -> Self { Self { cfg: EmitterConfig::default() } }
961
962    pub fn shape(mut self, s: EmitterShape)          -> Self { self.cfg.shape = s; self }
963    pub fn mode(mut self, m: SpawnMode)              -> Self { self.cfg.spawn_mode = m; self }
964    pub fn curve(mut self, c: SpawnCurve)            -> Self { self.cfg.spawn_curve = c; self }
965    pub fn velocity(mut self, v: VelocityMode)       -> Self { self.cfg.velocity_mode = v; self }
966    pub fn color(mut self, c: ColorOverLifetime)     -> Self { self.cfg.color_over_life = c; self }
967    pub fn size_curve(mut self, s: SizeOverLifetime) -> Self { self.cfg.size_over_life = s; self }
968    pub fn lifetime(mut self, min: f32, max: f32)    -> Self { self.cfg.lifetime_min = min; self.cfg.lifetime_max = max; self }
969    pub fn size(mut self, min: f32, max: f32)        -> Self { self.cfg.size_min = min; self.cfg.size_max = max; self }
970    pub fn max_particles(mut self, n: usize)         -> Self { self.cfg.max_particles = n; self }
971    pub fn tag(mut self, t: ParticleTag)             -> Self { self.cfg.tag = t; self }
972    pub fn world_space(mut self, ws: bool)           -> Self { self.cfg.world_space = ws; self }
973    pub fn sim_speed(mut self, s: f32)               -> Self { self.cfg.simulation_speed = s; self }
974
975    pub fn build(self) -> EmitterConfig { self.cfg }
976}
977
978impl Default for EmitterBuilder {
979    fn default() -> Self { Self::new() }
980}