1use 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#[derive(Clone, Debug)]
31pub struct ParticleEmitter {
32 pub position: Vec3Fix,
34 pub direction: Vec3Fix,
36 pub spread_angle: Fix128,
39 pub emission_rate: Fix128,
41 pub initial_speed: Fix128,
43 pub lifetime: Fix128,
45 pub particle_mass: Fix128,
47 emission_accumulator: Fix128,
49}
50
51impl ParticleEmitter {
52 #[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#[derive(Clone, Debug)]
78pub struct Particle {
79 pub position: Vec3Fix,
81 pub velocity: Vec3Fix,
83 pub age: Fix128,
85 pub lifetime: Fix128,
87 pub mass: Fix128,
89 pub alive: bool,
91}
92
93impl Particle {
94 #[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#[non_exhaustive]
113pub enum LandingTarget<'a> {
114 Field(&'a dyn SdfField),
118 #[cfg(feature = "std")]
121 Collider(&'a SdfCollider),
122 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 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#[non_exhaustive]
164#[derive(Clone, Copy, Debug, PartialEq, Eq)]
165pub struct LandingEvent {
166 pub particle: usize,
169 pub target: usize,
171 pub position: Vec3Fix,
173 pub normal: Vec3Fix,
175 pub time_in_step: Fix128,
177}
178
179pub struct ParticleSystem {
181 pub particles: Vec<Particle>,
183 pub emitters: Vec<ParticleEmitter>,
185 pub gravity: Vec3Fix,
187 pub damping: Fix128,
189 pub max_particles: usize,
191}
192
193impl ParticleSystem {
194 #[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 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 #[must_use]
220 pub fn alive_count(&self) -> usize {
221 self.particles.iter().filter(|p| p.alive).count()
222 }
223
224 pub fn step(&mut self, dt: Fix128, rng: &mut DeterministicRng) {
233 if dt.is_zero() {
234 return;
235 }
236
237 let num_emitters = self.emitters.len();
239 for ei in 0..num_emitters {
240 self.emit_particles(ei, dt, rng);
241 }
242
243 for particle in &mut self.particles {
245 if !particle.alive {
246 continue;
247 }
248
249 particle.velocity = particle.velocity + self.gravity * dt;
251
252 particle.velocity = particle.velocity * self.damping;
254
255 particle.position = particle.position + particle.velocity * dt;
257
258 particle.age = particle.age + dt;
260
261 if particle.age >= particle.lifetime {
263 particle.alive = false;
264 }
265 }
266 }
267
268 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 fn emit_particles(&mut self, emitter_index: usize, dt: Fix128, rng: &mut DeterministicRng) {
406 let emitter = &mut self.emitters[emitter_index];
407
408 emitter.emission_accumulator = emitter.emission_accumulator + emitter.emission_rate * dt;
410
411 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 if self.alive_count() >= self.max_particles {
429 break;
430 }
431 let vel = compute_emission_velocity(direction, speed, spread, rng);
432 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 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 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
484const MAX_LANDING_SAMPLES: usize = 1 << 20;
488
489fn 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 (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
532fn 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 let rand_dir = rng.next_direction();
545
546 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#[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), 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 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), Fix128::ONE,
647 );
648 ps.add_emitter(emitter);
649
650 let mut rng = DeterministicRng::new(42);
651
652 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 for _ in 0..30 {
659 ps.step(Fix128::from_ratio(1, 60), &mut rng);
660 }
661
662 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, 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 ps.step(Fix128::from_ratio(1, 60), &mut rng);
687
688 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), 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 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 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 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); 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 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), Fix128::ONE,
875 );
876 ps.add_emitter(emitter);
877
878 let mut rng = DeterministicRng::new(42);
879
880 ps.step(Fix128::from_ratio(1, 60), &mut rng);
882 ps.step(Fix128::from_ratio(1, 60), &mut rng);
884 ps.step(Fix128::from_ratio(1, 60), &mut rng);
886
887 assert!(ps.alive_count() <= 2);
889 }
890}