Skip to main content

roxlap_render/
particles.rs

1//! Stage PS — particle system over sprite instancing.
2//!
3//! A [`ParticleSystem`] is a **host-owned** layer over the facade's
4//! dynamic sprite instances (see `docs/porting/PORTING-PARTICLES.md`):
5//! every live particle is one posed kv6 instance, so particles inherit
6//! both backends, per-instance tint/alpha/material, TV
7//! volumetric/additive looks and [`BillboardLighting`] for free. This
8//! module is deliberately **not** part of [`SceneRenderer`]'s method
9//! surface — the system *drives* the facade through its public API.
10//!
11//! Two halves: the renderer-free simulation
12//! ([`ParticleSystem::update`] — emitters, deterministic integration,
13//! over-life curves, budget; PS.0/PS.2) and the facade binding
14//! ([`ParticleSystem::sync`] / [`tick`](ParticleSystem::tick) —
15//! spawn/despawn/batch-move; PS.1). Hosts doing their own rendering
16//! can use `update` alone and consume
17//! [`ParticleSystem::drain_dead_instances`] directly.
18//!
19//! Axes reminder: **+z is DOWN** (voxlap convention) — gravity is
20//! positive z, smoke rises with negative z velocity.
21//!
22//! [`SceneRenderer`]: crate::SceneRenderer
23
24use std::ops::Range;
25
26use crate::Rgb;
27use crate::{
28    BillboardLighting, DynSpriteTransform, EpochSlotMap, SceneRenderer, ShadowFlags, SlotHandle,
29    SpriteInstanceId, SpriteModelId,
30};
31
32/// Stable handle to an emitter inside one [`ParticleSystem`] — the
33/// result of [`add_emitter`](ParticleSystem::add_emitter), passed to
34/// the per-emitter setters and [`burst`](ParticleSystem::burst).
35/// Epoch-generational like every other facade handle family: a removed
36/// emitter's handle resolves to a safe no-op, never to another emitter.
37#[derive(Clone, Copy, PartialEq, Eq, Hash, Debug)]
38pub struct EmitterId {
39    slot: u32,
40    gen: u32,
41}
42
43// `impl_slot_handle!` is textually scoped to lib.rs; the trait is
44// crate-visible, so the two-method impl is written out here.
45impl 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/// How an emitter produces particles.
55#[derive(Clone, Copy, Debug)]
56pub enum SpawnMode {
57    /// Continuous emission at `n` particles per second; fractional
58    /// particles accumulate across frames, so `Rate(0.5)` spawns one
59    /// particle every two seconds regardless of frame rate.
60    Rate(f32),
61    /// One burst of `n` particles the moment the emitter is added,
62    /// then nothing (explosions). Further bursts via
63    /// [`burst`](ParticleSystem::burst).
64    Burst(u32),
65    /// Nothing automatic — the host calls
66    /// [`burst`](ParticleSystem::burst) itself.
67    Manual,
68}
69
70/// Where new particles appear, relative to the emitter position (PS.2).
71#[derive(Clone, Copy, Debug, Default)]
72pub enum EmitterShape {
73    /// Every particle starts exactly at the emitter position (default).
74    #[default]
75    Point,
76    /// Uniform inside a ball of this radius.
77    Sphere {
78        /// Ball radius, world units.
79        radius: f32,
80    },
81    /// Uniform inside an axis-aligned box of these half-extents.
82    Box {
83        /// Half-extents per axis, world units.
84        half: [f32; 3],
85    },
86}
87
88/// Directional cone emission (PS.2) — fountains, muzzle flashes,
89/// impact sprays: a random direction within `half_angle_deg` of
90/// `axis`, at a speed sampled from `speed`.
91#[derive(Clone, Debug)]
92pub struct ConeDef {
93    /// Cone axis (need not be unit length; a zero vector falls back to
94    /// straight **up**, `[0, 0, -1]` — +z is down).
95    pub axis: [f32; 3],
96    /// Cone half-angle in **degrees** (the [`SpotLight`] convention):
97    /// `0` = a beam along the axis, `180` = fully isotropic. Clamped
98    /// to `0..=180`.
99    ///
100    /// [`SpotLight`]: crate::SpotLight
101    pub half_angle_deg: f32,
102    /// Speed along the sampled direction, world units/second, uniform.
103    pub speed: Range<f32>,
104}
105
106/// Initial particle velocity: a fixed base, an isotropic spread, and
107/// an optional directional cone — the three compose by addition.
108#[derive(Clone, Debug, Default)]
109pub struct VelocityDef {
110    /// Velocity every particle starts from, world units/second.
111    /// Remember +z is down: a fountain fires with negative z.
112    pub base: [f32; 3],
113    /// Magnitude of the random isotropic kick added to `base`: each
114    /// particle adds a uniformly random direction scaled by a uniform
115    /// `0..spread` speed. `0.0` (default) = no randomness.
116    pub spread: f32,
117    /// Optional cone kick added on top (PS.2). `None` (default) = no
118    /// directional term.
119    pub cone: Option<ConeDef>,
120}
121
122/// How particles react to solid voxels (PS.3) — checked only by the
123/// `_with_scene` update/tick variants; the scene-free ones never
124/// collide. The test is a point sample of the post-step position,
125/// nudged half a voxel along the velocity ([`Scene::resolve_voxel`]'s
126/// picking nudge, reused): contact registers slightly before visual
127/// interpenetration, fast particles can tunnel through walls thinner
128/// than one step's travel, and a **resting** particle (zero velocity)
129/// is never re-tested — all acceptable for effects, none of this is a
130/// physics engine.
131///
132/// [`Scene::resolve_voxel`]: roxlap_scene::Scene::resolve_voxel
133#[derive(Clone, Copy, Debug, Default, PartialEq)]
134pub enum CollisionMode {
135    /// No voxel interaction (default) — particles pass through.
136    #[default]
137    None,
138    /// Die on contact (impact sparks, raindrops).
139    Kill,
140    /// Reflect off the surface: velocity flips on the axes whose voxel
141    /// boundary was crossed this step (the cheapest normal estimate —
142    /// exact for axis-aligned grids, approximate for rotated ones),
143    /// then the whole velocity scales by `restitution` — tangential
144    /// damping included, deliberately arcade.
145    Bounce {
146        /// Energy kept per bounce, `0..=1` (`0.5` = half).
147        restitution: f32,
148    },
149}
150
151/// Recipe for [`add_emitter`](ParticleSystem::add_emitter). Construct
152/// with [`ParticleEmitterDef::new`] and override what the effect needs;
153/// there is no `Default` because a def is meaningless without a live
154/// [`SpriteModelId`].
155#[derive(Clone, Debug)]
156pub struct ParticleEmitterDef {
157    /// The kv6 sprite model every particle instantiates (a puff, a
158    /// spark, a shard) — from
159    /// [`add_sprite_model`](crate::SceneRenderer::add_sprite_model).
160    pub model: SpriteModelId,
161    /// Emitter position, world space. Movable later via
162    /// [`set_emitter_pos`](ParticleSystem::set_emitter_pos).
163    pub pos: [f32; 3],
164    /// Spawn-position distribution around `pos` (PS.2; default
165    /// [`EmitterShape::Point`]).
166    pub shape: EmitterShape,
167    /// Spawn behaviour (default [`SpawnMode::Manual`]).
168    pub spawn: SpawnMode,
169    /// Per-particle lifetime, seconds, sampled uniformly. A degenerate
170    /// range (`end <= start`) collapses to `start`. Clamped to ≥ 1 ms.
171    pub lifetime: Range<f32>,
172    /// Initial velocity distribution.
173    pub velocity: VelocityDef,
174    /// Constant acceleration, world units/s² — gravity is **positive
175    /// z** (default `[0, 0, 22]`, a decent arcade fall).
176    pub gravity: [f32; 3],
177    /// Linear drag coefficient, 1/s: each step removes
178    /// `drag · vel · dt`. `0.0` = ballistic; smoke wants ~1-3.
179    pub drag: f32,
180    /// Voxel-collision reaction (PS.3; default [`CollisionMode::None`]).
181    /// Only the `_with_scene` update/tick variants test it.
182    pub collision: CollisionMode,
183    /// Uniform base scale applied to the instance basis (`1.0` =
184    /// authored model size). The rendered scale clamps to ≥ 0.05 —
185    /// a degenerate basis silently skips drawing.
186    pub scale: f32,
187    /// End-of-life scale (PS.2): the particle lerps `scale →
188    /// scale_end` over its lifetime — growing smoke, shrinking sparks.
189    /// `None` (default) = constant.
190    pub scale_end: Option<f32>,
191    /// Spin rate about the world vertical, **radians/second**, sampled
192    /// uniformly per particle (PS.2) — a symmetric range like
193    /// `-3.0..3.0` spins debris both ways. Default `0.0..0.0` = no
194    /// spin.
195    pub spin: Range<f32>,
196    /// Fraction of the lifetime over which alpha ramps 0 → 255 at the
197    /// **start** (PS.2) — smoke that condenses in rather than pops.
198    /// `0.0` (default) = born fully opaque.
199    pub fade_in_frac: f32,
200    /// Fraction of the lifetime over which alpha fades 255 → 0 at the
201    /// end (`0.25` = the last quarter). `0.0` = no fade, particles
202    /// vanish at full opacity.
203    pub fade_out_frac: f32,
204    /// Per-particle RGB tint, packed `0x00RRGGBB` (white = no-op).
205    pub tint: Rgb,
206    /// End-of-life tint (PS.2): lerped per channel from `tint` over
207    /// the lifetime — white-hot → red → dark ember. `None` (default) =
208    /// constant.
209    pub tint_end: Option<Rgb>,
210    /// Voxel-material id for every particle (TV palette; `0` opaque).
211    /// Smoke wants an alpha/volumetric material, sparks additive.
212    pub material: u8,
213    /// Shading-normal mode (default [`BillboardLighting::FaceNormal`];
214    /// glowing effects want [`BillboardLighting::FullBright`]).
215    pub lighting: BillboardLighting,
216    /// Shadow participation. Defaults to **neither cast nor receive** —
217    /// hundreds of shadow-casting particles are a perf trap; opt back
218    /// in per effect.
219    pub shadows: ShadowFlags,
220}
221
222impl ParticleEmitterDef {
223    /// A def with every field at its documented default: manual spawn
224    /// at a point at the origin, 1-2 s lifetime, no initial velocity,
225    /// arcade gravity, no drag, constant unit scale, no spin, fade out
226    /// over the last quarter, constant no-op tint, opaque material,
227    /// face-normal lighting, shadows off.
228    #[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/// One live particle — a read-only view for hosts (HUD counters,
258/// custom overlays); the system owns the mutation.
259#[derive(Clone, Copy, Debug)]
260pub struct Particle {
261    /// World position.
262    pub pos: [f32; 3],
263    /// World velocity, units/second.
264    pub vel: [f32; 3],
265    /// Seconds lived so far.
266    pub age: f32,
267    /// Seconds this particle lives in total.
268    pub lifetime: f32,
269    /// Current uniform render scale (lerps `scale → scale_end` when
270    /// the emitter sets an end scale).
271    pub scale: f32,
272    /// Current rotation about the world vertical, radians (PS.2).
273    pub yaw: f32,
274    /// Sampled spin rate, radians/second (PS.2).
275    pub spin_rate: f32,
276    /// Current alpha 0..=255, driven by the emitter's fade curves.
277    pub alpha: u8,
278    /// Current packed `0x00RRGGBB` tint (lerps `tint → tint_end` when
279    /// the emitter sets an end tint).
280    pub tint: Rgb,
281    /// Owning emitter slot — resolves render params (model, material,
282    /// lighting) at sync time.
283    pub(crate) emitter_slot: u32,
284    /// The facade instance backing this particle: `None` until the
285    /// first [`sync`](ParticleSystem::sync) spawns it.
286    pub(crate) instance: Option<SpriteInstanceId>,
287    /// The alpha last written to the facade (which defaults a fresh
288    /// instance to 255) — `sync` calls the per-instance alpha setter
289    /// only when this differs, since alpha has no batch API.
290    pub(crate) last_alpha: u8,
291    /// The tint last written to the facade (fresh-instance default:
292    /// white) — same change-only discipline as `last_alpha`.
293    pub(crate) last_tint: Rgb,
294    /// The tint this particle was born with — the `tint_end` lerp's
295    /// start point. Usually the emitter's `tint`; `carve_debris` seeds
296    /// it with the sampled voxel colour.
297    pub(crate) tint_start: Rgb,
298}
299
300/// Per-emitter live state.
301struct EmitterState {
302    def: ParticleEmitterDef,
303    /// Fractional particles owed by [`SpawnMode::Rate`].
304    spawn_acc: f64,
305    /// Live particles owned by this emitter — a retired emitter's
306    /// state is kept until this drains to 0 (particles read their
307    /// def's gravity/drag every step).
308    live: u32,
309    /// [`ParticleSystem::remove_emitter`] called: stop spawning, free
310    /// the state once `live == 0`.
311    retired: bool,
312}
313
314/// PCG32 (Melissa O'Neill's `pcg32_oneseq`): 64-bit state, 32-bit
315/// output. Deterministic and tiny — same seed + same `dt` sequence ⇒
316/// bit-identical simulation, so effects are golden-testable. Not
317/// cryptographic, deliberately.
318struct 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    /// Uniform in `[0, 1)` with 24 bits of mantissa.
342    fn next_f32(&mut self) -> f32 {
343        (self.next_u32() >> 8) as f32 * (1.0 / (1u32 << 24) as f32)
344    }
345
346    /// Uniform in `[start, end)`; a degenerate range yields `start`.
347    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    /// Uniform direction on the unit sphere (cube-rejection — no
355    /// trig, deterministic step count per accepted sample stream).
356    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
372/// Default [`ParticleSystem`] particle budget.
373pub const DEFAULT_MAX_PARTICLES: usize = 4096;
374
375/// Debris cap per [`ParticleSystem::carve_debris`] call: bigger carves
376/// stride-sample an even spatial subset down to this many particles,
377/// so one large explosion can't monopolise the pool budget.
378pub const CARVE_DEBRIS_CAP: usize = 96;
379
380/// A self-contained particle simulation: emitters, a shared particle
381/// pool with a budget, and a deterministic seeded RNG. Host-owned;
382/// per frame call [`update`](Self::update) (pure simulation, no
383/// renderer) then — from PS.1 — `sync(&mut SceneRenderer)` to mirror
384/// the pool into dynamic sprite instances.
385///
386/// Budget semantics: when the pool is full, **spawns are dropped**
387/// (never evict a live particle — a visible pop);
388/// [`dropped_spawns`](Self::dropped_spawns) makes the cap observable
389/// instead of silent.
390pub struct ParticleSystem {
391    rng: Pcg32,
392    map: EpochSlotMap<EmitterId>,
393    /// Parallel to `map`'s slots; `None` once a retired emitter drains.
394    emitters: Vec<Option<EmitterState>>,
395    particles: Vec<Particle>,
396    /// Instances whose particles died since the last drain —
397    /// [`sync`](Self::sync) removes these from the facade.
398    dead_instances: Vec<SpriteInstanceId>,
399    max_particles: usize,
400    /// Per-[`carve_debris`](Self::carve_debris) debris cap (default
401    /// [`CARVE_DEBRIS_CAP`]).
402    carve_debris_cap: usize,
403    dropped_spawns: u64,
404    stale_model_kills: u64,
405    /// Persistent scratch for the per-frame transform batch (PF
406    /// lesson: no per-frame allocs in the hot loop).
407    xf_scratch: Vec<(SpriteInstanceId, DynSpriteTransform)>,
408}
409
410impl ParticleSystem {
411    /// A system with the given RNG seed and the default budget
412    /// ([`DEFAULT_MAX_PARTICLES`]). Same seed + same call sequence ⇒
413    /// bit-identical simulation.
414    #[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    /// Set the particle budget. Lowering it below the current live
431    /// count kills nothing — it only gates future spawns.
432    pub fn set_max_particles(&mut self, max: usize) {
433        self.max_particles = max;
434    }
435
436    /// Tune how many debris particles one
437    /// [`carve_debris`](Self::carve_debris) call may spawn (default
438    /// [`CARVE_DEBRIS_CAP`]) — the per-explosion load knob. Bigger
439    /// carves stride-sample an even spatial subset down to this;
440    /// clamped to ≥ 1.
441    pub fn set_carve_debris_cap(&mut self, cap: usize) {
442        self.carve_debris_cap = cap.max(1);
443    }
444
445    /// Register an emitter. [`SpawnMode::Burst`] fires immediately.
446    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    /// Retire an emitter: it stops spawning immediately and its handle
466    /// goes stale, but particles already in flight live out their
467    /// lifetimes (the state drains away with the last one). Returns
468    /// `false` on a stale handle.
469    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    /// Move an emitter (attach effects to moving things). Returns
486    /// `false` on a stale handle.
487    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    /// Spawn `n` particles from `id` right now (any [`SpawnMode`]).
497    /// Returns how many actually spawned (budget may drop the rest);
498    /// `0` on a stale handle.
499    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    /// Advance the simulation by `dt` seconds: integrate + age + fade
507    /// every particle, retire the dead (their facade instances queue
508    /// in [`drain_dead_instances`](Self::drain_dead_instances)), then
509    /// run [`SpawnMode::Rate`] emitters. Pure simulation — no facade
510    /// calls, unit-testable without a window or GPU. Never collides;
511    /// for [`CollisionMode`] emitters use
512    /// [`update_with_scene`](Self::update_with_scene).
513    pub fn update(&mut self, dt: f64) {
514        self.step(dt, None);
515    }
516
517    /// [`update`](Self::update) + voxel collision (PS.3): emitters
518    /// with a non-[`None`](CollisionMode::None) [`CollisionMode`] test
519    /// each particle's post-step position against `scene`'s solid
520    /// voxels. See [`CollisionMode`] for the sampling caveats.
521    pub fn update_with_scene(&mut self, dt: f64, scene: &roxlap_scene::Scene) {
522        self.step(dt, Some(scene));
523    }
524
525    /// The shared body of the `update` variants.
526    fn step(&mut self, dt: f64, scene: Option<&roxlap_scene::Scene>) {
527        let dtf = dt.max(0.0) as f32;
528
529        // 1. Age + semi-implicit Euler + fade. Split field borrows:
530        //    defs are read-only here.
531        let emitters = &self.emitters;
532        for p in &mut self.particles {
533            p.age += dtf;
534            if p.age >= p.lifetime {
535                continue; // swept below
536            }
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            // PS.3 — voxel collision at the post-step position (see
547            // `CollisionMode` for the sampling caveats).
548            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; // swept below
555                            continue;
556                        }
557                        CollisionMode::Bounce { restitution } => {
558                            // Reflect the axes whose voxel boundary
559                            // was crossed this step; a same-cell hit
560                            // (spawned against a wall / nudge-early
561                            // contact) reverses outright.
562                            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            // Over-life curves (PS.2), all keyed on the life fraction.
584            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        // 2. Kill sweep (swap-remove keeps the pool dense).
595        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        // 3. Rate spawning (after the sweep, so freed budget is
609        //    available the same frame; newborns keep age 0 and the
610        //    emitter position — their first integration is next frame,
611        //    matching the pre-posed facade spawn).
612        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    /// The live particles, unordered (the pool swap-removes).
635    #[must_use]
636    pub fn particles(&self) -> &[Particle] {
637        &self.particles
638    }
639
640    /// Number of live particles.
641    #[must_use]
642    pub fn particle_count(&self) -> usize {
643        self.particles.len()
644    }
645
646    /// Number of active (non-retired) emitters.
647    #[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    /// Spawns dropped by the budget since construction. A steadily
656    /// climbing value means the effect design outruns
657    /// [`set_max_particles`](Self::set_max_particles).
658    #[must_use]
659    pub fn dropped_spawns(&self) -> u64 {
660        self.dropped_spawns
661    }
662
663    /// Drain the facade instances of particles that died since the
664    /// last drain. [`sync`](Self::sync) removes each via
665    /// [`remove_sprite_instance`](crate::SceneRenderer::remove_sprite_instance);
666    /// hosts doing their own rendering can consume it directly.
667    pub fn drain_dead_instances(&mut self) -> impl Iterator<Item = SpriteInstanceId> + '_ {
668        self.dead_instances.drain(..)
669    }
670
671    /// Newborn spawns that failed because the emitter's
672    /// [`SpriteModelId`] went stale (the model was removed / the
673    /// registry was reset). Each failure kills its particle — a
674    /// climbing value means an emitter outlived its model.
675    #[must_use]
676    pub fn stale_model_kills(&self) -> u64 {
677        self.stale_model_kills
678    }
679
680    /// Mirror the simulation into `renderer` (PS.1): despawn the dead,
681    /// spawn newborns **pre-posed** (the documented streaming-spawn
682    /// path — no one-frame axis-aligned flash) with their one-time
683    /// material/lighting/shadow/tint setup, batch-move everything else
684    /// via one
685    /// [`set_sprite_instance_transforms`](SceneRenderer::set_sprite_instance_transforms),
686    /// and write alpha only for particles whose fade actually changed
687    /// (alpha has no batch API). Call after
688    /// [`update`](Self::update), before
689    /// [`render`](SceneRenderer::render) — or use
690    /// [`tick`](Self::tick).
691    pub fn sync(&mut self, renderer: &mut SceneRenderer) {
692        self.sync_with(renderer);
693    }
694
695    /// [`update`](Self::update) + [`sync`](Self::sync) in one call —
696    /// the per-frame default, named after the facade's own
697    /// [`tick`](SceneRenderer::tick).
698    pub fn tick(&mut self, renderer: &mut SceneRenderer, dt: f64) {
699        self.step(dt, None);
700        self.sync_with(renderer);
701    }
702
703    /// [`update_with_scene`](Self::update_with_scene) +
704    /// [`sync`](Self::sync) — [`tick`](Self::tick) for hosts using
705    /// [`CollisionMode`] emitters. Call before handing the same scene
706    /// to [`render`](SceneRenderer::render).
707    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    /// [`sync`](Self::sync) against the internal facade seam — the
718    /// testable core (see [`ParticleFacade`]).
719    fn sync_with<F: ParticleFacade>(&mut self, facade: &mut F) {
720        // 1. Dead first — frees instance slots before this frame's
721        //    spawns reuse them.
722        for id in self.dead_instances.drain(..) {
723            facade.despawn(id);
724        }
725
726        // 2. One pass: spawn newborns, batch-collect live moves.
727        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                    // Stale model handle — this particle can never
749                    // render; kill it now rather than retry per frame.
750                    self.stale_model_kills += 1;
751                    self.particles.swap_remove(i);
752                    self.on_particle_died(slot);
753                    continue;
754                };
755                // One-time setup, skipping facade defaults (spawn
756                // already left the instance there).
757                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                // Fresh-instance facade defaults; the change-only
769                // blocks below write the real values (tint rides them
770                // too — it can lerp per frame from PS.2 on).
771                p.last_alpha = 255;
772                p.last_tint = Rgb::WHITE;
773            } else {
774                // Live: frame-0 pose came from the posed spawn, so
775                // only pre-existing instances join the move batch.
776                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    /// Carve a sphere out of a grid and burst its **actual voxel
797    /// colours** as debris (PS.5) — the "shoot the wall, the wall's
798    /// colours fly off" effect: sample the solid voxels inside the
799    /// ball, `set_sphere(…, None)` them away, then spawn one
800    /// def-driven debris particle per sampled voxel, positioned at its
801    /// voxel's world centre (transform-correct for rotated grids),
802    /// tinted with its colour, and kicked radially away from the carve
803    /// centre at a speed from `outward` (on top of the def's normal
804    /// velocity terms).
805    ///
806    /// `def` supplies the model, physics, lifetime and curves;
807    /// its `pos`/`shape`/`spawn`/`tint` are ignored (position and tint
808    /// are per-voxel; nothing else auto-spawns — the transient emitter
809    /// retires immediately and drains with its last particle).
810    /// `tint_end` still applies, lerping *from the voxel colour*.
811    ///
812    /// Big carves are stride-sampled down to the system's debris cap
813    /// ([`CARVE_DEBRIS_CAP`] by default —
814    /// [`set_carve_debris_cap`](Self::set_carve_debris_cap) tunes it)
815    /// debris (an even spatial subset, not the first N); the pool
816    /// budget then applies on top, counting overflow in
817    /// [`dropped_spawns`](Self::dropped_spawns). Returns how many
818    /// debris actually spawned. A stale `grid` or an all-air ball
819    /// carves/spawns nothing.
820    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        // 1. Sample the solid voxels' colours before they vanish.
833        #[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        // 2. Carve (even when the cap will drop some debris — the
851        //    crater is the ground truth, debris is garnish).
852        g.set_sphere(centre, radius, None);
853        if samples.is_empty() {
854            return 0;
855        }
856
857        // 3. One transient emitter carries the def; retire-drain frees
858        //    it with the last debris particle.
859        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        // World-space carve centre (voxel centres sit at +0.5).
865        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            // Radial kick away from the carve centre; the centre voxel
882            // itself scatters randomly.
883            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    /// Spawn up to `n` particles from emitter `slot`; returns how many
923    /// fit the budget.
924    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(); // stack-only, cheap
929        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            // Position: emitter pos + the shape's offset (PS.2).
936            let mut pos = def.pos;
937            match def.shape {
938                EmitterShape::Point => {}
939                EmitterShape::Sphere { radius } => {
940                    // Uniform in the ball: direction × r·∛u.
941                    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        // Re-borrow: the RNG borrow above forced dropping `state`.
975        self.emitters[slot]
976            .as_mut()
977            .expect("slot unchanged during spawn")
978            .live += spawned;
979        spawned
980    }
981
982    /// Bookkeeping for one particle death: decrement the emitter's
983    /// live count and free a drained retired emitter.
984    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
995/// The pose a particle renders at: position + a basis rotated by
996/// [`Particle::yaw`] about the world vertical and uniformly scaled by
997/// [`Particle::scale`]. The scale clamps to ≥ 0.05 — a degenerate
998/// basis makes the facade silently skip drawing, which is right for a
999/// kill but wrong for a fade.
1000fn 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
1011/// Sample one initial velocity from a [`VelocityDef`]: base +
1012/// isotropic spread + cone, by addition.
1013fn 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
1032/// Uniform random direction within `half_angle_deg` of `axis` —
1033/// uniform over the spherical cap, so a wide cone doesn't bunch at the
1034/// rim. A degenerate axis falls back to straight up (`[0, 0, -1]`).
1035fn 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    // cos θ uniform on [cos half, 1] ⇒ uniform area on the cap.
1044    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    // Any orthonormal frame around `w`: cross with the axis `w` leans
1049    // on least.
1050    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
1077/// Whether a solid voxel sits at `pos` nudged half a voxel along
1078/// `vel` — [`Scene::resolve_voxel`]'s picking nudge doing collision
1079/// look-ahead duty. Zero velocity returns `false` (a resting particle
1080/// never re-collides).
1081///
1082/// [`Scene::resolve_voxel`]: roxlap_scene::Scene::resolve_voxel
1083fn 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
1089/// Per-channel lerp of two [`Rgb`] tints.
1090fn 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
1100/// The slice of [`SceneRenderer`] that [`ParticleSystem::sync`]
1101/// drives — an internal seam so the binding logic (spawn ordering,
1102/// one-time setup, change-only alpha writes) is unit-testable with a
1103/// mock, since constructing a real backend needs a window. The facade
1104/// impl is pure forwarding.
1105pub(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        // A stale handle here means the host reset the registry
1122        // (`set_sprites`) — already gone, nothing owed.
1123        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
1145/// Alpha for `age` of `lifetime`: ramps 0 → 255 over the leading
1146/// `in_frac` (PS.2), 255 → 0 over the trailing `out_frac`, 255 in
1147/// between; overlapping windows take the darker of the two.
1148fn 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    /// A model handle for tests. The sim core never dereferences it —
1165    /// only `sync` (PS.1) resolves models — so a minted dummy is fine.
1166    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        // Sub-particle rates accumulate: 0.5/s over 2 s = 1 particle.
1226        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); // age 0.6 ≥ 0.5
1269        assert_eq!(sys.particle_count(), 0);
1270        // Never synced ⇒ no facade instances owed.
1271        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        // vel += g·dt first, then pos += vel·dt.
1286        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); // window edge
1294        assert_eq!(fade_alpha(0.75, 1.0, 0.0, 0.5), 127); // mid-fade
1295        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); // no fade
1297                                                          // Fade-in (PS.2): born dark, ramps up, then the out-window
1298                                                          // takes over; overlap picks the darker.
1299        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); // crossover
1303    }
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; // uses the wide axis
1338            }
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]; // non-unit on purpose
1347        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        // A zero axis falls back to straight up (-z).
1375        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); // yaw = 0.5 rad
1392        let p = sys.particles()[0];
1393        assert!((p.yaw - 0.5).abs() < 1e-6);
1394        let xf = particle_xf(&p);
1395        // Columns stay orthogonal with length == scale.
1396        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        // sync writes the lerping tint every frame it changes.
1427        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        // Stale-handle ops are safe no-ops.
1448        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        // In-flight particles live out their lifetime, then the slot
1452        // frees.
1453        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    /// Records every facade call `sync` makes; mints sequential ids.
1461    #[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        // Newborns spawn pre-posed — no move batch for them.
1527        assert_eq!(f.spawns.len(), 3);
1528        assert!(f.batch_sizes.is_empty());
1529        // Particle shadows default off ≠ facade default (cast+receive)
1530        // ⇒ set once each; everything else is at facade defaults.
1531        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        // Next frame: no new spawns, one batch of 3.
1536        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(), // back to facade default
1551            ..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        // Re-sync repeats none of it: still one call per family.
1561        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        // Pre-fade window: alpha stays 255, no writes.
1579        sys.update(0.25);
1580        sys.sync_with(&mut f);
1581        assert!(f.alphas.is_empty());
1582
1583        // Inside the fade window: one write per particle per change.
1584        sys.update(0.5); // age 0.75 ⇒ alpha 127
1585        sys.sync_with(&mut f);
1586        assert_eq!(f.alphas.len(), 2);
1587        assert!(f.alphas.iter().all(|&(_, a)| a == 127));
1588
1589        // Death ⇒ despawn of exactly the minted ids.
1590        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        // The emitter's live count drained cleanly: removing it frees
1609        // the slot immediately.
1610        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        // Degenerate scale clamps to the visible minimum.
1636        let tiny = particle_xf(&Particle { scale: 0.0, ..p });
1637        assert_eq!(tiny.right[0], 0.05);
1638    }
1639
1640    /// A scene with a solid slab: z ∈ 10..=12 across generous xy.
1641    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    /// An emitter dropping one particle straight down (+z) at the slab.
1655    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() // zero gravity — constant fall speed
1665        }
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        // The scene-free update never collides, whatever the mode.
1680        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        // A dedicated slab in one recognisable colour.
1730        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        // Every debris particle wears the sampled voxel colour.
1753        for p in sys.particles() {
1754            assert_eq!(p.tint, Rgb(0x0012_3456), "tint is the voxel colour");
1755        }
1756        // The ball is actually gone from the grid.
1757        let g = scene.grid_mut(grid).expect("grid");
1758        assert!(g.voxel_color(centre).is_none());
1759        // Radial kick: particles off-centre move away from the centre.
1760        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        // The transient emitter retired; it drains with its debris.
1769        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        // Radius 6 ball in a 3-thick slab ≫ the cap.
1782        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        // A lowered per-system cap throttles the same carve.
1800        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        // Pool budget on top: a tiny pool drops the rest, counted.
1818        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        // Slab colour 0x00FF_FFFF darkening toward black: mid ≈ 127.
1857        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            // Short life ⇒ the u8 fade actually steps every frame (a
1873            // 100 s life would quantise alpha to one write per ~12
1874            // frames); 2 s > the 30 simulated frames, so none die.
1875            lifetime: 2.0..2.0,
1876            velocity: VelocityDef {
1877                spread: 10.0,
1878                ..VelocityDef::default()
1879            },
1880            // Fade the whole life ⇒ alpha changes (and syncs) every frame.
1881            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        // Every later frame batch-moves all 10k and rewrites the
1898        // churning alpha/tint per instance.
1899        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    /// Manual perf probe (release): `cargo test -p roxlap-render
1904    /// --release stress_10k_probe -- --ignored --nocapture`.
1905    #[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            // 8 s life: the fade steps ~every frame across the 200
1912            // timed frames (worst-case alpha churn) and nothing dies.
1913            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); // spawn frame excluded from timing
1928        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}