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;