Skip to main content

rlx_core/render/scenes/
shape_field.rs

1//! **The mark roster, drawn at frame scale as a distance field**
2//! (ADR-0105, Plan 0091).
3//!
4//! Every other scene here hands the palette a *level* — a noise field, a
5//! chemical concentration, a particle's depth. This one hands it a **distance**,
6//! and that single substitution is the whole scene:
7//!
8//! ```text
9//! palette coordinate = mark_distance(p) * color_span + color_center
10//! ```
11//!
12//! (that is `coord_mode`'s default; the second coordinate is below.)
13//!
14//! Because a band of the palette coordinate is now a band of constant distance,
15//! turning `palette_steps` up produces **concentric offset contours of the
16//! chosen shape** — not concentric circles, and not an outline sampled to
17//! straight segments. It is per pixel, resolution-independent, and there is no
18//! geometry to facet (which is the entire argument of ADR-0105 against routing
19//! this through the line renderer). `palette_contour` then draws thin outlines
20//! at those band boundaries, and this is the third scene that param does
21//! anything in.
22//!
23//! # Two coordinates now, and the second is the one the references asked for
24//! (ADR-0111, Plan 0098)
25//!
26//! An offset family is an **erosion**, and erosion rounds a reflex corner while
27//! keeping convex ones sharp — so a nested heart keeps its bottom point and
28//! loses its top notch as the contours move inward, and no amount of tuning
29//! reaches the construction two batches of user reference images have asked for.
30//! `coord_mode = "1"` hands the palette
31//!
32//! ```text
33//! s = length(p) / r_boundary(theta)
34//! ```
35//!
36//! instead — `0` at the centre and exactly `1` on the outline, the same contract,
37//! but its level sets are **scaled copies** of the outline. The ring count is
38//! then `palette_steps` alone and the innermost figure is a scaled copy at any
39//! count, so notch sharpness stops trading against ring count.
40//!
41//! **The distance stays the default and stays bit-identical**, which is what
42//! keeps every shipped preset and every golden baseline on the arithmetic it has
43//! today. The two are not interchangeable settings of one knob: `color_span`
44//! means a different thing under each, because the exterior is divided by the
45//! shape's inradius under one and grows linearly in `r` under the other.
46//!
47//! # It shares the shape vocabulary rather than restating it
48//!
49//! The silhouettes come from `marks` — the same WGSL chunk
50//! `swarm` and `emitter` splice in, and the same CPU-side quantizers for the
51//! `shape` selector and the `points` count. So a mark a particle can wear and a
52//! figure this scene can be cannot drift apart, and the roster stays closed at
53//! five names (ADR-0084's consequence, restated in ADR-0105).
54//!
55//! # A silhouette can also be authored (ADR-0107)
56//!
57//! A `[path]` table hands this scene a closed contour parsed from inline SVG
58//! path data, and it takes the place of the roster's arm in exactly one spot:
59//! where the shader asks for the figure's coordinate. Everything downstream —
60//! both `coord_mode`s, `gamma`, the banding, the contours, the palette — is the
61//! same code reading the same `d`, so an authored figure gets the whole colour
62//! surface for free. The roster is untouched and still closed at five names; a
63//! preset naming no path executes not one instruction of the contour walk.
64//!
65//! Two things fall out of the distance being a **`min` over segments**. It is
66//! `O(N)` per pixel and the field is fullscreen, so the arity is a per-frame
67//! cost paid whether or not the figure is on screen — `MAX_SAMPLES` is where
68//! `core/tests/path_cost.rs` measured that becoming half the floor tier's frame
69//! budget, and asking for more is a load error. And **fill and stroke stop
70//! being two routes**: the interior is `d < 1` and the outline is
71//! `abs(d - 1) < w` on the one evaluation, which is what the `stroke` param is.
72//!
73//! What *is* new is that this scene reads the field **outside** the silhouette,
74//! where the particle path never looked. Plan 0091 Phase 2 measured that region
75//! and repaired the two arms that were wrong out there; see `marks.rs`'s own
76//! header for what it found and what it deliberately left approximate.
77//!
78//! # The aspect comes from the render target (ADR-0037)
79//!
80//! There is no internal grid here to take an aspect from by accident, which
81//! removes the usual mechanism for that bug but not the obligation. The figure
82//! is drawn in a square-unit space built by stretching NDC x by the **render
83//! target's** aspect, so a disc is round at every window shape. `tests` renders
84//! at 2:1 and 1:2 and measures the figure's own width against its height,
85//! because both 1920x1080 and this box's 2048x1152 quantize to exactly 16:9 —
86//! where no test can tell a right aspect source from a wrong one.
87
88// Hot-path panic-denial pragma, as everywhere under `scenes/`.
89#![deny(
90    clippy::unwrap_used,
91    clippy::expect_used,
92    clippy::indexing_slicing,
93    clippy::panic,
94    clippy::unreachable
95)]
96
97use crate::render::gpu;
98
99use super::Scene;
100use super::common;
101use super::marks;
102use crate::dsp::AnalysisFrame;
103use crate::preset::path::{MAX_ARC_PIECES, MAX_SAMPLES};
104use crate::render::palette::{self, Palette};
105use crate::render::scenes::{ParamGroup, ParamKind, ParamSpec, default_of};
106
107/// How many `vec4` one arc piece occupies: its circle, its sector test, its two
108/// endpoints, and its signed sweep. See the WGSL's `arc_chain_sd` for what each
109/// field is for.
110const VEC4S_PER_PIECE: usize = 4;
111
112/// How many `vec4` the uniform's geometry array holds — enough for
113/// [`MAX_ARC_PIECES`] arc pieces, which is more than the polyline's two points
114/// per element ever needs.
115///
116/// The geometry rides the **uniform** buffer rather than a storage one, and that
117/// is an ADR-0058 choice rather than a performance one: a fragment-visible
118/// read-only storage entry after this layout's uniform would make its shape
119/// byte-identical to `shape-collage-bind-layout`, which is live in the same
120/// frame during a preset dissolve. Two layouts of one shape alias on the DX12
121/// WARP adapter the whole golden suite captures on, so the collision would be
122/// blessed rather than caught. Packing into the uniform leaves the layout's
123/// four entries exactly as they were.
124const PATH_VEC4S: usize = VEC4S_PER_PIECE * MAX_ARC_PIECES;
125
126/// The WGSL below spells the array length as a literal — `format!` cannot reach
127/// into a raw string full of braces — so the two are held together here. Raise
128/// either bound and this fails the build rather than letting the shader read
129/// past what the uniform carries.
130const _: () = assert!(
131    PATH_VEC4S == 128 && PATH_VEC4S >= MAX_SAMPLES / 2,
132    "the WGSL `path` array must be PATH_VEC4S long, and hold either geometry"
133);
134
135/// `scale` default — the figure's outline sits at 0.6 of the frame's short
136/// half-axis, which leaves room for several contour bands around it before they
137/// leave the frame. The whole point of this scene is what happens *outside* the
138/// silhouette, so a figure filling the frame would be the wrong default.
139const DEFAULT_SCALE: f32 = default_of(PARAMS, "scale");
140/// Smallest `scale` the shader is handed. Not zero: at zero the figure has no
141/// size and every pixel is infinitely far outside it in units of nothing, so
142/// the coordinate degenerates rather than fading out.
143const MIN_SCALE: f32 = 0.01;
144/// Largest `scale`. Past this the figure is far outside the frame and the whole
145/// screen is one interior band — reachable, but it is the end of the useful
146/// range rather than an arbitrary cap.
147const MAX_SCALE: f32 = 20.0;
148
149/// `rotation` default — **0, and an exact arithmetic identity**: the shader
150/// tests for it and skips the rotation entirely, so every shipped preset and
151/// every golden baseline stays on the arithmetic it has today.
152///
153/// Radians, matching `lines/star.rs` and `lines/lsystem.rs` — the two other
154/// figure-drawing scenes that carry this name. Unclamped for the same reason
155/// they are: an angle wraps, so there is no end of the useful range to hold it
156/// inside; a non-finite binding falls back to the identity because `cos(NaN)`
157/// would take the whole frame with it.
158const DEFAULT_ROTATION: f32 = default_of(PARAMS, "rotation");
159
160/// `stroke` default — **0, the filled figure, and an exact arithmetic
161/// identity**: the shader tests for it and skips the stroke mask entirely.
162const DEFAULT_STROKE: f32 = default_of(PARAMS, "stroke");
163/// Largest `stroke`. One coordinate unit is the figure's whole interior — 0 at
164/// its deepest point, 1 on the outline — so a half-width of 1 is a band
165/// reaching from the centre to twice the outline, and past that the stroke has
166/// stopped being an outline of anything.
167const MAX_STROKE: f32 = 1.0;
168
169/// `morph` default — **0, the authored figure**, and an exact identity: at 0 the
170/// packed contour is `[path] d` verbatim, with no interpolation run at all. A
171/// preset declaring no `morph_to` has nothing to travel towards and this is
172/// inert whatever it is bound to, exactly as the attractor's `morph` is.
173const DEFAULT_MORPH: f32 = default_of(PARAMS, "morph");
174
175/// `gamma` default — **the identity**, and it is exactly `1.0` on the way to the
176/// uniform because the shader's identity branch tests for it (`pow(x, 1.0)` is
177/// not bit-exact, ADR-0092's care).
178const DEFAULT_GAMMA: f32 = default_of(PARAMS, "gamma");
179/// The range `gamma` is held in. Same shape and the same reasoning as
180/// `ink_gamma` and `bg_ramp_gamma`: positive on both sides, wide enough that the
181/// clamp is the end of the useful range rather than a limit an author meets.
182const MIN_GAMMA: f32 = 0.05;
183const MAX_GAMMA: f32 = 20.0;
184
185/// The `coord_mode` roster, in the order the numeric parameter selects them.
186///
187/// `0` hands the palette the normalized **distance** to the figure, whose level
188/// sets are offset curves; `1` hands it `r / r_boundary(theta)`, whose level
189/// sets are **scaled copies** of the outline
190/// (ADR-0111). Both are `0` at the figure's centre and exactly `1` on its outline; what
191/// differs is the shape of everything in between.
192pub(crate) const COORD_MODES: [&str; 2] = ["distance", "radius"];
193
194/// `coord_mode` default — **0, the distance**, and that is an obligation rather
195/// than a preference: it is the arithmetic every shipped preset and every golden
196/// baseline has today.
197const DEFAULT_COORD_MODE: f32 = default_of(PARAMS, "coord_mode");
198const MIN_COORD_MODE: f32 = 0.0;
199const MAX_COORD_MODE: f32 = COORD_MODES.len() as f32 - 1.0;
200
201/// Shared palette colour knobs (ADR-0021). `color_span = 0.6` puts the
202/// silhouette's interior (`d` in `0..1`) across the gradient's first 60 %, so
203/// the exterior contours have somewhere to go.
204const DEFAULT_COLOR_SPAN: f32 = default_of(PARAMS, "color_span");
205const DEFAULT_COLOR_CENTER: f32 = default_of(PARAMS, "color_center");
206
207/// How many of the palette's [`LUT_SIZE`] texels a resting `color_span` spends on
208/// the **figure's own interior**.
209///
210/// Both coordinates are `0` at the figure's centre and exactly `1` on its
211/// outline, so the interior is one unit of the coordinate whatever the shape and
212/// whatever `gamma` does to the spacing inside it — which makes this share
213/// exact. What it does *not* know is how much of the frame that interior covers:
214/// a figure spanning half the screen stretches those texels across hundreds of
215/// pixels, and one filling a corner does not. That is why the warning built on
216/// it says estimate.
217pub(crate) fn interior_texels(color_span: f32) -> f32 {
218    color_span.abs() * crate::render::palette::LUT_SIZE as f32
219}
220
221/// Below this many texels across the interior, the linear-filtered LUT is
222/// stretching too few distinct colours across the figure and the result reads as
223/// an upscaled gradient rather than as shading (design-backlog 0099).
224///
225/// **The property is the count, not this constant.** A figure's interior drawn
226/// through N texels carries at most N colours no matter how large it is on
227/// screen, so somewhere below a few dozen the sampler is interpolating more than
228/// it is reading. The Plan 0091 Phase 6 star probes bracket where that becomes
229/// visible — 8.6 texels read as *"dirty and upscaled"*, 32.3 did not — and this
230/// is the middle of that bracket rounded to a power of two. It is a warning
231/// threshold, so a value inside it is legal and renders exactly as asked;
232/// `palette_steps` is the remedy, because a quantized coordinate samples one
233/// texel per band and interpolates nothing.
234pub(crate) const MIN_INTERIOR_TEXELS: f32 = 16.0;
235
236const SHADER: &str = r#"
237struct Params {
238    // x: aspect (from the RENDER TARGET), y: shape position (clamped CPU-side
239    // and NOT rounded, so a fractional value blends two arms - ADR-0226),
240    // z: points (quantized CPU-side), w: scale
241    a: vec4<f32>,
242    // xy: pan (the shared ViewTransform, ADR-0018), z: color_span,
243    // w: color_center
244    b: vec4<f32>,
245    // x: saturation, y: palette_mix, z: palette_steps (integral, quantized
246    // CPU-side), w: palette_contour
247    c: vec4<f32>,
248    // x: occlude (ADR-0085), y: gamma (the response exponent on the distance,
249    // exactly 1.0 for the identity), z: coord_mode (quantized CPU-side; 0 = the
250    // distance, 1 = the scaled-copy radius), w: rotation in radians, exactly 0.0
251    // for the identity.
252    d: vec4<f32>,
253    // xyz: the star arm's shape params (valley, curve, jitter), conditioned
254    // CPU-side. Inert on every other silhouette.
255    e: vec4<f32>,
256    // x: path point count (0 = no authored contour, and every line of the path
257    // arms below is unreached), y: the contour's inradius — the divisor that
258    // makes the distance 0 at its deepest interior point, measured CPU-side,
259    // z: stroke half-width in coordinate units (exactly 0.0 = filled),
260    // w: arc piece count — nonzero means `path` holds an ARC CHAIN rather than
261    // a polyline, and `x` is then unread.
262    f: vec4<f32>,
263    // x: palette_contour_style (integral, rounded CPU-side),
264    // y: palette_contour_ink (ADR-0197), zw: unused.
265    //
266    // Its own vec4 rather than `e.w` plus a slot borrowed from elsewhere: the two
267    // are one control split in half, and `e.w` is the only free slot this struct
268    // has.
269    g: vec4<f32>,
270    // The authored contour (ADR-0107), in one of two packings.
271    //
272    // **As a polyline** (`f.w == 0`): TWO POINTS PER ELEMENT, point `i` at
273    // `path[i >> 1].xy` for even `i` and `.zw` for odd. Packed because a uniform
274    // array's elements are 16-byte aligned, so an `array<vec2<f32>, N>` would
275    // spend half the buffer on padding.
276    //
277    // **As an arc chain** (`f.w > 0`): FOUR ELEMENTS PER PIECE, piece `i` at
278    // `path[i * 4 ..]`:
279    //   +0  (kind, cx, cy, radius)    kind 0 = straight run, 1 = arc
280    //   +1  (mx, my, cos_half, 0)     the sector's mid direction and half-angle
281    //   +2  (ax, ay, bx, by)          the piece's two endpoints
282    //   +3  (start, sweep, 0, 0)      the signed sweep, for the crossing test
283    path: array<vec4<f32>, 128>,
284}
285
286// **One bind group, sampler first and uniform last — and that arrangement is
287// what buys this pipeline a layout shape nothing else holds** (ADR-0058: two
288// byte-identical layouts alias on the DX12 WARP adapter, and the whole golden
289// suite runs there, so a collision is blessed rather than caught).
290//
291// It is deliberately not `fragment_field`'s two-group split, because that split
292// has no free shape left for a tenth scene. A lone uniform group can vary only
293// by visibility and by whether it declares a `min_binding_size`, and all four
294// combinations are taken: `[Uniform:FRAGMENT]` by the fragment field, the RD
295// init and the test disc; `+size` by the backdrop; `VERTEX_FRAGMENT` by the
296// line renderer; and `VERTEX_FRAGMENT+size` by the emitter. Merging the groups
297// is what keeps this unique WITHOUT padding a layout with a binding the shader
298// does not use, which is the cure ADR-0058's Alternative A refuses.
299//
300// Pick another free shape rather than tidying this back into two groups.
301@group(0) @binding(0) var lut_samp: sampler;
302@group(0) @binding(1) var lut_a: texture_2d<f32>;
303@group(0) @binding(2) var lut_b: texture_2d<f32>;
304@group(0) @binding(3) var<uniform> params: Params;
305
306// Shared `saturation` (mirrors core/src/render/palette.rs::desaturate verbatim).
307fn apply_saturation(c: vec3<f32>, s: f32) -> vec3<f32> {
308    let luma = dot(c, vec3<f32>(0.299, 0.587, 0.114));
309    return vec3<f32>(luma) + (c - vec3<f32>(luma)) * s;
310}
311
312// Shared `palette_steps` (mirrors core/src/render/palette.rs::band_coord
313// verbatim, ADR-0078): snap the palette coordinate to a band centre before the
314// LUT read. Below 1.5 steps it is the exact identity, not a one-band degenerate.
315fn band_coord(t: f32, steps: f32) -> f32 {
316    if (steps < 1.5) {
317        return t;
318    }
319    return (floor(t * steps) + 0.5) / steps;
320}
321
322// Shared `palette_contour` (ADR-0078 / ADR-0133; the WGSL is the implementation,
323// copied verbatim at each fragment-stage site — palette.rs has no CPU
324// counterpart to be canonical, since `fwidth` exists only here).
325//
326// Darkens within one PIXEL of a band edge, so the line has the same weight where
327// the field is shallow and where it is steep — AND ONLY WHERE THE INK ACTUALLY
328// CHANGES (ADR-0133). It samples the two band centres either side of the nearest
329// edge and returns unchanged when they resolve to the same colour within half a
330// code value, which is below the LUT's own 8-bit quantization. On a smooth
331// palette two distinct centres always differ by at least one code value, so
332// every edge draws exactly as it did at any `palette_steps`; inside a plateau
333// the LUT is literally constant and the samples are bit-equal, so the line
334// vanishes there and survives at the run boundaries. One rule, both behaviours,
335// no new parameter.
336//
337// The two LUTs, the sampler and `palette_mix` are EXPLICIT parameters rather
338// than module-scope globals this happens to find: all six sites name them the
339// same today, so implicit capture would compile — and would silently bind the
340// shared function to whatever a future site called its textures.
341//
342// `textureSampleLevel`, not `textureSample`: the LUT has one mip, and an
343// explicit LOD keeps these reads free of the uniformity requirement that a
344// sample after a conditional return would otherwise carry.
345//
346// **What the line is drawn in is `style`** (ADR-0197): `0` the soft darkening
347// above, `1` a hard darkening over the same footprint, `2` a soft line in the
348// palette's own colour at `ink_t` and `3` a hard one. `style` arrives rounded to
349// a whole number from the CPU (`palette::band_contour_style`), so the equality
350// comparisons below are exact. The `style < 0.5` arm is the expression that
351// shipped before the other three existed, which is what keeps every golden still.
352fn band_contour_ink(
353    col: vec3<f32>,
354    t: f32,
355    steps: f32,
356    amount: f32,
357    style: f32,
358    ink_t: f32,
359    lut_a: texture_2d<f32>,
360    lut_b: texture_2d<f32>,
361    lut_samp: sampler,
362    mix_ab: f32,
363) -> vec3<f32> {
364    let f = t * steps;
365    let w = max(fwidth(f), 1e-5);
366    if (steps < 1.5 || amount <= 0.0) {
367        return col;
368    }
369    let n = round(f);
370    let m = clamp(mix_ab, 0.0, 1.0);
371    let lo = mix(
372        textureSampleLevel(lut_a, lut_samp, vec2<f32>((n - 0.5) / steps, 0.5), 0.0).rgb,
373        textureSampleLevel(lut_b, lut_samp, vec2<f32>((n - 0.5) / steps, 0.5), 0.0).rgb,
374        m
375    );
376    let hi = mix(
377        textureSampleLevel(lut_a, lut_samp, vec2<f32>((n + 0.5) / steps, 0.5), 0.0).rgb,
378        textureSampleLevel(lut_b, lut_samp, vec2<f32>((n + 0.5) / steps, 0.5), 0.0).rgb,
379        m
380    );
381    if (all(abs(hi - lo) < vec3<f32>(0.5 / 255.0))) {
382        return col;
383    }
384    let d = min(fract(f), 1.0 - fract(f));
385    if (style < 0.5) {
386        return col * (1.0 - clamp(amount, 0.0, 1.0) * (1.0 - smoothstep(0.0, w, d)));
387    }
388    let hard = style == 1.0 || style == 3.0;
389    let cover = select(1.0 - smoothstep(0.0, w, d), f32(d < w), hard);
390    let ink_lut = mix(
391        textureSampleLevel(lut_a, lut_samp, vec2<f32>(ink_t, 0.5), 0.0).rgb,
392        textureSampleLevel(lut_b, lut_samp, vec2<f32>(ink_t, 0.5), 0.0).rgb,
393        m
394    );
395    let ink = select(vec3<f32>(0.0), ink_lut, style >= 2.0);
396    return mix(col, ink, clamp(amount, 0.0, 1.0) * cover);
397}
398
399// Point `i` of the authored contour, unpacked from the two-per-vec4 array.
400fn path_pt(i: u32) -> vec2<f32> {
401    let v = params.path[i >> 1u];
402    if ((i & 1u) == 0u) {
403        return v.xy;
404    }
405    return v.zw;
406}
407
408// **The authored contour's signed distance**: `min` over the distance to each
409// closing segment, signed by a crossing count (ADR-0107).
410//
411// The sign is a RAY-CROSSING PARITY rather than an orientation test, so it does
412// not care which way the author wound their path — which is what lets Phase 1
413// keep the contour's own winding and leave the alignment to the morph.
414//
415// `min` over segment distances has no quads, no overlap and no vertex bead: it
416// is exactly correct at every join, which is why ADR-0098's faceting objection
417// against a polyline stroke does not transfer to a polyline FILL. The cost is
418// `O(n)` per pixel and it is paid at every pixel of the frame whether or not the
419// figure is on screen, which is what the arity ceiling exists to bound.
420fn path_sd(p: vec2<f32>, n: u32) -> f32 {
421    var best = 1e20;
422    var s = 1.0;
423    // The previous point is carried rather than re-indexed, so each iteration
424    // makes one dynamically indexed uniform read instead of two. It measured as
425    // free — `path_cost.rs` reports the same ms/frame either way, so the loop's
426    // cost is its arithmetic and not its loads — and it stays because it is the
427    // simpler loop, not because it bought anything.
428    var b = path_pt(n - 1u);
429    for (var i = 0u; i < n; i = i + 1u) {
430        let a = path_pt(i);
431        let e = b - a;
432        let w = p - a;
433        // The nearest point ON THE SEGMENT, not on its infinite line: the clamp
434        // is what makes a sample beyond an end measure to the vertex.
435        let t = clamp(dot(w, e) / max(dot(e, e), 1e-20), 0.0, 1.0);
436        let q = w - e * t;
437        best = min(best, dot(q, q));
438        let c1 = p.y >= a.y;
439        let c2 = p.y < b.y;
440        let c3 = e.x * w.y > e.y * w.x;
441        if ((c1 && c2 && c3) || (!c1 && !c2 && !c3)) {
442            s = -s;
443        }
444        b = a;
445    }
446    return s * sqrt(best);
447}
448
449// **The authored contour's signed distance, as a chain of circular arcs**
450// (ADR-0098's primitive, ADR-0107's figure).
451//
452// The same two quantities as `path_sd` — a `min` over pieces for the magnitude,
453// a ray-crossing parity for the sign — over a chain that a curve needs FIVE TO
454// TEN TIMES fewer of than the polyline it was fitted from. A piece costs more
455// than a segment; whether that trade is a win is `path_cost.rs`'s reading, not
456// an assertion here.
457//
458// **No `atan2` on the distance path.** Whether the nearest point on the circle
459// lies within the piece's sweep is a sector test, and a sector test is a dot
460// product against the sweep's mid direction — both precomputed CPU-side. The
461// crossing test below does need the angle, but only for a piece the scan line
462// actually meets, which is a small minority of them.
463fn arc_chain_sd(p: vec2<f32>, n: u32) -> f32 {
464    let TAU = 6.28318530718;
465    var best = 1e20;
466    var crossings = 0u;
467    for (var i = 0u; i < n; i = i + 1u) {
468        let base = i * 4u;
469        let head = params.path[base];
470        let ends = params.path[base + 2u];
471        let a = ends.xy;
472        let b = ends.zw;
473
474        if (head.x < 0.5) {
475            // A straight run — the fitter emits these for a corner it must keep
476            // and for an arc whose radius is too large to shade stably, so this
477            // arm carries a real share of a polygonal figure.
478            let e = b - a;
479            let w = p - a;
480            let t = clamp(dot(w, e) / max(dot(e, e), 1e-20), 0.0, 1.0);
481            let q = w - e * t;
482            best = min(best, dot(q, q));
483            // The half-open rule on y, exactly as the polyline uses it: a joint
484            // lying on the scan line belongs to one piece, not to both.
485            let c1 = p.y >= a.y;
486            let c2 = p.y < b.y;
487            let c3 = e.x * w.y > e.y * w.x;
488            if ((c1 && c2 && c3) || (!c1 && !c2 && !c3)) {
489                crossings = crossings + 1u;
490            }
491            continue;
492        }
493
494        let c = head.yz;
495        let r = head.w;
496        let sector = params.path[base + 1u];
497        let sweep = params.path[base + 3u];
498        let w = p - c;
499        let l = length(w);
500        // Inside the sweep, the nearest point on the circle is the nearest point
501        // on the arc; outside it, the nearest point is whichever end is closer.
502        if (l > 1e-9 && dot(w / l, sector.xy) >= sector.z) {
503            let d = abs(l - r);
504            best = min(best, d * d);
505        } else {
506            best = min(best, min(dot(p - a, p - a), dot(p - b, p - b)));
507        }
508
509        // The crossing test: where the scan line `y = p.y` meets this circle, to
510        // the RIGHT of `p`, and inside the sweep.
511        let dy = p.y - c.y;
512        let disc = r * r - dy * dy;
513        if (disc > 0.0) {
514            let sx = sqrt(disc);
515            for (var k = 0u; k < 2u; k = k + 1u) {
516                let xr = c.x + select(-sx, sx, k == 1u);
517                if (xr <= p.x) {
518                    continue;
519                }
520                // Half-open on the sweep — `u < span`, not `<=` — so a joint on
521                // the scan line is counted by the piece that starts there and
522                // not also by the one that ends there.
523                let ang = atan2(dy, xr - c.x);
524                var u = (ang - sweep.x) * sign(sweep.y);
525                u = u - TAU * floor(u / TAU);
526                if (u < abs(sweep.y)) {
527                    crossings = crossings + 1u;
528                }
529            }
530        }
531    }
532    return select(1.0, -1.0, (crossings & 1u) == 1u) * sqrt(best);
533}
534
535// The contour's radius along the ray from the figure's centre through `p` — the
536// divisor of `coord_mode = 1`'s scaled-copy coordinate (ADR-0111), on an
537// authored contour instead of a rostered arm.
538//
539// The OUTERMOST crossing is taken. A single closed contour that is star-shaped
540// about its centre has exactly one, and the choice only shows on one that is
541// not (a crescent), where the outer edge is the boundary and the concavity is
542// interior to the coordinate.
543fn path_boundary_radius(p: vec2<f32>, n: u32) -> f32 {
544    let l = length(p);
545    if (l < 1e-6) {
546        return 1e-6;
547    }
548    let u = p / l;
549    var r = 0.0;
550    var b = path_pt(n - 1u);
551    for (var i = 0u; i < n; i = i + 1u) {
552        let a = path_pt(i);
553        let e = b - a;
554        // Cross both sides of `s*u = a + e*t` with `u` to drop `s`, then solve
555        // for the segment parameter `t`.
556        let denom = e.x * u.y - e.y * u.x;
557        if (abs(denom) > 1e-9) {
558            let t = (a.y * u.x - a.x * u.y) / denom;
559            if (t >= 0.0 && t <= 1.0) {
560                let s = dot(a + e * t, u);
561                r = max(r, s);
562            }
563        }
564        b = a;
565    }
566    return max(r, 1e-6);
567}
568
569@fragment
570fn fs_main(in: VsOut) -> @location(0) vec4<f32> {
571    let aspect = params.a.x;
572    let shape = params.a.y;
573    let points = params.a.z;
574    let scale = params.a.w;
575    let pan = params.b.xy;
576    let color_span = params.b.z;
577    let color_center = params.b.w;
578    let saturation = params.c.x;
579    let palette_mix = params.c.y;
580    let palette_steps = params.c.z;
581    let palette_contour = params.c.w;
582    let gamma = params.d.y;
583    let coord_mode = params.d.z;
584    let rotation = params.d.w;
585    let star = params.e.xyz;
586    // The star arm's hand-drawn controls (seed, wobble amplitude, wobble
587    // frequency), which live in the three padding slots `e` and `g` already
588    // carried rather than in a wider uniform.
589    let rough = vec3<f32>(params.e.w, params.g.z, params.g.w);
590    let path_n = u32(params.f.x);
591    let path_inradius = params.f.y;
592    let stroke = params.f.z;
593    let path_arcs = u32(params.f.w);
594    let palette_contour_style = params.g.x;
595    let palette_contour_ink = params.g.y;
596
597    // Square units, from the RENDER TARGET's aspect (ADR-0037): stretching x
598    // makes one unit of `uv` the same length on both axes, so the figure below
599    // is the shape it claims to be and not the window's shape.
600    var uv = in.ndc;
601    uv.x = uv.x * aspect;
602
603    // The figure's own frame: `pan` moves its centre, `scale` sets its size.
604    //
605    // **`rotation` is applied AFTER the pan, and that is a choice.** Turning the
606    // sample point before subtracting `pan` would swing the figure around the
607    // frame's centre — an orbit — and turning it after swings it about its own.
608    // Both are defensible and they look completely different; this scene draws
609    // ONE figure, and a figure that spins in place is what `rotation` means on
610    // `lines/star.rs` and `lines/lsystem.rs` too.
611    //
612    // It is done in `uv`, which is already SQUARE units (ADR-0037): x has been
613    // stretched by the render target's aspect, so one unit is the same length on
614    // both axes and this is a rotation. In raw NDC the same two lines would
615    // SHEAR — invisible at 16:9, where the stretch is nearly 1, and obvious at
616    // 2:1. `tests` renders a square at 2:1 and turns it a quarter turn.
617    //
618    // A branch rather than an unconditional multiply, so 0 is an exact identity
619    // and no shipped preset moves through `cos`/`sin` (ADR-0092's care, the same
620    // reason `gamma` has one).
621    var q = uv - pan;
622    if (rotation != 0.0) {
623        let cr = cos(rotation);
624        let sr = sin(rotation);
625        // The INVERSE rotation on the sample point, so a positive `rotation`
626        // turns the figure counter-clockwise on screen rather than the frame.
627        q = vec2<f32>(cr * q.x + sr * q.y, cr * q.y - sr * q.x);
628    }
629    let p = q / scale;
630
631    // THE substitution this scene exists for: the palette coordinate is a
632    // FIGURE coordinate rather than a level. Both modes are 0 at the figure's
633    // centre and exactly 1 on its outline, and both grow outward — what differs
634    // is what a band of the coordinate is a band OF.
635    //
636    // An `if` rather than a `select`, and that is not style: `select` evaluates
637    // both arms, and the second arm here is a whole second shape evaluation. The
638    // mode is a per-draw uniform, so this branch is uniform across a warp and
639    // the hardware takes one arm rather than both.
640    //
641    // An authored contour takes the same two modes on the same terms
642    // (ADR-0107): what changes is where the silhouette came from, not what a
643    // band of the coordinate is a band of. `path_n` is 0 for every preset that
644    // declares no `[path]`, so those take the roster arms below and not one
645    // instruction of the contour walk executes.
646    var d: f32;
647    if (path_arcs >= 1u) {
648        // The arc chain, chosen CPU-side and only where it can serve: the
649        // distance coordinate, and no morph in flight. `path_inradius` is the
650        // POLYLINE's, which describes the same figure to within the fit's own
651        // lateral budget — a sub-pixel difference in a divisor.
652        d = max(1.0 + arc_chain_sd(p, path_arcs) / max(path_inradius, 1e-6), 0.0);
653    } else if (path_n >= 3u) {
654        if (coord_mode < 0.5) {
655            // `1 + sd / inradius` — the SAME normalization `mark_distance`
656            // applies to the roster, so an authored figure reads 0 at its
657            // deepest interior point and exactly 1 on its outline like every
658            // other silhouette this scene draws. Held at 0 from below because
659            // the inradius is measured on a grid and can land a hair short of
660            // the true deepest point; a negative coordinate would be a NaN
661            // under a bound `gamma` (`pow` of a negative base).
662            d = max(1.0 + path_sd(p, path_n) / max(path_inradius, 1e-6), 0.0);
663        } else {
664            d = length(p) / path_boundary_radius(p, path_n);
665        }
666    } else if (coord_mode < 0.5) {
667        // Mode 0 — a band of the coordinate is a band of constant DISTANCE,
668        // which is the definition of an offset curve (ADR-0105). This is the
669        // default and it is bit-for-bit the arithmetic that shipped.
670        d = mark_distance(p, shape, points, star, rough);
671    } else {
672        // Mode 1 — a band of the coordinate is a band of constant SCALING, so
673        // its level sets are scaled copies of the outline (ADR-0111). On a
674        // polygon that keeps the corners the offsets round off; on a heart it
675        // keeps the notch, which is the construction the reference images are.
676        d = length(p) / max(mark_boundary_radius(p, shape, points, star, rough), 1e-6);
677    }
678
679    // **The stroke's screen width, taken before any branch.** A derivative has
680    // to be evaluated in uniform control flow, and hoisting it is what keeps
681    // that true however the branch below is compiled — `band_contour_ink` hoists
682    // its own for the same reason.
683    let d_width = max(fwidth(d), 1e-5);
684    // The response exponent, applied to the distance BEFORE it becomes a palette
685    // coordinate — so it reshapes where the contours sit rather than which
686    // colours they take. Above 1 the bands crowd toward the centre, which is what
687    // the reference images do and what a raw (evenly spaced) distance cannot.
688    // `select` rather than a branch, and the identity is exact: `pow(x, 1.0)` is
689    // not bit-exact, so an unbound preset must not go through it (ADR-0092).
690    let shaped = select(pow(d, gamma), d, gamma == 1.0);
691    let coord = shaped * color_span + color_center;
692
693    // Hard bands, then the contour drawn from the SAME coordinate (ADR-0078).
694    let banded = band_coord(coord, palette_steps);
695    let ca = textureSample(lut_a, lut_samp, vec2<f32>(banded, 0.5)).rgb;
696    let cb = textureSample(lut_b, lut_samp, vec2<f32>(banded, 0.5)).rgb;
697    var col = mix(ca, cb, clamp(palette_mix, 0.0, 1.0));
698    col = band_contour_ink(
699        col, coord, palette_steps, palette_contour, palette_contour_style,
700        palette_contour_ink, lut_a, lut_b, lut_samp, palette_mix
701    );
702    col = apply_saturation(col, saturation);
703
704    // **Fill and stroke are one field, not two routes** (ADR-0107). `d` is the
705    // single evaluation above; the interior is `d < 1` and the outline is
706    // `abs(d - 1) < w`, so a stroke cannot drift off the fill it belongs to
707    // because there is nothing for it to drift from. (The ADR writes the pair
708    // as `d < 0` and `abs(d) < w` against a raw signed distance; this scene's
709    // coordinate is that distance normalized to 1 on the outline, so the two
710    // tests are the same two tests shifted by one.)
711    //
712    // **`stroke` is a width in the coordinate, and the coordinate is a metric
713    // distance only at a WHOLE `shape`** (ADR-0226). Mid-travel `d` is a blend
714    // of two arms' fields, so `abs(d - 1) < stroke` still finds the blended
715    // outline — the two arms both read 1 there — but the band's thickness on
716    // screen is not the width a whole index would draw, and it varies around the
717    // figure. The same qualification covers the spacing of the bands above.
718    //
719    // Exactly 0 is the identity and takes the branch away, which is what keeps
720    // every shipped preset and every golden baseline on the arithmetic it has.
721    if (stroke > 0.0) {
722        col = col * (1.0 - smoothstep(stroke - d_width, stroke + d_width, abs(d - 1.0)));
723    }
724
725    // Alpha: this field covers every pixel, which is the coverage it honestly
726    // has (ADR-0056). `occlude` scales how much of that the backdrop underneath
727    // resolves against (ADR-0085). Reached only when no post stage is active;
728    // the chain owns the seam otherwise and the renderer hands a literal 1.0.
729    return vec4<f32>(col, params.d.x);
730}
731"#;
732
733#[repr(C)]
734#[derive(Clone, Copy, bytemuck::Pod, bytemuck::Zeroable)]
735struct Params {
736    a: [f32; 4],
737    b: [f32; 4],
738    c: [f32; 4],
739    d: [f32; 4],
740    e: [f32; 4],
741    f: [f32; 4],
742    /// x: palette_contour_style, y: palette_contour_ink (ADR-0197); zw unused.
743    g: [f32; 4],
744    /// The authored contour, two points per element — see the WGSL's `path_pt`.
745    /// Written every frame with the rest of the struct; it changes only on a
746    /// preset switch, and 1.5 KB of `write_buffer` is far below the cost of
747    /// splitting it into a second binding whose layout shape would then have to
748    /// be argued against ADR-0058.
749    path: [[f32; 4]; PATH_VEC4S],
750}
751
752/// A fullscreen signed-distance figure from the shared mark roster, coloured
753/// through the shared palette.
754pub struct ShapeFieldScene {
755    /// The pipeline, the uniform buffer, the 256x1 gradient LUT pair (A/B) the
756    /// fragment samples + crossfades for colour (ADR-0021), and the one bind
757    /// group this scene binds.
758    gpu: gpu::FullscreenScene,
759    /// The silhouette and its point count, raw as the preset bound them —
760    /// `marks::mark_shape` / `mark_points` condition them on the way to the
761    /// uniform, which is where a selector's precondition belongs (the
762    /// `kaleido_edge` precedent). Only `mark_points` quantizes; `mark_shape`
763    /// clamps and leaves the fraction, which is what lets a bound `shape`
764    /// travel between two arms (ADR-0226).
765    shape: f32,
766    points: f32,
767    /// The `star` arm's shape params and its hand-drawn controls, raw as the
768    /// preset bound them (Plan 0091 Phase 5). `marks::star_*` condition them on
769    /// the way to the uniform. Inert on every other silhouette, and nothing
770    /// warns — `presets/README.md` carries that.
771    star_valley: f32,
772    star_curve: f32,
773    star_jitter: f32,
774    star_seed: f32,
775    star_wobble: f32,
776    star_wobble_freq: f32,
777    scale: f32,
778    /// The shared palette knobs (ADR-0021). This scene has no `hue` or
779    /// `brightness`.
780    colour: common::PaletteParams,
781    /// The shared view transform (ADR-0018).
782    pan: common::PanParams,
783    color_span: f32,
784    color_center: f32,
785    /// The response exponent on the distance, raw as the preset bound it;
786    /// [`applied_gamma`] conditions it on the way to the uniform.
787    gamma: f32,
788    /// Which coordinate the palette is handed, raw as the preset bound it;
789    /// [`applied_coord_mode`] quantizes it on the way to the uniform, which is
790    /// where a selector's precondition belongs.
791    coord_mode: f32,
792    /// The figure's own turn, in radians, raw as the preset bound it. Applied
793    /// about the figure's centre rather than the frame's — see the shader.
794    rotation: f32,
795    /// The stroke half-width in coordinate units, raw as the preset bound it;
796    /// [`applied_stroke`] conditions it on the way to the uniform. `0` — the
797    /// default — is the filled figure and an exact arithmetic identity.
798    stroke: f32,
799    /// The authored contour, and the one `morph` travels towards (ADR-0107).
800    /// Both are set by [`Scene::configure`] on a preset switch and by nothing
801    /// else — geometry is structural, not a param, so `reset_params` does not
802    /// touch them.
803    ///
804    /// Fewer than 3 points in `path_from` means no authored contour and the
805    /// scene draws the `marks` roster, which is what every preset declaring no
806    /// `[path]` gets. An empty `path_to` means no morph target, so `morph` is
807    /// inert and the packed contour is `path_from` verbatim.
808    path_from: Vec<[f32; 2]>,
809    path_to: Vec<[f32; 2]>,
810    /// The same authored outline as a **G1-continuous chain of circular arcs**,
811    /// fitted at load through the line renderer's own fitter (ADR-0098). Empty
812    /// where the fit was not worth keeping, and unread while a morph is in
813    /// flight or under the scaled-copy coordinate — see [`Self::pack_path`].
814    pieces: Vec<crate::render::scenes::lines::biarc::Piece>,
815    /// The contour packed for the uniform — the interpolation of the two above
816    /// at this frame's `morph`, rebuilt in `render`.
817    ///
818    /// A field rather than a local so the per-frame pack writes into an
819    /// allocation made once. This is the render thread, not the audio callback,
820    /// but the rule that a per-frame path does not allocate is the same one.
821    path: Box<[[f32; 4]; PATH_VEC4S]>,
822    /// The two contours' inradii — the distance from each one's deepest interior
823    /// point to its own outline, measured by [`contour_inradius`] at configure
824    /// time.
825    ///
826    /// It is the divisor that makes the authored figure's coordinate `0` at that
827    /// deepest point and `1` on the outline, which is the contract every
828    /// silhouette in this scene meets (`marks`' own header states it for the
829    /// roster).
830    ///
831    /// **Mid-morph the two are interpolated rather than re-measured.** The true
832    /// inradius of an interpolated contour is not the interpolation of the two
833    /// inradii, and measuring it is a grid search — load-time work, not
834    /// per-frame. The error is bounded and one-sided in the direction that
835    /// matters: the shader clamps the coordinate at 0 from below, so an
836    /// underestimate costs nothing and an overestimate leaves the innermost
837    /// sliver short of the palette's first texel.
838    path_inradius: f32,
839    path_inradius_to: f32,
840    /// Whether the contour(s) this scene is holding are star-shaped about the
841    /// centre `coord_mode = 1` divides by — `None` where the preset declares no
842    /// `[path]` and the roster arm is the figure.
843    ///
844    /// Read off the parsed shape at configure time, where the geometry is
845    /// (`PathShape::star_shaped`), and `false` when EITHER endpoint of a morph
846    /// pair fails: both are drawn. **An intermediate contour of a morph is not
847    /// tested** — it exists only between two frames' interpolation, and two
848    /// star-shaped endpoints can pass through a contour that is not.
849    path_star_shaped: Option<bool>,
850    /// How far along `path_from` -> `path_to` the figure is, raw as the preset
851    /// bound it. `0` — the default — is the authored figure, and an exact
852    /// identity: the interpolation is skipped entirely.
853    morph: f32,
854    /// How much of this field's (total) coverage the backdrop resolves against
855    /// (ADR-0085). Set by the renderer every frame — **not** a named param, so
856    /// it is not reset by `reset_params`.
857    occlude: f32,
858}
859
860impl ShapeFieldScene {
861    /// Build the scene's pipeline and uniform buffer on `device`.
862    pub fn new(device: &wgpu::Device, surface_format: wgpu::TextureFormat) -> Self {
863        // The mark roster's chunk is prepended, exactly as the two particle
864        // scenes do it: it declares no bindings and no entry points, so
865        // splicing it in changes nothing about this pipeline's layout.
866        let source = format!("{}{SHADER}", marks::sdf_wgsl());
867        let shader = gpu::fullscreen_shader(
868            device,
869            "shape-field-shader",
870            gpu::FULLSCREEN_VS_NDC,
871            &source,
872        );
873        let parts = gpu::FullscreenParts::new(device, "shape-field", std::mem::size_of::<Params>());
874        // One group, sampler first and uniform last — see the WGSL's note for
875        // why this shape and not `fragment_field`'s two-group split. The uniform
876        // entry is a full literal rather than `gpu::uniform` because that helper
877        // passes `min_binding_size: None`, and declaring one is half of what
878        // makes this shape unique.
879        let bind_layout = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
880            label: Some("shape-field-bind-layout"),
881            entries: &[
882                gpu::sampler(0),
883                gpu::texture(1, true),
884                gpu::texture(2, true),
885                wgpu::BindGroupLayoutEntry {
886                    binding: 3,
887                    visibility: wgpu::ShaderStages::VERTEX_FRAGMENT,
888                    ty: wgpu::BindingType::Buffer {
889                        ty: wgpu::BufferBindingType::Uniform,
890                        has_dynamic_offset: false,
891                        min_binding_size: wgpu::BufferSize::new(
892                            std::mem::size_of::<Params>() as u64
893                        ),
894                    },
895                    count: None,
896                },
897            ],
898        });
899        // This layout binds the sampler first and the two textures after it, so
900        // the pair's role-ordered array is destructured into binding order here.
901        let [lut_a, lut_b, lut_sampler] = parts.luts().bind_entries(1, 2, 0);
902        let bind_group = device.create_bind_group(&wgpu::BindGroupDescriptor {
903            label: Some("shape-field-bind-group"),
904            layout: &bind_layout,
905            entries: &[
906                lut_sampler,
907                lut_a,
908                lut_b,
909                wgpu::BindGroupEntry {
910                    binding: 3,
911                    resource: parts.uniforms().as_entire_binding(),
912                },
913            ],
914        });
915
916        Self {
917            gpu: parts.finish(
918                device,
919                &shader,
920                &[&bind_layout],
921                bind_group,
922                None,
923                surface_format,
924                wgpu::BlendState::PREMULTIPLIED_ALPHA_BLENDING,
925                "shape-field",
926            ),
927            shape: marks::DEFAULT_SHAPE,
928            points: marks::DEFAULT_POINTS,
929            star_valley: marks::DEFAULT_STAR_VALLEY,
930            star_curve: marks::DEFAULT_STAR_CURVE,
931            star_jitter: marks::DEFAULT_STAR_JITTER,
932            star_seed: marks::DEFAULT_STAR_SEED,
933            star_wobble: marks::DEFAULT_STAR_WOBBLE,
934            star_wobble_freq: marks::DEFAULT_STAR_WOBBLE_FREQ,
935            scale: DEFAULT_SCALE,
936            colour: common::PaletteParams::new(0.0, common::DEFAULT_BRIGHTNESS),
937            pan: common::PanParams::default(),
938            color_span: DEFAULT_COLOR_SPAN,
939            color_center: DEFAULT_COLOR_CENTER,
940            gamma: DEFAULT_GAMMA,
941            coord_mode: DEFAULT_COORD_MODE,
942            rotation: DEFAULT_ROTATION,
943            stroke: DEFAULT_STROKE,
944            morph: DEFAULT_MORPH,
945            path_from: Vec::new(),
946            path_to: Vec::new(),
947            pieces: Vec::new(),
948            path: Box::new([[0.0; 4]; PATH_VEC4S]),
949            path_inradius: 1.0,
950            path_inradius_to: 1.0,
951            path_star_shaped: None,
952            occlude: crate::render::post::DEFAULT_OCCLUDE,
953        }
954    }
955}
956
957/// The `scale` the shader is handed: held inside the range the arithmetic needs,
958/// with a non-finite binding falling back to the default.
959///
960/// The clamp is CPU-side for the reason `background::applied_ramp_gamma` states:
961/// it can never be reached with a NaN, where WGSL's `clamp` is
962/// implementation-defined, and the default stays **exactly** the default on the
963/// way to the uniform.
964fn applied_scale(scale: f32) -> f32 {
965    if scale.is_finite() {
966        scale.clamp(MIN_SCALE, MAX_SCALE)
967    } else {
968        DEFAULT_SCALE
969    }
970}
971
972/// The `rotation` the shader is handed: passed through, with a non-finite
973/// binding falling back to the identity.
974///
975/// No clamp, because an angle wraps and there is no end of the range to hold it
976/// inside — the same treatment `lines/star.rs` gives the name. The finiteness
977/// guard is not decoration: `cos(NaN)` is `NaN`, and one `NaN` in the figure's
978/// own frame takes every pixel of the frame with it.
979fn applied_rotation(rotation: f32) -> f32 {
980    if rotation.is_finite() {
981        rotation
982    } else {
983        DEFAULT_ROTATION
984    }
985}
986
987/// The `coord_mode` the shader is handed: clamped into the roster, then
988/// **rounded to an integer**, with a non-finite binding falling back to the
989/// default — and **forced back to the distance on a figure the scaled copy has
990/// no single value on**.
991///
992/// The quantizing half is the `kaleido_edge` precedent, and `marks::mark_points`
993/// beside it. A mode's values are **identities** rather than a quantity:
994/// `[smoothing]` and preset dissolves interpolate a binding continuously from
995/// one setting to another, so easing the distance to the radius passes through
996/// 0.4 and 0.6, and there is nothing halfway between an offset curve and a
997/// scaled copy for the shader to draw there. `marks::mark_shape` is the
998/// contrasting case rather than the parallel one: a position between two
999/// silhouettes is a figure (ADR-0226), and a position between two coordinate
1000/// modes is not.
1001///
1002/// # The fallback, and why it is not silent
1003///
1004/// The scaled copy divides by the boundary radius along a ray from the figure's
1005/// centre, so it needs a figure every such ray leaves exactly once. Two figures
1006/// this scene can draw are not: a **`ring`**, whose centre is in its hole, and
1007/// an **authored contour that is not star-shaped about its centre** — a
1008/// crescent, or a silhouette whose sinuses put one lobe across the ray into
1009/// another. On both the shader takes the outermost crossing and everything
1010/// between the crossings reads as interior, which collapses the figure to a dot
1011/// inside a few huge rays.
1012///
1013/// A roster position that only *touches* the `ring` — anywhere in the open
1014/// travel between `disc` and `polygon` (ADR-0226) — inherits the defect from the
1015/// side it blends, so `marks::shape_touches_ring` is the test rather than
1016/// equality. At a whole index the two are the same test.
1017///
1018/// `contour_star_shaped` is the verdict for the contour being drawn, or `None`
1019/// where there is no `[path]` table — in which case the roster arm is the figure
1020/// and the `ring` is the one arm that fails. A contour REPLACES the roster arm
1021/// in the shader, so the two tests are alternatives rather than both.
1022///
1023/// The `ring`'s case is the one behavioural choice ADR-0111 leaves open. Plan
1024/// 0098 Phase 4 rendered the three defensible answers before picking, and what
1025/// settled it is that defining the boundary as the outer rim produces a figure
1026/// **byte-identical to a `disc`**: the coordinate collapses to `length(p)` and
1027/// the hole stops existing, so a preset naming one roster entry would be shown
1028/// another. That is the negative ADR-0111 records, reached in practice.
1029///
1030/// So the combination is refused rather than approximated, and the refusal is
1031/// **announced**: `Preset::from_toml_str` warns at load when a preset rests on
1032/// it (ADR-0020's shape, the `thickness` dead-zone precedent), on the same
1033/// condition this decides on. The silent fallback was the third candidate and it
1034/// is the one this rejects — it renders the same pixels as this does and costs
1035/// an author the afternoon.
1036fn applied_coord_mode(mode: f32, shape: f32, contour_star_shaped: Option<bool>) -> f32 {
1037    let single_valued = match contour_star_shaped {
1038        Some(star_shaped) => star_shaped,
1039        None => !marks::shape_touches_ring(shape),
1040    };
1041    if !single_valued {
1042        return DEFAULT_COORD_MODE;
1043    }
1044    if mode.is_finite() {
1045        mode.clamp(MIN_COORD_MODE, MAX_COORD_MODE).round()
1046    } else {
1047        DEFAULT_COORD_MODE
1048    }
1049}
1050
1051/// The exponent the shader will **actually apply** for a bound `gamma`: a
1052/// non-finite binding falls back to the identity, and a finite one is held
1053/// inside the positive range ([`MIN_GAMMA`], [`MAX_GAMMA`]).
1054///
1055/// CPU-side for `ink::applied_gamma`'s two reasons: `1.0` stays **exactly**
1056/// `1.0` on the way to the uniform, which is what the shader's identity branch
1057/// tests, and the clamp can never be reached with a NaN, where WGSL's `clamp` is
1058/// implementation-defined.
1059fn applied_gamma(gamma: f32) -> f32 {
1060    if gamma.is_finite() {
1061        gamma.clamp(MIN_GAMMA, MAX_GAMMA)
1062    } else {
1063        DEFAULT_GAMMA
1064    }
1065}
1066
1067/// The `stroke` the shader is handed: held inside its range, with a non-finite
1068/// binding falling back to the filled figure.
1069///
1070/// **`0` survives as exactly `0`**, which the shader's identity branch tests
1071/// for: a filled figure must execute none of the stroke arithmetic, so every
1072/// shipped preset and every golden baseline stays on what it has (ADR-0092's
1073/// care, the same reason `gamma` and `rotation` have identity branches).
1074fn applied_stroke(stroke: f32) -> f32 {
1075    if stroke.is_finite() {
1076        stroke.clamp(0.0, MAX_STROKE)
1077    } else {
1078        DEFAULT_STROKE
1079    }
1080}
1081
1082/// The `morph` the contour is interpolated at: held inside `0..=1`, with a
1083/// non-finite binding falling back to the authored figure.
1084///
1085/// Clamped rather than wrapped, and not extrapolated past either end: outside
1086/// `0..=1` the interpolation leaves both authored silhouettes behind and the
1087/// figure is one nobody drew — which is a different thing from the mid-morph
1088/// shapes nobody drew, because those at least lie between two that someone did.
1089fn applied_morph(morph: f32) -> f32 {
1090    if morph.is_finite() {
1091        morph.clamp(0.0, 1.0)
1092    } else {
1093        DEFAULT_MORPH
1094    }
1095}
1096
1097/// The contour's **inradius**: the distance from its deepest interior point to
1098/// its own outline, in the normalized `[-1, 1]` frame the contour lives in.
1099///
1100/// Measured rather than derived, because a closed contour has no closed form for
1101/// it. A coarse grid over the box finds the deepest cell, then three rounds of
1102/// local search shrink around it — so the reading is the grid's resolution only
1103/// until the refinement, and the refinement halves its neighbourhood each round.
1104///
1105/// **It can still land a hair short**, which is why the shader clamps the
1106/// coordinate at 0 from below rather than trusting this. Short is the safe
1107/// direction: it makes the innermost sliver of the figure read as the palette's
1108/// first texel, where over-reporting would leave the interior never reaching it.
1109fn contour_inradius(points: &[[f32; 2]]) -> f32 {
1110    /// Cells per axis of the first pass, over the `[-1, 1]` box.
1111    const GRID: i32 = 96;
1112    /// Local refinement rounds, each halving the search radius.
1113    const REFINE: u32 = 12;
1114
1115    let depth_at = |p: [f32; 2]| -> f32 {
1116        // Unsigned distance to the closing polygon, and a crossing parity for
1117        // whether `p` is inside it — the CPU counterpart of the WGSL's
1118        // `path_sd`, kept to the one quantity the shader needs from the CPU
1119        // rather than mirroring the whole field.
1120        let n = points.len();
1121        let mut best = f32::INFINITY;
1122        let mut inside = false;
1123        for i in 0..n {
1124            let (Some(&a), Some(&b)) = (points.get(i), points.get((i + n - 1) % n)) else {
1125                continue;
1126            };
1127            let e = [b[0] - a[0], b[1] - a[1]];
1128            let w = [p[0] - a[0], p[1] - a[1]];
1129            let ee = (e[0] * e[0] + e[1] * e[1]).max(1e-20);
1130            let t = ((w[0] * e[0] + w[1] * e[1]) / ee).clamp(0.0, 1.0);
1131            let q = [w[0] - e[0] * t, w[1] - e[1] * t];
1132            best = best.min(q[0] * q[0] + q[1] * q[1]);
1133            let c1 = p[1] >= a[1];
1134            let c2 = p[1] < b[1];
1135            let c3 = e[0] * w[1] > e[1] * w[0];
1136            if (c1 && c2 && c3) || (!c1 && !c2 && !c3) {
1137                inside = !inside;
1138            }
1139        }
1140        if inside { best.sqrt() } else { 0.0 }
1141    };
1142
1143    let mut best_p = [0.0f32, 0.0];
1144    let mut best_d = depth_at(best_p);
1145    for gy in 0..=GRID {
1146        for gx in 0..=GRID {
1147            let p = [
1148                (gx as f32 / GRID as f32) * 2.0 - 1.0,
1149                (gy as f32 / GRID as f32) * 2.0 - 1.0,
1150            ];
1151            let d = depth_at(p);
1152            if d > best_d {
1153                best_d = d;
1154                best_p = p;
1155            }
1156        }
1157    }
1158    let mut radius = 2.0 / GRID as f32;
1159    for _ in 0..REFINE {
1160        for (dx, dy) in [
1161            (-1.0f32, 0.0f32),
1162            (1.0, 0.0),
1163            (0.0, -1.0),
1164            (0.0, 1.0),
1165            (-1.0, -1.0),
1166            (1.0, -1.0),
1167            (-1.0, 1.0),
1168            (1.0, 1.0),
1169        ] {
1170            let p = [best_p[0] + dx * radius, best_p[1] + dy * radius];
1171            let d = depth_at(p);
1172            if d > best_d {
1173                best_d = d;
1174                best_p = p;
1175            }
1176        }
1177        radius *= 0.5;
1178    }
1179    // A contour with no interior the search could find would divide the whole
1180    // frame by zero; the floor keeps the coordinate finite and the figure reads
1181    // as all exterior, which is what a zero-area contour is.
1182    best_d.max(1e-4)
1183}
1184
1185/// The palette coordinate this scene hands the LUT, as a CPU mirror of the
1186/// shader's two lines — so the exponent's properties are testable without a GPU
1187/// (the arrangement `ink::key` and `tonemap::map` both use).
1188#[cfg(test)]
1189pub(crate) fn coord(distance: f32, gamma: f32, color_span: f32, color_center: f32) -> f32 {
1190    let g = applied_gamma(gamma);
1191    let shaped = if g == 1.0 { distance } else { distance.powf(g) };
1192    shaped * color_span + color_center
1193}
1194
1195/// The parameter names this scene consumes — the vocabulary a preset binding is
1196/// checked against at load (ADR-0020). **Keep in sync with `set_param` below**;
1197/// `declared_params_match_set_param` in `core/tests/suite/preset.rs` fails if the two
1198/// drift.
1199pub const PARAMS: &[ParamSpec] = &[
1200    crate::render::scenes::marks::SHAPE,
1201    crate::render::scenes::marks::POINTS,
1202    crate::render::scenes::marks::STAR_VALLEY,
1203    crate::render::scenes::marks::STAR_CURVE,
1204    crate::render::scenes::marks::STAR_JITTER,
1205    crate::render::scenes::marks::STAR_SEED,
1206    crate::render::scenes::marks::STAR_WOBBLE,
1207    crate::render::scenes::marks::STAR_WOBBLE_FREQ,
1208    ParamSpec {
1209        name: "scale",
1210        default: 0.6,
1211        range: Some([0.05, 2.0]),
1212        doc: "Size of the shape within the frame.",
1213        kind: ParamKind::Modal,
1214        group: ParamGroup::Shape,
1215        main: true,
1216    },
1217    crate::render::scenes::common::PAN_X,
1218    crate::render::scenes::common::PAN_Y,
1219    ParamSpec {
1220        name: "color_span",
1221        default: 0.6,
1222        range: Some([0.0, 1.0]),
1223        doc: "How much of the palette the field's range covers.",
1224        kind: ParamKind::Modal,
1225        group: ParamGroup::Colour,
1226        main: false,
1227    },
1228    ParamSpec {
1229        name: "color_center",
1230        default: 0.0,
1231        range: Some([-1.0, 1.0]),
1232        doc: "Shifts which part of that range lands in the middle of the palette.",
1233        kind: ParamKind::Modal,
1234        group: ParamGroup::Colour,
1235        main: false,
1236    },
1237    crate::render::scenes::common::SATURATION,
1238    crate::render::scenes::common::PALETTE_MIX,
1239    crate::render::scenes::common::PALETTE_STEPS,
1240    crate::render::scenes::common::PALETTE_CONTOUR,
1241    crate::render::scenes::common::PALETTE_CONTOUR_STYLE,
1242    crate::render::scenes::common::PALETTE_CONTOUR_INK,
1243    ParamSpec {
1244        name: "gamma",
1245        default: 1.0,
1246        range: Some([0.25, 4.0]),
1247        doc: "Shapes the falloff from the shape's edge; below 1 it bites sooner.",
1248        kind: ParamKind::Modal,
1249        group: ParamGroup::Light,
1250        main: false,
1251    },
1252    ParamSpec {
1253        name: "coord_mode",
1254        default: 0.0,
1255        // The top of the range is the roster's last index, which is where
1256        // `applied_coord_mode` clamps. A range above it advertises a mode that
1257        // silently resolves to another one.
1258        range: Some([MIN_COORD_MODE, MAX_COORD_MODE]),
1259        doc: "Which coordinate frame the distance is measured in, which changes the shape's whole geometry.",
1260        kind: ParamKind::Structural,
1261        group: ParamGroup::Shape,
1262        main: false,
1263    },
1264    ParamSpec {
1265        name: "rotation",
1266        default: 0.0,
1267        range: Some([0.0, std::f32::consts::TAU]),
1268        doc: "Turns the shape, in radians.",
1269        kind: ParamKind::Modal,
1270        group: ParamGroup::Motion,
1271        main: true,
1272    },
1273    ParamSpec {
1274        name: "stroke",
1275        default: 0.0,
1276        range: Some([0.0, 1.0]),
1277        doc: "Draws the outline instead of the filled figure, at this half-width; 0 fills.",
1278        kind: ParamKind::Modal,
1279        group: ParamGroup::Shape,
1280        main: false,
1281    },
1282    ParamSpec {
1283        name: "morph",
1284        default: 0.0,
1285        range: Some([0.0, 1.0]),
1286        doc: "Travels the authored path towards its morph_to silhouette; inert without one.",
1287        kind: ParamKind::Modal,
1288        group: ParamGroup::Motion,
1289        main: false,
1290    },
1291];
1292
1293impl ShapeFieldScene {
1294    /// Pack this frame's contour into the uniform array and report `(point
1295    /// count, inradius)` for the uniform's scalars.
1296    ///
1297    /// **The morph is interpolated here, on the CPU, once per frame** — not per
1298    /// pixel in the shader. The alternative was to hand the GPU both contours
1299    /// and lerp inside the distance loop, which would double the uniform, double
1300    /// the per-pixel loads, and re-derive at 2 M pixels a value that changes once
1301    /// a frame. `morph` is a parameter, not geometry.
1302    ///
1303    /// At `morph = 0`, or with no target, the authored contour is copied
1304    /// verbatim and no interpolation runs — the identity every other param on
1305    /// this scene keeps.
1306    fn pack_path(&mut self, coord_mode: f32) -> (usize, usize, f32) {
1307        let n = self.path_from.len().min(MAX_SAMPLES);
1308        if n < 3 {
1309            return (0, 0, 1.0);
1310        }
1311        let morphing = self.path_to.len() == self.path_from.len();
1312
1313        // **The arc chain serves where it can, and the polyline everywhere
1314        // else.** Two things put a figure back on points, and both are the
1315        // chain's own limits rather than a preference:
1316        //
1317        // - **a morph in flight.** Phase-correspondent points interpolate; two
1318        //   arc chains have no such correspondence, and inventing one is the
1319        //   representation problem ADR-0075 exists about. So a morphing pair
1320        //   travels on the polyline it was aligned as.
1321        // - **the scaled-copy coordinate.** `coord_mode = 1` needs the boundary
1322        //   radius along a ray, which is a second intersection routine the chain
1323        //   does not carry.
1324        if !morphing && coord_mode < 0.5 && !self.pieces.is_empty() {
1325            let pieces = self.pieces.len().min(MAX_ARC_PIECES);
1326            self.pack_pieces(pieces);
1327            return (0, pieces, self.path_inradius);
1328        }
1329
1330        let t = if morphing {
1331            applied_morph(self.morph)
1332        } else {
1333            0.0
1334        };
1335        for i in 0..n {
1336            let Some(&a) = self.path_from.get(i) else {
1337                continue;
1338            };
1339            let p = match self.path_to.get(i) {
1340                Some(&b) if t != 0.0 => [a[0] + (b[0] - a[0]) * t, a[1] + (b[1] - a[1]) * t],
1341                _ => a,
1342            };
1343            let slot = i >> 1;
1344            let half = (i & 1) * 2;
1345            if let Some(v) = self.path.get_mut(slot) {
1346                if let Some(x) = v.get_mut(half) {
1347                    *x = p[0];
1348                }
1349                if let Some(y) = v.get_mut(half + 1) {
1350                    *y = p[1];
1351                }
1352            }
1353        }
1354        let inradius = self.path_inradius + (self.path_inradius_to - self.path_inradius) * t;
1355        (n, 0, inradius.max(1e-4))
1356    }
1357
1358    /// Pack the fitted arc chain into the uniform: four elements per piece, in
1359    /// the layout the WGSL's `Params.path` comment spells out.
1360    ///
1361    /// The sector test's mid direction and half-angle are computed **here**, so
1362    /// the fragment's own test is a dot product rather than an `atan2` per piece
1363    /// per pixel. Same for the endpoints, which the outside-the-sweep arm needs.
1364    fn pack_pieces(&mut self, count: usize) {
1365        use crate::render::scenes::lines::biarc::Piece;
1366        for i in 0..count {
1367            let Some(&piece) = self.pieces.get(i) else {
1368                continue;
1369            };
1370            let base = i * VEC4S_PER_PIECE;
1371            let (a, b) = (piece.start_point(), piece.end_point());
1372            let (head, sector, sweep) = match piece {
1373                Piece::Arc {
1374                    centre,
1375                    radius,
1376                    start,
1377                    sweep,
1378                } => {
1379                    let mid = start + sweep * 0.5;
1380                    (
1381                        [1.0, centre[0], centre[1], radius],
1382                        [mid.cos(), mid.sin(), (sweep.abs() * 0.5).cos(), 0.0],
1383                        [start, sweep, 0.0, 0.0],
1384                    )
1385                }
1386                Piece::Line { .. } => ([0.0; 4], [0.0; 4], [0.0; 4]),
1387            };
1388            for (offset, value) in [
1389                (0, head),
1390                (1, sector),
1391                (2, [a[0], a[1], b[0], b[1]]),
1392                (3, sweep),
1393            ] {
1394                if let Some(slot) = self.path.get_mut(base + offset) {
1395                    *slot = value;
1396                }
1397            }
1398        }
1399    }
1400}
1401
1402impl Scene for ShapeFieldScene {
1403    fn name(&self) -> &'static str {
1404        "shape field"
1405    }
1406
1407    fn set_occlude(&mut self, occlude: f32) {
1408        self.occlude = occlude;
1409    }
1410
1411    fn set_palette(&mut self, palette: &Palette) {
1412        self.gpu.set_palette(palette);
1413    }
1414
1415    fn reset_params(&mut self) {
1416        self.shape = marks::DEFAULT_SHAPE;
1417        self.points = marks::DEFAULT_POINTS;
1418        self.star_valley = marks::DEFAULT_STAR_VALLEY;
1419        self.star_curve = marks::DEFAULT_STAR_CURVE;
1420        self.star_jitter = marks::DEFAULT_STAR_JITTER;
1421        self.star_seed = marks::DEFAULT_STAR_SEED;
1422        self.star_wobble = marks::DEFAULT_STAR_WOBBLE;
1423        self.star_wobble_freq = marks::DEFAULT_STAR_WOBBLE_FREQ;
1424        self.scale = DEFAULT_SCALE;
1425        self.colour.reset();
1426        self.pan.reset();
1427        self.color_span = DEFAULT_COLOR_SPAN;
1428        self.color_center = DEFAULT_COLOR_CENTER;
1429        self.gamma = DEFAULT_GAMMA;
1430        self.coord_mode = DEFAULT_COORD_MODE;
1431        self.rotation = DEFAULT_ROTATION;
1432        self.stroke = DEFAULT_STROKE;
1433        self.morph = DEFAULT_MORPH;
1434    }
1435
1436    /// The `[path]` table (ADR-0107), which is the only structural config this
1437    /// scene takes.
1438    ///
1439    /// **Called on every `shape_field` preset switch, table or no table** — the
1440    /// loader hands `Some(Path { shape: None })` for a preset that declares
1441    /// none, exactly so this runs and clears the contour. Without that, a switch
1442    /// from a path preset to a roster one would keep drawing the outgoing
1443    /// preset's silhouette.
1444    fn configure(
1445        &mut self,
1446        cfg: &super::lines::GeneratorConfig,
1447    ) -> Option<super::lines::CapOverflow> {
1448        if let super::lines::GeneratorConfig::Path { shape, morph_to } = cfg {
1449            self.path_from.clear();
1450            self.path_to.clear();
1451            self.pieces.clear();
1452            self.path_inradius = 1.0;
1453            self.path_inradius_to = 1.0;
1454            self.path_star_shaped = None;
1455            if let Some(contour) = shape {
1456                // The load boundary already refused an arity above the ceiling,
1457                // so the `take` is a belt on a boundary that holds rather than a
1458                // decimation an author is not told about.
1459                self.path_from
1460                    .extend(contour.points().iter().take(MAX_SAMPLES).copied());
1461                self.path_inradius = contour_inradius(&self.path_from);
1462                self.path_star_shaped = Some(contour.star_shaped());
1463                self.pieces.extend_from_slice(contour.pieces());
1464            }
1465            // The pair is aligned at load; a target of a different arity would
1466            // mean the loader let one through, so it is dropped rather than
1467            // interpolated against the wrong correspondent.
1468            if let Some(target) = morph_to
1469                .as_ref()
1470                .filter(|t| t.points().len() == self.path_from.len())
1471            {
1472                self.path_to.extend(target.points().iter().copied());
1473                self.path_inradius_to = contour_inradius(&self.path_to);
1474                // Both endpoints are drawn, so either one failing takes the
1475                // scaled copy away for the whole travel.
1476                self.path_star_shaped =
1477                    Some(self.path_star_shaped.unwrap_or(true) && target.star_shaped());
1478            }
1479        }
1480        None
1481    }
1482
1483    fn set_param(&mut self, name: &str, value: f32) {
1484        // The shared param blocks first, this scene's own names after
1485        // (`scenes::common`).
1486        if self.colour.set(name, value) || self.pan.set(name, value) {
1487            return;
1488        }
1489        match name {
1490            "shape" => self.shape = value,
1491            "points" => self.points = value,
1492            "star_valley" => self.star_valley = value,
1493            "star_curve" => self.star_curve = value,
1494            "star_jitter" => self.star_jitter = value,
1495            "star_seed" => self.star_seed = value,
1496            "star_wobble" => self.star_wobble = value,
1497            "star_wobble_freq" => self.star_wobble_freq = value,
1498            "scale" => self.scale = value,
1499            "color_span" => self.color_span = value,
1500            "color_center" => self.color_center = value,
1501            "gamma" => self.gamma = value,
1502            "coord_mode" => self.coord_mode = value,
1503            "rotation" => self.rotation = value,
1504            "stroke" => self.stroke = value,
1505            "morph" => self.morph = value,
1506            _ => {}
1507        }
1508    }
1509
1510    fn update(&mut self, _frame: &AnalysisFrame) {
1511        // Fully parameter-driven; the analysis reaches this scene only through
1512        // the preset expressions bound to its parameters.
1513    }
1514
1515    fn render(
1516        &mut self,
1517        queue: &wgpu::Queue,
1518        encoder: &mut wgpu::CommandEncoder,
1519        view: &wgpu::TextureView,
1520        aspect: f32,
1521    ) {
1522        // Quantized once, because `applied_coord_mode` has to see the same value
1523        // the shader will: the `ring` refusal is a fact about the SELECTED arm,
1524        // not about the raw binding.
1525        let shape = marks::mark_shape(self.shape);
1526        self.gpu.flush_palette(queue);
1527        let coord_mode = applied_coord_mode(self.coord_mode, shape, self.path_star_shaped);
1528        let (path_count, path_arcs, path_inradius) = self.pack_path(coord_mode);
1529
1530        let params = Params {
1531            // `aspect` is the argument the chain hands down for the target this
1532            // scene is drawing into — never a size this scene chose (ADR-0037).
1533            a: [
1534                aspect.max(0.1),
1535                shape,
1536                marks::mark_points(self.points),
1537                applied_scale(self.scale),
1538            ],
1539            b: [self.pan.x, self.pan.y, self.color_span, self.color_center],
1540            c: [
1541                self.colour.saturation,
1542                self.colour.mix,
1543                palette::band_steps(self.colour.steps),
1544                palette::band_contour(self.colour.contour),
1545            ],
1546            d: [
1547                self.occlude,
1548                applied_gamma(self.gamma),
1549                coord_mode,
1550                applied_rotation(self.rotation),
1551            ],
1552            e: [
1553                marks::star_valley(self.star_valley),
1554                marks::star_curve(self.star_curve),
1555                marks::star_jitter(self.star_jitter),
1556                marks::star_seed(self.star_seed),
1557            ],
1558            f: [
1559                path_count as f32,
1560                path_inradius,
1561                applied_stroke(self.stroke),
1562                path_arcs as f32,
1563            ],
1564            g: [
1565                palette::band_contour_style(self.colour.contour_style),
1566                self.colour.contour_ink,
1567                marks::star_wobble(self.star_wobble),
1568                marks::star_wobble_freq(self.star_wobble_freq),
1569            ],
1570            path: *self.path,
1571        };
1572        self.gpu.write_uniform(queue, &params);
1573        self.gpu
1574            .draw(encoder, "shape-field-pass", view, wgpu::LoadOp::Load);
1575    }
1576}
1577
1578#[cfg(test)]
1579mod tests;