Skip to main content

rlx_core/render/scenes/
emitter.rs

1//! Emitter scene: objects that **spawn**, follow an **analytic** ballistic path,
2//! age, and are **retired** — the first scene in the engine whose population is
3//! not fixed (ADR-0057).
4//!
5//! It exists beside the swarm rather than inside it. The swarm's world is a
6//! **torus** (ADR-0044): `bounds(aspect)` wraps every particle back into frame,
7//! deliberately, so the field stays populated with no respawn hitches. A cascade
8//! is the opposite requirement — a thing that falls out of shot and does not come
9//! back — so the two worlds cannot share one scene without a mode switch that
10//! changes the world topology.
11//!
12//! # Position is a closed form, not an accumulator
13//!
14//! Each object stores its spawn time, spawn position, launch velocity and the
15//! gravity it was launched under; its position at scene time `t` is
16//!
17//! ```text
18//! p(t) = p0 + v0 * (t - t0) + 0.5 * a * (t - t0)^2
19//! ```
20//!
21//! There is **no `dt` in the position at all**, so the trajectory is exactly
22//! frame-rate independent by construction rather than by tuning — the `SCENE_DT`
23//! class of divergence (Plan 0014) cannot reappear here. It also makes the
24//! arithmetic checkable: an object launched with vertical speed `v0` against
25//! gravity `g` reaches its apex at `t = v0 / g` and at height `v0^2 / (2 g)`, on
26//! any cadence. See
27//! `an_object_follows_the_closed_form_parabola`.
28//!
29//! **Retirement is a closed form too, and that is not decoration.** Sampling "is
30//! this object outside the frame?" once per frame would make the *population* a
31//! function of where the frames happened to land — an object that arcs above the
32//! top bound and falls back would be culled by a cadence that sampled while it was
33//! out and survive one that did not. So each object's death time is solved at
34//! spawn: the earliest of its lifetime, the time it leaves through a side (linear
35//! in `t`), and the last time it is above the bottom bound (the larger root of the
36//! quadratic). Retirement is then `time >= death_time`, monotone in scene time,
37//! and the whole scene is a pure function of `(seed, scene time)`.
38//!
39//! # The pool is fixed and spawning is clamped to it
40//!
41//! Spawn/die is the one place this scene could allocate on the hot path, so it
42//! cannot: the object array and its free list are sized once from
43//! [`TierConfig::emitter_objects`](crate::render::TierConfig::emitter_objects) and
44//! never grow. When the pool is full a spawn is **dropped** — not queued, not
45//! allocated for — and the spawn loop is capped at one pool's worth of spawns per
46//! frame so an absurd `spawn_rate` costs bounded work rather than a stall.
47//!
48//! Objects draw through the swarm's sprite idiom — `vec4(colour * g, g)` on
49//! `gpu::ADDITIVE_LIGHT_SATURATING_COVERAGE` — so this is the **third** pipeline
50//! that writes directly into the post chain's input and it owes the third
51//! lit-backdrop guard (ADR-0056).
52
53// Hot-path panic-denial pragma (Plan 0002 Phase 2, extended to scenes by Plan
54// 0003 Phase 0). Runs every displayed frame.
55#![deny(
56    clippy::unwrap_used,
57    clippy::expect_used,
58    clippy::indexing_slicing,
59    clippy::panic,
60    clippy::unreachable
61)]
62
63use super::common;
64use super::marks;
65use super::{Scene, SeededRng};
66use crate::dsp::AnalysisFrame;
67use crate::render::palette::{self, Palette};
68use crate::render::scenes::{ParamKind, ParamSpec, default_of};
69
70/// The scene's spawn seed — the only randomness it has, and it is explicit
71/// (NFR §6). The bytes are ASCII used as a number: re-spelling them to match
72/// a renamed prefix changes every spawn and moves this scene's goldens, so
73/// the value is opaque.
74const SEED: u64 = 0x4C4D_565F_454D_4954;
75
76/// Domain aspect before the first [`Scene::render`] hands one over. Only reached
77/// on the very first `update` of a fresh scene.
78const FALLBACK_ASPECT: f32 = 16.0 / 9.0;
79
80/// How far past the visible frame an object may travel before it is gone for
81/// good, as a multiple of the frame's half-extents.
82///
83/// The visible frame is `|world.y| <= 1` by `|world.x| <= aspect` (the shader
84/// divides x by the aspect on its way to NDC), so this is the visible rectangle
85/// scaled outward. It is deliberately generous: a sprite is retired when its
86/// *centre* passes the bound, and a mark still half in frame that vanished would
87/// read as a pop rather than an exit.
88const RETIRE_MARGIN: f32 = 1.6;
89
90/// The sprite's world half-size at `size = 1`, before the per-object size draw.
91const BASE_SIZE: f32 = 0.019;
92
93/// The mark's minor axis as a fraction of its major one — **this scene's `disc`**.
94///
95/// It exists because a perfect disc is rotationally symmetric, so rotating its
96/// quad would change nothing at all and `spin` would join the list of parameters
97/// that are documented and do nothing (the swarm's `hue` was one for four plans).
98/// One constant on one radial falloff makes the mark a soft elongated *glint*
99/// instead, which a rotation can be seen in.
100///
101/// Sized so the elongation reads at a few pixels across without the mark becoming
102/// a streak: at 0.55 the long axis is not quite twice the short one.
103///
104/// **Plan 0070 answered the shape question and deliberately left this arm alone**
105/// (ADR-0084): `shape = disc` on the emitter is *this* figure, not a circle, so
106/// every existing preset and the golden baseline are untouched. The roster's
107/// other four silhouettes are evaluated on the un-squashed sprite frame, so a
108/// star is a star rather than a squashed one — and `spin` turns it, which is what
109/// makes a shaped mark read as an object.
110const GLINT_ANISO: f32 = 0.55;
111
112/// The per-object twinkle rate, in Hz, at the two ends of the seeded draw.
113///
114/// **The spread across objects is the point, not the values.** A field of
115/// oscillators that all share a rate flashes as one sheet however their phases
116/// are scattered — the sum of N sinusoids at one frequency is a sinusoid at that
117/// frequency. Drawing the *rate* per object as well is what makes the whole-frame
118/// mean steady while every member of it is not, which is the property Phase 2's
119/// last done-when asserts. The range is a little under two octaves: wide enough
120/// to decorrelate, narrow enough that no object reads as either frozen or
121/// strobing.
122const TWINKLE_FREQ_LO: f32 = 0.35;
123const TWINKLE_FREQ_HI: f32 = 1.6;
124
125/// Fraction of an object's life spent fading in. Short — the mark should be lit
126/// by the time it reaches the frame — but not zero, because a sprite switched on
127/// at full brightness inside the frame is a pop.
128const ATTACK_FRAC: f32 = 0.08;
129
130/// Hard ceiling on `spawn_rate`, in objects per second.
131///
132/// Not a look value: it bounds the per-frame spawn loop's *arithmetic* alongside
133/// the pool cap that bounds its *effect*. A preset binding `spawn_rate` to an
134/// unclamped expression is the realistic way this is reached, and the answer is a
135/// saturated pool, not a stall.
136const MAX_SPAWN_RATE: f32 = 20_000.0;
137
138/// Bounds on `lifetime`, in seconds. The lower bound keeps `age / lifetime`
139/// finite; the upper one keeps an object launched into a gravity-free sky from
140/// occupying a pool slot forever.
141const MIN_LIFETIME: f32 = 0.05;
142const MAX_LIFETIME: f32 = 60.0;
143
144// Parameter defaults — an unbound emitter is a calm upward shower.
145const DEFAULT_SPAWN_RATE: f32 = default_of(PARAMS, "spawn_rate");
146const DEFAULT_GRAVITY: f32 = default_of(PARAMS, "gravity");
147const DEFAULT_LAUNCH_SPEED: f32 = default_of(PARAMS, "launch_speed");
148const DEFAULT_LAUNCH_ANGLE: f32 = default_of(PARAMS, "launch_angle");
149const DEFAULT_LIFETIME: f32 = default_of(PARAMS, "lifetime");
150const DEFAULT_SIZE: f32 = default_of(PARAMS, "size");
151const DEFAULT_BRIGHTNESS: f32 = 1.0;
152// The distribution params (Phase 2). Each says how *wide* a per-object draw is;
153// the seed picks within it. `spread` and the two `*_spread` widths default
154// non-zero because a population with no variation is the defect this phase
155// exists to fix — a shower launched on one angle is a column, not a shower.
156// `spin` and `twinkle` default off: both are motion a preset asks for.
157/// Full width of the launch-angle cone, radians (~31 degrees).
158const DEFAULT_SPREAD: f32 = default_of(PARAMS, "spread");
159const DEFAULT_SIZE_SPREAD: f32 = default_of(PARAMS, "size_spread");
160const DEFAULT_LIFETIME_SPREAD: f32 = default_of(PARAMS, "lifetime_spread");
161const DEFAULT_SPIN: f32 = default_of(PARAMS, "spin");
162const DEFAULT_TWINKLE: f32 = default_of(PARAMS, "twinkle");
163// The source geometry (Plan 0090). Both defaults are the geometry this scene
164// shipped with, stated as values rather than as constants at the spawn site
165// (ADR-0104).
166/// Where the source line sits, in world units. Just **below** the visible frame,
167/// so an upward-launched object rises into shot rather than appearing in it.
168///
169/// A preset may move it, **including inside the frame** — that is the only route
170/// to a slow look the behavioral gates can see, and the object then switches on
171/// where the eye is unless the preset also asks for a `spawn_fade`. It is still
172/// clamped to the retirement bound, by correctness rather than by taste: a source
173/// outside it spawns objects whose exit time has already passed.
174const DEFAULT_SOURCE_Y: f32 = default_of(PARAMS, "source_y");
175/// The source line's half-width **as a fraction of the frame's**, so the default
176/// resolves to `aspect * 1.0` — bit for bit the full-frame line this scene has
177/// always drawn. `0` collapses the line to a point source.
178const DEFAULT_SOURCE_WIDTH: f32 = default_of(PARAMS, "source_width");
179/// Fraction of an object's life over which its brightness ramps up from zero.
180/// Off by default, which is exactly today: an object arrives at the brightness
181/// [`ATTACK_FRAC`] gives it. It is the answer to an inside-frame `source_y`,
182/// where a mark switched on at full brightness is a pop.
183const DEFAULT_SPAWN_FADE: f32 = default_of(PARAMS, "spawn_fade");
184/// Lifetimes of spawns to back-date at scene start. Off by default, because a
185/// prewarmed world is *full* on its first frame — right for a sky, wrong for a
186/// cascade, and the two readings live one number apart.
187const DEFAULT_PREWARM: f32 = default_of(PARAMS, "prewarm");
188
189/// Ceiling on `prewarm`, in lifetimes. Past one nothing new survives to be
190/// added — an object older than its own life is dead by definition — and the
191/// widest `lifetime_spread` stretches that to one and a half, so two is already
192/// generous. It bounds the back-dated spawn loop's arithmetic the way
193/// [`MAX_SPAWN_RATE`] bounds the live one's.
194const MAX_PREWARM: f32 = 2.0;
195// Shared palette colour knobs (ADR-0021), same meaning as the swarm's.
196const DEFAULT_HUE: f32 = 0.0;
197const DEFAULT_HUE_SPREAD: f32 = default_of(PARAMS, "hue_spread");
198const DEFAULT_HUE_CENTER: f32 = default_of(PARAMS, "hue_center");
199// Shared view transform (ADR-0018): identity by default.
200const DEFAULT_ZOOM: f32 = 1.0;
201// The shared mark silhouette (ADR-0084). `disc` is this scene's glint, exactly
202// as it was, so an unbound emitter is unchanged.
203const DEFAULT_SHAPE: f32 = marks::DEFAULT_SHAPE;
204const DEFAULT_POINTS: f32 = marks::DEFAULT_POINTS;
205/// The `star` arm's three shape params (Plan 0091 Phase 5), aliased beside the
206/// other two mark defaults so this scene states its whole vocabulary locally.
207const DEFAULT_STAR_VALLEY: f32 = marks::DEFAULT_STAR_VALLEY;
208const DEFAULT_STAR_CURVE: f32 = marks::DEFAULT_STAR_CURVE;
209const DEFAULT_STAR_JITTER: f32 = marks::DEFAULT_STAR_JITTER;
210
211/// The WGSL, with `%ANISO%` substituted from [`GLINT_ANISO`] at module creation
212/// so the elongation exists in exactly one place — a second copy in the shader
213/// string is a constant that drifts silently the first time the Rust one moves.
214/// The shared mark-silhouette chunk ([`marks::sdf_wgsl`]) is prepended, so
215/// `mark_distance` here is the same function the swarm evaluates.
216///
217/// `shape` / `points` travel vertex -> fragment as flat varyings, as they do on
218/// the swarm — see that scene's shader comment for why a per-draw value goes
219/// through the varyings rather than through a wider bind-layout visibility.
220const SHADER: &str = r#"
221const ANISO: f32 = %ANISO%;
222
223struct Misc {
224    // x: aspect, y: zoom, zw: pan (the shared ViewTransform, ADR-0018)
225    v: vec4<f32>,
226    // x: mark shape index, y: quantized point count (ADR-0084). Per draw, not
227    // per instance.
228    m: vec4<f32>,
229    // xyz: the star arm's shape params (valley, curve, jitter), conditioned
230    // CPU-side (Plan 0091 Phase 5). Per draw, like `m`. Inert on every other
231    // shape, and at their defaults the arm takes its original closed form.
232    s: vec4<f32>,
233}
234
235@group(0) @binding(0) var<uniform> misc: Misc;
236
237struct VsOut {
238    @builtin(position) pos: vec4<f32>,
239    @location(0) local: vec2<f32>,
240    @location(1) color: vec3<f32>,
241    @location(2) @interpolate(flat) shape: f32,
242    @location(3) @interpolate(flat) points: f32,
243    @location(4) @interpolate(flat) star: vec3<f32>,
244}
245
246@vertex
247fn vs_main(
248    @builtin(vertex_index) vi: u32,
249    @location(0) center: vec2<f32>,
250    @location(1) size: f32,
251    @location(2) color: vec3<f32>,
252    @location(3) angle: f32,
253) -> VsOut {
254    var corners = array<vec2<f32>, 6>(
255        vec2<f32>(0.0, 0.0), vec2<f32>(1.0, 0.0), vec2<f32>(0.0, 1.0),
256        vec2<f32>(0.0, 1.0), vec2<f32>(1.0, 0.0), vec2<f32>(1.0, 1.0),
257    );
258    let c = corners[vi] * 2.0 - vec2<f32>(1.0, 1.0);
259    // The quad is rotated in world space and `local` is left un-rotated, so the
260    // elongated falloff below is written in the sprite's own frame and turns
261    // with it. `angle` is the CPU-resolved orientation: a seeded base plus
262    // `spin` times age.
263    let s = sin(angle);
264    let k = cos(angle);
265    let r = vec2<f32>(c.x * k - c.y * s, c.x * s + c.y * k);
266    // Shared ViewTransform (ADR-0018): zoom about the frame centre, then pan;
267    // the sprite quad (r * size) keeps its on-screen size.
268    let zoom = misc.v.y;
269    let pan = misc.v.zw;
270    let world = center * zoom + pan + r * size;
271    var out: VsOut;
272    out.pos = vec4<f32>(world.x / misc.v.x, world.y, 0.0, 1.0);
273    out.local = c;
274    out.color = color;
275    out.shape = misc.m.x;
276    out.points = misc.m.y;
277    out.star = misc.s.xyz;
278    return out;
279}
280
281@fragment
282fn fs_main(in: VsOut) -> @location(0) vec4<f32> {
283    // This scene's `disc` is the glint, not a circle: one radial falloff with one
284    // axis scaled, which is what makes a rotation visible at all. Left exactly as
285    // it was so every shipped emitter preset is untouched (ADR-0084). The
286    // roster's other silhouettes read the un-squashed sprite frame, so a star is
287    // a star rather than a squashed one.
288    var d: f32;
289    if (in.shape < 0.5) {
290        d = length(vec2<f32>(in.local.x, in.local.y / ANISO));
291    } else {
292        d = mark_distance(in.local, in.shape, in.points, in.star);
293    }
294    let falloff = max(0.0, 1.0 - d);
295    let g = falloff * falloff;
296    // Premultiplied: colour AND alpha carry the same coverage `g`, so the four
297    // corners outside the inscribed disc write nothing at all rather than
298    // opaque black (ADR-0056). See `gpu::ADDITIVE_LIGHT_SATURATING_COVERAGE`.
299    return vec4<f32>(in.color * g, g);
300}
301"#;
302
303/// One thrown object. Everything here is fixed at spawn — the path is decided
304/// once and never re-steered (ADR-0057: no drag, no flow field, no collision).
305#[derive(Clone, Copy, Debug)]
306struct Object {
307    /// Spawn position, world units.
308    p0: [f32; 2],
309    /// Launch velocity, world units per second.
310    v0: [f32; 2],
311    /// Scene time at spawn.
312    t0: f32,
313    /// Seconds this object lives, after the per-object draw.
314    lifetime: f32,
315    /// The gravity it was launched under, **carried per object** rather than read
316    /// from the scene each frame. `gravity` is bindable, so a scene-level read
317    /// would teleport every object in flight the moment a preset moved it; the
318    /// path is fixed at spawn, so the acceleration is part of the path.
319    gravity: f32,
320    /// Scene time at which this object is retired: the earliest of its lifetime
321    /// and the moment it leaves the bound for good, solved at spawn so
322    /// retirement is monotone in scene time (see the module docs).
323    death_time: f32,
324    /// The scene's integrated `spin` at the instant this object was thrown, so
325    /// the angle it has turned through is the integral **since its own birth**
326    /// (ADR-0132, ADR-0153): one scene-wide accumulator minus this, rather than
327    /// the current rate multiplied by the object's age, which would re-turn
328    /// every second the object had already flown whenever a binding moved.
329    ///
330    /// Unlike [`Self::gravity`] this is not the rate baked at spawn — the rate
331    /// stays live, and an object in flight answers a moving `spin` from the
332    /// moment it moves. What is fixed at spawn is only where its rotation is
333    /// measured *from*.
334    spin0: f32,
335    /// Drawn once at spawn. **Every** individuating quantity is a pure function
336    /// of this and a preset distribution param (ADR-0057).
337    seed: u32,
338    /// Whether this slot holds a live object. The free list holds the rest.
339    alive: bool,
340}
341
342impl Object {
343    /// A dead slot — what the pool is filled with at construction.
344    const DEAD: Self = Self {
345        p0: [0.0, 0.0],
346        v0: [0.0, 0.0],
347        t0: 0.0,
348        lifetime: 1.0,
349        gravity: 0.0,
350        death_time: 0.0,
351        spin0: 0.0,
352        seed: 0,
353        alive: false,
354    };
355
356    /// Position at scene time `time` — the closed form, and the whole of this
357    /// scene's motion.
358    fn position(&self, time: f32) -> [f32; 2] {
359        let age = time - self.t0;
360        [
361            self.p0[0] + self.v0[0] * age,
362            self.p0[1] + self.v0[1] * age - 0.5 * self.gravity * age * age,
363        ]
364    }
365}
366
367/// The per-frame spawn configuration, resolved from the bound params once per
368/// frame and validated there (validate at the boundary, trust inside).
369#[derive(Clone, Copy, Debug)]
370struct Spawn {
371    /// Objects per second, in `0..=`[`MAX_SPAWN_RATE`].
372    rate: f32,
373    gravity: f32,
374    speed: f32,
375    /// Radians clockwise from straight up.
376    angle: f32,
377    /// Full width of the launch-angle cone, radians.
378    spread: f32,
379    /// Seconds, in [`MIN_LIFETIME`]`..=`[`MAX_LIFETIME`].
380    lifetime: f32,
381    /// Fractional width of the per-object lifetime draw (`0` = every object
382    /// lives exactly `lifetime`).
383    lifetime_spread: f32,
384    /// Half-extent of the source line, world units: `aspect * source_width`,
385    /// clamped to the retirement bound. `0` is a point source.
386    source_half_width: f32,
387    /// The source line's world `y`, clamped to the retirement bound.
388    source_y: f32,
389    /// Lifetimes of spawns to back-date at scene start, in `0..=`[`MAX_PREWARM`].
390    /// Read once, on the field's first [`step`](Field::step), and never again.
391    prewarm: f32,
392    /// This frame's `spin`, and the scene's integral of it at this frame's time.
393    /// A spawn needs both to place its own birth on that integral — see
394    /// [`build`].
395    spin: f32,
396    spin_integral: f32,
397    /// Half-extents of the retirement bound, world units.
398    bound: [f32; 2],
399}
400
401/// The fixed-capacity object pool: spawn, retire, and nothing else. **GPU-free
402/// on purpose** — the properties ADR-0057 claims (the closed-form path, cadence
403/// independence, that objects genuinely leave, that the pool cannot be overrun)
404/// are properties of this struct, so they are asserted against it directly
405/// rather than inferred from pixels.
406struct Field {
407    /// Fixed length: one slot per unit of pool capacity. Never resized.
408    objects: Vec<Object>,
409    /// Indices of the dead slots. Never grows past the pool.
410    free: Vec<u32>,
411    live: usize,
412    rng: SeededRng,
413    /// Scene time of the **next** spawn instant. Advanced by the spawn period,
414    /// not by `dt`, so the sequence of `t0` values a run produces does not depend
415    /// on where its frames landed.
416    next_spawn: f32,
417    /// Whether [`step`](Self::step) has run once, so `next_spawn` can be seeded
418    /// from the first scene time this field ever sees rather than from 0 (a
419    /// mid-session tier change rebuilds scenes at a non-zero clock).
420    started: bool,
421}
422
423impl Field {
424    fn new(capacity: usize) -> Self {
425        let mut free = Vec::with_capacity(capacity);
426        // Highest index first, so the pool fills from slot 0 upward — the draw
427        // order is then stable and readable rather than reversed.
428        for i in (0..capacity).rev() {
429            free.push(i as u32);
430        }
431        Self {
432            objects: vec![Object::DEAD; capacity],
433            free,
434            live: 0,
435            rng: SeededRng::new(SEED),
436            next_spawn: 0.0,
437            started: false,
438        }
439    }
440
441    fn capacity(&self) -> usize {
442        self.objects.len()
443    }
444
445    /// Retire everything whose death time has passed, then spawn everything due
446    /// at or before `time`.
447    fn step(&mut self, time: f32, cfg: &Spawn) {
448        if !self.started {
449            self.started = true;
450            self.next_spawn = time;
451            self.prewarm(time, cfg);
452        }
453        self.retire(time);
454        self.spawn_due(time, cfg);
455    }
456
457    /// Fill the pool as if this field had already been running for
458    /// `prewarm * lifetime` seconds, so the population **begins** at its steady
459    /// state instead of ramping toward it over a lifetime (ADR-0104).
460    ///
461    /// This is the second warm-up, and moving the source does not touch it:
462    /// wherever the source sits, the population climbs toward `rate * lifetime`
463    /// at `rate` a second, and a behavioral gate captures 30 frames — half a
464    /// second. A world whose lifetime is measured in seconds is therefore
465    /// scored on a fraction of the picture it is actually about.
466    ///
467    /// **Back-dating is exact, not approximated**, and that is a property of the
468    /// scene rather than of this function: a path is closed-form in `t - t0` and
469    /// a death time is derived from `t0`, so an object built with a back-dated
470    /// `t0` is indistinguishable from one that genuinely spawned then. The RNG
471    /// advances once per back-dated spawn exactly as a real run's would, so the
472    /// seeds match too, and nothing here reads a clock (NFR §6).
473    ///
474    /// Two bounds keep the work finite and the pool holding the right end of
475    /// the history. An object spawned more than a longest-possible life ago
476    /// cannot still be alive, so the window is clipped there rather than at
477    /// whatever `prewarm` asked for; and a back-dated object whose life has
478    /// already ended is **dropped rather than stored**, because it is invisible
479    /// either way (its envelope is zero) but a stored one would hold a slot the
480    /// live object behind it needs. A world whose steady state exceeds the pool
481    /// starts full of the oldest survivors, which is a saturated pool either
482    /// way.
483    fn prewarm(&mut self, time: f32, cfg: &Spawn) {
484        if cfg.rate <= 0.0 || cfg.prewarm <= 0.0 {
485            return;
486        }
487        let longest_life = cfg.lifetime * (1.0 + cfg.lifetime_spread * 0.5);
488        let seconds = (cfg.prewarm * cfg.lifetime).min(longest_life);
489        let period = 1.0 / cfg.rate;
490        let cap = self.capacity();
491        let mut t0 = time - seconds;
492        let mut spawned = 0usize;
493        while t0 <= time && spawned < cap {
494            let seed = (self.rng.next_u64() >> 32) as u32;
495            let object = build(seed, t0, time, cfg);
496            if object.death_time > time
497                && let Some(index) = self.free.pop()
498                && let Some(slot) = self.objects.get_mut(index as usize)
499            {
500                *slot = object;
501                self.live += 1;
502            }
503            t0 += period;
504            spawned += 1;
505        }
506        // The live schedule resumes where the back-dated one left off, so the
507        // seam is a spawn instant like any other rather than a gap or a burst.
508        self.next_spawn = t0;
509    }
510
511    fn retire(&mut self, time: f32) {
512        for (index, object) in self.objects.iter_mut().enumerate() {
513            if object.alive && time >= object.death_time {
514                object.alive = false;
515                self.free.push(index as u32);
516                self.live -= 1;
517            }
518        }
519    }
520
521    fn spawn_due(&mut self, time: f32, cfg: &Spawn) {
522        if cfg.rate <= 0.0 {
523            // No backlog accrues while the source is off: an emitter switched on
524            // after ten silent seconds must not fire ten seconds of sparks.
525            self.next_spawn = time;
526            return;
527        }
528        let period = 1.0 / cfg.rate;
529        // Capped at one pool's worth per frame. Past that the pool is full by
530        // definition, so further spawns are drops — and a drop still costs the
531        // loop an iteration, which is what this bounds.
532        let cap = self.capacity();
533        let mut spawned = 0usize;
534        while self.next_spawn <= time && spawned < cap {
535            let t0 = self.next_spawn;
536            self.spawn_at(t0, time, cfg);
537            self.next_spawn += period;
538            spawned += 1;
539        }
540        if self.next_spawn < time {
541            self.next_spawn = time;
542        }
543    }
544
545    /// Spawn one object at scene time `t0`, **or drop it** when the pool is full.
546    ///
547    /// The RNG advances either way, so the seed sequence is a function of the
548    /// spawn schedule alone and not of how full the pool happened to be.
549    fn spawn_at(&mut self, t0: f32, now: f32, cfg: &Spawn) {
550        let seed = (self.rng.next_u64() >> 32) as u32;
551        let Some(index) = self.free.pop() else {
552            return;
553        };
554        let object = build(seed, t0, now, cfg);
555        if let Some(slot) = self.objects.get_mut(index as usize) {
556            *slot = object;
557            self.live += 1;
558        }
559    }
560}
561
562/// A uniform in `[0, 1)` derived from an object's seed and a channel index `k`.
563///
564/// The individuation contract in one function: a per-object quantity is a pure
565/// function of `(seed, k)`, so it is stable for the object's whole life, needs no
566/// per-object state, and costs no RNG draw beyond the single one taken at spawn.
567fn unit(seed: u32, k: u32) -> f32 {
568    // splitmix64's finalizer over the (seed, channel) pair — the same mixer
569    // `SeededRng` uses, applied as a hash rather than as a stream.
570    let mut z = ((seed as u64) << 32 | k as u64).wrapping_add(0x9E37_79B9_7F4A_7C15);
571    z = (z ^ (z >> 30)).wrapping_mul(0xBF58_476D_1CE4_E5B9);
572    z = (z ^ (z >> 27)).wrapping_mul(0x94D0_49BB_1331_11EB);
573    z ^= z >> 31;
574    (z >> 40) as f32 / (1u64 << 24) as f32
575}
576
577/// Seed channels. Named so a later quantity cannot silently reuse one and
578/// correlate itself with an existing draw.
579mod channel {
580    pub(super) const SOURCE_X: u32 = 0;
581    pub(super) const ANGLE: u32 = 1;
582    pub(super) const SIZE: u32 = 2;
583    pub(super) const LIFETIME: u32 = 3;
584    pub(super) const ORIENT: u32 = 4;
585    pub(super) const SPIN: u32 = 5;
586    pub(super) const TWINKLE_FREQ: u32 = 6;
587    pub(super) const TWINKLE_PHASE: u32 = 7;
588    pub(super) const HUE: u32 = 8;
589}
590
591/// Build the object a `seed` spawned at `t0` under `cfg` describes. `now` is
592/// the frame's scene time, which a back-dated spawn sits behind.
593///
594/// Free-standing and pure, so the closed-form path and the death-time solve can
595/// be exercised without a pool around them.
596///
597/// **The birth's place on the spin integral is reconstructed, and exactly.** The
598/// accumulator's value is known at `now`, so a spawn back-dated by `now - t0`
599/// starts from `cfg.spin * (now - t0)` less of it. That is exact for a `spin`
600/// held across the interval, which is the only history a back-dated object has:
601/// it did not exist while the rate was doing anything else. A spawn at `now`
602/// takes the accumulator untouched and starts from zero turned.
603fn build(seed: u32, t0: f32, now: f32, cfg: &Spawn) -> Object {
604    let angle = cfg.angle + (unit(seed, channel::ANGLE) - 0.5) * cfg.spread;
605    let lifetime = (cfg.lifetime
606        * (1.0 + (unit(seed, channel::LIFETIME) - 0.5) * cfg.lifetime_spread))
607        .clamp(MIN_LIFETIME, MAX_LIFETIME);
608    let p0 = [
609        (unit(seed, channel::SOURCE_X) * 2.0 - 1.0) * cfg.source_half_width,
610        cfg.source_y,
611    ];
612    // Angle is measured clockwise from straight up, so zero launches along +y.
613    let v0 = [angle.sin() * cfg.speed, angle.cos() * cfg.speed];
614    let exit = exit_time(p0, v0, cfg.gravity, cfg.bound);
615    Object {
616        p0,
617        v0,
618        t0,
619        lifetime,
620        gravity: cfg.gravity,
621        death_time: t0 + lifetime.min(exit),
622        spin0: cfg.spin_integral - cfg.spin * (now - t0),
623        seed,
624        alive: true,
625    }
626}
627
628/// **When this path leaves the bound for good** — elapsed seconds from spawn, or
629/// [`f32::INFINITY`] if it never does.
630///
631/// Solved rather than sampled. The horizontal component is linear in `t` (gravity
632/// is vertical), so a side exit is one division and is permanent — `v0.x` never
633/// changes. The vertical one is the *larger* root of `p0.y + v0.y t - g t^2 / 2 =
634/// -bound.y`: the object may arc above the top bound and fall back, so only the
635/// bottom is an exit at all, and only its last crossing counts.
636///
637/// A non-positive gravity is the case with no bottom exit: the path either rises
638/// forever (`g < 0`, an upward accelerator) or is a straight line, and a straight
639/// line only leaves downward when it is already heading that way. Both are then
640/// bounded by lifetime alone, which is why lifetime has a ceiling.
641fn exit_time(p0: [f32; 2], v0: [f32; 2], gravity: f32, bound: [f32; 2]) -> f32 {
642    let (bx, by) = (bound[0], bound[1]);
643    let side = if v0[0] > 0.0 {
644        (bx - p0[0]) / v0[0]
645    } else if v0[0] < 0.0 {
646        (-bx - p0[0]) / v0[0]
647    } else {
648        f32::INFINITY
649    };
650    let below = if gravity > 0.0 {
651        let disc = v0[1] * v0[1] + 2.0 * gravity * (p0[1] + by);
652        if disc < 0.0 {
653            // Already below the bound with too little upward speed to return.
654            0.0
655        } else {
656            (v0[1] + disc.sqrt()) / gravity
657        }
658    } else if gravity == 0.0 && v0[1] < 0.0 {
659        (p0[1] + by) / -v0[1]
660    } else {
661        f32::INFINITY
662    };
663    side.max(0.0).min(below.max(0.0))
664}
665
666/// The retirement bound for a render target of this aspect: the visible frame
667/// scaled by [`RETIRE_MARGIN`].
668///
669/// The **render target's** aspect, never an internal grid's (ADR-0037) — this is
670/// screen-destined geometry, and the quantized post-chain grid is a resolution,
671/// not a shape.
672fn bounds(aspect: f32) -> [f32; 2] {
673    [aspect * RETIRE_MARGIN, RETIRE_MARGIN]
674}
675
676/// The source line's half-extent on a target of this aspect, from `source_width`
677/// **as the preset bound it** (ADR-0104).
678///
679/// Fractional rather than absolute, which is what makes the default an exact
680/// identity: `aspect * 1.0` is bit for bit `aspect`, the full-frame line this
681/// scene has always drawn, so nothing shipped moves on the way in. It is also
682/// where the aspect belongs (ADR-0037) — an absolute width would be a different
683/// fraction of the frame on every display and would hand the author the
684/// reconciliation.
685///
686/// Clamped as a magnitude, the way `lifetime_spread` is, and at the retirement
687/// margin: a line wider than the bound puts its ends where the side exit has
688/// already happened, which is a pool churning against itself rather than
689/// anything visible.
690fn source_half_width(aspect: f32, source_width: f32) -> f32 {
691    aspect * finite(source_width, DEFAULT_SOURCE_WIDTH).clamp(0.0, RETIRE_MARGIN)
692}
693
694/// The source line's world `y`, from `source_y` as the preset bound it.
695///
696/// Clamped to the retirement bound and deliberately **not** to the visible frame:
697/// a source inside the frame is legal (ADR-0104) and is the only route to a look
698/// slow enough to read as a sky, at the price of a spawn pop that `spawn_fade`
699/// is there to answer. Outside the *bound* is a different matter and is a
700/// correctness clamp: an object spawned there is born with its exit time already
701/// past.
702fn source_line_y(source_y: f32, bound: [f32; 2]) -> f32 {
703    finite(source_y, DEFAULT_SOURCE_Y).clamp(-bound[1], bound[1])
704}
705
706/// The LUT sample coordinate for one object (ADR-0021), identical in meaning to
707/// the swarm's: the per-object hue occupies the band
708/// `hue_center + (object_hue - 0.5) * hue_spread`, plus the shared rotation.
709fn hue_coord(hue_center: f32, hue_spread: f32, object_hue: f32, hue: f32) -> f32 {
710    hue_center + (object_hue - 0.5) * hue_spread + hue
711}
712
713/// The age envelope: a short fade in, then a fade toward the end of life.
714///
715/// An object that vanished at full brightness would pop; this is what makes a
716/// retirement read as a spark burning out. `u` is `age / lifetime`.
717fn envelope(u: f32) -> f32 {
718    let attack = (u / ATTACK_FRAC).clamp(0.0, 1.0);
719    let remaining = (1.0 - u).clamp(0.0, 1.0);
720    attack * remaining.sqrt()
721}
722
723/// **The spawn ramp** (Plan 0090 Phase 2): a second, preset-owned fade-in over
724/// the first `spawn_fade` of an object's life, multiplying [`envelope`]'s own
725/// short attack rather than replacing it. `u` is `age / lifetime`.
726///
727/// It exists for the source that sits *inside* the frame (ADR-0104), where the
728/// engine's 8 % attack is far too short to hide a mark switching on where the
729/// eye already is. It is also a soft spark on its own terms — a ramp this scene
730/// could not express at any `brightness`.
731///
732/// **Exactly `1.0` when the fade is off, by an equality branch and not by
733/// arithmetic.** The natural form divides by the fade and is `0/0` at age zero,
734/// and the house precedent is that the obviously-equivalent arithmetic is not
735/// bit-exact: ADR-0092's `ink_gamma` and ADR-0094's ramp exponent both take the
736/// same branch. That exactness is what keeps the default free of the one
737/// committed emitter baseline.
738fn spawn_ramp(u: f32, spawn_fade: f32) -> f32 {
739    if spawn_fade <= 0.0 {
740        return 1.0;
741    }
742    (u / spawn_fade).clamp(0.0, 1.0)
743}
744
745/// **The per-object brightness multiplier** — the answer to ADR-0057's
746/// Notes, where the user asked for stars that *blink* and got a
747/// field-wide flash, because a binding is evaluated once per frame for
748/// the whole scene.
749///
750/// Both the rate and the phase come off the object's seed, so no two objects
751/// share an oscillator and the field never flashes as one sheet. Exactly `1.0`
752/// at `twinkle <= 0`, which is what makes the population-varies assertion
753/// falsifiable in both directions.
754///
755/// Clamped at zero: `twinkle` is a preset expression and may exceed 1, and a
756/// negative multiplier would subtract light rather than removing it.
757fn twinkle_factor(seed: u32, time: f32, twinkle: f32) -> f32 {
758    if twinkle <= 0.0 {
759        return 1.0;
760    }
761    let freq =
762        TWINKLE_FREQ_LO + unit(seed, channel::TWINKLE_FREQ) * (TWINKLE_FREQ_HI - TWINKLE_FREQ_LO);
763    let phase = unit(seed, channel::TWINKLE_PHASE);
764    let wave = (std::f32::consts::TAU * (freq * time + phase)).sin();
765    (1.0 + twinkle * wave).max(0.0)
766}
767
768/// The sprite's orientation: a seeded base angle plus however far the object has
769/// turned, signed per object so the field turns both ways.
770///
771/// `span` is the scene's integrated `spin` **since this object was thrown** —
772/// [`Object::spin0`] subtracted from the live accumulator — so a binding that
773/// moves turns the field from that instant instead of re-turning the flight it
774/// has already made (ADR-0132, ADR-0153). The seeded sign is applied here rather
775/// than folded into the accumulator, because the accumulator is one value for
776/// the whole field and the sign is what individuates an object within it.
777///
778/// The base exists at `spin = 0` too — a population of identically-oriented
779/// glints is the sheet the distribution params exist to break up, and it costs
780/// nothing to scatter.
781fn sprite_angle(seed: u32, span: f32) -> f32 {
782    let base = unit(seed, channel::ORIENT) * std::f32::consts::TAU;
783    let sign = unit(seed, channel::SPIN) * 2.0 - 1.0;
784    base + sign * span
785}
786
787/// The object's size multiplier within `size_spread`. `1.0` exactly at zero
788/// spread.
789fn size_factor(seed: u32, size_spread: f32) -> f32 {
790    (1.0 + (unit(seed, channel::SIZE) - 0.5) * size_spread).max(0.0)
791}
792
793/// Parameter vocabulary — see [`fragment_field::PARAMS`](super::fragment_field::PARAMS).
794/// **Keep in sync with `set_param` below.**
795pub const PARAMS: &[ParamSpec] = &[
796    ParamSpec {
797        name: "spawn_rate",
798        default: 120.0,
799        range: Some([0.0, 2000.0]),
800        doc: "New objects launched per second.",
801        kind: ParamKind::Modal,
802    },
803    ParamSpec {
804        name: "gravity",
805        default: 1.5,
806        range: Some([-4.0, 8.0]),
807        doc: "Downward acceleration, in frame heights per second squared; negative floats them up.",
808        kind: ParamKind::Modal,
809    },
810    ParamSpec {
811        name: "launch_speed",
812        default: 1.75,
813        range: Some([0.0, 6.0]),
814        doc: "Speed each object leaves the source at.",
815        kind: ParamKind::Modal,
816    },
817    ParamSpec {
818        name: "launch_angle",
819        default: 0.0,
820        range: Some([-1.0, 1.0]),
821        doc: "Direction of launch, as a fraction of a turn from straight up.",
822        kind: ParamKind::Modal,
823    },
824    ParamSpec {
825        name: "spread",
826        default: 0.55,
827        range: Some([0.0, 1.0]),
828        doc: "How wide the launch directions fan out about that angle.",
829        kind: ParamKind::Modal,
830    },
831    ParamSpec {
832        name: "lifetime",
833        default: 3.0,
834        range: Some([0.1, 20.0]),
835        doc: "Seconds an object lives before it fades out.",
836        kind: ParamKind::Modal,
837    },
838    ParamSpec {
839        name: "lifetime_spread",
840        default: 0.45,
841        range: Some([0.0, 1.0]),
842        doc: "How much lifetimes vary between objects; 0 makes them all die together.",
843        kind: ParamKind::Modal,
844    },
845    ParamSpec {
846        name: "source_y",
847        default: -1.12,
848        range: None,
849        doc: "Height the source sits at, which is normally just below the frame.",
850        kind: ParamKind::Modal,
851    },
852    ParamSpec {
853        name: "source_width",
854        default: 1.0,
855        range: Some([0.0, 4.0]),
856        doc: "How wide a line the objects are launched from; 0 is a single point.",
857        kind: ParamKind::Modal,
858    },
859    ParamSpec {
860        name: "spawn_fade",
861        default: 0.0,
862        range: Some([0.0, 1.0]),
863        doc: "Fades each object in over the start of its life rather than popping it on.",
864        kind: ParamKind::Modal,
865    },
866    ParamSpec {
867        name: "prewarm",
868        default: 0.0,
869        range: Some([0.0, 1.0]),
870        doc: "Back-dates the population so the first frame is already the steady state.",
871        kind: ParamKind::Modal,
872    },
873    ParamSpec {
874        name: "size",
875        default: 1.0,
876        range: Some([0.0, 4.0]),
877        doc: "Size of each object's mark.",
878        kind: ParamKind::Modal,
879    },
880    ParamSpec {
881        name: "size_spread",
882        default: 0.6,
883        range: Some([0.0, 1.0]),
884        doc: "How much sizes vary between objects.",
885        kind: ParamKind::Modal,
886    },
887    ParamSpec {
888        name: "spin",
889        default: 0.0,
890        range: Some([-4.0, 4.0]),
891        doc: "Turns per second each object rotates by as it flies.",
892        kind: ParamKind::Modal,
893    },
894    ParamSpec {
895        name: "twinkle",
896        default: 0.0,
897        range: Some([0.0, 1.0]),
898        doc: "Per-object brightness flicker, seeded so it is reproducible.",
899        kind: ParamKind::Modal,
900    },
901    crate::render::scenes::common::brightness(DEFAULT_BRIGHTNESS),
902    crate::render::scenes::common::hue(DEFAULT_HUE),
903    ParamSpec {
904        name: "hue_spread",
905        default: 1.0,
906        range: Some([0.0, 1.0]),
907        doc: "How far across the palette the object colours reach.",
908        kind: ParamKind::Modal,
909    },
910    ParamSpec {
911        name: "hue_center",
912        default: 0.5,
913        range: Some([0.0, 1.0]),
914        doc: "Where that band sits along the palette.",
915        kind: ParamKind::Modal,
916    },
917    crate::render::scenes::common::SATURATION,
918    crate::render::scenes::common::PALETTE_MIX,
919    crate::render::scenes::common::PALETTE_STEPS,
920    crate::render::scenes::common::PALETTE_CONTOUR,
921    crate::render::scenes::common::zoom(DEFAULT_ZOOM),
922    crate::render::scenes::common::PAN_X,
923    crate::render::scenes::common::PAN_Y,
924    crate::render::scenes::marks::SHAPE,
925    crate::render::scenes::marks::POINTS,
926    crate::render::scenes::marks::STAR_VALLEY,
927    crate::render::scenes::marks::STAR_CURVE,
928    crate::render::scenes::marks::STAR_JITTER,
929];
930
931/// Objects that spawn, fall on a parabola, and die (ADR-0057).
932pub struct EmitterScene {
933    /// The instance buffer, the view/silhouette uniform, the bind group over the
934    /// layout declared below, and the instanced-quad pipeline (ADR-0007).
935    quads: marks::InstancedQuads,
936    field: Field,
937    /// This frame's marks, rebuilt in place every `update` — the fourth attribute
938    /// is this scene's **sprite orientation** in radians, resolved on the CPU
939    /// from the object's seeded base angle and `spin` times its age (Plan 0052
940    /// Phase 2), so the shader needs no per-object state.
941    instance_data: Vec<marks::QuadInstance>,
942    /// How many of `instance_data`'s slots this frame's draw uses.
943    draw_count: usize,
944    /// Shared scene clock (seconds), set by the renderer each frame.
945    time: f32,
946    /// The **render target's** aspect, recorded by `render` for the next `update`
947    /// to size the source line and the retirement bound from (ADR-0037). One
948    /// frame behind by construction, which is harmless: it moves a bound that
949    /// already sits well off-screen.
950    aspect: f32,
951    spawn_rate: f32,
952    gravity: f32,
953    launch_speed: f32,
954    launch_angle: f32,
955    spread: f32,
956    lifetime: f32,
957    lifetime_spread: f32,
958    /// The source line's world `y` and its half-width as a fraction of the
959    /// frame's, **as bound** (ADR-0104). Both are conditioned in
960    /// [`spawn_config`](Self::spawn_config), which is the one place a binding's
961    /// arbitrary arithmetic is allowed to reach the pool.
962    source_y: f32,
963    source_width: f32,
964    /// Fraction of a life spent ramping up from black, **as bound**; conditioned
965    /// at the draw site beside the other appearance params.
966    spawn_fade: f32,
967    /// Lifetimes of spawns to back-date at scene start, **as bound**. Read once,
968    /// on the pool's first step — a preset easing it afterwards changes nothing,
969    /// which is why it is the one param here that is not a per-frame quantity.
970    prewarm: f32,
971    size: f32,
972    size_spread: f32,
973    spin: f32,
974    /// The integral of [`Self::spin`] over the scene's life, in radians, advanced
975    /// from the injected `dt` (ADR-0132, ADR-0153). An object turns through the
976    /// span of this since its own [`Object::spin0`], so a moving binding steers
977    /// the field rather than re-turning the flight already made.
978    ///
979    /// **One accumulator for the whole field, not one per object.** Every object
980    /// integrates the same `spin`; what differs is the seeded sign it is read
981    /// with and the point it is measured from, and both of those are per object
982    /// already. Growth is unbounded in principle and bounded in practice by
983    /// `f32` — the same footing every other rate in the engine stands on.
984    spin_integral: f32,
985    twinkle: f32,
986    /// The shared palette knobs (ADR-0021).
987    colour: common::PaletteParams,
988    /// The shared view transform (ADR-0018).
989    pan: common::PanParams,
990    /// The active baked palette (ADR-0021), sampled per object on the CPU.
991    palette: Palette,
992    hue_spread: f32,
993    hue_center: f32,
994    zoom: f32,
995    /// The mark silhouette and its point count, **as bound** (ADR-0084). Both
996    /// are quantized on the way to the uniform, not here, so a `[smoothing]`-eased
997    /// binding still eases — it just steps at the midpoints
998    /// (see [`marks::mark_points`]).
999    shape: f32,
1000    points: f32,
1001    /// The `star` arm's three shape params, raw as the preset bound them
1002    /// (Plan 0091 Phase 5). `marks::star_*` condition them on the way to the
1003    /// uniform. Inert on every other silhouette, and nothing warns —
1004    /// `presets/README.md` carries that.
1005    star_valley: f32,
1006    star_curve: f32,
1007    star_jitter: f32,
1008}
1009
1010impl EmitterScene {
1011    /// Build the pipeline, buffers and object pool on `device`. `capacity` is the
1012    /// active tier's
1013    /// [`emitter_objects`](crate::render::TierConfig::emitter_objects); it is
1014    /// fixed for the life of the scene, so the per-frame path never allocates,
1015    /// and a tier change rebuilds the scene rather than resizing it.
1016    pub fn new(
1017        device: &wgpu::Device,
1018        surface_format: wgpu::TextureFormat,
1019        capacity: usize,
1020    ) -> Self {
1021        let shader = device.create_shader_module(wgpu::ShaderModuleDescriptor {
1022            label: Some("emitter-shader"),
1023            source: wgpu::ShaderSource::Wgsl(
1024                // The shared silhouette chunk first, then this scene's own
1025                // source — one `mark_distance`, two scenes (ADR-0084).
1026                format!(
1027                    "{}{}",
1028                    marks::sdf_wgsl(),
1029                    SHADER.replace("%ANISO%", &format!("{GLINT_ANISO:?}"))
1030                )
1031                .into(),
1032            ),
1033        });
1034        // **This layout is deliberately not the swarm's, and that is load-bearing
1035        // on the software adapter** (design-backlog 0039, the surface Plan 0053
1036        // is about).
1037        //
1038        // Written first as the swarm's exactly — one `[Uniform]` entry, `VERTEX`
1039        // visibility, `min_binding_size: None` — which is a byte-identical
1040        // descriptor to the pipeline this scene sits beside. On DX12 WARP that
1041        // made the **swarm** read this scene's uniform: `golden` came back with
1042        // every other fixture at mean 0.0000 and `swarm` at **0.1803** with a
1043        // max outlier of **175**, and `sanity` gave the three swarm presets a
1044        // different set of numbers on each run (Storm 0.0000 then 0.1667 against
1045        // its documented 0.8407). Nothing about the swarm had changed; merely
1046        // *constructing* a seventh pipeline with the same layout shape was
1047        // enough. Hardware renders both correctly, which is exactly why this
1048        // could only be caught by looking — a bless here would have committed
1049        // garbage as the swarm's baseline (the failure mode ADR-0074 and Plan
1050        // 0053 exist for).
1051        //
1052        // Distinguishing the descriptor — a wider visibility mask and an
1053        // explicit `min_binding_size` — restored `swarm` to mean 0.0000 with a
1054        // zero outlier. The two changes are cheap and honest on their own terms
1055        // (the size *is* known; the mask is a superset, so it forbids nothing),
1056        // but the reason they are here is the collision. **Do not "tidy" this
1057        // back into the swarm's shape.**
1058        let bind_layout = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
1059            label: Some("emitter-bind-layout"),
1060            entries: &[wgpu::BindGroupLayoutEntry {
1061                binding: 0,
1062                visibility: wgpu::ShaderStages::VERTEX_FRAGMENT,
1063                ty: wgpu::BindingType::Buffer {
1064                    ty: wgpu::BufferBindingType::Uniform,
1065                    has_dynamic_offset: false,
1066                    min_binding_size: std::num::NonZeroU64::new(std::mem::size_of::<
1067                        marks::QuadUniform,
1068                    >() as u64),
1069                },
1070                count: None,
1071            }],
1072        });
1073
1074        Self {
1075            quads: marks::InstancedQuads::new(
1076                device,
1077                "emitter",
1078                capacity,
1079                &shader,
1080                &bind_layout,
1081                surface_format,
1082            ),
1083            field: Field::new(capacity),
1084            instance_data: vec![
1085                marks::QuadInstance {
1086                    center: [0.0, 0.0],
1087                    size: 0.0,
1088                    color: [0.0, 0.0, 0.0],
1089                    attr: 0.0,
1090                };
1091                capacity
1092            ],
1093            draw_count: 0,
1094            time: 0.0,
1095            aspect: FALLBACK_ASPECT,
1096            spawn_rate: DEFAULT_SPAWN_RATE,
1097            gravity: DEFAULT_GRAVITY,
1098            launch_speed: DEFAULT_LAUNCH_SPEED,
1099            launch_angle: DEFAULT_LAUNCH_ANGLE,
1100            spread: DEFAULT_SPREAD,
1101            lifetime: DEFAULT_LIFETIME,
1102            lifetime_spread: DEFAULT_LIFETIME_SPREAD,
1103            source_y: DEFAULT_SOURCE_Y,
1104            source_width: DEFAULT_SOURCE_WIDTH,
1105            spawn_fade: DEFAULT_SPAWN_FADE,
1106            prewarm: DEFAULT_PREWARM,
1107            size: DEFAULT_SIZE,
1108            size_spread: DEFAULT_SIZE_SPREAD,
1109            spin: DEFAULT_SPIN,
1110            spin_integral: 0.0,
1111            twinkle: DEFAULT_TWINKLE,
1112            colour: common::PaletteParams::new(DEFAULT_HUE, DEFAULT_BRIGHTNESS),
1113            pan: common::PanParams::default(),
1114            palette: Palette::default_spectrum(),
1115            hue_spread: DEFAULT_HUE_SPREAD,
1116            hue_center: DEFAULT_HUE_CENTER,
1117            zoom: DEFAULT_ZOOM,
1118            shape: DEFAULT_SHAPE,
1119            points: DEFAULT_POINTS,
1120            star_valley: DEFAULT_STAR_VALLEY,
1121            star_curve: DEFAULT_STAR_CURVE,
1122            star_jitter: DEFAULT_STAR_JITTER,
1123        }
1124    }
1125
1126    /// This frame's spawn configuration — the one place the bound params are
1127    /// validated. A binding may produce anything at all (an expression is
1128    /// arbitrary arithmetic over the analysis frame), so every value that reaches
1129    /// the pool is clamped and de-NaN'd here and trusted below.
1130    fn spawn_config(&self) -> Spawn {
1131        let bound = bounds(self.aspect);
1132        Spawn {
1133            rate: finite(self.spawn_rate, DEFAULT_SPAWN_RATE).clamp(0.0, MAX_SPAWN_RATE),
1134            gravity: finite(self.gravity, DEFAULT_GRAVITY),
1135            speed: finite(self.launch_speed, DEFAULT_LAUNCH_SPEED),
1136            angle: finite(self.launch_angle, DEFAULT_LAUNCH_ANGLE),
1137            spread: finite(self.spread, DEFAULT_SPREAD),
1138            lifetime: finite(self.lifetime, DEFAULT_LIFETIME).clamp(MIN_LIFETIME, MAX_LIFETIME),
1139            // A width, so it is only meaningful as a magnitude; clamped at 1 so
1140            // a preset cannot draw a negative lifetime out of the distribution.
1141            lifetime_spread: finite(self.lifetime_spread, DEFAULT_LIFETIME_SPREAD).clamp(0.0, 1.0),
1142            source_half_width: source_half_width(self.aspect, self.source_width),
1143            source_y: source_line_y(self.source_y, bound),
1144            prewarm: finite(self.prewarm, DEFAULT_PREWARM).clamp(0.0, MAX_PREWARM),
1145            bound,
1146            spin: finite(self.spin, DEFAULT_SPIN),
1147            spin_integral: self.spin_integral,
1148        }
1149    }
1150}
1151
1152/// `value`, or `fallback` when a binding produced something that is not a
1153/// number. NaN would propagate into a death time and pin a pool slot forever.
1154fn finite(value: f32, fallback: f32) -> f32 {
1155    if value.is_finite() { value } else { fallback }
1156}
1157
1158impl Scene for EmitterScene {
1159    fn name(&self) -> &'static str {
1160        "emitter"
1161    }
1162
1163    fn set_time(&mut self, time: f32) {
1164        self.time = time;
1165    }
1166
1167    /// Advance the spin integral by `dt` real seconds.
1168    ///
1169    /// The rest of this scene is a closed form in scene time and needs no step;
1170    /// a rate is the one thing that cannot be, because its own value moves
1171    /// (ADR-0132). `finite` runs before the add rather than at the read: the
1172    /// accumulator is permanent state, so one NaN frame from a binding would
1173    /// poison every object's angle for the rest of the scene's life instead of
1174    /// for the frame that produced it. `dt` needs no guard of its own — the
1175    /// seam sanitizes it before this is called (ADR-0152).
1176    fn advance(&mut self, dt: f32) {
1177        self.spin_integral += finite(self.spin, DEFAULT_SPIN) * dt;
1178    }
1179
1180    fn set_palette(&mut self, palette: &Palette) {
1181        // CPU-sampled per object in `update`; a cheap array copy, off the hot
1182        // path (once per preset switch).
1183        self.palette = palette.clone();
1184    }
1185
1186    /// **The integral is not reset here.** `reset_params` runs every frame
1187    /// before the bindings are applied; zeroing an accumulator there would put
1188    /// every object's angle back to its base each frame.
1189    fn reset_params(&mut self) {
1190        self.spawn_rate = DEFAULT_SPAWN_RATE;
1191        self.gravity = DEFAULT_GRAVITY;
1192        self.launch_speed = DEFAULT_LAUNCH_SPEED;
1193        self.launch_angle = DEFAULT_LAUNCH_ANGLE;
1194        self.spread = DEFAULT_SPREAD;
1195        self.lifetime = DEFAULT_LIFETIME;
1196        self.lifetime_spread = DEFAULT_LIFETIME_SPREAD;
1197        self.source_y = DEFAULT_SOURCE_Y;
1198        self.source_width = DEFAULT_SOURCE_WIDTH;
1199        self.spawn_fade = DEFAULT_SPAWN_FADE;
1200        self.prewarm = DEFAULT_PREWARM;
1201        self.size = DEFAULT_SIZE;
1202        self.size_spread = DEFAULT_SIZE_SPREAD;
1203        self.spin = DEFAULT_SPIN;
1204        self.twinkle = DEFAULT_TWINKLE;
1205        self.colour.reset();
1206        self.pan.reset();
1207        self.hue_spread = DEFAULT_HUE_SPREAD;
1208        self.hue_center = DEFAULT_HUE_CENTER;
1209        self.zoom = DEFAULT_ZOOM;
1210        self.shape = DEFAULT_SHAPE;
1211        self.points = DEFAULT_POINTS;
1212        self.star_valley = DEFAULT_STAR_VALLEY;
1213        self.star_curve = DEFAULT_STAR_CURVE;
1214        self.star_jitter = DEFAULT_STAR_JITTER;
1215    }
1216
1217    fn set_param(&mut self, name: &str, value: f32) {
1218        // The shared param blocks first, this scene's own names after
1219        // (`scenes::common`).
1220        if self.colour.set(name, value) || self.pan.set(name, value) {
1221            return;
1222        }
1223        match name {
1224            "spawn_rate" => self.spawn_rate = value,
1225            "gravity" => self.gravity = value,
1226            "launch_speed" => self.launch_speed = value,
1227            "launch_angle" => self.launch_angle = value,
1228            "spread" => self.spread = value,
1229            "lifetime" => self.lifetime = value,
1230            "lifetime_spread" => self.lifetime_spread = value,
1231            "source_y" => self.source_y = value,
1232            "source_width" => self.source_width = value,
1233            "spawn_fade" => self.spawn_fade = value,
1234            "prewarm" => self.prewarm = value,
1235            "size" => self.size = value,
1236            "size_spread" => self.size_spread = value,
1237            "spin" => self.spin = value,
1238            "twinkle" => self.twinkle = value,
1239            "hue_spread" => self.hue_spread = value,
1240            "hue_center" => self.hue_center = value,
1241            "zoom" => self.zoom = value,
1242            "shape" => self.shape = value,
1243            "points" => self.points = value,
1244            "star_valley" => self.star_valley = value,
1245            "star_curve" => self.star_curve = value,
1246            "star_jitter" => self.star_jitter = value,
1247            _ => {}
1248        }
1249    }
1250
1251    fn update(&mut self, _frame: &AnalysisFrame) {
1252        let cfg = self.spawn_config();
1253        let time = self.time;
1254        self.field.step(time, &cfg);
1255
1256        let size = finite(self.size, DEFAULT_SIZE) * BASE_SIZE;
1257        let brightness = finite(self.colour.brightness, DEFAULT_BRIGHTNESS);
1258        // The three appearance distributions, hoisted: read once, used for every
1259        // live object. Unlike `spread` and `lifetime_spread` these are resolved
1260        // at *draw* rather than at spawn, because they describe how an object
1261        // looks rather than where it goes — so a preset easing one of them moves
1262        // the whole population continuously instead of only the objects spawned
1263        // since the change.
1264        let size_spread = finite(self.size_spread, DEFAULT_SIZE_SPREAD).clamp(0.0, 2.0);
1265        let spin_integral = self.spin_integral;
1266        let twinkle = finite(self.twinkle, DEFAULT_TWINKLE);
1267        // A fraction of a life, so past 1 there is no more life to ramp over.
1268        // Resolved here rather than at spawn for the same reason as the three
1269        // above: it says how an object *looks*, so easing it moves the whole
1270        // population and not only the marks thrown since the change.
1271        let spawn_fade = finite(self.spawn_fade, DEFAULT_SPAWN_FADE).clamp(0.0, 1.0);
1272        let mut count = 0usize;
1273        // One pass over the pool, writing the live objects into the front of the
1274        // instance buffer. Iterating dead slots costs a branch; compacting is
1275        // what keeps the draw proportional to the population rather than to the
1276        // pool.
1277        for object in self.field.objects.iter() {
1278            if !object.alive {
1279                continue;
1280            }
1281            let Some(slot) = self.instance_data.get_mut(count) else {
1282                break;
1283            };
1284            let pos = object.position(time);
1285            let age = time - object.t0;
1286            let u = age / object.lifetime;
1287            let coord = hue_coord(
1288                self.hue_center,
1289                self.hue_spread,
1290                unit(object.seed, channel::HUE),
1291                self.colour.hue,
1292            );
1293            // Hard bands on the palette coordinate (ADR-0078), the canonical
1294            // `palette::band_coord` called rather than copied. `palette_steps <= 1`
1295            // returns it untouched, so an unbound preset is byte-unchanged.
1296            let base = palette::desaturate(
1297                self.palette.sample(
1298                    palette::band_coord(coord, self.colour.steps),
1299                    self.colour.mix,
1300                ),
1301                self.colour.saturation,
1302            );
1303            let bright = brightness
1304                * envelope(u)
1305                * spawn_ramp(u, spawn_fade)
1306                * twinkle_factor(object.seed, time, twinkle);
1307            *slot = marks::QuadInstance {
1308                center: pos,
1309                size: size * size_factor(object.seed, size_spread),
1310                color: [base[0] * bright, base[1] * bright, base[2] * bright],
1311                attr: sprite_angle(object.seed, spin_integral - object.spin0),
1312            };
1313            count += 1;
1314        }
1315        self.draw_count = count;
1316    }
1317
1318    fn render(
1319        &mut self,
1320        queue: &wgpu::Queue,
1321        encoder: &mut wgpu::CommandEncoder,
1322        view: &wgpu::TextureView,
1323        aspect: f32,
1324    ) {
1325        // The bound the *next* `update` retires against. This argument is the
1326        // render target's aspect — the only correct source for a shape
1327        // (ADR-0037).
1328        self.aspect = aspect.max(0.1);
1329        self.quads.write_uniform(
1330            queue,
1331            &marks::QuadUniform {
1332                v: [self.aspect, self.zoom, self.pan.x, self.pan.y],
1333                // Quantized here, on the way into the uniform, so the shader's
1334                // precondition stays visible on the CPU side: no fractional
1335                // point count ever reaches an angular fold (ADR-0084).
1336                m: [
1337                    marks::mark_shape(self.shape),
1338                    marks::mark_points(self.points),
1339                    0.0,
1340                    0.0,
1341                ],
1342                s: [
1343                    marks::star_valley(self.star_valley),
1344                    marks::star_curve(self.star_curve),
1345                    marks::star_jitter(self.star_jitter),
1346                    0.0,
1347                ],
1348            },
1349        );
1350        if let Some(live) = self.instance_data.get(..self.draw_count) {
1351            self.quads.write_instances(queue, live);
1352        }
1353
1354        // Load over the engine backdrop (ADR-0018).
1355        self.quads.draw(
1356            encoder,
1357            "emitter-pass",
1358            view,
1359            wgpu::LoadOp::Load,
1360            self.draw_count as u32,
1361        );
1362    }
1363}
1364
1365#[cfg(test)]
1366mod tests;