1use 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#[derive(Debug, Clone, Copy, PartialEq)]
38pub enum FloatDist {
39 Constant(f32),
41 Uniform { min: f32, max: f32 },
43}
44
45impl FloatDist {
46 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#[derive(Debug, Clone, Copy, PartialEq)]
57pub enum Vec3Dist {
58 Constant([f32; 3]),
60 UniformBox { min: [f32; 3], max: [f32; 3] },
63}
64
65impl Vec3Dist {
66 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#[derive(Debug, Clone, Copy, PartialEq)]
81pub enum ColorDist {
82 Constant([f32; 3]),
84 UniformBox { min: [f32; 3], max: [f32; 3] },
86}
87
88impl ColorDist {
89 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#[derive(Debug, Clone)]
117pub struct ParticleRng {
118 state: u64,
119}
120
121impl ParticleRng {
122 pub fn from_seed(seed: u64) -> Self {
125 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 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 pub fn next_f32(&mut self) -> f32 {
150 let bits = (self.next_u64() >> 40) as u32; bits as f32 / (1u32 << 24) as f32
152 }
153}
154
155#[derive(Component, Debug, Clone)]
167pub struct Emitter {
168 pub rate: f32,
170 pub lifetime: FloatDist,
172 pub velocity: Vec3Dist,
174 pub size: FloatDist,
177 pub color: ColorDist,
179 pub max: u32,
182 pub spawn_accumulator: f32,
185 pub rng: ParticleRng,
187}
188
189impl Emitter {
190 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 pub fn with_velocity(mut self, velocity: Vec3Dist) -> Self {
207 self.velocity = velocity;
208 self
209 }
210
211 pub fn with_size(mut self, size: FloatDist) -> Self {
213 self.size = size;
214 self
215 }
216
217 pub fn with_color(mut self, color: ColorDist) -> Self {
219 self.color = color;
220 self
221 }
222
223 pub fn with_seed(mut self, seed: u64) -> Self {
225 self.rng = ParticleRng::from_seed(seed);
226 self
227 }
228}
229
230#[derive(Component, Debug, Clone, Copy, PartialEq)]
236pub struct Particle {
237 pub source: Entity,
239 pub age: f32,
241 pub lifetime: f32,
244 pub velocity: [f32; 3],
247 pub size: f32,
249 pub color: [f32; 3],
251}
252
253#[derive(Component, Debug, Default, Clone, Copy, PartialEq, Eq)]
259pub struct Billboard;
260
261pub 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 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 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 e.spawn_accumulator = 0.0;
306 continue;
307 }
308
309 e.spawn_accumulator += e.rate * step;
310 if e.spawn_accumulator < 0.0 {
311 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#[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 #[test]
349 fn rng_zero_seed_does_not_stick() {
350 let mut rng = ParticleRng::from_seed(0);
351 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 #[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 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 #[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 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 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 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 assert_eq!(particle_count(&engine), 20);
528 }
529
530 #[test]
531 fn fractional_rate_accumulates_across_ticks() {
532 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 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 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 engine.tick(0.1);
571 assert_eq!(particle_count(&engine), 1);
572
573 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 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 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 assert_eq!(particle_count(&engine), 12);
636 }
637
638 #[test]
639 fn emitter_despawn_strands_particles_until_expire() {
640 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 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 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 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 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 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}