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, ¶ms);
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;