Skip to main content

alice_physics/
particle.rs

1//! General-Purpose Particle System
2//!
3//! A deterministic particle system supporting emitters, gravity, damping,
4//! force fields, and particle lifetimes. Uses the deterministic RNG for
5//! reproducible emission patterns.
6//!
7//! # Features
8//!
9//! - Multiple particle emitters with configurable spread, speed, and rate
10//! - Particle aging and lifetime management
11//! - Gravity and linear damping
12//! - Integration with the force field system
13//! - Deterministic via `DeterministicRng`
14
15use crate::force::{field_force_at, ForceField};
16use crate::math::{Fix128, Vec3Fix};
17use crate::rng::DeterministicRng;
18use crate::sdf_collider::SdfField;
19#[cfg(feature = "std")]
20use crate::sdf_collider::{collide_point_sdf, SdfCollider};
21
22#[cfg(not(feature = "std"))]
23use alloc::vec::Vec;
24
25// ============================================================================
26// Particle Types
27// ============================================================================
28
29/// Configuration for a particle emitter.
30#[derive(Clone, Debug)]
31pub struct ParticleEmitter {
32    /// Emitter position (world space)
33    pub position: Vec3Fix,
34    /// Emission direction (normalized)
35    pub direction: Vec3Fix,
36    /// Full apex angle of the emission cone in radians (0 = focused beam, PI = hemisphere);
37    /// the largest deflection from `direction` is `spread_angle / 2`, capped at PI/2
38    pub spread_angle: Fix128,
39    /// Number of particles emitted per second
40    pub emission_rate: Fix128,
41    /// Initial speed of emitted particles
42    pub initial_speed: Fix128,
43    /// Lifetime of emitted particles (seconds)
44    pub lifetime: Fix128,
45    /// Mass of emitted particles
46    pub particle_mass: Fix128,
47    /// Accumulated fractional emission (sub-frame emission tracking)
48    emission_accumulator: Fix128,
49}
50
51impl ParticleEmitter {
52    /// Create a new particle emitter.
53    #[must_use]
54    pub fn new(
55        position: Vec3Fix,
56        direction: Vec3Fix,
57        spread_angle: Fix128,
58        emission_rate: Fix128,
59        initial_speed: Fix128,
60        lifetime: Fix128,
61        particle_mass: Fix128,
62    ) -> Self {
63        Self {
64            position,
65            direction: direction.normalize(),
66            spread_angle,
67            emission_rate,
68            initial_speed,
69            lifetime,
70            particle_mass,
71            emission_accumulator: Fix128::ZERO,
72        }
73    }
74}
75
76/// A single particle in the system.
77#[derive(Clone, Debug)]
78pub struct Particle {
79    /// Current position
80    pub position: Vec3Fix,
81    /// Current velocity
82    pub velocity: Vec3Fix,
83    /// Time since emission (seconds)
84    pub age: Fix128,
85    /// Maximum lifetime (seconds)
86    pub lifetime: Fix128,
87    /// Particle mass
88    pub mass: Fix128,
89    /// Whether this particle is active
90    pub alive: bool,
91}
92
93impl Particle {
94    /// Create a new active particle.
95    #[must_use]
96    pub const fn new(position: Vec3Fix, velocity: Vec3Fix, lifetime: Fix128, mass: Fix128) -> Self {
97        Self {
98            position,
99            velocity,
100            age: Fix128::ZERO,
101            lifetime,
102            mass,
103            alive: true,
104        }
105    }
106}
107
108/// Something a moving particle can land on, for [`ParticleSystem::step_with_landing`].
109///
110/// Each target answers one question per sample point: "is this point occupied, and if
111/// so, which way is out?".
112#[non_exhaustive]
113pub enum LandingTarget<'a> {
114    /// A world-space signed distance field (unit scale, no transform). A sample point
115    /// lands when `distance < 0`; the normal is the field's outward normal and the
116    /// reported position is the point projected back onto the surface.
117    Field(&'a dyn SdfField),
118    /// A placed SDF collider (position, rotation, scale), tested with
119    /// [`collide_point_sdf`]; the reported position is the contact's surface point.
120    #[cfg(feature = "std")]
121    Collider(&'a SdfCollider),
122    /// A non-SDF occupant (cloth particles, a voxel mask, ...): returns the outward
123    /// normal when the point is occupied, `None` when it is free. The reported
124    /// position is the sample point itself.
125    Query(&'a dyn Fn(Vec3Fix) -> Option<Vec3Fix>),
126}
127
128impl core::fmt::Debug for LandingTarget<'_> {
129    fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
130        match self {
131            Self::Field(_) => f.write_str("LandingTarget::Field"),
132            #[cfg(feature = "std")]
133            Self::Collider(_) => f.write_str("LandingTarget::Collider"),
134            Self::Query(_) => f.write_str("LandingTarget::Query"),
135        }
136    }
137}
138
139impl LandingTarget<'_> {
140    /// `(surface position, outward normal)` when `point` is occupied by this target.
141    fn probe(&self, point: Vec3Fix) -> Option<(Vec3Fix, Vec3Fix)> {
142        match self {
143            Self::Field(field) => {
144                let (x, y, z) = point.to_f32();
145                let d = field.distance(x, y, z);
146                if d >= 0.0 {
147                    return None;
148                }
149                let (nx, ny, nz) = field.normal(x, y, z);
150                let normal = Vec3Fix::from_f32(nx, ny, nz).normalize();
151                Some((point + normal * Fix128::from_f32(-d), normal))
152            }
153            #[cfg(feature = "std")]
154            Self::Collider(collider) => {
155                collide_point_sdf(point, collider).map(|c| (c.point_b, c.normal))
156            }
157            Self::Query(query) => query(point).map(|normal| (point, normal)),
158        }
159    }
160}
161
162/// One particle landing on a [`LandingTarget`] during [`ParticleSystem::step_with_landing`].
163#[non_exhaustive]
164#[derive(Clone, Copy, Debug, PartialEq, Eq)]
165pub struct LandingEvent {
166    /// Index into [`ParticleSystem::particles`] of the particle that landed (the slot now
167    /// holds the particle re-emitted in its place)
168    pub particle: usize,
169    /// Index into the `targets` slice of the target that was hit
170    pub target: usize,
171    /// Landing position (on the surface for SDF targets, the sample point for queries)
172    pub position: Vec3Fix,
173    /// Outward surface normal at the landing position
174    pub normal: Vec3Fix,
175    /// Time from the start of the step to the first occupied sample, in `(0, dt]`
176    pub time_in_step: Fix128,
177}
178
179/// A particle system managing particles and emitters.
180pub struct ParticleSystem {
181    /// All particles (alive and dead)
182    pub particles: Vec<Particle>,
183    /// Particle emitters
184    pub emitters: Vec<ParticleEmitter>,
185    /// Gravity vector
186    pub gravity: Vec3Fix,
187    /// Linear velocity damping (0 = full damping, 1 = no damping)
188    pub damping: Fix128,
189    /// Maximum number of particles allowed
190    pub max_particles: usize,
191}
192
193impl ParticleSystem {
194    /// Create a new particle system.
195    ///
196    /// # Arguments
197    ///
198    /// - `max_particles`: Maximum number of live particles
199    /// - `gravity`: Gravity vector applied to all particles
200    #[must_use]
201    pub fn new(max_particles: usize, gravity: Vec3Fix) -> Self {
202        Self {
203            particles: Vec::new(),
204            emitters: Vec::new(),
205            gravity,
206            damping: Fix128::from_ratio(99, 100),
207            max_particles,
208        }
209    }
210
211    /// Add a particle emitter and return its index.
212    pub fn add_emitter(&mut self, emitter: ParticleEmitter) -> usize {
213        let idx = self.emitters.len();
214        self.emitters.push(emitter);
215        idx
216    }
217
218    /// Count the number of alive particles.
219    #[must_use]
220    pub fn alive_count(&self) -> usize {
221        self.particles.iter().filter(|p| p.alive).count()
222    }
223
224    /// Step the particle system by `dt` seconds.
225    ///
226    /// 1. Emit new particles from each emitter
227    /// 2. Apply gravity and damping to all alive particles
228    /// 3. Integrate positions (Euler)
229    /// 4. Age particles and kill expired ones
230    ///
231    /// The `rng` parameter ensures deterministic emission patterns.
232    pub fn step(&mut self, dt: Fix128, rng: &mut DeterministicRng) {
233        if dt.is_zero() {
234            return;
235        }
236
237        // --- Emit new particles ---
238        let num_emitters = self.emitters.len();
239        for ei in 0..num_emitters {
240            self.emit_particles(ei, dt, rng);
241        }
242
243        // --- Update existing particles ---
244        for particle in &mut self.particles {
245            if !particle.alive {
246                continue;
247            }
248
249            // Apply gravity
250            particle.velocity = particle.velocity + self.gravity * dt;
251
252            // Apply damping
253            particle.velocity = particle.velocity * self.damping;
254
255            // Integrate position
256            particle.position = particle.position + particle.velocity * dt;
257
258            // Age
259            particle.age = particle.age + dt;
260
261            // Kill expired particles
262            if particle.age >= particle.lifetime {
263                particle.alive = false;
264            }
265        }
266    }
267
268    /// Step like [`step`](Self::step), but stop each particle at the first
269    /// [`LandingTarget`] its path enters, report it and re-emit it from
270    /// `emitters[respawn_emitter]` (rain hitting shapes and falling again).
271    ///
272    /// The velocity update is the same as `step` (`v += g·dt`, `v *= damping`). The
273    /// straight path `x → x + v·dt` is then sampled at `n = ceil(|v·dt| / max_travel)`
274    /// evenly spaced points (`k/n` for `k = 1..=n`, the last one is exactly the position
275    /// `step` would produce), and the targets are probed at each sample in order. The
276    /// first occupied sample produces a [`LandingEvent`]; the slot is then refilled with
277    /// a fresh particle from the respawn emitter (same position / velocity law as normal
278    /// emission, drawing from `rng`), so landing never changes the number of live
279    /// particles. Particles that land are not aged or expired in that step.
280    ///
281    /// A target whose extent along the path is longer than `max_travel` cannot be
282    /// skipped, whatever `dt` is: consecutive samples are at most `max_travel` apart.
283    /// The cost is `O(|v·dt| / max_travel)` probes per particle per step. With no
284    /// targets the result is bit-identical to [`step`](Self::step).
285    ///
286    /// # Panics
287    ///
288    /// If `max_travel <= 0`, if `respawn_emitter` is not a valid emitter index, or if a
289    /// particle's step would need more than 2^20 samples (`|v·dt| / max_travel > 2^20`).
290    ///
291    /// # Example
292    ///
293    /// ```
294    /// use alice_physics::math::{Fix128, Vec3Fix};
295    /// use alice_physics::particle::{LandingTarget, ParticleEmitter, ParticleSystem};
296    /// use alice_physics::rng::DeterministicRng;
297    /// use alice_physics::sdf_collider::ClosureSdf;
298    ///
299    /// // floor y = 0
300    /// let floor = ClosureSdf::new(|_, y, _| y, |_, _, _| (0.0, 1.0, 0.0));
301    /// let mut rain = ParticleSystem::new(64, Vec3Fix::from_int(0, -10, 0));
302    /// rain.damping = Fix128::ONE;
303    /// rain.add_emitter(ParticleEmitter::new(
304    ///     Vec3Fix::from_int(0, 5, 0),
305    ///     Vec3Fix::from_int(0, -1, 0),
306    ///     Fix128::from_ratio(1, 4),
307    ///     Fix128::from_int(120),
308    ///     Fix128::ONE,
309    ///     Fix128::from_int(100),
310    ///     Fix128::ONE,
311    /// ));
312    /// let mut rng = DeterministicRng::new(7);
313    /// let targets = [LandingTarget::Field(&floor)];
314    /// let mut landed = 0;
315    /// for _ in 0..240 {
316    ///     let events = rain.step_with_landing(
317    ///         Fix128::from_ratio(1, 60),
318    ///         &mut rng,
319    ///         &targets,
320    ///         Fix128::from_ratio(1, 20),
321    ///         0,
322    ///     );
323    ///     for e in &events {
324    ///         assert!(e.normal.y > Fix128::ZERO);
325    ///     }
326    ///     landed += events.len();
327    /// }
328    /// assert!(landed > 0);
329    /// ```
330    pub fn step_with_landing(
331        &mut self,
332        dt: Fix128,
333        rng: &mut DeterministicRng,
334        targets: &[LandingTarget<'_>],
335        max_travel: Fix128,
336        respawn_emitter: usize,
337    ) -> Vec<LandingEvent> {
338        assert!(
339            max_travel > Fix128::ZERO,
340            "step_with_landing: max_travel must be positive"
341        );
342        assert!(
343            respawn_emitter < self.emitters.len(),
344            "step_with_landing: respawn_emitter {respawn_emitter} out of range ({} emitters)",
345            self.emitters.len()
346        );
347        let mut events = Vec::new();
348        if dt.is_zero() {
349            return events;
350        }
351
352        let num_emitters = self.emitters.len();
353        for ei in 0..num_emitters {
354            self.emit_particles(ei, dt, rng);
355        }
356
357        let gravity = self.gravity;
358        let damping = self.damping;
359        let respawn = &self.emitters[respawn_emitter];
360        for (index, particle) in self.particles.iter_mut().enumerate() {
361            if !particle.alive {
362                continue;
363            }
364            particle.velocity = particle.velocity + gravity * dt;
365            particle.velocity = particle.velocity * damping;
366            let start = particle.position;
367            let displacement = particle.velocity * dt;
368
369            let hit = if targets.is_empty() {
370                None
371            } else {
372                first_landing(start, displacement, dt, targets, max_travel)
373            };
374            if let Some((target, position, normal, time_in_step)) = hit {
375                events.push(LandingEvent {
376                    particle: index,
377                    target,
378                    position,
379                    normal,
380                    time_in_step,
381                });
382                particle.position = respawn.position;
383                particle.velocity = compute_emission_velocity(
384                    respawn.direction,
385                    respawn.initial_speed,
386                    respawn.spread_angle,
387                    rng,
388                );
389                particle.age = Fix128::ZERO;
390                particle.lifetime = respawn.lifetime;
391                particle.mass = respawn.particle_mass;
392                continue;
393            }
394
395            particle.position = start + particle.velocity * dt;
396            particle.age = particle.age + dt;
397            if particle.age >= particle.lifetime {
398                particle.alive = false;
399            }
400        }
401        events
402    }
403
404    /// Emit particles from a specific emitter.
405    fn emit_particles(&mut self, emitter_index: usize, dt: Fix128, rng: &mut DeterministicRng) {
406        let emitter = &mut self.emitters[emitter_index];
407
408        // Accumulate fractional emission
409        emitter.emission_accumulator = emitter.emission_accumulator + emitter.emission_rate * dt;
410
411        // Determine how many particles to emit this frame
412        let to_emit = emitter.emission_accumulator.hi.max(0) as usize;
413        if to_emit == 0 {
414            return;
415        }
416        emitter.emission_accumulator =
417            emitter.emission_accumulator - Fix128::from_int(to_emit as i64);
418
419        let direction = emitter.direction;
420        let speed = emitter.initial_speed;
421        let lifetime = emitter.lifetime;
422        let mass = emitter.particle_mass;
423        let position = emitter.position;
424        let spread = emitter.spread_angle;
425
426        for _ in 0..to_emit {
427            // `max_particles` bounds the number of live particles: no emission at the cap
428            if self.alive_count() >= self.max_particles {
429                break;
430            }
431            let vel = compute_emission_velocity(direction, speed, spread, rng);
432            // reuse a dead slot before growing the pool, so `particles.len()` stays bounded
433            if !self.recycle_dead_particle(position, vel, lifetime, mass) {
434                self.particles
435                    .push(Particle::new(position, vel, lifetime, mass));
436            }
437        }
438    }
439
440    /// Revive the first dead slot with a fresh particle; `false` when every slot is alive.
441    fn recycle_dead_particle(
442        &mut self,
443        position: Vec3Fix,
444        velocity: Vec3Fix,
445        lifetime: Fix128,
446        mass: Fix128,
447    ) -> bool {
448        for p in &mut self.particles {
449            if !p.alive {
450                p.position = position;
451                p.velocity = velocity;
452                p.age = Fix128::ZERO;
453                p.lifetime = lifetime;
454                p.mass = mass;
455                p.alive = true;
456                return true;
457            }
458        }
459        false
460    }
461
462    /// Apply a force field to all alive particles.
463    ///
464    /// The force is [`crate::force::field_force_at`] (the same laws as
465    /// [`crate::force::compute_force`] for rigid bodies), applied as a velocity
466    /// impulse: `v += F/m * dt`.
467    /// Uses a fixed dt of 1/60 for the impulse (since force fields are
468    /// typically applied once per frame).
469    pub fn apply_force_field(&mut self, field: &ForceField) {
470        let dt = Fix128::from_ratio(1, 60);
471
472        for particle in &mut self.particles {
473            if !particle.alive || particle.mass.is_zero() {
474                continue;
475            }
476
477            let force = field_force_at(field, particle.position, particle.velocity);
478            let inv_mass = Fix128::ONE / particle.mass;
479            particle.velocity = particle.velocity + force * inv_mass * dt;
480        }
481    }
482}
483
484/// Upper bound on the samples one particle may take in one
485/// [`ParticleSystem::step_with_landing`] call (`|v·dt| / max_travel`); a larger request
486/// panics instead of looping for an unbounded time.
487const MAX_LANDING_SAMPLES: usize = 1 << 20;
488
489/// First occupied sample on the segment `start → start + displacement`:
490/// `(target index, surface position, outward normal, time from step start)`.
491///
492/// Samples are at `k/n` of the segment for `k = 1..=n`, `n = ceil(|displacement| /
493/// max_travel)`, so two consecutive samples are never more than `max_travel` apart.
494fn first_landing(
495    start: Vec3Fix,
496    displacement: Vec3Fix,
497    dt: Fix128,
498    targets: &[LandingTarget<'_>],
499    max_travel: Fix128,
500) -> Option<(usize, Vec3Fix, Vec3Fix, Fix128)> {
501    let length = displacement.length();
502    let ratio = length / max_travel;
503    let whole = usize::try_from(ratio.hi).unwrap_or(usize::MAX);
504    let samples = if ratio.lo == 0 {
505        whole
506    } else {
507        whole.saturating_add(1)
508    }
509    .max(1);
510    assert!(
511        samples <= MAX_LANDING_SAMPLES,
512        "step_with_landing: |v*dt| / max_travel = {samples} samples exceeds {MAX_LANDING_SAMPLES}"
513    );
514    let n = Fix128::from_int(samples as i64);
515    for k in 1..=samples {
516        let (point, time) = if k == samples {
517            // the last sample is exactly the end point `step` would produce
518            (start + displacement, dt)
519        } else {
520            let frac = Fix128::from_int(k as i64) / n;
521            (start + displacement * frac, dt * frac)
522        };
523        for (ti, target) in targets.iter().enumerate() {
524            if let Some((position, normal)) = target.probe(point) {
525                return Some((ti, position, normal, time));
526            }
527        }
528    }
529    None
530}
531
532/// Compute emission velocity with spread.
533fn compute_emission_velocity(
534    direction: Vec3Fix,
535    speed: Fix128,
536    spread: Fix128,
537    rng: &mut DeterministicRng,
538) -> Vec3Fix {
539    if spread.is_zero() {
540        return direction * speed;
541    }
542
543    // Add random spread using the deterministic RNG
544    let rand_dir = rng.next_direction();
545
546    // Blend between exact direction and random direction based on spread
547    // spread = 0 -> exact, spread = PI -> fully random hemisphere
548    // `spread` is the full apex angle of the emission cone (PI = hemisphere, half-angle
549    // PI/2). d + t r with a unit random r deflects by at most asin(t), so t = sin(spread/2)
550    // makes the maximum deflection exactly spread/2; beyond PI the cone stays a hemisphere.
551    let full = if spread > Fix128::PI {
552        Fix128::PI
553    } else {
554        spread
555    };
556    let t = full.half().sin();
557    let blended = Vec3Fix::new(
558        direction.x + rand_dir.x * t,
559        direction.y + rand_dir.y * t,
560        direction.z + rand_dir.z * t,
561    )
562    .normalize();
563
564    blended * speed
565}
566
567impl core::fmt::Debug for ParticleSystem {
568    fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
569        f.debug_struct("ParticleSystem")
570            .field(
571                "particles",
572                &format_args!("[{} items]", self.particles.len()),
573            )
574            .field("emitters", &format_args!("[{} items]", self.emitters.len()))
575            .field("gravity", &self.gravity)
576            .field("damping", &self.damping)
577            .field("max_particles", &self.max_particles)
578            .finish()
579    }
580}
581
582// ============================================================================
583// Tests
584// ============================================================================
585
586#[cfg(all(test, feature = "std"))]
587mod tests {
588    use super::*;
589
590    #[test]
591    fn test_new_particle_system() {
592        let ps = ParticleSystem::new(100, Vec3Fix::from_int(0, -10, 0));
593        assert_eq!(ps.max_particles, 100);
594        assert_eq!(ps.alive_count(), 0);
595        assert!(ps.particles.is_empty());
596    }
597
598    #[test]
599    fn test_add_emitter() {
600        let mut ps = ParticleSystem::new(100, Vec3Fix::ZERO);
601        let emitter = ParticleEmitter::new(
602            Vec3Fix::ZERO,
603            Vec3Fix::UNIT_Y,
604            Fix128::ZERO,
605            Fix128::from_int(10),
606            Fix128::from_int(5),
607            Fix128::from_int(2),
608            Fix128::ONE,
609        );
610        let idx = ps.add_emitter(emitter);
611        assert_eq!(idx, 0);
612        assert_eq!(ps.emitters.len(), 1);
613    }
614
615    #[test]
616    fn test_step_emits_particles() {
617        let mut ps = ParticleSystem::new(1000, Vec3Fix::ZERO);
618        let emitter = ParticleEmitter::new(
619            Vec3Fix::ZERO,
620            Vec3Fix::UNIT_Y,
621            Fix128::ZERO,
622            Fix128::from_int(120), // 120 particles per second
623            Fix128::from_int(5),
624            Fix128::from_int(10),
625            Fix128::ONE,
626        );
627        ps.add_emitter(emitter);
628
629        let mut rng = DeterministicRng::new(42);
630        // Step for 1/60 s: expect 120/60 = 2 particles emitted
631        ps.step(Fix128::from_ratio(1, 60), &mut rng);
632
633        assert!(ps.alive_count() > 0);
634    }
635
636    #[test]
637    fn test_particles_age_and_die() {
638        let mut ps = ParticleSystem::new(1000, Vec3Fix::ZERO);
639        let emitter = ParticleEmitter::new(
640            Vec3Fix::ZERO,
641            Vec3Fix::UNIT_Y,
642            Fix128::ZERO,
643            Fix128::from_int(100),
644            Fix128::from_int(1),
645            Fix128::from_ratio(1, 10), // 0.1 second lifetime
646            Fix128::ONE,
647        );
648        ps.add_emitter(emitter);
649
650        let mut rng = DeterministicRng::new(42);
651
652        // Emit some particles
653        ps.step(Fix128::from_ratio(1, 60), &mut rng);
654        let alive_after_emit = ps.alive_count();
655        assert!(alive_after_emit > 0);
656
657        // Step many times until particles die
658        for _ in 0..30 {
659            ps.step(Fix128::from_ratio(1, 60), &mut rng);
660        }
661
662        // Some initial particles should have died by now (lifetime = 0.1s, 30 frames at 1/60 = 0.5s)
663        // But new ones keep getting emitted, so we just check that aging works
664        let has_dead = ps.particles.iter().any(|p| !p.alive);
665        assert!(has_dead, "Some particles should have expired");
666    }
667
668    #[test]
669    fn test_gravity_affects_particles() {
670        let mut ps = ParticleSystem::new(100, Vec3Fix::from_int(0, -10, 0));
671        let emitter = ParticleEmitter::new(
672            Vec3Fix::ZERO,
673            Vec3Fix::UNIT_X,
674            Fix128::ZERO,
675            Fix128::from_int(60),
676            Fix128::ZERO, // zero initial speed
677            Fix128::from_int(10),
678            Fix128::ONE,
679        );
680        ps.add_emitter(emitter);
681
682        let mut rng = DeterministicRng::new(42);
683        ps.step(Fix128::from_ratio(1, 60), &mut rng);
684
685        // Step again so gravity has time to act
686        ps.step(Fix128::from_ratio(1, 60), &mut rng);
687
688        // Particles should have negative Y velocity from gravity
689        for p in &ps.particles {
690            if p.alive && p.age > Fix128::ZERO {
691                assert!(
692                    p.velocity.y.is_negative(),
693                    "Gravity should pull particles down"
694                );
695            }
696        }
697    }
698
699    #[test]
700    fn test_max_particles_limit() {
701        let mut ps = ParticleSystem::new(5, Vec3Fix::ZERO);
702        let emitter = ParticleEmitter::new(
703            Vec3Fix::ZERO,
704            Vec3Fix::UNIT_Y,
705            Fix128::ZERO,
706            Fix128::from_int(1000), // Very high rate
707            Fix128::ONE,
708            Fix128::from_int(100),
709            Fix128::ONE,
710        );
711        ps.add_emitter(emitter);
712
713        let mut rng = DeterministicRng::new(42);
714        ps.step(Fix128::from_ratio(1, 10), &mut rng);
715
716        assert!(ps.alive_count() <= 5);
717    }
718
719    #[test]
720    fn test_zero_dt_no_emission() {
721        let mut ps = ParticleSystem::new(100, Vec3Fix::ZERO);
722        let emitter = ParticleEmitter::new(
723            Vec3Fix::ZERO,
724            Vec3Fix::UNIT_Y,
725            Fix128::ZERO,
726            Fix128::from_int(60),
727            Fix128::ONE,
728            Fix128::ONE,
729            Fix128::ONE,
730        );
731        ps.add_emitter(emitter);
732
733        let mut rng = DeterministicRng::new(42);
734        ps.step(Fix128::ZERO, &mut rng);
735
736        assert_eq!(ps.alive_count(), 0);
737    }
738
739    #[test]
740    fn test_deterministic_emission() {
741        // Two systems with same config and seed should produce identical results
742        let create_and_step = |seed: u64| -> Vec<(i64, u64, i64, u64)> {
743            let mut ps = ParticleSystem::new(100, Vec3Fix::from_int(0, -10, 0));
744            let emitter = ParticleEmitter::new(
745                Vec3Fix::ZERO,
746                Vec3Fix::UNIT_Y,
747                Fix128::from_ratio(1, 4),
748                Fix128::from_int(10),
749                Fix128::from_int(5),
750                Fix128::from_int(2),
751                Fix128::ONE,
752            );
753            ps.add_emitter(emitter);
754
755            let mut rng = DeterministicRng::new(seed);
756            for _ in 0..10 {
757                ps.step(Fix128::from_ratio(1, 60), &mut rng);
758            }
759
760            ps.particles
761                .iter()
762                .map(|p| {
763                    (
764                        p.position.x.hi,
765                        p.position.x.lo,
766                        p.position.y.hi,
767                        p.position.y.lo,
768                    )
769                })
770                .collect()
771        };
772
773        let result1 = create_and_step(12345);
774        let result2 = create_and_step(12345);
775        assert_eq!(result1, result2, "Particle system should be deterministic");
776    }
777
778    #[test]
779    fn test_apply_force_field_drag() {
780        let mut ps = ParticleSystem::new(100, Vec3Fix::ZERO);
781        ps.particles.push(Particle::new(
782            Vec3Fix::ZERO,
783            Vec3Fix::from_int(10, 0, 0),
784            Fix128::from_int(10),
785            Fix128::ONE,
786        ));
787
788        let drag = ForceField::Drag {
789            coefficient: Fix128::from_int(5),
790        };
791        ps.apply_force_field(&drag);
792
793        // Velocity should decrease due to drag
794        assert!(ps.particles[0].velocity.x < Fix128::from_int(10));
795    }
796
797    #[test]
798    fn test_apply_force_field_directional() {
799        let mut ps = ParticleSystem::new(100, Vec3Fix::ZERO);
800        ps.particles.push(Particle::new(
801            Vec3Fix::ZERO,
802            Vec3Fix::ZERO,
803            Fix128::from_int(10),
804            Fix128::ONE,
805        ));
806
807        let wind = ForceField::Directional {
808            direction: Vec3Fix::UNIT_X,
809            strength: Fix128::from_int(100),
810        };
811        ps.apply_force_field(&wind);
812
813        // Should have gained X velocity
814        assert!(ps.particles[0].velocity.x > Fix128::ZERO);
815    }
816
817    #[test]
818    fn test_particle_new() {
819        let p = Particle::new(
820            Vec3Fix::from_int(1, 2, 3),
821            Vec3Fix::from_int(4, 5, 6),
822            Fix128::from_int(10),
823            Fix128::ONE,
824        );
825        assert!(p.alive);
826        assert!(p.age.is_zero());
827        assert_eq!(p.position.x.hi, 1);
828    }
829
830    #[test]
831    fn test_emitter_direction_normalized() {
832        let emitter = ParticleEmitter::new(
833            Vec3Fix::ZERO,
834            Vec3Fix::from_int(3, 4, 0),
835            Fix128::ZERO,
836            Fix128::ONE,
837            Fix128::ONE,
838            Fix128::ONE,
839            Fix128::ONE,
840        );
841        let len = emitter.direction.length();
842        let eps = Fix128::from_ratio(1, 100);
843        assert!((len - Fix128::ONE).abs() < eps);
844    }
845
846    #[test]
847    fn test_damping_reduces_velocity() {
848        let mut ps = ParticleSystem::new(100, Vec3Fix::ZERO);
849        ps.damping = Fix128::from_ratio(9, 10); // 0.9 damping
850        ps.particles.push(Particle::new(
851            Vec3Fix::ZERO,
852            Vec3Fix::from_int(10, 0, 0),
853            Fix128::from_int(100),
854            Fix128::ONE,
855        ));
856
857        let mut rng = DeterministicRng::new(42);
858        ps.step(Fix128::from_ratio(1, 60), &mut rng);
859
860        // Velocity should be reduced by damping factor
861        assert!(ps.particles[0].velocity.x < Fix128::from_int(10));
862    }
863
864    #[test]
865    fn test_recycle_dead_particles() {
866        let mut ps = ParticleSystem::new(2, Vec3Fix::ZERO);
867        let emitter = ParticleEmitter::new(
868            Vec3Fix::ZERO,
869            Vec3Fix::UNIT_Y,
870            Fix128::ZERO,
871            Fix128::from_int(60),
872            Fix128::ONE,
873            Fix128::from_ratio(1, 60), // 1 frame lifetime
874            Fix128::ONE,
875        );
876        ps.add_emitter(emitter);
877
878        let mut rng = DeterministicRng::new(42);
879
880        // Emit particles
881        ps.step(Fix128::from_ratio(1, 60), &mut rng);
882        // Let them die
883        ps.step(Fix128::from_ratio(1, 60), &mut rng);
884        // Emit again (should recycle)
885        ps.step(Fix128::from_ratio(1, 60), &mut rng);
886
887        // Total particle count should not exceed max
888        assert!(ps.alive_count() <= 2);
889    }
890}