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