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;
125
126const _: () = 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
135const DEFAULT_SCALE: f32 = default_of(PARAMS, "scale");
140const MIN_SCALE: f32 = 0.01;
144const MAX_SCALE: f32 = 20.0;
148
149const DEFAULT_ROTATION: f32 = default_of(PARAMS, "rotation");
159
160const DEFAULT_STROKE: f32 = default_of(PARAMS, "stroke");
163const MAX_STROKE: f32 = 1.0;
168
169const DEFAULT_MORPH: f32 = default_of(PARAMS, "morph");
174
175const DEFAULT_GAMMA: f32 = default_of(PARAMS, "gamma");
179const MIN_GAMMA: f32 = 0.05;
183const MAX_GAMMA: f32 = 20.0;
184
185pub(crate) const COORD_MODES: [&str; 2] = ["distance", "radius"];
193
194const 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
201const DEFAULT_COLOR_SPAN: f32 = default_of(PARAMS, "color_span");
205const DEFAULT_COLOR_CENTER: f32 = default_of(PARAMS, "color_center");
206
207pub(crate) fn interior_texels(color_span: f32) -> f32 {
218 color_span.abs() * crate::render::palette::LUT_SIZE as f32
219}
220
221pub(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 g: [f32; 4],
744 path: [[f32; 4]; PATH_VEC4S],
750}
751
752pub struct ShapeFieldScene {
755 gpu: gpu::FullscreenScene,
759 shape: f32,
766 points: f32,
767 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 colour: common::PaletteParams,
781 pan: common::PanParams,
783 color_span: f32,
784 color_center: f32,
785 gamma: f32,
788 coord_mode: f32,
792 rotation: f32,
795 stroke: f32,
799 path_from: Vec<[f32; 2]>,
809 path_to: Vec<[f32; 2]>,
810 pieces: Vec<crate::render::scenes::lines::biarc::Piece>,
815 path: Box<[[f32; 4]; PATH_VEC4S]>,
822 path_inradius: f32,
839 path_inradius_to: f32,
840 path_star_shaped: Option<bool>,
850 morph: f32,
854 occlude: f32,
858}
859
860impl ShapeFieldScene {
861 pub fn new(device: &wgpu::Device, surface_format: wgpu::TextureFormat) -> Self {
863 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 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 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
957fn 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
972fn applied_rotation(rotation: f32) -> f32 {
980 if rotation.is_finite() {
981 rotation
982 } else {
983 DEFAULT_ROTATION
984 }
985}
986
987fn 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
1051fn 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
1067fn 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
1082fn 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
1097fn contour_inradius(points: &[[f32; 2]]) -> f32 {
1110 const GRID: i32 = 96;
1112 const REFINE: u32 = 12;
1114
1115 let depth_at = |p: [f32; 2]| -> f32 {
1116 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 best_d.max(1e-4)
1183}
1184
1185#[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
1195pub 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 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 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 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 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 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 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 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 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 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 }
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 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 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, ¶ms);
1573 self.gpu
1574 .draw(encoder, "shape-field-pass", view, wgpu::LoadOp::Load);
1575 }
1576}
1577
1578#[cfg(test)]
1579mod tests;