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    /// Leak-gate introspection (DT.3): `true` when every emitter slot
656    /// has been reclaimed — the strict form of [`Self::emitter_count`]
657    /// `== 0`, which ignores retired-but-not-yet-drained slots (a
658    /// transient `voxel_debris` emitter lingers until its last
659    /// particle dies). Test-only: the debris leak gate lives in a
660    /// sibling module, so it cannot reach `emitters` directly.
661    #[cfg(test)]
662    pub(crate) fn emitter_slots_all_free(&self) -> bool {
663        self.emitters.iter().all(Option::is_none)
664    }
665
666    /// Spawns dropped by the budget since construction. A steadily
667    /// climbing value means the effect design outruns
668    /// [`set_max_particles`](Self::set_max_particles).
669    #[must_use]
670    pub fn dropped_spawns(&self) -> u64 {
671        self.dropped_spawns
672    }
673
674    /// Drain the facade instances of particles that died since the
675    /// last drain. [`sync`](Self::sync) removes each via
676    /// [`remove_sprite_instance`](crate::SceneRenderer::remove_sprite_instance);
677    /// hosts doing their own rendering can consume it directly.
678    pub fn drain_dead_instances(&mut self) -> impl Iterator<Item = SpriteInstanceId> + '_ {
679        self.dead_instances.drain(..)
680    }
681
682    /// Newborn spawns that failed because the emitter's
683    /// [`SpriteModelId`] went stale (the model was removed / the
684    /// registry was reset). Each failure kills its particle — a
685    /// climbing value means an emitter outlived its model.
686    #[must_use]
687    pub fn stale_model_kills(&self) -> u64 {
688        self.stale_model_kills
689    }
690
691    /// Mirror the simulation into `renderer` (PS.1): despawn the dead,
692    /// spawn newborns **pre-posed** (the documented streaming-spawn
693    /// path — no one-frame axis-aligned flash) with their one-time
694    /// material/lighting/shadow/tint setup, batch-move everything else
695    /// via one
696    /// [`set_sprite_instance_transforms`](SceneRenderer::set_sprite_instance_transforms),
697    /// and write alpha only for particles whose fade actually changed
698    /// (alpha has no batch API). Call after
699    /// [`update`](Self::update), before
700    /// [`render`](SceneRenderer::render) — or use
701    /// [`tick`](Self::tick).
702    pub fn sync(&mut self, renderer: &mut SceneRenderer) {
703        self.sync_with(renderer);
704    }
705
706    /// [`update`](Self::update) + [`sync`](Self::sync) in one call —
707    /// the per-frame default, named after the facade's own
708    /// [`tick`](SceneRenderer::tick).
709    pub fn tick(&mut self, renderer: &mut SceneRenderer, dt: f64) {
710        self.step(dt, None);
711        self.sync_with(renderer);
712    }
713
714    /// [`update_with_scene`](Self::update_with_scene) +
715    /// [`sync`](Self::sync) — [`tick`](Self::tick) for hosts using
716    /// [`CollisionMode`] emitters. Call before handing the same scene
717    /// to [`render`](SceneRenderer::render).
718    pub fn tick_with_scene(
719        &mut self,
720        renderer: &mut SceneRenderer,
721        dt: f64,
722        scene: &roxlap_scene::Scene,
723    ) {
724        self.step(dt, Some(scene));
725        self.sync_with(renderer);
726    }
727
728    /// [`sync`](Self::sync) against the internal facade seam — the
729    /// testable core (see [`ParticleFacade`]).
730    fn sync_with<F: ParticleFacade>(&mut self, facade: &mut F) {
731        // 1. Dead first — frees instance slots before this frame's
732        //    spawns reuse them.
733        for id in self.dead_instances.drain(..) {
734            facade.despawn(id);
735        }
736
737        // 2. One pass: spawn newborns, batch-collect live moves.
738        let mut batch = std::mem::take(&mut self.xf_scratch);
739        batch.clear();
740        let mut i = 0;
741        while i < self.particles.len() {
742            if self.particles[i].instance.is_none() {
743                let (slot, xf) = {
744                    let p = &self.particles[i];
745                    (p.emitter_slot as usize, particle_xf(p))
746                };
747                let (model, material, lighting, shadows) = {
748                    let st = self.emitters[slot]
749                        .as_ref()
750                        .expect("live particle ⇒ emitter state retained");
751                    (
752                        st.def.model,
753                        st.def.material,
754                        st.def.lighting,
755                        st.def.shadows,
756                    )
757                };
758                let Some(id) = facade.spawn(model, xf) else {
759                    // Stale model handle — this particle can never
760                    // render; kill it now rather than retry per frame.
761                    self.stale_model_kills += 1;
762                    self.particles.swap_remove(i);
763                    self.on_particle_died(slot);
764                    continue;
765                };
766                // One-time setup, skipping facade defaults (spawn
767                // already left the instance there).
768                if material != 0 {
769                    facade.set_material(id, material);
770                }
771                if lighting != BillboardLighting::default() {
772                    facade.set_lighting(id, lighting);
773                }
774                if shadows != ShadowFlags::default() {
775                    facade.set_shadows(id, shadows);
776                }
777                let p = &mut self.particles[i];
778                p.instance = Some(id);
779                // Fresh-instance facade defaults; the change-only
780                // blocks below write the real values (tint rides them
781                // too — it can lerp per frame from PS.2 on).
782                p.last_alpha = 255;
783                p.last_tint = Rgb::WHITE;
784            } else {
785                // Live: frame-0 pose came from the posed spawn, so
786                // only pre-existing instances join the move batch.
787                let p = &self.particles[i];
788                batch.push((p.instance.expect("checked above"), particle_xf(p)));
789            }
790            let p = &mut self.particles[i];
791            if p.alpha != p.last_alpha {
792                facade.set_alpha(p.instance.expect("set above"), p.alpha);
793                p.last_alpha = p.alpha;
794            }
795            if p.tint != p.last_tint {
796                facade.set_tint(p.instance.expect("set above"), p.tint);
797                p.last_tint = p.tint;
798            }
799            i += 1;
800        }
801        if !batch.is_empty() {
802            facade.set_transforms(&batch);
803        }
804        self.xf_scratch = batch;
805    }
806
807    /// Carve a sphere out of a grid and burst its **actual voxel
808    /// colours** as debris (PS.5) — the "shoot the wall, the wall's
809    /// colours fly off" effect: sample the solid voxels inside the
810    /// ball, `set_sphere(…, None)` them away, then spawn one
811    /// def-driven debris particle per sampled voxel, positioned at its
812    /// voxel's world centre (transform-correct for rotated grids),
813    /// tinted with its colour, and kicked radially away from the carve
814    /// centre at a speed from `outward` (on top of the def's normal
815    /// velocity terms).
816    ///
817    /// `def` supplies the model, physics, lifetime and curves;
818    /// its `pos`/`shape`/`spawn`/`tint` are ignored (position and tint
819    /// are per-voxel; nothing else auto-spawns — the transient emitter
820    /// retires immediately and drains with its last particle).
821    /// `tint_end` still applies, lerping *from the voxel colour*.
822    ///
823    /// Big carves are stride-sampled down to the system's debris cap
824    /// ([`CARVE_DEBRIS_CAP`] by default —
825    /// [`set_carve_debris_cap`](Self::set_carve_debris_cap) tunes it)
826    /// debris (an even spatial subset, not the first N); the pool
827    /// budget then applies on top, counting overflow in
828    /// [`dropped_spawns`](Self::dropped_spawns). Returns how many
829    /// debris actually spawned. A stale `grid` or an all-air ball
830    /// carves/spawns nothing.
831    pub fn carve_debris(
832        &mut self,
833        scene: &mut roxlap_scene::Scene,
834        grid: roxlap_scene::GridId,
835        centre: glam::IVec3,
836        radius: u32,
837        outward: Range<f32>,
838        def: &ParticleEmitterDef,
839    ) -> u32 {
840        let Some(g) = scene.grid_mut(grid) else {
841            return 0;
842        };
843        // 1. Sample the solid voxels' colours before they vanish.
844        #[allow(clippy::cast_possible_wrap)]
845        let r = radius as i32;
846        let mut samples: Vec<(glam::IVec3, roxlap_formats::VoxColor)> = Vec::new();
847        for z in -r..=r {
848            for y in -r..=r {
849                for x in -r..=r {
850                    if x * x + y * y + z * z > r * r {
851                        continue;
852                    }
853                    let v = centre + glam::IVec3::new(x, y, z);
854                    if let Some(col) = g.voxel_color(v) {
855                        samples.push((v, col));
856                    }
857                }
858            }
859        }
860        let transform = g.transform;
861        // 2. Carve (even when the cap will drop some debris — the
862        //    crater is the ground truth, debris is garnish).
863        g.set_sphere(centre, radius, None);
864
865        // 3. Burst the sampled colours (DT.3 factored this half out so
866        //    a landed island can shatter without a carve). Stride
867        //    BEFORE the world conversion so a big carve never converts
868        //    samples the cap would drop anyway; the pre-strided list is
869        //    ≤ cap, so `voxel_debris`'s own stride resolves to 1 and
870        //    the spawn sequence is byte-identical to the unfactored
871        //    original.
872        let to_world = |v: glam::IVec3| -> [f32; 3] {
873            let w =
874                transform.origin + transform.rotation * (v.as_dvec3() + glam::DVec3::splat(0.5));
875            #[allow(clippy::cast_possible_truncation)]
876            [w.x as f32, w.y as f32, w.z as f32]
877        };
878        let stride = samples.len().div_ceil(self.carve_debris_cap).max(1);
879        let sites: Vec<([f32; 3], Rgb)> = samples
880            .iter()
881            .step_by(stride)
882            .map(|&(v, col)| (to_world(v), col.rgb_part()))
883            .collect();
884        self.voxel_debris(&sites, to_world(centre), outward, def)
885    }
886
887    /// Burst a set of **world-space voxel sites** as debris — the
888    /// spawn half of [`carve_debris`](Self::carve_debris), factored
889    /// out (DT.3) so a landed island's voxels can shatter without a
890    /// carve: one def-driven particle per site, positioned there,
891    /// tinted with the site's colour, kicked radially away from
892    /// `from` at a speed from `outward` (a site at `from` itself
893    /// scatters in a random direction).
894    ///
895    /// Same contract as `carve_debris` (which is now this on top of a
896    /// sphere sample+carve): `def`'s `pos`/`shape`/`spawn`/`tint` are
897    /// ignored, `tint_end` lerps from the site colour, big sets are
898    /// stride-sampled to the debris cap, the pool budget counts
899    /// overflow in [`dropped_spawns`](Self::dropped_spawns). Returns
900    /// how many debris actually spawned.
901    pub fn voxel_debris(
902        &mut self,
903        sites: &[([f32; 3], Rgb)],
904        from: [f32; 3],
905        outward: Range<f32>,
906        def: &ParticleEmitterDef,
907    ) -> u32 {
908        if sites.is_empty() {
909            return 0;
910        }
911        // One transient emitter carries the def; retire-drain frees
912        // it with the last debris particle.
913        let mut def = def.clone();
914        def.spawn = SpawnMode::Manual;
915        let id = self.add_emitter(def.clone());
916        let slot = self.map.index(id).expect("just allocated");
917
918        let stride = sites.len().div_ceil(self.carve_debris_cap).max(1);
919        let mut spawned: u32 = 0;
920        for (pos, col) in sites.iter().step_by(stride) {
921            if self.particles.len() >= self.max_particles {
922                self.dropped_spawns += 1;
923                continue;
924            }
925            let pos = *pos;
926            // Radial kick away from the burst origin; a site at the
927            // origin itself scatters randomly.
928            let d = [pos[0] - from[0], pos[1] - from[1], pos[2] - from[2]];
929            let len = (d[0] * d[0] + d[1] * d[1] + d[2] * d[2]).sqrt();
930            let dir = if len > 1e-4 {
931                [d[0] / len, d[1] / len, d[2] / len]
932            } else {
933                self.rng.unit_vec()
934            };
935            let mut vel = sample_velocity(&mut self.rng, &def.velocity);
936            let speed = self.rng.range_f32(&outward);
937            for a in 0..3 {
938                vel[a] += dir[a] * speed;
939            }
940            let lifetime = self.rng.range_f32(&def.lifetime).max(1e-3);
941            self.particles.push(Particle {
942                pos,
943                vel,
944                age: 0.0,
945                lifetime,
946                scale: def.scale,
947                yaw: 0.0,
948                spin_rate: self.rng.range_f32(&def.spin),
949                alpha: fade_alpha(0.0, lifetime, def.fade_in_frac, def.fade_out_frac),
950                tint: *col,
951                emitter_slot: slot as u32,
952                instance: None,
953                last_alpha: 255,
954                last_tint: Rgb::WHITE,
955                tint_start: *col,
956            });
957            spawned += 1;
958        }
959        self.emitters[slot]
960            .as_mut()
961            .expect("slot allocated above")
962            .live += spawned;
963        self.remove_emitter(id);
964        spawned
965    }
966
967    /// Spawn up to `n` particles from emitter `slot`; returns how many
968    /// fit the budget.
969    fn spawn_from(&mut self, slot: usize, n: u32) -> u32 {
970        let state = self.emitters[slot]
971            .as_mut()
972            .expect("spawn_from callers hold a live slot");
973        let def = state.def.clone(); // stack-only, cheap
974        let mut spawned = 0;
975        for _ in 0..n {
976            if self.particles.len() >= self.max_particles {
977                self.dropped_spawns += u64::from(n - spawned);
978                break;
979            }
980            // Position: emitter pos + the shape's offset (PS.2).
981            let mut pos = def.pos;
982            match def.shape {
983                EmitterShape::Point => {}
984                EmitterShape::Sphere { radius } => {
985                    // Uniform in the ball: direction × r·∛u.
986                    let dir = self.rng.unit_vec();
987                    let r = radius * self.rng.next_f32().cbrt();
988                    for a in 0..3 {
989                        pos[a] += dir[a] * r;
990                    }
991                }
992                EmitterShape::Box { half } => {
993                    for a in 0..3 {
994                        pos[a] += (self.rng.next_f32() * 2.0 - 1.0) * half[a];
995                    }
996                }
997            }
998
999            let vel = sample_velocity(&mut self.rng, &def.velocity);
1000            let lifetime = self.rng.range_f32(&def.lifetime).max(1e-3);
1001            self.particles.push(Particle {
1002                pos,
1003                vel,
1004                age: 0.0,
1005                lifetime,
1006                scale: def.scale,
1007                yaw: 0.0,
1008                spin_rate: self.rng.range_f32(&def.spin),
1009                alpha: fade_alpha(0.0, lifetime, def.fade_in_frac, def.fade_out_frac),
1010                tint: def.tint,
1011                emitter_slot: slot as u32,
1012                instance: None,
1013                last_alpha: 255,
1014                last_tint: Rgb::WHITE,
1015                tint_start: def.tint,
1016            });
1017            spawned += 1;
1018        }
1019        // Re-borrow: the RNG borrow above forced dropping `state`.
1020        self.emitters[slot]
1021            .as_mut()
1022            .expect("slot unchanged during spawn")
1023            .live += spawned;
1024        spawned
1025    }
1026
1027    /// Bookkeeping for one particle death: decrement the emitter's
1028    /// live count and free a drained retired emitter.
1029    fn on_particle_died(&mut self, slot: usize) {
1030        let state = self.emitters[slot]
1031            .as_mut()
1032            .expect("live particle ⇒ emitter state retained");
1033        state.live -= 1;
1034        if state.retired && state.live == 0 {
1035            self.emitters[slot] = None;
1036        }
1037    }
1038}
1039
1040/// The pose a particle renders at: position + a basis rotated by
1041/// [`Particle::yaw`] about the world vertical and uniformly scaled by
1042/// [`Particle::scale`]. The scale clamps to ≥ 0.05 — a degenerate
1043/// basis makes the facade silently skip drawing, which is right for a
1044/// kill but wrong for a fade.
1045fn particle_xf(p: &Particle) -> DynSpriteTransform {
1046    let k = p.scale.max(0.05);
1047    let (c, s) = (p.yaw.cos() * k, p.yaw.sin() * k);
1048    DynSpriteTransform {
1049        pos: p.pos,
1050        right: [c, s, 0.0],
1051        up: [-s, c, 0.0],
1052        forward: [0.0, 0.0, k],
1053    }
1054}
1055
1056/// Sample one initial velocity from a [`VelocityDef`]: base +
1057/// isotropic spread + cone, by addition.
1058fn sample_velocity(rng: &mut Pcg32, v: &VelocityDef) -> [f32; 3] {
1059    let mut vel = v.base;
1060    if v.spread > 0.0 {
1061        let dir = rng.unit_vec();
1062        let speed = rng.next_f32() * v.spread;
1063        for a in 0..3 {
1064            vel[a] += dir[a] * speed;
1065        }
1066    }
1067    if let Some(cone) = &v.cone {
1068        let dir = cone_dir(rng, cone.axis, cone.half_angle_deg);
1069        let speed = rng.range_f32(&cone.speed);
1070        for a in 0..3 {
1071            vel[a] += dir[a] * speed;
1072        }
1073    }
1074    vel
1075}
1076
1077/// Uniform random direction within `half_angle_deg` of `axis` —
1078/// uniform over the spherical cap, so a wide cone doesn't bunch at the
1079/// rim. A degenerate axis falls back to straight up (`[0, 0, -1]`).
1080fn cone_dir(rng: &mut Pcg32, axis: [f32; 3], half_angle_deg: f32) -> [f32; 3] {
1081    let len2 = axis[0] * axis[0] + axis[1] * axis[1] + axis[2] * axis[2];
1082    let w = if len2 > 1e-12 {
1083        let inv = 1.0 / len2.sqrt();
1084        [axis[0] * inv, axis[1] * inv, axis[2] * inv]
1085    } else {
1086        [0.0, 0.0, -1.0]
1087    };
1088    // cos θ uniform on [cos half, 1] ⇒ uniform area on the cap.
1089    let cos_half = half_angle_deg.clamp(0.0, 180.0).to_radians().cos();
1090    let cz = 1.0 - rng.next_f32() * (1.0 - cos_half);
1091    let sz = (1.0 - cz * cz).max(0.0).sqrt();
1092    let phi = rng.next_f32() * std::f32::consts::TAU;
1093    // Any orthonormal frame around `w`: cross with the axis `w` leans
1094    // on least.
1095    let t = if w[0].abs() < 0.5 {
1096        [1.0, 0.0, 0.0]
1097    } else {
1098        [0.0, 1.0, 0.0]
1099    };
1100    let u = {
1101        let c = [
1102            w[1] * t[2] - w[2] * t[1],
1103            w[2] * t[0] - w[0] * t[2],
1104            w[0] * t[1] - w[1] * t[0],
1105        ];
1106        let inv = 1.0 / (c[0] * c[0] + c[1] * c[1] + c[2] * c[2]).sqrt();
1107        [c[0] * inv, c[1] * inv, c[2] * inv]
1108    };
1109    let v = [
1110        w[1] * u[2] - w[2] * u[1],
1111        w[2] * u[0] - w[0] * u[2],
1112        w[0] * u[1] - w[1] * u[0],
1113    ];
1114    let (cp, sp) = (phi.cos() * sz, phi.sin() * sz);
1115    [
1116        w[0] * cz + u[0] * cp + v[0] * sp,
1117        w[1] * cz + u[1] * cp + v[1] * sp,
1118        w[2] * cz + u[2] * cp + v[2] * sp,
1119    ]
1120}
1121
1122/// Whether a solid voxel sits at `pos` nudged half a voxel along
1123/// `vel` — [`Scene::resolve_voxel`]'s picking nudge doing collision
1124/// look-ahead duty. Zero velocity returns `false` (a resting particle
1125/// never re-collides).
1126///
1127/// [`Scene::resolve_voxel`]: roxlap_scene::Scene::resolve_voxel
1128fn scene_solid_ahead(scene: &roxlap_scene::Scene, pos: [f32; 3], vel: [f32; 3]) -> bool {
1129    let world = glam::DVec3::new(f64::from(pos[0]), f64::from(pos[1]), f64::from(pos[2]));
1130    let dir = glam::DVec3::new(f64::from(vel[0]), f64::from(vel[1]), f64::from(vel[2]));
1131    scene.resolve_voxel(world, dir).is_some()
1132}
1133
1134/// Per-channel lerp of two [`Rgb`] tints.
1135fn lerp_tint(a: Rgb, b: Rgb, t: f32) -> Rgb {
1136    let t = t.clamp(0.0, 1.0);
1137    let ch = |sh: u32| {
1138        let (ca, cb) = ((a.0 >> sh) & 0xff, (b.0 >> sh) & 0xff);
1139        let m = ca as f32 + (cb as f32 - ca as f32) * t;
1140        ((m as u32) & 0xff) << sh
1141    };
1142    Rgb(ch(16) | ch(8) | ch(0))
1143}
1144
1145/// The slice of [`SceneRenderer`] that [`ParticleSystem::sync`]
1146/// drives — an internal seam so the binding logic (spawn ordering,
1147/// one-time setup, change-only alpha writes) is unit-testable with a
1148/// mock, since constructing a real backend needs a window. The facade
1149/// impl is pure forwarding.
1150pub(crate) trait ParticleFacade {
1151    fn spawn(&mut self, model: SpriteModelId, xf: DynSpriteTransform) -> Option<SpriteInstanceId>;
1152    fn despawn(&mut self, id: SpriteInstanceId);
1153    fn set_transforms(&mut self, batch: &[(SpriteInstanceId, DynSpriteTransform)]);
1154    fn set_alpha(&mut self, id: SpriteInstanceId, alpha: u8);
1155    fn set_tint(&mut self, id: SpriteInstanceId, tint: Rgb);
1156    fn set_material(&mut self, id: SpriteInstanceId, material: u8);
1157    fn set_lighting(&mut self, id: SpriteInstanceId, mode: BillboardLighting);
1158    fn set_shadows(&mut self, id: SpriteInstanceId, flags: ShadowFlags);
1159}
1160
1161impl ParticleFacade for SceneRenderer {
1162    fn spawn(&mut self, model: SpriteModelId, xf: DynSpriteTransform) -> Option<SpriteInstanceId> {
1163        self.add_sprite_instance_posed(model, xf)
1164    }
1165    fn despawn(&mut self, id: SpriteInstanceId) {
1166        // A stale handle here means the host reset the registry
1167        // (`set_sprites`) — already gone, nothing owed.
1168        self.remove_sprite_instance(id);
1169    }
1170    fn set_transforms(&mut self, batch: &[(SpriteInstanceId, DynSpriteTransform)]) {
1171        self.set_sprite_instance_transforms(batch);
1172    }
1173    fn set_alpha(&mut self, id: SpriteInstanceId, alpha: u8) {
1174        self.set_sprite_instance_alpha(id, alpha);
1175    }
1176    fn set_tint(&mut self, id: SpriteInstanceId, tint: Rgb) {
1177        self.set_sprite_instance_tint(id, tint);
1178    }
1179    fn set_material(&mut self, id: SpriteInstanceId, material: u8) {
1180        self.set_sprite_instance_material(id, material);
1181    }
1182    fn set_lighting(&mut self, id: SpriteInstanceId, mode: BillboardLighting) {
1183        self.set_sprite_instance_lighting(id, mode);
1184    }
1185    fn set_shadows(&mut self, id: SpriteInstanceId, flags: ShadowFlags) {
1186        self.set_sprite_instance_shadow_flags(id, flags);
1187    }
1188}
1189
1190/// Alpha for `age` of `lifetime`: ramps 0 → 255 over the leading
1191/// `in_frac` (PS.2), 255 → 0 over the trailing `out_frac`, 255 in
1192/// between; overlapping windows take the darker of the two.
1193fn fade_alpha(age: f32, lifetime: f32, in_frac: f32, out_frac: f32) -> u8 {
1194    let frac = age / lifetime;
1195    let mut a = 1.0_f32;
1196    if in_frac > 0.0 {
1197        a = a.min((frac / in_frac.min(1.0)).clamp(0.0, 1.0));
1198    }
1199    if out_frac > 0.0 {
1200        a = a.min(((1.0 - frac) / out_frac.min(1.0)).clamp(0.0, 1.0));
1201    }
1202    (a * 255.0) as u8
1203}
1204
1205#[cfg(test)]
1206mod tests {
1207    use super::*;
1208
1209    /// A model handle for tests. The sim core never dereferences it —
1210    /// only `sync` (PS.1) resolves models — so a minted dummy is fine.
1211    fn dummy_model() -> SpriteModelId {
1212        SpriteModelId::mint(0, 0)
1213    }
1214
1215    fn base_def() -> ParticleEmitterDef {
1216        ParticleEmitterDef {
1217            spawn: SpawnMode::Manual,
1218            lifetime: 1.0..1.0,
1219            gravity: [0.0, 0.0, 0.0],
1220            fade_out_frac: 0.0,
1221            ..ParticleEmitterDef::new(dummy_model())
1222        }
1223    }
1224
1225    #[test]
1226    fn same_seed_is_bit_identical() {
1227        let run = || {
1228            let mut sys = ParticleSystem::new(0x00C0_FFEE);
1229            let em = sys.add_emitter(ParticleEmitterDef {
1230                spawn: SpawnMode::Rate(120.0),
1231                lifetime: 0.3..0.9,
1232                velocity: VelocityDef {
1233                    base: [0.0, 0.0, -10.0],
1234                    spread: 4.0,
1235                    ..VelocityDef::default()
1236                },
1237                gravity: [0.0, 0.0, 22.0],
1238                ..ParticleEmitterDef::new(dummy_model())
1239            });
1240            sys.burst(em, 7);
1241            for _ in 0..60 {
1242                sys.update(1.0 / 60.0);
1243            }
1244            sys.particles()
1245                .iter()
1246                .map(|p| (p.pos, p.vel, p.age, p.lifetime))
1247                .collect::<Vec<_>>()
1248        };
1249        let (a, b) = (run(), run());
1250        assert_eq!(a.len(), b.len());
1251        for (pa, pb) in a.iter().zip(&b) {
1252            assert_eq!(pa, pb, "same seed must be bit-identical");
1253        }
1254        assert!(!a.is_empty());
1255    }
1256
1257    #[test]
1258    fn rate_accumulates_across_frames() {
1259        let mut sys = ParticleSystem::new(1);
1260        sys.add_emitter(ParticleEmitterDef {
1261            spawn: SpawnMode::Rate(10.0),
1262            lifetime: 100.0..100.0,
1263            ..base_def()
1264        });
1265        for _ in 0..10 {
1266            sys.update(0.1);
1267        }
1268        assert_eq!(sys.particle_count(), 10);
1269
1270        // Sub-particle rates accumulate: 0.5/s over 2 s = 1 particle.
1271        let mut slow = ParticleSystem::new(2);
1272        slow.add_emitter(ParticleEmitterDef {
1273            spawn: SpawnMode::Rate(0.5),
1274            lifetime: 100.0..100.0,
1275            ..base_def()
1276        });
1277        for _ in 0..20 {
1278            slow.update(0.1);
1279        }
1280        assert_eq!(slow.particle_count(), 1);
1281    }
1282
1283    #[test]
1284    fn burst_mode_fires_on_add() {
1285        let mut sys = ParticleSystem::new(3);
1286        sys.add_emitter(ParticleEmitterDef {
1287            spawn: SpawnMode::Burst(5),
1288            ..base_def()
1289        });
1290        assert_eq!(sys.particle_count(), 5);
1291    }
1292
1293    #[test]
1294    fn budget_drops_spawns_and_counts_them() {
1295        let mut sys = ParticleSystem::new(4);
1296        sys.set_max_particles(5);
1297        let em = sys.add_emitter(base_def());
1298        assert_eq!(sys.burst(em, 10), 5);
1299        assert_eq!(sys.particle_count(), 5);
1300        assert_eq!(sys.dropped_spawns(), 5);
1301    }
1302
1303    #[test]
1304    fn particles_die_at_lifetime() {
1305        let mut sys = ParticleSystem::new(5);
1306        let em = sys.add_emitter(ParticleEmitterDef {
1307            lifetime: 0.5..0.5,
1308            ..base_def()
1309        });
1310        sys.burst(em, 3);
1311        sys.update(0.3);
1312        assert_eq!(sys.particle_count(), 3);
1313        sys.update(0.3); // age 0.6 ≥ 0.5
1314        assert_eq!(sys.particle_count(), 0);
1315        // Never synced ⇒ no facade instances owed.
1316        assert_eq!(sys.drain_dead_instances().count(), 0);
1317    }
1318
1319    #[test]
1320    fn semi_implicit_euler_gravity() {
1321        let mut sys = ParticleSystem::new(6);
1322        let em = sys.add_emitter(ParticleEmitterDef {
1323            gravity: [0.0, 0.0, 10.0],
1324            lifetime: 100.0..100.0,
1325            ..base_def()
1326        });
1327        sys.burst(em, 1);
1328        sys.update(0.1);
1329        let p = sys.particles()[0];
1330        // vel += g·dt first, then pos += vel·dt.
1331        assert!((p.vel[2] - 1.0).abs() < 1e-6);
1332        assert!((p.pos[2] - 0.1).abs() < 1e-6);
1333    }
1334
1335    #[test]
1336    fn fade_curve_hits_endpoints() {
1337        assert_eq!(fade_alpha(0.0, 1.0, 0.0, 0.5), 255);
1338        assert_eq!(fade_alpha(0.5, 1.0, 0.0, 0.5), 255); // window edge
1339        assert_eq!(fade_alpha(0.75, 1.0, 0.0, 0.5), 127); // mid-fade
1340        assert_eq!(fade_alpha(1.0, 1.0, 0.0, 0.5), 0);
1341        assert_eq!(fade_alpha(0.99, 1.0, 0.0, 0.0), 255); // no fade
1342                                                          // Fade-in (PS.2): born dark, ramps up, then the out-window
1343                                                          // takes over; overlap picks the darker.
1344        assert_eq!(fade_alpha(0.0, 1.0, 0.25, 0.0), 0);
1345        assert_eq!(fade_alpha(0.125, 1.0, 0.25, 0.0), 127);
1346        assert_eq!(fade_alpha(0.25, 1.0, 0.25, 0.0), 255);
1347        assert_eq!(fade_alpha(0.5, 1.0, 1.0, 1.0), 127); // crossover
1348    }
1349
1350    #[test]
1351    fn shapes_sample_within_bounds() {
1352        let mut sys = ParticleSystem::new(20);
1353        let sphere = sys.add_emitter(ParticleEmitterDef {
1354            pos: [10.0, 0.0, 0.0],
1355            shape: EmitterShape::Sphere { radius: 3.0 },
1356            ..base_def()
1357        });
1358        let boxy = sys.add_emitter(ParticleEmitterDef {
1359            pos: [-10.0, 0.0, 0.0],
1360            shape: EmitterShape::Box {
1361                half: [1.0, 2.0, 0.5],
1362            },
1363            ..base_def()
1364        });
1365        sys.burst(sphere, 64);
1366        sys.burst(boxy, 64);
1367        let mut spread = false;
1368        for p in sys.particles() {
1369            if p.pos[0] > 0.0 {
1370                let d = [p.pos[0] - 10.0, p.pos[1], p.pos[2]];
1371                let r = (d[0] * d[0] + d[1] * d[1] + d[2] * d[2]).sqrt();
1372                assert!(r <= 3.0 + 1e-4, "sphere sample outside radius: {r}");
1373                spread |= r > 0.1;
1374            } else {
1375                let d = [p.pos[0] + 10.0, p.pos[1], p.pos[2]];
1376                assert!(
1377                    d[0].abs() <= 1.0 + 1e-4
1378                        && d[1].abs() <= 2.0 + 1e-4
1379                        && d[2].abs() <= 0.5 + 1e-4,
1380                    "box sample outside half-extents: {d:?}"
1381                );
1382                spread |= d[1].abs() > 1.0; // uses the wide axis
1383            }
1384        }
1385        assert!(spread, "samples must actually spread out");
1386    }
1387
1388    #[test]
1389    fn cone_stays_within_half_angle() {
1390        let mut sys = ParticleSystem::new(21);
1391        let axis = [0.0, 1.0, -1.0]; // non-unit on purpose
1392        let em = sys.add_emitter(ParticleEmitterDef {
1393            velocity: VelocityDef {
1394                cone: Some(ConeDef {
1395                    axis,
1396                    half_angle_deg: 30.0,
1397                    speed: 5.0..10.0,
1398                }),
1399                ..VelocityDef::default()
1400            },
1401            ..base_def()
1402        });
1403        sys.burst(em, 64);
1404        let inv = 1.0 / (2.0_f32).sqrt();
1405        let w = [0.0, inv, -inv];
1406        let cos_half = 30.0_f32.to_radians().cos();
1407        for p in sys.particles() {
1408            let sp = (p.vel[0] * p.vel[0] + p.vel[1] * p.vel[1] + p.vel[2] * p.vel[2]).sqrt();
1409            assert!(
1410                (5.0 - 1e-3..10.0 + 1e-3).contains(&sp),
1411                "speed in range: {sp}"
1412            );
1413            let cosang = (p.vel[0] * w[0] + p.vel[1] * w[1] + p.vel[2] * w[2]) / sp;
1414            assert!(
1415                cosang >= cos_half - 1e-4,
1416                "direction within the cone: cos {cosang} < {cos_half}"
1417            );
1418        }
1419        // A zero axis falls back to straight up (-z).
1420        assert_eq!(
1421            cone_dir(&mut Pcg32::new(1), [0.0; 3], 0.0),
1422            [0.0, 0.0, -1.0]
1423        );
1424    }
1425
1426    #[test]
1427    fn spin_rotates_pose_and_keeps_scale() {
1428        let mut sys = ParticleSystem::new(22);
1429        let em = sys.add_emitter(ParticleEmitterDef {
1430            spin: 2.0..2.0,
1431            scale: 3.0,
1432            lifetime: 100.0..100.0,
1433            ..base_def()
1434        });
1435        sys.burst(em, 1);
1436        sys.update(0.25); // yaw = 0.5 rad
1437        let p = sys.particles()[0];
1438        assert!((p.yaw - 0.5).abs() < 1e-6);
1439        let xf = particle_xf(&p);
1440        // Columns stay orthogonal with length == scale.
1441        let len = |v: [f32; 3]| (v[0] * v[0] + v[1] * v[1] + v[2] * v[2]).sqrt();
1442        assert!((len(xf.right) - 3.0).abs() < 1e-5);
1443        assert!((len(xf.up) - 3.0).abs() < 1e-5);
1444        assert!(xf.right[1].abs() > 0.1, "yaw actually rotates the basis");
1445        let dot = xf.right[0] * xf.up[0] + xf.right[1] * xf.up[1];
1446        assert!(dot.abs() < 1e-5);
1447    }
1448
1449    #[test]
1450    fn scale_and_tint_lerp_over_life() {
1451        let mut sys = ParticleSystem::new(23);
1452        let em = sys.add_emitter(ParticleEmitterDef {
1453            lifetime: 1.0..1.0,
1454            scale: 1.0,
1455            scale_end: Some(3.0),
1456            tint: Rgb(0x00FF_0000),
1457            tint_end: Some(Rgb(0x0000_00FF)),
1458            ..base_def()
1459        });
1460        sys.burst(em, 1);
1461        sys.update(0.5);
1462        let p = sys.particles()[0];
1463        assert!((p.scale - 2.0).abs() < 1e-5, "mid-life scale: {}", p.scale);
1464        let (r, b) = ((p.tint.0 >> 16) & 0xff, p.tint.0 & 0xff);
1465        assert!(
1466            (126..=128).contains(&r) && (126..=128).contains(&b),
1467            "mid tint: {:#08x}",
1468            p.tint.0
1469        );
1470
1471        // sync writes the lerping tint every frame it changes.
1472        let mut f = Mock::default();
1473        sys.sync_with(&mut f);
1474        assert_eq!(f.tints.len(), 1);
1475        sys.update(0.1);
1476        sys.sync_with(&mut f);
1477        assert_eq!(f.tints.len(), 2, "changed tint re-syncs");
1478    }
1479
1480    #[test]
1481    fn retired_emitter_drains_then_frees() {
1482        let mut sys = ParticleSystem::new(7);
1483        let em = sys.add_emitter(ParticleEmitterDef {
1484            spawn: SpawnMode::Rate(1000.0),
1485            lifetime: 0.2..0.2,
1486            ..base_def()
1487        });
1488        sys.update(0.01);
1489        assert!(sys.particle_count() > 0);
1490        assert!(sys.remove_emitter(em));
1491        assert_eq!(sys.emitter_count(), 0);
1492        // Stale-handle ops are safe no-ops.
1493        assert!(!sys.remove_emitter(em));
1494        assert!(!sys.set_emitter_pos(em, [1.0, 2.0, 3.0]));
1495        assert_eq!(sys.burst(em, 10), 0);
1496        // In-flight particles live out their lifetime, then the slot
1497        // frees.
1498        let live = sys.particle_count();
1499        sys.update(0.3);
1500        assert_eq!(sys.particle_count(), 0);
1501        assert!(live > 0);
1502        assert!(sys.emitters.iter().all(Option::is_none));
1503    }
1504
1505    /// Records every facade call `sync` makes; mints sequential ids.
1506    #[derive(Default)]
1507    struct Mock {
1508        next_slot: u32,
1509        fail_spawn: bool,
1510        spawns: Vec<(SpriteModelId, DynSpriteTransform)>,
1511        despawns: Vec<SpriteInstanceId>,
1512        batch_sizes: Vec<usize>,
1513        alphas: Vec<(SpriteInstanceId, u8)>,
1514        tints: Vec<Rgb>,
1515        materials: Vec<u8>,
1516        lightings: Vec<BillboardLighting>,
1517        shadows: Vec<ShadowFlags>,
1518    }
1519
1520    impl ParticleFacade for Mock {
1521        fn spawn(
1522            &mut self,
1523            model: SpriteModelId,
1524            xf: DynSpriteTransform,
1525        ) -> Option<SpriteInstanceId> {
1526            if self.fail_spawn {
1527                return None;
1528            }
1529            self.spawns.push((model, xf));
1530            let id = SpriteInstanceId {
1531                slot: self.next_slot,
1532                gen: 0,
1533            };
1534            self.next_slot += 1;
1535            Some(id)
1536        }
1537        fn despawn(&mut self, id: SpriteInstanceId) {
1538            self.despawns.push(id);
1539        }
1540        fn set_transforms(&mut self, batch: &[(SpriteInstanceId, DynSpriteTransform)]) {
1541            self.batch_sizes.push(batch.len());
1542        }
1543        fn set_alpha(&mut self, id: SpriteInstanceId, alpha: u8) {
1544            self.alphas.push((id, alpha));
1545        }
1546        fn set_tint(&mut self, _id: SpriteInstanceId, tint: Rgb) {
1547            self.tints.push(tint);
1548        }
1549        fn set_material(&mut self, _id: SpriteInstanceId, material: u8) {
1550            self.materials.push(material);
1551        }
1552        fn set_lighting(&mut self, _id: SpriteInstanceId, mode: BillboardLighting) {
1553            self.lightings.push(mode);
1554        }
1555        fn set_shadows(&mut self, _id: SpriteInstanceId, flags: ShadowFlags) {
1556            self.shadows.push(flags);
1557        }
1558    }
1559
1560    #[test]
1561    fn sync_spawns_once_then_batch_moves() {
1562        let mut sys = ParticleSystem::new(10);
1563        let em = sys.add_emitter(ParticleEmitterDef {
1564            lifetime: 100.0..100.0,
1565            ..base_def()
1566        });
1567        sys.burst(em, 3);
1568        let mut f = Mock::default();
1569
1570        sys.sync_with(&mut f);
1571        // Newborns spawn pre-posed — no move batch for them.
1572        assert_eq!(f.spawns.len(), 3);
1573        assert!(f.batch_sizes.is_empty());
1574        // Particle shadows default off ≠ facade default (cast+receive)
1575        // ⇒ set once each; everything else is at facade defaults.
1576        assert_eq!(f.shadows.len(), 3);
1577        assert!(f.materials.is_empty() && f.tints.is_empty() && f.lightings.is_empty());
1578        assert!(f.alphas.is_empty(), "alpha 255 == facade default");
1579
1580        // Next frame: no new spawns, one batch of 3.
1581        sys.update(0.01);
1582        sys.sync_with(&mut f);
1583        assert_eq!(f.spawns.len(), 3);
1584        assert_eq!(f.batch_sizes, vec![3]);
1585    }
1586
1587    #[test]
1588    fn sync_one_time_setup_honours_def() {
1589        let mut sys = ParticleSystem::new(11);
1590        let em = sys.add_emitter(ParticleEmitterDef {
1591            lifetime: 100.0..100.0,
1592            tint: Rgb(0x00FF_0000),
1593            material: 5,
1594            lighting: BillboardLighting::FullBright,
1595            shadows: ShadowFlags::default(), // back to facade default
1596            ..base_def()
1597        });
1598        sys.burst(em, 1);
1599        let mut f = Mock::default();
1600        sys.sync_with(&mut f);
1601        assert_eq!(f.materials, vec![5]);
1602        assert_eq!(f.tints, vec![Rgb(0x00FF_0000)]);
1603        assert_eq!(f.lightings, vec![BillboardLighting::FullBright]);
1604        assert!(f.shadows.is_empty(), "facade-default shadows skip the call");
1605        // Re-sync repeats none of it: still one call per family.
1606        sys.update(0.01);
1607        sys.sync_with(&mut f);
1608        assert_eq!(f.materials.len() + f.tints.len() + f.lightings.len(), 3);
1609    }
1610
1611    #[test]
1612    fn sync_despawns_dead_and_writes_alpha_on_change_only() {
1613        let mut sys = ParticleSystem::new(12);
1614        let em = sys.add_emitter(ParticleEmitterDef {
1615            lifetime: 1.0..1.0,
1616            fade_out_frac: 0.5,
1617            ..base_def()
1618        });
1619        sys.burst(em, 2);
1620        let mut f = Mock::default();
1621        sys.sync_with(&mut f);
1622
1623        // Pre-fade window: alpha stays 255, no writes.
1624        sys.update(0.25);
1625        sys.sync_with(&mut f);
1626        assert!(f.alphas.is_empty());
1627
1628        // Inside the fade window: one write per particle per change.
1629        sys.update(0.5); // age 0.75 ⇒ alpha 127
1630        sys.sync_with(&mut f);
1631        assert_eq!(f.alphas.len(), 2);
1632        assert!(f.alphas.iter().all(|&(_, a)| a == 127));
1633
1634        // Death ⇒ despawn of exactly the minted ids.
1635        sys.update(0.5);
1636        assert_eq!(sys.particle_count(), 0);
1637        sys.sync_with(&mut f);
1638        assert_eq!(f.despawns.len(), 2);
1639    }
1640
1641    #[test]
1642    fn stale_model_spawn_kills_particle() {
1643        let mut sys = ParticleSystem::new(13);
1644        let em = sys.add_emitter(base_def());
1645        sys.burst(em, 2);
1646        let mut f = Mock {
1647            fail_spawn: true,
1648            ..Mock::default()
1649        };
1650        sys.sync_with(&mut f);
1651        assert_eq!(sys.particle_count(), 0);
1652        assert_eq!(sys.stale_model_kills(), 2);
1653        // The emitter's live count drained cleanly: removing it frees
1654        // the slot immediately.
1655        assert!(sys.remove_emitter(em));
1656        assert!(sys.emitters.iter().all(Option::is_none));
1657    }
1658
1659    #[test]
1660    fn particle_xf_scales_and_clamps() {
1661        let p = Particle {
1662            pos: [1.0, 2.0, 3.0],
1663            vel: [0.0; 3],
1664            age: 0.0,
1665            lifetime: 1.0,
1666            scale: 2.0,
1667            yaw: 0.0,
1668            spin_rate: 0.0,
1669            alpha: 255,
1670            tint: Rgb(0),
1671            emitter_slot: 0,
1672            instance: None,
1673            last_alpha: 255,
1674            last_tint: Rgb::WHITE,
1675            tint_start: Rgb(0),
1676        };
1677        let xf = particle_xf(&p);
1678        assert_eq!(xf.pos, [1.0, 2.0, 3.0]);
1679        assert_eq!((xf.right[0], xf.up[1], xf.forward[2]), (2.0, 2.0, 2.0));
1680        // Degenerate scale clamps to the visible minimum.
1681        let tiny = particle_xf(&Particle { scale: 0.0, ..p });
1682        assert_eq!(tiny.right[0], 0.05);
1683    }
1684
1685    /// A scene with a solid slab: z ∈ 10..=12 across generous xy.
1686    fn scene_with_floor() -> roxlap_scene::Scene {
1687        use glam::{DVec3, IVec3};
1688        let mut scene = roxlap_scene::Scene::new();
1689        let id = scene.add_grid(roxlap_scene::GridTransform::at(DVec3::ZERO));
1690        let g = scene.grid_mut(id).expect("fresh grid");
1691        g.set_rect(
1692            IVec3::new(-16, -16, 10),
1693            IVec3::new(16, 16, 12),
1694            Some(roxlap_formats::VoxColor(0x80FF_FFFF)),
1695        );
1696        scene
1697    }
1698
1699    /// An emitter dropping one particle straight down (+z) at the slab.
1700    fn falling(collision: CollisionMode) -> ParticleEmitterDef {
1701        ParticleEmitterDef {
1702            pos: [0.0, 0.0, 5.0],
1703            velocity: VelocityDef {
1704                base: [0.0, 0.0, 20.0],
1705                ..VelocityDef::default()
1706            },
1707            lifetime: 100.0..100.0,
1708            collision,
1709            ..base_def() // zero gravity — constant fall speed
1710        }
1711    }
1712
1713    #[test]
1714    fn collision_kill_dies_on_contact() {
1715        let scene = scene_with_floor();
1716        let mut sys = ParticleSystem::new(30);
1717        let em = sys.add_emitter(falling(CollisionMode::Kill));
1718        sys.burst(em, 1);
1719        for _ in 0..20 {
1720            sys.update_with_scene(0.05, &scene);
1721        }
1722        assert_eq!(sys.particle_count(), 0, "dies at the slab");
1723
1724        // The scene-free update never collides, whatever the mode.
1725        let mut free = ParticleSystem::new(30);
1726        let em = free.add_emitter(falling(CollisionMode::Kill));
1727        free.burst(em, 1);
1728        for _ in 0..20 {
1729            free.update(0.05);
1730        }
1731        assert_eq!(free.particle_count(), 1);
1732        assert!(free.particles()[0].pos[2] > 12.0, "fell straight through");
1733    }
1734
1735    #[test]
1736    fn collision_none_passes_through() {
1737        let scene = scene_with_floor();
1738        let mut sys = ParticleSystem::new(31);
1739        let em = sys.add_emitter(falling(CollisionMode::None));
1740        sys.burst(em, 1);
1741        for _ in 0..20 {
1742            sys.update_with_scene(0.05, &scene);
1743        }
1744        assert!(sys.particles()[0].pos[2] > 12.0);
1745    }
1746
1747    #[test]
1748    fn collision_bounce_reflects_and_damps() {
1749        let scene = scene_with_floor();
1750        let mut sys = ParticleSystem::new(32);
1751        let em = sys.add_emitter(falling(CollisionMode::Bounce { restitution: 0.5 }));
1752        sys.burst(em, 1);
1753        let mut bounced = false;
1754        for _ in 0..40 {
1755            sys.update_with_scene(0.05, &scene);
1756            let p = sys.particles()[0];
1757            assert!(p.pos[2] < 10.5, "never sinks into the slab: {}", p.pos[2]);
1758            if p.vel[2] < 0.0 {
1759                bounced = true;
1760            }
1761        }
1762        assert!(bounced, "velocity reflected upward");
1763        let p = sys.particles()[0];
1764        assert!(
1765            p.vel[2].abs() <= 10.0 + 1e-3,
1766            "restitution halves the speed: {}",
1767            p.vel[2]
1768        );
1769    }
1770
1771    #[test]
1772    fn carve_debris_samples_colours_and_carves() {
1773        use glam::{DVec3, IVec3};
1774        // A dedicated slab in one recognisable colour.
1775        let mut scene = roxlap_scene::Scene::new();
1776        let grid = scene.add_grid(roxlap_scene::GridTransform::at(DVec3::ZERO));
1777        scene.grid_mut(grid).expect("grid").set_rect(
1778            IVec3::new(-8, -8, 10),
1779            IVec3::new(8, 8, 12),
1780            Some(roxlap_formats::VoxColor(0x80_12_34_56)),
1781        );
1782        let mut sys = ParticleSystem::new(40);
1783        let centre = IVec3::new(0, 0, 11);
1784        let spawned = sys.carve_debris(
1785            &mut scene,
1786            grid,
1787            centre,
1788            2,
1789            4.0..6.0,
1790            &ParticleEmitterDef {
1791                lifetime: 100.0..100.0,
1792                ..base_def()
1793            },
1794        );
1795        assert!(spawned > 0);
1796        assert_eq!(sys.particle_count() as u32, spawned);
1797        // Every debris particle wears the sampled voxel colour.
1798        for p in sys.particles() {
1799            assert_eq!(p.tint, Rgb(0x0012_3456), "tint is the voxel colour");
1800        }
1801        // The ball is actually gone from the grid.
1802        let g = scene.grid_mut(grid).expect("grid");
1803        assert!(g.voxel_color(centre).is_none());
1804        // Radial kick: particles off-centre move away from the centre.
1805        for p in sys.particles() {
1806            let d = [p.pos[0] - 0.5, p.pos[1] - 0.5, p.pos[2] - 11.5];
1807            let r2 = d[0] * d[0] + d[1] * d[1] + d[2] * d[2];
1808            if r2 > 0.5 {
1809                let dot = d[0] * p.vel[0] + d[1] * p.vel[1] + d[2] * p.vel[2];
1810                assert!(dot > 0.0, "debris flies outward: {d:?} vs {:?}", p.vel);
1811            }
1812        }
1813        // The transient emitter retired; it drains with its debris.
1814        assert_eq!(sys.emitter_count(), 0);
1815        sys.update(200.0);
1816        assert_eq!(sys.particle_count(), 0);
1817        assert!(sys.emitters.iter().all(Option::is_none));
1818    }
1819
1820    /// DT.3 — `carve_debris` is byte-compatible with its factored
1821    /// halves: manually sampling the sphere (the same z, y, x order),
1822    /// carving, and calling `voxel_debris` yields **bit-identical**
1823    /// particles from same-seed systems (the RNG is consumed in the
1824    /// same order) — the regression the refactor is gated on.
1825    #[test]
1826    fn carve_debris_equals_sample_plus_voxel_debris() {
1827        use glam::{DVec3, IVec3};
1828        let build = || {
1829            let mut scene = roxlap_scene::Scene::new();
1830            let grid = scene.add_grid(roxlap_scene::GridTransform::at(DVec3::ZERO));
1831            scene.grid_mut(grid).expect("grid").set_rect(
1832                IVec3::new(-8, -8, 10),
1833                IVec3::new(8, 8, 12),
1834                Some(roxlap_formats::VoxColor(0x80_12_34_56)),
1835            );
1836            (scene, grid)
1837        };
1838        let def = ParticleEmitterDef {
1839            lifetime: 1.0..3.0,
1840            spin: -2.0..2.0,
1841            velocity: VelocityDef {
1842                spread: 3.0,
1843                ..VelocityDef::default()
1844            },
1845            ..base_def()
1846        };
1847        let centre = IVec3::new(0, 0, 11);
1848
1849        let (mut scene_a, grid_a) = build();
1850        let mut a = ParticleSystem::new(77);
1851        let na = a.carve_debris(&mut scene_a, grid_a, centre, 2, 4.0..6.0, &def);
1852
1853        let (mut scene_b, grid_b) = build();
1854        let mut b = ParticleSystem::new(77);
1855        let g = scene_b.grid_mut(grid_b).expect("grid");
1856        let mut sites = Vec::new();
1857        let r = 2i32;
1858        for z in -r..=r {
1859            for y in -r..=r {
1860                for x in -r..=r {
1861                    if x * x + y * y + z * z > r * r {
1862                        continue;
1863                    }
1864                    let v = centre + IVec3::new(x, y, z);
1865                    if let Some(col) = g.voxel_color(v) {
1866                        let w = v.as_dvec3() + DVec3::splat(0.5); // identity transform
1867                        #[allow(clippy::cast_possible_truncation)]
1868                        sites.push(([w.x as f32, w.y as f32, w.z as f32], col.rgb_part()));
1869                    }
1870                }
1871            }
1872        }
1873        g.set_sphere(centre, 2, None);
1874        let cw = centre.as_dvec3() + DVec3::splat(0.5);
1875        #[allow(clippy::cast_possible_truncation)]
1876        let nb = b.voxel_debris(
1877            &sites,
1878            [cw.x as f32, cw.y as f32, cw.z as f32],
1879            4.0..6.0,
1880            &def,
1881        );
1882
1883        assert_eq!(na, nb, "same debris count");
1884        assert!(na > 0);
1885        for (pa, pb) in a.particles().iter().zip(b.particles()) {
1886            assert_eq!(pa.pos, pb.pos);
1887            assert_eq!(pa.vel, pb.vel);
1888            assert_eq!(pa.tint, pb.tint);
1889            assert_eq!(pa.lifetime, pb.lifetime);
1890            assert_eq!(pa.spin_rate, pb.spin_rate);
1891        }
1892    }
1893
1894    #[test]
1895    fn carve_debris_caps_big_carves_and_respects_budget() {
1896        use glam::IVec3;
1897        let mut scene = scene_with_floor();
1898        let grid = scene.grids().next().expect("one grid").0;
1899        let mut sys = ParticleSystem::new(41);
1900        // Radius 6 ball in a 3-thick slab ≫ the cap.
1901        let spawned = sys.carve_debris(
1902            &mut scene,
1903            grid,
1904            IVec3::new(0, 0, 11),
1905            6,
1906            1.0..2.0,
1907            &ParticleEmitterDef {
1908                lifetime: 100.0..100.0,
1909                ..base_def()
1910            },
1911        );
1912        assert!(spawned as usize <= CARVE_DEBRIS_CAP);
1913        assert!(
1914            spawned as usize > CARVE_DEBRIS_CAP / 2,
1915            "stride fills near the cap"
1916        );
1917
1918        // A lowered per-system cap throttles the same carve.
1919        let mut low = ParticleSystem::new(44);
1920        low.set_carve_debris_cap(16);
1921        let mut scene3 = scene_with_floor();
1922        let spawned = low.carve_debris(
1923            &mut scene3,
1924            grid,
1925            IVec3::new(0, 0, 11),
1926            6,
1927            1.0..2.0,
1928            &ParticleEmitterDef {
1929                lifetime: 100.0..100.0,
1930                ..base_def()
1931            },
1932        );
1933        assert!(spawned <= 16, "tuned cap respected: {spawned}");
1934        assert!(spawned >= 8, "stride still fills near the tuned cap");
1935
1936        // Pool budget on top: a tiny pool drops the rest, counted.
1937        let mut tiny = ParticleSystem::new(42);
1938        tiny.set_max_particles(5);
1939        let mut scene2 = scene_with_floor();
1940        let spawned = tiny.carve_debris(
1941            &mut scene2,
1942            grid,
1943            IVec3::new(10, 10, 11),
1944            4,
1945            1.0..2.0,
1946            &ParticleEmitterDef {
1947                lifetime: 100.0..100.0,
1948                ..base_def()
1949            },
1950        );
1951        assert_eq!(spawned, 5);
1952        assert!(tiny.dropped_spawns() > 0);
1953    }
1954
1955    #[test]
1956    fn carve_debris_tint_end_lerps_from_voxel_colour() {
1957        use glam::IVec3;
1958        let mut scene = scene_with_floor();
1959        let grid = scene.grids().next().expect("one grid").0;
1960        let mut sys = ParticleSystem::new(43);
1961        sys.carve_debris(
1962            &mut scene,
1963            grid,
1964            IVec3::new(0, 0, 11),
1965            1,
1966            0.0..0.0,
1967            &ParticleEmitterDef {
1968                lifetime: 1.0..1.0,
1969                tint_end: Some(Rgb(0x0000_0000)),
1970                ..base_def()
1971            },
1972        );
1973        assert!(sys.particle_count() > 0);
1974        sys.update(0.5);
1975        // Slab colour 0x00FF_FFFF darkening toward black: mid ≈ 127.
1976        for p in sys.particles() {
1977            let r = (p.tint.0 >> 16) & 0xff;
1978            assert!(
1979                (120..=135).contains(&r),
1980                "lerp starts at the voxel colour, not def.tint: {:#08x}",
1981                p.tint.0
1982            );
1983        }
1984    }
1985
1986    #[test]
1987    fn stress_10k_particles_simulate_and_sync() {
1988        let mut sys = ParticleSystem::new(50);
1989        sys.set_max_particles(10_000);
1990        let em = sys.add_emitter(ParticleEmitterDef {
1991            // Short life ⇒ the u8 fade actually steps every frame (a
1992            // 100 s life would quantise alpha to one write per ~12
1993            // frames); 2 s > the 30 simulated frames, so none die.
1994            lifetime: 2.0..2.0,
1995            velocity: VelocityDef {
1996                spread: 10.0,
1997                ..VelocityDef::default()
1998            },
1999            // Fade the whole life ⇒ alpha changes (and syncs) every frame.
2000            fade_in_frac: 0.5,
2001            fade_out_frac: 0.5,
2002            spin: -3.0..3.0,
2003            scale_end: Some(2.0),
2004            tint_end: Some(Rgb(0x0000_0000)),
2005            gravity: [0.0, 0.0, 5.0],
2006            ..base_def()
2007        });
2008        assert_eq!(sys.burst(em, 10_000), 10_000);
2009        let mut f = Mock::default();
2010        for _ in 0..30 {
2011            sys.update(1.0 / 60.0);
2012            sys.sync_with(&mut f);
2013        }
2014        assert_eq!(sys.particle_count(), 10_000);
2015        assert_eq!(f.spawns.len(), 10_000);
2016        // Every later frame batch-moves all 10k and rewrites the
2017        // churning alpha/tint per instance.
2018        assert_eq!(*f.batch_sizes.last().expect("batches ran"), 10_000);
2019        assert!(f.alphas.len() > 100_000, "alpha churns every frame");
2020    }
2021
2022    /// Manual perf probe (release): `cargo test -p roxlap-render
2023    /// --release stress_10k_probe -- --ignored --nocapture`.
2024    #[test]
2025    #[ignore = "manual perf probe — prints timings, asserts nothing"]
2026    fn stress_10k_probe() {
2027        let mut sys = ParticleSystem::new(51);
2028        sys.set_max_particles(10_000);
2029        let em = sys.add_emitter(ParticleEmitterDef {
2030            // 8 s life: the fade steps ~every frame across the 200
2031            // timed frames (worst-case alpha churn) and nothing dies.
2032            lifetime: 8.0..8.0,
2033            velocity: VelocityDef {
2034                spread: 10.0,
2035                ..VelocityDef::default()
2036            },
2037            fade_in_frac: 0.5,
2038            fade_out_frac: 0.5,
2039            spin: -3.0..3.0,
2040            scale_end: Some(2.0),
2041            tint_end: Some(Rgb(0x0000_0000)),
2042            ..base_def()
2043        });
2044        sys.burst(em, 10_000);
2045        let mut f = Mock::default();
2046        sys.sync_with(&mut f); // spawn frame excluded from timing
2047        let t0 = std::time::Instant::now();
2048        const FRAMES: u32 = 200;
2049        for _ in 0..FRAMES {
2050            sys.update(1.0 / 60.0);
2051            sys.sync_with(&mut f);
2052        }
2053        let per_frame = t0.elapsed() / FRAMES;
2054        eprintln!("10k particles: {per_frame:?}/frame (update + sync w/ alpha+tint churn)");
2055    }
2056
2057    #[test]
2058    fn moved_emitter_spawns_at_new_pos() {
2059        let mut sys = ParticleSystem::new(8);
2060        let em = sys.add_emitter(base_def());
2061        assert!(sys.set_emitter_pos(em, [5.0, 6.0, 7.0]));
2062        sys.burst(em, 1);
2063        assert_eq!(sys.particles()[0].pos, [5.0, 6.0, 7.0]);
2064    }
2065}