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::{
27    BillboardLighting, DynSpriteTransform, EpochSlotMap, SceneRenderer, ShadowFlags, SlotHandle,
28    SpriteInstanceId, SpriteModelId,
29};
30
31/// Stable handle to an emitter inside one [`ParticleSystem`] — the
32/// result of [`add_emitter`](ParticleSystem::add_emitter), passed to
33/// the per-emitter setters and [`burst`](ParticleSystem::burst).
34/// Epoch-generational like every other facade handle family: a removed
35/// emitter's handle resolves to a safe no-op, never to another emitter.
36#[derive(Clone, Copy, PartialEq, Eq, Hash, Debug)]
37pub struct EmitterId {
38    slot: u32,
39    gen: u32,
40}
41
42// `impl_slot_handle!` is textually scoped to lib.rs; the trait is
43// crate-visible, so the two-method impl is written out here.
44impl SlotHandle for EmitterId {
45    fn mint(slot: u32, gen: u32) -> Self {
46        Self { slot, gen }
47    }
48    fn parts(self) -> (u32, u32) {
49        (self.slot, self.gen)
50    }
51}
52
53/// How an emitter produces particles.
54#[derive(Clone, Copy, Debug)]
55pub enum SpawnMode {
56    /// Continuous emission at `n` particles per second; fractional
57    /// particles accumulate across frames, so `Rate(0.5)` spawns one
58    /// particle every two seconds regardless of frame rate.
59    Rate(f32),
60    /// One burst of `n` particles the moment the emitter is added,
61    /// then nothing (explosions). Further bursts via
62    /// [`burst`](ParticleSystem::burst).
63    Burst(u32),
64    /// Nothing automatic — the host calls
65    /// [`burst`](ParticleSystem::burst) itself.
66    Manual,
67}
68
69/// Where new particles appear, relative to the emitter position (PS.2).
70#[derive(Clone, Copy, Debug, Default)]
71pub enum EmitterShape {
72    /// Every particle starts exactly at the emitter position (default).
73    #[default]
74    Point,
75    /// Uniform inside a ball of this radius.
76    Sphere {
77        /// Ball radius, world units.
78        radius: f32,
79    },
80    /// Uniform inside an axis-aligned box of these half-extents.
81    Box {
82        /// Half-extents per axis, world units.
83        half: [f32; 3],
84    },
85}
86
87/// Directional cone emission (PS.2) — fountains, muzzle flashes,
88/// impact sprays: a random direction within `half_angle_deg` of
89/// `axis`, at a speed sampled from `speed`.
90#[derive(Clone, Debug)]
91pub struct ConeDef {
92    /// Cone axis (need not be unit length; a zero vector falls back to
93    /// straight **up**, `[0, 0, -1]` — +z is down).
94    pub axis: [f32; 3],
95    /// Cone half-angle in **degrees** (the [`SpotLight`] convention):
96    /// `0` = a beam along the axis, `180` = fully isotropic. Clamped
97    /// to `0..=180`.
98    ///
99    /// [`SpotLight`]: crate::SpotLight
100    pub half_angle_deg: f32,
101    /// Speed along the sampled direction, world units/second, uniform.
102    pub speed: Range<f32>,
103}
104
105/// Initial particle velocity: a fixed base, an isotropic spread, and
106/// an optional directional cone — the three compose by addition.
107#[derive(Clone, Debug, Default)]
108pub struct VelocityDef {
109    /// Velocity every particle starts from, world units/second.
110    /// Remember +z is down: a fountain fires with negative z.
111    pub base: [f32; 3],
112    /// Magnitude of the random isotropic kick added to `base`: each
113    /// particle adds a uniformly random direction scaled by a uniform
114    /// `0..spread` speed. `0.0` (default) = no randomness.
115    pub spread: f32,
116    /// Optional cone kick added on top (PS.2). `None` (default) = no
117    /// directional term.
118    pub cone: Option<ConeDef>,
119}
120
121/// How particles react to solid voxels (PS.3) — checked only by the
122/// `_with_scene` update/tick variants; the scene-free ones never
123/// collide. The test is a point sample of the post-step position,
124/// nudged half a voxel along the velocity ([`Scene::resolve_voxel`]'s
125/// picking nudge, reused): contact registers slightly before visual
126/// interpenetration, fast particles can tunnel through walls thinner
127/// than one step's travel, and a **resting** particle (zero velocity)
128/// is never re-tested — all acceptable for effects, none of this is a
129/// physics engine.
130///
131/// [`Scene::resolve_voxel`]: roxlap_scene::Scene::resolve_voxel
132#[derive(Clone, Copy, Debug, Default, PartialEq)]
133pub enum CollisionMode {
134    /// No voxel interaction (default) — particles pass through.
135    #[default]
136    None,
137    /// Die on contact (impact sparks, raindrops).
138    Kill,
139    /// Reflect off the surface: velocity flips on the axes whose voxel
140    /// boundary was crossed this step (the cheapest normal estimate —
141    /// exact for axis-aligned grids, approximate for rotated ones),
142    /// then the whole velocity scales by `restitution` — tangential
143    /// damping included, deliberately arcade.
144    Bounce {
145        /// Energy kept per bounce, `0..=1` (`0.5` = half).
146        restitution: f32,
147    },
148}
149
150/// Recipe for [`add_emitter`](ParticleSystem::add_emitter). Construct
151/// with [`ParticleEmitterDef::new`] and override what the effect needs;
152/// there is no `Default` because a def is meaningless without a live
153/// [`SpriteModelId`].
154#[derive(Clone, Debug)]
155pub struct ParticleEmitterDef {
156    /// The kv6 sprite model every particle instantiates (a puff, a
157    /// spark, a shard) — from
158    /// [`add_sprite_model`](crate::SceneRenderer::add_sprite_model).
159    pub model: SpriteModelId,
160    /// Emitter position, world space. Movable later via
161    /// [`set_emitter_pos`](ParticleSystem::set_emitter_pos).
162    pub pos: [f32; 3],
163    /// Spawn-position distribution around `pos` (PS.2; default
164    /// [`EmitterShape::Point`]).
165    pub shape: EmitterShape,
166    /// Spawn behaviour (default [`SpawnMode::Manual`]).
167    pub spawn: SpawnMode,
168    /// Per-particle lifetime, seconds, sampled uniformly. A degenerate
169    /// range (`end <= start`) collapses to `start`. Clamped to ≥ 1 ms.
170    pub lifetime: Range<f32>,
171    /// Initial velocity distribution.
172    pub velocity: VelocityDef,
173    /// Constant acceleration, world units/s² — gravity is **positive
174    /// z** (default `[0, 0, 22]`, a decent arcade fall).
175    pub gravity: [f32; 3],
176    /// Linear drag coefficient, 1/s: each step removes
177    /// `drag · vel · dt`. `0.0` = ballistic; smoke wants ~1-3.
178    pub drag: f32,
179    /// Voxel-collision reaction (PS.3; default [`CollisionMode::None`]).
180    /// Only the `_with_scene` update/tick variants test it.
181    pub collision: CollisionMode,
182    /// Uniform base scale applied to the instance basis (`1.0` =
183    /// authored model size). The rendered scale clamps to ≥ 0.05 —
184    /// a degenerate basis silently skips drawing.
185    pub scale: f32,
186    /// End-of-life scale (PS.2): the particle lerps `scale →
187    /// scale_end` over its lifetime — growing smoke, shrinking sparks.
188    /// `None` (default) = constant.
189    pub scale_end: Option<f32>,
190    /// Spin rate about the world vertical, **radians/second**, sampled
191    /// uniformly per particle (PS.2) — a symmetric range like
192    /// `-3.0..3.0` spins debris both ways. Default `0.0..0.0` = no
193    /// spin.
194    pub spin: Range<f32>,
195    /// Fraction of the lifetime over which alpha ramps 0 → 255 at the
196    /// **start** (PS.2) — smoke that condenses in rather than pops.
197    /// `0.0` (default) = born fully opaque.
198    pub fade_in_frac: f32,
199    /// Fraction of the lifetime over which alpha fades 255 → 0 at the
200    /// end (`0.25` = the last quarter). `0.0` = no fade, particles
201    /// vanish at full opacity.
202    pub fade_out_frac: f32,
203    /// Per-particle RGB tint, packed `0x00RRGGBB` (white = no-op).
204    pub tint: u32,
205    /// End-of-life tint (PS.2): lerped per channel from `tint` over
206    /// the lifetime — white-hot → red → dark ember. `None` (default) =
207    /// constant.
208    pub tint_end: Option<u32>,
209    /// Voxel-material id for every particle (TV palette; `0` opaque).
210    /// Smoke wants an alpha/volumetric material, sparks additive.
211    pub material: u8,
212    /// Shading-normal mode (default [`BillboardLighting::FaceNormal`];
213    /// glowing effects want [`BillboardLighting::FullBright`]).
214    pub lighting: BillboardLighting,
215    /// Shadow participation. Defaults to **neither cast nor receive** —
216    /// hundreds of shadow-casting particles are a perf trap; opt back
217    /// in per effect.
218    pub shadows: ShadowFlags,
219}
220
221impl ParticleEmitterDef {
222    /// A def with every field at its documented default: manual spawn
223    /// at a point at the origin, 1-2 s lifetime, no initial velocity,
224    /// arcade gravity, no drag, constant unit scale, no spin, fade out
225    /// over the last quarter, constant no-op tint, opaque material,
226    /// face-normal lighting, shadows off.
227    #[must_use]
228    pub fn new(model: SpriteModelId) -> Self {
229        Self {
230            model,
231            pos: [0.0, 0.0, 0.0],
232            shape: EmitterShape::Point,
233            spawn: SpawnMode::Manual,
234            lifetime: 1.0..2.0,
235            velocity: VelocityDef::default(),
236            gravity: [0.0, 0.0, 22.0],
237            drag: 0.0,
238            collision: CollisionMode::None,
239            scale: 1.0,
240            scale_end: None,
241            spin: 0.0..0.0,
242            fade_in_frac: 0.0,
243            fade_out_frac: 0.25,
244            tint: 0x00FF_FFFF,
245            tint_end: None,
246            material: 0,
247            lighting: BillboardLighting::FaceNormal,
248            shadows: ShadowFlags {
249                casts: false,
250                receives: false,
251            },
252        }
253    }
254}
255
256/// One live particle — a read-only view for hosts (HUD counters,
257/// custom overlays); the system owns the mutation.
258#[derive(Clone, Copy, Debug)]
259pub struct Particle {
260    /// World position.
261    pub pos: [f32; 3],
262    /// World velocity, units/second.
263    pub vel: [f32; 3],
264    /// Seconds lived so far.
265    pub age: f32,
266    /// Seconds this particle lives in total.
267    pub lifetime: f32,
268    /// Current uniform render scale (lerps `scale → scale_end` when
269    /// the emitter sets an end scale).
270    pub scale: f32,
271    /// Current rotation about the world vertical, radians (PS.2).
272    pub yaw: f32,
273    /// Sampled spin rate, radians/second (PS.2).
274    pub spin_rate: f32,
275    /// Current alpha 0..=255, driven by the emitter's fade curves.
276    pub alpha: u8,
277    /// Current packed `0x00RRGGBB` tint (lerps `tint → tint_end` when
278    /// the emitter sets an end tint).
279    pub tint: u32,
280    /// Owning emitter slot — resolves render params (model, material,
281    /// lighting) at sync time.
282    pub(crate) emitter_slot: u32,
283    /// The facade instance backing this particle: `None` until the
284    /// first [`sync`](ParticleSystem::sync) spawns it.
285    pub(crate) instance: Option<SpriteInstanceId>,
286    /// The alpha last written to the facade (which defaults a fresh
287    /// instance to 255) — `sync` calls the per-instance alpha setter
288    /// only when this differs, since alpha has no batch API.
289    pub(crate) last_alpha: u8,
290    /// The tint last written to the facade (fresh-instance default:
291    /// white) — same change-only discipline as `last_alpha`.
292    pub(crate) last_tint: u32,
293    /// The tint this particle was born with — the `tint_end` lerp's
294    /// start point. Usually the emitter's `tint`; `carve_debris` seeds
295    /// it with the sampled voxel colour.
296    pub(crate) tint_start: u32,
297}
298
299/// Per-emitter live state.
300struct EmitterState {
301    def: ParticleEmitterDef,
302    /// Fractional particles owed by [`SpawnMode::Rate`].
303    spawn_acc: f64,
304    /// Live particles owned by this emitter — a retired emitter's
305    /// state is kept until this drains to 0 (particles read their
306    /// def's gravity/drag every step).
307    live: u32,
308    /// [`ParticleSystem::remove_emitter`] called: stop spawning, free
309    /// the state once `live == 0`.
310    retired: bool,
311}
312
313/// PCG32 (Melissa O'Neill's `pcg32_oneseq`): 64-bit state, 32-bit
314/// output. Deterministic and tiny — same seed + same `dt` sequence ⇒
315/// bit-identical simulation, so effects are golden-testable. Not
316/// cryptographic, deliberately.
317struct Pcg32 {
318    state: u64,
319}
320
321impl Pcg32 {
322    const MULT: u64 = 6_364_136_223_846_793_005;
323    const INC: u64 = 1_442_695_040_888_963_407;
324
325    fn new(seed: u64) -> Self {
326        let mut rng = Self {
327            state: seed.wrapping_add(Self::INC),
328        };
329        rng.next_u32();
330        rng
331    }
332
333    fn next_u32(&mut self) -> u32 {
334        let old = self.state;
335        self.state = old.wrapping_mul(Self::MULT).wrapping_add(Self::INC);
336        let xorshifted = (((old >> 18) ^ old) >> 27) as u32;
337        xorshifted.rotate_right((old >> 59) as u32)
338    }
339
340    /// Uniform in `[0, 1)` with 24 bits of mantissa.
341    fn next_f32(&mut self) -> f32 {
342        (self.next_u32() >> 8) as f32 * (1.0 / (1u32 << 24) as f32)
343    }
344
345    /// Uniform in `[start, end)`; a degenerate range yields `start`.
346    fn range_f32(&mut self, r: &Range<f32>) -> f32 {
347        if r.end <= r.start {
348            return r.start;
349        }
350        r.start + (r.end - r.start) * self.next_f32()
351    }
352
353    /// Uniform direction on the unit sphere (cube-rejection — no
354    /// trig, deterministic step count per accepted sample stream).
355    fn unit_vec(&mut self) -> [f32; 3] {
356        loop {
357            let v = [
358                self.next_f32() * 2.0 - 1.0,
359                self.next_f32() * 2.0 - 1.0,
360                self.next_f32() * 2.0 - 1.0,
361            ];
362            let len2 = v[0] * v[0] + v[1] * v[1] + v[2] * v[2];
363            if len2 > 1e-4 && len2 <= 1.0 {
364                let inv = 1.0 / len2.sqrt();
365                return [v[0] * inv, v[1] * inv, v[2] * inv];
366            }
367        }
368    }
369}
370
371/// Default [`ParticleSystem`] particle budget.
372pub const DEFAULT_MAX_PARTICLES: usize = 4096;
373
374/// Debris cap per [`ParticleSystem::carve_debris`] call: bigger carves
375/// stride-sample an even spatial subset down to this many particles,
376/// so one large explosion can't monopolise the pool budget.
377pub const CARVE_DEBRIS_CAP: usize = 96;
378
379/// A self-contained particle simulation: emitters, a shared particle
380/// pool with a budget, and a deterministic seeded RNG. Host-owned;
381/// per frame call [`update`](Self::update) (pure simulation, no
382/// renderer) then — from PS.1 — `sync(&mut SceneRenderer)` to mirror
383/// the pool into dynamic sprite instances.
384///
385/// Budget semantics: when the pool is full, **spawns are dropped**
386/// (never evict a live particle — a visible pop);
387/// [`dropped_spawns`](Self::dropped_spawns) makes the cap observable
388/// instead of silent.
389pub struct ParticleSystem {
390    rng: Pcg32,
391    map: EpochSlotMap<EmitterId>,
392    /// Parallel to `map`'s slots; `None` once a retired emitter drains.
393    emitters: Vec<Option<EmitterState>>,
394    particles: Vec<Particle>,
395    /// Instances whose particles died since the last drain —
396    /// [`sync`](Self::sync) removes these from the facade.
397    dead_instances: Vec<SpriteInstanceId>,
398    max_particles: usize,
399    /// Per-[`carve_debris`](Self::carve_debris) debris cap (default
400    /// [`CARVE_DEBRIS_CAP`]).
401    carve_debris_cap: usize,
402    dropped_spawns: u64,
403    stale_model_kills: u64,
404    /// Persistent scratch for the per-frame transform batch (PF
405    /// lesson: no per-frame allocs in the hot loop).
406    xf_scratch: Vec<(SpriteInstanceId, DynSpriteTransform)>,
407}
408
409impl ParticleSystem {
410    /// A system with the given RNG seed and the default budget
411    /// ([`DEFAULT_MAX_PARTICLES`]). Same seed + same call sequence ⇒
412    /// bit-identical simulation.
413    #[must_use]
414    pub fn new(seed: u64) -> Self {
415        Self {
416            rng: Pcg32::new(seed),
417            map: EpochSlotMap::default(),
418            emitters: Vec::new(),
419            particles: Vec::new(),
420            dead_instances: Vec::new(),
421            max_particles: DEFAULT_MAX_PARTICLES,
422            carve_debris_cap: CARVE_DEBRIS_CAP,
423            dropped_spawns: 0,
424            stale_model_kills: 0,
425            xf_scratch: Vec::new(),
426        }
427    }
428
429    /// Set the particle budget. Lowering it below the current live
430    /// count kills nothing — it only gates future spawns.
431    pub fn set_max_particles(&mut self, max: usize) {
432        self.max_particles = max;
433    }
434
435    /// Tune how many debris particles one
436    /// [`carve_debris`](Self::carve_debris) call may spawn (default
437    /// [`CARVE_DEBRIS_CAP`]) — the per-explosion load knob. Bigger
438    /// carves stride-sample an even spatial subset down to this;
439    /// clamped to ≥ 1.
440    pub fn set_carve_debris_cap(&mut self, cap: usize) {
441        self.carve_debris_cap = cap.max(1);
442    }
443
444    /// Register an emitter. [`SpawnMode::Burst`] fires immediately.
445    pub fn add_emitter(&mut self, def: ParticleEmitterDef) -> EmitterId {
446        let slot = self.emitters.len() as u32;
447        let id = self.map.alloc(slot);
448        let burst = match def.spawn {
449            SpawnMode::Burst(n) => n,
450            _ => 0,
451        };
452        self.emitters.push(Some(EmitterState {
453            def,
454            spawn_acc: 0.0,
455            live: 0,
456            retired: false,
457        }));
458        if burst > 0 {
459            self.spawn_from(slot as usize, burst);
460        }
461        id
462    }
463
464    /// Retire an emitter: it stops spawning immediately and its handle
465    /// goes stale, but particles already in flight live out their
466    /// lifetimes (the state drains away with the last one). Returns
467    /// `false` on a stale handle.
468    pub fn remove_emitter(&mut self, id: EmitterId) -> bool {
469        let Some(slot) = self.map.index(id) else {
470            return false;
471        };
472        if !self.map.remove(id) {
473            return false;
474        }
475        let state = self.emitters[slot].as_mut().expect("live map ⇒ state");
476        if state.live == 0 {
477            self.emitters[slot] = None;
478        } else {
479            state.retired = true;
480        }
481        true
482    }
483
484    /// Move an emitter (attach effects to moving things). Returns
485    /// `false` on a stale handle.
486    pub fn set_emitter_pos(&mut self, id: EmitterId, pos: [f32; 3]) -> bool {
487        let Some(slot) = self.map.index(id) else {
488            return false;
489        };
490        let state = self.emitters[slot].as_mut().expect("live map ⇒ state");
491        state.def.pos = pos;
492        true
493    }
494
495    /// Spawn `n` particles from `id` right now (any [`SpawnMode`]).
496    /// Returns how many actually spawned (budget may drop the rest);
497    /// `0` on a stale handle.
498    pub fn burst(&mut self, id: EmitterId, n: u32) -> u32 {
499        let Some(slot) = self.map.index(id) else {
500            return 0;
501        };
502        self.spawn_from(slot, n)
503    }
504
505    /// Advance the simulation by `dt` seconds: integrate + age + fade
506    /// every particle, retire the dead (their facade instances queue
507    /// in [`drain_dead_instances`](Self::drain_dead_instances)), then
508    /// run [`SpawnMode::Rate`] emitters. Pure simulation — no facade
509    /// calls, unit-testable without a window or GPU. Never collides;
510    /// for [`CollisionMode`] emitters use
511    /// [`update_with_scene`](Self::update_with_scene).
512    pub fn update(&mut self, dt: f64) {
513        self.step(dt, None);
514    }
515
516    /// [`update`](Self::update) + voxel collision (PS.3): emitters
517    /// with a non-[`None`](CollisionMode::None) [`CollisionMode`] test
518    /// each particle's post-step position against `scene`'s solid
519    /// voxels. See [`CollisionMode`] for the sampling caveats.
520    pub fn update_with_scene(&mut self, dt: f64, scene: &roxlap_scene::Scene) {
521        self.step(dt, Some(scene));
522    }
523
524    /// The shared body of the `update` variants.
525    fn step(&mut self, dt: f64, scene: Option<&roxlap_scene::Scene>) {
526        let dtf = dt.max(0.0) as f32;
527
528        // 1. Age + semi-implicit Euler + fade. Split field borrows:
529        //    defs are read-only here.
530        let emitters = &self.emitters;
531        for p in &mut self.particles {
532            p.age += dtf;
533            if p.age >= p.lifetime {
534                continue; // swept below
535            }
536            let def = &emitters[p.emitter_slot as usize]
537                .as_ref()
538                .expect("live particle ⇒ emitter state retained")
539                .def;
540            let prev = p.pos;
541            for a in 0..3 {
542                p.vel[a] += (def.gravity[a] - def.drag * p.vel[a]) * dtf;
543                p.pos[a] += p.vel[a] * dtf;
544            }
545            // PS.3 — voxel collision at the post-step position (see
546            // `CollisionMode` for the sampling caveats).
547            if let Some(scene) = scene {
548                if !matches!(def.collision, CollisionMode::None)
549                    && scene_solid_ahead(scene, p.pos, p.vel)
550                {
551                    match def.collision {
552                        CollisionMode::Kill => {
553                            p.age = p.lifetime; // swept below
554                            continue;
555                        }
556                        CollisionMode::Bounce { restitution } => {
557                            // Reflect the axes whose voxel boundary
558                            // was crossed this step; a same-cell hit
559                            // (spawned against a wall / nudge-early
560                            // contact) reverses outright.
561                            let mut crossed = false;
562                            for ((prev_a, pos_a), vel_a) in prev.iter().zip(&p.pos).zip(&mut p.vel)
563                            {
564                                if prev_a.floor() != pos_a.floor() {
565                                    *vel_a = -*vel_a;
566                                    crossed = true;
567                                }
568                            }
569                            for vel_a in &mut p.vel {
570                                if !crossed {
571                                    *vel_a = -*vel_a;
572                                }
573                                *vel_a *= restitution;
574                            }
575                            p.pos = prev;
576                        }
577                        CollisionMode::None => unreachable!("guarded above"),
578                    }
579                }
580            }
581            p.yaw += p.spin_rate * dtf;
582            // Over-life curves (PS.2), all keyed on the life fraction.
583            let frac = p.age / p.lifetime;
584            if let Some(end) = def.scale_end {
585                p.scale = def.scale + (end - def.scale) * frac;
586            }
587            if let Some(end) = def.tint_end {
588                p.tint = lerp_tint(p.tint_start, end, frac);
589            }
590            p.alpha = fade_alpha(p.age, p.lifetime, def.fade_in_frac, def.fade_out_frac);
591        }
592
593        // 2. Kill sweep (swap-remove keeps the pool dense).
594        let mut i = 0;
595        while i < self.particles.len() {
596            if self.particles[i].age >= self.particles[i].lifetime {
597                let p = self.particles.swap_remove(i);
598                if let Some(inst) = p.instance {
599                    self.dead_instances.push(inst);
600                }
601                self.on_particle_died(p.emitter_slot as usize);
602            } else {
603                i += 1;
604            }
605        }
606
607        // 3. Rate spawning (after the sweep, so freed budget is
608        //    available the same frame; newborns keep age 0 and the
609        //    emitter position — their first integration is next frame,
610        //    matching the pre-posed facade spawn).
611        for slot in 0..self.emitters.len() {
612            let n = {
613                let Some(state) = self.emitters[slot].as_mut() else {
614                    continue;
615                };
616                if state.retired {
617                    continue;
618                }
619                let SpawnMode::Rate(rate) = state.def.spawn else {
620                    continue;
621                };
622                state.spawn_acc += f64::from(rate) * dt.max(0.0);
623                let n = state.spawn_acc.floor();
624                state.spawn_acc -= n;
625                n as u32
626            };
627            if n > 0 {
628                self.spawn_from(slot, n);
629            }
630        }
631    }
632
633    /// The live particles, unordered (the pool swap-removes).
634    #[must_use]
635    pub fn particles(&self) -> &[Particle] {
636        &self.particles
637    }
638
639    /// Number of live particles.
640    #[must_use]
641    pub fn particle_count(&self) -> usize {
642        self.particles.len()
643    }
644
645    /// Number of active (non-retired) emitters.
646    #[must_use]
647    pub fn emitter_count(&self) -> usize {
648        self.emitters
649            .iter()
650            .filter(|e| e.as_ref().is_some_and(|s| !s.retired))
651            .count()
652    }
653
654    /// Spawns dropped by the budget since construction. A steadily
655    /// climbing value means the effect design outruns
656    /// [`set_max_particles`](Self::set_max_particles).
657    #[must_use]
658    pub fn dropped_spawns(&self) -> u64 {
659        self.dropped_spawns
660    }
661
662    /// Drain the facade instances of particles that died since the
663    /// last drain. [`sync`](Self::sync) removes each via
664    /// [`remove_sprite_instance`](crate::SceneRenderer::remove_sprite_instance);
665    /// hosts doing their own rendering can consume it directly.
666    pub fn drain_dead_instances(&mut self) -> impl Iterator<Item = SpriteInstanceId> + '_ {
667        self.dead_instances.drain(..)
668    }
669
670    /// Newborn spawns that failed because the emitter's
671    /// [`SpriteModelId`] went stale (the model was removed / the
672    /// registry was reset). Each failure kills its particle — a
673    /// climbing value means an emitter outlived its model.
674    #[must_use]
675    pub fn stale_model_kills(&self) -> u64 {
676        self.stale_model_kills
677    }
678
679    /// Mirror the simulation into `renderer` (PS.1): despawn the dead,
680    /// spawn newborns **pre-posed** (the documented streaming-spawn
681    /// path — no one-frame axis-aligned flash) with their one-time
682    /// material/lighting/shadow/tint setup, batch-move everything else
683    /// via one
684    /// [`set_sprite_instance_transforms`](SceneRenderer::set_sprite_instance_transforms),
685    /// and write alpha only for particles whose fade actually changed
686    /// (alpha has no batch API). Call after
687    /// [`update`](Self::update), before
688    /// [`render`](SceneRenderer::render) — or use
689    /// [`tick`](Self::tick).
690    pub fn sync(&mut self, renderer: &mut SceneRenderer) {
691        self.sync_with(renderer);
692    }
693
694    /// [`update`](Self::update) + [`sync`](Self::sync) in one call —
695    /// the per-frame default, named after the facade's own
696    /// [`tick`](SceneRenderer::tick).
697    pub fn tick(&mut self, renderer: &mut SceneRenderer, dt: f64) {
698        self.step(dt, None);
699        self.sync_with(renderer);
700    }
701
702    /// [`update_with_scene`](Self::update_with_scene) +
703    /// [`sync`](Self::sync) — [`tick`](Self::tick) for hosts using
704    /// [`CollisionMode`] emitters. Call before handing the same scene
705    /// to [`render`](SceneRenderer::render).
706    pub fn tick_with_scene(
707        &mut self,
708        renderer: &mut SceneRenderer,
709        dt: f64,
710        scene: &roxlap_scene::Scene,
711    ) {
712        self.step(dt, Some(scene));
713        self.sync_with(renderer);
714    }
715
716    /// [`sync`](Self::sync) against the internal facade seam — the
717    /// testable core (see [`ParticleFacade`]).
718    fn sync_with<F: ParticleFacade>(&mut self, facade: &mut F) {
719        // 1. Dead first — frees instance slots before this frame's
720        //    spawns reuse them.
721        for id in self.dead_instances.drain(..) {
722            facade.despawn(id);
723        }
724
725        // 2. One pass: spawn newborns, batch-collect live moves.
726        let mut batch = std::mem::take(&mut self.xf_scratch);
727        batch.clear();
728        let mut i = 0;
729        while i < self.particles.len() {
730            if self.particles[i].instance.is_none() {
731                let (slot, xf) = {
732                    let p = &self.particles[i];
733                    (p.emitter_slot as usize, particle_xf(p))
734                };
735                let (model, material, lighting, shadows) = {
736                    let st = self.emitters[slot]
737                        .as_ref()
738                        .expect("live particle ⇒ emitter state retained");
739                    (
740                        st.def.model,
741                        st.def.material,
742                        st.def.lighting,
743                        st.def.shadows,
744                    )
745                };
746                let Some(id) = facade.spawn(model, xf) else {
747                    // Stale model handle — this particle can never
748                    // render; kill it now rather than retry per frame.
749                    self.stale_model_kills += 1;
750                    self.particles.swap_remove(i);
751                    self.on_particle_died(slot);
752                    continue;
753                };
754                // One-time setup, skipping facade defaults (spawn
755                // already left the instance there).
756                if material != 0 {
757                    facade.set_material(id, material);
758                }
759                if lighting != BillboardLighting::default() {
760                    facade.set_lighting(id, lighting);
761                }
762                if shadows != ShadowFlags::default() {
763                    facade.set_shadows(id, shadows);
764                }
765                let p = &mut self.particles[i];
766                p.instance = Some(id);
767                // Fresh-instance facade defaults; the change-only
768                // blocks below write the real values (tint rides them
769                // too — it can lerp per frame from PS.2 on).
770                p.last_alpha = 255;
771                p.last_tint = 0x00FF_FFFF;
772            } else {
773                // Live: frame-0 pose came from the posed spawn, so
774                // only pre-existing instances join the move batch.
775                let p = &self.particles[i];
776                batch.push((p.instance.expect("checked above"), particle_xf(p)));
777            }
778            let p = &mut self.particles[i];
779            if p.alpha != p.last_alpha {
780                facade.set_alpha(p.instance.expect("set above"), p.alpha);
781                p.last_alpha = p.alpha;
782            }
783            if p.tint != p.last_tint {
784                facade.set_tint(p.instance.expect("set above"), p.tint);
785                p.last_tint = p.tint;
786            }
787            i += 1;
788        }
789        if !batch.is_empty() {
790            facade.set_transforms(&batch);
791        }
792        self.xf_scratch = batch;
793    }
794
795    /// Carve a sphere out of a grid and burst its **actual voxel
796    /// colours** as debris (PS.5) — the "shoot the wall, the wall's
797    /// colours fly off" effect: sample the solid voxels inside the
798    /// ball, `set_sphere(…, None)` them away, then spawn one
799    /// def-driven debris particle per sampled voxel, positioned at its
800    /// voxel's world centre (transform-correct for rotated grids),
801    /// tinted with its colour, and kicked radially away from the carve
802    /// centre at a speed from `outward` (on top of the def's normal
803    /// velocity terms).
804    ///
805    /// `def` supplies the model, physics, lifetime and curves;
806    /// its `pos`/`shape`/`spawn`/`tint` are ignored (position and tint
807    /// are per-voxel; nothing else auto-spawns — the transient emitter
808    /// retires immediately and drains with its last particle).
809    /// `tint_end` still applies, lerping *from the voxel colour*.
810    ///
811    /// Big carves are stride-sampled down to the system's debris cap
812    /// ([`CARVE_DEBRIS_CAP`] by default —
813    /// [`set_carve_debris_cap`](Self::set_carve_debris_cap) tunes it)
814    /// debris (an even spatial subset, not the first N); the pool
815    /// budget then applies on top, counting overflow in
816    /// [`dropped_spawns`](Self::dropped_spawns). Returns how many
817    /// debris actually spawned. A stale `grid` or an all-air ball
818    /// carves/spawns nothing.
819    pub fn carve_debris(
820        &mut self,
821        scene: &mut roxlap_scene::Scene,
822        grid: roxlap_scene::GridId,
823        centre: glam::IVec3,
824        radius: u32,
825        outward: Range<f32>,
826        def: &ParticleEmitterDef,
827    ) -> u32 {
828        let Some(g) = scene.grid_mut(grid) else {
829            return 0;
830        };
831        // 1. Sample the solid voxels' colours before they vanish.
832        #[allow(clippy::cast_possible_wrap)]
833        let r = radius as i32;
834        let mut samples: Vec<(glam::IVec3, u32)> = Vec::new();
835        for z in -r..=r {
836            for y in -r..=r {
837                for x in -r..=r {
838                    if x * x + y * y + z * z > r * r {
839                        continue;
840                    }
841                    let v = centre + glam::IVec3::new(x, y, z);
842                    if let Some(col) = g.voxel_color(v) {
843                        samples.push((v, col));
844                    }
845                }
846            }
847        }
848        let transform = g.transform;
849        // 2. Carve (even when the cap will drop some debris — the
850        //    crater is the ground truth, debris is garnish).
851        g.set_sphere(centre, radius, None);
852        if samples.is_empty() {
853            return 0;
854        }
855
856        // 3. One transient emitter carries the def; retire-drain frees
857        //    it with the last debris particle.
858        let mut def = def.clone();
859        def.spawn = SpawnMode::Manual;
860        let id = self.add_emitter(def.clone());
861        let slot = self.map.index(id).expect("just allocated");
862
863        // World-space carve centre (voxel centres sit at +0.5).
864        let to_world = |v: glam::IVec3| -> [f32; 3] {
865            let w =
866                transform.origin + transform.rotation * (v.as_dvec3() + glam::DVec3::splat(0.5));
867            #[allow(clippy::cast_possible_truncation)]
868            [w.x as f32, w.y as f32, w.z as f32]
869        };
870        let cw = to_world(centre);
871
872        let stride = samples.len().div_ceil(self.carve_debris_cap).max(1);
873        let mut spawned: u32 = 0;
874        for (v, col) in samples.iter().step_by(stride) {
875            if self.particles.len() >= self.max_particles {
876                self.dropped_spawns += 1;
877                continue;
878            }
879            let pos = to_world(*v);
880            // Radial kick away from the carve centre; the centre voxel
881            // itself scatters randomly.
882            let d = [pos[0] - cw[0], pos[1] - cw[1], pos[2] - cw[2]];
883            let len = (d[0] * d[0] + d[1] * d[1] + d[2] * d[2]).sqrt();
884            let dir = if len > 1e-4 {
885                [d[0] / len, d[1] / len, d[2] / len]
886            } else {
887                self.rng.unit_vec()
888            };
889            let mut vel = sample_velocity(&mut self.rng, &def.velocity);
890            let speed = self.rng.range_f32(&outward);
891            for a in 0..3 {
892                vel[a] += dir[a] * speed;
893            }
894            let lifetime = self.rng.range_f32(&def.lifetime).max(1e-3);
895            self.particles.push(Particle {
896                pos,
897                vel,
898                age: 0.0,
899                lifetime,
900                scale: def.scale,
901                yaw: 0.0,
902                spin_rate: self.rng.range_f32(&def.spin),
903                alpha: fade_alpha(0.0, lifetime, def.fade_in_frac, def.fade_out_frac),
904                tint: col & 0x00FF_FFFF,
905                emitter_slot: slot as u32,
906                instance: None,
907                last_alpha: 255,
908                last_tint: 0x00FF_FFFF,
909                tint_start: col & 0x00FF_FFFF,
910            });
911            spawned += 1;
912        }
913        self.emitters[slot]
914            .as_mut()
915            .expect("slot allocated above")
916            .live += spawned;
917        self.remove_emitter(id);
918        spawned
919    }
920
921    /// Spawn up to `n` particles from emitter `slot`; returns how many
922    /// fit the budget.
923    fn spawn_from(&mut self, slot: usize, n: u32) -> u32 {
924        let state = self.emitters[slot]
925            .as_mut()
926            .expect("spawn_from callers hold a live slot");
927        let def = state.def.clone(); // stack-only, cheap
928        let mut spawned = 0;
929        for _ in 0..n {
930            if self.particles.len() >= self.max_particles {
931                self.dropped_spawns += u64::from(n - spawned);
932                break;
933            }
934            // Position: emitter pos + the shape's offset (PS.2).
935            let mut pos = def.pos;
936            match def.shape {
937                EmitterShape::Point => {}
938                EmitterShape::Sphere { radius } => {
939                    // Uniform in the ball: direction × r·∛u.
940                    let dir = self.rng.unit_vec();
941                    let r = radius * self.rng.next_f32().cbrt();
942                    for a in 0..3 {
943                        pos[a] += dir[a] * r;
944                    }
945                }
946                EmitterShape::Box { half } => {
947                    for a in 0..3 {
948                        pos[a] += (self.rng.next_f32() * 2.0 - 1.0) * half[a];
949                    }
950                }
951            }
952
953            let vel = sample_velocity(&mut self.rng, &def.velocity);
954            let lifetime = self.rng.range_f32(&def.lifetime).max(1e-3);
955            self.particles.push(Particle {
956                pos,
957                vel,
958                age: 0.0,
959                lifetime,
960                scale: def.scale,
961                yaw: 0.0,
962                spin_rate: self.rng.range_f32(&def.spin),
963                alpha: fade_alpha(0.0, lifetime, def.fade_in_frac, def.fade_out_frac),
964                tint: def.tint,
965                emitter_slot: slot as u32,
966                instance: None,
967                last_alpha: 255,
968                last_tint: 0x00FF_FFFF,
969                tint_start: def.tint,
970            });
971            spawned += 1;
972        }
973        // Re-borrow: the RNG borrow above forced dropping `state`.
974        self.emitters[slot]
975            .as_mut()
976            .expect("slot unchanged during spawn")
977            .live += spawned;
978        spawned
979    }
980
981    /// Bookkeeping for one particle death: decrement the emitter's
982    /// live count and free a drained retired emitter.
983    fn on_particle_died(&mut self, slot: usize) {
984        let state = self.emitters[slot]
985            .as_mut()
986            .expect("live particle ⇒ emitter state retained");
987        state.live -= 1;
988        if state.retired && state.live == 0 {
989            self.emitters[slot] = None;
990        }
991    }
992}
993
994/// The pose a particle renders at: position + a basis rotated by
995/// [`Particle::yaw`] about the world vertical and uniformly scaled by
996/// [`Particle::scale`]. The scale clamps to ≥ 0.05 — a degenerate
997/// basis makes the facade silently skip drawing, which is right for a
998/// kill but wrong for a fade.
999fn particle_xf(p: &Particle) -> DynSpriteTransform {
1000    let k = p.scale.max(0.05);
1001    let (c, s) = (p.yaw.cos() * k, p.yaw.sin() * k);
1002    DynSpriteTransform {
1003        pos: p.pos,
1004        right: [c, s, 0.0],
1005        up: [-s, c, 0.0],
1006        forward: [0.0, 0.0, k],
1007    }
1008}
1009
1010/// Sample one initial velocity from a [`VelocityDef`]: base +
1011/// isotropic spread + cone, by addition.
1012fn sample_velocity(rng: &mut Pcg32, v: &VelocityDef) -> [f32; 3] {
1013    let mut vel = v.base;
1014    if v.spread > 0.0 {
1015        let dir = rng.unit_vec();
1016        let speed = rng.next_f32() * v.spread;
1017        for a in 0..3 {
1018            vel[a] += dir[a] * speed;
1019        }
1020    }
1021    if let Some(cone) = &v.cone {
1022        let dir = cone_dir(rng, cone.axis, cone.half_angle_deg);
1023        let speed = rng.range_f32(&cone.speed);
1024        for a in 0..3 {
1025            vel[a] += dir[a] * speed;
1026        }
1027    }
1028    vel
1029}
1030
1031/// Uniform random direction within `half_angle_deg` of `axis` —
1032/// uniform over the spherical cap, so a wide cone doesn't bunch at the
1033/// rim. A degenerate axis falls back to straight up (`[0, 0, -1]`).
1034fn cone_dir(rng: &mut Pcg32, axis: [f32; 3], half_angle_deg: f32) -> [f32; 3] {
1035    let len2 = axis[0] * axis[0] + axis[1] * axis[1] + axis[2] * axis[2];
1036    let w = if len2 > 1e-12 {
1037        let inv = 1.0 / len2.sqrt();
1038        [axis[0] * inv, axis[1] * inv, axis[2] * inv]
1039    } else {
1040        [0.0, 0.0, -1.0]
1041    };
1042    // cos θ uniform on [cos half, 1] ⇒ uniform area on the cap.
1043    let cos_half = half_angle_deg.clamp(0.0, 180.0).to_radians().cos();
1044    let cz = 1.0 - rng.next_f32() * (1.0 - cos_half);
1045    let sz = (1.0 - cz * cz).max(0.0).sqrt();
1046    let phi = rng.next_f32() * std::f32::consts::TAU;
1047    // Any orthonormal frame around `w`: cross with the axis `w` leans
1048    // on least.
1049    let t = if w[0].abs() < 0.5 {
1050        [1.0, 0.0, 0.0]
1051    } else {
1052        [0.0, 1.0, 0.0]
1053    };
1054    let u = {
1055        let c = [
1056            w[1] * t[2] - w[2] * t[1],
1057            w[2] * t[0] - w[0] * t[2],
1058            w[0] * t[1] - w[1] * t[0],
1059        ];
1060        let inv = 1.0 / (c[0] * c[0] + c[1] * c[1] + c[2] * c[2]).sqrt();
1061        [c[0] * inv, c[1] * inv, c[2] * inv]
1062    };
1063    let v = [
1064        w[1] * u[2] - w[2] * u[1],
1065        w[2] * u[0] - w[0] * u[2],
1066        w[0] * u[1] - w[1] * u[0],
1067    ];
1068    let (cp, sp) = (phi.cos() * sz, phi.sin() * sz);
1069    [
1070        w[0] * cz + u[0] * cp + v[0] * sp,
1071        w[1] * cz + u[1] * cp + v[1] * sp,
1072        w[2] * cz + u[2] * cp + v[2] * sp,
1073    ]
1074}
1075
1076/// Whether a solid voxel sits at `pos` nudged half a voxel along
1077/// `vel` — [`Scene::resolve_voxel`]'s picking nudge doing collision
1078/// look-ahead duty. Zero velocity returns `false` (a resting particle
1079/// never re-collides).
1080///
1081/// [`Scene::resolve_voxel`]: roxlap_scene::Scene::resolve_voxel
1082fn scene_solid_ahead(scene: &roxlap_scene::Scene, pos: [f32; 3], vel: [f32; 3]) -> bool {
1083    let world = glam::DVec3::new(f64::from(pos[0]), f64::from(pos[1]), f64::from(pos[2]));
1084    let dir = glam::DVec3::new(f64::from(vel[0]), f64::from(vel[1]), f64::from(vel[2]));
1085    scene.resolve_voxel(world, dir).is_some()
1086}
1087
1088/// Per-channel lerp of two packed `0x00RRGGBB` tints.
1089fn lerp_tint(a: u32, b: u32, t: f32) -> u32 {
1090    let t = t.clamp(0.0, 1.0);
1091    let ch = |sh: u32| {
1092        let (ca, cb) = ((a >> sh) & 0xff, (b >> sh) & 0xff);
1093        let m = ca as f32 + (cb as f32 - ca as f32) * t;
1094        ((m as u32) & 0xff) << sh
1095    };
1096    ch(16) | ch(8) | ch(0)
1097}
1098
1099/// The slice of [`SceneRenderer`] that [`ParticleSystem::sync`]
1100/// drives — an internal seam so the binding logic (spawn ordering,
1101/// one-time setup, change-only alpha writes) is unit-testable with a
1102/// mock, since constructing a real backend needs a window. The facade
1103/// impl is pure forwarding.
1104pub(crate) trait ParticleFacade {
1105    fn spawn(&mut self, model: SpriteModelId, xf: DynSpriteTransform) -> Option<SpriteInstanceId>;
1106    fn despawn(&mut self, id: SpriteInstanceId);
1107    fn set_transforms(&mut self, batch: &[(SpriteInstanceId, DynSpriteTransform)]);
1108    fn set_alpha(&mut self, id: SpriteInstanceId, alpha: u8);
1109    fn set_tint(&mut self, id: SpriteInstanceId, tint: u32);
1110    fn set_material(&mut self, id: SpriteInstanceId, material: u8);
1111    fn set_lighting(&mut self, id: SpriteInstanceId, mode: BillboardLighting);
1112    fn set_shadows(&mut self, id: SpriteInstanceId, flags: ShadowFlags);
1113}
1114
1115impl ParticleFacade for SceneRenderer {
1116    fn spawn(&mut self, model: SpriteModelId, xf: DynSpriteTransform) -> Option<SpriteInstanceId> {
1117        self.add_sprite_instance_posed(model, xf)
1118    }
1119    fn despawn(&mut self, id: SpriteInstanceId) {
1120        // A stale handle here means the host reset the registry
1121        // (`set_sprites`) — already gone, nothing owed.
1122        self.remove_sprite_instance(id);
1123    }
1124    fn set_transforms(&mut self, batch: &[(SpriteInstanceId, DynSpriteTransform)]) {
1125        self.set_sprite_instance_transforms(batch);
1126    }
1127    fn set_alpha(&mut self, id: SpriteInstanceId, alpha: u8) {
1128        self.set_sprite_instance_alpha(id, alpha);
1129    }
1130    fn set_tint(&mut self, id: SpriteInstanceId, tint: u32) {
1131        self.set_sprite_instance_tint(id, tint);
1132    }
1133    fn set_material(&mut self, id: SpriteInstanceId, material: u8) {
1134        self.set_sprite_instance_material(id, material);
1135    }
1136    fn set_lighting(&mut self, id: SpriteInstanceId, mode: BillboardLighting) {
1137        self.set_sprite_instance_lighting(id, mode);
1138    }
1139    fn set_shadows(&mut self, id: SpriteInstanceId, flags: ShadowFlags) {
1140        self.set_sprite_instance_shadow_flags(id, flags);
1141    }
1142}
1143
1144/// Alpha for `age` of `lifetime`: ramps 0 → 255 over the leading
1145/// `in_frac` (PS.2), 255 → 0 over the trailing `out_frac`, 255 in
1146/// between; overlapping windows take the darker of the two.
1147fn fade_alpha(age: f32, lifetime: f32, in_frac: f32, out_frac: f32) -> u8 {
1148    let frac = age / lifetime;
1149    let mut a = 1.0_f32;
1150    if in_frac > 0.0 {
1151        a = a.min((frac / in_frac.min(1.0)).clamp(0.0, 1.0));
1152    }
1153    if out_frac > 0.0 {
1154        a = a.min(((1.0 - frac) / out_frac.min(1.0)).clamp(0.0, 1.0));
1155    }
1156    (a * 255.0) as u8
1157}
1158
1159#[cfg(test)]
1160mod tests {
1161    use super::*;
1162
1163    /// A model handle for tests. The sim core never dereferences it —
1164    /// only `sync` (PS.1) resolves models — so a minted dummy is fine.
1165    fn dummy_model() -> SpriteModelId {
1166        SpriteModelId::mint(0, 0)
1167    }
1168
1169    fn base_def() -> ParticleEmitterDef {
1170        ParticleEmitterDef {
1171            spawn: SpawnMode::Manual,
1172            lifetime: 1.0..1.0,
1173            gravity: [0.0, 0.0, 0.0],
1174            fade_out_frac: 0.0,
1175            ..ParticleEmitterDef::new(dummy_model())
1176        }
1177    }
1178
1179    #[test]
1180    fn same_seed_is_bit_identical() {
1181        let run = || {
1182            let mut sys = ParticleSystem::new(0x00C0_FFEE);
1183            let em = sys.add_emitter(ParticleEmitterDef {
1184                spawn: SpawnMode::Rate(120.0),
1185                lifetime: 0.3..0.9,
1186                velocity: VelocityDef {
1187                    base: [0.0, 0.0, -10.0],
1188                    spread: 4.0,
1189                    ..VelocityDef::default()
1190                },
1191                gravity: [0.0, 0.0, 22.0],
1192                ..ParticleEmitterDef::new(dummy_model())
1193            });
1194            sys.burst(em, 7);
1195            for _ in 0..60 {
1196                sys.update(1.0 / 60.0);
1197            }
1198            sys.particles()
1199                .iter()
1200                .map(|p| (p.pos, p.vel, p.age, p.lifetime))
1201                .collect::<Vec<_>>()
1202        };
1203        let (a, b) = (run(), run());
1204        assert_eq!(a.len(), b.len());
1205        for (pa, pb) in a.iter().zip(&b) {
1206            assert_eq!(pa, pb, "same seed must be bit-identical");
1207        }
1208        assert!(!a.is_empty());
1209    }
1210
1211    #[test]
1212    fn rate_accumulates_across_frames() {
1213        let mut sys = ParticleSystem::new(1);
1214        sys.add_emitter(ParticleEmitterDef {
1215            spawn: SpawnMode::Rate(10.0),
1216            lifetime: 100.0..100.0,
1217            ..base_def()
1218        });
1219        for _ in 0..10 {
1220            sys.update(0.1);
1221        }
1222        assert_eq!(sys.particle_count(), 10);
1223
1224        // Sub-particle rates accumulate: 0.5/s over 2 s = 1 particle.
1225        let mut slow = ParticleSystem::new(2);
1226        slow.add_emitter(ParticleEmitterDef {
1227            spawn: SpawnMode::Rate(0.5),
1228            lifetime: 100.0..100.0,
1229            ..base_def()
1230        });
1231        for _ in 0..20 {
1232            slow.update(0.1);
1233        }
1234        assert_eq!(slow.particle_count(), 1);
1235    }
1236
1237    #[test]
1238    fn burst_mode_fires_on_add() {
1239        let mut sys = ParticleSystem::new(3);
1240        sys.add_emitter(ParticleEmitterDef {
1241            spawn: SpawnMode::Burst(5),
1242            ..base_def()
1243        });
1244        assert_eq!(sys.particle_count(), 5);
1245    }
1246
1247    #[test]
1248    fn budget_drops_spawns_and_counts_them() {
1249        let mut sys = ParticleSystem::new(4);
1250        sys.set_max_particles(5);
1251        let em = sys.add_emitter(base_def());
1252        assert_eq!(sys.burst(em, 10), 5);
1253        assert_eq!(sys.particle_count(), 5);
1254        assert_eq!(sys.dropped_spawns(), 5);
1255    }
1256
1257    #[test]
1258    fn particles_die_at_lifetime() {
1259        let mut sys = ParticleSystem::new(5);
1260        let em = sys.add_emitter(ParticleEmitterDef {
1261            lifetime: 0.5..0.5,
1262            ..base_def()
1263        });
1264        sys.burst(em, 3);
1265        sys.update(0.3);
1266        assert_eq!(sys.particle_count(), 3);
1267        sys.update(0.3); // age 0.6 ≥ 0.5
1268        assert_eq!(sys.particle_count(), 0);
1269        // Never synced ⇒ no facade instances owed.
1270        assert_eq!(sys.drain_dead_instances().count(), 0);
1271    }
1272
1273    #[test]
1274    fn semi_implicit_euler_gravity() {
1275        let mut sys = ParticleSystem::new(6);
1276        let em = sys.add_emitter(ParticleEmitterDef {
1277            gravity: [0.0, 0.0, 10.0],
1278            lifetime: 100.0..100.0,
1279            ..base_def()
1280        });
1281        sys.burst(em, 1);
1282        sys.update(0.1);
1283        let p = sys.particles()[0];
1284        // vel += g·dt first, then pos += vel·dt.
1285        assert!((p.vel[2] - 1.0).abs() < 1e-6);
1286        assert!((p.pos[2] - 0.1).abs() < 1e-6);
1287    }
1288
1289    #[test]
1290    fn fade_curve_hits_endpoints() {
1291        assert_eq!(fade_alpha(0.0, 1.0, 0.0, 0.5), 255);
1292        assert_eq!(fade_alpha(0.5, 1.0, 0.0, 0.5), 255); // window edge
1293        assert_eq!(fade_alpha(0.75, 1.0, 0.0, 0.5), 127); // mid-fade
1294        assert_eq!(fade_alpha(1.0, 1.0, 0.0, 0.5), 0);
1295        assert_eq!(fade_alpha(0.99, 1.0, 0.0, 0.0), 255); // no fade
1296                                                          // Fade-in (PS.2): born dark, ramps up, then the out-window
1297                                                          // takes over; overlap picks the darker.
1298        assert_eq!(fade_alpha(0.0, 1.0, 0.25, 0.0), 0);
1299        assert_eq!(fade_alpha(0.125, 1.0, 0.25, 0.0), 127);
1300        assert_eq!(fade_alpha(0.25, 1.0, 0.25, 0.0), 255);
1301        assert_eq!(fade_alpha(0.5, 1.0, 1.0, 1.0), 127); // crossover
1302    }
1303
1304    #[test]
1305    fn shapes_sample_within_bounds() {
1306        let mut sys = ParticleSystem::new(20);
1307        let sphere = sys.add_emitter(ParticleEmitterDef {
1308            pos: [10.0, 0.0, 0.0],
1309            shape: EmitterShape::Sphere { radius: 3.0 },
1310            ..base_def()
1311        });
1312        let boxy = sys.add_emitter(ParticleEmitterDef {
1313            pos: [-10.0, 0.0, 0.0],
1314            shape: EmitterShape::Box {
1315                half: [1.0, 2.0, 0.5],
1316            },
1317            ..base_def()
1318        });
1319        sys.burst(sphere, 64);
1320        sys.burst(boxy, 64);
1321        let mut spread = false;
1322        for p in sys.particles() {
1323            if p.pos[0] > 0.0 {
1324                let d = [p.pos[0] - 10.0, p.pos[1], p.pos[2]];
1325                let r = (d[0] * d[0] + d[1] * d[1] + d[2] * d[2]).sqrt();
1326                assert!(r <= 3.0 + 1e-4, "sphere sample outside radius: {r}");
1327                spread |= r > 0.1;
1328            } else {
1329                let d = [p.pos[0] + 10.0, p.pos[1], p.pos[2]];
1330                assert!(
1331                    d[0].abs() <= 1.0 + 1e-4
1332                        && d[1].abs() <= 2.0 + 1e-4
1333                        && d[2].abs() <= 0.5 + 1e-4,
1334                    "box sample outside half-extents: {d:?}"
1335                );
1336                spread |= d[1].abs() > 1.0; // uses the wide axis
1337            }
1338        }
1339        assert!(spread, "samples must actually spread out");
1340    }
1341
1342    #[test]
1343    fn cone_stays_within_half_angle() {
1344        let mut sys = ParticleSystem::new(21);
1345        let axis = [0.0, 1.0, -1.0]; // non-unit on purpose
1346        let em = sys.add_emitter(ParticleEmitterDef {
1347            velocity: VelocityDef {
1348                cone: Some(ConeDef {
1349                    axis,
1350                    half_angle_deg: 30.0,
1351                    speed: 5.0..10.0,
1352                }),
1353                ..VelocityDef::default()
1354            },
1355            ..base_def()
1356        });
1357        sys.burst(em, 64);
1358        let inv = 1.0 / (2.0_f32).sqrt();
1359        let w = [0.0, inv, -inv];
1360        let cos_half = 30.0_f32.to_radians().cos();
1361        for p in sys.particles() {
1362            let sp = (p.vel[0] * p.vel[0] + p.vel[1] * p.vel[1] + p.vel[2] * p.vel[2]).sqrt();
1363            assert!(
1364                (5.0 - 1e-3..10.0 + 1e-3).contains(&sp),
1365                "speed in range: {sp}"
1366            );
1367            let cosang = (p.vel[0] * w[0] + p.vel[1] * w[1] + p.vel[2] * w[2]) / sp;
1368            assert!(
1369                cosang >= cos_half - 1e-4,
1370                "direction within the cone: cos {cosang} < {cos_half}"
1371            );
1372        }
1373        // A zero axis falls back to straight up (-z).
1374        assert_eq!(
1375            cone_dir(&mut Pcg32::new(1), [0.0; 3], 0.0),
1376            [0.0, 0.0, -1.0]
1377        );
1378    }
1379
1380    #[test]
1381    fn spin_rotates_pose_and_keeps_scale() {
1382        let mut sys = ParticleSystem::new(22);
1383        let em = sys.add_emitter(ParticleEmitterDef {
1384            spin: 2.0..2.0,
1385            scale: 3.0,
1386            lifetime: 100.0..100.0,
1387            ..base_def()
1388        });
1389        sys.burst(em, 1);
1390        sys.update(0.25); // yaw = 0.5 rad
1391        let p = sys.particles()[0];
1392        assert!((p.yaw - 0.5).abs() < 1e-6);
1393        let xf = particle_xf(&p);
1394        // Columns stay orthogonal with length == scale.
1395        let len = |v: [f32; 3]| (v[0] * v[0] + v[1] * v[1] + v[2] * v[2]).sqrt();
1396        assert!((len(xf.right) - 3.0).abs() < 1e-5);
1397        assert!((len(xf.up) - 3.0).abs() < 1e-5);
1398        assert!(xf.right[1].abs() > 0.1, "yaw actually rotates the basis");
1399        let dot = xf.right[0] * xf.up[0] + xf.right[1] * xf.up[1];
1400        assert!(dot.abs() < 1e-5);
1401    }
1402
1403    #[test]
1404    fn scale_and_tint_lerp_over_life() {
1405        let mut sys = ParticleSystem::new(23);
1406        let em = sys.add_emitter(ParticleEmitterDef {
1407            lifetime: 1.0..1.0,
1408            scale: 1.0,
1409            scale_end: Some(3.0),
1410            tint: 0x00FF_0000,
1411            tint_end: Some(0x0000_00FF),
1412            ..base_def()
1413        });
1414        sys.burst(em, 1);
1415        sys.update(0.5);
1416        let p = sys.particles()[0];
1417        assert!((p.scale - 2.0).abs() < 1e-5, "mid-life scale: {}", p.scale);
1418        let (r, b) = ((p.tint >> 16) & 0xff, p.tint & 0xff);
1419        assert!(
1420            (126..=128).contains(&r) && (126..=128).contains(&b),
1421            "mid tint: {:#08x}",
1422            p.tint
1423        );
1424
1425        // sync writes the lerping tint every frame it changes.
1426        let mut f = Mock::default();
1427        sys.sync_with(&mut f);
1428        assert_eq!(f.tints.len(), 1);
1429        sys.update(0.1);
1430        sys.sync_with(&mut f);
1431        assert_eq!(f.tints.len(), 2, "changed tint re-syncs");
1432    }
1433
1434    #[test]
1435    fn retired_emitter_drains_then_frees() {
1436        let mut sys = ParticleSystem::new(7);
1437        let em = sys.add_emitter(ParticleEmitterDef {
1438            spawn: SpawnMode::Rate(1000.0),
1439            lifetime: 0.2..0.2,
1440            ..base_def()
1441        });
1442        sys.update(0.01);
1443        assert!(sys.particle_count() > 0);
1444        assert!(sys.remove_emitter(em));
1445        assert_eq!(sys.emitter_count(), 0);
1446        // Stale-handle ops are safe no-ops.
1447        assert!(!sys.remove_emitter(em));
1448        assert!(!sys.set_emitter_pos(em, [1.0, 2.0, 3.0]));
1449        assert_eq!(sys.burst(em, 10), 0);
1450        // In-flight particles live out their lifetime, then the slot
1451        // frees.
1452        let live = sys.particle_count();
1453        sys.update(0.3);
1454        assert_eq!(sys.particle_count(), 0);
1455        assert!(live > 0);
1456        assert!(sys.emitters.iter().all(Option::is_none));
1457    }
1458
1459    /// Records every facade call `sync` makes; mints sequential ids.
1460    #[derive(Default)]
1461    struct Mock {
1462        next_slot: u32,
1463        fail_spawn: bool,
1464        spawns: Vec<(SpriteModelId, DynSpriteTransform)>,
1465        despawns: Vec<SpriteInstanceId>,
1466        batch_sizes: Vec<usize>,
1467        alphas: Vec<(SpriteInstanceId, u8)>,
1468        tints: Vec<u32>,
1469        materials: Vec<u8>,
1470        lightings: Vec<BillboardLighting>,
1471        shadows: Vec<ShadowFlags>,
1472    }
1473
1474    impl ParticleFacade for Mock {
1475        fn spawn(
1476            &mut self,
1477            model: SpriteModelId,
1478            xf: DynSpriteTransform,
1479        ) -> Option<SpriteInstanceId> {
1480            if self.fail_spawn {
1481                return None;
1482            }
1483            self.spawns.push((model, xf));
1484            let id = SpriteInstanceId {
1485                slot: self.next_slot,
1486                gen: 0,
1487            };
1488            self.next_slot += 1;
1489            Some(id)
1490        }
1491        fn despawn(&mut self, id: SpriteInstanceId) {
1492            self.despawns.push(id);
1493        }
1494        fn set_transforms(&mut self, batch: &[(SpriteInstanceId, DynSpriteTransform)]) {
1495            self.batch_sizes.push(batch.len());
1496        }
1497        fn set_alpha(&mut self, id: SpriteInstanceId, alpha: u8) {
1498            self.alphas.push((id, alpha));
1499        }
1500        fn set_tint(&mut self, _id: SpriteInstanceId, tint: u32) {
1501            self.tints.push(tint);
1502        }
1503        fn set_material(&mut self, _id: SpriteInstanceId, material: u8) {
1504            self.materials.push(material);
1505        }
1506        fn set_lighting(&mut self, _id: SpriteInstanceId, mode: BillboardLighting) {
1507            self.lightings.push(mode);
1508        }
1509        fn set_shadows(&mut self, _id: SpriteInstanceId, flags: ShadowFlags) {
1510            self.shadows.push(flags);
1511        }
1512    }
1513
1514    #[test]
1515    fn sync_spawns_once_then_batch_moves() {
1516        let mut sys = ParticleSystem::new(10);
1517        let em = sys.add_emitter(ParticleEmitterDef {
1518            lifetime: 100.0..100.0,
1519            ..base_def()
1520        });
1521        sys.burst(em, 3);
1522        let mut f = Mock::default();
1523
1524        sys.sync_with(&mut f);
1525        // Newborns spawn pre-posed — no move batch for them.
1526        assert_eq!(f.spawns.len(), 3);
1527        assert!(f.batch_sizes.is_empty());
1528        // Particle shadows default off ≠ facade default (cast+receive)
1529        // ⇒ set once each; everything else is at facade defaults.
1530        assert_eq!(f.shadows.len(), 3);
1531        assert!(f.materials.is_empty() && f.tints.is_empty() && f.lightings.is_empty());
1532        assert!(f.alphas.is_empty(), "alpha 255 == facade default");
1533
1534        // Next frame: no new spawns, one batch of 3.
1535        sys.update(0.01);
1536        sys.sync_with(&mut f);
1537        assert_eq!(f.spawns.len(), 3);
1538        assert_eq!(f.batch_sizes, vec![3]);
1539    }
1540
1541    #[test]
1542    fn sync_one_time_setup_honours_def() {
1543        let mut sys = ParticleSystem::new(11);
1544        let em = sys.add_emitter(ParticleEmitterDef {
1545            lifetime: 100.0..100.0,
1546            tint: 0x00FF_0000,
1547            material: 5,
1548            lighting: BillboardLighting::FullBright,
1549            shadows: ShadowFlags::default(), // back to facade default
1550            ..base_def()
1551        });
1552        sys.burst(em, 1);
1553        let mut f = Mock::default();
1554        sys.sync_with(&mut f);
1555        assert_eq!(f.materials, vec![5]);
1556        assert_eq!(f.tints, vec![0x00FF_0000]);
1557        assert_eq!(f.lightings, vec![BillboardLighting::FullBright]);
1558        assert!(f.shadows.is_empty(), "facade-default shadows skip the call");
1559        // Re-sync repeats none of it: still one call per family.
1560        sys.update(0.01);
1561        sys.sync_with(&mut f);
1562        assert_eq!(f.materials.len() + f.tints.len() + f.lightings.len(), 3);
1563    }
1564
1565    #[test]
1566    fn sync_despawns_dead_and_writes_alpha_on_change_only() {
1567        let mut sys = ParticleSystem::new(12);
1568        let em = sys.add_emitter(ParticleEmitterDef {
1569            lifetime: 1.0..1.0,
1570            fade_out_frac: 0.5,
1571            ..base_def()
1572        });
1573        sys.burst(em, 2);
1574        let mut f = Mock::default();
1575        sys.sync_with(&mut f);
1576
1577        // Pre-fade window: alpha stays 255, no writes.
1578        sys.update(0.25);
1579        sys.sync_with(&mut f);
1580        assert!(f.alphas.is_empty());
1581
1582        // Inside the fade window: one write per particle per change.
1583        sys.update(0.5); // age 0.75 ⇒ alpha 127
1584        sys.sync_with(&mut f);
1585        assert_eq!(f.alphas.len(), 2);
1586        assert!(f.alphas.iter().all(|&(_, a)| a == 127));
1587
1588        // Death ⇒ despawn of exactly the minted ids.
1589        sys.update(0.5);
1590        assert_eq!(sys.particle_count(), 0);
1591        sys.sync_with(&mut f);
1592        assert_eq!(f.despawns.len(), 2);
1593    }
1594
1595    #[test]
1596    fn stale_model_spawn_kills_particle() {
1597        let mut sys = ParticleSystem::new(13);
1598        let em = sys.add_emitter(base_def());
1599        sys.burst(em, 2);
1600        let mut f = Mock {
1601            fail_spawn: true,
1602            ..Mock::default()
1603        };
1604        sys.sync_with(&mut f);
1605        assert_eq!(sys.particle_count(), 0);
1606        assert_eq!(sys.stale_model_kills(), 2);
1607        // The emitter's live count drained cleanly: removing it frees
1608        // the slot immediately.
1609        assert!(sys.remove_emitter(em));
1610        assert!(sys.emitters.iter().all(Option::is_none));
1611    }
1612
1613    #[test]
1614    fn particle_xf_scales_and_clamps() {
1615        let p = Particle {
1616            pos: [1.0, 2.0, 3.0],
1617            vel: [0.0; 3],
1618            age: 0.0,
1619            lifetime: 1.0,
1620            scale: 2.0,
1621            yaw: 0.0,
1622            spin_rate: 0.0,
1623            alpha: 255,
1624            tint: 0,
1625            emitter_slot: 0,
1626            instance: None,
1627            last_alpha: 255,
1628            last_tint: 0x00FF_FFFF,
1629            tint_start: 0,
1630        };
1631        let xf = particle_xf(&p);
1632        assert_eq!(xf.pos, [1.0, 2.0, 3.0]);
1633        assert_eq!((xf.right[0], xf.up[1], xf.forward[2]), (2.0, 2.0, 2.0));
1634        // Degenerate scale clamps to the visible minimum.
1635        let tiny = particle_xf(&Particle { scale: 0.0, ..p });
1636        assert_eq!(tiny.right[0], 0.05);
1637    }
1638
1639    /// A scene with a solid slab: z ∈ 10..=12 across generous xy.
1640    fn scene_with_floor() -> roxlap_scene::Scene {
1641        use glam::{DVec3, IVec3};
1642        let mut scene = roxlap_scene::Scene::new();
1643        let id = scene.add_grid(roxlap_scene::GridTransform::at(DVec3::ZERO));
1644        let g = scene.grid_mut(id).expect("fresh grid");
1645        g.set_rect(
1646            IVec3::new(-16, -16, 10),
1647            IVec3::new(16, 16, 12),
1648            Some(0x80FF_FFFF),
1649        );
1650        scene
1651    }
1652
1653    /// An emitter dropping one particle straight down (+z) at the slab.
1654    fn falling(collision: CollisionMode) -> ParticleEmitterDef {
1655        ParticleEmitterDef {
1656            pos: [0.0, 0.0, 5.0],
1657            velocity: VelocityDef {
1658                base: [0.0, 0.0, 20.0],
1659                ..VelocityDef::default()
1660            },
1661            lifetime: 100.0..100.0,
1662            collision,
1663            ..base_def() // zero gravity — constant fall speed
1664        }
1665    }
1666
1667    #[test]
1668    fn collision_kill_dies_on_contact() {
1669        let scene = scene_with_floor();
1670        let mut sys = ParticleSystem::new(30);
1671        let em = sys.add_emitter(falling(CollisionMode::Kill));
1672        sys.burst(em, 1);
1673        for _ in 0..20 {
1674            sys.update_with_scene(0.05, &scene);
1675        }
1676        assert_eq!(sys.particle_count(), 0, "dies at the slab");
1677
1678        // The scene-free update never collides, whatever the mode.
1679        let mut free = ParticleSystem::new(30);
1680        let em = free.add_emitter(falling(CollisionMode::Kill));
1681        free.burst(em, 1);
1682        for _ in 0..20 {
1683            free.update(0.05);
1684        }
1685        assert_eq!(free.particle_count(), 1);
1686        assert!(free.particles()[0].pos[2] > 12.0, "fell straight through");
1687    }
1688
1689    #[test]
1690    fn collision_none_passes_through() {
1691        let scene = scene_with_floor();
1692        let mut sys = ParticleSystem::new(31);
1693        let em = sys.add_emitter(falling(CollisionMode::None));
1694        sys.burst(em, 1);
1695        for _ in 0..20 {
1696            sys.update_with_scene(0.05, &scene);
1697        }
1698        assert!(sys.particles()[0].pos[2] > 12.0);
1699    }
1700
1701    #[test]
1702    fn collision_bounce_reflects_and_damps() {
1703        let scene = scene_with_floor();
1704        let mut sys = ParticleSystem::new(32);
1705        let em = sys.add_emitter(falling(CollisionMode::Bounce { restitution: 0.5 }));
1706        sys.burst(em, 1);
1707        let mut bounced = false;
1708        for _ in 0..40 {
1709            sys.update_with_scene(0.05, &scene);
1710            let p = sys.particles()[0];
1711            assert!(p.pos[2] < 10.5, "never sinks into the slab: {}", p.pos[2]);
1712            if p.vel[2] < 0.0 {
1713                bounced = true;
1714            }
1715        }
1716        assert!(bounced, "velocity reflected upward");
1717        let p = sys.particles()[0];
1718        assert!(
1719            p.vel[2].abs() <= 10.0 + 1e-3,
1720            "restitution halves the speed: {}",
1721            p.vel[2]
1722        );
1723    }
1724
1725    #[test]
1726    fn carve_debris_samples_colours_and_carves() {
1727        use glam::{DVec3, IVec3};
1728        // A dedicated slab in one recognisable colour.
1729        let mut scene = roxlap_scene::Scene::new();
1730        let grid = scene.add_grid(roxlap_scene::GridTransform::at(DVec3::ZERO));
1731        scene.grid_mut(grid).expect("grid").set_rect(
1732            IVec3::new(-8, -8, 10),
1733            IVec3::new(8, 8, 12),
1734            Some(0x80_12_34_56),
1735        );
1736        let mut sys = ParticleSystem::new(40);
1737        let centre = IVec3::new(0, 0, 11);
1738        let spawned = sys.carve_debris(
1739            &mut scene,
1740            grid,
1741            centre,
1742            2,
1743            4.0..6.0,
1744            &ParticleEmitterDef {
1745                lifetime: 100.0..100.0,
1746                ..base_def()
1747            },
1748        );
1749        assert!(spawned > 0);
1750        assert_eq!(sys.particle_count() as u32, spawned);
1751        // Every debris particle wears the sampled voxel colour.
1752        for p in sys.particles() {
1753            assert_eq!(p.tint, 0x0012_3456, "tint is the voxel colour");
1754        }
1755        // The ball is actually gone from the grid.
1756        let g = scene.grid_mut(grid).expect("grid");
1757        assert!(g.voxel_color(centre).is_none());
1758        // Radial kick: particles off-centre move away from the centre.
1759        for p in sys.particles() {
1760            let d = [p.pos[0] - 0.5, p.pos[1] - 0.5, p.pos[2] - 11.5];
1761            let r2 = d[0] * d[0] + d[1] * d[1] + d[2] * d[2];
1762            if r2 > 0.5 {
1763                let dot = d[0] * p.vel[0] + d[1] * p.vel[1] + d[2] * p.vel[2];
1764                assert!(dot > 0.0, "debris flies outward: {d:?} vs {:?}", p.vel);
1765            }
1766        }
1767        // The transient emitter retired; it drains with its debris.
1768        assert_eq!(sys.emitter_count(), 0);
1769        sys.update(200.0);
1770        assert_eq!(sys.particle_count(), 0);
1771        assert!(sys.emitters.iter().all(Option::is_none));
1772    }
1773
1774    #[test]
1775    fn carve_debris_caps_big_carves_and_respects_budget() {
1776        use glam::IVec3;
1777        let mut scene = scene_with_floor();
1778        let grid = scene.grids().next().expect("one grid").0;
1779        let mut sys = ParticleSystem::new(41);
1780        // Radius 6 ball in a 3-thick slab ≫ the cap.
1781        let spawned = sys.carve_debris(
1782            &mut scene,
1783            grid,
1784            IVec3::new(0, 0, 11),
1785            6,
1786            1.0..2.0,
1787            &ParticleEmitterDef {
1788                lifetime: 100.0..100.0,
1789                ..base_def()
1790            },
1791        );
1792        assert!(spawned as usize <= CARVE_DEBRIS_CAP);
1793        assert!(
1794            spawned as usize > CARVE_DEBRIS_CAP / 2,
1795            "stride fills near the cap"
1796        );
1797
1798        // A lowered per-system cap throttles the same carve.
1799        let mut low = ParticleSystem::new(44);
1800        low.set_carve_debris_cap(16);
1801        let mut scene3 = scene_with_floor();
1802        let spawned = low.carve_debris(
1803            &mut scene3,
1804            grid,
1805            IVec3::new(0, 0, 11),
1806            6,
1807            1.0..2.0,
1808            &ParticleEmitterDef {
1809                lifetime: 100.0..100.0,
1810                ..base_def()
1811            },
1812        );
1813        assert!(spawned <= 16, "tuned cap respected: {spawned}");
1814        assert!(spawned >= 8, "stride still fills near the tuned cap");
1815
1816        // Pool budget on top: a tiny pool drops the rest, counted.
1817        let mut tiny = ParticleSystem::new(42);
1818        tiny.set_max_particles(5);
1819        let mut scene2 = scene_with_floor();
1820        let spawned = tiny.carve_debris(
1821            &mut scene2,
1822            grid,
1823            IVec3::new(10, 10, 11),
1824            4,
1825            1.0..2.0,
1826            &ParticleEmitterDef {
1827                lifetime: 100.0..100.0,
1828                ..base_def()
1829            },
1830        );
1831        assert_eq!(spawned, 5);
1832        assert!(tiny.dropped_spawns() > 0);
1833    }
1834
1835    #[test]
1836    fn carve_debris_tint_end_lerps_from_voxel_colour() {
1837        use glam::IVec3;
1838        let mut scene = scene_with_floor();
1839        let grid = scene.grids().next().expect("one grid").0;
1840        let mut sys = ParticleSystem::new(43);
1841        sys.carve_debris(
1842            &mut scene,
1843            grid,
1844            IVec3::new(0, 0, 11),
1845            1,
1846            0.0..0.0,
1847            &ParticleEmitterDef {
1848                lifetime: 1.0..1.0,
1849                tint_end: Some(0x0000_0000),
1850                ..base_def()
1851            },
1852        );
1853        assert!(sys.particle_count() > 0);
1854        sys.update(0.5);
1855        // Slab colour 0x00FF_FFFF darkening toward black: mid ≈ 127.
1856        for p in sys.particles() {
1857            let r = (p.tint >> 16) & 0xff;
1858            assert!(
1859                (120..=135).contains(&r),
1860                "lerp starts at the voxel colour, not def.tint: {:#08x}",
1861                p.tint
1862            );
1863        }
1864    }
1865
1866    #[test]
1867    fn stress_10k_particles_simulate_and_sync() {
1868        let mut sys = ParticleSystem::new(50);
1869        sys.set_max_particles(10_000);
1870        let em = sys.add_emitter(ParticleEmitterDef {
1871            // Short life ⇒ the u8 fade actually steps every frame (a
1872            // 100 s life would quantise alpha to one write per ~12
1873            // frames); 2 s > the 30 simulated frames, so none die.
1874            lifetime: 2.0..2.0,
1875            velocity: VelocityDef {
1876                spread: 10.0,
1877                ..VelocityDef::default()
1878            },
1879            // Fade the whole life ⇒ alpha changes (and syncs) every frame.
1880            fade_in_frac: 0.5,
1881            fade_out_frac: 0.5,
1882            spin: -3.0..3.0,
1883            scale_end: Some(2.0),
1884            tint_end: Some(0x0000_0000),
1885            gravity: [0.0, 0.0, 5.0],
1886            ..base_def()
1887        });
1888        assert_eq!(sys.burst(em, 10_000), 10_000);
1889        let mut f = Mock::default();
1890        for _ in 0..30 {
1891            sys.update(1.0 / 60.0);
1892            sys.sync_with(&mut f);
1893        }
1894        assert_eq!(sys.particle_count(), 10_000);
1895        assert_eq!(f.spawns.len(), 10_000);
1896        // Every later frame batch-moves all 10k and rewrites the
1897        // churning alpha/tint per instance.
1898        assert_eq!(*f.batch_sizes.last().expect("batches ran"), 10_000);
1899        assert!(f.alphas.len() > 100_000, "alpha churns every frame");
1900    }
1901
1902    /// Manual perf probe (release): `cargo test -p roxlap-render
1903    /// --release stress_10k_probe -- --ignored --nocapture`.
1904    #[test]
1905    #[ignore = "manual perf probe — prints timings, asserts nothing"]
1906    fn stress_10k_probe() {
1907        let mut sys = ParticleSystem::new(51);
1908        sys.set_max_particles(10_000);
1909        let em = sys.add_emitter(ParticleEmitterDef {
1910            // 8 s life: the fade steps ~every frame across the 200
1911            // timed frames (worst-case alpha churn) and nothing dies.
1912            lifetime: 8.0..8.0,
1913            velocity: VelocityDef {
1914                spread: 10.0,
1915                ..VelocityDef::default()
1916            },
1917            fade_in_frac: 0.5,
1918            fade_out_frac: 0.5,
1919            spin: -3.0..3.0,
1920            scale_end: Some(2.0),
1921            tint_end: Some(0x0000_0000),
1922            ..base_def()
1923        });
1924        sys.burst(em, 10_000);
1925        let mut f = Mock::default();
1926        sys.sync_with(&mut f); // spawn frame excluded from timing
1927        let t0 = std::time::Instant::now();
1928        const FRAMES: u32 = 200;
1929        for _ in 0..FRAMES {
1930            sys.update(1.0 / 60.0);
1931            sys.sync_with(&mut f);
1932        }
1933        let per_frame = t0.elapsed() / FRAMES;
1934        eprintln!("10k particles: {per_frame:?}/frame (update + sync w/ alpha+tint churn)");
1935    }
1936
1937    #[test]
1938    fn moved_emitter_spawns_at_new_pos() {
1939        let mut sys = ParticleSystem::new(8);
1940        let em = sys.add_emitter(base_def());
1941        assert!(sys.set_emitter_pos(em, [5.0, 6.0, 7.0]));
1942        sys.burst(em, 1);
1943        assert_eq!(sys.particles()[0].pos, [5.0, 6.0, 7.0]);
1944    }
1945}