1use std::ops::Range;
25
26use crate::Rgb;
27use crate::{
28 BillboardLighting, DynSpriteTransform, EpochSlotMap, SceneRenderer, ShadowFlags, SlotHandle,
29 SpriteInstanceId, SpriteModelId,
30};
31
32#[derive(Clone, Copy, PartialEq, Eq, Hash, Debug)]
38pub struct EmitterId {
39 slot: u32,
40 gen: u32,
41}
42
43impl SlotHandle for EmitterId {
46 fn mint(slot: u32, gen: u32) -> Self {
47 Self { slot, gen }
48 }
49 fn parts(self) -> (u32, u32) {
50 (self.slot, self.gen)
51 }
52}
53
54#[derive(Clone, Copy, Debug)]
56pub enum SpawnMode {
57 Rate(f32),
61 Burst(u32),
65 Manual,
68}
69
70#[derive(Clone, Copy, Debug, Default)]
72pub enum EmitterShape {
73 #[default]
75 Point,
76 Sphere {
78 radius: f32,
80 },
81 Box {
83 half: [f32; 3],
85 },
86}
87
88#[derive(Clone, Debug)]
92pub struct ConeDef {
93 pub axis: [f32; 3],
96 pub half_angle_deg: f32,
102 pub speed: Range<f32>,
104}
105
106#[derive(Clone, Debug, Default)]
109pub struct VelocityDef {
110 pub base: [f32; 3],
113 pub spread: f32,
117 pub cone: Option<ConeDef>,
120}
121
122#[derive(Clone, Copy, Debug, Default, PartialEq)]
134pub enum CollisionMode {
135 #[default]
137 None,
138 Kill,
140 Bounce {
146 restitution: f32,
148 },
149}
150
151#[derive(Clone, Debug)]
156pub struct ParticleEmitterDef {
157 pub model: SpriteModelId,
161 pub pos: [f32; 3],
164 pub shape: EmitterShape,
167 pub spawn: SpawnMode,
169 pub lifetime: Range<f32>,
172 pub velocity: VelocityDef,
174 pub gravity: [f32; 3],
177 pub drag: f32,
180 pub collision: CollisionMode,
183 pub scale: f32,
187 pub scale_end: Option<f32>,
191 pub spin: Range<f32>,
196 pub fade_in_frac: f32,
200 pub fade_out_frac: f32,
204 pub tint: Rgb,
206 pub tint_end: Option<Rgb>,
210 pub material: u8,
213 pub lighting: BillboardLighting,
216 pub shadows: ShadowFlags,
220}
221
222impl ParticleEmitterDef {
223 #[must_use]
229 pub fn new(model: SpriteModelId) -> Self {
230 Self {
231 model,
232 pos: [0.0, 0.0, 0.0],
233 shape: EmitterShape::Point,
234 spawn: SpawnMode::Manual,
235 lifetime: 1.0..2.0,
236 velocity: VelocityDef::default(),
237 gravity: [0.0, 0.0, 22.0],
238 drag: 0.0,
239 collision: CollisionMode::None,
240 scale: 1.0,
241 scale_end: None,
242 spin: 0.0..0.0,
243 fade_in_frac: 0.0,
244 fade_out_frac: 0.25,
245 tint: Rgb::WHITE,
246 tint_end: None,
247 material: 0,
248 lighting: BillboardLighting::FaceNormal,
249 shadows: ShadowFlags {
250 casts: false,
251 receives: false,
252 },
253 }
254 }
255}
256
257#[derive(Clone, Copy, Debug)]
260pub struct Particle {
261 pub pos: [f32; 3],
263 pub vel: [f32; 3],
265 pub age: f32,
267 pub lifetime: f32,
269 pub scale: f32,
272 pub yaw: f32,
274 pub spin_rate: f32,
276 pub alpha: u8,
278 pub tint: Rgb,
281 pub(crate) emitter_slot: u32,
284 pub(crate) instance: Option<SpriteInstanceId>,
287 pub(crate) last_alpha: u8,
291 pub(crate) last_tint: Rgb,
294 pub(crate) tint_start: Rgb,
298}
299
300struct EmitterState {
302 def: ParticleEmitterDef,
303 spawn_acc: f64,
305 live: u32,
309 retired: bool,
312}
313
314struct Pcg32 {
319 state: u64,
320}
321
322impl Pcg32 {
323 const MULT: u64 = 6_364_136_223_846_793_005;
324 const INC: u64 = 1_442_695_040_888_963_407;
325
326 fn new(seed: u64) -> Self {
327 let mut rng = Self {
328 state: seed.wrapping_add(Self::INC),
329 };
330 rng.next_u32();
331 rng
332 }
333
334 fn next_u32(&mut self) -> u32 {
335 let old = self.state;
336 self.state = old.wrapping_mul(Self::MULT).wrapping_add(Self::INC);
337 let xorshifted = (((old >> 18) ^ old) >> 27) as u32;
338 xorshifted.rotate_right((old >> 59) as u32)
339 }
340
341 fn next_f32(&mut self) -> f32 {
343 (self.next_u32() >> 8) as f32 * (1.0 / (1u32 << 24) as f32)
344 }
345
346 fn range_f32(&mut self, r: &Range<f32>) -> f32 {
348 if r.end <= r.start {
349 return r.start;
350 }
351 r.start + (r.end - r.start) * self.next_f32()
352 }
353
354 fn unit_vec(&mut self) -> [f32; 3] {
357 loop {
358 let v = [
359 self.next_f32() * 2.0 - 1.0,
360 self.next_f32() * 2.0 - 1.0,
361 self.next_f32() * 2.0 - 1.0,
362 ];
363 let len2 = v[0] * v[0] + v[1] * v[1] + v[2] * v[2];
364 if len2 > 1e-4 && len2 <= 1.0 {
365 let inv = 1.0 / len2.sqrt();
366 return [v[0] * inv, v[1] * inv, v[2] * inv];
367 }
368 }
369 }
370}
371
372pub const DEFAULT_MAX_PARTICLES: usize = 4096;
374
375pub const CARVE_DEBRIS_CAP: usize = 96;
379
380pub struct ParticleSystem {
391 rng: Pcg32,
392 map: EpochSlotMap<EmitterId>,
393 emitters: Vec<Option<EmitterState>>,
395 particles: Vec<Particle>,
396 dead_instances: Vec<SpriteInstanceId>,
399 max_particles: usize,
400 carve_debris_cap: usize,
403 dropped_spawns: u64,
404 stale_model_kills: u64,
405 xf_scratch: Vec<(SpriteInstanceId, DynSpriteTransform)>,
408}
409
410impl ParticleSystem {
411 #[must_use]
415 pub fn new(seed: u64) -> Self {
416 Self {
417 rng: Pcg32::new(seed),
418 map: EpochSlotMap::default(),
419 emitters: Vec::new(),
420 particles: Vec::new(),
421 dead_instances: Vec::new(),
422 max_particles: DEFAULT_MAX_PARTICLES,
423 carve_debris_cap: CARVE_DEBRIS_CAP,
424 dropped_spawns: 0,
425 stale_model_kills: 0,
426 xf_scratch: Vec::new(),
427 }
428 }
429
430 pub fn set_max_particles(&mut self, max: usize) {
433 self.max_particles = max;
434 }
435
436 pub fn set_carve_debris_cap(&mut self, cap: usize) {
442 self.carve_debris_cap = cap.max(1);
443 }
444
445 pub fn add_emitter(&mut self, def: ParticleEmitterDef) -> EmitterId {
447 let slot = self.emitters.len() as u32;
448 let id = self.map.alloc(slot);
449 let burst = match def.spawn {
450 SpawnMode::Burst(n) => n,
451 _ => 0,
452 };
453 self.emitters.push(Some(EmitterState {
454 def,
455 spawn_acc: 0.0,
456 live: 0,
457 retired: false,
458 }));
459 if burst > 0 {
460 self.spawn_from(slot as usize, burst);
461 }
462 id
463 }
464
465 pub fn remove_emitter(&mut self, id: EmitterId) -> bool {
470 let Some(slot) = self.map.index(id) else {
471 return false;
472 };
473 if !self.map.remove(id) {
474 return false;
475 }
476 let state = self.emitters[slot].as_mut().expect("live map ⇒ state");
477 if state.live == 0 {
478 self.emitters[slot] = None;
479 } else {
480 state.retired = true;
481 }
482 true
483 }
484
485 pub fn set_emitter_pos(&mut self, id: EmitterId, pos: [f32; 3]) -> bool {
488 let Some(slot) = self.map.index(id) else {
489 return false;
490 };
491 let state = self.emitters[slot].as_mut().expect("live map ⇒ state");
492 state.def.pos = pos;
493 true
494 }
495
496 pub fn burst(&mut self, id: EmitterId, n: u32) -> u32 {
500 let Some(slot) = self.map.index(id) else {
501 return 0;
502 };
503 self.spawn_from(slot, n)
504 }
505
506 pub fn update(&mut self, dt: f64) {
514 self.step(dt, None);
515 }
516
517 pub fn update_with_scene(&mut self, dt: f64, scene: &roxlap_scene::Scene) {
522 self.step(dt, Some(scene));
523 }
524
525 fn step(&mut self, dt: f64, scene: Option<&roxlap_scene::Scene>) {
527 let dtf = dt.max(0.0) as f32;
528
529 let emitters = &self.emitters;
532 for p in &mut self.particles {
533 p.age += dtf;
534 if p.age >= p.lifetime {
535 continue; }
537 let def = &emitters[p.emitter_slot as usize]
538 .as_ref()
539 .expect("live particle ⇒ emitter state retained")
540 .def;
541 let prev = p.pos;
542 for a in 0..3 {
543 p.vel[a] += (def.gravity[a] - def.drag * p.vel[a]) * dtf;
544 p.pos[a] += p.vel[a] * dtf;
545 }
546 if let Some(scene) = scene {
549 if !matches!(def.collision, CollisionMode::None)
550 && scene_solid_ahead(scene, p.pos, p.vel)
551 {
552 match def.collision {
553 CollisionMode::Kill => {
554 p.age = p.lifetime; continue;
556 }
557 CollisionMode::Bounce { restitution } => {
558 let mut crossed = false;
563 for ((prev_a, pos_a), vel_a) in prev.iter().zip(&p.pos).zip(&mut p.vel)
564 {
565 if prev_a.floor() != pos_a.floor() {
566 *vel_a = -*vel_a;
567 crossed = true;
568 }
569 }
570 for vel_a in &mut p.vel {
571 if !crossed {
572 *vel_a = -*vel_a;
573 }
574 *vel_a *= restitution;
575 }
576 p.pos = prev;
577 }
578 CollisionMode::None => unreachable!("guarded above"),
579 }
580 }
581 }
582 p.yaw += p.spin_rate * dtf;
583 let frac = p.age / p.lifetime;
585 if let Some(end) = def.scale_end {
586 p.scale = def.scale + (end - def.scale) * frac;
587 }
588 if let Some(end) = def.tint_end {
589 p.tint = lerp_tint(p.tint_start, end, frac);
590 }
591 p.alpha = fade_alpha(p.age, p.lifetime, def.fade_in_frac, def.fade_out_frac);
592 }
593
594 let mut i = 0;
596 while i < self.particles.len() {
597 if self.particles[i].age >= self.particles[i].lifetime {
598 let p = self.particles.swap_remove(i);
599 if let Some(inst) = p.instance {
600 self.dead_instances.push(inst);
601 }
602 self.on_particle_died(p.emitter_slot as usize);
603 } else {
604 i += 1;
605 }
606 }
607
608 for slot in 0..self.emitters.len() {
613 let n = {
614 let Some(state) = self.emitters[slot].as_mut() else {
615 continue;
616 };
617 if state.retired {
618 continue;
619 }
620 let SpawnMode::Rate(rate) = state.def.spawn else {
621 continue;
622 };
623 state.spawn_acc += f64::from(rate) * dt.max(0.0);
624 let n = state.spawn_acc.floor();
625 state.spawn_acc -= n;
626 n as u32
627 };
628 if n > 0 {
629 self.spawn_from(slot, n);
630 }
631 }
632 }
633
634 #[must_use]
636 pub fn particles(&self) -> &[Particle] {
637 &self.particles
638 }
639
640 #[must_use]
642 pub fn particle_count(&self) -> usize {
643 self.particles.len()
644 }
645
646 #[must_use]
648 pub fn emitter_count(&self) -> usize {
649 self.emitters
650 .iter()
651 .filter(|e| e.as_ref().is_some_and(|s| !s.retired))
652 .count()
653 }
654
655 #[cfg(test)]
662 pub(crate) fn emitter_slots_all_free(&self) -> bool {
663 self.emitters.iter().all(Option::is_none)
664 }
665
666 #[must_use]
670 pub fn dropped_spawns(&self) -> u64 {
671 self.dropped_spawns
672 }
673
674 pub fn drain_dead_instances(&mut self) -> impl Iterator<Item = SpriteInstanceId> + '_ {
679 self.dead_instances.drain(..)
680 }
681
682 #[must_use]
687 pub fn stale_model_kills(&self) -> u64 {
688 self.stale_model_kills
689 }
690
691 pub fn sync(&mut self, renderer: &mut SceneRenderer) {
703 self.sync_with(renderer);
704 }
705
706 pub fn tick(&mut self, renderer: &mut SceneRenderer, dt: f64) {
710 self.step(dt, None);
711 self.sync_with(renderer);
712 }
713
714 pub fn tick_with_scene(
719 &mut self,
720 renderer: &mut SceneRenderer,
721 dt: f64,
722 scene: &roxlap_scene::Scene,
723 ) {
724 self.step(dt, Some(scene));
725 self.sync_with(renderer);
726 }
727
728 fn sync_with<F: ParticleFacade>(&mut self, facade: &mut F) {
731 for id in self.dead_instances.drain(..) {
734 facade.despawn(id);
735 }
736
737 let mut batch = std::mem::take(&mut self.xf_scratch);
739 batch.clear();
740 let mut i = 0;
741 while i < self.particles.len() {
742 if self.particles[i].instance.is_none() {
743 let (slot, xf) = {
744 let p = &self.particles[i];
745 (p.emitter_slot as usize, particle_xf(p))
746 };
747 let (model, material, lighting, shadows) = {
748 let st = self.emitters[slot]
749 .as_ref()
750 .expect("live particle ⇒ emitter state retained");
751 (
752 st.def.model,
753 st.def.material,
754 st.def.lighting,
755 st.def.shadows,
756 )
757 };
758 let Some(id) = facade.spawn(model, xf) else {
759 self.stale_model_kills += 1;
762 self.particles.swap_remove(i);
763 self.on_particle_died(slot);
764 continue;
765 };
766 if material != 0 {
769 facade.set_material(id, material);
770 }
771 if lighting != BillboardLighting::default() {
772 facade.set_lighting(id, lighting);
773 }
774 if shadows != ShadowFlags::default() {
775 facade.set_shadows(id, shadows);
776 }
777 let p = &mut self.particles[i];
778 p.instance = Some(id);
779 p.last_alpha = 255;
783 p.last_tint = Rgb::WHITE;
784 } else {
785 let p = &self.particles[i];
788 batch.push((p.instance.expect("checked above"), particle_xf(p)));
789 }
790 let p = &mut self.particles[i];
791 if p.alpha != p.last_alpha {
792 facade.set_alpha(p.instance.expect("set above"), p.alpha);
793 p.last_alpha = p.alpha;
794 }
795 if p.tint != p.last_tint {
796 facade.set_tint(p.instance.expect("set above"), p.tint);
797 p.last_tint = p.tint;
798 }
799 i += 1;
800 }
801 if !batch.is_empty() {
802 facade.set_transforms(&batch);
803 }
804 self.xf_scratch = batch;
805 }
806
807 pub fn carve_debris(
832 &mut self,
833 scene: &mut roxlap_scene::Scene,
834 grid: roxlap_scene::GridId,
835 centre: glam::IVec3,
836 radius: u32,
837 outward: Range<f32>,
838 def: &ParticleEmitterDef,
839 ) -> u32 {
840 let Some(g) = scene.grid_mut(grid) else {
841 return 0;
842 };
843 #[allow(clippy::cast_possible_wrap)]
845 let r = radius as i32;
846 let mut samples: Vec<(glam::IVec3, roxlap_formats::VoxColor)> = Vec::new();
847 for z in -r..=r {
848 for y in -r..=r {
849 for x in -r..=r {
850 if x * x + y * y + z * z > r * r {
851 continue;
852 }
853 let v = centre + glam::IVec3::new(x, y, z);
854 if let Some(col) = g.voxel_color(v) {
855 samples.push((v, col));
856 }
857 }
858 }
859 }
860 let transform = g.transform;
861 g.set_sphere(centre, radius, None);
864
865 let to_world = |v: glam::IVec3| -> [f32; 3] {
873 let w =
874 transform.origin + transform.rotation * (v.as_dvec3() + glam::DVec3::splat(0.5));
875 #[allow(clippy::cast_possible_truncation)]
876 [w.x as f32, w.y as f32, w.z as f32]
877 };
878 let stride = samples.len().div_ceil(self.carve_debris_cap).max(1);
879 let sites: Vec<([f32; 3], Rgb)> = samples
880 .iter()
881 .step_by(stride)
882 .map(|&(v, col)| (to_world(v), col.rgb_part()))
883 .collect();
884 self.voxel_debris(&sites, to_world(centre), outward, def)
885 }
886
887 pub fn voxel_debris(
902 &mut self,
903 sites: &[([f32; 3], Rgb)],
904 from: [f32; 3],
905 outward: Range<f32>,
906 def: &ParticleEmitterDef,
907 ) -> u32 {
908 if sites.is_empty() {
909 return 0;
910 }
911 let mut def = def.clone();
914 def.spawn = SpawnMode::Manual;
915 let id = self.add_emitter(def.clone());
916 let slot = self.map.index(id).expect("just allocated");
917
918 let stride = sites.len().div_ceil(self.carve_debris_cap).max(1);
919 let mut spawned: u32 = 0;
920 for (pos, col) in sites.iter().step_by(stride) {
921 if self.particles.len() >= self.max_particles {
922 self.dropped_spawns += 1;
923 continue;
924 }
925 let pos = *pos;
926 let d = [pos[0] - from[0], pos[1] - from[1], pos[2] - from[2]];
929 let len = (d[0] * d[0] + d[1] * d[1] + d[2] * d[2]).sqrt();
930 let dir = if len > 1e-4 {
931 [d[0] / len, d[1] / len, d[2] / len]
932 } else {
933 self.rng.unit_vec()
934 };
935 let mut vel = sample_velocity(&mut self.rng, &def.velocity);
936 let speed = self.rng.range_f32(&outward);
937 for a in 0..3 {
938 vel[a] += dir[a] * speed;
939 }
940 let lifetime = self.rng.range_f32(&def.lifetime).max(1e-3);
941 self.particles.push(Particle {
942 pos,
943 vel,
944 age: 0.0,
945 lifetime,
946 scale: def.scale,
947 yaw: 0.0,
948 spin_rate: self.rng.range_f32(&def.spin),
949 alpha: fade_alpha(0.0, lifetime, def.fade_in_frac, def.fade_out_frac),
950 tint: *col,
951 emitter_slot: slot as u32,
952 instance: None,
953 last_alpha: 255,
954 last_tint: Rgb::WHITE,
955 tint_start: *col,
956 });
957 spawned += 1;
958 }
959 self.emitters[slot]
960 .as_mut()
961 .expect("slot allocated above")
962 .live += spawned;
963 self.remove_emitter(id);
964 spawned
965 }
966
967 fn spawn_from(&mut self, slot: usize, n: u32) -> u32 {
970 let state = self.emitters[slot]
971 .as_mut()
972 .expect("spawn_from callers hold a live slot");
973 let def = state.def.clone(); let mut spawned = 0;
975 for _ in 0..n {
976 if self.particles.len() >= self.max_particles {
977 self.dropped_spawns += u64::from(n - spawned);
978 break;
979 }
980 let mut pos = def.pos;
982 match def.shape {
983 EmitterShape::Point => {}
984 EmitterShape::Sphere { radius } => {
985 let dir = self.rng.unit_vec();
987 let r = radius * self.rng.next_f32().cbrt();
988 for a in 0..3 {
989 pos[a] += dir[a] * r;
990 }
991 }
992 EmitterShape::Box { half } => {
993 for a in 0..3 {
994 pos[a] += (self.rng.next_f32() * 2.0 - 1.0) * half[a];
995 }
996 }
997 }
998
999 let vel = sample_velocity(&mut self.rng, &def.velocity);
1000 let lifetime = self.rng.range_f32(&def.lifetime).max(1e-3);
1001 self.particles.push(Particle {
1002 pos,
1003 vel,
1004 age: 0.0,
1005 lifetime,
1006 scale: def.scale,
1007 yaw: 0.0,
1008 spin_rate: self.rng.range_f32(&def.spin),
1009 alpha: fade_alpha(0.0, lifetime, def.fade_in_frac, def.fade_out_frac),
1010 tint: def.tint,
1011 emitter_slot: slot as u32,
1012 instance: None,
1013 last_alpha: 255,
1014 last_tint: Rgb::WHITE,
1015 tint_start: def.tint,
1016 });
1017 spawned += 1;
1018 }
1019 self.emitters[slot]
1021 .as_mut()
1022 .expect("slot unchanged during spawn")
1023 .live += spawned;
1024 spawned
1025 }
1026
1027 fn on_particle_died(&mut self, slot: usize) {
1030 let state = self.emitters[slot]
1031 .as_mut()
1032 .expect("live particle ⇒ emitter state retained");
1033 state.live -= 1;
1034 if state.retired && state.live == 0 {
1035 self.emitters[slot] = None;
1036 }
1037 }
1038}
1039
1040fn particle_xf(p: &Particle) -> DynSpriteTransform {
1046 let k = p.scale.max(0.05);
1047 let (c, s) = (p.yaw.cos() * k, p.yaw.sin() * k);
1048 DynSpriteTransform {
1049 pos: p.pos,
1050 right: [c, s, 0.0],
1051 up: [-s, c, 0.0],
1052 forward: [0.0, 0.0, k],
1053 }
1054}
1055
1056fn sample_velocity(rng: &mut Pcg32, v: &VelocityDef) -> [f32; 3] {
1059 let mut vel = v.base;
1060 if v.spread > 0.0 {
1061 let dir = rng.unit_vec();
1062 let speed = rng.next_f32() * v.spread;
1063 for a in 0..3 {
1064 vel[a] += dir[a] * speed;
1065 }
1066 }
1067 if let Some(cone) = &v.cone {
1068 let dir = cone_dir(rng, cone.axis, cone.half_angle_deg);
1069 let speed = rng.range_f32(&cone.speed);
1070 for a in 0..3 {
1071 vel[a] += dir[a] * speed;
1072 }
1073 }
1074 vel
1075}
1076
1077fn cone_dir(rng: &mut Pcg32, axis: [f32; 3], half_angle_deg: f32) -> [f32; 3] {
1081 let len2 = axis[0] * axis[0] + axis[1] * axis[1] + axis[2] * axis[2];
1082 let w = if len2 > 1e-12 {
1083 let inv = 1.0 / len2.sqrt();
1084 [axis[0] * inv, axis[1] * inv, axis[2] * inv]
1085 } else {
1086 [0.0, 0.0, -1.0]
1087 };
1088 let cos_half = half_angle_deg.clamp(0.0, 180.0).to_radians().cos();
1090 let cz = 1.0 - rng.next_f32() * (1.0 - cos_half);
1091 let sz = (1.0 - cz * cz).max(0.0).sqrt();
1092 let phi = rng.next_f32() * std::f32::consts::TAU;
1093 let t = if w[0].abs() < 0.5 {
1096 [1.0, 0.0, 0.0]
1097 } else {
1098 [0.0, 1.0, 0.0]
1099 };
1100 let u = {
1101 let c = [
1102 w[1] * t[2] - w[2] * t[1],
1103 w[2] * t[0] - w[0] * t[2],
1104 w[0] * t[1] - w[1] * t[0],
1105 ];
1106 let inv = 1.0 / (c[0] * c[0] + c[1] * c[1] + c[2] * c[2]).sqrt();
1107 [c[0] * inv, c[1] * inv, c[2] * inv]
1108 };
1109 let v = [
1110 w[1] * u[2] - w[2] * u[1],
1111 w[2] * u[0] - w[0] * u[2],
1112 w[0] * u[1] - w[1] * u[0],
1113 ];
1114 let (cp, sp) = (phi.cos() * sz, phi.sin() * sz);
1115 [
1116 w[0] * cz + u[0] * cp + v[0] * sp,
1117 w[1] * cz + u[1] * cp + v[1] * sp,
1118 w[2] * cz + u[2] * cp + v[2] * sp,
1119 ]
1120}
1121
1122fn scene_solid_ahead(scene: &roxlap_scene::Scene, pos: [f32; 3], vel: [f32; 3]) -> bool {
1129 let world = glam::DVec3::new(f64::from(pos[0]), f64::from(pos[1]), f64::from(pos[2]));
1130 let dir = glam::DVec3::new(f64::from(vel[0]), f64::from(vel[1]), f64::from(vel[2]));
1131 scene.resolve_voxel(world, dir).is_some()
1132}
1133
1134fn lerp_tint(a: Rgb, b: Rgb, t: f32) -> Rgb {
1136 let t = t.clamp(0.0, 1.0);
1137 let ch = |sh: u32| {
1138 let (ca, cb) = ((a.0 >> sh) & 0xff, (b.0 >> sh) & 0xff);
1139 let m = ca as f32 + (cb as f32 - ca as f32) * t;
1140 ((m as u32) & 0xff) << sh
1141 };
1142 Rgb(ch(16) | ch(8) | ch(0))
1143}
1144
1145pub(crate) trait ParticleFacade {
1151 fn spawn(&mut self, model: SpriteModelId, xf: DynSpriteTransform) -> Option<SpriteInstanceId>;
1152 fn despawn(&mut self, id: SpriteInstanceId);
1153 fn set_transforms(&mut self, batch: &[(SpriteInstanceId, DynSpriteTransform)]);
1154 fn set_alpha(&mut self, id: SpriteInstanceId, alpha: u8);
1155 fn set_tint(&mut self, id: SpriteInstanceId, tint: Rgb);
1156 fn set_material(&mut self, id: SpriteInstanceId, material: u8);
1157 fn set_lighting(&mut self, id: SpriteInstanceId, mode: BillboardLighting);
1158 fn set_shadows(&mut self, id: SpriteInstanceId, flags: ShadowFlags);
1159}
1160
1161impl ParticleFacade for SceneRenderer {
1162 fn spawn(&mut self, model: SpriteModelId, xf: DynSpriteTransform) -> Option<SpriteInstanceId> {
1163 self.add_sprite_instance_posed(model, xf)
1164 }
1165 fn despawn(&mut self, id: SpriteInstanceId) {
1166 self.remove_sprite_instance(id);
1169 }
1170 fn set_transforms(&mut self, batch: &[(SpriteInstanceId, DynSpriteTransform)]) {
1171 self.set_sprite_instance_transforms(batch);
1172 }
1173 fn set_alpha(&mut self, id: SpriteInstanceId, alpha: u8) {
1174 self.set_sprite_instance_alpha(id, alpha);
1175 }
1176 fn set_tint(&mut self, id: SpriteInstanceId, tint: Rgb) {
1177 self.set_sprite_instance_tint(id, tint);
1178 }
1179 fn set_material(&mut self, id: SpriteInstanceId, material: u8) {
1180 self.set_sprite_instance_material(id, material);
1181 }
1182 fn set_lighting(&mut self, id: SpriteInstanceId, mode: BillboardLighting) {
1183 self.set_sprite_instance_lighting(id, mode);
1184 }
1185 fn set_shadows(&mut self, id: SpriteInstanceId, flags: ShadowFlags) {
1186 self.set_sprite_instance_shadow_flags(id, flags);
1187 }
1188}
1189
1190fn fade_alpha(age: f32, lifetime: f32, in_frac: f32, out_frac: f32) -> u8 {
1194 let frac = age / lifetime;
1195 let mut a = 1.0_f32;
1196 if in_frac > 0.0 {
1197 a = a.min((frac / in_frac.min(1.0)).clamp(0.0, 1.0));
1198 }
1199 if out_frac > 0.0 {
1200 a = a.min(((1.0 - frac) / out_frac.min(1.0)).clamp(0.0, 1.0));
1201 }
1202 (a * 255.0) as u8
1203}
1204
1205#[cfg(test)]
1206mod tests {
1207 use super::*;
1208
1209 fn dummy_model() -> SpriteModelId {
1212 SpriteModelId::mint(0, 0)
1213 }
1214
1215 fn base_def() -> ParticleEmitterDef {
1216 ParticleEmitterDef {
1217 spawn: SpawnMode::Manual,
1218 lifetime: 1.0..1.0,
1219 gravity: [0.0, 0.0, 0.0],
1220 fade_out_frac: 0.0,
1221 ..ParticleEmitterDef::new(dummy_model())
1222 }
1223 }
1224
1225 #[test]
1226 fn same_seed_is_bit_identical() {
1227 let run = || {
1228 let mut sys = ParticleSystem::new(0x00C0_FFEE);
1229 let em = sys.add_emitter(ParticleEmitterDef {
1230 spawn: SpawnMode::Rate(120.0),
1231 lifetime: 0.3..0.9,
1232 velocity: VelocityDef {
1233 base: [0.0, 0.0, -10.0],
1234 spread: 4.0,
1235 ..VelocityDef::default()
1236 },
1237 gravity: [0.0, 0.0, 22.0],
1238 ..ParticleEmitterDef::new(dummy_model())
1239 });
1240 sys.burst(em, 7);
1241 for _ in 0..60 {
1242 sys.update(1.0 / 60.0);
1243 }
1244 sys.particles()
1245 .iter()
1246 .map(|p| (p.pos, p.vel, p.age, p.lifetime))
1247 .collect::<Vec<_>>()
1248 };
1249 let (a, b) = (run(), run());
1250 assert_eq!(a.len(), b.len());
1251 for (pa, pb) in a.iter().zip(&b) {
1252 assert_eq!(pa, pb, "same seed must be bit-identical");
1253 }
1254 assert!(!a.is_empty());
1255 }
1256
1257 #[test]
1258 fn rate_accumulates_across_frames() {
1259 let mut sys = ParticleSystem::new(1);
1260 sys.add_emitter(ParticleEmitterDef {
1261 spawn: SpawnMode::Rate(10.0),
1262 lifetime: 100.0..100.0,
1263 ..base_def()
1264 });
1265 for _ in 0..10 {
1266 sys.update(0.1);
1267 }
1268 assert_eq!(sys.particle_count(), 10);
1269
1270 let mut slow = ParticleSystem::new(2);
1272 slow.add_emitter(ParticleEmitterDef {
1273 spawn: SpawnMode::Rate(0.5),
1274 lifetime: 100.0..100.0,
1275 ..base_def()
1276 });
1277 for _ in 0..20 {
1278 slow.update(0.1);
1279 }
1280 assert_eq!(slow.particle_count(), 1);
1281 }
1282
1283 #[test]
1284 fn burst_mode_fires_on_add() {
1285 let mut sys = ParticleSystem::new(3);
1286 sys.add_emitter(ParticleEmitterDef {
1287 spawn: SpawnMode::Burst(5),
1288 ..base_def()
1289 });
1290 assert_eq!(sys.particle_count(), 5);
1291 }
1292
1293 #[test]
1294 fn budget_drops_spawns_and_counts_them() {
1295 let mut sys = ParticleSystem::new(4);
1296 sys.set_max_particles(5);
1297 let em = sys.add_emitter(base_def());
1298 assert_eq!(sys.burst(em, 10), 5);
1299 assert_eq!(sys.particle_count(), 5);
1300 assert_eq!(sys.dropped_spawns(), 5);
1301 }
1302
1303 #[test]
1304 fn particles_die_at_lifetime() {
1305 let mut sys = ParticleSystem::new(5);
1306 let em = sys.add_emitter(ParticleEmitterDef {
1307 lifetime: 0.5..0.5,
1308 ..base_def()
1309 });
1310 sys.burst(em, 3);
1311 sys.update(0.3);
1312 assert_eq!(sys.particle_count(), 3);
1313 sys.update(0.3); assert_eq!(sys.particle_count(), 0);
1315 assert_eq!(sys.drain_dead_instances().count(), 0);
1317 }
1318
1319 #[test]
1320 fn semi_implicit_euler_gravity() {
1321 let mut sys = ParticleSystem::new(6);
1322 let em = sys.add_emitter(ParticleEmitterDef {
1323 gravity: [0.0, 0.0, 10.0],
1324 lifetime: 100.0..100.0,
1325 ..base_def()
1326 });
1327 sys.burst(em, 1);
1328 sys.update(0.1);
1329 let p = sys.particles()[0];
1330 assert!((p.vel[2] - 1.0).abs() < 1e-6);
1332 assert!((p.pos[2] - 0.1).abs() < 1e-6);
1333 }
1334
1335 #[test]
1336 fn fade_curve_hits_endpoints() {
1337 assert_eq!(fade_alpha(0.0, 1.0, 0.0, 0.5), 255);
1338 assert_eq!(fade_alpha(0.5, 1.0, 0.0, 0.5), 255); assert_eq!(fade_alpha(0.75, 1.0, 0.0, 0.5), 127); assert_eq!(fade_alpha(1.0, 1.0, 0.0, 0.5), 0);
1341 assert_eq!(fade_alpha(0.99, 1.0, 0.0, 0.0), 255); assert_eq!(fade_alpha(0.0, 1.0, 0.25, 0.0), 0);
1345 assert_eq!(fade_alpha(0.125, 1.0, 0.25, 0.0), 127);
1346 assert_eq!(fade_alpha(0.25, 1.0, 0.25, 0.0), 255);
1347 assert_eq!(fade_alpha(0.5, 1.0, 1.0, 1.0), 127); }
1349
1350 #[test]
1351 fn shapes_sample_within_bounds() {
1352 let mut sys = ParticleSystem::new(20);
1353 let sphere = sys.add_emitter(ParticleEmitterDef {
1354 pos: [10.0, 0.0, 0.0],
1355 shape: EmitterShape::Sphere { radius: 3.0 },
1356 ..base_def()
1357 });
1358 let boxy = sys.add_emitter(ParticleEmitterDef {
1359 pos: [-10.0, 0.0, 0.0],
1360 shape: EmitterShape::Box {
1361 half: [1.0, 2.0, 0.5],
1362 },
1363 ..base_def()
1364 });
1365 sys.burst(sphere, 64);
1366 sys.burst(boxy, 64);
1367 let mut spread = false;
1368 for p in sys.particles() {
1369 if p.pos[0] > 0.0 {
1370 let d = [p.pos[0] - 10.0, p.pos[1], p.pos[2]];
1371 let r = (d[0] * d[0] + d[1] * d[1] + d[2] * d[2]).sqrt();
1372 assert!(r <= 3.0 + 1e-4, "sphere sample outside radius: {r}");
1373 spread |= r > 0.1;
1374 } else {
1375 let d = [p.pos[0] + 10.0, p.pos[1], p.pos[2]];
1376 assert!(
1377 d[0].abs() <= 1.0 + 1e-4
1378 && d[1].abs() <= 2.0 + 1e-4
1379 && d[2].abs() <= 0.5 + 1e-4,
1380 "box sample outside half-extents: {d:?}"
1381 );
1382 spread |= d[1].abs() > 1.0; }
1384 }
1385 assert!(spread, "samples must actually spread out");
1386 }
1387
1388 #[test]
1389 fn cone_stays_within_half_angle() {
1390 let mut sys = ParticleSystem::new(21);
1391 let axis = [0.0, 1.0, -1.0]; let em = sys.add_emitter(ParticleEmitterDef {
1393 velocity: VelocityDef {
1394 cone: Some(ConeDef {
1395 axis,
1396 half_angle_deg: 30.0,
1397 speed: 5.0..10.0,
1398 }),
1399 ..VelocityDef::default()
1400 },
1401 ..base_def()
1402 });
1403 sys.burst(em, 64);
1404 let inv = 1.0 / (2.0_f32).sqrt();
1405 let w = [0.0, inv, -inv];
1406 let cos_half = 30.0_f32.to_radians().cos();
1407 for p in sys.particles() {
1408 let sp = (p.vel[0] * p.vel[0] + p.vel[1] * p.vel[1] + p.vel[2] * p.vel[2]).sqrt();
1409 assert!(
1410 (5.0 - 1e-3..10.0 + 1e-3).contains(&sp),
1411 "speed in range: {sp}"
1412 );
1413 let cosang = (p.vel[0] * w[0] + p.vel[1] * w[1] + p.vel[2] * w[2]) / sp;
1414 assert!(
1415 cosang >= cos_half - 1e-4,
1416 "direction within the cone: cos {cosang} < {cos_half}"
1417 );
1418 }
1419 assert_eq!(
1421 cone_dir(&mut Pcg32::new(1), [0.0; 3], 0.0),
1422 [0.0, 0.0, -1.0]
1423 );
1424 }
1425
1426 #[test]
1427 fn spin_rotates_pose_and_keeps_scale() {
1428 let mut sys = ParticleSystem::new(22);
1429 let em = sys.add_emitter(ParticleEmitterDef {
1430 spin: 2.0..2.0,
1431 scale: 3.0,
1432 lifetime: 100.0..100.0,
1433 ..base_def()
1434 });
1435 sys.burst(em, 1);
1436 sys.update(0.25); let p = sys.particles()[0];
1438 assert!((p.yaw - 0.5).abs() < 1e-6);
1439 let xf = particle_xf(&p);
1440 let len = |v: [f32; 3]| (v[0] * v[0] + v[1] * v[1] + v[2] * v[2]).sqrt();
1442 assert!((len(xf.right) - 3.0).abs() < 1e-5);
1443 assert!((len(xf.up) - 3.0).abs() < 1e-5);
1444 assert!(xf.right[1].abs() > 0.1, "yaw actually rotates the basis");
1445 let dot = xf.right[0] * xf.up[0] + xf.right[1] * xf.up[1];
1446 assert!(dot.abs() < 1e-5);
1447 }
1448
1449 #[test]
1450 fn scale_and_tint_lerp_over_life() {
1451 let mut sys = ParticleSystem::new(23);
1452 let em = sys.add_emitter(ParticleEmitterDef {
1453 lifetime: 1.0..1.0,
1454 scale: 1.0,
1455 scale_end: Some(3.0),
1456 tint: Rgb(0x00FF_0000),
1457 tint_end: Some(Rgb(0x0000_00FF)),
1458 ..base_def()
1459 });
1460 sys.burst(em, 1);
1461 sys.update(0.5);
1462 let p = sys.particles()[0];
1463 assert!((p.scale - 2.0).abs() < 1e-5, "mid-life scale: {}", p.scale);
1464 let (r, b) = ((p.tint.0 >> 16) & 0xff, p.tint.0 & 0xff);
1465 assert!(
1466 (126..=128).contains(&r) && (126..=128).contains(&b),
1467 "mid tint: {:#08x}",
1468 p.tint.0
1469 );
1470
1471 let mut f = Mock::default();
1473 sys.sync_with(&mut f);
1474 assert_eq!(f.tints.len(), 1);
1475 sys.update(0.1);
1476 sys.sync_with(&mut f);
1477 assert_eq!(f.tints.len(), 2, "changed tint re-syncs");
1478 }
1479
1480 #[test]
1481 fn retired_emitter_drains_then_frees() {
1482 let mut sys = ParticleSystem::new(7);
1483 let em = sys.add_emitter(ParticleEmitterDef {
1484 spawn: SpawnMode::Rate(1000.0),
1485 lifetime: 0.2..0.2,
1486 ..base_def()
1487 });
1488 sys.update(0.01);
1489 assert!(sys.particle_count() > 0);
1490 assert!(sys.remove_emitter(em));
1491 assert_eq!(sys.emitter_count(), 0);
1492 assert!(!sys.remove_emitter(em));
1494 assert!(!sys.set_emitter_pos(em, [1.0, 2.0, 3.0]));
1495 assert_eq!(sys.burst(em, 10), 0);
1496 let live = sys.particle_count();
1499 sys.update(0.3);
1500 assert_eq!(sys.particle_count(), 0);
1501 assert!(live > 0);
1502 assert!(sys.emitters.iter().all(Option::is_none));
1503 }
1504
1505 #[derive(Default)]
1507 struct Mock {
1508 next_slot: u32,
1509 fail_spawn: bool,
1510 spawns: Vec<(SpriteModelId, DynSpriteTransform)>,
1511 despawns: Vec<SpriteInstanceId>,
1512 batch_sizes: Vec<usize>,
1513 alphas: Vec<(SpriteInstanceId, u8)>,
1514 tints: Vec<Rgb>,
1515 materials: Vec<u8>,
1516 lightings: Vec<BillboardLighting>,
1517 shadows: Vec<ShadowFlags>,
1518 }
1519
1520 impl ParticleFacade for Mock {
1521 fn spawn(
1522 &mut self,
1523 model: SpriteModelId,
1524 xf: DynSpriteTransform,
1525 ) -> Option<SpriteInstanceId> {
1526 if self.fail_spawn {
1527 return None;
1528 }
1529 self.spawns.push((model, xf));
1530 let id = SpriteInstanceId {
1531 slot: self.next_slot,
1532 gen: 0,
1533 };
1534 self.next_slot += 1;
1535 Some(id)
1536 }
1537 fn despawn(&mut self, id: SpriteInstanceId) {
1538 self.despawns.push(id);
1539 }
1540 fn set_transforms(&mut self, batch: &[(SpriteInstanceId, DynSpriteTransform)]) {
1541 self.batch_sizes.push(batch.len());
1542 }
1543 fn set_alpha(&mut self, id: SpriteInstanceId, alpha: u8) {
1544 self.alphas.push((id, alpha));
1545 }
1546 fn set_tint(&mut self, _id: SpriteInstanceId, tint: Rgb) {
1547 self.tints.push(tint);
1548 }
1549 fn set_material(&mut self, _id: SpriteInstanceId, material: u8) {
1550 self.materials.push(material);
1551 }
1552 fn set_lighting(&mut self, _id: SpriteInstanceId, mode: BillboardLighting) {
1553 self.lightings.push(mode);
1554 }
1555 fn set_shadows(&mut self, _id: SpriteInstanceId, flags: ShadowFlags) {
1556 self.shadows.push(flags);
1557 }
1558 }
1559
1560 #[test]
1561 fn sync_spawns_once_then_batch_moves() {
1562 let mut sys = ParticleSystem::new(10);
1563 let em = sys.add_emitter(ParticleEmitterDef {
1564 lifetime: 100.0..100.0,
1565 ..base_def()
1566 });
1567 sys.burst(em, 3);
1568 let mut f = Mock::default();
1569
1570 sys.sync_with(&mut f);
1571 assert_eq!(f.spawns.len(), 3);
1573 assert!(f.batch_sizes.is_empty());
1574 assert_eq!(f.shadows.len(), 3);
1577 assert!(f.materials.is_empty() && f.tints.is_empty() && f.lightings.is_empty());
1578 assert!(f.alphas.is_empty(), "alpha 255 == facade default");
1579
1580 sys.update(0.01);
1582 sys.sync_with(&mut f);
1583 assert_eq!(f.spawns.len(), 3);
1584 assert_eq!(f.batch_sizes, vec![3]);
1585 }
1586
1587 #[test]
1588 fn sync_one_time_setup_honours_def() {
1589 let mut sys = ParticleSystem::new(11);
1590 let em = sys.add_emitter(ParticleEmitterDef {
1591 lifetime: 100.0..100.0,
1592 tint: Rgb(0x00FF_0000),
1593 material: 5,
1594 lighting: BillboardLighting::FullBright,
1595 shadows: ShadowFlags::default(), ..base_def()
1597 });
1598 sys.burst(em, 1);
1599 let mut f = Mock::default();
1600 sys.sync_with(&mut f);
1601 assert_eq!(f.materials, vec![5]);
1602 assert_eq!(f.tints, vec![Rgb(0x00FF_0000)]);
1603 assert_eq!(f.lightings, vec![BillboardLighting::FullBright]);
1604 assert!(f.shadows.is_empty(), "facade-default shadows skip the call");
1605 sys.update(0.01);
1607 sys.sync_with(&mut f);
1608 assert_eq!(f.materials.len() + f.tints.len() + f.lightings.len(), 3);
1609 }
1610
1611 #[test]
1612 fn sync_despawns_dead_and_writes_alpha_on_change_only() {
1613 let mut sys = ParticleSystem::new(12);
1614 let em = sys.add_emitter(ParticleEmitterDef {
1615 lifetime: 1.0..1.0,
1616 fade_out_frac: 0.5,
1617 ..base_def()
1618 });
1619 sys.burst(em, 2);
1620 let mut f = Mock::default();
1621 sys.sync_with(&mut f);
1622
1623 sys.update(0.25);
1625 sys.sync_with(&mut f);
1626 assert!(f.alphas.is_empty());
1627
1628 sys.update(0.5); sys.sync_with(&mut f);
1631 assert_eq!(f.alphas.len(), 2);
1632 assert!(f.alphas.iter().all(|&(_, a)| a == 127));
1633
1634 sys.update(0.5);
1636 assert_eq!(sys.particle_count(), 0);
1637 sys.sync_with(&mut f);
1638 assert_eq!(f.despawns.len(), 2);
1639 }
1640
1641 #[test]
1642 fn stale_model_spawn_kills_particle() {
1643 let mut sys = ParticleSystem::new(13);
1644 let em = sys.add_emitter(base_def());
1645 sys.burst(em, 2);
1646 let mut f = Mock {
1647 fail_spawn: true,
1648 ..Mock::default()
1649 };
1650 sys.sync_with(&mut f);
1651 assert_eq!(sys.particle_count(), 0);
1652 assert_eq!(sys.stale_model_kills(), 2);
1653 assert!(sys.remove_emitter(em));
1656 assert!(sys.emitters.iter().all(Option::is_none));
1657 }
1658
1659 #[test]
1660 fn particle_xf_scales_and_clamps() {
1661 let p = Particle {
1662 pos: [1.0, 2.0, 3.0],
1663 vel: [0.0; 3],
1664 age: 0.0,
1665 lifetime: 1.0,
1666 scale: 2.0,
1667 yaw: 0.0,
1668 spin_rate: 0.0,
1669 alpha: 255,
1670 tint: Rgb(0),
1671 emitter_slot: 0,
1672 instance: None,
1673 last_alpha: 255,
1674 last_tint: Rgb::WHITE,
1675 tint_start: Rgb(0),
1676 };
1677 let xf = particle_xf(&p);
1678 assert_eq!(xf.pos, [1.0, 2.0, 3.0]);
1679 assert_eq!((xf.right[0], xf.up[1], xf.forward[2]), (2.0, 2.0, 2.0));
1680 let tiny = particle_xf(&Particle { scale: 0.0, ..p });
1682 assert_eq!(tiny.right[0], 0.05);
1683 }
1684
1685 fn scene_with_floor() -> roxlap_scene::Scene {
1687 use glam::{DVec3, IVec3};
1688 let mut scene = roxlap_scene::Scene::new();
1689 let id = scene.add_grid(roxlap_scene::GridTransform::at(DVec3::ZERO));
1690 let g = scene.grid_mut(id).expect("fresh grid");
1691 g.set_rect(
1692 IVec3::new(-16, -16, 10),
1693 IVec3::new(16, 16, 12),
1694 Some(roxlap_formats::VoxColor(0x80FF_FFFF)),
1695 );
1696 scene
1697 }
1698
1699 fn falling(collision: CollisionMode) -> ParticleEmitterDef {
1701 ParticleEmitterDef {
1702 pos: [0.0, 0.0, 5.0],
1703 velocity: VelocityDef {
1704 base: [0.0, 0.0, 20.0],
1705 ..VelocityDef::default()
1706 },
1707 lifetime: 100.0..100.0,
1708 collision,
1709 ..base_def() }
1711 }
1712
1713 #[test]
1714 fn collision_kill_dies_on_contact() {
1715 let scene = scene_with_floor();
1716 let mut sys = ParticleSystem::new(30);
1717 let em = sys.add_emitter(falling(CollisionMode::Kill));
1718 sys.burst(em, 1);
1719 for _ in 0..20 {
1720 sys.update_with_scene(0.05, &scene);
1721 }
1722 assert_eq!(sys.particle_count(), 0, "dies at the slab");
1723
1724 let mut free = ParticleSystem::new(30);
1726 let em = free.add_emitter(falling(CollisionMode::Kill));
1727 free.burst(em, 1);
1728 for _ in 0..20 {
1729 free.update(0.05);
1730 }
1731 assert_eq!(free.particle_count(), 1);
1732 assert!(free.particles()[0].pos[2] > 12.0, "fell straight through");
1733 }
1734
1735 #[test]
1736 fn collision_none_passes_through() {
1737 let scene = scene_with_floor();
1738 let mut sys = ParticleSystem::new(31);
1739 let em = sys.add_emitter(falling(CollisionMode::None));
1740 sys.burst(em, 1);
1741 for _ in 0..20 {
1742 sys.update_with_scene(0.05, &scene);
1743 }
1744 assert!(sys.particles()[0].pos[2] > 12.0);
1745 }
1746
1747 #[test]
1748 fn collision_bounce_reflects_and_damps() {
1749 let scene = scene_with_floor();
1750 let mut sys = ParticleSystem::new(32);
1751 let em = sys.add_emitter(falling(CollisionMode::Bounce { restitution: 0.5 }));
1752 sys.burst(em, 1);
1753 let mut bounced = false;
1754 for _ in 0..40 {
1755 sys.update_with_scene(0.05, &scene);
1756 let p = sys.particles()[0];
1757 assert!(p.pos[2] < 10.5, "never sinks into the slab: {}", p.pos[2]);
1758 if p.vel[2] < 0.0 {
1759 bounced = true;
1760 }
1761 }
1762 assert!(bounced, "velocity reflected upward");
1763 let p = sys.particles()[0];
1764 assert!(
1765 p.vel[2].abs() <= 10.0 + 1e-3,
1766 "restitution halves the speed: {}",
1767 p.vel[2]
1768 );
1769 }
1770
1771 #[test]
1772 fn carve_debris_samples_colours_and_carves() {
1773 use glam::{DVec3, IVec3};
1774 let mut scene = roxlap_scene::Scene::new();
1776 let grid = scene.add_grid(roxlap_scene::GridTransform::at(DVec3::ZERO));
1777 scene.grid_mut(grid).expect("grid").set_rect(
1778 IVec3::new(-8, -8, 10),
1779 IVec3::new(8, 8, 12),
1780 Some(roxlap_formats::VoxColor(0x80_12_34_56)),
1781 );
1782 let mut sys = ParticleSystem::new(40);
1783 let centre = IVec3::new(0, 0, 11);
1784 let spawned = sys.carve_debris(
1785 &mut scene,
1786 grid,
1787 centre,
1788 2,
1789 4.0..6.0,
1790 &ParticleEmitterDef {
1791 lifetime: 100.0..100.0,
1792 ..base_def()
1793 },
1794 );
1795 assert!(spawned > 0);
1796 assert_eq!(sys.particle_count() as u32, spawned);
1797 for p in sys.particles() {
1799 assert_eq!(p.tint, Rgb(0x0012_3456), "tint is the voxel colour");
1800 }
1801 let g = scene.grid_mut(grid).expect("grid");
1803 assert!(g.voxel_color(centre).is_none());
1804 for p in sys.particles() {
1806 let d = [p.pos[0] - 0.5, p.pos[1] - 0.5, p.pos[2] - 11.5];
1807 let r2 = d[0] * d[0] + d[1] * d[1] + d[2] * d[2];
1808 if r2 > 0.5 {
1809 let dot = d[0] * p.vel[0] + d[1] * p.vel[1] + d[2] * p.vel[2];
1810 assert!(dot > 0.0, "debris flies outward: {d:?} vs {:?}", p.vel);
1811 }
1812 }
1813 assert_eq!(sys.emitter_count(), 0);
1815 sys.update(200.0);
1816 assert_eq!(sys.particle_count(), 0);
1817 assert!(sys.emitters.iter().all(Option::is_none));
1818 }
1819
1820 #[test]
1826 fn carve_debris_equals_sample_plus_voxel_debris() {
1827 use glam::{DVec3, IVec3};
1828 let build = || {
1829 let mut scene = roxlap_scene::Scene::new();
1830 let grid = scene.add_grid(roxlap_scene::GridTransform::at(DVec3::ZERO));
1831 scene.grid_mut(grid).expect("grid").set_rect(
1832 IVec3::new(-8, -8, 10),
1833 IVec3::new(8, 8, 12),
1834 Some(roxlap_formats::VoxColor(0x80_12_34_56)),
1835 );
1836 (scene, grid)
1837 };
1838 let def = ParticleEmitterDef {
1839 lifetime: 1.0..3.0,
1840 spin: -2.0..2.0,
1841 velocity: VelocityDef {
1842 spread: 3.0,
1843 ..VelocityDef::default()
1844 },
1845 ..base_def()
1846 };
1847 let centre = IVec3::new(0, 0, 11);
1848
1849 let (mut scene_a, grid_a) = build();
1850 let mut a = ParticleSystem::new(77);
1851 let na = a.carve_debris(&mut scene_a, grid_a, centre, 2, 4.0..6.0, &def);
1852
1853 let (mut scene_b, grid_b) = build();
1854 let mut b = ParticleSystem::new(77);
1855 let g = scene_b.grid_mut(grid_b).expect("grid");
1856 let mut sites = Vec::new();
1857 let r = 2i32;
1858 for z in -r..=r {
1859 for y in -r..=r {
1860 for x in -r..=r {
1861 if x * x + y * y + z * z > r * r {
1862 continue;
1863 }
1864 let v = centre + IVec3::new(x, y, z);
1865 if let Some(col) = g.voxel_color(v) {
1866 let w = v.as_dvec3() + DVec3::splat(0.5); #[allow(clippy::cast_possible_truncation)]
1868 sites.push(([w.x as f32, w.y as f32, w.z as f32], col.rgb_part()));
1869 }
1870 }
1871 }
1872 }
1873 g.set_sphere(centre, 2, None);
1874 let cw = centre.as_dvec3() + DVec3::splat(0.5);
1875 #[allow(clippy::cast_possible_truncation)]
1876 let nb = b.voxel_debris(
1877 &sites,
1878 [cw.x as f32, cw.y as f32, cw.z as f32],
1879 4.0..6.0,
1880 &def,
1881 );
1882
1883 assert_eq!(na, nb, "same debris count");
1884 assert!(na > 0);
1885 for (pa, pb) in a.particles().iter().zip(b.particles()) {
1886 assert_eq!(pa.pos, pb.pos);
1887 assert_eq!(pa.vel, pb.vel);
1888 assert_eq!(pa.tint, pb.tint);
1889 assert_eq!(pa.lifetime, pb.lifetime);
1890 assert_eq!(pa.spin_rate, pb.spin_rate);
1891 }
1892 }
1893
1894 #[test]
1895 fn carve_debris_caps_big_carves_and_respects_budget() {
1896 use glam::IVec3;
1897 let mut scene = scene_with_floor();
1898 let grid = scene.grids().next().expect("one grid").0;
1899 let mut sys = ParticleSystem::new(41);
1900 let spawned = sys.carve_debris(
1902 &mut scene,
1903 grid,
1904 IVec3::new(0, 0, 11),
1905 6,
1906 1.0..2.0,
1907 &ParticleEmitterDef {
1908 lifetime: 100.0..100.0,
1909 ..base_def()
1910 },
1911 );
1912 assert!(spawned as usize <= CARVE_DEBRIS_CAP);
1913 assert!(
1914 spawned as usize > CARVE_DEBRIS_CAP / 2,
1915 "stride fills near the cap"
1916 );
1917
1918 let mut low = ParticleSystem::new(44);
1920 low.set_carve_debris_cap(16);
1921 let mut scene3 = scene_with_floor();
1922 let spawned = low.carve_debris(
1923 &mut scene3,
1924 grid,
1925 IVec3::new(0, 0, 11),
1926 6,
1927 1.0..2.0,
1928 &ParticleEmitterDef {
1929 lifetime: 100.0..100.0,
1930 ..base_def()
1931 },
1932 );
1933 assert!(spawned <= 16, "tuned cap respected: {spawned}");
1934 assert!(spawned >= 8, "stride still fills near the tuned cap");
1935
1936 let mut tiny = ParticleSystem::new(42);
1938 tiny.set_max_particles(5);
1939 let mut scene2 = scene_with_floor();
1940 let spawned = tiny.carve_debris(
1941 &mut scene2,
1942 grid,
1943 IVec3::new(10, 10, 11),
1944 4,
1945 1.0..2.0,
1946 &ParticleEmitterDef {
1947 lifetime: 100.0..100.0,
1948 ..base_def()
1949 },
1950 );
1951 assert_eq!(spawned, 5);
1952 assert!(tiny.dropped_spawns() > 0);
1953 }
1954
1955 #[test]
1956 fn carve_debris_tint_end_lerps_from_voxel_colour() {
1957 use glam::IVec3;
1958 let mut scene = scene_with_floor();
1959 let grid = scene.grids().next().expect("one grid").0;
1960 let mut sys = ParticleSystem::new(43);
1961 sys.carve_debris(
1962 &mut scene,
1963 grid,
1964 IVec3::new(0, 0, 11),
1965 1,
1966 0.0..0.0,
1967 &ParticleEmitterDef {
1968 lifetime: 1.0..1.0,
1969 tint_end: Some(Rgb(0x0000_0000)),
1970 ..base_def()
1971 },
1972 );
1973 assert!(sys.particle_count() > 0);
1974 sys.update(0.5);
1975 for p in sys.particles() {
1977 let r = (p.tint.0 >> 16) & 0xff;
1978 assert!(
1979 (120..=135).contains(&r),
1980 "lerp starts at the voxel colour, not def.tint: {:#08x}",
1981 p.tint.0
1982 );
1983 }
1984 }
1985
1986 #[test]
1987 fn stress_10k_particles_simulate_and_sync() {
1988 let mut sys = ParticleSystem::new(50);
1989 sys.set_max_particles(10_000);
1990 let em = sys.add_emitter(ParticleEmitterDef {
1991 lifetime: 2.0..2.0,
1995 velocity: VelocityDef {
1996 spread: 10.0,
1997 ..VelocityDef::default()
1998 },
1999 fade_in_frac: 0.5,
2001 fade_out_frac: 0.5,
2002 spin: -3.0..3.0,
2003 scale_end: Some(2.0),
2004 tint_end: Some(Rgb(0x0000_0000)),
2005 gravity: [0.0, 0.0, 5.0],
2006 ..base_def()
2007 });
2008 assert_eq!(sys.burst(em, 10_000), 10_000);
2009 let mut f = Mock::default();
2010 for _ in 0..30 {
2011 sys.update(1.0 / 60.0);
2012 sys.sync_with(&mut f);
2013 }
2014 assert_eq!(sys.particle_count(), 10_000);
2015 assert_eq!(f.spawns.len(), 10_000);
2016 assert_eq!(*f.batch_sizes.last().expect("batches ran"), 10_000);
2019 assert!(f.alphas.len() > 100_000, "alpha churns every frame");
2020 }
2021
2022 #[test]
2025 #[ignore = "manual perf probe — prints timings, asserts nothing"]
2026 fn stress_10k_probe() {
2027 let mut sys = ParticleSystem::new(51);
2028 sys.set_max_particles(10_000);
2029 let em = sys.add_emitter(ParticleEmitterDef {
2030 lifetime: 8.0..8.0,
2033 velocity: VelocityDef {
2034 spread: 10.0,
2035 ..VelocityDef::default()
2036 },
2037 fade_in_frac: 0.5,
2038 fade_out_frac: 0.5,
2039 spin: -3.0..3.0,
2040 scale_end: Some(2.0),
2041 tint_end: Some(Rgb(0x0000_0000)),
2042 ..base_def()
2043 });
2044 sys.burst(em, 10_000);
2045 let mut f = Mock::default();
2046 sys.sync_with(&mut f); let t0 = std::time::Instant::now();
2048 const FRAMES: u32 = 200;
2049 for _ in 0..FRAMES {
2050 sys.update(1.0 / 60.0);
2051 sys.sync_with(&mut f);
2052 }
2053 let per_frame = t0.elapsed() / FRAMES;
2054 eprintln!("10k particles: {per_frame:?}/frame (update + sync w/ alpha+tint churn)");
2055 }
2056
2057 #[test]
2058 fn moved_emitter_spawns_at_new_pos() {
2059 let mut sys = ParticleSystem::new(8);
2060 let em = sys.add_emitter(base_def());
2061 assert!(sys.set_emitter_pos(em, [5.0, 6.0, 7.0]));
2062 sys.burst(em, 1);
2063 assert_eq!(sys.particles()[0].pos, [5.0, 6.0, 7.0]);
2064 }
2065}