1use std::ops::Range;
25
26use crate::{
27 BillboardLighting, DynSpriteTransform, EpochSlotMap, SceneRenderer, ShadowFlags, SlotHandle,
28 SpriteInstanceId, SpriteModelId,
29};
30
31#[derive(Clone, Copy, PartialEq, Eq, Hash, Debug)]
37pub struct EmitterId {
38 slot: u32,
39 gen: u32,
40}
41
42impl SlotHandle for EmitterId {
45 fn mint(slot: u32, gen: u32) -> Self {
46 Self { slot, gen }
47 }
48 fn parts(self) -> (u32, u32) {
49 (self.slot, self.gen)
50 }
51}
52
53#[derive(Clone, Copy, Debug)]
55pub enum SpawnMode {
56 Rate(f32),
60 Burst(u32),
64 Manual,
67}
68
69#[derive(Clone, Copy, Debug, Default)]
71pub enum EmitterShape {
72 #[default]
74 Point,
75 Sphere {
77 radius: f32,
79 },
80 Box {
82 half: [f32; 3],
84 },
85}
86
87#[derive(Clone, Debug)]
91pub struct ConeDef {
92 pub axis: [f32; 3],
95 pub half_angle_deg: f32,
101 pub speed: Range<f32>,
103}
104
105#[derive(Clone, Debug, Default)]
108pub struct VelocityDef {
109 pub base: [f32; 3],
112 pub spread: f32,
116 pub cone: Option<ConeDef>,
119}
120
121#[derive(Clone, Copy, Debug, Default, PartialEq)]
133pub enum CollisionMode {
134 #[default]
136 None,
137 Kill,
139 Bounce {
145 restitution: f32,
147 },
148}
149
150#[derive(Clone, Debug)]
155pub struct ParticleEmitterDef {
156 pub model: SpriteModelId,
160 pub pos: [f32; 3],
163 pub shape: EmitterShape,
166 pub spawn: SpawnMode,
168 pub lifetime: Range<f32>,
171 pub velocity: VelocityDef,
173 pub gravity: [f32; 3],
176 pub drag: f32,
179 pub collision: CollisionMode,
182 pub scale: f32,
186 pub scale_end: Option<f32>,
190 pub spin: Range<f32>,
195 pub fade_in_frac: f32,
199 pub fade_out_frac: f32,
203 pub tint: u32,
205 pub tint_end: Option<u32>,
209 pub material: u8,
212 pub lighting: BillboardLighting,
215 pub shadows: ShadowFlags,
219}
220
221impl ParticleEmitterDef {
222 #[must_use]
228 pub fn new(model: SpriteModelId) -> Self {
229 Self {
230 model,
231 pos: [0.0, 0.0, 0.0],
232 shape: EmitterShape::Point,
233 spawn: SpawnMode::Manual,
234 lifetime: 1.0..2.0,
235 velocity: VelocityDef::default(),
236 gravity: [0.0, 0.0, 22.0],
237 drag: 0.0,
238 collision: CollisionMode::None,
239 scale: 1.0,
240 scale_end: None,
241 spin: 0.0..0.0,
242 fade_in_frac: 0.0,
243 fade_out_frac: 0.25,
244 tint: 0x00FF_FFFF,
245 tint_end: None,
246 material: 0,
247 lighting: BillboardLighting::FaceNormal,
248 shadows: ShadowFlags {
249 casts: false,
250 receives: false,
251 },
252 }
253 }
254}
255
256#[derive(Clone, Copy, Debug)]
259pub struct Particle {
260 pub pos: [f32; 3],
262 pub vel: [f32; 3],
264 pub age: f32,
266 pub lifetime: f32,
268 pub scale: f32,
271 pub yaw: f32,
273 pub spin_rate: f32,
275 pub alpha: u8,
277 pub tint: u32,
280 pub(crate) emitter_slot: u32,
283 pub(crate) instance: Option<SpriteInstanceId>,
286 pub(crate) last_alpha: u8,
290 pub(crate) last_tint: u32,
293 pub(crate) tint_start: u32,
297}
298
299struct EmitterState {
301 def: ParticleEmitterDef,
302 spawn_acc: f64,
304 live: u32,
308 retired: bool,
311}
312
313struct Pcg32 {
318 state: u64,
319}
320
321impl Pcg32 {
322 const MULT: u64 = 6_364_136_223_846_793_005;
323 const INC: u64 = 1_442_695_040_888_963_407;
324
325 fn new(seed: u64) -> Self {
326 let mut rng = Self {
327 state: seed.wrapping_add(Self::INC),
328 };
329 rng.next_u32();
330 rng
331 }
332
333 fn next_u32(&mut self) -> u32 {
334 let old = self.state;
335 self.state = old.wrapping_mul(Self::MULT).wrapping_add(Self::INC);
336 let xorshifted = (((old >> 18) ^ old) >> 27) as u32;
337 xorshifted.rotate_right((old >> 59) as u32)
338 }
339
340 fn next_f32(&mut self) -> f32 {
342 (self.next_u32() >> 8) as f32 * (1.0 / (1u32 << 24) as f32)
343 }
344
345 fn range_f32(&mut self, r: &Range<f32>) -> f32 {
347 if r.end <= r.start {
348 return r.start;
349 }
350 r.start + (r.end - r.start) * self.next_f32()
351 }
352
353 fn unit_vec(&mut self) -> [f32; 3] {
356 loop {
357 let v = [
358 self.next_f32() * 2.0 - 1.0,
359 self.next_f32() * 2.0 - 1.0,
360 self.next_f32() * 2.0 - 1.0,
361 ];
362 let len2 = v[0] * v[0] + v[1] * v[1] + v[2] * v[2];
363 if len2 > 1e-4 && len2 <= 1.0 {
364 let inv = 1.0 / len2.sqrt();
365 return [v[0] * inv, v[1] * inv, v[2] * inv];
366 }
367 }
368 }
369}
370
371pub const DEFAULT_MAX_PARTICLES: usize = 4096;
373
374pub const CARVE_DEBRIS_CAP: usize = 96;
378
379pub struct ParticleSystem {
390 rng: Pcg32,
391 map: EpochSlotMap<EmitterId>,
392 emitters: Vec<Option<EmitterState>>,
394 particles: Vec<Particle>,
395 dead_instances: Vec<SpriteInstanceId>,
398 max_particles: usize,
399 carve_debris_cap: usize,
402 dropped_spawns: u64,
403 stale_model_kills: u64,
404 xf_scratch: Vec<(SpriteInstanceId, DynSpriteTransform)>,
407}
408
409impl ParticleSystem {
410 #[must_use]
414 pub fn new(seed: u64) -> Self {
415 Self {
416 rng: Pcg32::new(seed),
417 map: EpochSlotMap::default(),
418 emitters: Vec::new(),
419 particles: Vec::new(),
420 dead_instances: Vec::new(),
421 max_particles: DEFAULT_MAX_PARTICLES,
422 carve_debris_cap: CARVE_DEBRIS_CAP,
423 dropped_spawns: 0,
424 stale_model_kills: 0,
425 xf_scratch: Vec::new(),
426 }
427 }
428
429 pub fn set_max_particles(&mut self, max: usize) {
432 self.max_particles = max;
433 }
434
435 pub fn set_carve_debris_cap(&mut self, cap: usize) {
441 self.carve_debris_cap = cap.max(1);
442 }
443
444 pub fn add_emitter(&mut self, def: ParticleEmitterDef) -> EmitterId {
446 let slot = self.emitters.len() as u32;
447 let id = self.map.alloc(slot);
448 let burst = match def.spawn {
449 SpawnMode::Burst(n) => n,
450 _ => 0,
451 };
452 self.emitters.push(Some(EmitterState {
453 def,
454 spawn_acc: 0.0,
455 live: 0,
456 retired: false,
457 }));
458 if burst > 0 {
459 self.spawn_from(slot as usize, burst);
460 }
461 id
462 }
463
464 pub fn remove_emitter(&mut self, id: EmitterId) -> bool {
469 let Some(slot) = self.map.index(id) else {
470 return false;
471 };
472 if !self.map.remove(id) {
473 return false;
474 }
475 let state = self.emitters[slot].as_mut().expect("live map ⇒ state");
476 if state.live == 0 {
477 self.emitters[slot] = None;
478 } else {
479 state.retired = true;
480 }
481 true
482 }
483
484 pub fn set_emitter_pos(&mut self, id: EmitterId, pos: [f32; 3]) -> bool {
487 let Some(slot) = self.map.index(id) else {
488 return false;
489 };
490 let state = self.emitters[slot].as_mut().expect("live map ⇒ state");
491 state.def.pos = pos;
492 true
493 }
494
495 pub fn burst(&mut self, id: EmitterId, n: u32) -> u32 {
499 let Some(slot) = self.map.index(id) else {
500 return 0;
501 };
502 self.spawn_from(slot, n)
503 }
504
505 pub fn update(&mut self, dt: f64) {
513 self.step(dt, None);
514 }
515
516 pub fn update_with_scene(&mut self, dt: f64, scene: &roxlap_scene::Scene) {
521 self.step(dt, Some(scene));
522 }
523
524 fn step(&mut self, dt: f64, scene: Option<&roxlap_scene::Scene>) {
526 let dtf = dt.max(0.0) as f32;
527
528 let emitters = &self.emitters;
531 for p in &mut self.particles {
532 p.age += dtf;
533 if p.age >= p.lifetime {
534 continue; }
536 let def = &emitters[p.emitter_slot as usize]
537 .as_ref()
538 .expect("live particle ⇒ emitter state retained")
539 .def;
540 let prev = p.pos;
541 for a in 0..3 {
542 p.vel[a] += (def.gravity[a] - def.drag * p.vel[a]) * dtf;
543 p.pos[a] += p.vel[a] * dtf;
544 }
545 if let Some(scene) = scene {
548 if !matches!(def.collision, CollisionMode::None)
549 && scene_solid_ahead(scene, p.pos, p.vel)
550 {
551 match def.collision {
552 CollisionMode::Kill => {
553 p.age = p.lifetime; continue;
555 }
556 CollisionMode::Bounce { restitution } => {
557 let mut crossed = false;
562 for ((prev_a, pos_a), vel_a) in prev.iter().zip(&p.pos).zip(&mut p.vel)
563 {
564 if prev_a.floor() != pos_a.floor() {
565 *vel_a = -*vel_a;
566 crossed = true;
567 }
568 }
569 for vel_a in &mut p.vel {
570 if !crossed {
571 *vel_a = -*vel_a;
572 }
573 *vel_a *= restitution;
574 }
575 p.pos = prev;
576 }
577 CollisionMode::None => unreachable!("guarded above"),
578 }
579 }
580 }
581 p.yaw += p.spin_rate * dtf;
582 let frac = p.age / p.lifetime;
584 if let Some(end) = def.scale_end {
585 p.scale = def.scale + (end - def.scale) * frac;
586 }
587 if let Some(end) = def.tint_end {
588 p.tint = lerp_tint(p.tint_start, end, frac);
589 }
590 p.alpha = fade_alpha(p.age, p.lifetime, def.fade_in_frac, def.fade_out_frac);
591 }
592
593 let mut i = 0;
595 while i < self.particles.len() {
596 if self.particles[i].age >= self.particles[i].lifetime {
597 let p = self.particles.swap_remove(i);
598 if let Some(inst) = p.instance {
599 self.dead_instances.push(inst);
600 }
601 self.on_particle_died(p.emitter_slot as usize);
602 } else {
603 i += 1;
604 }
605 }
606
607 for slot in 0..self.emitters.len() {
612 let n = {
613 let Some(state) = self.emitters[slot].as_mut() else {
614 continue;
615 };
616 if state.retired {
617 continue;
618 }
619 let SpawnMode::Rate(rate) = state.def.spawn else {
620 continue;
621 };
622 state.spawn_acc += f64::from(rate) * dt.max(0.0);
623 let n = state.spawn_acc.floor();
624 state.spawn_acc -= n;
625 n as u32
626 };
627 if n > 0 {
628 self.spawn_from(slot, n);
629 }
630 }
631 }
632
633 #[must_use]
635 pub fn particles(&self) -> &[Particle] {
636 &self.particles
637 }
638
639 #[must_use]
641 pub fn particle_count(&self) -> usize {
642 self.particles.len()
643 }
644
645 #[must_use]
647 pub fn emitter_count(&self) -> usize {
648 self.emitters
649 .iter()
650 .filter(|e| e.as_ref().is_some_and(|s| !s.retired))
651 .count()
652 }
653
654 #[must_use]
658 pub fn dropped_spawns(&self) -> u64 {
659 self.dropped_spawns
660 }
661
662 pub fn drain_dead_instances(&mut self) -> impl Iterator<Item = SpriteInstanceId> + '_ {
667 self.dead_instances.drain(..)
668 }
669
670 #[must_use]
675 pub fn stale_model_kills(&self) -> u64 {
676 self.stale_model_kills
677 }
678
679 pub fn sync(&mut self, renderer: &mut SceneRenderer) {
691 self.sync_with(renderer);
692 }
693
694 pub fn tick(&mut self, renderer: &mut SceneRenderer, dt: f64) {
698 self.step(dt, None);
699 self.sync_with(renderer);
700 }
701
702 pub fn tick_with_scene(
707 &mut self,
708 renderer: &mut SceneRenderer,
709 dt: f64,
710 scene: &roxlap_scene::Scene,
711 ) {
712 self.step(dt, Some(scene));
713 self.sync_with(renderer);
714 }
715
716 fn sync_with<F: ParticleFacade>(&mut self, facade: &mut F) {
719 for id in self.dead_instances.drain(..) {
722 facade.despawn(id);
723 }
724
725 let mut batch = std::mem::take(&mut self.xf_scratch);
727 batch.clear();
728 let mut i = 0;
729 while i < self.particles.len() {
730 if self.particles[i].instance.is_none() {
731 let (slot, xf) = {
732 let p = &self.particles[i];
733 (p.emitter_slot as usize, particle_xf(p))
734 };
735 let (model, material, lighting, shadows) = {
736 let st = self.emitters[slot]
737 .as_ref()
738 .expect("live particle ⇒ emitter state retained");
739 (
740 st.def.model,
741 st.def.material,
742 st.def.lighting,
743 st.def.shadows,
744 )
745 };
746 let Some(id) = facade.spawn(model, xf) else {
747 self.stale_model_kills += 1;
750 self.particles.swap_remove(i);
751 self.on_particle_died(slot);
752 continue;
753 };
754 if material != 0 {
757 facade.set_material(id, material);
758 }
759 if lighting != BillboardLighting::default() {
760 facade.set_lighting(id, lighting);
761 }
762 if shadows != ShadowFlags::default() {
763 facade.set_shadows(id, shadows);
764 }
765 let p = &mut self.particles[i];
766 p.instance = Some(id);
767 p.last_alpha = 255;
771 p.last_tint = 0x00FF_FFFF;
772 } else {
773 let p = &self.particles[i];
776 batch.push((p.instance.expect("checked above"), particle_xf(p)));
777 }
778 let p = &mut self.particles[i];
779 if p.alpha != p.last_alpha {
780 facade.set_alpha(p.instance.expect("set above"), p.alpha);
781 p.last_alpha = p.alpha;
782 }
783 if p.tint != p.last_tint {
784 facade.set_tint(p.instance.expect("set above"), p.tint);
785 p.last_tint = p.tint;
786 }
787 i += 1;
788 }
789 if !batch.is_empty() {
790 facade.set_transforms(&batch);
791 }
792 self.xf_scratch = batch;
793 }
794
795 pub fn carve_debris(
820 &mut self,
821 scene: &mut roxlap_scene::Scene,
822 grid: roxlap_scene::GridId,
823 centre: glam::IVec3,
824 radius: u32,
825 outward: Range<f32>,
826 def: &ParticleEmitterDef,
827 ) -> u32 {
828 let Some(g) = scene.grid_mut(grid) else {
829 return 0;
830 };
831 #[allow(clippy::cast_possible_wrap)]
833 let r = radius as i32;
834 let mut samples: Vec<(glam::IVec3, u32)> = Vec::new();
835 for z in -r..=r {
836 for y in -r..=r {
837 for x in -r..=r {
838 if x * x + y * y + z * z > r * r {
839 continue;
840 }
841 let v = centre + glam::IVec3::new(x, y, z);
842 if let Some(col) = g.voxel_color(v) {
843 samples.push((v, col));
844 }
845 }
846 }
847 }
848 let transform = g.transform;
849 g.set_sphere(centre, radius, None);
852 if samples.is_empty() {
853 return 0;
854 }
855
856 let mut def = def.clone();
859 def.spawn = SpawnMode::Manual;
860 let id = self.add_emitter(def.clone());
861 let slot = self.map.index(id).expect("just allocated");
862
863 let to_world = |v: glam::IVec3| -> [f32; 3] {
865 let w =
866 transform.origin + transform.rotation * (v.as_dvec3() + glam::DVec3::splat(0.5));
867 #[allow(clippy::cast_possible_truncation)]
868 [w.x as f32, w.y as f32, w.z as f32]
869 };
870 let cw = to_world(centre);
871
872 let stride = samples.len().div_ceil(self.carve_debris_cap).max(1);
873 let mut spawned: u32 = 0;
874 for (v, col) in samples.iter().step_by(stride) {
875 if self.particles.len() >= self.max_particles {
876 self.dropped_spawns += 1;
877 continue;
878 }
879 let pos = to_world(*v);
880 let d = [pos[0] - cw[0], pos[1] - cw[1], pos[2] - cw[2]];
883 let len = (d[0] * d[0] + d[1] * d[1] + d[2] * d[2]).sqrt();
884 let dir = if len > 1e-4 {
885 [d[0] / len, d[1] / len, d[2] / len]
886 } else {
887 self.rng.unit_vec()
888 };
889 let mut vel = sample_velocity(&mut self.rng, &def.velocity);
890 let speed = self.rng.range_f32(&outward);
891 for a in 0..3 {
892 vel[a] += dir[a] * speed;
893 }
894 let lifetime = self.rng.range_f32(&def.lifetime).max(1e-3);
895 self.particles.push(Particle {
896 pos,
897 vel,
898 age: 0.0,
899 lifetime,
900 scale: def.scale,
901 yaw: 0.0,
902 spin_rate: self.rng.range_f32(&def.spin),
903 alpha: fade_alpha(0.0, lifetime, def.fade_in_frac, def.fade_out_frac),
904 tint: col & 0x00FF_FFFF,
905 emitter_slot: slot as u32,
906 instance: None,
907 last_alpha: 255,
908 last_tint: 0x00FF_FFFF,
909 tint_start: col & 0x00FF_FFFF,
910 });
911 spawned += 1;
912 }
913 self.emitters[slot]
914 .as_mut()
915 .expect("slot allocated above")
916 .live += spawned;
917 self.remove_emitter(id);
918 spawned
919 }
920
921 fn spawn_from(&mut self, slot: usize, n: u32) -> u32 {
924 let state = self.emitters[slot]
925 .as_mut()
926 .expect("spawn_from callers hold a live slot");
927 let def = state.def.clone(); let mut spawned = 0;
929 for _ in 0..n {
930 if self.particles.len() >= self.max_particles {
931 self.dropped_spawns += u64::from(n - spawned);
932 break;
933 }
934 let mut pos = def.pos;
936 match def.shape {
937 EmitterShape::Point => {}
938 EmitterShape::Sphere { radius } => {
939 let dir = self.rng.unit_vec();
941 let r = radius * self.rng.next_f32().cbrt();
942 for a in 0..3 {
943 pos[a] += dir[a] * r;
944 }
945 }
946 EmitterShape::Box { half } => {
947 for a in 0..3 {
948 pos[a] += (self.rng.next_f32() * 2.0 - 1.0) * half[a];
949 }
950 }
951 }
952
953 let vel = sample_velocity(&mut self.rng, &def.velocity);
954 let lifetime = self.rng.range_f32(&def.lifetime).max(1e-3);
955 self.particles.push(Particle {
956 pos,
957 vel,
958 age: 0.0,
959 lifetime,
960 scale: def.scale,
961 yaw: 0.0,
962 spin_rate: self.rng.range_f32(&def.spin),
963 alpha: fade_alpha(0.0, lifetime, def.fade_in_frac, def.fade_out_frac),
964 tint: def.tint,
965 emitter_slot: slot as u32,
966 instance: None,
967 last_alpha: 255,
968 last_tint: 0x00FF_FFFF,
969 tint_start: def.tint,
970 });
971 spawned += 1;
972 }
973 self.emitters[slot]
975 .as_mut()
976 .expect("slot unchanged during spawn")
977 .live += spawned;
978 spawned
979 }
980
981 fn on_particle_died(&mut self, slot: usize) {
984 let state = self.emitters[slot]
985 .as_mut()
986 .expect("live particle ⇒ emitter state retained");
987 state.live -= 1;
988 if state.retired && state.live == 0 {
989 self.emitters[slot] = None;
990 }
991 }
992}
993
994fn particle_xf(p: &Particle) -> DynSpriteTransform {
1000 let k = p.scale.max(0.05);
1001 let (c, s) = (p.yaw.cos() * k, p.yaw.sin() * k);
1002 DynSpriteTransform {
1003 pos: p.pos,
1004 right: [c, s, 0.0],
1005 up: [-s, c, 0.0],
1006 forward: [0.0, 0.0, k],
1007 }
1008}
1009
1010fn sample_velocity(rng: &mut Pcg32, v: &VelocityDef) -> [f32; 3] {
1013 let mut vel = v.base;
1014 if v.spread > 0.0 {
1015 let dir = rng.unit_vec();
1016 let speed = rng.next_f32() * v.spread;
1017 for a in 0..3 {
1018 vel[a] += dir[a] * speed;
1019 }
1020 }
1021 if let Some(cone) = &v.cone {
1022 let dir = cone_dir(rng, cone.axis, cone.half_angle_deg);
1023 let speed = rng.range_f32(&cone.speed);
1024 for a in 0..3 {
1025 vel[a] += dir[a] * speed;
1026 }
1027 }
1028 vel
1029}
1030
1031fn cone_dir(rng: &mut Pcg32, axis: [f32; 3], half_angle_deg: f32) -> [f32; 3] {
1035 let len2 = axis[0] * axis[0] + axis[1] * axis[1] + axis[2] * axis[2];
1036 let w = if len2 > 1e-12 {
1037 let inv = 1.0 / len2.sqrt();
1038 [axis[0] * inv, axis[1] * inv, axis[2] * inv]
1039 } else {
1040 [0.0, 0.0, -1.0]
1041 };
1042 let cos_half = half_angle_deg.clamp(0.0, 180.0).to_radians().cos();
1044 let cz = 1.0 - rng.next_f32() * (1.0 - cos_half);
1045 let sz = (1.0 - cz * cz).max(0.0).sqrt();
1046 let phi = rng.next_f32() * std::f32::consts::TAU;
1047 let t = if w[0].abs() < 0.5 {
1050 [1.0, 0.0, 0.0]
1051 } else {
1052 [0.0, 1.0, 0.0]
1053 };
1054 let u = {
1055 let c = [
1056 w[1] * t[2] - w[2] * t[1],
1057 w[2] * t[0] - w[0] * t[2],
1058 w[0] * t[1] - w[1] * t[0],
1059 ];
1060 let inv = 1.0 / (c[0] * c[0] + c[1] * c[1] + c[2] * c[2]).sqrt();
1061 [c[0] * inv, c[1] * inv, c[2] * inv]
1062 };
1063 let v = [
1064 w[1] * u[2] - w[2] * u[1],
1065 w[2] * u[0] - w[0] * u[2],
1066 w[0] * u[1] - w[1] * u[0],
1067 ];
1068 let (cp, sp) = (phi.cos() * sz, phi.sin() * sz);
1069 [
1070 w[0] * cz + u[0] * cp + v[0] * sp,
1071 w[1] * cz + u[1] * cp + v[1] * sp,
1072 w[2] * cz + u[2] * cp + v[2] * sp,
1073 ]
1074}
1075
1076fn scene_solid_ahead(scene: &roxlap_scene::Scene, pos: [f32; 3], vel: [f32; 3]) -> bool {
1083 let world = glam::DVec3::new(f64::from(pos[0]), f64::from(pos[1]), f64::from(pos[2]));
1084 let dir = glam::DVec3::new(f64::from(vel[0]), f64::from(vel[1]), f64::from(vel[2]));
1085 scene.resolve_voxel(world, dir).is_some()
1086}
1087
1088fn lerp_tint(a: u32, b: u32, t: f32) -> u32 {
1090 let t = t.clamp(0.0, 1.0);
1091 let ch = |sh: u32| {
1092 let (ca, cb) = ((a >> sh) & 0xff, (b >> sh) & 0xff);
1093 let m = ca as f32 + (cb as f32 - ca as f32) * t;
1094 ((m as u32) & 0xff) << sh
1095 };
1096 ch(16) | ch(8) | ch(0)
1097}
1098
1099pub(crate) trait ParticleFacade {
1105 fn spawn(&mut self, model: SpriteModelId, xf: DynSpriteTransform) -> Option<SpriteInstanceId>;
1106 fn despawn(&mut self, id: SpriteInstanceId);
1107 fn set_transforms(&mut self, batch: &[(SpriteInstanceId, DynSpriteTransform)]);
1108 fn set_alpha(&mut self, id: SpriteInstanceId, alpha: u8);
1109 fn set_tint(&mut self, id: SpriteInstanceId, tint: u32);
1110 fn set_material(&mut self, id: SpriteInstanceId, material: u8);
1111 fn set_lighting(&mut self, id: SpriteInstanceId, mode: BillboardLighting);
1112 fn set_shadows(&mut self, id: SpriteInstanceId, flags: ShadowFlags);
1113}
1114
1115impl ParticleFacade for SceneRenderer {
1116 fn spawn(&mut self, model: SpriteModelId, xf: DynSpriteTransform) -> Option<SpriteInstanceId> {
1117 self.add_sprite_instance_posed(model, xf)
1118 }
1119 fn despawn(&mut self, id: SpriteInstanceId) {
1120 self.remove_sprite_instance(id);
1123 }
1124 fn set_transforms(&mut self, batch: &[(SpriteInstanceId, DynSpriteTransform)]) {
1125 self.set_sprite_instance_transforms(batch);
1126 }
1127 fn set_alpha(&mut self, id: SpriteInstanceId, alpha: u8) {
1128 self.set_sprite_instance_alpha(id, alpha);
1129 }
1130 fn set_tint(&mut self, id: SpriteInstanceId, tint: u32) {
1131 self.set_sprite_instance_tint(id, tint);
1132 }
1133 fn set_material(&mut self, id: SpriteInstanceId, material: u8) {
1134 self.set_sprite_instance_material(id, material);
1135 }
1136 fn set_lighting(&mut self, id: SpriteInstanceId, mode: BillboardLighting) {
1137 self.set_sprite_instance_lighting(id, mode);
1138 }
1139 fn set_shadows(&mut self, id: SpriteInstanceId, flags: ShadowFlags) {
1140 self.set_sprite_instance_shadow_flags(id, flags);
1141 }
1142}
1143
1144fn fade_alpha(age: f32, lifetime: f32, in_frac: f32, out_frac: f32) -> u8 {
1148 let frac = age / lifetime;
1149 let mut a = 1.0_f32;
1150 if in_frac > 0.0 {
1151 a = a.min((frac / in_frac.min(1.0)).clamp(0.0, 1.0));
1152 }
1153 if out_frac > 0.0 {
1154 a = a.min(((1.0 - frac) / out_frac.min(1.0)).clamp(0.0, 1.0));
1155 }
1156 (a * 255.0) as u8
1157}
1158
1159#[cfg(test)]
1160mod tests {
1161 use super::*;
1162
1163 fn dummy_model() -> SpriteModelId {
1166 SpriteModelId::mint(0, 0)
1167 }
1168
1169 fn base_def() -> ParticleEmitterDef {
1170 ParticleEmitterDef {
1171 spawn: SpawnMode::Manual,
1172 lifetime: 1.0..1.0,
1173 gravity: [0.0, 0.0, 0.0],
1174 fade_out_frac: 0.0,
1175 ..ParticleEmitterDef::new(dummy_model())
1176 }
1177 }
1178
1179 #[test]
1180 fn same_seed_is_bit_identical() {
1181 let run = || {
1182 let mut sys = ParticleSystem::new(0x00C0_FFEE);
1183 let em = sys.add_emitter(ParticleEmitterDef {
1184 spawn: SpawnMode::Rate(120.0),
1185 lifetime: 0.3..0.9,
1186 velocity: VelocityDef {
1187 base: [0.0, 0.0, -10.0],
1188 spread: 4.0,
1189 ..VelocityDef::default()
1190 },
1191 gravity: [0.0, 0.0, 22.0],
1192 ..ParticleEmitterDef::new(dummy_model())
1193 });
1194 sys.burst(em, 7);
1195 for _ in 0..60 {
1196 sys.update(1.0 / 60.0);
1197 }
1198 sys.particles()
1199 .iter()
1200 .map(|p| (p.pos, p.vel, p.age, p.lifetime))
1201 .collect::<Vec<_>>()
1202 };
1203 let (a, b) = (run(), run());
1204 assert_eq!(a.len(), b.len());
1205 for (pa, pb) in a.iter().zip(&b) {
1206 assert_eq!(pa, pb, "same seed must be bit-identical");
1207 }
1208 assert!(!a.is_empty());
1209 }
1210
1211 #[test]
1212 fn rate_accumulates_across_frames() {
1213 let mut sys = ParticleSystem::new(1);
1214 sys.add_emitter(ParticleEmitterDef {
1215 spawn: SpawnMode::Rate(10.0),
1216 lifetime: 100.0..100.0,
1217 ..base_def()
1218 });
1219 for _ in 0..10 {
1220 sys.update(0.1);
1221 }
1222 assert_eq!(sys.particle_count(), 10);
1223
1224 let mut slow = ParticleSystem::new(2);
1226 slow.add_emitter(ParticleEmitterDef {
1227 spawn: SpawnMode::Rate(0.5),
1228 lifetime: 100.0..100.0,
1229 ..base_def()
1230 });
1231 for _ in 0..20 {
1232 slow.update(0.1);
1233 }
1234 assert_eq!(slow.particle_count(), 1);
1235 }
1236
1237 #[test]
1238 fn burst_mode_fires_on_add() {
1239 let mut sys = ParticleSystem::new(3);
1240 sys.add_emitter(ParticleEmitterDef {
1241 spawn: SpawnMode::Burst(5),
1242 ..base_def()
1243 });
1244 assert_eq!(sys.particle_count(), 5);
1245 }
1246
1247 #[test]
1248 fn budget_drops_spawns_and_counts_them() {
1249 let mut sys = ParticleSystem::new(4);
1250 sys.set_max_particles(5);
1251 let em = sys.add_emitter(base_def());
1252 assert_eq!(sys.burst(em, 10), 5);
1253 assert_eq!(sys.particle_count(), 5);
1254 assert_eq!(sys.dropped_spawns(), 5);
1255 }
1256
1257 #[test]
1258 fn particles_die_at_lifetime() {
1259 let mut sys = ParticleSystem::new(5);
1260 let em = sys.add_emitter(ParticleEmitterDef {
1261 lifetime: 0.5..0.5,
1262 ..base_def()
1263 });
1264 sys.burst(em, 3);
1265 sys.update(0.3);
1266 assert_eq!(sys.particle_count(), 3);
1267 sys.update(0.3); assert_eq!(sys.particle_count(), 0);
1269 assert_eq!(sys.drain_dead_instances().count(), 0);
1271 }
1272
1273 #[test]
1274 fn semi_implicit_euler_gravity() {
1275 let mut sys = ParticleSystem::new(6);
1276 let em = sys.add_emitter(ParticleEmitterDef {
1277 gravity: [0.0, 0.0, 10.0],
1278 lifetime: 100.0..100.0,
1279 ..base_def()
1280 });
1281 sys.burst(em, 1);
1282 sys.update(0.1);
1283 let p = sys.particles()[0];
1284 assert!((p.vel[2] - 1.0).abs() < 1e-6);
1286 assert!((p.pos[2] - 0.1).abs() < 1e-6);
1287 }
1288
1289 #[test]
1290 fn fade_curve_hits_endpoints() {
1291 assert_eq!(fade_alpha(0.0, 1.0, 0.0, 0.5), 255);
1292 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);
1295 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);
1299 assert_eq!(fade_alpha(0.125, 1.0, 0.25, 0.0), 127);
1300 assert_eq!(fade_alpha(0.25, 1.0, 0.25, 0.0), 255);
1301 assert_eq!(fade_alpha(0.5, 1.0, 1.0, 1.0), 127); }
1303
1304 #[test]
1305 fn shapes_sample_within_bounds() {
1306 let mut sys = ParticleSystem::new(20);
1307 let sphere = sys.add_emitter(ParticleEmitterDef {
1308 pos: [10.0, 0.0, 0.0],
1309 shape: EmitterShape::Sphere { radius: 3.0 },
1310 ..base_def()
1311 });
1312 let boxy = sys.add_emitter(ParticleEmitterDef {
1313 pos: [-10.0, 0.0, 0.0],
1314 shape: EmitterShape::Box {
1315 half: [1.0, 2.0, 0.5],
1316 },
1317 ..base_def()
1318 });
1319 sys.burst(sphere, 64);
1320 sys.burst(boxy, 64);
1321 let mut spread = false;
1322 for p in sys.particles() {
1323 if p.pos[0] > 0.0 {
1324 let d = [p.pos[0] - 10.0, p.pos[1], p.pos[2]];
1325 let r = (d[0] * d[0] + d[1] * d[1] + d[2] * d[2]).sqrt();
1326 assert!(r <= 3.0 + 1e-4, "sphere sample outside radius: {r}");
1327 spread |= r > 0.1;
1328 } else {
1329 let d = [p.pos[0] + 10.0, p.pos[1], p.pos[2]];
1330 assert!(
1331 d[0].abs() <= 1.0 + 1e-4
1332 && d[1].abs() <= 2.0 + 1e-4
1333 && d[2].abs() <= 0.5 + 1e-4,
1334 "box sample outside half-extents: {d:?}"
1335 );
1336 spread |= d[1].abs() > 1.0; }
1338 }
1339 assert!(spread, "samples must actually spread out");
1340 }
1341
1342 #[test]
1343 fn cone_stays_within_half_angle() {
1344 let mut sys = ParticleSystem::new(21);
1345 let axis = [0.0, 1.0, -1.0]; let em = sys.add_emitter(ParticleEmitterDef {
1347 velocity: VelocityDef {
1348 cone: Some(ConeDef {
1349 axis,
1350 half_angle_deg: 30.0,
1351 speed: 5.0..10.0,
1352 }),
1353 ..VelocityDef::default()
1354 },
1355 ..base_def()
1356 });
1357 sys.burst(em, 64);
1358 let inv = 1.0 / (2.0_f32).sqrt();
1359 let w = [0.0, inv, -inv];
1360 let cos_half = 30.0_f32.to_radians().cos();
1361 for p in sys.particles() {
1362 let sp = (p.vel[0] * p.vel[0] + p.vel[1] * p.vel[1] + p.vel[2] * p.vel[2]).sqrt();
1363 assert!(
1364 (5.0 - 1e-3..10.0 + 1e-3).contains(&sp),
1365 "speed in range: {sp}"
1366 );
1367 let cosang = (p.vel[0] * w[0] + p.vel[1] * w[1] + p.vel[2] * w[2]) / sp;
1368 assert!(
1369 cosang >= cos_half - 1e-4,
1370 "direction within the cone: cos {cosang} < {cos_half}"
1371 );
1372 }
1373 assert_eq!(
1375 cone_dir(&mut Pcg32::new(1), [0.0; 3], 0.0),
1376 [0.0, 0.0, -1.0]
1377 );
1378 }
1379
1380 #[test]
1381 fn spin_rotates_pose_and_keeps_scale() {
1382 let mut sys = ParticleSystem::new(22);
1383 let em = sys.add_emitter(ParticleEmitterDef {
1384 spin: 2.0..2.0,
1385 scale: 3.0,
1386 lifetime: 100.0..100.0,
1387 ..base_def()
1388 });
1389 sys.burst(em, 1);
1390 sys.update(0.25); let p = sys.particles()[0];
1392 assert!((p.yaw - 0.5).abs() < 1e-6);
1393 let xf = particle_xf(&p);
1394 let len = |v: [f32; 3]| (v[0] * v[0] + v[1] * v[1] + v[2] * v[2]).sqrt();
1396 assert!((len(xf.right) - 3.0).abs() < 1e-5);
1397 assert!((len(xf.up) - 3.0).abs() < 1e-5);
1398 assert!(xf.right[1].abs() > 0.1, "yaw actually rotates the basis");
1399 let dot = xf.right[0] * xf.up[0] + xf.right[1] * xf.up[1];
1400 assert!(dot.abs() < 1e-5);
1401 }
1402
1403 #[test]
1404 fn scale_and_tint_lerp_over_life() {
1405 let mut sys = ParticleSystem::new(23);
1406 let em = sys.add_emitter(ParticleEmitterDef {
1407 lifetime: 1.0..1.0,
1408 scale: 1.0,
1409 scale_end: Some(3.0),
1410 tint: 0x00FF_0000,
1411 tint_end: Some(0x0000_00FF),
1412 ..base_def()
1413 });
1414 sys.burst(em, 1);
1415 sys.update(0.5);
1416 let p = sys.particles()[0];
1417 assert!((p.scale - 2.0).abs() < 1e-5, "mid-life scale: {}", p.scale);
1418 let (r, b) = ((p.tint >> 16) & 0xff, p.tint & 0xff);
1419 assert!(
1420 (126..=128).contains(&r) && (126..=128).contains(&b),
1421 "mid tint: {:#08x}",
1422 p.tint
1423 );
1424
1425 let mut f = Mock::default();
1427 sys.sync_with(&mut f);
1428 assert_eq!(f.tints.len(), 1);
1429 sys.update(0.1);
1430 sys.sync_with(&mut f);
1431 assert_eq!(f.tints.len(), 2, "changed tint re-syncs");
1432 }
1433
1434 #[test]
1435 fn retired_emitter_drains_then_frees() {
1436 let mut sys = ParticleSystem::new(7);
1437 let em = sys.add_emitter(ParticleEmitterDef {
1438 spawn: SpawnMode::Rate(1000.0),
1439 lifetime: 0.2..0.2,
1440 ..base_def()
1441 });
1442 sys.update(0.01);
1443 assert!(sys.particle_count() > 0);
1444 assert!(sys.remove_emitter(em));
1445 assert_eq!(sys.emitter_count(), 0);
1446 assert!(!sys.remove_emitter(em));
1448 assert!(!sys.set_emitter_pos(em, [1.0, 2.0, 3.0]));
1449 assert_eq!(sys.burst(em, 10), 0);
1450 let live = sys.particle_count();
1453 sys.update(0.3);
1454 assert_eq!(sys.particle_count(), 0);
1455 assert!(live > 0);
1456 assert!(sys.emitters.iter().all(Option::is_none));
1457 }
1458
1459 #[derive(Default)]
1461 struct Mock {
1462 next_slot: u32,
1463 fail_spawn: bool,
1464 spawns: Vec<(SpriteModelId, DynSpriteTransform)>,
1465 despawns: Vec<SpriteInstanceId>,
1466 batch_sizes: Vec<usize>,
1467 alphas: Vec<(SpriteInstanceId, u8)>,
1468 tints: Vec<u32>,
1469 materials: Vec<u8>,
1470 lightings: Vec<BillboardLighting>,
1471 shadows: Vec<ShadowFlags>,
1472 }
1473
1474 impl ParticleFacade for Mock {
1475 fn spawn(
1476 &mut self,
1477 model: SpriteModelId,
1478 xf: DynSpriteTransform,
1479 ) -> Option<SpriteInstanceId> {
1480 if self.fail_spawn {
1481 return None;
1482 }
1483 self.spawns.push((model, xf));
1484 let id = SpriteInstanceId {
1485 slot: self.next_slot,
1486 gen: 0,
1487 };
1488 self.next_slot += 1;
1489 Some(id)
1490 }
1491 fn despawn(&mut self, id: SpriteInstanceId) {
1492 self.despawns.push(id);
1493 }
1494 fn set_transforms(&mut self, batch: &[(SpriteInstanceId, DynSpriteTransform)]) {
1495 self.batch_sizes.push(batch.len());
1496 }
1497 fn set_alpha(&mut self, id: SpriteInstanceId, alpha: u8) {
1498 self.alphas.push((id, alpha));
1499 }
1500 fn set_tint(&mut self, _id: SpriteInstanceId, tint: u32) {
1501 self.tints.push(tint);
1502 }
1503 fn set_material(&mut self, _id: SpriteInstanceId, material: u8) {
1504 self.materials.push(material);
1505 }
1506 fn set_lighting(&mut self, _id: SpriteInstanceId, mode: BillboardLighting) {
1507 self.lightings.push(mode);
1508 }
1509 fn set_shadows(&mut self, _id: SpriteInstanceId, flags: ShadowFlags) {
1510 self.shadows.push(flags);
1511 }
1512 }
1513
1514 #[test]
1515 fn sync_spawns_once_then_batch_moves() {
1516 let mut sys = ParticleSystem::new(10);
1517 let em = sys.add_emitter(ParticleEmitterDef {
1518 lifetime: 100.0..100.0,
1519 ..base_def()
1520 });
1521 sys.burst(em, 3);
1522 let mut f = Mock::default();
1523
1524 sys.sync_with(&mut f);
1525 assert_eq!(f.spawns.len(), 3);
1527 assert!(f.batch_sizes.is_empty());
1528 assert_eq!(f.shadows.len(), 3);
1531 assert!(f.materials.is_empty() && f.tints.is_empty() && f.lightings.is_empty());
1532 assert!(f.alphas.is_empty(), "alpha 255 == facade default");
1533
1534 sys.update(0.01);
1536 sys.sync_with(&mut f);
1537 assert_eq!(f.spawns.len(), 3);
1538 assert_eq!(f.batch_sizes, vec![3]);
1539 }
1540
1541 #[test]
1542 fn sync_one_time_setup_honours_def() {
1543 let mut sys = ParticleSystem::new(11);
1544 let em = sys.add_emitter(ParticleEmitterDef {
1545 lifetime: 100.0..100.0,
1546 tint: 0x00FF_0000,
1547 material: 5,
1548 lighting: BillboardLighting::FullBright,
1549 shadows: ShadowFlags::default(), ..base_def()
1551 });
1552 sys.burst(em, 1);
1553 let mut f = Mock::default();
1554 sys.sync_with(&mut f);
1555 assert_eq!(f.materials, vec![5]);
1556 assert_eq!(f.tints, vec![0x00FF_0000]);
1557 assert_eq!(f.lightings, vec![BillboardLighting::FullBright]);
1558 assert!(f.shadows.is_empty(), "facade-default shadows skip the call");
1559 sys.update(0.01);
1561 sys.sync_with(&mut f);
1562 assert_eq!(f.materials.len() + f.tints.len() + f.lightings.len(), 3);
1563 }
1564
1565 #[test]
1566 fn sync_despawns_dead_and_writes_alpha_on_change_only() {
1567 let mut sys = ParticleSystem::new(12);
1568 let em = sys.add_emitter(ParticleEmitterDef {
1569 lifetime: 1.0..1.0,
1570 fade_out_frac: 0.5,
1571 ..base_def()
1572 });
1573 sys.burst(em, 2);
1574 let mut f = Mock::default();
1575 sys.sync_with(&mut f);
1576
1577 sys.update(0.25);
1579 sys.sync_with(&mut f);
1580 assert!(f.alphas.is_empty());
1581
1582 sys.update(0.5); sys.sync_with(&mut f);
1585 assert_eq!(f.alphas.len(), 2);
1586 assert!(f.alphas.iter().all(|&(_, a)| a == 127));
1587
1588 sys.update(0.5);
1590 assert_eq!(sys.particle_count(), 0);
1591 sys.sync_with(&mut f);
1592 assert_eq!(f.despawns.len(), 2);
1593 }
1594
1595 #[test]
1596 fn stale_model_spawn_kills_particle() {
1597 let mut sys = ParticleSystem::new(13);
1598 let em = sys.add_emitter(base_def());
1599 sys.burst(em, 2);
1600 let mut f = Mock {
1601 fail_spawn: true,
1602 ..Mock::default()
1603 };
1604 sys.sync_with(&mut f);
1605 assert_eq!(sys.particle_count(), 0);
1606 assert_eq!(sys.stale_model_kills(), 2);
1607 assert!(sys.remove_emitter(em));
1610 assert!(sys.emitters.iter().all(Option::is_none));
1611 }
1612
1613 #[test]
1614 fn particle_xf_scales_and_clamps() {
1615 let p = Particle {
1616 pos: [1.0, 2.0, 3.0],
1617 vel: [0.0; 3],
1618 age: 0.0,
1619 lifetime: 1.0,
1620 scale: 2.0,
1621 yaw: 0.0,
1622 spin_rate: 0.0,
1623 alpha: 255,
1624 tint: 0,
1625 emitter_slot: 0,
1626 instance: None,
1627 last_alpha: 255,
1628 last_tint: 0x00FF_FFFF,
1629 tint_start: 0,
1630 };
1631 let xf = particle_xf(&p);
1632 assert_eq!(xf.pos, [1.0, 2.0, 3.0]);
1633 assert_eq!((xf.right[0], xf.up[1], xf.forward[2]), (2.0, 2.0, 2.0));
1634 let tiny = particle_xf(&Particle { scale: 0.0, ..p });
1636 assert_eq!(tiny.right[0], 0.05);
1637 }
1638
1639 fn scene_with_floor() -> roxlap_scene::Scene {
1641 use glam::{DVec3, IVec3};
1642 let mut scene = roxlap_scene::Scene::new();
1643 let id = scene.add_grid(roxlap_scene::GridTransform::at(DVec3::ZERO));
1644 let g = scene.grid_mut(id).expect("fresh grid");
1645 g.set_rect(
1646 IVec3::new(-16, -16, 10),
1647 IVec3::new(16, 16, 12),
1648 Some(0x80FF_FFFF),
1649 );
1650 scene
1651 }
1652
1653 fn falling(collision: CollisionMode) -> ParticleEmitterDef {
1655 ParticleEmitterDef {
1656 pos: [0.0, 0.0, 5.0],
1657 velocity: VelocityDef {
1658 base: [0.0, 0.0, 20.0],
1659 ..VelocityDef::default()
1660 },
1661 lifetime: 100.0..100.0,
1662 collision,
1663 ..base_def() }
1665 }
1666
1667 #[test]
1668 fn collision_kill_dies_on_contact() {
1669 let scene = scene_with_floor();
1670 let mut sys = ParticleSystem::new(30);
1671 let em = sys.add_emitter(falling(CollisionMode::Kill));
1672 sys.burst(em, 1);
1673 for _ in 0..20 {
1674 sys.update_with_scene(0.05, &scene);
1675 }
1676 assert_eq!(sys.particle_count(), 0, "dies at the slab");
1677
1678 let mut free = ParticleSystem::new(30);
1680 let em = free.add_emitter(falling(CollisionMode::Kill));
1681 free.burst(em, 1);
1682 for _ in 0..20 {
1683 free.update(0.05);
1684 }
1685 assert_eq!(free.particle_count(), 1);
1686 assert!(free.particles()[0].pos[2] > 12.0, "fell straight through");
1687 }
1688
1689 #[test]
1690 fn collision_none_passes_through() {
1691 let scene = scene_with_floor();
1692 let mut sys = ParticleSystem::new(31);
1693 let em = sys.add_emitter(falling(CollisionMode::None));
1694 sys.burst(em, 1);
1695 for _ in 0..20 {
1696 sys.update_with_scene(0.05, &scene);
1697 }
1698 assert!(sys.particles()[0].pos[2] > 12.0);
1699 }
1700
1701 #[test]
1702 fn collision_bounce_reflects_and_damps() {
1703 let scene = scene_with_floor();
1704 let mut sys = ParticleSystem::new(32);
1705 let em = sys.add_emitter(falling(CollisionMode::Bounce { restitution: 0.5 }));
1706 sys.burst(em, 1);
1707 let mut bounced = false;
1708 for _ in 0..40 {
1709 sys.update_with_scene(0.05, &scene);
1710 let p = sys.particles()[0];
1711 assert!(p.pos[2] < 10.5, "never sinks into the slab: {}", p.pos[2]);
1712 if p.vel[2] < 0.0 {
1713 bounced = true;
1714 }
1715 }
1716 assert!(bounced, "velocity reflected upward");
1717 let p = sys.particles()[0];
1718 assert!(
1719 p.vel[2].abs() <= 10.0 + 1e-3,
1720 "restitution halves the speed: {}",
1721 p.vel[2]
1722 );
1723 }
1724
1725 #[test]
1726 fn carve_debris_samples_colours_and_carves() {
1727 use glam::{DVec3, IVec3};
1728 let mut scene = roxlap_scene::Scene::new();
1730 let grid = scene.add_grid(roxlap_scene::GridTransform::at(DVec3::ZERO));
1731 scene.grid_mut(grid).expect("grid").set_rect(
1732 IVec3::new(-8, -8, 10),
1733 IVec3::new(8, 8, 12),
1734 Some(0x80_12_34_56),
1735 );
1736 let mut sys = ParticleSystem::new(40);
1737 let centre = IVec3::new(0, 0, 11);
1738 let spawned = sys.carve_debris(
1739 &mut scene,
1740 grid,
1741 centre,
1742 2,
1743 4.0..6.0,
1744 &ParticleEmitterDef {
1745 lifetime: 100.0..100.0,
1746 ..base_def()
1747 },
1748 );
1749 assert!(spawned > 0);
1750 assert_eq!(sys.particle_count() as u32, spawned);
1751 for p in sys.particles() {
1753 assert_eq!(p.tint, 0x0012_3456, "tint is the voxel colour");
1754 }
1755 let g = scene.grid_mut(grid).expect("grid");
1757 assert!(g.voxel_color(centre).is_none());
1758 for p in sys.particles() {
1760 let d = [p.pos[0] - 0.5, p.pos[1] - 0.5, p.pos[2] - 11.5];
1761 let r2 = d[0] * d[0] + d[1] * d[1] + d[2] * d[2];
1762 if r2 > 0.5 {
1763 let dot = d[0] * p.vel[0] + d[1] * p.vel[1] + d[2] * p.vel[2];
1764 assert!(dot > 0.0, "debris flies outward: {d:?} vs {:?}", p.vel);
1765 }
1766 }
1767 assert_eq!(sys.emitter_count(), 0);
1769 sys.update(200.0);
1770 assert_eq!(sys.particle_count(), 0);
1771 assert!(sys.emitters.iter().all(Option::is_none));
1772 }
1773
1774 #[test]
1775 fn carve_debris_caps_big_carves_and_respects_budget() {
1776 use glam::IVec3;
1777 let mut scene = scene_with_floor();
1778 let grid = scene.grids().next().expect("one grid").0;
1779 let mut sys = ParticleSystem::new(41);
1780 let spawned = sys.carve_debris(
1782 &mut scene,
1783 grid,
1784 IVec3::new(0, 0, 11),
1785 6,
1786 1.0..2.0,
1787 &ParticleEmitterDef {
1788 lifetime: 100.0..100.0,
1789 ..base_def()
1790 },
1791 );
1792 assert!(spawned as usize <= CARVE_DEBRIS_CAP);
1793 assert!(
1794 spawned as usize > CARVE_DEBRIS_CAP / 2,
1795 "stride fills near the cap"
1796 );
1797
1798 let mut low = ParticleSystem::new(44);
1800 low.set_carve_debris_cap(16);
1801 let mut scene3 = scene_with_floor();
1802 let spawned = low.carve_debris(
1803 &mut scene3,
1804 grid,
1805 IVec3::new(0, 0, 11),
1806 6,
1807 1.0..2.0,
1808 &ParticleEmitterDef {
1809 lifetime: 100.0..100.0,
1810 ..base_def()
1811 },
1812 );
1813 assert!(spawned <= 16, "tuned cap respected: {spawned}");
1814 assert!(spawned >= 8, "stride still fills near the tuned cap");
1815
1816 let mut tiny = ParticleSystem::new(42);
1818 tiny.set_max_particles(5);
1819 let mut scene2 = scene_with_floor();
1820 let spawned = tiny.carve_debris(
1821 &mut scene2,
1822 grid,
1823 IVec3::new(10, 10, 11),
1824 4,
1825 1.0..2.0,
1826 &ParticleEmitterDef {
1827 lifetime: 100.0..100.0,
1828 ..base_def()
1829 },
1830 );
1831 assert_eq!(spawned, 5);
1832 assert!(tiny.dropped_spawns() > 0);
1833 }
1834
1835 #[test]
1836 fn carve_debris_tint_end_lerps_from_voxel_colour() {
1837 use glam::IVec3;
1838 let mut scene = scene_with_floor();
1839 let grid = scene.grids().next().expect("one grid").0;
1840 let mut sys = ParticleSystem::new(43);
1841 sys.carve_debris(
1842 &mut scene,
1843 grid,
1844 IVec3::new(0, 0, 11),
1845 1,
1846 0.0..0.0,
1847 &ParticleEmitterDef {
1848 lifetime: 1.0..1.0,
1849 tint_end: Some(0x0000_0000),
1850 ..base_def()
1851 },
1852 );
1853 assert!(sys.particle_count() > 0);
1854 sys.update(0.5);
1855 for p in sys.particles() {
1857 let r = (p.tint >> 16) & 0xff;
1858 assert!(
1859 (120..=135).contains(&r),
1860 "lerp starts at the voxel colour, not def.tint: {:#08x}",
1861 p.tint
1862 );
1863 }
1864 }
1865
1866 #[test]
1867 fn stress_10k_particles_simulate_and_sync() {
1868 let mut sys = ParticleSystem::new(50);
1869 sys.set_max_particles(10_000);
1870 let em = sys.add_emitter(ParticleEmitterDef {
1871 lifetime: 2.0..2.0,
1875 velocity: VelocityDef {
1876 spread: 10.0,
1877 ..VelocityDef::default()
1878 },
1879 fade_in_frac: 0.5,
1881 fade_out_frac: 0.5,
1882 spin: -3.0..3.0,
1883 scale_end: Some(2.0),
1884 tint_end: Some(0x0000_0000),
1885 gravity: [0.0, 0.0, 5.0],
1886 ..base_def()
1887 });
1888 assert_eq!(sys.burst(em, 10_000), 10_000);
1889 let mut f = Mock::default();
1890 for _ in 0..30 {
1891 sys.update(1.0 / 60.0);
1892 sys.sync_with(&mut f);
1893 }
1894 assert_eq!(sys.particle_count(), 10_000);
1895 assert_eq!(f.spawns.len(), 10_000);
1896 assert_eq!(*f.batch_sizes.last().expect("batches ran"), 10_000);
1899 assert!(f.alphas.len() > 100_000, "alpha churns every frame");
1900 }
1901
1902 #[test]
1905 #[ignore = "manual perf probe — prints timings, asserts nothing"]
1906 fn stress_10k_probe() {
1907 let mut sys = ParticleSystem::new(51);
1908 sys.set_max_particles(10_000);
1909 let em = sys.add_emitter(ParticleEmitterDef {
1910 lifetime: 8.0..8.0,
1913 velocity: VelocityDef {
1914 spread: 10.0,
1915 ..VelocityDef::default()
1916 },
1917 fade_in_frac: 0.5,
1918 fade_out_frac: 0.5,
1919 spin: -3.0..3.0,
1920 scale_end: Some(2.0),
1921 tint_end: Some(0x0000_0000),
1922 ..base_def()
1923 });
1924 sys.burst(em, 10_000);
1925 let mut f = Mock::default();
1926 sys.sync_with(&mut f); let t0 = std::time::Instant::now();
1928 const FRAMES: u32 = 200;
1929 for _ in 0..FRAMES {
1930 sys.update(1.0 / 60.0);
1931 sys.sync_with(&mut f);
1932 }
1933 let per_frame = t0.elapsed() / FRAMES;
1934 eprintln!("10k particles: {per_frame:?}/frame (update + sync w/ alpha+tint churn)");
1935 }
1936
1937 #[test]
1938 fn moved_emitter_spawns_at_new_pos() {
1939 let mut sys = ParticleSystem::new(8);
1940 let em = sys.add_emitter(base_def());
1941 assert!(sys.set_emitter_pos(em, [5.0, 6.0, 7.0]));
1942 sys.burst(em, 1);
1943 assert_eq!(sys.particles()[0].pos, [5.0, 6.0, 7.0]);
1944 }
1945}