Skip to main content

rlx_core/render/scenes/
shape_collage.rs

1//! **Flat opaque elements painted on their own paper** (ADR-0123).
2//!
3//! Every other scene in this engine draws **light**: premultiplied additive
4//! colour into a linear-light composite, where nothing is in front of anything
5//! (ADR-0018, ADR-0046, ADR-0056). This one draws a **graphic**. A pixel starts
6//! at the paper colour and walks an array of elements *in array order*,
7//! compositing each with `over`, so a black bar genuinely sits in front of a red
8//! one and **the array index is the depth**. There is no depth buffer, no sort,
9//! and no ordering state — the painter's loop is the whole mechanism.
10//!
11//! # Three engine properties this look rests on, and breaks without
12//!
13//! Measured in ADR-0123's Context; named here because each is a thing an
14//! unrelated edit could take away.
15//!
16//! - A fullscreen scene emitting alpha 1 **holds the backdrop out entirely** —
17//!   not darkened, absent. So a scene covering every pixel owns its own ground.
18//! - The tonemap is **exactly the identity** below
19//!   `KNEE = 0.6` (ADR-0046), so an element at
20//!   or under it leaves the post chain **unshaded**. Below the knee the pipeline
21//!   is a no-op — flatness is not argued for against it.
22//! - Bloom's threshold sits **above** that knee, so a canvas living under it gets
23//!   no halo and hard edges stay hard, at no cost and no parameter.
24//!
25//! **The authored hex does reach the display, and the knee is why.** A palette
26//! stop is sRGB and is decoded to light once at the load boundary (ADR-0151), so
27//! the load decode and the display encode are inverses and everything between
28//! them is the identity below the knee: an element written `#494949` presents as
29//! `#494949`. The cap therefore has an authored form — `0.6` of light is sRGB
30//! byte `0xcb`, the brightest channel any element here may carry. Same curve for
31//! every element, no shading and no halo: that is the property the look rests
32//! on. Nor does the curve give paper at pure white — `f(1.0) = 0.800`, and 1.0 is
33//! asymptotically unreachable, so both reference grounds are off-white by
34//! construction.
35//!
36//! # Colour is a palette **coordinate**
37//!
38//! An element stores a coordinate, never an RGB triple, so every palette, custom
39//! stop and A/B crossfade in `docs/preset-palettes.md` applies here on arrival
40//! with no special case (ADR-0086, ADR-0102). The paper takes a coordinate too,
41//! and deliberately a **raw** one: `color_span` and `palette_shift` move the
42//! elements' colours and must not drag the ground along with them.
43//!
44//! # The aspect comes from the render target (ADR-0037)
45//!
46//! This scene computes screen-destined geometry from a normalized space, which
47//! is exactly the shape of the bug that has shipped three times in this repo.
48//! The canvas is built in square units by stretching NDC x by the **render
49//! target's** aspect, so a circle element is round at every window shape. `tests`
50//! renders at 1280x800 and measures a circle's own width against its height:
51//! 1920x1080 and this box's 2048x1152 are both exactly 16:9, where no test can
52//! tell a target-derived aspect from a grid-derived one, and 16:10 is the case
53//! that discriminates.
54//!
55//! # The cost, and where its bound lives
56//!
57//! The draw is O(elements) per pixel and the bounding-box reject removes the
58//! distance evaluation but **not** the loop step, so a wavefront walks every
59//! element regardless. The bound is
60//! [`TierConfig::collage_elements`](crate::render::TierConfig::collage_elements)
61//! — measured by `core/tests/collage_cost.rs`, not assumed.
62//!
63//! Two things here exist to keep that loop cheap and are worth not tidying away:
64//! the rotation arrives as a precomputed **cosine and sine pair** rather than an
65//! angle (no per-pixel-per-element trig, and no dependence on `sin`'s
66//! implementation-defined precision, which ADR-0096 disqualifies elsewhere for
67//! the same reason), and the axis-aligned bounding box is computed CPU-side and
68//! **tight** for every kind — a loose box is a silent cost regression that no
69//! picture would show.
70
71// Hot-path panic-denial pragma, as everywhere under `scenes/`.
72#![deny(
73    clippy::unwrap_used,
74    clippy::expect_used,
75    clippy::indexing_slicing,
76    clippy::panic,
77    clippy::unreachable
78)]
79
80use crate::render::gpu;
81
82use super::Scene;
83use super::common;
84use crate::dsp::AnalysisFrame;
85use crate::render::palette::Palette;
86use crate::render::scenes::{ParamKind, ParamSpec, default_of};
87
88/// Element kind selectors, as they reach the shader's `shape.z`. Phase 7 of
89/// Plan 0113 extends this roster; these three are what a suprematist canvas is
90/// made of.
91pub(crate) const KIND_QUAD: f32 = Kind::Quad.as_f32();
92pub(crate) const KIND_CIRCLE: f32 = Kind::Circle.as_f32();
93pub(crate) const KIND_TRIANGLE: f32 = Kind::Triangle.as_f32();
94pub(crate) const KIND_BAR: f32 = Kind::Bar.as_f32();
95pub(crate) const KIND_RING: f32 = Kind::Ring.as_f32();
96pub(crate) const KIND_SEGMENT: f32 = Kind::Segment.as_f32();
97pub(crate) const KIND_ARC: f32 = Kind::Arc.as_f32();
98pub(crate) const KIND_CHECKER: f32 = Kind::Checker.as_f32();
99
100/// Which figure an element is, and **the one place the numbering lives**.
101///
102/// The number is what crosses to the GPU — an element's `shape.z` — so this side
103/// and the painter have to agree on it. They are two spellings of one table:
104/// [`Kind::as_f32`] here, and `sdf.rs`'s threshold chain there. When they
105/// disagreed the symptom was not a compile error but a *silently wrong shape*,
106/// which is why `the_wgsl_kind_chain_matches_the_roster` holds them together.
107#[derive(Clone, Copy, PartialEq, Eq, Debug)]
108pub(crate) enum Kind {
109    /// A rotated rectangle at the element's half extents.
110    Quad,
111    /// An axis-aligned ellipse in the element's own frame.
112    Circle,
113    /// Equilateral, inscribed in the unit circle, apex at `+y`.
114    Triangle,
115    /// A capsule: a segment swept by a disc.
116    Bar,
117    /// An annulus of thickness `min(hy, hx)` inside radius `hx`.
118    Ring,
119    /// A circular sector — a pie slice with its apex at the centre.
120    Segment,
121    /// An annular sector: [`Ring`](Self::Ring) cut by [`Segment`](Self::Segment).
122    Arc,
123    /// A checkerboard patch filling the element's box.
124    Checker,
125}
126
127impl Kind {
128    /// Every kind, in numbering order. The array's length is the roster: adding
129    /// a variant without a row here is a compile error at [`Kind::as_f32`],
130    /// which is exhaustive.
131    ///
132    /// `#[cfg(test)]`: the shipped painter selects a kind by number in WGSL and
133    /// has no use for a Rust roster, so this exists only so the box check cannot
134    /// quietly stop covering a kind someone added.
135    #[cfg(test)]
136    pub(crate) const ALL: [Kind; 8] = [
137        Kind::Quad,
138        Kind::Circle,
139        Kind::Triangle,
140        Kind::Bar,
141        Kind::Ring,
142        Kind::Segment,
143        Kind::Arc,
144        Kind::Checker,
145    ];
146
147    /// This kind's number, as an element's `shape.z` carries it.
148    pub(crate) const fn as_f32(self) -> f32 {
149        match self {
150            Kind::Quad => 0.0,
151            Kind::Circle => 1.0,
152            Kind::Triangle => 2.0,
153            Kind::Bar => 3.0,
154            Kind::Ring => 4.0,
155            Kind::Segment => 5.0,
156            Kind::Arc => 6.0,
157            Kind::Checker => 7.0,
158        }
159    }
160
161    /// The kind a `shape.z` names.
162    ///
163    /// **Total, and it rounds**, because `kind` reaches here as a preset-authored
164    /// `f32` that nothing clamps at load: the same midpoint thresholds the WGSL
165    /// chain uses, so both sides read an out-of-range or fractional value
166    /// identically. A `NaN` falls to `Checker`, which shares `Quad`'s bounding
167    /// box — the value the range test it replaced also produced.
168    pub(crate) fn from_f32(kind: f32) -> Kind {
169        if kind < 0.5 {
170            Kind::Quad
171        } else if kind < 1.5 {
172            Kind::Circle
173        } else if kind < 2.5 {
174            Kind::Triangle
175        } else if kind < 3.5 {
176            Kind::Bar
177        } else if kind < 4.5 {
178            Kind::Ring
179        } else if kind < 5.5 {
180            Kind::Segment
181        } else if kind < 6.5 {
182            Kind::Arc
183        } else {
184            Kind::Checker
185        }
186    }
187
188    /// The name this kind goes by in `presets/README.md` and in a failure
189    /// message. `#[cfg(test)]` for [`Kind::ALL`]'s reason.
190    #[cfg(test)]
191    pub(crate) fn name(self) -> &'static str {
192        match self {
193            Kind::Quad => "quad",
194            Kind::Circle => "circle",
195            Kind::Triangle => "triangle",
196            Kind::Bar => "bar",
197            Kind::Ring => "ring",
198            Kind::Segment => "segment",
199            Kind::Arc => "arc",
200            Kind::Checker => "checker",
201        }
202    }
203}
204
205/// Half the base of the unit triangle, i.e. `cos(30 deg)`. The triangle is
206/// equilateral and inscribed in the unit circle — apex at `(0, 1)`, base corners
207/// at `(+-SQRT3_2, -0.5)` — scaled by the element's half extents. Stated as a
208/// constant because both the WGSL and the CPU-side bounding box are built from
209/// it, and two spellings of the same vertex is how a box stops being tight.
210const SQRT3_2: f32 = 0.866_025_4;
211
212/// `scale` default — the authored canvas is laid out to fill roughly the frame
213/// at 1.0, so the neutral value is the composition as composed.
214const DEFAULT_SCALE: f32 = default_of(PARAMS, "scale");
215/// Smallest `scale` the shader is handed. Not zero: the canvas transform divides
216/// by it.
217const MIN_SCALE: f32 = 0.05;
218/// Largest `scale`. Past this a single element fills the frame and the
219/// canvas stops being a composition — the end of the useful range, not
220/// an arbitrary cap.
221const MAX_SCALE: f32 = 20.0;
222
223/// `paper` default — the top of the gradient, which is where a light stop
224/// naturally goes and what both reference grounds are.
225const DEFAULT_PAPER: f32 = default_of(PARAMS, "paper");
226
227/// Shared palette colour knobs (ADR-0021). Both defaults are the identity on an
228/// element's stored coordinate, so an unbound preset gets exactly the colours it
229/// authored into its stops.
230const DEFAULT_COLOR_SPAN: f32 = default_of(PARAMS, "color_span");
231const DEFAULT_PALETTE_SHIFT: f32 = default_of(PARAMS, "palette_shift");
232
233/// `opacity` default — fully opaque, which is the whole point of the scene.
234const DEFAULT_OPACITY: f32 = default_of(PARAMS, "opacity");
235
236/// `edge_softness` default — **zero, and that is the hard edge**. Coverage comes
237/// from the distance against exactly one pixel, so an edge is analytically
238/// antialiased and nothing more. Raising this widens the ramp in pixels; it is
239/// an escape from the look, not a quality knob.
240const DEFAULT_EDGE_SOFTNESS: f32 = default_of(PARAMS, "edge_softness");
241/// Widest ramp, in pixels. Past a few pixels the elements stop reading as flat
242/// graphics at all.
243const MAX_EDGE_SOFTNESS: f32 = 32.0;
244
245const SHADER: &str = r#"
246struct Params {
247    // x: aspect (from the RENDER TARGET, ADR-0037), y: live element count
248    // (integral, quantized CPU-side), z: scale, w: edge softness in pixels
249    a: vec4<f32>,
250    // xy: pan (the shared ViewTransform, ADR-0018), z: color_span,
251    // w: palette_shift
252    b: vec4<f32>,
253    // x: saturation, y: palette_mix, z: opacity, w: paper (a RAW palette
254    // coordinate — see the module docs on why span/shift do not touch it)
255    c: vec4<f32>,
256    // x: occlude (ADR-0085), yzw: reserved.
257    d: vec4<f32>,
258}
259
260// `Element` and every distance function are declared by the chunk `sdf.rs`
261// splices in ahead of this body — the struct travels with the functions that
262// read it, which is also what makes the chunk parse on its own.
263
264// **A bind-group layout shape nothing else in the crate holds** (ADR-0058: two
265// byte-identical layouts alias on the DX12 WARP adapter, and the whole golden
266// suite runs there, so a collision would be blessed rather than caught). The
267// fragment-visible read-only storage buffer is the discriminator — no other
268// layout here binds storage outside a compute stage — so keep binding 4 where
269// it is rather than tidying the group.
270@group(0) @binding(0) var lut_samp: sampler;
271@group(0) @binding(1) var lut_a: texture_2d<f32>;
272@group(0) @binding(2) var lut_b: texture_2d<f32>;
273@group(0) @binding(3) var<uniform> params: Params;
274@group(0) @binding(4) var<storage, read> elements: array<Element>;
275
276// Shared `saturation` (mirrors core/src/render/palette.rs::desaturate verbatim).
277fn apply_saturation(c: vec3<f32>, s: f32) -> vec3<f32> {
278    let luma = dot(c, vec3<f32>(0.299, 0.587, 0.114));
279    return vec3<f32>(luma) + (c - vec3<f32>(luma)) * s;
280}
281
282// The crossfaded palette at a coordinate.
283//
284// `textureSampleLevel`, not `textureSample`, and that is a requirement rather
285// than a preference: this is called from inside the element loop, which is
286// non-uniform control flow, where an implicit-derivative sample is invalid. The
287// LUT is 256x1 with one mip, so level 0 is the whole texture.
288fn palette_at(t: f32) -> vec3<f32> {
289    let ca = textureSampleLevel(lut_a, lut_samp, vec2<f32>(t, 0.5), 0.0).rgb;
290    let cb = textureSampleLevel(lut_b, lut_samp, vec2<f32>(t, 0.5), 0.0).rgb;
291    return mix(ca, cb, clamp(params.c.y, 0.0, 1.0));
292}
293
294@fragment
295fn fs_main(in: VsOut) -> @location(0) vec4<f32> {
296    let aspect = params.a.x;
297    let count = u32(max(params.a.y, 0.0));
298    let scale = params.a.z;
299    let softness = params.a.w;
300    let pan = params.b.xy;
301    let color_span = params.b.z;
302    let palette_shift = params.b.w;
303    let saturation = params.c.x;
304    let opacity = clamp(params.c.z, 0.0, 1.0);
305
306    // Square units, from the RENDER TARGET's aspect (ADR-0037): stretching x
307    // makes one unit the same length on both axes, so a circle element is round
308    // and not the window's shape.
309    var uv = in.ndc;
310    uv.x = uv.x * aspect;
311
312    // One pixel, in uv units, taken under UNIFORM control flow — `fwidth` inside
313    // the loop below would be invalid. `uv` is linear in the fragment position,
314    // so this is a constant across the frame and equals `2/height` on both axes.
315    let pixel_uv = fwidth(uv.x);
316
317    // The canvas frame: `pan` moves it, `scale` sets its size. Elements and
318    // their reject boxes live here, so neither is re-derived per frame.
319    let p = (uv - pan) / scale;
320    // The coverage ramp, in canvas units. Exactly one pixel wide at
321    // `edge_softness = 0`, which is the hard edge this scene is for.
322    let pw = max(pixel_uv * (1.0 + softness) / scale, 1e-7);
323
324    // The paper. A RAW coordinate — span and shift are the elements' knobs.
325    var col = palette_at(params.c.w);
326
327    // **The painter's loop.** Array order is depth order; this is the whole of
328    // the occlusion mechanism.
329    for (var i = 0u; i < count; i = i + 1u) {
330        let e = elements[i];
331        let bb = e.aabb;
332        // The reject. It saves the distance evaluation, not the iteration —
333        // which is exactly why the element cap is load-bearing (ADR-0123).
334        if (p.x < bb.x - pw || p.x > bb.z + pw || p.y < bb.y - pw || p.y > bb.w + pw) {
335            continue;
336        }
337        let d = element_distance(e, p);
338        // Analytic antialiasing: a box filter one pixel wide across the edge.
339        // Full coverage half a pixel inside, none half a pixel outside, and
340        // correct under arbitrary rotation because a rotation preserves distance.
341        let cov = clamp(0.5 - d / pw, 0.0, 1.0) * clamp(e.tint.y, 0.0, 1.0) * opacity;
342        if (cov <= 0.0) {
343            continue;
344        }
345        let c = palette_at(e.tint.x * color_span + palette_shift);
346        col = mix(col, c, cov);
347    }
348
349    col = apply_saturation(col, saturation);
350
351    // Alpha: this canvas covers every pixel, which is the coverage it honestly
352    // has (ADR-0056) and what holds the backdrop out so the paper is the ground.
353    // `occlude` scales how much of it the backdrop resolves against (ADR-0085);
354    // reached only when no post stage is active, since the chain owns that seam
355    // otherwise and the renderer hands a literal 1.0.
356    return vec4<f32>(col, params.d.x);
357}
358"#;
359
360#[repr(C)]
361#[derive(Clone, Copy, bytemuck::Pod, bytemuck::Zeroable)]
362struct Params {
363    a: [f32; 4],
364    b: [f32; 4],
365    c: [f32; 4],
366    d: [f32; 4],
367}
368
369/// One flat element, exactly as the shader's `Element` reads it. 64 bytes,
370/// 16-byte aligned, so the storage array's stride needs no padding.
371///
372/// The array **is** the painter's order: index is depth.
373#[repr(C)]
374#[derive(Clone, Copy, Debug, PartialEq, bytemuck::Pod, bytemuck::Zeroable)]
375pub(crate) struct Element {
376    /// `cx, cy, half_x, half_y` — canvas space.
377    pub(crate) center_size: [f32; 4],
378    /// `cos(angle), sin(angle), kind, p0`. The rotation is precomputed here
379    /// rather than passed as an angle: it takes a trig pair out of the innermost
380    /// loop, and it keeps the geometry off `sin`'s implementation-defined
381    /// precision, which ADR-0096 rules out for the same reason elsewhere.
382    pub(crate) shape: [f32; 4],
383    /// `palette coordinate, alpha, birth, p1`.
384    pub(crate) tint: [f32; 4],
385    /// `x0, y0, x1, y1` — the reject box, **tight** for every kind.
386    pub(crate) aabb: [f32; 4],
387}
388
389/// A hand-authored element, in the terms a composition is written in. Turned
390/// into an [`Element`] — bounding box and all — by [`Element::build`].
391#[derive(Clone, Copy, Debug, PartialEq)]
392pub(crate) struct Spec {
393    /// One of [`KIND_QUAD`], [`KIND_CIRCLE`], [`KIND_TRIANGLE`].
394    pub(crate) kind: f32,
395    /// Centre, canvas space.
396    pub(crate) center: [f32; 2],
397    /// Half extents before rotation, canvas space.
398    pub(crate) half: [f32; 2],
399    /// Rotation in **degrees**, counter-clockwise. Degrees because a composition
400    /// is authored in them and this is off the hot path.
401    pub(crate) angle_deg: f32,
402    /// Palette coordinate.
403    pub(crate) coord: f32,
404    /// Per-element alpha. `1.0` is the opaque element this scene is for; below
405    /// it the crossing is an `over` composite of both (Plan 0113 Phase 7).
406    pub(crate) alpha: f32,
407    /// Kind-specific shape parameters — `sdf.rs`'s table says what each kind
408    /// reads. `segment` and `arc` take their half-aperture in radians from `p0`;
409    /// `checker` takes its cells-per-axis from `p1`. Inert on every other kind,
410    /// and nothing warns: that is the roster's own documentation's job.
411    pub(crate) p0: f32,
412    pub(crate) p1: f32,
413}
414
415impl Spec {
416    /// A spec with the kind-specific parameters at their defaults, which is what
417    /// the three original kinds want and what a caller that does not care
418    /// should write.
419    #[cfg(test)]
420    pub(crate) fn new(
421        kind: f32,
422        center: [f32; 2],
423        half: [f32; 2],
424        angle_deg: f32,
425        coord: f32,
426        alpha: f32,
427    ) -> Spec {
428        Spec {
429            kind,
430            center,
431            half,
432            angle_deg,
433            coord,
434            alpha,
435            p0: DEFAULT_APERTURE,
436            p1: DEFAULT_CHECKER_CELLS,
437        }
438    }
439}
440
441/// `segment` and `arc` half-aperture default, in radians — a quarter turn either
442/// side, so an unparameterised sector is a half disc rather than a sliver or a
443/// whole one.
444const DEFAULT_APERTURE: f32 = std::f32::consts::FRAC_PI_2;
445/// `checker` cells per axis, default. Even, for the reason `checker_cells`
446/// gives.
447const DEFAULT_CHECKER_CELLS: f32 = 4.0;
448
449/// The cells-per-axis a `checker` actually uses: **even**, at least two.
450///
451/// Even is load-bearing rather than tidy. With an even count the cells at both
452/// ends of each axis are filled, so the patch's drawn extent is its box and the
453/// bounding box below is exact. With an odd count two opposite corners are empty
454/// and the box would be loose in a way no picture shows — which is precisely the
455/// silent cost regression Phase 7 asks be asserted away.
456pub(crate) fn checker_cells(p1: f32) -> f32 {
457    if !p1.is_finite() {
458        return DEFAULT_CHECKER_CELLS;
459    }
460    let n = (p1 * 0.5).round() * 2.0;
461    n.clamp(2.0, 32.0)
462}
463
464/// The half-aperture a `segment` or `arc` actually uses, radians, held inside
465/// `(0, PI]` — zero is an invisible sliver and past PI the sector is the whole
466/// disc twice over.
467pub(crate) fn aperture(p0: f32) -> f32 {
468    if p0.is_finite() {
469        p0.clamp(0.02, std::f32::consts::PI)
470    } else {
471        DEFAULT_APERTURE
472    }
473}
474
475impl Element {
476    /// Build the GPU element for a spec, computing the **tight** axis-aligned
477    /// bounding box of the rotated shape.
478    ///
479    /// Tightness is per-kind and exact, not a shared conservative box: a loose
480    /// box costs a distance evaluation at every pixel it wrongly admits, which
481    /// is a cost regression no rendered frame would reveal. A test asserts it
482    /// for every kind.
483    pub(crate) fn build(spec: Spec) -> Element {
484        let angle = spec.angle_deg.to_radians();
485        let (sa, ca) = angle.sin_cos();
486        let [cx, cy] = spec.center;
487        let [hx, hy] = [spec.half[0].abs(), spec.half[1].abs()];
488
489        // **The box is a min/max pair, not a half extent, and that matters for
490        // two kinds.** A triangle's apex is at `+hy` while its base sits at
491        // `-hy/2`, and a sector reaches its radius only where its own span does
492        // — both are *asymmetric about their centre*, so a symmetric box stands
493        // off them. It shipped that way through Phase 1 (a quarter of the
494        // triangle's height of empty box on one side) because the check was
495        // CPU-only and compared half extents to half extents; the rendered check
496        // in `tests` is what found it.
497        // One `Kind`, decided once: the painter reads the same `shape.z` through
498        // the same thresholds, so a box built against a different reading of the
499        // number is a silent clip rather than a compile error.
500        let kind = Kind::from_f32(spec.kind);
501        let (lo, hi) = if matches!(kind, Kind::Quad | Kind::Checker) {
502            // A rotated rectangle: the support function of the four corners.
503            // `checker` shares it — its cell count is forced even, so the cells
504            // at both ends of each axis are filled and the patch's drawn extent
505            // is its box (`checker_cells`).
506            symmetric(ca.abs() * hx + sa.abs() * hy, sa.abs() * hx + ca.abs() * hy)
507        } else if kind == Kind::Circle {
508            // A rotated ellipse: the exact extent of the parametric form.
509            symmetric(
510                ((hx * ca) * (hx * ca) + (hy * sa) * (hy * sa)).sqrt(),
511                ((hx * sa) * (hx * sa) + (hy * ca) * (hy * ca)).sqrt(),
512            )
513        } else if kind == Kind::Triangle {
514            // The triangle, over its three rotated vertices — built from the same
515            // three points the shader's distance uses. **Asymmetric**: the apex
516            // is at `+hy` and the base at `-hy/2`, so a symmetric box would stand
517            // a quarter of the figure's height off its bottom edge.
518            hull(
519                triangle_vertices(hx, hy)
520                    .iter()
521                    .map(|&[vx, vy]| [ca * vx - sa * vy, sa * vx + ca * vy]),
522            )
523        } else if kind == Kind::Bar {
524            // A capsule is a segment swept by a disc, so its extent is the
525            // rotated segment's plus the radius on both axes — exact, and the one
526            // place a Minkowski sum makes the box trivial.
527            let r = hy.min(hx);
528            let half = (hx - r).max(0.0);
529            symmetric(ca.abs() * half + r, sa.abs() * half + r)
530        } else if kind == Kind::Ring {
531            // A ring's outer boundary is the circle of radius hx, so its box is
532            // that square whatever the rotation.
533            symmetric(hx, hx)
534        } else {
535            // `segment` and `arc`: a circular sector, so the box is taken over the
536            // sector's angular span in the WORLD frame, and it is **asymmetric**
537            // for the obvious reason — a sector reaches its radius only where it
538            // opens. The candidates are exhaustive: a circular arc can only touch
539            // an axis extreme at a cardinal angle or at one of its own ends, and
540            // the figure is otherwise bounded by its straight edges.
541            let a = aperture(spec.p0);
542            // An `arc` is an annulus cut by the sector, so its near edge sits at
543            // `hx - thickness` rather than at the apex.
544            let inner = if kind == Kind::Segment {
545                0.0
546            } else {
547                (hx - hy.min(hx)).max(0.0)
548            };
549            // **A fixed array, not a `Vec`.** `compose` calls this for every
550            // live element on every frame, so a heap allocation here is one per
551            // sector per frame on the render thread — see
552            // `the_element_builder_allocates_nothing`. Nine is exhaustive: one
553            // apex, two ends at two radii, and at most four cardinal touches.
554            let mut points = [[0.0f32; 2]; 9];
555            let mut n = 0usize;
556            {
557                let mut push = |p: [f32; 2]| {
558                    // The `debug_assert` is the point of the branch, not the
559                    // fallback. Dropping a candidate would shrink the hull, and
560                    // a hull that is too SMALL is a bounding box the painter
561                    // rejects real pixels against — a silent clip, which
562                    // `every_kind_is_contained_by_its_own_bounding_box` only
563                    // catches at an angle that happens to expose it. A future
564                    // kind that adds a tenth candidate should fail loudly here.
565                    debug_assert!(
566                        n < points.len(),
567                        "the hull candidate buffer is full at {n}; a new element kind needs \
568                         the array widened, not its candidates dropped"
569                    );
570                    if let Some(slot) = points.get_mut(n) {
571                        *slot = p;
572                        n += 1;
573                    }
574                };
575                // A `segment`'s apex is its own centre; an `arc` has none.
576                if kind == Kind::Segment {
577                    push([0.0, 0.0]);
578                }
579                for end in [-a, a] {
580                    let (se, ce) = (angle + end).sin_cos();
581                    for radius in [inner, hx] {
582                        push([radius * ce, radius * se]);
583                    }
584                }
585                for k in 0..4 {
586                    let phi = k as f32 * std::f32::consts::FRAC_PI_2;
587                    // Angular distance from the sector's world-frame axis,
588                    // wrapped into [-PI, PI].
589                    let tau = std::f32::consts::TAU;
590                    let raw = phi - angle;
591                    let delta = raw - tau * ((raw + std::f32::consts::PI) / tau).floor();
592                    if delta.abs() <= a {
593                        push([hx * phi.cos(), hx * phi.sin()]);
594                    }
595                }
596            }
597            hull(points.iter().take(n).copied())
598        };
599
600        Element {
601            center_size: [cx, cy, hx, hy],
602            shape: [ca, sa, spec.kind, aperture(spec.p0)],
603            tint: [spec.coord, spec.alpha, 0.0, checker_cells(spec.p1)],
604            aabb: [cx + lo[0], cy + lo[1], cx + hi[0], cy + hi[1]],
605        }
606    }
607}
608
609/// A box centred on the element, as the `(min, max)` offset pair every arm of
610/// [`Element::build`] returns. For the kinds that are symmetric about their own
611/// centre, which is most of them.
612fn symmetric(ex: f32, ey: f32) -> ([f32; 2], [f32; 2]) {
613    ([-ex, -ey], [ex, ey])
614}
615
616/// The `(min, max)` box of a point set — for the kinds whose figure is **not**
617/// centred on the element's own centre, where a half extent would be a box with
618/// empty space on one side.
619fn hull(points: impl Iterator<Item = [f32; 2]>) -> ([f32; 2], [f32; 2]) {
620    let mut lo = [f32::INFINITY; 2];
621    let mut hi = [f32::NEG_INFINITY; 2];
622    for p in points {
623        for axis in 0..2 {
624            if let (Some(l), Some(h), Some(v)) = (lo.get_mut(axis), hi.get_mut(axis), p.get(axis)) {
625                *l = l.min(*v);
626                *h = h.max(*v);
627            }
628        }
629    }
630    if lo[0].is_finite() {
631        (lo, hi)
632    } else {
633        ([0.0; 2], [0.0; 2])
634    }
635}
636
637/// The unit triangle's three vertices, scaled by half extents — apex up, base
638/// down. **The shader's `element_distance` builds the same three points**, and
639/// the two must not drift: this is what makes the triangle's bounding box tight
640/// rather than approximately right.
641pub(crate) fn triangle_vertices(hx: f32, hy: f32) -> [[f32; 2]; 3] {
642    [
643        [0.0, hy],
644        [-SQRT3_2 * hx, -0.5 * hy],
645        [SQRT3_2 * hx, -0.5 * hy],
646    ]
647}
648
649/// **The authored canvas** — a suprematist composition of fourteen elements, in
650/// painter order (later entries sit in front).
651///
652/// Fourteen is counted rather than chosen: ADR-0123 counts 14 elements in
653/// Malevich's *Suprematism*, the sparsest canvas in the reference set, and this
654/// is that density.
655///
656/// This list is **Plan 0113 Phase 1's element source and nothing more.** Phase 4
657/// replaces it with the seeded layout grammar; until then it is what makes the
658/// scene's first user-visible behaviour a static canvas rather than a blank one,
659/// and it is what the golden fixture pins.
660const SUPREMATIST: &[Spec] = &[
661    // The ground of the composition: a broad blue plane on the dominant angle.
662    Spec {
663        kind: KIND_QUAD,
664        center: [-0.15, 0.10],
665        half: [0.62, 0.115],
666        angle_deg: -22.0,
667        coord: 0.4375,
668        alpha: 1.0,
669        p0: DEFAULT_APERTURE,
670        p1: DEFAULT_CHECKER_CELLS,
671    },
672    // The black bar that crosses it — the occlusion this scene exists for.
673    Spec {
674        kind: KIND_QUAD,
675        center: [0.05, -0.05],
676        half: [0.72, 0.075],
677        angle_deg: -22.0,
678        coord: 0.0625,
679        alpha: 1.0,
680        p0: DEFAULT_APERTURE,
681        p1: DEFAULT_CHECKER_CELLS,
682    },
683    Spec {
684        kind: KIND_QUAD,
685        center: [-0.30, 0.42],
686        half: [0.40, 0.045],
687        angle_deg: -22.0,
688        coord: 0.3125,
689        alpha: 1.0,
690        p0: DEFAULT_APERTURE,
691        p1: DEFAULT_CHECKER_CELLS,
692    },
693    Spec {
694        kind: KIND_QUAD,
695        center: [0.30, 0.30],
696        half: [0.16, 0.160],
697        angle_deg: 12.0,
698        coord: 0.1875,
699        alpha: 1.0,
700        p0: DEFAULT_APERTURE,
701        p1: DEFAULT_CHECKER_CELLS,
702    },
703    Spec {
704        kind: KIND_CIRCLE,
705        center: [-0.55, -0.32],
706        half: [0.13, 0.130],
707        angle_deg: 0.0,
708        coord: 0.0625,
709        alpha: 1.0,
710        p0: DEFAULT_APERTURE,
711        p1: DEFAULT_CHECKER_CELLS,
712    },
713    Spec {
714        kind: KIND_QUAD,
715        center: [0.42, -0.34],
716        half: [0.26, 0.035],
717        angle_deg: -22.0,
718        coord: 0.5625,
719        alpha: 1.0,
720        p0: DEFAULT_APERTURE,
721        p1: DEFAULT_CHECKER_CELLS,
722    },
723    Spec {
724        kind: KIND_TRIANGLE,
725        center: [-0.05, -0.52],
726        half: [0.16, 0.180],
727        angle_deg: 8.0,
728        coord: 0.6875,
729        alpha: 1.0,
730        p0: DEFAULT_APERTURE,
731        p1: DEFAULT_CHECKER_CELLS,
732    },
733    Spec {
734        kind: KIND_QUAD,
735        center: [-0.62, 0.02],
736        half: [0.30, 0.028],
737        angle_deg: 62.0,
738        coord: 0.1875,
739        alpha: 1.0,
740        p0: DEFAULT_APERTURE,
741        p1: DEFAULT_CHECKER_CELLS,
742    },
743    Spec {
744        kind: KIND_QUAD,
745        center: [0.62, 0.08],
746        half: [0.10, 0.220],
747        angle_deg: -22.0,
748        coord: 0.8125,
749        alpha: 1.0,
750        p0: DEFAULT_APERTURE,
751        p1: DEFAULT_CHECKER_CELLS,
752    },
753    Spec {
754        kind: KIND_QUAD,
755        center: [0.15, 0.52],
756        half: [0.09, 0.090],
757        angle_deg: 40.0,
758        coord: 0.0625,
759        alpha: 1.0,
760        p0: DEFAULT_APERTURE,
761        p1: DEFAULT_CHECKER_CELLS,
762    },
763    Spec {
764        kind: KIND_CIRCLE,
765        center: [0.58, -0.62],
766        half: [0.075, 0.075],
767        angle_deg: 0.0,
768        coord: 0.1875,
769        alpha: 1.0,
770        p0: DEFAULT_APERTURE,
771        p1: DEFAULT_CHECKER_CELLS,
772    },
773    // Down in the empty lower-left rather than beside the ochre bar's end: at
774    // its first placement it touched that bar, and two same-coloured elements
775    // meeting merge into one silhouette — there, an arrow, which is a
776    // representational shape this style exists to refuse.
777    Spec {
778        kind: KIND_TRIANGLE,
779        center: [-0.88, -0.58],
780        half: [0.12, 0.140],
781        angle_deg: -18.0,
782        coord: 0.3125,
783        alpha: 1.0,
784        p0: DEFAULT_APERTURE,
785        p1: DEFAULT_CHECKER_CELLS,
786    },
787    Spec {
788        kind: KIND_QUAD,
789        center: [0.00, -0.72],
790        half: [0.34, 0.022],
791        angle_deg: -22.0,
792        coord: 0.4375,
793        alpha: 1.0,
794        p0: DEFAULT_APERTURE,
795        p1: DEFAULT_CHECKER_CELLS,
796    },
797    Spec {
798        kind: KIND_TRIANGLE,
799        center: [0.80, 0.42],
800        half: [0.10, 0.120],
801        angle_deg: 190.0,
802        coord: 0.0625,
803        alpha: 1.0,
804        p0: DEFAULT_APERTURE,
805        p1: DEFAULT_CHECKER_CELLS,
806    },
807];
808
809/// How many elements the authored canvas holds — the default `count`, and what
810/// [`Grammar::Authored`](layout::Grammar::Authored) cycles.
811pub(crate) const AUTHORED_COUNT: usize = SUPREMATIST.len();
812
813/// `roster` default — the suprematist three, so a preset that says nothing
814/// draws the canvas Phase 5 settled on.
815const DEFAULT_ROSTER: f32 = default_of(PARAMS, "roster");
816/// `layout` default — the authored control, not a grammar (see `layout.rs`).
817const DEFAULT_LAYOUT: f32 = default_of(PARAMS, "layout");
818/// `seed` default.
819const DEFAULT_SEED: f32 = default_of(PARAMS, "seed");
820/// `size_hierarchy` default — a middling fall from the largest form to the
821/// smallest, so the generated grammars have a range without being dominated.
822const DEFAULT_SIZE_HIERARCHY: f32 = default_of(PARAMS, "size_hierarchy");
823/// `angle_bias` default, in **degrees** as an author writes it. `-22` is the
824/// authored canvas's own dominant angle, so a generated canvas starts out
825/// leaning the same way the control does.
826const DEFAULT_ANGLE_BIAS: f32 = default_of(PARAMS, "angle_bias");
827
828/// `density` default — every generated element is live.
829const DEFAULT_DENSITY: f32 = default_of(PARAMS, "density");
830/// `drift`, `spin`, `recompose`, `recompose_blend`, `pump_size`, `pump_alpha`
831/// defaults. **Every one of them is the identity**, so a preset that binds none
832/// of Phase 6's levers draws exactly the still canvas Phase 5 settled on — which
833/// is what lets the golden baseline survive this phase unchanged.
834const DEFAULT_DRIFT: f32 = default_of(PARAMS, "drift");
835const DEFAULT_SPIN: f32 = default_of(PARAMS, "spin");
836const DEFAULT_RECOMPOSE: f32 = default_of(PARAMS, "recompose");
837const DEFAULT_RECOMPOSE_BLEND: f32 = default_of(PARAMS, "recompose_blend");
838const DEFAULT_PUMP: f32 = 0.0;
839
840/// `recompose` rises past this to recompose once — **edge-triggered**, the
841/// engine's convention and its reason (`swarm`, `particles`): a sustained beat
842/// flag must not re-run the generator every frame.
843const RECOMPOSE_THRESHOLD: f32 = 0.5;
844/// The longest crossfade `recompose_blend` may name, in seconds. Past this a
845/// recomposition stops reading as an event.
846const MAX_BLEND_SECS: f32 = 10.0;
847/// How long an element takes to fade in or out when `density` moves it across
848/// the gate. Short enough to read as an arrival, long enough not to pop.
849const FADE_SECS: f32 = 0.45;
850/// The internal pump oscillator's rate, in Hz.
851///
852/// A constant rather than a parameter, deliberately: `pump_size` and
853/// `pump_alpha` are **depths**, and an author drives them from the music. What
854/// this sets is only how fast the per-element phases sweep past each other, and
855/// a second rate knob would be one more thing to keep in step with the beat for
856/// no visual gain the depth does not already give.
857const PUMP_RATE: f32 = 0.55;
858
859/// The largest `seed` a preset can name. `f32` represents integers exactly to
860/// `2^24`, and past that a "different seed" silently is not one — so the range
861/// ends where the type stops being able to tell two seeds apart.
862const MAX_SEED: f32 = 16_777_216.0;
863
864/// The flat-graphic canvas: opaque elements over their own paper, composited in
865/// one fullscreen distance-field pass.
866pub struct ShapeCollageScene {
867    /// The pipeline, the uniform buffer, the per-element storage buffer, the
868    /// 256x1 gradient LUT pair (A/B) the fragment samples + crossfades for colour
869    /// (ADR-0021), and the one bind group this scene binds.
870    gpu: gpu::FullscreenScene,
871    /// The element array the GPU reads, rebuilt every frame from [`Self::live`]
872    /// (and [`Self::outgoing`] during a blend) with this frame's time applied.
873    ///
874    /// **Capacity is twice the tier cap**, because a recomposition crossfade has
875    /// two whole canvases on screen at once — see [`Self::blend`].
876    elements: Vec<Element>,
877    /// The live canvas, as generated: geometry plus per-element motion rates.
878    live: Vec<layout::Placed>,
879    /// The canvas being crossfaded *out* of, during a recomposition.
880    outgoing: Vec<layout::Placed>,
881    /// Crossfade progress, `0..=1`. `1.0` means no blend is in flight and
882    /// [`Self::outgoing`] is not drawn.
883    blend: f32,
884    /// Seconds the blend in flight runs over, captured at the edge so a preset
885    /// changing `recompose_blend` mid-blend does not change its own duration.
886    blend_secs: f32,
887    /// Real seconds since the scene was built, accumulated from the **injected**
888    /// `dt` (`Scene::advance`). Every motion below is a function of this, never
889    /// of a per-frame constant, which is what makes the canvas move identically
890    /// at any refresh rate (ADR-0012).
891    elapsed: f32,
892    /// [`Self::elapsed`] when the live canvas was composed, so a recomposition
893    /// starts from zero rather than teleporting. What it clocks is the **pump**;
894    /// drift and spin reset through their own accumulators below, which is why
895    /// every site that writes this one writes those too.
896    born: f32,
897    /// The same for [`Self::outgoing`].
898    outgoing_born: f32,
899    /// **The integrated drift and spin of the live canvas**, in
900    /// rate-multiplier-seconds: `drift` and `spin` are rates, so what places an
901    /// element is the integral of the bound value over the canvas's life, never
902    /// the current value scaled by its age (ADR-0132, ADR-0153). A binding that
903    /// moves therefore steers the canvas from that instant and does not rewrite
904    /// where it has already been.
905    ///
906    /// Per-set rather than per-element, and the two coincide here: every element
907    /// in a set is generated at the same instant, so there is no per-element
908    /// birth for one to be measured against.
909    ///
910    /// **Both reset to zero wherever [`Self::born`] is rewritten**, and there
911    /// are two such sites — [`Self::rebuild`] and the recomposition edge in
912    /// [`Self::step`]. Missing either leaves an accumulator running under a
913    /// canvas that has been replaced.
914    drift_accum: f32,
915    spin_accum: f32,
916    /// The same pair for [`Self::outgoing`], which keeps accumulating under the
917    /// crossfade because the outgoing canvas keeps moving while it dissolves.
918    outgoing_drift_accum: f32,
919    outgoing_spin_accum: f32,
920    /// How many recompositions have fired — the generator's recomposition index.
921    recompose_count: u64,
922    /// Previous frame's `recompose` level, for rising-edge detection.
923    prev_recompose: f32,
924    /// The recipe [`Self::elements`] was last built from, so an unchanged canvas
925    /// neither regenerates nor re-uploads. `None` before the first build.
926    built: Option<layout::Recipe>,
927    /// Whether [`Self::elements`] has changed since the last upload.
928    dirty: bool,
929    /// A test has installed its own element array, so the canvas must not be
930    /// rebuilt from the authored roster.
931    ///
932    /// **`#[cfg(test)]`, and that gate is the whole justification** — the same
933    /// argument `Scene::feedback_field` carries. Order *is* the occlusion
934    /// mechanism here, so the assertion that proves it works has to render two
935    /// chosen elements in both array orders; nothing a preset can say reverses
936    /// a compiled-in roster. This field does not exist in a shipped build.
937    #[cfg(test)]
938    specs_override: bool,
939    /// The live element count, raw as the preset bound it. Quantized on the way
940    /// to the rebuild — an eased binding is continuous even where the arithmetic
941    /// needs an integer.
942    count: f32,
943    /// Which layout grammar composes the canvas, raw as the preset bound it.
944    /// `layout::Grammar::from_param` quantizes it.
945    layout: f32,
946    /// The preset's seed, raw as the preset bound it.
947    seed: f32,
948    /// How steeply generated sizes fall, raw as the preset bound it.
949    size_hierarchy: f32,
950    /// The canvas's dominant angle in **degrees**, raw as the preset bound it.
951    angle_bias: f32,
952    /// Which kinds the canvas draws from, raw as the preset bound it.
953    roster: f32,
954    /// What fraction of the generated list is live, raw as the preset bound it.
955    density: f32,
956    /// Per-element drift and spin multipliers, raw as the preset bound them.
957    drift: f32,
958    spin: f32,
959    /// This frame's `recompose` level, and how long its crossfade should run.
960    recompose: f32,
961    recompose_blend: f32,
962    /// Per-element pump depths, raw as the preset bound them.
963    pump_size: f32,
964    pump_alpha: f32,
965    scale: f32,
966    /// The shared view transform (ADR-0018): `pan_*` moves the canvas.
967    pan: common::PanParams,
968    paper: f32,
969    color_span: f32,
970    palette_shift: f32,
971    /// The shared palette knobs (ADR-0021). This scene's roster carries only
972    /// `saturation` and `palette_mix` of them.
973    colour: common::PaletteParams,
974    opacity: f32,
975    edge_softness: f32,
976    /// How much of this canvas's (total) coverage the backdrop resolves against
977    /// (ADR-0085). Set by the renderer every frame — **not** a named param, so
978    /// it is not reset by `reset_params`.
979    occlude: f32,
980}
981
982impl ShapeCollageScene {
983    /// Build the scene's pipeline, uniform buffer and element storage on
984    /// `device`. `cap` is the tier's element cap and sizes both the storage
985    /// buffer and the CPU vector, so neither grows afterwards.
986    pub fn new(device: &wgpu::Device, surface_format: wgpu::TextureFormat, cap: usize) -> Self {
987        // At least one element's worth: a zero-length storage buffer is invalid,
988        // and a tier could in principle name a small cap.
989        let cap = cap.max(1);
990        // The roster's `Element` struct and its distance functions are spliced
991        // in ahead of the painter's own body, exactly as the two particle scenes
992        // splice in `marks`: the chunk declares no bindings and no entry points,
993        // so the pipeline's layout is unchanged by it.
994        let source = format!("{}{SHADER}", sdf::wgsl());
995        let shader = gpu::fullscreen_shader(
996            device,
997            "shape-collage-shader",
998            gpu::FULLSCREEN_VS_NDC,
999            &source,
1000        );
1001        let parts =
1002            gpu::FullscreenParts::new(device, "shape-collage", std::mem::size_of::<Params>());
1003        let storage = gpu::storage_buffer(
1004            device,
1005            "shape-collage-elements",
1006            // Twice the cap: a recomposition crossfade draws two whole canvases.
1007            2 * cap * std::mem::size_of::<Element>(),
1008        );
1009        // See the WGSL's note: the fragment-visible storage buffer is what makes
1010        // this layout's shape unique in the crate (ADR-0058). Both buffer entries
1011        // are full literals so each declares a `min_binding_size`, which Plan
1012        // 0053 Phase 3 measured to be half of what separates two layouts on WARP.
1013        let bind_layout = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
1014            label: Some("shape-collage-bind-layout"),
1015            entries: &[
1016                gpu::sampler(0),
1017                gpu::texture(1, true),
1018                gpu::texture(2, true),
1019                wgpu::BindGroupLayoutEntry {
1020                    binding: 3,
1021                    visibility: wgpu::ShaderStages::VERTEX_FRAGMENT,
1022                    ty: wgpu::BindingType::Buffer {
1023                        ty: wgpu::BufferBindingType::Uniform,
1024                        has_dynamic_offset: false,
1025                        min_binding_size: wgpu::BufferSize::new(
1026                            std::mem::size_of::<Params>() as u64
1027                        ),
1028                    },
1029                    count: None,
1030                },
1031                wgpu::BindGroupLayoutEntry {
1032                    binding: 4,
1033                    visibility: wgpu::ShaderStages::FRAGMENT,
1034                    ty: wgpu::BindingType::Buffer {
1035                        ty: wgpu::BufferBindingType::Storage { read_only: true },
1036                        has_dynamic_offset: false,
1037                        min_binding_size: wgpu::BufferSize::new(
1038                            std::mem::size_of::<Element>() as u64
1039                        ),
1040                    },
1041                    count: None,
1042                },
1043            ],
1044        });
1045        // This layout binds the sampler first and the two textures after it, so
1046        // the pair's role-ordered array is destructured into binding order here.
1047        let [lut_a, lut_b, lut_sampler] = parts.luts().bind_entries(1, 2, 0);
1048        let bind_group = device.create_bind_group(&wgpu::BindGroupDescriptor {
1049            label: Some("shape-collage-bind-group"),
1050            layout: &bind_layout,
1051            entries: &[
1052                lut_sampler,
1053                lut_a,
1054                lut_b,
1055                wgpu::BindGroupEntry {
1056                    binding: 3,
1057                    resource: parts.uniforms().as_entire_binding(),
1058                },
1059                wgpu::BindGroupEntry {
1060                    binding: 4,
1061                    resource: storage.as_entire_binding(),
1062                },
1063            ],
1064        });
1065
1066        Self {
1067            gpu: parts.with_storage(storage).finish(
1068                device,
1069                &shader,
1070                &[&bind_layout],
1071                bind_group,
1072                None,
1073                surface_format,
1074                wgpu::BlendState::REPLACE,
1075                "shape-collage",
1076            ),
1077            elements: Vec::with_capacity(2 * cap),
1078            live: Vec::with_capacity(cap),
1079            outgoing: Vec::with_capacity(cap),
1080            blend: 1.0,
1081            blend_secs: 0.0,
1082            elapsed: 0.0,
1083            born: 0.0,
1084            outgoing_born: 0.0,
1085            drift_accum: 0.0,
1086            spin_accum: 0.0,
1087            outgoing_drift_accum: 0.0,
1088            outgoing_spin_accum: 0.0,
1089            recompose_count: 0,
1090            prev_recompose: 0.0,
1091            built: None,
1092            dirty: false,
1093            #[cfg(test)]
1094            specs_override: false,
1095            count: AUTHORED_COUNT as f32,
1096            layout: DEFAULT_LAYOUT,
1097            seed: DEFAULT_SEED,
1098            size_hierarchy: DEFAULT_SIZE_HIERARCHY,
1099            angle_bias: DEFAULT_ANGLE_BIAS,
1100            roster: DEFAULT_ROSTER,
1101            density: DEFAULT_DENSITY,
1102            drift: DEFAULT_DRIFT,
1103            spin: DEFAULT_SPIN,
1104            recompose: DEFAULT_RECOMPOSE,
1105            recompose_blend: DEFAULT_RECOMPOSE_BLEND,
1106            pump_size: DEFAULT_PUMP,
1107            pump_alpha: DEFAULT_PUMP,
1108            scale: DEFAULT_SCALE,
1109            pan: common::PanParams::default(),
1110            colour: common::PaletteParams::new(0.0, common::DEFAULT_BRIGHTNESS),
1111            paper: DEFAULT_PAPER,
1112            color_span: DEFAULT_COLOR_SPAN,
1113            palette_shift: DEFAULT_PALETTE_SHIFT,
1114            opacity: DEFAULT_OPACITY,
1115            edge_softness: DEFAULT_EDGE_SOFTNESS,
1116            occlude: crate::render::post::DEFAULT_OCCLUDE,
1117        }
1118    }
1119
1120    /// The canvas this scene's parameters currently describe.
1121    ///
1122    /// Every field is conditioned **here**, CPU-side: a grammar selector, an
1123    /// element count and a seed all need to be integral, and an eased binding
1124    /// sweeps continuously through the values in between.
1125    fn recipe(&self) -> layout::Recipe {
1126        layout::Recipe {
1127            grammar: layout::Grammar::from_param(self.layout),
1128            count: applied_count(self.count, self.live.capacity()),
1129            seed: applied_seed(self.seed),
1130            recompose: self.recompose_count,
1131            size_hierarchy: applied_size_hierarchy(self.size_hierarchy),
1132            angle_bias: applied_angle_bias(self.angle_bias),
1133            roster: layout::Roster::from_param(self.roster),
1134        }
1135    }
1136
1137    /// Regenerate the live canvas if the recipe moved, unless a test has
1138    /// installed its own. A no-op when nothing changed, so the common frame does
1139    /// not re-run the generator.
1140    fn rebuild(&mut self) {
1141        #[cfg(test)]
1142        if self.specs_override {
1143            return;
1144        }
1145        let recipe = self.recipe();
1146        if self.built == Some(recipe) {
1147            return;
1148        }
1149        layout::generate(&mut self.live, &recipe);
1150        self.snap_fades();
1151        self.born = self.elapsed;
1152        self.drift_accum = 0.0;
1153        self.spin_accum = 0.0;
1154        self.built = Some(recipe);
1155    }
1156
1157    /// Set every live element's fade straight to its density target.
1158    ///
1159    /// A **fresh canvas does not fade itself in**: `density` is the spawn/decay
1160    /// lever and it animates when it moves, but a canvas arriving is covered by
1161    /// the recomposition blend instead. Without this a two-frame capture would
1162    /// read every element at partial alpha.
1163    fn snap_fades(&mut self) {
1164        let live_count = self.live_count(self.live.len());
1165        for (i, p) in self.live.iter_mut().enumerate() {
1166            p.fade = if i < live_count { 1.0 } else { 0.0 };
1167        }
1168    }
1169
1170    /// How many of `total` elements the `density` gate admits.
1171    ///
1172    /// **A prefix, and that is the whole of the stability guarantee.** Birth
1173    /// order is the array's own order, so raising `density` only ever *extends*
1174    /// the live set — an element that is already live keeps its index, its
1175    /// colour and its place in the painter's order, and nothing reorders or
1176    /// pops. Any scheme that picked a live *subset* per frame would fail that.
1177    fn live_count(&self, total: usize) -> usize {
1178        let d = if self.density.is_finite() {
1179            self.density.clamp(0.0, 1.0)
1180        } else {
1181            DEFAULT_DENSITY
1182        };
1183        // `ceil`, so any density above zero keeps at least one element: a canvas
1184        // that vanishes entirely is indistinguishable from a broken one.
1185        ((total as f32 * d).ceil() as usize).min(total)
1186    }
1187
1188    /// Advance the recomposition edge, the crossfade and the per-element fades
1189    /// by `dt` real seconds, then rebuild the GPU array for this instant.
1190    ///
1191    /// Split out of [`Scene::render`] so it can be driven — and inspected —
1192    /// without a GPU, which is what the frame-rate-independence assertion needs.
1193    fn step(&mut self, dt: f32) {
1194        self.elapsed += dt;
1195
1196        // **The rates integrate** (ADR-0132, ADR-0153). Both canvases advance:
1197        // the outgoing one keeps moving under the crossfade, exactly as its
1198        // `age` keeps growing.
1199        //
1200        // `finite_or` before the add, not after: an accumulator is permanent
1201        // state, so one NaN frame from a binding would poison the canvas for
1202        // the rest of its life rather than for the frame that produced it. `dt`
1203        // needs no such guard — the seam sanitizes it before `advance`.
1204        let drift_rate = finite_or(self.drift, DEFAULT_DRIFT);
1205        let spin_rate = finite_or(self.spin, DEFAULT_SPIN);
1206        self.drift_accum += drift_rate * dt;
1207        self.spin_accum += spin_rate * dt;
1208        self.outgoing_drift_accum += drift_rate * dt;
1209        self.outgoing_spin_accum += spin_rate * dt;
1210
1211        // **The recomposition edge.** Rising past the threshold recomposes once;
1212        // a held gate does not fire again, which is why the previous level
1213        // survives `reset_params`.
1214        let rising =
1215            self.recompose >= RECOMPOSE_THRESHOLD && self.prev_recompose < RECOMPOSE_THRESHOLD;
1216        self.prev_recompose = self.recompose;
1217        #[cfg(test)]
1218        let rising = rising && !self.specs_override;
1219        if rising {
1220            self.recompose_count = self.recompose_count.wrapping_add(1);
1221            std::mem::swap(&mut self.live, &mut self.outgoing);
1222            self.outgoing_born = self.born;
1223            self.outgoing_drift_accum = self.drift_accum;
1224            self.outgoing_spin_accum = self.spin_accum;
1225            let recipe = self.recipe();
1226            layout::generate(&mut self.live, &recipe);
1227            self.snap_fades();
1228            self.born = self.elapsed;
1229            self.drift_accum = 0.0;
1230            self.spin_accum = 0.0;
1231            self.built = Some(recipe);
1232            self.blend_secs = applied_blend_secs(self.recompose_blend);
1233            // At zero seconds this is already finished, which is the hard cut.
1234            self.blend = if self.blend_secs > 0.0 { 0.0 } else { 1.0 };
1235        } else if self.blend < 1.0 {
1236            self.blend = if self.blend_secs > 0.0 {
1237                (self.blend + dt / self.blend_secs).min(1.0)
1238            } else {
1239                1.0
1240            };
1241        }
1242
1243        // The density gate, eased so an element arrives and leaves rather than
1244        // popping. Frame-rate independent: the step is `dt / FADE_SECS`.
1245        let live_count = self.live_count(self.live.len());
1246        let step = if FADE_SECS > 0.0 { dt / FADE_SECS } else { 1.0 };
1247        for (i, p) in self.live.iter_mut().enumerate() {
1248            let target = if i < live_count { 1.0 } else { 0.0 };
1249            if p.fade < target {
1250                p.fade = (p.fade + step).min(target);
1251            } else if p.fade > target {
1252                p.fade = (p.fade - step).max(target);
1253            }
1254        }
1255
1256        self.compose();
1257    }
1258
1259    /// Rebuild the GPU element array for this instant, from the live canvas and
1260    /// — while a recomposition crossfades — the outgoing one under it.
1261    fn compose(&mut self) {
1262        #[cfg(test)]
1263        if self.specs_override {
1264            return;
1265        }
1266        let pump_size = finite_or(self.pump_size, DEFAULT_PUMP);
1267        let pump_alpha = finite_or(self.pump_alpha, DEFAULT_PUMP);
1268        let blend = self.blend.clamp(0.0, 1.0);
1269
1270        self.elements.clear();
1271        // **Equal-power weights, not linear ones**, and this is the difference
1272        // between a dissolve and a wash. The two canvases composite
1273        // *sequentially* with `over` rather than being mixed, so at linear
1274        // weights a pixel covered by both reads
1275        // `t(1-t)*paper + (1-t)^2*A + t*B` — a quarter of it is bare paper at
1276        // the midpoint, and a preset recomposing often sits there permanently.
1277        // The frames go pale, which is what a filmstrip under a click track
1278        // showed at Plan 0113 Phase 6. `sqrt` weights are the same fix audio
1279        // uses for an equal-power pan: they take the paper leak at the midpoint
1280        // from 25 % to under 9 %, and they still reach 0 and 1 at the ends.
1281        let out_alpha = (1.0 - blend).sqrt();
1282        let in_alpha = blend.sqrt();
1283
1284        // The outgoing canvas goes in FIRST, so the incoming one paints over it —
1285        // array order is depth, and a recomposition should arrive on top of what
1286        // it replaces rather than under it.
1287        if blend < 1.0 {
1288            let age = self.elapsed - self.outgoing_born;
1289            for p in &self.outgoing {
1290                self.elements.push(apply_time(
1291                    p,
1292                    age,
1293                    self.outgoing_drift_accum,
1294                    self.outgoing_spin_accum,
1295                    pump_size,
1296                    pump_alpha,
1297                    out_alpha,
1298                ));
1299            }
1300        }
1301        let age = self.elapsed - self.born;
1302        for p in &self.live {
1303            self.elements.push(apply_time(
1304                p,
1305                age,
1306                self.drift_accum,
1307                self.spin_accum,
1308                pump_size,
1309                pump_alpha,
1310                in_alpha,
1311            ));
1312        }
1313        self.dirty = true;
1314    }
1315
1316    /// The element array this scene would upload right now — the composed
1317    /// canvas at this instant, after drift, spin, fades and the blend.
1318    #[cfg(test)]
1319    pub(crate) fn composed(&self) -> &[Element] {
1320        &self.elements
1321    }
1322
1323    /// How many recompositions have fired, for the edge-trigger assertion.
1324    #[cfg(test)]
1325    pub(crate) fn recompositions(&self) -> u64 {
1326        self.recompose_count
1327    }
1328
1329    /// Install an element array of the test's own, in painter order, in place of
1330    /// the authored canvas. See [`Self::specs_override`] for why this exists.
1331    #[cfg(test)]
1332    pub(crate) fn set_specs(&mut self, specs: &[Spec]) {
1333        self.elements.clear();
1334        for &spec in specs.iter().take(self.elements.capacity()) {
1335            self.elements.push(Element::build(spec));
1336        }
1337        self.built = None;
1338        self.dirty = true;
1339        self.specs_override = true;
1340    }
1341}
1342
1343/// The `scale` the shader is handed: held inside the range the canvas transform
1344/// needs, with a non-finite binding falling back to the default.
1345///
1346/// CPU-side for `shape_field::applied_scale`'s reasons — it can never be reached
1347/// with a NaN, where WGSL's `clamp` is implementation-defined, and the default
1348/// stays **exactly** the default on the way to the uniform.
1349fn applied_scale(scale: f32) -> f32 {
1350    if scale.is_finite() {
1351        scale.clamp(MIN_SCALE, MAX_SCALE)
1352    } else {
1353        DEFAULT_SCALE
1354    }
1355}
1356
1357/// The live element count for a bound `count`, held in `0..=cap`.
1358///
1359/// **Quantized here rather than in the shader**, because an eased binding sweeps
1360/// continuously through values the arithmetic needs to be integral — the hazard
1361/// the kaleidoscope seam was fixed for. A non-finite binding falls back to the
1362/// authored canvas rather than to zero: a blank frame is the worse failure.
1363fn applied_count(count: f32, cap: usize) -> usize {
1364    if !count.is_finite() {
1365        return AUTHORED_COUNT.min(cap);
1366    }
1367    // `floor` and not `round`: a `count` easing from 14 toward 20 should admit
1368    // the fifteenth element when it has actually arrived.
1369    let n = count.floor();
1370    if n <= 0.0 {
1371        return 0;
1372    }
1373    (n as usize).min(cap)
1374}
1375
1376/// A finite value, or the default for a broken binding. The one-line form of
1377/// the fallback every conditioner here performs.
1378fn finite_or(value: f32, default: f32) -> f32 {
1379    if value.is_finite() { value } else { default }
1380}
1381
1382/// The crossfade duration a bound `recompose_blend` names, in seconds. Exactly
1383/// zero — the default — is the hard cut.
1384fn applied_blend_secs(blend: f32) -> f32 {
1385    if blend.is_finite() {
1386        blend.clamp(0.0, MAX_BLEND_SECS)
1387    } else {
1388        DEFAULT_RECOMPOSE_BLEND
1389    }
1390}
1391
1392/// **One element at one instant**: the generated element carried along its
1393/// canvas's integrated drift and spin, pumped, and its alpha scaled by the
1394/// crossfade.
1395///
1396/// Two clocks, and they are not interchangeable. `drift_accum` and `spin_accum`
1397/// are the **integrals** of the two bound rates over the canvas's life, so a
1398/// binding that moves changes the motion from here on rather than rescaling
1399/// what is already on screen (ADR-0132, ADR-0153). `age` is real seconds since
1400/// the canvas was composed, and only the pump reads it — the pump's rate is the
1401/// engine's constant [`PUMP_RATE`] and cannot move, so there is nothing there
1402/// to integrate.
1403///
1404/// Both are accumulated from the **injected** `dt`, never from a per-frame
1405/// constant, so a held rate covers the same ground per second at any refresh
1406/// rate (ADR-0012) — the property the frame-rate test asserts.
1407fn apply_time(
1408    p: &layout::Placed,
1409    age: f32,
1410    drift_accum: f32,
1411    spin_accum: f32,
1412    pump_size: f32,
1413    pump_alpha: f32,
1414    canvas_alpha: f32,
1415) -> Element {
1416    // One oscillator per element, **phase-offset at generation**, so the canvas
1417    // does not breathe in unison.
1418    let pump = (std::f32::consts::TAU * (age * PUMP_RATE + p.phase)).sin();
1419    // Never to zero or below: an element scaled through zero inverts, and the
1420    // distance functions would draw it inside out on the way.
1421    let size = (1.0 + pump_size * pump).max(0.05);
1422    let alpha = p.spec.alpha * p.fade * canvas_alpha * (1.0 + pump_alpha * pump).clamp(0.0, 1.0);
1423    // Drift **wraps** into the canvas rather than travelling off it: over a long
1424    // set a linear drift empties the canvas entirely, and a wrap at the edge is
1425    // the cheaper artefact. It is also what keeps the position a pure function
1426    // of `drift_accum`, which a bounce would not be — a bounce depends on which
1427    // side the element approached from, so it would need per-element state of
1428    // its own.
1429    let wrap = |v: f32, half: f32| (v + half).rem_euclid(2.0 * half) - half;
1430    Element::build(Spec {
1431        center: [
1432            wrap(p.spec.center[0] + p.vel[0] * drift_accum, layout::CANVAS_X),
1433            wrap(p.spec.center[1] + p.vel[1] * drift_accum, layout::CANVAS_Y),
1434        ],
1435        half: [p.spec.half[0] * size, p.spec.half[1] * size],
1436        angle_deg: p.spec.angle_deg + (p.spin * spin_accum).to_degrees(),
1437        alpha,
1438        ..p.spec
1439    })
1440}
1441
1442/// The generator's seed for a bound `seed`.
1443///
1444/// Truncated to a whole number and held in `0..=`[`MAX_SEED`]. The ceiling is
1445/// not arbitrary: an `f32` represents integers exactly only to `2^24`, so past
1446/// it two "different" seeds can be the same value and a `recompose` would stop
1447/// advancing. A non-finite binding falls back to the default rather than to
1448/// whatever `as u64` makes of a NaN.
1449fn applied_seed(seed: f32) -> u64 {
1450    if !seed.is_finite() {
1451        return DEFAULT_SEED as u64;
1452    }
1453    seed.clamp(0.0, MAX_SEED).floor() as u64
1454}
1455
1456/// The size-hierarchy exponent's input, held in `0..=1`.
1457fn applied_size_hierarchy(hierarchy: f32) -> f32 {
1458    if hierarchy.is_finite() {
1459        hierarchy.clamp(0.0, 1.0)
1460    } else {
1461        DEFAULT_SIZE_HIERARCHY
1462    }
1463}
1464
1465/// The canvas's dominant angle in **radians**, from a param an author writes in
1466/// degrees. Wrapped rather than clamped — an angle has no ends, and a `spin`
1467/// binding walking past 360 must not stick at a bound.
1468fn applied_angle_bias(degrees: f32) -> f32 {
1469    if degrees.is_finite() {
1470        degrees.rem_euclid(360.0).to_radians()
1471    } else {
1472        DEFAULT_ANGLE_BIAS.to_radians()
1473    }
1474}
1475
1476/// The `edge_softness` the shader is handed, in pixels of extra ramp.
1477fn applied_edge_softness(softness: f32) -> f32 {
1478    if softness.is_finite() {
1479        softness.clamp(0.0, MAX_EDGE_SOFTNESS)
1480    } else {
1481        DEFAULT_EDGE_SOFTNESS
1482    }
1483}
1484
1485/// The parameter names this scene consumes — the vocabulary a preset binding is
1486/// checked against at load (ADR-0020). **Keep in sync with `set_param` below**;
1487/// `declared_params_match_set_param` in `core/tests/preset.rs` fails if the two
1488/// drift.
1489pub const PARAMS: &[ParamSpec] = &[
1490    ParamSpec {
1491        name: "count",
1492        default: 0.0,
1493        range: Some([0.0, 64.0]),
1494        doc: "How many elements are placed; 0 lets the layout decide. Truncated, so a rise \
1495               admits its next element on arrival.",
1496        kind: ParamKind::Modal,
1497    },
1498    ParamSpec {
1499        name: "layout",
1500        default: 0.0,
1501        range: Some([0.0, 8.0]),
1502        doc: "Picks which arrangement the elements are placed by.",
1503        kind: ParamKind::Structural,
1504    },
1505    ParamSpec {
1506        name: "seed",
1507        default: 0.0,
1508        range: None,
1509        doc: "Chooses one arrangement out of the family; the same seed always composes the \
1510               same way. Truncated, like `count`.",
1511        kind: ParamKind::Modal,
1512    },
1513    ParamSpec {
1514        name: "size_hierarchy",
1515        default: 0.5,
1516        range: Some([0.0, 1.0]),
1517        doc: "How much larger the leading elements are than the rest; 0 makes them equal.",
1518        kind: ParamKind::Modal,
1519    },
1520    ParamSpec {
1521        name: "angle_bias",
1522        default: -22.0,
1523        range: None,
1524        doc: "Degrees the elements lean by, which is what gives the composition its tilt.",
1525        kind: ParamKind::Modal,
1526    },
1527    ParamSpec {
1528        name: "roster",
1529        default: 0.0,
1530        range: Some([0.0, 8.0]),
1531        doc: "Picks which set of shapes the elements are drawn from.",
1532        kind: ParamKind::Structural,
1533    },
1534    ParamSpec {
1535        name: "density",
1536        default: 1.0,
1537        range: Some([0.0, 2.0]),
1538        doc: "How much of the frame the arrangement fills.",
1539        kind: ParamKind::Modal,
1540    },
1541    ParamSpec {
1542        name: "drift",
1543        default: 0.0,
1544        range: Some([0.0, 2.0]),
1545        doc: "How far the elements wander from their placed positions.",
1546        kind: ParamKind::Modal,
1547    },
1548    ParamSpec {
1549        name: "spin",
1550        default: 0.0,
1551        range: Some([-2.0, 2.0]),
1552        doc: "Turns per second the elements rotate by.",
1553        kind: ParamKind::Modal,
1554    },
1555    ParamSpec {
1556        name: "recompose",
1557        default: 0.0,
1558        range: Some([0.0, 1.0]),
1559        doc: "Crossing zero lays the composition out again from a new arrangement.",
1560        kind: ParamKind::Modal,
1561    },
1562    ParamSpec {
1563        name: "recompose_blend",
1564        default: 0.0,
1565        range: Some([0.0, 1.0]),
1566        doc: "How long the change between two arrangements takes, rather than cutting.",
1567        kind: ParamKind::Modal,
1568    },
1569    ParamSpec {
1570        name: "pump_size",
1571        default: DEFAULT_PUMP,
1572        range: Some([0.0, 2.0]),
1573        doc: "Scales every element together, for a beat to make the whole composition breathe.",
1574        kind: ParamKind::Modal,
1575    },
1576    ParamSpec {
1577        name: "pump_alpha",
1578        default: DEFAULT_PUMP,
1579        range: Some([0.0, 2.0]),
1580        doc: "Fades every element together, the opacity twin of `pump_size`.",
1581        kind: ParamKind::Modal,
1582    },
1583    ParamSpec {
1584        name: "scale",
1585        default: 1.0,
1586        range: Some([0.1, 4.0]),
1587        doc: "Size of the whole composition within the frame.",
1588        kind: ParamKind::Modal,
1589    },
1590    crate::render::scenes::common::PAN_X,
1591    crate::render::scenes::common::PAN_Y,
1592    ParamSpec {
1593        name: "paper",
1594        default: 1.0,
1595        range: Some([0.0, 1.0]),
1596        doc: "How opaque the ground behind the elements is; 0 leaves the backdrop showing.",
1597        kind: ParamKind::Modal,
1598    },
1599    ParamSpec {
1600        name: "color_span",
1601        default: 1.0,
1602        range: Some([0.0, 1.0]),
1603        doc: "How much of the palette the elements are coloured across.",
1604        kind: ParamKind::Modal,
1605    },
1606    ParamSpec {
1607        name: "palette_shift",
1608        default: 0.0,
1609        range: Some([0.0, 1.0]),
1610        doc: "Rotates every element's colour along the palette together.",
1611        kind: ParamKind::Modal,
1612    },
1613    crate::render::scenes::common::SATURATION,
1614    crate::render::scenes::common::PALETTE_MIX,
1615    ParamSpec {
1616        name: "opacity",
1617        default: 1.0,
1618        range: Some([0.0, 1.0]),
1619        doc: "How opaque each element is, so overlaps can show through.",
1620        kind: ParamKind::Modal,
1621    },
1622    ParamSpec {
1623        name: "edge_softness",
1624        default: 0.0,
1625        range: Some([0.0, 1.0]),
1626        doc: "How far each element's edge fades; 0 is a hard cut.",
1627        kind: ParamKind::Modal,
1628    },
1629];
1630
1631impl Scene for ShapeCollageScene {
1632    fn name(&self) -> &'static str {
1633        "shape collage"
1634    }
1635
1636    fn set_occlude(&mut self, occlude: f32) {
1637        self.occlude = occlude;
1638    }
1639
1640    fn set_palette(&mut self, palette: &Palette) {
1641        self.gpu.set_palette(palette);
1642    }
1643
1644    fn reset_params(&mut self) {
1645        self.count = AUTHORED_COUNT as f32;
1646        self.layout = DEFAULT_LAYOUT;
1647        self.seed = DEFAULT_SEED;
1648        self.size_hierarchy = DEFAULT_SIZE_HIERARCHY;
1649        self.angle_bias = DEFAULT_ANGLE_BIAS;
1650        self.roster = DEFAULT_ROSTER;
1651        self.density = DEFAULT_DENSITY;
1652        self.drift = DEFAULT_DRIFT;
1653        self.spin = DEFAULT_SPIN;
1654        self.recompose_blend = DEFAULT_RECOMPOSE_BLEND;
1655        self.pump_size = DEFAULT_PUMP;
1656        self.pump_alpha = DEFAULT_PUMP;
1657        // `prev_recompose` is deliberately NOT reset — this runs every frame
1658        // before the bindings are routed, and resetting the previous level would
1659        // turn a held gate into an edge per frame, recomposing continuously
1660        // instead of on the beat. `swarm::reset_params` makes the same omission
1661        // for the same reason.
1662        self.recompose = DEFAULT_RECOMPOSE;
1663        self.scale = DEFAULT_SCALE;
1664        self.pan.reset();
1665        self.paper = DEFAULT_PAPER;
1666        self.color_span = DEFAULT_COLOR_SPAN;
1667        self.palette_shift = DEFAULT_PALETTE_SHIFT;
1668        self.colour.reset();
1669        self.opacity = DEFAULT_OPACITY;
1670        self.edge_softness = DEFAULT_EDGE_SOFTNESS;
1671    }
1672
1673    fn set_param(&mut self, name: &str, value: f32) {
1674        // The shared param blocks first, this scene's own names after
1675        // (`scenes::common`).
1676        if self.colour.set(name, value) || self.pan.set(name, value) {
1677            return;
1678        }
1679        match name {
1680            "count" => self.count = value,
1681            "layout" => self.layout = value,
1682            "seed" => self.seed = value,
1683            "size_hierarchy" => self.size_hierarchy = value,
1684            "angle_bias" => self.angle_bias = value,
1685            "roster" => self.roster = value,
1686            "density" => self.density = value,
1687            "drift" => self.drift = value,
1688            "spin" => self.spin = value,
1689            "recompose" => self.recompose = value,
1690            "recompose_blend" => self.recompose_blend = value,
1691            "pump_size" => self.pump_size = value,
1692            "pump_alpha" => self.pump_alpha = value,
1693            "scale" => self.scale = value,
1694            "paper" => self.paper = value,
1695            "color_span" => self.color_span = value,
1696            "palette_shift" => self.palette_shift = value,
1697            "opacity" => self.opacity = value,
1698            "edge_softness" => self.edge_softness = value,
1699            _ => {}
1700        }
1701    }
1702
1703    fn update(&mut self, _frame: &AnalysisFrame) {
1704        // Fully parameter-driven; the analysis reaches this scene only through
1705        // the preset expressions bound to its parameters.
1706    }
1707
1708    /// Advance the canvas by `dt` real seconds (ADR-0012).
1709    ///
1710    /// **The whole of this scene's animation hangs off this argument** — the
1711    /// recomposition edge, the crossfade, the density fades, and every element's
1712    /// drift, spin and pump. Nothing here reads a clock or assumes a frame rate,
1713    /// so a second of music moves the canvas the same distance at 30 Hz and at
1714    /// 144 Hz.
1715    fn advance(&mut self, dt: f32) {
1716        self.rebuild();
1717        self.step(dt);
1718    }
1719
1720    fn render(
1721        &mut self,
1722        queue: &wgpu::Queue,
1723        encoder: &mut wgpu::CommandEncoder,
1724        view: &wgpu::TextureView,
1725        aspect: f32,
1726    ) {
1727        self.gpu.flush_palette(queue);
1728
1729        // `advance` has already stepped the canvas for this frame; a renderer
1730        // that never calls it (there is none) still gets a composed canvas.
1731        if self.built.is_none() {
1732            self.rebuild();
1733            self.compose();
1734        }
1735        // A zero-length write is not a legal `write_buffer`, and an empty canvas
1736        // needs no upload: the loop reads `count` entries and stops.
1737        if self.dirty && !self.elements.is_empty() {
1738            self.gpu.write_storage(queue, &self.elements);
1739            self.dirty = false;
1740        }
1741
1742        let params = Params {
1743            // `aspect` is the argument the chain hands down for the target this
1744            // scene draws into — never a size this scene chose (ADR-0037).
1745            a: [
1746                aspect.max(0.1),
1747                self.elements.len() as f32,
1748                applied_scale(self.scale),
1749                applied_edge_softness(self.edge_softness),
1750            ],
1751            b: [self.pan.x, self.pan.y, self.color_span, self.palette_shift],
1752            c: [
1753                self.colour.saturation,
1754                self.colour.mix,
1755                self.opacity,
1756                self.paper,
1757            ],
1758            d: [self.occlude, 0.0, 0.0, 0.0],
1759        };
1760        self.gpu.write_uniform(queue, &params);
1761        self.gpu
1762            .draw(encoder, "shape-collage-pass", view, wgpu::LoadOp::Load);
1763    }
1764}
1765
1766pub(crate) mod layout;
1767pub(crate) mod sdf;
1768
1769#[cfg(test)]
1770mod tests;