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