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 #[must_use]
659 pub fn dropped_spawns(&self) -> u64 {
660 self.dropped_spawns
661 }
662
663 pub fn drain_dead_instances(&mut self) -> impl Iterator<Item = SpriteInstanceId> + '_ {
668 self.dead_instances.drain(..)
669 }
670
671 #[must_use]
676 pub fn stale_model_kills(&self) -> u64 {
677 self.stale_model_kills
678 }
679
680 pub fn sync(&mut self, renderer: &mut SceneRenderer) {
692 self.sync_with(renderer);
693 }
694
695 pub fn tick(&mut self, renderer: &mut SceneRenderer, dt: f64) {
699 self.step(dt, None);
700 self.sync_with(renderer);
701 }
702
703 pub fn tick_with_scene(
708 &mut self,
709 renderer: &mut SceneRenderer,
710 dt: f64,
711 scene: &roxlap_scene::Scene,
712 ) {
713 self.step(dt, Some(scene));
714 self.sync_with(renderer);
715 }
716
717 fn sync_with<F: ParticleFacade>(&mut self, facade: &mut F) {
720 for id in self.dead_instances.drain(..) {
723 facade.despawn(id);
724 }
725
726 let mut batch = std::mem::take(&mut self.xf_scratch);
728 batch.clear();
729 let mut i = 0;
730 while i < self.particles.len() {
731 if self.particles[i].instance.is_none() {
732 let (slot, xf) = {
733 let p = &self.particles[i];
734 (p.emitter_slot as usize, particle_xf(p))
735 };
736 let (model, material, lighting, shadows) = {
737 let st = self.emitters[slot]
738 .as_ref()
739 .expect("live particle ⇒ emitter state retained");
740 (
741 st.def.model,
742 st.def.material,
743 st.def.lighting,
744 st.def.shadows,
745 )
746 };
747 let Some(id) = facade.spawn(model, xf) else {
748 self.stale_model_kills += 1;
751 self.particles.swap_remove(i);
752 self.on_particle_died(slot);
753 continue;
754 };
755 if material != 0 {
758 facade.set_material(id, material);
759 }
760 if lighting != BillboardLighting::default() {
761 facade.set_lighting(id, lighting);
762 }
763 if shadows != ShadowFlags::default() {
764 facade.set_shadows(id, shadows);
765 }
766 let p = &mut self.particles[i];
767 p.instance = Some(id);
768 p.last_alpha = 255;
772 p.last_tint = Rgb::WHITE;
773 } else {
774 let p = &self.particles[i];
777 batch.push((p.instance.expect("checked above"), particle_xf(p)));
778 }
779 let p = &mut self.particles[i];
780 if p.alpha != p.last_alpha {
781 facade.set_alpha(p.instance.expect("set above"), p.alpha);
782 p.last_alpha = p.alpha;
783 }
784 if p.tint != p.last_tint {
785 facade.set_tint(p.instance.expect("set above"), p.tint);
786 p.last_tint = p.tint;
787 }
788 i += 1;
789 }
790 if !batch.is_empty() {
791 facade.set_transforms(&batch);
792 }
793 self.xf_scratch = batch;
794 }
795
796 pub fn carve_debris(
821 &mut self,
822 scene: &mut roxlap_scene::Scene,
823 grid: roxlap_scene::GridId,
824 centre: glam::IVec3,
825 radius: u32,
826 outward: Range<f32>,
827 def: &ParticleEmitterDef,
828 ) -> u32 {
829 let Some(g) = scene.grid_mut(grid) else {
830 return 0;
831 };
832 #[allow(clippy::cast_possible_wrap)]
834 let r = radius as i32;
835 let mut samples: Vec<(glam::IVec3, roxlap_formats::VoxColor)> = Vec::new();
836 for z in -r..=r {
837 for y in -r..=r {
838 for x in -r..=r {
839 if x * x + y * y + z * z > r * r {
840 continue;
841 }
842 let v = centre + glam::IVec3::new(x, y, z);
843 if let Some(col) = g.voxel_color(v) {
844 samples.push((v, col));
845 }
846 }
847 }
848 }
849 let transform = g.transform;
850 g.set_sphere(centre, radius, None);
853 if samples.is_empty() {
854 return 0;
855 }
856
857 let mut def = def.clone();
860 def.spawn = SpawnMode::Manual;
861 let id = self.add_emitter(def.clone());
862 let slot = self.map.index(id).expect("just allocated");
863
864 let to_world = |v: glam::IVec3| -> [f32; 3] {
866 let w =
867 transform.origin + transform.rotation * (v.as_dvec3() + glam::DVec3::splat(0.5));
868 #[allow(clippy::cast_possible_truncation)]
869 [w.x as f32, w.y as f32, w.z as f32]
870 };
871 let cw = to_world(centre);
872
873 let stride = samples.len().div_ceil(self.carve_debris_cap).max(1);
874 let mut spawned: u32 = 0;
875 for (v, col) in samples.iter().step_by(stride) {
876 if self.particles.len() >= self.max_particles {
877 self.dropped_spawns += 1;
878 continue;
879 }
880 let pos = to_world(*v);
881 let d = [pos[0] - cw[0], pos[1] - cw[1], pos[2] - cw[2]];
884 let len = (d[0] * d[0] + d[1] * d[1] + d[2] * d[2]).sqrt();
885 let dir = if len > 1e-4 {
886 [d[0] / len, d[1] / len, d[2] / len]
887 } else {
888 self.rng.unit_vec()
889 };
890 let mut vel = sample_velocity(&mut self.rng, &def.velocity);
891 let speed = self.rng.range_f32(&outward);
892 for a in 0..3 {
893 vel[a] += dir[a] * speed;
894 }
895 let lifetime = self.rng.range_f32(&def.lifetime).max(1e-3);
896 self.particles.push(Particle {
897 pos,
898 vel,
899 age: 0.0,
900 lifetime,
901 scale: def.scale,
902 yaw: 0.0,
903 spin_rate: self.rng.range_f32(&def.spin),
904 alpha: fade_alpha(0.0, lifetime, def.fade_in_frac, def.fade_out_frac),
905 tint: col.rgb_part(),
906 emitter_slot: slot as u32,
907 instance: None,
908 last_alpha: 255,
909 last_tint: Rgb::WHITE,
910 tint_start: col.rgb_part(),
911 });
912 spawned += 1;
913 }
914 self.emitters[slot]
915 .as_mut()
916 .expect("slot allocated above")
917 .live += spawned;
918 self.remove_emitter(id);
919 spawned
920 }
921
922 fn spawn_from(&mut self, slot: usize, n: u32) -> u32 {
925 let state = self.emitters[slot]
926 .as_mut()
927 .expect("spawn_from callers hold a live slot");
928 let def = state.def.clone(); let mut spawned = 0;
930 for _ in 0..n {
931 if self.particles.len() >= self.max_particles {
932 self.dropped_spawns += u64::from(n - spawned);
933 break;
934 }
935 let mut pos = def.pos;
937 match def.shape {
938 EmitterShape::Point => {}
939 EmitterShape::Sphere { radius } => {
940 let dir = self.rng.unit_vec();
942 let r = radius * self.rng.next_f32().cbrt();
943 for a in 0..3 {
944 pos[a] += dir[a] * r;
945 }
946 }
947 EmitterShape::Box { half } => {
948 for a in 0..3 {
949 pos[a] += (self.rng.next_f32() * 2.0 - 1.0) * half[a];
950 }
951 }
952 }
953
954 let vel = sample_velocity(&mut self.rng, &def.velocity);
955 let lifetime = self.rng.range_f32(&def.lifetime).max(1e-3);
956 self.particles.push(Particle {
957 pos,
958 vel,
959 age: 0.0,
960 lifetime,
961 scale: def.scale,
962 yaw: 0.0,
963 spin_rate: self.rng.range_f32(&def.spin),
964 alpha: fade_alpha(0.0, lifetime, def.fade_in_frac, def.fade_out_frac),
965 tint: def.tint,
966 emitter_slot: slot as u32,
967 instance: None,
968 last_alpha: 255,
969 last_tint: Rgb::WHITE,
970 tint_start: def.tint,
971 });
972 spawned += 1;
973 }
974 self.emitters[slot]
976 .as_mut()
977 .expect("slot unchanged during spawn")
978 .live += spawned;
979 spawned
980 }
981
982 fn on_particle_died(&mut self, slot: usize) {
985 let state = self.emitters[slot]
986 .as_mut()
987 .expect("live particle ⇒ emitter state retained");
988 state.live -= 1;
989 if state.retired && state.live == 0 {
990 self.emitters[slot] = None;
991 }
992 }
993}
994
995fn particle_xf(p: &Particle) -> DynSpriteTransform {
1001 let k = p.scale.max(0.05);
1002 let (c, s) = (p.yaw.cos() * k, p.yaw.sin() * k);
1003 DynSpriteTransform {
1004 pos: p.pos,
1005 right: [c, s, 0.0],
1006 up: [-s, c, 0.0],
1007 forward: [0.0, 0.0, k],
1008 }
1009}
1010
1011fn sample_velocity(rng: &mut Pcg32, v: &VelocityDef) -> [f32; 3] {
1014 let mut vel = v.base;
1015 if v.spread > 0.0 {
1016 let dir = rng.unit_vec();
1017 let speed = rng.next_f32() * v.spread;
1018 for a in 0..3 {
1019 vel[a] += dir[a] * speed;
1020 }
1021 }
1022 if let Some(cone) = &v.cone {
1023 let dir = cone_dir(rng, cone.axis, cone.half_angle_deg);
1024 let speed = rng.range_f32(&cone.speed);
1025 for a in 0..3 {
1026 vel[a] += dir[a] * speed;
1027 }
1028 }
1029 vel
1030}
1031
1032fn cone_dir(rng: &mut Pcg32, axis: [f32; 3], half_angle_deg: f32) -> [f32; 3] {
1036 let len2 = axis[0] * axis[0] + axis[1] * axis[1] + axis[2] * axis[2];
1037 let w = if len2 > 1e-12 {
1038 let inv = 1.0 / len2.sqrt();
1039 [axis[0] * inv, axis[1] * inv, axis[2] * inv]
1040 } else {
1041 [0.0, 0.0, -1.0]
1042 };
1043 let cos_half = half_angle_deg.clamp(0.0, 180.0).to_radians().cos();
1045 let cz = 1.0 - rng.next_f32() * (1.0 - cos_half);
1046 let sz = (1.0 - cz * cz).max(0.0).sqrt();
1047 let phi = rng.next_f32() * std::f32::consts::TAU;
1048 let t = if w[0].abs() < 0.5 {
1051 [1.0, 0.0, 0.0]
1052 } else {
1053 [0.0, 1.0, 0.0]
1054 };
1055 let u = {
1056 let c = [
1057 w[1] * t[2] - w[2] * t[1],
1058 w[2] * t[0] - w[0] * t[2],
1059 w[0] * t[1] - w[1] * t[0],
1060 ];
1061 let inv = 1.0 / (c[0] * c[0] + c[1] * c[1] + c[2] * c[2]).sqrt();
1062 [c[0] * inv, c[1] * inv, c[2] * inv]
1063 };
1064 let v = [
1065 w[1] * u[2] - w[2] * u[1],
1066 w[2] * u[0] - w[0] * u[2],
1067 w[0] * u[1] - w[1] * u[0],
1068 ];
1069 let (cp, sp) = (phi.cos() * sz, phi.sin() * sz);
1070 [
1071 w[0] * cz + u[0] * cp + v[0] * sp,
1072 w[1] * cz + u[1] * cp + v[1] * sp,
1073 w[2] * cz + u[2] * cp + v[2] * sp,
1074 ]
1075}
1076
1077fn scene_solid_ahead(scene: &roxlap_scene::Scene, pos: [f32; 3], vel: [f32; 3]) -> bool {
1084 let world = glam::DVec3::new(f64::from(pos[0]), f64::from(pos[1]), f64::from(pos[2]));
1085 let dir = glam::DVec3::new(f64::from(vel[0]), f64::from(vel[1]), f64::from(vel[2]));
1086 scene.resolve_voxel(world, dir).is_some()
1087}
1088
1089fn lerp_tint(a: Rgb, b: Rgb, t: f32) -> Rgb {
1091 let t = t.clamp(0.0, 1.0);
1092 let ch = |sh: u32| {
1093 let (ca, cb) = ((a.0 >> sh) & 0xff, (b.0 >> sh) & 0xff);
1094 let m = ca as f32 + (cb as f32 - ca as f32) * t;
1095 ((m as u32) & 0xff) << sh
1096 };
1097 Rgb(ch(16) | ch(8) | ch(0))
1098}
1099
1100pub(crate) trait ParticleFacade {
1106 fn spawn(&mut self, model: SpriteModelId, xf: DynSpriteTransform) -> Option<SpriteInstanceId>;
1107 fn despawn(&mut self, id: SpriteInstanceId);
1108 fn set_transforms(&mut self, batch: &[(SpriteInstanceId, DynSpriteTransform)]);
1109 fn set_alpha(&mut self, id: SpriteInstanceId, alpha: u8);
1110 fn set_tint(&mut self, id: SpriteInstanceId, tint: Rgb);
1111 fn set_material(&mut self, id: SpriteInstanceId, material: u8);
1112 fn set_lighting(&mut self, id: SpriteInstanceId, mode: BillboardLighting);
1113 fn set_shadows(&mut self, id: SpriteInstanceId, flags: ShadowFlags);
1114}
1115
1116impl ParticleFacade for SceneRenderer {
1117 fn spawn(&mut self, model: SpriteModelId, xf: DynSpriteTransform) -> Option<SpriteInstanceId> {
1118 self.add_sprite_instance_posed(model, xf)
1119 }
1120 fn despawn(&mut self, id: SpriteInstanceId) {
1121 self.remove_sprite_instance(id);
1124 }
1125 fn set_transforms(&mut self, batch: &[(SpriteInstanceId, DynSpriteTransform)]) {
1126 self.set_sprite_instance_transforms(batch);
1127 }
1128 fn set_alpha(&mut self, id: SpriteInstanceId, alpha: u8) {
1129 self.set_sprite_instance_alpha(id, alpha);
1130 }
1131 fn set_tint(&mut self, id: SpriteInstanceId, tint: Rgb) {
1132 self.set_sprite_instance_tint(id, tint);
1133 }
1134 fn set_material(&mut self, id: SpriteInstanceId, material: u8) {
1135 self.set_sprite_instance_material(id, material);
1136 }
1137 fn set_lighting(&mut self, id: SpriteInstanceId, mode: BillboardLighting) {
1138 self.set_sprite_instance_lighting(id, mode);
1139 }
1140 fn set_shadows(&mut self, id: SpriteInstanceId, flags: ShadowFlags) {
1141 self.set_sprite_instance_shadow_flags(id, flags);
1142 }
1143}
1144
1145fn fade_alpha(age: f32, lifetime: f32, in_frac: f32, out_frac: f32) -> u8 {
1149 let frac = age / lifetime;
1150 let mut a = 1.0_f32;
1151 if in_frac > 0.0 {
1152 a = a.min((frac / in_frac.min(1.0)).clamp(0.0, 1.0));
1153 }
1154 if out_frac > 0.0 {
1155 a = a.min(((1.0 - frac) / out_frac.min(1.0)).clamp(0.0, 1.0));
1156 }
1157 (a * 255.0) as u8
1158}
1159
1160#[cfg(test)]
1161mod tests {
1162 use super::*;
1163
1164 fn dummy_model() -> SpriteModelId {
1167 SpriteModelId::mint(0, 0)
1168 }
1169
1170 fn base_def() -> ParticleEmitterDef {
1171 ParticleEmitterDef {
1172 spawn: SpawnMode::Manual,
1173 lifetime: 1.0..1.0,
1174 gravity: [0.0, 0.0, 0.0],
1175 fade_out_frac: 0.0,
1176 ..ParticleEmitterDef::new(dummy_model())
1177 }
1178 }
1179
1180 #[test]
1181 fn same_seed_is_bit_identical() {
1182 let run = || {
1183 let mut sys = ParticleSystem::new(0x00C0_FFEE);
1184 let em = sys.add_emitter(ParticleEmitterDef {
1185 spawn: SpawnMode::Rate(120.0),
1186 lifetime: 0.3..0.9,
1187 velocity: VelocityDef {
1188 base: [0.0, 0.0, -10.0],
1189 spread: 4.0,
1190 ..VelocityDef::default()
1191 },
1192 gravity: [0.0, 0.0, 22.0],
1193 ..ParticleEmitterDef::new(dummy_model())
1194 });
1195 sys.burst(em, 7);
1196 for _ in 0..60 {
1197 sys.update(1.0 / 60.0);
1198 }
1199 sys.particles()
1200 .iter()
1201 .map(|p| (p.pos, p.vel, p.age, p.lifetime))
1202 .collect::<Vec<_>>()
1203 };
1204 let (a, b) = (run(), run());
1205 assert_eq!(a.len(), b.len());
1206 for (pa, pb) in a.iter().zip(&b) {
1207 assert_eq!(pa, pb, "same seed must be bit-identical");
1208 }
1209 assert!(!a.is_empty());
1210 }
1211
1212 #[test]
1213 fn rate_accumulates_across_frames() {
1214 let mut sys = ParticleSystem::new(1);
1215 sys.add_emitter(ParticleEmitterDef {
1216 spawn: SpawnMode::Rate(10.0),
1217 lifetime: 100.0..100.0,
1218 ..base_def()
1219 });
1220 for _ in 0..10 {
1221 sys.update(0.1);
1222 }
1223 assert_eq!(sys.particle_count(), 10);
1224
1225 let mut slow = ParticleSystem::new(2);
1227 slow.add_emitter(ParticleEmitterDef {
1228 spawn: SpawnMode::Rate(0.5),
1229 lifetime: 100.0..100.0,
1230 ..base_def()
1231 });
1232 for _ in 0..20 {
1233 slow.update(0.1);
1234 }
1235 assert_eq!(slow.particle_count(), 1);
1236 }
1237
1238 #[test]
1239 fn burst_mode_fires_on_add() {
1240 let mut sys = ParticleSystem::new(3);
1241 sys.add_emitter(ParticleEmitterDef {
1242 spawn: SpawnMode::Burst(5),
1243 ..base_def()
1244 });
1245 assert_eq!(sys.particle_count(), 5);
1246 }
1247
1248 #[test]
1249 fn budget_drops_spawns_and_counts_them() {
1250 let mut sys = ParticleSystem::new(4);
1251 sys.set_max_particles(5);
1252 let em = sys.add_emitter(base_def());
1253 assert_eq!(sys.burst(em, 10), 5);
1254 assert_eq!(sys.particle_count(), 5);
1255 assert_eq!(sys.dropped_spawns(), 5);
1256 }
1257
1258 #[test]
1259 fn particles_die_at_lifetime() {
1260 let mut sys = ParticleSystem::new(5);
1261 let em = sys.add_emitter(ParticleEmitterDef {
1262 lifetime: 0.5..0.5,
1263 ..base_def()
1264 });
1265 sys.burst(em, 3);
1266 sys.update(0.3);
1267 assert_eq!(sys.particle_count(), 3);
1268 sys.update(0.3); assert_eq!(sys.particle_count(), 0);
1270 assert_eq!(sys.drain_dead_instances().count(), 0);
1272 }
1273
1274 #[test]
1275 fn semi_implicit_euler_gravity() {
1276 let mut sys = ParticleSystem::new(6);
1277 let em = sys.add_emitter(ParticleEmitterDef {
1278 gravity: [0.0, 0.0, 10.0],
1279 lifetime: 100.0..100.0,
1280 ..base_def()
1281 });
1282 sys.burst(em, 1);
1283 sys.update(0.1);
1284 let p = sys.particles()[0];
1285 assert!((p.vel[2] - 1.0).abs() < 1e-6);
1287 assert!((p.pos[2] - 0.1).abs() < 1e-6);
1288 }
1289
1290 #[test]
1291 fn fade_curve_hits_endpoints() {
1292 assert_eq!(fade_alpha(0.0, 1.0, 0.0, 0.5), 255);
1293 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);
1296 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);
1300 assert_eq!(fade_alpha(0.125, 1.0, 0.25, 0.0), 127);
1301 assert_eq!(fade_alpha(0.25, 1.0, 0.25, 0.0), 255);
1302 assert_eq!(fade_alpha(0.5, 1.0, 1.0, 1.0), 127); }
1304
1305 #[test]
1306 fn shapes_sample_within_bounds() {
1307 let mut sys = ParticleSystem::new(20);
1308 let sphere = sys.add_emitter(ParticleEmitterDef {
1309 pos: [10.0, 0.0, 0.0],
1310 shape: EmitterShape::Sphere { radius: 3.0 },
1311 ..base_def()
1312 });
1313 let boxy = sys.add_emitter(ParticleEmitterDef {
1314 pos: [-10.0, 0.0, 0.0],
1315 shape: EmitterShape::Box {
1316 half: [1.0, 2.0, 0.5],
1317 },
1318 ..base_def()
1319 });
1320 sys.burst(sphere, 64);
1321 sys.burst(boxy, 64);
1322 let mut spread = false;
1323 for p in sys.particles() {
1324 if p.pos[0] > 0.0 {
1325 let d = [p.pos[0] - 10.0, p.pos[1], p.pos[2]];
1326 let r = (d[0] * d[0] + d[1] * d[1] + d[2] * d[2]).sqrt();
1327 assert!(r <= 3.0 + 1e-4, "sphere sample outside radius: {r}");
1328 spread |= r > 0.1;
1329 } else {
1330 let d = [p.pos[0] + 10.0, p.pos[1], p.pos[2]];
1331 assert!(
1332 d[0].abs() <= 1.0 + 1e-4
1333 && d[1].abs() <= 2.0 + 1e-4
1334 && d[2].abs() <= 0.5 + 1e-4,
1335 "box sample outside half-extents: {d:?}"
1336 );
1337 spread |= d[1].abs() > 1.0; }
1339 }
1340 assert!(spread, "samples must actually spread out");
1341 }
1342
1343 #[test]
1344 fn cone_stays_within_half_angle() {
1345 let mut sys = ParticleSystem::new(21);
1346 let axis = [0.0, 1.0, -1.0]; let em = sys.add_emitter(ParticleEmitterDef {
1348 velocity: VelocityDef {
1349 cone: Some(ConeDef {
1350 axis,
1351 half_angle_deg: 30.0,
1352 speed: 5.0..10.0,
1353 }),
1354 ..VelocityDef::default()
1355 },
1356 ..base_def()
1357 });
1358 sys.burst(em, 64);
1359 let inv = 1.0 / (2.0_f32).sqrt();
1360 let w = [0.0, inv, -inv];
1361 let cos_half = 30.0_f32.to_radians().cos();
1362 for p in sys.particles() {
1363 let sp = (p.vel[0] * p.vel[0] + p.vel[1] * p.vel[1] + p.vel[2] * p.vel[2]).sqrt();
1364 assert!(
1365 (5.0 - 1e-3..10.0 + 1e-3).contains(&sp),
1366 "speed in range: {sp}"
1367 );
1368 let cosang = (p.vel[0] * w[0] + p.vel[1] * w[1] + p.vel[2] * w[2]) / sp;
1369 assert!(
1370 cosang >= cos_half - 1e-4,
1371 "direction within the cone: cos {cosang} < {cos_half}"
1372 );
1373 }
1374 assert_eq!(
1376 cone_dir(&mut Pcg32::new(1), [0.0; 3], 0.0),
1377 [0.0, 0.0, -1.0]
1378 );
1379 }
1380
1381 #[test]
1382 fn spin_rotates_pose_and_keeps_scale() {
1383 let mut sys = ParticleSystem::new(22);
1384 let em = sys.add_emitter(ParticleEmitterDef {
1385 spin: 2.0..2.0,
1386 scale: 3.0,
1387 lifetime: 100.0..100.0,
1388 ..base_def()
1389 });
1390 sys.burst(em, 1);
1391 sys.update(0.25); let p = sys.particles()[0];
1393 assert!((p.yaw - 0.5).abs() < 1e-6);
1394 let xf = particle_xf(&p);
1395 let len = |v: [f32; 3]| (v[0] * v[0] + v[1] * v[1] + v[2] * v[2]).sqrt();
1397 assert!((len(xf.right) - 3.0).abs() < 1e-5);
1398 assert!((len(xf.up) - 3.0).abs() < 1e-5);
1399 assert!(xf.right[1].abs() > 0.1, "yaw actually rotates the basis");
1400 let dot = xf.right[0] * xf.up[0] + xf.right[1] * xf.up[1];
1401 assert!(dot.abs() < 1e-5);
1402 }
1403
1404 #[test]
1405 fn scale_and_tint_lerp_over_life() {
1406 let mut sys = ParticleSystem::new(23);
1407 let em = sys.add_emitter(ParticleEmitterDef {
1408 lifetime: 1.0..1.0,
1409 scale: 1.0,
1410 scale_end: Some(3.0),
1411 tint: Rgb(0x00FF_0000),
1412 tint_end: Some(Rgb(0x0000_00FF)),
1413 ..base_def()
1414 });
1415 sys.burst(em, 1);
1416 sys.update(0.5);
1417 let p = sys.particles()[0];
1418 assert!((p.scale - 2.0).abs() < 1e-5, "mid-life scale: {}", p.scale);
1419 let (r, b) = ((p.tint.0 >> 16) & 0xff, p.tint.0 & 0xff);
1420 assert!(
1421 (126..=128).contains(&r) && (126..=128).contains(&b),
1422 "mid tint: {:#08x}",
1423 p.tint.0
1424 );
1425
1426 let mut f = Mock::default();
1428 sys.sync_with(&mut f);
1429 assert_eq!(f.tints.len(), 1);
1430 sys.update(0.1);
1431 sys.sync_with(&mut f);
1432 assert_eq!(f.tints.len(), 2, "changed tint re-syncs");
1433 }
1434
1435 #[test]
1436 fn retired_emitter_drains_then_frees() {
1437 let mut sys = ParticleSystem::new(7);
1438 let em = sys.add_emitter(ParticleEmitterDef {
1439 spawn: SpawnMode::Rate(1000.0),
1440 lifetime: 0.2..0.2,
1441 ..base_def()
1442 });
1443 sys.update(0.01);
1444 assert!(sys.particle_count() > 0);
1445 assert!(sys.remove_emitter(em));
1446 assert_eq!(sys.emitter_count(), 0);
1447 assert!(!sys.remove_emitter(em));
1449 assert!(!sys.set_emitter_pos(em, [1.0, 2.0, 3.0]));
1450 assert_eq!(sys.burst(em, 10), 0);
1451 let live = sys.particle_count();
1454 sys.update(0.3);
1455 assert_eq!(sys.particle_count(), 0);
1456 assert!(live > 0);
1457 assert!(sys.emitters.iter().all(Option::is_none));
1458 }
1459
1460 #[derive(Default)]
1462 struct Mock {
1463 next_slot: u32,
1464 fail_spawn: bool,
1465 spawns: Vec<(SpriteModelId, DynSpriteTransform)>,
1466 despawns: Vec<SpriteInstanceId>,
1467 batch_sizes: Vec<usize>,
1468 alphas: Vec<(SpriteInstanceId, u8)>,
1469 tints: Vec<Rgb>,
1470 materials: Vec<u8>,
1471 lightings: Vec<BillboardLighting>,
1472 shadows: Vec<ShadowFlags>,
1473 }
1474
1475 impl ParticleFacade for Mock {
1476 fn spawn(
1477 &mut self,
1478 model: SpriteModelId,
1479 xf: DynSpriteTransform,
1480 ) -> Option<SpriteInstanceId> {
1481 if self.fail_spawn {
1482 return None;
1483 }
1484 self.spawns.push((model, xf));
1485 let id = SpriteInstanceId {
1486 slot: self.next_slot,
1487 gen: 0,
1488 };
1489 self.next_slot += 1;
1490 Some(id)
1491 }
1492 fn despawn(&mut self, id: SpriteInstanceId) {
1493 self.despawns.push(id);
1494 }
1495 fn set_transforms(&mut self, batch: &[(SpriteInstanceId, DynSpriteTransform)]) {
1496 self.batch_sizes.push(batch.len());
1497 }
1498 fn set_alpha(&mut self, id: SpriteInstanceId, alpha: u8) {
1499 self.alphas.push((id, alpha));
1500 }
1501 fn set_tint(&mut self, _id: SpriteInstanceId, tint: Rgb) {
1502 self.tints.push(tint);
1503 }
1504 fn set_material(&mut self, _id: SpriteInstanceId, material: u8) {
1505 self.materials.push(material);
1506 }
1507 fn set_lighting(&mut self, _id: SpriteInstanceId, mode: BillboardLighting) {
1508 self.lightings.push(mode);
1509 }
1510 fn set_shadows(&mut self, _id: SpriteInstanceId, flags: ShadowFlags) {
1511 self.shadows.push(flags);
1512 }
1513 }
1514
1515 #[test]
1516 fn sync_spawns_once_then_batch_moves() {
1517 let mut sys = ParticleSystem::new(10);
1518 let em = sys.add_emitter(ParticleEmitterDef {
1519 lifetime: 100.0..100.0,
1520 ..base_def()
1521 });
1522 sys.burst(em, 3);
1523 let mut f = Mock::default();
1524
1525 sys.sync_with(&mut f);
1526 assert_eq!(f.spawns.len(), 3);
1528 assert!(f.batch_sizes.is_empty());
1529 assert_eq!(f.shadows.len(), 3);
1532 assert!(f.materials.is_empty() && f.tints.is_empty() && f.lightings.is_empty());
1533 assert!(f.alphas.is_empty(), "alpha 255 == facade default");
1534
1535 sys.update(0.01);
1537 sys.sync_with(&mut f);
1538 assert_eq!(f.spawns.len(), 3);
1539 assert_eq!(f.batch_sizes, vec![3]);
1540 }
1541
1542 #[test]
1543 fn sync_one_time_setup_honours_def() {
1544 let mut sys = ParticleSystem::new(11);
1545 let em = sys.add_emitter(ParticleEmitterDef {
1546 lifetime: 100.0..100.0,
1547 tint: Rgb(0x00FF_0000),
1548 material: 5,
1549 lighting: BillboardLighting::FullBright,
1550 shadows: ShadowFlags::default(), ..base_def()
1552 });
1553 sys.burst(em, 1);
1554 let mut f = Mock::default();
1555 sys.sync_with(&mut f);
1556 assert_eq!(f.materials, vec![5]);
1557 assert_eq!(f.tints, vec![Rgb(0x00FF_0000)]);
1558 assert_eq!(f.lightings, vec![BillboardLighting::FullBright]);
1559 assert!(f.shadows.is_empty(), "facade-default shadows skip the call");
1560 sys.update(0.01);
1562 sys.sync_with(&mut f);
1563 assert_eq!(f.materials.len() + f.tints.len() + f.lightings.len(), 3);
1564 }
1565
1566 #[test]
1567 fn sync_despawns_dead_and_writes_alpha_on_change_only() {
1568 let mut sys = ParticleSystem::new(12);
1569 let em = sys.add_emitter(ParticleEmitterDef {
1570 lifetime: 1.0..1.0,
1571 fade_out_frac: 0.5,
1572 ..base_def()
1573 });
1574 sys.burst(em, 2);
1575 let mut f = Mock::default();
1576 sys.sync_with(&mut f);
1577
1578 sys.update(0.25);
1580 sys.sync_with(&mut f);
1581 assert!(f.alphas.is_empty());
1582
1583 sys.update(0.5); sys.sync_with(&mut f);
1586 assert_eq!(f.alphas.len(), 2);
1587 assert!(f.alphas.iter().all(|&(_, a)| a == 127));
1588
1589 sys.update(0.5);
1591 assert_eq!(sys.particle_count(), 0);
1592 sys.sync_with(&mut f);
1593 assert_eq!(f.despawns.len(), 2);
1594 }
1595
1596 #[test]
1597 fn stale_model_spawn_kills_particle() {
1598 let mut sys = ParticleSystem::new(13);
1599 let em = sys.add_emitter(base_def());
1600 sys.burst(em, 2);
1601 let mut f = Mock {
1602 fail_spawn: true,
1603 ..Mock::default()
1604 };
1605 sys.sync_with(&mut f);
1606 assert_eq!(sys.particle_count(), 0);
1607 assert_eq!(sys.stale_model_kills(), 2);
1608 assert!(sys.remove_emitter(em));
1611 assert!(sys.emitters.iter().all(Option::is_none));
1612 }
1613
1614 #[test]
1615 fn particle_xf_scales_and_clamps() {
1616 let p = Particle {
1617 pos: [1.0, 2.0, 3.0],
1618 vel: [0.0; 3],
1619 age: 0.0,
1620 lifetime: 1.0,
1621 scale: 2.0,
1622 yaw: 0.0,
1623 spin_rate: 0.0,
1624 alpha: 255,
1625 tint: Rgb(0),
1626 emitter_slot: 0,
1627 instance: None,
1628 last_alpha: 255,
1629 last_tint: Rgb::WHITE,
1630 tint_start: Rgb(0),
1631 };
1632 let xf = particle_xf(&p);
1633 assert_eq!(xf.pos, [1.0, 2.0, 3.0]);
1634 assert_eq!((xf.right[0], xf.up[1], xf.forward[2]), (2.0, 2.0, 2.0));
1635 let tiny = particle_xf(&Particle { scale: 0.0, ..p });
1637 assert_eq!(tiny.right[0], 0.05);
1638 }
1639
1640 fn scene_with_floor() -> roxlap_scene::Scene {
1642 use glam::{DVec3, IVec3};
1643 let mut scene = roxlap_scene::Scene::new();
1644 let id = scene.add_grid(roxlap_scene::GridTransform::at(DVec3::ZERO));
1645 let g = scene.grid_mut(id).expect("fresh grid");
1646 g.set_rect(
1647 IVec3::new(-16, -16, 10),
1648 IVec3::new(16, 16, 12),
1649 Some(roxlap_formats::VoxColor(0x80FF_FFFF)),
1650 );
1651 scene
1652 }
1653
1654 fn falling(collision: CollisionMode) -> ParticleEmitterDef {
1656 ParticleEmitterDef {
1657 pos: [0.0, 0.0, 5.0],
1658 velocity: VelocityDef {
1659 base: [0.0, 0.0, 20.0],
1660 ..VelocityDef::default()
1661 },
1662 lifetime: 100.0..100.0,
1663 collision,
1664 ..base_def() }
1666 }
1667
1668 #[test]
1669 fn collision_kill_dies_on_contact() {
1670 let scene = scene_with_floor();
1671 let mut sys = ParticleSystem::new(30);
1672 let em = sys.add_emitter(falling(CollisionMode::Kill));
1673 sys.burst(em, 1);
1674 for _ in 0..20 {
1675 sys.update_with_scene(0.05, &scene);
1676 }
1677 assert_eq!(sys.particle_count(), 0, "dies at the slab");
1678
1679 let mut free = ParticleSystem::new(30);
1681 let em = free.add_emitter(falling(CollisionMode::Kill));
1682 free.burst(em, 1);
1683 for _ in 0..20 {
1684 free.update(0.05);
1685 }
1686 assert_eq!(free.particle_count(), 1);
1687 assert!(free.particles()[0].pos[2] > 12.0, "fell straight through");
1688 }
1689
1690 #[test]
1691 fn collision_none_passes_through() {
1692 let scene = scene_with_floor();
1693 let mut sys = ParticleSystem::new(31);
1694 let em = sys.add_emitter(falling(CollisionMode::None));
1695 sys.burst(em, 1);
1696 for _ in 0..20 {
1697 sys.update_with_scene(0.05, &scene);
1698 }
1699 assert!(sys.particles()[0].pos[2] > 12.0);
1700 }
1701
1702 #[test]
1703 fn collision_bounce_reflects_and_damps() {
1704 let scene = scene_with_floor();
1705 let mut sys = ParticleSystem::new(32);
1706 let em = sys.add_emitter(falling(CollisionMode::Bounce { restitution: 0.5 }));
1707 sys.burst(em, 1);
1708 let mut bounced = false;
1709 for _ in 0..40 {
1710 sys.update_with_scene(0.05, &scene);
1711 let p = sys.particles()[0];
1712 assert!(p.pos[2] < 10.5, "never sinks into the slab: {}", p.pos[2]);
1713 if p.vel[2] < 0.0 {
1714 bounced = true;
1715 }
1716 }
1717 assert!(bounced, "velocity reflected upward");
1718 let p = sys.particles()[0];
1719 assert!(
1720 p.vel[2].abs() <= 10.0 + 1e-3,
1721 "restitution halves the speed: {}",
1722 p.vel[2]
1723 );
1724 }
1725
1726 #[test]
1727 fn carve_debris_samples_colours_and_carves() {
1728 use glam::{DVec3, IVec3};
1729 let mut scene = roxlap_scene::Scene::new();
1731 let grid = scene.add_grid(roxlap_scene::GridTransform::at(DVec3::ZERO));
1732 scene.grid_mut(grid).expect("grid").set_rect(
1733 IVec3::new(-8, -8, 10),
1734 IVec3::new(8, 8, 12),
1735 Some(roxlap_formats::VoxColor(0x80_12_34_56)),
1736 );
1737 let mut sys = ParticleSystem::new(40);
1738 let centre = IVec3::new(0, 0, 11);
1739 let spawned = sys.carve_debris(
1740 &mut scene,
1741 grid,
1742 centre,
1743 2,
1744 4.0..6.0,
1745 &ParticleEmitterDef {
1746 lifetime: 100.0..100.0,
1747 ..base_def()
1748 },
1749 );
1750 assert!(spawned > 0);
1751 assert_eq!(sys.particle_count() as u32, spawned);
1752 for p in sys.particles() {
1754 assert_eq!(p.tint, Rgb(0x0012_3456), "tint is the voxel colour");
1755 }
1756 let g = scene.grid_mut(grid).expect("grid");
1758 assert!(g.voxel_color(centre).is_none());
1759 for p in sys.particles() {
1761 let d = [p.pos[0] - 0.5, p.pos[1] - 0.5, p.pos[2] - 11.5];
1762 let r2 = d[0] * d[0] + d[1] * d[1] + d[2] * d[2];
1763 if r2 > 0.5 {
1764 let dot = d[0] * p.vel[0] + d[1] * p.vel[1] + d[2] * p.vel[2];
1765 assert!(dot > 0.0, "debris flies outward: {d:?} vs {:?}", p.vel);
1766 }
1767 }
1768 assert_eq!(sys.emitter_count(), 0);
1770 sys.update(200.0);
1771 assert_eq!(sys.particle_count(), 0);
1772 assert!(sys.emitters.iter().all(Option::is_none));
1773 }
1774
1775 #[test]
1776 fn carve_debris_caps_big_carves_and_respects_budget() {
1777 use glam::IVec3;
1778 let mut scene = scene_with_floor();
1779 let grid = scene.grids().next().expect("one grid").0;
1780 let mut sys = ParticleSystem::new(41);
1781 let spawned = sys.carve_debris(
1783 &mut scene,
1784 grid,
1785 IVec3::new(0, 0, 11),
1786 6,
1787 1.0..2.0,
1788 &ParticleEmitterDef {
1789 lifetime: 100.0..100.0,
1790 ..base_def()
1791 },
1792 );
1793 assert!(spawned as usize <= CARVE_DEBRIS_CAP);
1794 assert!(
1795 spawned as usize > CARVE_DEBRIS_CAP / 2,
1796 "stride fills near the cap"
1797 );
1798
1799 let mut low = ParticleSystem::new(44);
1801 low.set_carve_debris_cap(16);
1802 let mut scene3 = scene_with_floor();
1803 let spawned = low.carve_debris(
1804 &mut scene3,
1805 grid,
1806 IVec3::new(0, 0, 11),
1807 6,
1808 1.0..2.0,
1809 &ParticleEmitterDef {
1810 lifetime: 100.0..100.0,
1811 ..base_def()
1812 },
1813 );
1814 assert!(spawned <= 16, "tuned cap respected: {spawned}");
1815 assert!(spawned >= 8, "stride still fills near the tuned cap");
1816
1817 let mut tiny = ParticleSystem::new(42);
1819 tiny.set_max_particles(5);
1820 let mut scene2 = scene_with_floor();
1821 let spawned = tiny.carve_debris(
1822 &mut scene2,
1823 grid,
1824 IVec3::new(10, 10, 11),
1825 4,
1826 1.0..2.0,
1827 &ParticleEmitterDef {
1828 lifetime: 100.0..100.0,
1829 ..base_def()
1830 },
1831 );
1832 assert_eq!(spawned, 5);
1833 assert!(tiny.dropped_spawns() > 0);
1834 }
1835
1836 #[test]
1837 fn carve_debris_tint_end_lerps_from_voxel_colour() {
1838 use glam::IVec3;
1839 let mut scene = scene_with_floor();
1840 let grid = scene.grids().next().expect("one grid").0;
1841 let mut sys = ParticleSystem::new(43);
1842 sys.carve_debris(
1843 &mut scene,
1844 grid,
1845 IVec3::new(0, 0, 11),
1846 1,
1847 0.0..0.0,
1848 &ParticleEmitterDef {
1849 lifetime: 1.0..1.0,
1850 tint_end: Some(Rgb(0x0000_0000)),
1851 ..base_def()
1852 },
1853 );
1854 assert!(sys.particle_count() > 0);
1855 sys.update(0.5);
1856 for p in sys.particles() {
1858 let r = (p.tint.0 >> 16) & 0xff;
1859 assert!(
1860 (120..=135).contains(&r),
1861 "lerp starts at the voxel colour, not def.tint: {:#08x}",
1862 p.tint.0
1863 );
1864 }
1865 }
1866
1867 #[test]
1868 fn stress_10k_particles_simulate_and_sync() {
1869 let mut sys = ParticleSystem::new(50);
1870 sys.set_max_particles(10_000);
1871 let em = sys.add_emitter(ParticleEmitterDef {
1872 lifetime: 2.0..2.0,
1876 velocity: VelocityDef {
1877 spread: 10.0,
1878 ..VelocityDef::default()
1879 },
1880 fade_in_frac: 0.5,
1882 fade_out_frac: 0.5,
1883 spin: -3.0..3.0,
1884 scale_end: Some(2.0),
1885 tint_end: Some(Rgb(0x0000_0000)),
1886 gravity: [0.0, 0.0, 5.0],
1887 ..base_def()
1888 });
1889 assert_eq!(sys.burst(em, 10_000), 10_000);
1890 let mut f = Mock::default();
1891 for _ in 0..30 {
1892 sys.update(1.0 / 60.0);
1893 sys.sync_with(&mut f);
1894 }
1895 assert_eq!(sys.particle_count(), 10_000);
1896 assert_eq!(f.spawns.len(), 10_000);
1897 assert_eq!(*f.batch_sizes.last().expect("batches ran"), 10_000);
1900 assert!(f.alphas.len() > 100_000, "alpha churns every frame");
1901 }
1902
1903 #[test]
1906 #[ignore = "manual perf probe — prints timings, asserts nothing"]
1907 fn stress_10k_probe() {
1908 let mut sys = ParticleSystem::new(51);
1909 sys.set_max_particles(10_000);
1910 let em = sys.add_emitter(ParticleEmitterDef {
1911 lifetime: 8.0..8.0,
1914 velocity: VelocityDef {
1915 spread: 10.0,
1916 ..VelocityDef::default()
1917 },
1918 fade_in_frac: 0.5,
1919 fade_out_frac: 0.5,
1920 spin: -3.0..3.0,
1921 scale_end: Some(2.0),
1922 tint_end: Some(Rgb(0x0000_0000)),
1923 ..base_def()
1924 });
1925 sys.burst(em, 10_000);
1926 let mut f = Mock::default();
1927 sys.sync_with(&mut f); let t0 = std::time::Instant::now();
1929 const FRAMES: u32 = 200;
1930 for _ in 0..FRAMES {
1931 sys.update(1.0 / 60.0);
1932 sys.sync_with(&mut f);
1933 }
1934 let per_frame = t0.elapsed() / FRAMES;
1935 eprintln!("10k particles: {per_frame:?}/frame (update + sync w/ alpha+tint churn)");
1936 }
1937
1938 #[test]
1939 fn moved_emitter_spawns_at_new_pos() {
1940 let mut sys = ParticleSystem::new(8);
1941 let em = sys.add_emitter(base_def());
1942 assert!(sys.set_emitter_pos(em, [5.0, 6.0, 7.0]));
1943 sys.burst(em, 1);
1944 assert_eq!(sys.particles()[0].pos, [5.0, 6.0, 7.0]);
1945 }
1946}