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