Skip to main content

galeon_engine/
particle.rs

1// SPDX-License-Identifier: AGPL-3.0-only OR Commercial
2
3//! Particle/billboard primitive.
4//!
5//! Provides:
6//! - [`Emitter`]: a component that spawns [`Particle`] entities each tick at a
7//!   configured rate, sampling lifetime / velocity / size / color from
8//!   per-emitter [`FloatDist`] / [`Vec3Dist`] / [`ColorDist`] distributions and
9//!   capping the alive count at [`Emitter::max`].
10//! - [`Particle`]: short-lived entity component with `age`, `lifetime`,
11//!   `velocity`, `size`, `color`, and a back-reference to its source emitter.
12//! - [`Billboard`]: tag component that opts an entity into the billboard render
13//!   path. Rendering is wired separately (T2 of #217, depends on #215).
14//! - [`emitter_spawn_expire_system`]: the CPU spawn / expire system. Reads
15//!   [`FixedTimestep::step`] for per-tick virtual delta.
16//!
17//! Distribution sampling is deterministic — each emitter holds a seedable
18//! xorshift64 RNG, so identical seeds produce identical particle streams. No
19//! external RNG dependency.
20
21use std::collections::HashMap;
22
23use galeon_engine_macros::Component;
24
25use crate::commands::Commands;
26use crate::entity::Entity;
27use crate::game_loop::FixedTimestep;
28use crate::system_param::{QueryMut, Res};
29
30// =============================================================================
31// Distributions
32// =============================================================================
33
34/// Scalar distribution: constant or uniform `[min, max)`.
35///
36/// `min > max` is tolerated — bounds are reordered before sampling.
37#[derive(Debug, Clone, Copy, PartialEq)]
38pub enum FloatDist {
39    /// Always returns the same value.
40    Constant(f32),
41    /// Uniform `[min, max)`. Equal bounds yield the constant.
42    Uniform { min: f32, max: f32 },
43}
44
45impl FloatDist {
46    /// Draw one sample from this distribution.
47    pub fn sample(&self, rng: &mut ParticleRng) -> f32 {
48        match *self {
49            Self::Constant(v) => v,
50            Self::Uniform { min, max } => lerp_uniform(min, max, rng.next_f32()),
51        }
52    }
53}
54
55/// 3D vector distribution: constant or uniform inside an axis-aligned box.
56#[derive(Debug, Clone, Copy, PartialEq)]
57pub enum Vec3Dist {
58    /// Always returns the same vector.
59    Constant([f32; 3]),
60    /// Uniform inside the AABB defined by `min` / `max`. Per-axis bounds are
61    /// reordered if `min[i] > max[i]`.
62    UniformBox { min: [f32; 3], max: [f32; 3] },
63}
64
65impl Vec3Dist {
66    /// Draw one sample from this distribution.
67    pub fn sample(&self, rng: &mut ParticleRng) -> [f32; 3] {
68        match *self {
69            Self::Constant(v) => v,
70            Self::UniformBox { min, max } => [
71                lerp_uniform(min[0], max[0], rng.next_f32()),
72                lerp_uniform(min[1], max[1], rng.next_f32()),
73                lerp_uniform(min[2], max[2], rng.next_f32()),
74            ],
75        }
76    }
77}
78
79/// RGB color distribution. Channel values are in linear `[0.0, 1.0]`.
80#[derive(Debug, Clone, Copy, PartialEq)]
81pub enum ColorDist {
82    /// Always returns the same color.
83    Constant([f32; 3]),
84    /// Uniform inside an RGB axis-aligned box.
85    UniformBox { min: [f32; 3], max: [f32; 3] },
86}
87
88impl ColorDist {
89    /// Draw one sample from this distribution.
90    pub fn sample(&self, rng: &mut ParticleRng) -> [f32; 3] {
91        match *self {
92            Self::Constant(v) => v,
93            Self::UniformBox { min, max } => [
94                lerp_uniform(min[0], max[0], rng.next_f32()),
95                lerp_uniform(min[1], max[1], rng.next_f32()),
96                lerp_uniform(min[2], max[2], rng.next_f32()),
97            ],
98        }
99    }
100}
101
102fn lerp_uniform(a: f32, b: f32, t: f32) -> f32 {
103    let lo = a.min(b);
104    let hi = a.max(b);
105    lo + t * (hi - lo)
106}
107
108// =============================================================================
109// RNG (xorshift64 with SplitMix-style seed mixing)
110// =============================================================================
111
112/// Deterministic per-emitter RNG (xorshift64).
113///
114/// No external `rand` dependency — keeps the engine crate light and gives
115/// downstream callers a stable byte-for-byte particle stream for a given seed.
116#[derive(Debug, Clone)]
117pub struct ParticleRng {
118    state: u64,
119}
120
121impl ParticleRng {
122    /// Construct an RNG from any `seed`. Zero seeds are remapped (xorshift64
123    /// degenerates at zero); identical non-zero seeds yield identical streams.
124    pub fn from_seed(seed: u64) -> Self {
125        // SplitMix64 mixer: scatters bits even for tiny seeds (0, 1, 2, ...).
126        let mut state = seed
127            .wrapping_add(0x9E37_79B9_7F4A_7C15)
128            .wrapping_mul(0xBF58_476D_1CE4_E5B9);
129        state ^= state >> 30;
130        state = state.wrapping_mul(0x94D0_49BB_1331_11EB);
131        state ^= state >> 27;
132        if state == 0 {
133            state = 0xDEAD_BEEF_DEAD_BEEF;
134        }
135        Self { state }
136    }
137
138    /// Next raw `u64`. Advances the state by one xorshift round.
139    pub fn next_u64(&mut self) -> u64 {
140        let mut x = self.state;
141        x ^= x << 13;
142        x ^= x >> 7;
143        x ^= x << 17;
144        self.state = x;
145        x
146    }
147
148    /// Uniform `[0.0, 1.0)` with 24 bits of mantissa precision.
149    pub fn next_f32(&mut self) -> f32 {
150        let bits = (self.next_u64() >> 40) as u32; // top 24 bits
151        bits as f32 / (1u32 << 24) as f32
152    }
153}
154
155// =============================================================================
156// Components
157// =============================================================================
158
159/// Particle emitter component.
160///
161/// Attach to any entity to make it a particle source. Use the builder helpers
162/// (`with_velocity`, `with_size`, `with_color`, `with_seed`) to configure
163/// distributions before spawning.
164///
165/// The system that drives emitters is [`emitter_spawn_expire_system`].
166#[derive(Component, Debug, Clone)]
167pub struct Emitter {
168    /// Particles per second.
169    pub rate: f32,
170    /// Per-particle lifetime distribution (seconds).
171    pub lifetime: FloatDist,
172    /// Initial velocity distribution (units/second).
173    pub velocity: Vec3Dist,
174    /// Size distribution (renderer-specific units; consumed by the billboard
175    /// render path in T2).
176    pub size: FloatDist,
177    /// Color distribution (linear RGB `[0, 1]`).
178    pub color: ColorDist,
179    /// Hard cap on alive particles sourced from this emitter. When reached,
180    /// the spawn debt is cleared instead of accumulating.
181    pub max: u32,
182    /// Fractional spawn debt carried between ticks so non-integer
183    /// `rate * step` values still average out to `rate` per second.
184    pub spawn_accumulator: f32,
185    /// Per-emitter RNG; seedable for deterministic particle streams.
186    pub rng: ParticleRng,
187}
188
189impl Emitter {
190    /// Construct an emitter with sensible defaults: zero velocity, unit size,
191    /// white color, RNG seeded at zero.
192    pub fn new(rate: f32, lifetime: FloatDist, max: u32) -> Self {
193        Self {
194            rate,
195            lifetime,
196            velocity: Vec3Dist::Constant([0.0; 3]),
197            size: FloatDist::Constant(1.0),
198            color: ColorDist::Constant([1.0, 1.0, 1.0]),
199            max,
200            spawn_accumulator: 0.0,
201            rng: ParticleRng::from_seed(0),
202        }
203    }
204
205    /// Override the velocity distribution.
206    pub fn with_velocity(mut self, velocity: Vec3Dist) -> Self {
207        self.velocity = velocity;
208        self
209    }
210
211    /// Override the size distribution.
212    pub fn with_size(mut self, size: FloatDist) -> Self {
213        self.size = size;
214        self
215    }
216
217    /// Override the color distribution.
218    pub fn with_color(mut self, color: ColorDist) -> Self {
219        self.color = color;
220        self
221    }
222
223    /// Override the RNG seed for deterministic playback.
224    pub fn with_seed(mut self, seed: u64) -> Self {
225        self.rng = ParticleRng::from_seed(seed);
226        self
227    }
228}
229
230/// Particle component spawned by an [`Emitter`].
231///
232/// Spawned particles are tagged with [`Billboard`] by the spawn system. The
233/// rendering system (T2 of #217) consumes those tags plus the `size`/`color`
234/// fields here.
235#[derive(Component, Debug, Clone, Copy, PartialEq)]
236pub struct Particle {
237    /// Back-reference to the emitter entity that produced this particle.
238    pub source: Entity,
239    /// Time elapsed since spawn (seconds). Aged each tick by the system.
240    pub age: f32,
241    /// Total lifetime (seconds). When `age >= lifetime`, the particle is
242    /// despawned.
243    pub lifetime: f32,
244    /// Initial linear velocity (units/second). Consumed by the renderer or
245    /// downstream movement systems.
246    pub velocity: [f32; 3],
247    /// Particle size (renderer-specific units).
248    pub size: f32,
249    /// Particle color (linear RGB `[0, 1]`).
250    pub color: [f32; 3],
251}
252
253/// Tag component opting an entity into the billboard render path.
254///
255/// The spawn system attaches this tag to every freshly spawned particle so
256/// the future T2 rendering path (`#215` instanced billboards) can pick them
257/// up via a single component query.
258#[derive(Component, Debug, Default, Clone, Copy, PartialEq, Eq)]
259pub struct Billboard;
260
261// =============================================================================
262// Spawn / expire system
263// =============================================================================
264
265/// CPU spawn / expire system for [`Emitter`] / [`Particle`] entities.
266///
267/// Each tick:
268/// 1. Ages every particle by `FixedTimestep::step`. Despawns expired ones.
269///    Counts surviving particles per source emitter.
270/// 2. For each emitter, spends `rate * step` particles of spawn debt against
271///    its remaining cap (`max - alive`). Spawns up to that many new particles
272///    with sampled lifetime / velocity / size / color, tagged [`Billboard`].
273///
274/// Spawn / despawn are deferred via [`Commands`] and applied between schedule
275/// stages, so freshly spawned particles will not be aged in the same tick they
276/// were spawned.
277pub fn emitter_spawn_expire_system(
278    ts: Res<'_, FixedTimestep>,
279    mut emitters: QueryMut<'_, Emitter>,
280    mut particles: QueryMut<'_, Particle>,
281    mut cmds: Commands<'_>,
282) {
283    let step = ts.step as f32;
284
285    // Stage 1: age + expire + count survivors per source.
286    let mut alive_per_emitter: HashMap<Entity, u32> = HashMap::new();
287    for (entity, particle) in particles.iter_mut() {
288        let p: &mut Particle = particle;
289        p.age += step;
290        if p.age >= p.lifetime {
291            cmds.despawn(entity);
292        } else {
293            *alive_per_emitter.entry(p.source).or_insert(0) += 1;
294        }
295    }
296
297    // Stage 2: per-emitter spawn budget.
298    for (emitter_entity, emitter) in emitters.iter_mut() {
299        let e: &mut Emitter = emitter;
300        let alive = alive_per_emitter.get(&emitter_entity).copied().unwrap_or(0);
301        let headroom = e.max.saturating_sub(alive);
302        if headroom == 0 {
303            // At cap: drop spawn debt rather than letting a transient burst
304            // build up and oversaturate when capacity recovers.
305            e.spawn_accumulator = 0.0;
306            continue;
307        }
308
309        e.spawn_accumulator += e.rate * step;
310        if e.spawn_accumulator < 0.0 {
311            // Negative rates are nonsensical — clamp the debt back to zero so
312            // a misconfigured emitter doesn't silently stay frozen.
313            e.spawn_accumulator = 0.0;
314            continue;
315        }
316
317        let want = e.spawn_accumulator.floor() as u32;
318        let n = want.min(headroom);
319        e.spawn_accumulator -= n as f32;
320
321        for _ in 0..n {
322            let particle = Particle {
323                source: emitter_entity,
324                age: 0.0,
325                lifetime: e.lifetime.sample(&mut e.rng),
326                velocity: e.velocity.sample(&mut e.rng),
327                size: e.size.sample(&mut e.rng),
328                color: e.color.sample(&mut e.rng),
329            };
330            cmds.spawn((particle, Billboard));
331        }
332    }
333}
334
335// =============================================================================
336// Tests
337// =============================================================================
338
339#[cfg(test)]
340mod tests {
341    use super::*;
342    use crate::engine::Engine;
343    use crate::game_loop::FixedTimestep;
344    use crate::system_param::QueryMut;
345
346    // -- ParticleRng --------------------------------------------------------
347
348    #[test]
349    fn rng_zero_seed_does_not_stick() {
350        let mut rng = ParticleRng::from_seed(0);
351        // 16 draws — none should be all-zero bits.
352        for _ in 0..16 {
353            assert_ne!(rng.next_u64(), 0);
354        }
355    }
356
357    #[test]
358    fn rng_next_f32_in_unit_interval() {
359        let mut rng = ParticleRng::from_seed(42);
360        for _ in 0..1024 {
361            let v = rng.next_f32();
362            assert!((0.0..1.0).contains(&v), "value {v} outside [0,1)");
363        }
364    }
365
366    #[test]
367    fn rng_seed_determinism() {
368        let mut a = ParticleRng::from_seed(0xC0FFEE);
369        let mut b = ParticleRng::from_seed(0xC0FFEE);
370        for _ in 0..32 {
371            assert_eq!(a.next_u64(), b.next_u64());
372        }
373    }
374
375    #[test]
376    fn rng_distinct_seeds_diverge() {
377        let mut a = ParticleRng::from_seed(1);
378        let mut b = ParticleRng::from_seed(2);
379        let mut diverged = false;
380        for _ in 0..16 {
381            if a.next_u64() != b.next_u64() {
382                diverged = true;
383                break;
384            }
385        }
386        assert!(diverged, "seeds 1 and 2 produced identical streams");
387    }
388
389    // -- Distribution sampling ----------------------------------------------
390
391    #[test]
392    fn float_constant_returns_value() {
393        let mut rng = ParticleRng::from_seed(0);
394        let d = FloatDist::Constant(2.5);
395        for _ in 0..8 {
396            assert_eq!(d.sample(&mut rng), 2.5);
397        }
398    }
399
400    #[test]
401    fn float_uniform_within_bounds() {
402        let mut rng = ParticleRng::from_seed(7);
403        let d = FloatDist::Uniform { min: 1.0, max: 3.0 };
404        for _ in 0..256 {
405            let v = d.sample(&mut rng);
406            assert!((1.0..3.0).contains(&v), "value {v} outside [1,3)");
407        }
408    }
409
410    #[test]
411    fn float_uniform_swapped_bounds_tolerated() {
412        let mut rng = ParticleRng::from_seed(7);
413        // min > max: distribution should still produce values in [min, max).
414        let d = FloatDist::Uniform { min: 3.0, max: 1.0 };
415        for _ in 0..64 {
416            let v = d.sample(&mut rng);
417            assert!((1.0..3.0).contains(&v), "value {v} outside [1,3)");
418        }
419    }
420
421    #[test]
422    fn vec3_uniform_box_per_axis_bounds() {
423        let mut rng = ParticleRng::from_seed(7);
424        let d = Vec3Dist::UniformBox {
425            min: [-1.0, 0.0, 5.0],
426            max: [1.0, 2.0, 7.0],
427        };
428        for _ in 0..64 {
429            let v = d.sample(&mut rng);
430            assert!((-1.0..1.0).contains(&v[0]));
431            assert!((0.0..2.0).contains(&v[1]));
432            assert!((5.0..7.0).contains(&v[2]));
433        }
434    }
435
436    #[test]
437    fn color_constant_returns_value() {
438        let mut rng = ParticleRng::from_seed(0);
439        let d = ColorDist::Constant([0.25, 0.5, 0.75]);
440        assert_eq!(d.sample(&mut rng), [0.25, 0.5, 0.75]);
441    }
442
443    // -- Emitter builder ----------------------------------------------------
444
445    #[test]
446    fn emitter_new_defaults() {
447        let e = Emitter::new(10.0, FloatDist::Constant(1.0), 100);
448        assert_eq!(e.rate, 10.0);
449        assert_eq!(e.max, 100);
450        assert_eq!(e.spawn_accumulator, 0.0);
451        assert_eq!(e.velocity, Vec3Dist::Constant([0.0; 3]));
452        assert_eq!(e.size, FloatDist::Constant(1.0));
453        assert_eq!(e.color, ColorDist::Constant([1.0; 3]));
454    }
455
456    #[test]
457    fn emitter_builders_are_chainable() {
458        let e = Emitter::new(10.0, FloatDist::Constant(1.0), 50)
459            .with_velocity(Vec3Dist::Constant([0.0, 1.0, 0.0]))
460            .with_size(FloatDist::Constant(0.5))
461            .with_color(ColorDist::Constant([1.0, 0.0, 0.0]))
462            .with_seed(0xFEED);
463        assert_eq!(e.velocity, Vec3Dist::Constant([0.0, 1.0, 0.0]));
464        assert_eq!(e.size, FloatDist::Constant(0.5));
465        assert_eq!(e.color, ColorDist::Constant([1.0, 0.0, 0.0]));
466    }
467
468    // -- emitter_spawn_expire_system ----------------------------------------
469
470    /// Build a 10 Hz engine with the spawn/expire system in stage `simulate`.
471    fn engine_with_spawn_system() -> Engine {
472        let mut engine = Engine::new();
473        engine.set_tick_rate(10.0);
474        engine.add_system::<(
475            Res<'_, FixedTimestep>,
476            QueryMut<'_, Emitter>,
477            QueryMut<'_, Particle>,
478            Commands<'_>,
479        )>(
480            "simulate",
481            "emitter_spawn_expire",
482            emitter_spawn_expire_system,
483        );
484        engine
485    }
486
487    /// Count alive particles in the world.
488    fn particle_count(engine: &Engine) -> usize {
489        engine.world().query::<&Particle>().count()
490    }
491
492    #[test]
493    fn no_emitters_no_particles() {
494        let mut engine = engine_with_spawn_system();
495        engine.tick(1.0);
496        assert_eq!(particle_count(&engine), 0);
497    }
498
499    #[test]
500    fn emitter_at_30hz_spawns_30_per_second() {
501        // Issue acceptance: emitter at 30/sec spawns ~30 per simulated second,
502        // capped at `max`. 10 Hz tick rate, lifetime > 1s so nothing expires.
503        let mut engine = engine_with_spawn_system();
504        engine
505            .world_mut()
506            .spawn((Emitter::new(30.0, FloatDist::Constant(100.0), 1000),));
507
508        for _ in 0..10 {
509            engine.tick(0.1);
510        }
511
512        assert_eq!(particle_count(&engine), 30);
513    }
514
515    #[test]
516    fn emitter_respects_max_cap() {
517        let mut engine = engine_with_spawn_system();
518        engine
519            .world_mut()
520            .spawn((Emitter::new(30.0, FloatDist::Constant(100.0), 20),));
521
522        for _ in 0..10 {
523            engine.tick(0.1);
524        }
525
526        // Capped at 20, not 30.
527        assert_eq!(particle_count(&engine), 20);
528    }
529
530    #[test]
531    fn fractional_rate_accumulates_across_ticks() {
532        // 15/sec at 10 Hz = 1.5 per tick — the .5 carries via spawn_accumulator.
533        let mut engine = engine_with_spawn_system();
534        engine
535            .world_mut()
536            .spawn((Emitter::new(15.0, FloatDist::Constant(100.0), 1000),));
537
538        for _ in 0..10 {
539            engine.tick(0.1);
540        }
541
542        assert_eq!(particle_count(&engine), 15);
543    }
544
545    #[test]
546    fn rate_below_one_per_tick_eventually_spawns() {
547        // 5/sec at 10 Hz = 0.5 per tick — alternating 0/1 spawn pattern.
548        let mut engine = engine_with_spawn_system();
549        engine
550            .world_mut()
551            .spawn((Emitter::new(5.0, FloatDist::Constant(100.0), 1000),));
552
553        for _ in 0..10 {
554            engine.tick(0.1);
555        }
556
557        assert_eq!(particle_count(&engine), 5);
558    }
559
560    #[test]
561    fn particles_expire_after_lifetime() {
562        // lifetime = 0.05s, step = 0.1s — every particle expires the tick
563        // after spawn (because aging happens BEFORE the same-tick spawn).
564        let mut engine = engine_with_spawn_system();
565        engine
566            .world_mut()
567            .spawn((Emitter::new(10.0, FloatDist::Constant(0.05), 1000),));
568
569        // Tick 1: spawn 1 (no aging — spawn happens via Commands at end of stage).
570        engine.tick(0.1);
571        assert_eq!(particle_count(&engine), 1);
572
573        // Tick 2: existing particle ages 0.1s >= 0.05s lifetime → despawned.
574        //         Then a fresh particle spawns. Net: still 1.
575        engine.tick(0.1);
576        assert_eq!(particle_count(&engine), 1);
577    }
578
579    #[test]
580    fn newly_spawned_particles_visible_after_apply_commands() {
581        let mut engine = engine_with_spawn_system();
582        engine
583            .world_mut()
584            .spawn((Emitter::new(10.0, FloatDist::Constant(100.0), 1000),));
585        // First tick: 1 spawn (10/sec * 0.1s = 1.0).
586        engine.tick(0.1);
587        assert_eq!(particle_count(&engine), 1);
588    }
589
590    #[test]
591    fn spawned_particles_carry_billboard_tag() {
592        let mut engine = engine_with_spawn_system();
593        engine
594            .world_mut()
595            .spawn((Emitter::new(10.0, FloatDist::Constant(100.0), 1000),));
596        engine.tick(0.1);
597
598        // Every Particle should also have a Billboard tag.
599        let particles = engine.world().query::<&Particle>().count();
600        let billboards = engine.world().query::<&Billboard>().count();
601        assert_eq!(particles, 1);
602        assert_eq!(billboards, 1);
603    }
604
605    #[test]
606    fn paused_engine_does_not_spawn() {
607        let mut engine = engine_with_spawn_system();
608        engine
609            .world_mut()
610            .spawn((Emitter::new(30.0, FloatDist::Constant(100.0), 1000),));
611        engine.pause();
612
613        for _ in 0..10 {
614            engine.tick(0.1);
615        }
616
617        assert_eq!(particle_count(&engine), 0);
618    }
619
620    #[test]
621    fn multiple_emitters_independent_caps() {
622        let mut engine = engine_with_spawn_system();
623        engine
624            .world_mut()
625            .spawn((Emitter::new(30.0, FloatDist::Constant(100.0), 5),));
626        engine
627            .world_mut()
628            .spawn((Emitter::new(30.0, FloatDist::Constant(100.0), 7),));
629
630        for _ in 0..10 {
631            engine.tick(0.1);
632        }
633
634        // 5 + 7 = 12 particles total — each emitter capped independently.
635        assert_eq!(particle_count(&engine), 12);
636    }
637
638    #[test]
639    fn emitter_despawn_strands_particles_until_expire() {
640        // After an emitter despawns mid-life, its particles continue ticking
641        // and expire normally — they are not orphaned.
642        let mut engine = engine_with_spawn_system();
643        let emitter =
644            engine
645                .world_mut()
646                .spawn((Emitter::new(10.0, FloatDist::Constant(0.3), 1000),));
647
648        for _ in 0..3 {
649            engine.tick(0.1);
650        }
651        let alive_before = particle_count(&engine);
652        assert!(alive_before > 0);
653
654        engine.world_mut().despawn(emitter);
655
656        // No new spawns; existing particles age out by 0.3s.
657        for _ in 0..4 {
658            engine.tick(0.1);
659        }
660        assert_eq!(particle_count(&engine), 0);
661    }
662
663    #[test]
664    fn deterministic_with_same_seed() {
665        // Two engines with identically seeded emitters produce the same
666        // lifetime/velocity/size/color sequence.
667        fn run() -> Vec<(f32, [f32; 3], f32, [f32; 3])> {
668            let mut engine = engine_with_spawn_system();
669            engine.world_mut().spawn((Emitter::new(
670                30.0,
671                FloatDist::Uniform { min: 0.5, max: 1.5 },
672                1000,
673            )
674            .with_velocity(Vec3Dist::UniformBox {
675                min: [-1.0, 0.0, -1.0],
676                max: [1.0, 1.0, 1.0],
677            })
678            .with_size(FloatDist::Uniform { min: 0.1, max: 0.3 })
679            .with_color(ColorDist::UniformBox {
680                min: [0.0; 3],
681                max: [1.0; 3],
682            })
683            .with_seed(0xCAFE_BABE),));
684
685            engine.tick(0.5);
686            let mut samples: Vec<_> = engine
687                .world()
688                .query::<&Particle>()
689                .map(|(_, p)| (p.lifetime, p.velocity, p.size, p.color))
690                .collect();
691            // Stable order for comparison.
692            samples.sort_by(|a, b| a.0.partial_cmp(&b.0).unwrap());
693            samples
694        }
695
696        assert_eq!(run(), run());
697    }
698
699    #[test]
700    fn rate_zero_emitter_never_spawns() {
701        let mut engine = engine_with_spawn_system();
702        engine
703            .world_mut()
704            .spawn((Emitter::new(0.0, FloatDist::Constant(100.0), 1000),));
705
706        for _ in 0..100 {
707            engine.tick(0.1);
708        }
709        assert_eq!(particle_count(&engine), 0);
710    }
711
712    #[test]
713    fn at_cap_drops_accumulator_does_not_burst() {
714        // After hitting the cap, lingering spawn_accumulator must not let a
715        // burst of N particles spawn the moment headroom returns. We check
716        // that spawn_accumulator is reset to 0 once the cap is hit.
717        let mut engine = engine_with_spawn_system();
718        let emitter = engine
719            .world_mut()
720            .spawn((Emitter::new(30.0, FloatDist::Constant(100.0), 5),));
721
722        for _ in 0..20 {
723            engine.tick(0.1);
724        }
725
726        // At cap, accumulator should be zero.
727        let acc = engine
728            .world()
729            .get::<Emitter>(emitter)
730            .map(|e| e.spawn_accumulator)
731            .expect("emitter alive");
732        assert_eq!(acc, 0.0);
733    }
734}