1#![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
107const VEC4S_PER_PIECE: usize = 4;
111
112const PATH_VEC4S: usize = VEC4S_PER_PIECE * MAX_ARC_PIECES;
126
127const _: () = assert!(
132 PATH_VEC4S == 128 && PATH_VEC4S >= MAX_SAMPLES / 2,
133 "the WGSL `path` array must be PATH_VEC4S long, and hold either geometry"
134);
135
136const DEFAULT_SCALE: f32 = default_of(PARAMS, "scale");
141const MIN_SCALE: f32 = 0.01;
145const MAX_SCALE: f32 = 20.0;
149
150const DEFAULT_ROTATION: f32 = default_of(PARAMS, "rotation");
160
161const DEFAULT_STROKE: f32 = default_of(PARAMS, "stroke");
164const MAX_STROKE: f32 = 1.0;
169
170const DEFAULT_MORPH: f32 = default_of(PARAMS, "morph");
175
176const DEFAULT_GAMMA: f32 = default_of(PARAMS, "gamma");
180const MIN_GAMMA: f32 = 0.05;
184const MAX_GAMMA: f32 = 20.0;
185
186pub(crate) const COORD_MODES: [&str; 2] = ["distance", "radius"];
194
195const DEFAULT_COORD_MODE: f32 = default_of(PARAMS, "coord_mode");
199const MIN_COORD_MODE: f32 = 0.0;
200const MAX_COORD_MODE: f32 = COORD_MODES.len() as f32 - 1.0;
201
202const DEFAULT_COLOR_SPAN: f32 = default_of(PARAMS, "color_span");
206const DEFAULT_COLOR_CENTER: f32 = default_of(PARAMS, "color_center");
207
208pub(crate) fn interior_texels(color_span: f32) -> f32 {
219 color_span.abs() * crate::render::palette::LUT_SIZE as f32
220}
221
222pub(crate) const MIN_INTERIOR_TEXELS: f32 = 16.0;
236
237const SHADER: &str = r#"
238struct Params {
239 // x: aspect (from the RENDER TARGET), y: shape position (clamped CPU-side
240 // and NOT rounded, so a fractional value blends two arms - ADR-0226),
241 // z: points (quantized CPU-side), w: scale
242 a: vec4<f32>,
243 // xy: pan (the shared ViewTransform, ADR-0018), z: color_span,
244 // w: color_center
245 b: vec4<f32>,
246 // x: saturation, y: palette_mix, z: palette_steps (integral, quantized
247 // CPU-side), w: palette_contour
248 c: vec4<f32>,
249 // x: occlude (ADR-0085), y: gamma (the response exponent on the distance,
250 // exactly 1.0 for the identity), z: coord_mode (quantized CPU-side; 0 = the
251 // distance, 1 = the scaled-copy radius), w: rotation in radians, exactly 0.0
252 // for the identity.
253 d: vec4<f32>,
254 // xyz: the star arm's shape params (valley, curve, jitter), conditioned
255 // CPU-side. Inert on every other silhouette.
256 e: vec4<f32>,
257 // x: path point count (0 = no authored contour, and every line of the path
258 // arms below is unreached), y: the contour's inradius — the divisor that
259 // makes the distance 0 at its deepest interior point, measured CPU-side,
260 // z: stroke half-width in coordinate units (exactly 0.0 = filled),
261 // w: arc piece count — nonzero means `path` holds an ARC CHAIN rather than
262 // a polyline, and `x` is then unread.
263 f: vec4<f32>,
264 // x: palette_contour_style (integral, rounded CPU-side),
265 // y: palette_contour_ink (ADR-0197), zw: unused.
266 //
267 // Its own vec4 rather than `e.w` plus a slot borrowed from elsewhere: the two
268 // are one control split in half, and `e.w` is the only free slot this struct
269 // has.
270 g: vec4<f32>,
271 // The authored contour (ADR-0107), in one of two packings.
272 //
273 // **As a polyline** (`f.w == 0`): TWO POINTS PER ELEMENT, point `i` at
274 // `path[i >> 1].xy` for even `i` and `.zw` for odd. Packed because a uniform
275 // array's elements are 16-byte aligned, so an `array<vec2<f32>, N>` would
276 // spend half the buffer on padding.
277 //
278 // **As an arc chain** (`f.w > 0`): FOUR ELEMENTS PER PIECE, piece `i` at
279 // `path[i * 4 ..]`:
280 // +0 (kind, cx, cy, radius) kind 0 = straight run, 1 = arc
281 // +1 (mx, my, cos_half, 0) the sector's mid direction and half-angle
282 // +2 (ax, ay, bx, by) the piece's two endpoints
283 // +3 (start, sweep, 0, 0) the signed sweep, for the crossing test
284 path: array<vec4<f32>, 128>,
285}
286
287// **One bind group, sampler first and uniform last — and that arrangement is
288// what buys this pipeline a layout shape nothing else holds** (ADR-0058: two
289// byte-identical layouts alias on the DX12 WARP adapter, a WARP reading
290// unverified on lavapipe as of 2026-10-06, and the whole golden suite runs on a
291// software adapter, so a collision is blessed rather than caught).
292//
293// It is deliberately not `fragment_field`'s two-group split, because that split
294// has no free shape left for a tenth scene. A lone uniform group can vary only
295// by visibility and by whether it declares a `min_binding_size`, and all four
296// combinations are taken: `[Uniform:FRAGMENT]` by the fragment field, the RD
297// init and the test disc; `+size` by the backdrop; `VERTEX_FRAGMENT` by the
298// line renderer; and `VERTEX_FRAGMENT+size` by the emitter. Merging the groups
299// is what keeps this unique WITHOUT padding a layout with a binding the shader
300// does not use, which is the cure ADR-0058's Alternative A refuses.
301//
302// Pick another free shape rather than tidying this back into two groups.
303@group(0) @binding(0) var lut_samp: sampler;
304@group(0) @binding(1) var lut_a: texture_2d<f32>;
305@group(0) @binding(2) var lut_b: texture_2d<f32>;
306@group(0) @binding(3) var<uniform> params: Params;
307
308// Shared `saturation` (mirrors core/src/render/palette.rs::desaturate verbatim).
309fn apply_saturation(c: vec3<f32>, s: f32) -> vec3<f32> {
310 let luma = dot(c, vec3<f32>(0.299, 0.587, 0.114));
311 return vec3<f32>(luma) + (c - vec3<f32>(luma)) * s;
312}
313
314// Shared `palette_steps` (mirrors core/src/render/palette.rs::band_coord
315// verbatim, ADR-0078): snap the palette coordinate to a band centre before the
316// LUT read. Below 1.5 steps it is the exact identity, not a one-band degenerate.
317fn band_coord(t: f32, steps: f32) -> f32 {
318 if (steps < 1.5) {
319 return t;
320 }
321 return (floor(t * steps) + 0.5) / steps;
322}
323
324// Shared `palette_contour` (ADR-0078 / ADR-0133; the WGSL is the implementation,
325// copied verbatim at each fragment-stage site — palette.rs has no CPU
326// counterpart to be canonical, since `fwidth` exists only here).
327//
328// Darkens within one PIXEL of a band edge, so the line has the same weight where
329// the field is shallow and where it is steep — AND ONLY WHERE THE INK ACTUALLY
330// CHANGES (ADR-0133). It samples the two band centres either side of the nearest
331// edge and returns unchanged when they resolve to the same colour within half a
332// code value, which is below the LUT's own 8-bit quantization. On a smooth
333// palette two distinct centres always differ by at least one code value, so
334// every edge draws exactly as it did at any `palette_steps`; inside a plateau
335// the LUT is literally constant and the samples are bit-equal, so the line
336// vanishes there and survives at the run boundaries. One rule, both behaviours,
337// no new parameter.
338//
339// The two LUTs, the sampler and `palette_mix` are EXPLICIT parameters rather
340// than module-scope globals this happens to find: all six sites name them the
341// same today, so implicit capture would compile — and would silently bind the
342// shared function to whatever a future site called its textures.
343//
344// `textureSampleLevel`, not `textureSample`: the LUT has one mip, and an
345// explicit LOD keeps these reads free of the uniformity requirement that a
346// sample after a conditional return would otherwise carry.
347//
348// **What the line is drawn in is `style`** (ADR-0197): `0` the soft darkening
349// above, `1` a hard darkening over the same footprint, `2` a soft line in the
350// palette's own colour at `ink_t` and `3` a hard one. `style` arrives rounded to
351// a whole number from the CPU (`palette::band_contour_style`), so the equality
352// comparisons below are exact. The `style < 0.5` arm is the expression that
353// shipped before the other three existed, which is what keeps every golden still.
354fn band_contour_ink(
355 col: vec3<f32>,
356 t: f32,
357 steps: f32,
358 amount: f32,
359 style: f32,
360 ink_t: f32,
361 lut_a: texture_2d<f32>,
362 lut_b: texture_2d<f32>,
363 lut_samp: sampler,
364 mix_ab: f32,
365) -> vec3<f32> {
366 let f = t * steps;
367 let w = max(fwidth(f), 1e-5);
368 if (steps < 1.5 || amount <= 0.0) {
369 return col;
370 }
371 let n = round(f);
372 let m = clamp(mix_ab, 0.0, 1.0);
373 let lo = mix(
374 textureSampleLevel(lut_a, lut_samp, vec2<f32>((n - 0.5) / steps, 0.5), 0.0).rgb,
375 textureSampleLevel(lut_b, lut_samp, vec2<f32>((n - 0.5) / steps, 0.5), 0.0).rgb,
376 m
377 );
378 let hi = mix(
379 textureSampleLevel(lut_a, lut_samp, vec2<f32>((n + 0.5) / steps, 0.5), 0.0).rgb,
380 textureSampleLevel(lut_b, lut_samp, vec2<f32>((n + 0.5) / steps, 0.5), 0.0).rgb,
381 m
382 );
383 if (all(abs(hi - lo) < vec3<f32>(0.5 / 255.0))) {
384 return col;
385 }
386 let d = min(fract(f), 1.0 - fract(f));
387 if (style < 0.5) {
388 return col * (1.0 - clamp(amount, 0.0, 1.0) * (1.0 - smoothstep(0.0, w, d)));
389 }
390 let hard = style == 1.0 || style == 3.0;
391 let cover = select(1.0 - smoothstep(0.0, w, d), f32(d < w), hard);
392 let ink_lut = mix(
393 textureSampleLevel(lut_a, lut_samp, vec2<f32>(ink_t, 0.5), 0.0).rgb,
394 textureSampleLevel(lut_b, lut_samp, vec2<f32>(ink_t, 0.5), 0.0).rgb,
395 m
396 );
397 let ink = select(vec3<f32>(0.0), ink_lut, style >= 2.0);
398 return mix(col, ink, clamp(amount, 0.0, 1.0) * cover);
399}
400
401// Point `i` of the authored contour, unpacked from the two-per-vec4 array.
402fn path_pt(i: u32) -> vec2<f32> {
403 let v = params.path[i >> 1u];
404 if ((i & 1u) == 0u) {
405 return v.xy;
406 }
407 return v.zw;
408}
409
410// **The authored contour's signed distance**: `min` over the distance to each
411// closing segment, signed by a crossing count (ADR-0107).
412//
413// The sign is a RAY-CROSSING PARITY rather than an orientation test, so it does
414// not care which way the author wound their path — which is what lets Phase 1
415// keep the contour's own winding and leave the alignment to the morph.
416//
417// `min` over segment distances has no quads, no overlap and no vertex bead: it
418// is exactly correct at every join, which is why ADR-0098's faceting objection
419// against a polyline stroke does not transfer to a polyline FILL. The cost is
420// `O(n)` per pixel and it is paid at every pixel of the frame whether or not the
421// figure is on screen, which is what the arity ceiling exists to bound.
422fn path_sd(p: vec2<f32>, n: u32) -> f32 {
423 var best = 1e20;
424 var s = 1.0;
425 // The previous point is carried rather than re-indexed, so each iteration
426 // makes one dynamically indexed uniform read instead of two. It measured as
427 // free — `path_cost.rs` reports the same ms/frame either way, so the loop's
428 // cost is its arithmetic and not its loads — and it stays because it is the
429 // simpler loop, not because it bought anything.
430 var b = path_pt(n - 1u);
431 for (var i = 0u; i < n; i = i + 1u) {
432 let a = path_pt(i);
433 let e = b - a;
434 let w = p - a;
435 // The nearest point ON THE SEGMENT, not on its infinite line: the clamp
436 // is what makes a sample beyond an end measure to the vertex.
437 let t = clamp(dot(w, e) / max(dot(e, e), 1e-20), 0.0, 1.0);
438 let q = w - e * t;
439 best = min(best, dot(q, q));
440 let c1 = p.y >= a.y;
441 let c2 = p.y < b.y;
442 let c3 = e.x * w.y > e.y * w.x;
443 if ((c1 && c2 && c3) || (!c1 && !c2 && !c3)) {
444 s = -s;
445 }
446 b = a;
447 }
448 return s * sqrt(best);
449}
450
451// **The authored contour's signed distance, as a chain of circular arcs**
452// (ADR-0098's primitive, ADR-0107's figure).
453//
454// The same two quantities as `path_sd` — a `min` over pieces for the magnitude,
455// a ray-crossing parity for the sign — over a chain that a curve needs FIVE TO
456// TEN TIMES fewer of than the polyline it was fitted from. A piece costs more
457// than a segment; whether that trade is a win is `path_cost.rs`'s reading, not
458// an assertion here.
459//
460// **No `atan2` on the distance path.** Whether the nearest point on the circle
461// lies within the piece's sweep is a sector test, and a sector test is a dot
462// product against the sweep's mid direction — both precomputed CPU-side. The
463// crossing test below does need the angle, but only for a piece the scan line
464// actually meets, which is a small minority of them.
465fn arc_chain_sd(p: vec2<f32>, n: u32) -> f32 {
466 let TAU = 6.28318530718;
467 var best = 1e20;
468 var crossings = 0u;
469 for (var i = 0u; i < n; i = i + 1u) {
470 let base = i * 4u;
471 let head = params.path[base];
472 let ends = params.path[base + 2u];
473 let a = ends.xy;
474 let b = ends.zw;
475
476 if (head.x < 0.5) {
477 // A straight run — the fitter emits these for a corner it must keep
478 // and for an arc whose radius is too large to shade stably, so this
479 // arm carries a real share of a polygonal figure.
480 let e = b - a;
481 let w = p - a;
482 let t = clamp(dot(w, e) / max(dot(e, e), 1e-20), 0.0, 1.0);
483 let q = w - e * t;
484 best = min(best, dot(q, q));
485 // The half-open rule on y, exactly as the polyline uses it: a joint
486 // lying on the scan line belongs to one piece, not to both.
487 let c1 = p.y >= a.y;
488 let c2 = p.y < b.y;
489 let c3 = e.x * w.y > e.y * w.x;
490 if ((c1 && c2 && c3) || (!c1 && !c2 && !c3)) {
491 crossings = crossings + 1u;
492 }
493 continue;
494 }
495
496 let c = head.yz;
497 let r = head.w;
498 let sector = params.path[base + 1u];
499 let sweep = params.path[base + 3u];
500 let w = p - c;
501 let l = length(w);
502 // Inside the sweep, the nearest point on the circle is the nearest point
503 // on the arc; outside it, the nearest point is whichever end is closer.
504 if (l > 1e-9 && dot(w / l, sector.xy) >= sector.z) {
505 let d = abs(l - r);
506 best = min(best, d * d);
507 } else {
508 best = min(best, min(dot(p - a, p - a), dot(p - b, p - b)));
509 }
510
511 // The crossing test: where the scan line `y = p.y` meets this circle, to
512 // the RIGHT of `p`, and inside the sweep.
513 let dy = p.y - c.y;
514 let disc = r * r - dy * dy;
515 if (disc > 0.0) {
516 let sx = sqrt(disc);
517 for (var k = 0u; k < 2u; k = k + 1u) {
518 let xr = c.x + select(-sx, sx, k == 1u);
519 if (xr <= p.x) {
520 continue;
521 }
522 // Half-open on the sweep — `u < span`, not `<=` — so a joint on
523 // the scan line is counted by the piece that starts there and
524 // not also by the one that ends there.
525 let ang = atan2(dy, xr - c.x);
526 var u = (ang - sweep.x) * sign(sweep.y);
527 u = u - TAU * floor(u / TAU);
528 if (u < abs(sweep.y)) {
529 crossings = crossings + 1u;
530 }
531 }
532 }
533 }
534 return select(1.0, -1.0, (crossings & 1u) == 1u) * sqrt(best);
535}
536
537// The contour's radius along the ray from the figure's centre through `p` — the
538// divisor of `coord_mode = 1`'s scaled-copy coordinate (ADR-0111), on an
539// authored contour instead of a rostered arm.
540//
541// The OUTERMOST crossing is taken. A single closed contour that is star-shaped
542// about its centre has exactly one, and the choice only shows on one that is
543// not (a crescent), where the outer edge is the boundary and the concavity is
544// interior to the coordinate.
545fn path_boundary_radius(p: vec2<f32>, n: u32) -> f32 {
546 let l = length(p);
547 if (l < 1e-6) {
548 return 1e-6;
549 }
550 let u = p / l;
551 var r = 0.0;
552 var b = path_pt(n - 1u);
553 for (var i = 0u; i < n; i = i + 1u) {
554 let a = path_pt(i);
555 let e = b - a;
556 // Cross both sides of `s*u = a + e*t` with `u` to drop `s`, then solve
557 // for the segment parameter `t`.
558 let denom = e.x * u.y - e.y * u.x;
559 if (abs(denom) > 1e-9) {
560 let t = (a.y * u.x - a.x * u.y) / denom;
561 if (t >= 0.0 && t <= 1.0) {
562 let s = dot(a + e * t, u);
563 r = max(r, s);
564 }
565 }
566 b = a;
567 }
568 return max(r, 1e-6);
569}
570
571@fragment
572fn fs_main(in: VsOut) -> @location(0) vec4<f32> {
573 let aspect = params.a.x;
574 let shape = params.a.y;
575 let points = params.a.z;
576 let scale = params.a.w;
577 let pan = params.b.xy;
578 let color_span = params.b.z;
579 let color_center = params.b.w;
580 let saturation = params.c.x;
581 let palette_mix = params.c.y;
582 let palette_steps = params.c.z;
583 let palette_contour = params.c.w;
584 let gamma = params.d.y;
585 let coord_mode = params.d.z;
586 let rotation = params.d.w;
587 let star = params.e.xyz;
588 // The star arm's hand-drawn controls (seed, wobble amplitude, wobble
589 // frequency), which live in the three padding slots `e` and `g` already
590 // carried rather than in a wider uniform.
591 let rough = vec3<f32>(params.e.w, params.g.z, params.g.w);
592 let path_n = u32(params.f.x);
593 let path_inradius = params.f.y;
594 let stroke = params.f.z;
595 let path_arcs = u32(params.f.w);
596 let palette_contour_style = params.g.x;
597 let palette_contour_ink = params.g.y;
598
599 // Square units, from the RENDER TARGET's aspect (ADR-0037): stretching x
600 // makes one unit of `uv` the same length on both axes, so the figure below
601 // is the shape it claims to be and not the window's shape.
602 var uv = in.ndc;
603 uv.x = uv.x * aspect;
604
605 // The figure's own frame: `pan` moves its centre, `scale` sets its size.
606 //
607 // **`rotation` is applied AFTER the pan, and that is a choice.** Turning the
608 // sample point before subtracting `pan` would swing the figure around the
609 // frame's centre — an orbit — and turning it after swings it about its own.
610 // Both are defensible and they look completely different; this scene draws
611 // ONE figure, and a figure that spins in place is what `rotation` means on
612 // `lines/star.rs` and `lines/lsystem.rs` too.
613 //
614 // It is done in `uv`, which is already SQUARE units (ADR-0037): x has been
615 // stretched by the render target's aspect, so one unit is the same length on
616 // both axes and this is a rotation. In raw NDC the same two lines would
617 // SHEAR — invisible at 16:9, where the stretch is nearly 1, and obvious at
618 // 2:1. `tests` renders a square at 2:1 and turns it a quarter turn.
619 //
620 // A branch rather than an unconditional multiply, so 0 is an exact identity
621 // and no shipped preset moves through `cos`/`sin` (ADR-0092's care, the same
622 // reason `gamma` has one).
623 var q = uv - pan;
624 if (rotation != 0.0) {
625 let cr = cos(rotation);
626 let sr = sin(rotation);
627 // The INVERSE rotation on the sample point, so a positive `rotation`
628 // turns the figure counter-clockwise on screen rather than the frame.
629 q = vec2<f32>(cr * q.x + sr * q.y, cr * q.y - sr * q.x);
630 }
631 let p = q / scale;
632
633 // THE substitution this scene exists for: the palette coordinate is a
634 // FIGURE coordinate rather than a level. Both modes are 0 at the figure's
635 // centre and exactly 1 on its outline, and both grow outward — what differs
636 // is what a band of the coordinate is a band OF.
637 //
638 // An `if` rather than a `select`, and that is not style: `select` evaluates
639 // both arms, and the second arm here is a whole second shape evaluation. The
640 // mode is a per-draw uniform, so this branch is uniform across a warp and
641 // the hardware takes one arm rather than both.
642 //
643 // An authored contour takes the same two modes on the same terms
644 // (ADR-0107): what changes is where the silhouette came from, not what a
645 // band of the coordinate is a band of. `path_n` is 0 for every preset that
646 // declares no `[path]`, so those take the roster arms below and not one
647 // instruction of the contour walk executes.
648 var d: f32;
649 if (path_arcs >= 1u) {
650 // The arc chain, chosen CPU-side and only where it can serve: the
651 // distance coordinate, and no morph in flight. `path_inradius` is the
652 // POLYLINE's, which describes the same figure to within the fit's own
653 // lateral budget — a sub-pixel difference in a divisor.
654 d = max(1.0 + arc_chain_sd(p, path_arcs) / max(path_inradius, 1e-6), 0.0);
655 } else if (path_n >= 3u) {
656 if (coord_mode < 0.5) {
657 // `1 + sd / inradius` — the SAME normalization `mark_distance`
658 // applies to the roster, so an authored figure reads 0 at its
659 // deepest interior point and exactly 1 on its outline like every
660 // other silhouette this scene draws. Held at 0 from below because
661 // the inradius is measured on a grid and can land a hair short of
662 // the true deepest point; a negative coordinate would be a NaN
663 // under a bound `gamma` (`pow` of a negative base).
664 d = max(1.0 + path_sd(p, path_n) / max(path_inradius, 1e-6), 0.0);
665 } else {
666 d = length(p) / path_boundary_radius(p, path_n);
667 }
668 } else if (coord_mode < 0.5) {
669 // Mode 0 — a band of the coordinate is a band of constant DISTANCE,
670 // which is the definition of an offset curve (ADR-0105). This is the
671 // default and it is bit-for-bit the arithmetic that shipped.
672 d = mark_distance(p, shape, points, star, rough);
673 } else {
674 // Mode 1 — a band of the coordinate is a band of constant SCALING, so
675 // its level sets are scaled copies of the outline (ADR-0111). On a
676 // polygon that keeps the corners the offsets round off; on a heart it
677 // keeps the notch, which is the construction the reference images are.
678 d = length(p) / max(mark_boundary_radius(p, shape, points, star, rough), 1e-6);
679 }
680
681 // **The stroke's screen width, taken before any branch.** A derivative has
682 // to be evaluated in uniform control flow, and hoisting it is what keeps
683 // that true however the branch below is compiled — `band_contour_ink` hoists
684 // its own for the same reason.
685 let d_width = max(fwidth(d), 1e-5);
686 // The response exponent, applied to the distance BEFORE it becomes a palette
687 // coordinate — so it reshapes where the contours sit rather than which
688 // colours they take. Above 1 the bands crowd toward the centre, which is what
689 // the reference images do and what a raw (evenly spaced) distance cannot.
690 // `select` rather than a branch, and the identity is exact: `pow(x, 1.0)` is
691 // not bit-exact, so an unbound preset must not go through it (ADR-0092).
692 let shaped = select(pow(d, gamma), d, gamma == 1.0);
693 let coord = shaped * color_span + color_center;
694
695 // Hard bands, then the contour drawn from the SAME coordinate (ADR-0078).
696 let banded = band_coord(coord, palette_steps);
697 let ca = textureSample(lut_a, lut_samp, vec2<f32>(banded, 0.5)).rgb;
698 let cb = textureSample(lut_b, lut_samp, vec2<f32>(banded, 0.5)).rgb;
699 var col = mix(ca, cb, clamp(palette_mix, 0.0, 1.0));
700 col = band_contour_ink(
701 col, coord, palette_steps, palette_contour, palette_contour_style,
702 palette_contour_ink, lut_a, lut_b, lut_samp, palette_mix
703 );
704 col = apply_saturation(col, saturation);
705
706 // **Fill and stroke are one field, not two routes** (ADR-0107). `d` is the
707 // single evaluation above; the interior is `d < 1` and the outline is
708 // `abs(d - 1) < w`, so a stroke cannot drift off the fill it belongs to
709 // because there is nothing for it to drift from. (The ADR writes the pair
710 // as `d < 0` and `abs(d) < w` against a raw signed distance; this scene's
711 // coordinate is that distance normalized to 1 on the outline, so the two
712 // tests are the same two tests shifted by one.)
713 //
714 // **`stroke` is a width in the coordinate, and the coordinate is a metric
715 // distance only at a WHOLE `shape`** (ADR-0226). Mid-travel `d` is a blend
716 // of two arms' fields, so `abs(d - 1) < stroke` still finds the blended
717 // outline — the two arms both read 1 there — but the band's thickness on
718 // screen is not the width a whole index would draw, and it varies around the
719 // figure. The same qualification covers the spacing of the bands above.
720 //
721 // Exactly 0 is the identity and takes the branch away, which is what keeps
722 // every shipped preset and every golden baseline on the arithmetic it has.
723 if (stroke > 0.0) {
724 col = col * (1.0 - smoothstep(stroke - d_width, stroke + d_width, abs(d - 1.0)));
725 }
726
727 // Alpha: this field covers every pixel, which is the coverage it honestly
728 // has (ADR-0056). `occlude` scales how much of that the backdrop underneath
729 // resolves against (ADR-0085). Reached only when no post stage is active;
730 // the chain owns the seam otherwise and the renderer hands a literal 1.0.
731 return vec4<f32>(col, params.d.x);
732}
733"#;
734
735#[repr(C)]
736#[derive(Clone, Copy, bytemuck::Pod, bytemuck::Zeroable)]
737struct Params {
738 a: [f32; 4],
739 b: [f32; 4],
740 c: [f32; 4],
741 d: [f32; 4],
742 e: [f32; 4],
743 f: [f32; 4],
744 g: [f32; 4],
746 path: [[f32; 4]; PATH_VEC4S],
752}
753
754pub struct ShapeFieldScene {
757 gpu: gpu::FullscreenScene,
761 shape: f32,
768 points: f32,
769 star_valley: f32,
774 star_curve: f32,
775 star_jitter: f32,
776 star_seed: f32,
777 star_wobble: f32,
778 star_wobble_freq: f32,
779 scale: f32,
780 colour: common::PaletteParams,
783 pan: common::PanParams,
785 color_span: f32,
786 color_center: f32,
787 gamma: f32,
790 coord_mode: f32,
794 rotation: f32,
797 stroke: f32,
801 path_from: Vec<[f32; 2]>,
811 path_to: Vec<[f32; 2]>,
812 pieces: Vec<crate::render::scenes::lines::biarc::Piece>,
817 path: Box<[[f32; 4]; PATH_VEC4S]>,
824 path_inradius: f32,
841 path_inradius_to: f32,
842 path_star_shaped: Option<bool>,
852 morph: f32,
856 occlude: f32,
860}
861
862impl ShapeFieldScene {
863 pub fn new(device: &wgpu::Device, surface_format: wgpu::TextureFormat) -> Self {
865 let source = format!("{}{SHADER}", marks::sdf_wgsl());
869 let shader = gpu::fullscreen_shader(
870 device,
871 "shape-field-shader",
872 gpu::FULLSCREEN_VS_NDC,
873 &source,
874 );
875 let parts = gpu::FullscreenParts::new(device, "shape-field", std::mem::size_of::<Params>());
876 let bind_layout = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
882 label: Some("shape-field-bind-layout"),
883 entries: &[
884 gpu::sampler(0),
885 gpu::texture(1, true),
886 gpu::texture(2, true),
887 wgpu::BindGroupLayoutEntry {
888 binding: 3,
889 visibility: wgpu::ShaderStages::VERTEX_FRAGMENT,
890 ty: wgpu::BindingType::Buffer {
891 ty: wgpu::BufferBindingType::Uniform,
892 has_dynamic_offset: false,
893 min_binding_size: wgpu::BufferSize::new(
894 std::mem::size_of::<Params>() as u64
895 ),
896 },
897 count: None,
898 },
899 ],
900 });
901 let [lut_a, lut_b, lut_sampler] = parts.luts().bind_entries(1, 2, 0);
904 let bind_group = device.create_bind_group(&wgpu::BindGroupDescriptor {
905 label: Some("shape-field-bind-group"),
906 layout: &bind_layout,
907 entries: &[
908 lut_sampler,
909 lut_a,
910 lut_b,
911 wgpu::BindGroupEntry {
912 binding: 3,
913 resource: parts.uniforms().as_entire_binding(),
914 },
915 ],
916 });
917
918 Self {
919 gpu: parts.finish(
920 device,
921 &shader,
922 &[&bind_layout],
923 bind_group,
924 None,
925 surface_format,
926 wgpu::BlendState::PREMULTIPLIED_ALPHA_BLENDING,
927 "shape-field",
928 ),
929 shape: marks::DEFAULT_SHAPE,
930 points: marks::DEFAULT_POINTS,
931 star_valley: marks::DEFAULT_STAR_VALLEY,
932 star_curve: marks::DEFAULT_STAR_CURVE,
933 star_jitter: marks::DEFAULT_STAR_JITTER,
934 star_seed: marks::DEFAULT_STAR_SEED,
935 star_wobble: marks::DEFAULT_STAR_WOBBLE,
936 star_wobble_freq: marks::DEFAULT_STAR_WOBBLE_FREQ,
937 scale: DEFAULT_SCALE,
938 colour: common::PaletteParams::new(0.0, common::DEFAULT_BRIGHTNESS),
939 pan: common::PanParams::default(),
940 color_span: DEFAULT_COLOR_SPAN,
941 color_center: DEFAULT_COLOR_CENTER,
942 gamma: DEFAULT_GAMMA,
943 coord_mode: DEFAULT_COORD_MODE,
944 rotation: DEFAULT_ROTATION,
945 stroke: DEFAULT_STROKE,
946 morph: DEFAULT_MORPH,
947 path_from: Vec::new(),
948 path_to: Vec::new(),
949 pieces: Vec::new(),
950 path: Box::new([[0.0; 4]; PATH_VEC4S]),
951 path_inradius: 1.0,
952 path_inradius_to: 1.0,
953 path_star_shaped: None,
954 occlude: crate::render::post::DEFAULT_OCCLUDE,
955 }
956 }
957}
958
959fn applied_scale(scale: f32) -> f32 {
967 if scale.is_finite() {
968 scale.clamp(MIN_SCALE, MAX_SCALE)
969 } else {
970 DEFAULT_SCALE
971 }
972}
973
974fn applied_rotation(rotation: f32) -> f32 {
982 if rotation.is_finite() {
983 rotation
984 } else {
985 DEFAULT_ROTATION
986 }
987}
988
989fn applied_coord_mode(mode: f32, shape: f32, contour_star_shaped: Option<bool>) -> f32 {
1039 let single_valued = match contour_star_shaped {
1040 Some(star_shaped) => star_shaped,
1041 None => !marks::shape_touches_ring(shape),
1042 };
1043 if !single_valued {
1044 return DEFAULT_COORD_MODE;
1045 }
1046 if mode.is_finite() {
1047 mode.clamp(MIN_COORD_MODE, MAX_COORD_MODE).round()
1048 } else {
1049 DEFAULT_COORD_MODE
1050 }
1051}
1052
1053fn applied_gamma(gamma: f32) -> f32 {
1062 if gamma.is_finite() {
1063 gamma.clamp(MIN_GAMMA, MAX_GAMMA)
1064 } else {
1065 DEFAULT_GAMMA
1066 }
1067}
1068
1069fn applied_stroke(stroke: f32) -> f32 {
1077 if stroke.is_finite() {
1078 stroke.clamp(0.0, MAX_STROKE)
1079 } else {
1080 DEFAULT_STROKE
1081 }
1082}
1083
1084fn applied_morph(morph: f32) -> f32 {
1092 if morph.is_finite() {
1093 morph.clamp(0.0, 1.0)
1094 } else {
1095 DEFAULT_MORPH
1096 }
1097}
1098
1099fn contour_inradius(points: &[[f32; 2]]) -> f32 {
1112 const GRID: i32 = 96;
1114 const REFINE: u32 = 12;
1116
1117 let depth_at = |p: [f32; 2]| -> f32 {
1118 let n = points.len();
1123 let mut best = f32::INFINITY;
1124 let mut inside = false;
1125 for i in 0..n {
1126 let (Some(&a), Some(&b)) = (points.get(i), points.get((i + n - 1) % n)) else {
1127 continue;
1128 };
1129 let e = [b[0] - a[0], b[1] - a[1]];
1130 let w = [p[0] - a[0], p[1] - a[1]];
1131 let ee = (e[0] * e[0] + e[1] * e[1]).max(1e-20);
1132 let t = ((w[0] * e[0] + w[1] * e[1]) / ee).clamp(0.0, 1.0);
1133 let q = [w[0] - e[0] * t, w[1] - e[1] * t];
1134 best = best.min(q[0] * q[0] + q[1] * q[1]);
1135 let c1 = p[1] >= a[1];
1136 let c2 = p[1] < b[1];
1137 let c3 = e[0] * w[1] > e[1] * w[0];
1138 if (c1 && c2 && c3) || (!c1 && !c2 && !c3) {
1139 inside = !inside;
1140 }
1141 }
1142 if inside { best.sqrt() } else { 0.0 }
1143 };
1144
1145 let mut best_p = [0.0f32, 0.0];
1146 let mut best_d = depth_at(best_p);
1147 for gy in 0..=GRID {
1148 for gx in 0..=GRID {
1149 let p = [
1150 (gx as f32 / GRID as f32) * 2.0 - 1.0,
1151 (gy as f32 / GRID as f32) * 2.0 - 1.0,
1152 ];
1153 let d = depth_at(p);
1154 if d > best_d {
1155 best_d = d;
1156 best_p = p;
1157 }
1158 }
1159 }
1160 let mut radius = 2.0 / GRID as f32;
1161 for _ in 0..REFINE {
1162 for (dx, dy) in [
1163 (-1.0f32, 0.0f32),
1164 (1.0, 0.0),
1165 (0.0, -1.0),
1166 (0.0, 1.0),
1167 (-1.0, -1.0),
1168 (1.0, -1.0),
1169 (-1.0, 1.0),
1170 (1.0, 1.0),
1171 ] {
1172 let p = [best_p[0] + dx * radius, best_p[1] + dy * radius];
1173 let d = depth_at(p);
1174 if d > best_d {
1175 best_d = d;
1176 best_p = p;
1177 }
1178 }
1179 radius *= 0.5;
1180 }
1181 best_d.max(1e-4)
1185}
1186
1187#[cfg(test)]
1191pub(crate) fn coord(distance: f32, gamma: f32, color_span: f32, color_center: f32) -> f32 {
1192 let g = applied_gamma(gamma);
1193 let shaped = if g == 1.0 { distance } else { distance.powf(g) };
1194 shaped * color_span + color_center
1195}
1196
1197pub const PARAMS: &[ParamSpec] = &[
1202 crate::render::scenes::marks::SHAPE,
1203 crate::render::scenes::marks::POINTS,
1204 crate::render::scenes::marks::STAR_VALLEY,
1205 crate::render::scenes::marks::STAR_CURVE,
1206 crate::render::scenes::marks::STAR_JITTER,
1207 crate::render::scenes::marks::STAR_SEED,
1208 crate::render::scenes::marks::STAR_WOBBLE,
1209 crate::render::scenes::marks::STAR_WOBBLE_FREQ,
1210 ParamSpec {
1211 name: "scale",
1212 default: 0.6,
1213 range: Some([0.05, 2.0]),
1214 doc: "Size of the shape within the frame.",
1215 kind: ParamKind::Modal,
1216 group: ParamGroup::Shape,
1217 main: true,
1218 },
1219 crate::render::scenes::common::PAN_X,
1220 crate::render::scenes::common::PAN_Y,
1221 ParamSpec {
1222 name: "color_span",
1223 default: 0.6,
1224 range: Some([0.0, 1.0]),
1225 doc: "How much of the palette the field's range covers.",
1226 kind: ParamKind::Modal,
1227 group: ParamGroup::Colour,
1228 main: false,
1229 },
1230 ParamSpec {
1231 name: "color_center",
1232 default: 0.0,
1233 range: Some([-1.0, 1.0]),
1234 doc: "Shifts which part of that range lands in the middle of the palette.",
1235 kind: ParamKind::Modal,
1236 group: ParamGroup::Colour,
1237 main: false,
1238 },
1239 crate::render::scenes::common::SATURATION,
1240 crate::render::scenes::common::PALETTE_MIX,
1241 crate::render::scenes::common::PALETTE_STEPS,
1242 crate::render::scenes::common::PALETTE_CONTOUR,
1243 crate::render::scenes::common::PALETTE_CONTOUR_STYLE,
1244 crate::render::scenes::common::PALETTE_CONTOUR_INK,
1245 ParamSpec {
1246 name: "gamma",
1247 default: 1.0,
1248 range: Some([0.25, 4.0]),
1249 doc: "Shapes the falloff from the shape's edge; below 1 it bites sooner.",
1250 kind: ParamKind::Modal,
1251 group: ParamGroup::Light,
1252 main: false,
1253 },
1254 ParamSpec {
1255 name: "coord_mode",
1256 default: 0.0,
1257 range: Some([MIN_COORD_MODE, MAX_COORD_MODE]),
1261 doc: "Which coordinate frame the distance is measured in, which changes the shape's whole geometry.",
1262 kind: ParamKind::Structural,
1263 group: ParamGroup::Shape,
1264 main: false,
1265 },
1266 ParamSpec {
1267 name: "rotation",
1268 default: 0.0,
1269 range: Some([0.0, std::f32::consts::TAU]),
1270 doc: "Turns the shape, in radians.",
1271 kind: ParamKind::Modal,
1272 group: ParamGroup::Motion,
1273 main: true,
1274 },
1275 ParamSpec {
1276 name: "stroke",
1277 default: 0.0,
1278 range: Some([0.0, 1.0]),
1279 doc: "Draws the outline instead of the filled figure, at this half-width; 0 fills.",
1280 kind: ParamKind::Modal,
1281 group: ParamGroup::Shape,
1282 main: false,
1283 },
1284 ParamSpec {
1285 name: "morph",
1286 default: 0.0,
1287 range: Some([0.0, 1.0]),
1288 doc: "Travels the authored path towards its morph_to silhouette; inert without one.",
1289 kind: ParamKind::Modal,
1290 group: ParamGroup::Motion,
1291 main: false,
1292 },
1293];
1294
1295impl ShapeFieldScene {
1296 fn pack_path(&mut self, coord_mode: f32) -> (usize, usize, f32) {
1309 let n = self.path_from.len().min(MAX_SAMPLES);
1310 if n < 3 {
1311 return (0, 0, 1.0);
1312 }
1313 let morphing = self.path_to.len() == self.path_from.len();
1314
1315 if !morphing && coord_mode < 0.5 && !self.pieces.is_empty() {
1327 let pieces = self.pieces.len().min(MAX_ARC_PIECES);
1328 self.pack_pieces(pieces);
1329 return (0, pieces, self.path_inradius);
1330 }
1331
1332 let t = if morphing {
1333 applied_morph(self.morph)
1334 } else {
1335 0.0
1336 };
1337 for i in 0..n {
1338 let Some(&a) = self.path_from.get(i) else {
1339 continue;
1340 };
1341 let p = match self.path_to.get(i) {
1342 Some(&b) if t != 0.0 => [a[0] + (b[0] - a[0]) * t, a[1] + (b[1] - a[1]) * t],
1343 _ => a,
1344 };
1345 let slot = i >> 1;
1346 let half = (i & 1) * 2;
1347 if let Some(v) = self.path.get_mut(slot) {
1348 if let Some(x) = v.get_mut(half) {
1349 *x = p[0];
1350 }
1351 if let Some(y) = v.get_mut(half + 1) {
1352 *y = p[1];
1353 }
1354 }
1355 }
1356 let inradius = self.path_inradius + (self.path_inradius_to - self.path_inradius) * t;
1357 (n, 0, inradius.max(1e-4))
1358 }
1359
1360 fn pack_pieces(&mut self, count: usize) {
1367 use crate::render::scenes::lines::biarc::Piece;
1368 for i in 0..count {
1369 let Some(&piece) = self.pieces.get(i) else {
1370 continue;
1371 };
1372 let base = i * VEC4S_PER_PIECE;
1373 let (a, b) = (piece.start_point(), piece.end_point());
1374 let (head, sector, sweep) = match piece {
1375 Piece::Arc {
1376 centre,
1377 radius,
1378 start,
1379 sweep,
1380 } => {
1381 let mid = start + sweep * 0.5;
1382 (
1383 [1.0, centre[0], centre[1], radius],
1384 [mid.cos(), mid.sin(), (sweep.abs() * 0.5).cos(), 0.0],
1385 [start, sweep, 0.0, 0.0],
1386 )
1387 }
1388 Piece::Line { .. } => ([0.0; 4], [0.0; 4], [0.0; 4]),
1389 };
1390 for (offset, value) in [
1391 (0, head),
1392 (1, sector),
1393 (2, [a[0], a[1], b[0], b[1]]),
1394 (3, sweep),
1395 ] {
1396 if let Some(slot) = self.path.get_mut(base + offset) {
1397 *slot = value;
1398 }
1399 }
1400 }
1401 }
1402}
1403
1404impl Scene for ShapeFieldScene {
1405 fn name(&self) -> &'static str {
1406 "shape field"
1407 }
1408
1409 fn set_occlude(&mut self, occlude: f32) {
1410 self.occlude = occlude;
1411 }
1412
1413 fn set_palette(&mut self, palette: &Palette) {
1414 self.gpu.set_palette(palette);
1415 }
1416
1417 fn reset_params(&mut self) {
1418 self.shape = marks::DEFAULT_SHAPE;
1419 self.points = marks::DEFAULT_POINTS;
1420 self.star_valley = marks::DEFAULT_STAR_VALLEY;
1421 self.star_curve = marks::DEFAULT_STAR_CURVE;
1422 self.star_jitter = marks::DEFAULT_STAR_JITTER;
1423 self.star_seed = marks::DEFAULT_STAR_SEED;
1424 self.star_wobble = marks::DEFAULT_STAR_WOBBLE;
1425 self.star_wobble_freq = marks::DEFAULT_STAR_WOBBLE_FREQ;
1426 self.scale = DEFAULT_SCALE;
1427 self.colour.reset();
1428 self.pan.reset();
1429 self.color_span = DEFAULT_COLOR_SPAN;
1430 self.color_center = DEFAULT_COLOR_CENTER;
1431 self.gamma = DEFAULT_GAMMA;
1432 self.coord_mode = DEFAULT_COORD_MODE;
1433 self.rotation = DEFAULT_ROTATION;
1434 self.stroke = DEFAULT_STROKE;
1435 self.morph = DEFAULT_MORPH;
1436 }
1437
1438 fn configure(
1447 &mut self,
1448 cfg: &super::lines::GeneratorConfig,
1449 ) -> Option<super::lines::CapOverflow> {
1450 if let super::lines::GeneratorConfig::Path { shape, morph_to } = cfg {
1451 self.path_from.clear();
1452 self.path_to.clear();
1453 self.pieces.clear();
1454 self.path_inradius = 1.0;
1455 self.path_inradius_to = 1.0;
1456 self.path_star_shaped = None;
1457 if let Some(contour) = shape {
1458 self.path_from
1462 .extend(contour.points().iter().take(MAX_SAMPLES).copied());
1463 self.path_inradius = contour_inradius(&self.path_from);
1464 self.path_star_shaped = Some(contour.star_shaped());
1465 self.pieces.extend_from_slice(contour.pieces());
1466 }
1467 if let Some(target) = morph_to
1471 .as_ref()
1472 .filter(|t| t.points().len() == self.path_from.len())
1473 {
1474 self.path_to.extend(target.points().iter().copied());
1475 self.path_inradius_to = contour_inradius(&self.path_to);
1476 self.path_star_shaped =
1479 Some(self.path_star_shaped.unwrap_or(true) && target.star_shaped());
1480 }
1481 }
1482 None
1483 }
1484
1485 fn set_param(&mut self, name: &str, value: f32) {
1486 if self.colour.set(name, value) || self.pan.set(name, value) {
1489 return;
1490 }
1491 match name {
1492 "shape" => self.shape = value,
1493 "points" => self.points = value,
1494 "star_valley" => self.star_valley = value,
1495 "star_curve" => self.star_curve = value,
1496 "star_jitter" => self.star_jitter = value,
1497 "star_seed" => self.star_seed = value,
1498 "star_wobble" => self.star_wobble = value,
1499 "star_wobble_freq" => self.star_wobble_freq = value,
1500 "scale" => self.scale = value,
1501 "color_span" => self.color_span = value,
1502 "color_center" => self.color_center = value,
1503 "gamma" => self.gamma = value,
1504 "coord_mode" => self.coord_mode = value,
1505 "rotation" => self.rotation = value,
1506 "stroke" => self.stroke = value,
1507 "morph" => self.morph = value,
1508 _ => {}
1509 }
1510 }
1511
1512 fn update(&mut self, _frame: &AnalysisFrame) {
1513 }
1516
1517 fn render(
1518 &mut self,
1519 queue: &wgpu::Queue,
1520 encoder: &mut wgpu::CommandEncoder,
1521 view: &wgpu::TextureView,
1522 aspect: f32,
1523 ) {
1524 let shape = marks::mark_shape(self.shape);
1528 self.gpu.flush_palette(queue);
1529 let coord_mode = applied_coord_mode(self.coord_mode, shape, self.path_star_shaped);
1530 let (path_count, path_arcs, path_inradius) = self.pack_path(coord_mode);
1531
1532 let params = Params {
1533 a: [
1536 aspect.max(0.1),
1537 shape,
1538 marks::mark_points(self.points),
1539 applied_scale(self.scale),
1540 ],
1541 b: [self.pan.x, self.pan.y, self.color_span, self.color_center],
1542 c: [
1543 self.colour.saturation,
1544 self.colour.mix,
1545 palette::band_steps(self.colour.steps),
1546 palette::band_contour(self.colour.contour),
1547 ],
1548 d: [
1549 self.occlude,
1550 applied_gamma(self.gamma),
1551 coord_mode,
1552 applied_rotation(self.rotation),
1553 ],
1554 e: [
1555 marks::star_valley(self.star_valley),
1556 marks::star_curve(self.star_curve),
1557 marks::star_jitter(self.star_jitter),
1558 marks::star_seed(self.star_seed),
1559 ],
1560 f: [
1561 path_count as f32,
1562 path_inradius,
1563 applied_stroke(self.stroke),
1564 path_arcs as f32,
1565 ],
1566 g: [
1567 palette::band_contour_style(self.colour.contour_style),
1568 self.colour.contour_ink,
1569 marks::star_wobble(self.star_wobble),
1570 marks::star_wobble_freq(self.star_wobble_freq),
1571 ],
1572 path: *self.path,
1573 };
1574 self.gpu.write_uniform(queue, ¶ms);
1575 self.gpu
1576 .draw(encoder, "shape-field-pass", view, wgpu::LoadOp::Load);
1577 }
1578}
1579
1580#[cfg(test)]
1581mod tests;