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