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::{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 index (quantized CPU-side),
239 // z: points (quantized CPU-side), w: scale
240 a: vec4<f32>,
241 // xy: pan (the shared ViewTransform, ADR-0018), z: color_span,
242 // w: color_center
243 b: vec4<f32>,
244 // x: saturation, y: palette_mix, z: palette_steps (integral, quantized
245 // CPU-side), w: palette_contour
246 c: vec4<f32>,
247 // x: occlude (ADR-0085), y: gamma (the response exponent on the distance,
248 // exactly 1.0 for the identity), z: coord_mode (quantized CPU-side; 0 = the
249 // distance, 1 = the scaled-copy radius), w: rotation in radians, exactly 0.0
250 // for the identity.
251 d: vec4<f32>,
252 // xyz: the star arm's shape params (valley, curve, jitter), conditioned
253 // CPU-side. Inert on every other silhouette.
254 e: vec4<f32>,
255 // x: path point count (0 = no authored contour, and every line of the path
256 // arms below is unreached), y: the contour's inradius — the divisor that
257 // makes the distance 0 at its deepest interior point, measured CPU-side,
258 // z: stroke half-width in coordinate units (exactly 0.0 = filled),
259 // w: arc piece count — nonzero means `path` holds an ARC CHAIN rather than
260 // a polyline, and `x` is then unread.
261 f: vec4<f32>,
262 // The authored contour (ADR-0107), in one of two packings.
263 //
264 // **As a polyline** (`f.w == 0`): TWO POINTS PER ELEMENT, point `i` at
265 // `path[i >> 1].xy` for even `i` and `.zw` for odd. Packed because a uniform
266 // array's elements are 16-byte aligned, so an `array<vec2<f32>, N>` would
267 // spend half the buffer on padding.
268 //
269 // **As an arc chain** (`f.w > 0`): FOUR ELEMENTS PER PIECE, piece `i` at
270 // `path[i * 4 ..]`:
271 // +0 (kind, cx, cy, radius) kind 0 = straight run, 1 = arc
272 // +1 (mx, my, cos_half, 0) the sector's mid direction and half-angle
273 // +2 (ax, ay, bx, by) the piece's two endpoints
274 // +3 (start, sweep, 0, 0) the signed sweep, for the crossing test
275 path: array<vec4<f32>, 128>,
276}
277
278// **One bind group, sampler first and uniform last — and that arrangement is
279// what buys this pipeline a layout shape nothing else holds** (ADR-0058: two
280// byte-identical layouts alias on the DX12 WARP adapter, and the whole golden
281// suite runs there, so a collision is blessed rather than caught).
282//
283// It is deliberately not `fragment_field`'s two-group split, because that split
284// has no free shape left for a tenth scene. A lone uniform group can vary only
285// by visibility and by whether it declares a `min_binding_size`, and all four
286// combinations are taken: `[Uniform:FRAGMENT]` by the fragment field, the RD
287// init and the test disc; `+size` by the backdrop; `VERTEX_FRAGMENT` by the
288// line renderer; and `VERTEX_FRAGMENT+size` by the emitter. Merging the groups
289// is what keeps this unique WITHOUT padding a layout with a binding the shader
290// does not use, which is the cure ADR-0058's Alternative A refuses.
291//
292// Pick another free shape rather than tidying this back into two groups.
293@group(0) @binding(0) var lut_samp: sampler;
294@group(0) @binding(1) var lut_a: texture_2d<f32>;
295@group(0) @binding(2) var lut_b: texture_2d<f32>;
296@group(0) @binding(3) var<uniform> params: Params;
297
298// Shared `saturation` (mirrors core/src/render/palette.rs::desaturate verbatim).
299fn apply_saturation(c: vec3<f32>, s: f32) -> vec3<f32> {
300 let luma = dot(c, vec3<f32>(0.299, 0.587, 0.114));
301 return vec3<f32>(luma) + (c - vec3<f32>(luma)) * s;
302}
303
304// Shared `palette_steps` (mirrors core/src/render/palette.rs::band_coord
305// verbatim, ADR-0078): snap the palette coordinate to a band centre before the
306// LUT read. Below 1.5 steps it is the exact identity, not a one-band degenerate.
307fn band_coord(t: f32, steps: f32) -> f32 {
308 if (steps < 1.5) {
309 return t;
310 }
311 return (floor(t * steps) + 0.5) / steps;
312}
313
314// Shared `palette_contour` (ADR-0078 / ADR-0133; the WGSL is the implementation,
315// copied verbatim at each fragment-stage site — palette.rs has no CPU
316// counterpart to be canonical, since `fwidth` exists only here).
317//
318// Darkens within one PIXEL of a band edge, so the line has the same weight where
319// the field is shallow and where it is steep — AND ONLY WHERE THE INK ACTUALLY
320// CHANGES (ADR-0133). It samples the two band centres either side of the nearest
321// edge and returns unchanged when they resolve to the same colour within half a
322// code value, which is below the LUT's own 8-bit quantization. On a smooth
323// palette two distinct centres always differ by at least one code value, so
324// every edge draws exactly as it did at any `palette_steps`; inside a plateau
325// the LUT is literally constant and the samples are bit-equal, so the line
326// vanishes there and survives at the run boundaries. One rule, both behaviours,
327// no new parameter.
328//
329// The two LUTs, the sampler and `palette_mix` are EXPLICIT parameters rather
330// than module-scope globals this happens to find: all four sites name them the
331// same today, so implicit capture would compile — and would silently bind the
332// shared function to whatever a future site called its textures.
333//
334// `textureSampleLevel`, not `textureSample`: the LUT has one mip, and an
335// explicit LOD keeps these reads free of the uniformity requirement that a
336// sample after a conditional return would otherwise carry.
337fn band_contour(
338 t: f32,
339 steps: f32,
340 amount: f32,
341 lut_a: texture_2d<f32>,
342 lut_b: texture_2d<f32>,
343 lut_samp: sampler,
344 mix_ab: f32,
345) -> f32 {
346 let f = t * steps;
347 let w = max(fwidth(f), 1e-5);
348 if (steps < 1.5 || amount <= 0.0) {
349 return 1.0;
350 }
351 let n = round(f);
352 let m = clamp(mix_ab, 0.0, 1.0);
353 let lo = mix(
354 textureSampleLevel(lut_a, lut_samp, vec2<f32>((n - 0.5) / steps, 0.5), 0.0).rgb,
355 textureSampleLevel(lut_b, lut_samp, vec2<f32>((n - 0.5) / steps, 0.5), 0.0).rgb,
356 m
357 );
358 let hi = mix(
359 textureSampleLevel(lut_a, lut_samp, vec2<f32>((n + 0.5) / steps, 0.5), 0.0).rgb,
360 textureSampleLevel(lut_b, lut_samp, vec2<f32>((n + 0.5) / steps, 0.5), 0.0).rgb,
361 m
362 );
363 if (all(abs(hi - lo) < vec3<f32>(0.5 / 255.0))) {
364 return 1.0;
365 }
366 let d = min(fract(f), 1.0 - fract(f));
367 return 1.0 - clamp(amount, 0.0, 1.0) * (1.0 - smoothstep(0.0, w, d));
368}
369
370// Point `i` of the authored contour, unpacked from the two-per-vec4 array.
371fn path_pt(i: u32) -> vec2<f32> {
372 let v = params.path[i >> 1u];
373 if ((i & 1u) == 0u) {
374 return v.xy;
375 }
376 return v.zw;
377}
378
379// **The authored contour's signed distance**: `min` over the distance to each
380// closing segment, signed by a crossing count (ADR-0107).
381//
382// The sign is a RAY-CROSSING PARITY rather than an orientation test, so it does
383// not care which way the author wound their path — which is what lets Phase 1
384// keep the contour's own winding and leave the alignment to the morph.
385//
386// `min` over segment distances has no quads, no overlap and no vertex bead: it
387// is exactly correct at every join, which is why ADR-0098's faceting objection
388// against a polyline stroke does not transfer to a polyline FILL. The cost is
389// `O(n)` per pixel and it is paid at every pixel of the frame whether or not the
390// figure is on screen, which is what the arity ceiling exists to bound.
391fn path_sd(p: vec2<f32>, n: u32) -> f32 {
392 var best = 1e20;
393 var s = 1.0;
394 // The previous point is carried rather than re-indexed, so each iteration
395 // makes one dynamically indexed uniform read instead of two. It measured as
396 // free — `path_cost.rs` reports the same ms/frame either way, so the loop's
397 // cost is its arithmetic and not its loads — and it stays because it is the
398 // simpler loop, not because it bought anything.
399 var b = path_pt(n - 1u);
400 for (var i = 0u; i < n; i = i + 1u) {
401 let a = path_pt(i);
402 let e = b - a;
403 let w = p - a;
404 // The nearest point ON THE SEGMENT, not on its infinite line: the clamp
405 // is what makes a sample beyond an end measure to the vertex.
406 let t = clamp(dot(w, e) / max(dot(e, e), 1e-20), 0.0, 1.0);
407 let q = w - e * t;
408 best = min(best, dot(q, q));
409 let c1 = p.y >= a.y;
410 let c2 = p.y < b.y;
411 let c3 = e.x * w.y > e.y * w.x;
412 if ((c1 && c2 && c3) || (!c1 && !c2 && !c3)) {
413 s = -s;
414 }
415 b = a;
416 }
417 return s * sqrt(best);
418}
419
420// **The authored contour's signed distance, as a chain of circular arcs**
421// (ADR-0098's primitive, ADR-0107's figure).
422//
423// The same two quantities as `path_sd` — a `min` over pieces for the magnitude,
424// a ray-crossing parity for the sign — over a chain that a curve needs FIVE TO
425// TEN TIMES fewer of than the polyline it was fitted from. A piece costs more
426// than a segment; whether that trade is a win is `path_cost.rs`'s reading, not
427// an assertion here.
428//
429// **No `atan2` on the distance path.** Whether the nearest point on the circle
430// lies within the piece's sweep is a sector test, and a sector test is a dot
431// product against the sweep's mid direction — both precomputed CPU-side. The
432// crossing test below does need the angle, but only for a piece the scan line
433// actually meets, which is a small minority of them.
434fn arc_chain_sd(p: vec2<f32>, n: u32) -> f32 {
435 let TAU = 6.28318530718;
436 var best = 1e20;
437 var crossings = 0u;
438 for (var i = 0u; i < n; i = i + 1u) {
439 let base = i * 4u;
440 let head = params.path[base];
441 let ends = params.path[base + 2u];
442 let a = ends.xy;
443 let b = ends.zw;
444
445 if (head.x < 0.5) {
446 // A straight run — the fitter emits these for a corner it must keep
447 // and for an arc whose radius is too large to shade stably, so this
448 // arm carries a real share of a polygonal figure.
449 let e = b - a;
450 let w = p - a;
451 let t = clamp(dot(w, e) / max(dot(e, e), 1e-20), 0.0, 1.0);
452 let q = w - e * t;
453 best = min(best, dot(q, q));
454 // The half-open rule on y, exactly as the polyline uses it: a joint
455 // lying on the scan line belongs to one piece, not to both.
456 let c1 = p.y >= a.y;
457 let c2 = p.y < b.y;
458 let c3 = e.x * w.y > e.y * w.x;
459 if ((c1 && c2 && c3) || (!c1 && !c2 && !c3)) {
460 crossings = crossings + 1u;
461 }
462 continue;
463 }
464
465 let c = head.yz;
466 let r = head.w;
467 let sector = params.path[base + 1u];
468 let sweep = params.path[base + 3u];
469 let w = p - c;
470 let l = length(w);
471 // Inside the sweep, the nearest point on the circle is the nearest point
472 // on the arc; outside it, the nearest point is whichever end is closer.
473 if (l > 1e-9 && dot(w / l, sector.xy) >= sector.z) {
474 let d = abs(l - r);
475 best = min(best, d * d);
476 } else {
477 best = min(best, min(dot(p - a, p - a), dot(p - b, p - b)));
478 }
479
480 // The crossing test: where the scan line `y = p.y` meets this circle, to
481 // the RIGHT of `p`, and inside the sweep.
482 let dy = p.y - c.y;
483 let disc = r * r - dy * dy;
484 if (disc > 0.0) {
485 let sx = sqrt(disc);
486 for (var k = 0u; k < 2u; k = k + 1u) {
487 let xr = c.x + select(-sx, sx, k == 1u);
488 if (xr <= p.x) {
489 continue;
490 }
491 // Half-open on the sweep — `u < span`, not `<=` — so a joint on
492 // the scan line is counted by the piece that starts there and
493 // not also by the one that ends there.
494 let ang = atan2(dy, xr - c.x);
495 var u = (ang - sweep.x) * sign(sweep.y);
496 u = u - TAU * floor(u / TAU);
497 if (u < abs(sweep.y)) {
498 crossings = crossings + 1u;
499 }
500 }
501 }
502 }
503 return select(1.0, -1.0, (crossings & 1u) == 1u) * sqrt(best);
504}
505
506// The contour's radius along the ray from the figure's centre through `p` — the
507// divisor of `coord_mode = 1`'s scaled-copy coordinate (ADR-0111), on an
508// authored contour instead of a rostered arm.
509//
510// The OUTERMOST crossing is taken. A single closed contour that is star-shaped
511// about its centre has exactly one, and the choice only shows on one that is
512// not (a crescent), where the outer edge is the boundary and the concavity is
513// interior to the coordinate.
514fn path_boundary_radius(p: vec2<f32>, n: u32) -> f32 {
515 let l = length(p);
516 if (l < 1e-6) {
517 return 1e-6;
518 }
519 let u = p / l;
520 var r = 0.0;
521 var b = path_pt(n - 1u);
522 for (var i = 0u; i < n; i = i + 1u) {
523 let a = path_pt(i);
524 let e = b - a;
525 // Cross both sides of `s*u = a + e*t` with `u` to drop `s`, then solve
526 // for the segment parameter `t`.
527 let denom = e.x * u.y - e.y * u.x;
528 if (abs(denom) > 1e-9) {
529 let t = (a.y * u.x - a.x * u.y) / denom;
530 if (t >= 0.0 && t <= 1.0) {
531 let s = dot(a + e * t, u);
532 r = max(r, s);
533 }
534 }
535 b = a;
536 }
537 return max(r, 1e-6);
538}
539
540@fragment
541fn fs_main(in: VsOut) -> @location(0) vec4<f32> {
542 let aspect = params.a.x;
543 let shape = params.a.y;
544 let points = params.a.z;
545 let scale = params.a.w;
546 let pan = params.b.xy;
547 let color_span = params.b.z;
548 let color_center = params.b.w;
549 let saturation = params.c.x;
550 let palette_mix = params.c.y;
551 let palette_steps = params.c.z;
552 let palette_contour = params.c.w;
553 let gamma = params.d.y;
554 let coord_mode = params.d.z;
555 let rotation = params.d.w;
556 let star = params.e.xyz;
557 let path_n = u32(params.f.x);
558 let path_inradius = params.f.y;
559 let stroke = params.f.z;
560 let path_arcs = u32(params.f.w);
561
562 // Square units, from the RENDER TARGET's aspect (ADR-0037): stretching x
563 // makes one unit of `uv` the same length on both axes, so the figure below
564 // is the shape it claims to be and not the window's shape.
565 var uv = in.ndc;
566 uv.x = uv.x * aspect;
567
568 // The figure's own frame: `pan` moves its centre, `scale` sets its size.
569 //
570 // **`rotation` is applied AFTER the pan, and that is a choice.** Turning the
571 // sample point before subtracting `pan` would swing the figure around the
572 // frame's centre — an orbit — and turning it after swings it about its own.
573 // Both are defensible and they look completely different; this scene draws
574 // ONE figure, and a figure that spins in place is what `rotation` means on
575 // `lines/star.rs` and `lines/lsystem.rs` too.
576 //
577 // It is done in `uv`, which is already SQUARE units (ADR-0037): x has been
578 // stretched by the render target's aspect, so one unit is the same length on
579 // both axes and this is a rotation. In raw NDC the same two lines would
580 // SHEAR — invisible at 16:9, where the stretch is nearly 1, and obvious at
581 // 2:1. `tests` renders a square at 2:1 and turns it a quarter turn.
582 //
583 // A branch rather than an unconditional multiply, so 0 is an exact identity
584 // and no shipped preset moves through `cos`/`sin` (ADR-0092's care, the same
585 // reason `gamma` has one).
586 var q = uv - pan;
587 if (rotation != 0.0) {
588 let cr = cos(rotation);
589 let sr = sin(rotation);
590 // The INVERSE rotation on the sample point, so a positive `rotation`
591 // turns the figure counter-clockwise on screen rather than the frame.
592 q = vec2<f32>(cr * q.x + sr * q.y, cr * q.y - sr * q.x);
593 }
594 let p = q / scale;
595
596 // THE substitution this scene exists for: the palette coordinate is a
597 // FIGURE coordinate rather than a level. Both modes are 0 at the figure's
598 // centre and exactly 1 on its outline, and both grow outward — what differs
599 // is what a band of the coordinate is a band OF.
600 //
601 // An `if` rather than a `select`, and that is not style: `select` evaluates
602 // both arms, and the second arm here is a whole second shape evaluation. The
603 // mode is a per-draw uniform, so this branch is uniform across a warp and
604 // the hardware takes one arm rather than both.
605 //
606 // An authored contour takes the same two modes on the same terms
607 // (ADR-0107): what changes is where the silhouette came from, not what a
608 // band of the coordinate is a band of. `path_n` is 0 for every preset that
609 // declares no `[path]`, so those take the roster arms below and not one
610 // instruction of the contour walk executes.
611 var d: f32;
612 if (path_arcs >= 1u) {
613 // The arc chain, chosen CPU-side and only where it can serve: the
614 // distance coordinate, and no morph in flight. `path_inradius` is the
615 // POLYLINE's, which describes the same figure to within the fit's own
616 // lateral budget — a sub-pixel difference in a divisor.
617 d = max(1.0 + arc_chain_sd(p, path_arcs) / max(path_inradius, 1e-6), 0.0);
618 } else if (path_n >= 3u) {
619 if (coord_mode < 0.5) {
620 // `1 + sd / inradius` — the SAME normalization `mark_distance`
621 // applies to the roster, so an authored figure reads 0 at its
622 // deepest interior point and exactly 1 on its outline like every
623 // other silhouette this scene draws. Held at 0 from below because
624 // the inradius is measured on a grid and can land a hair short of
625 // the true deepest point; a negative coordinate would be a NaN
626 // under a bound `gamma` (`pow` of a negative base).
627 d = max(1.0 + path_sd(p, path_n) / max(path_inradius, 1e-6), 0.0);
628 } else {
629 d = length(p) / path_boundary_radius(p, path_n);
630 }
631 } else if (coord_mode < 0.5) {
632 // Mode 0 — a band of the coordinate is a band of constant DISTANCE,
633 // which is the definition of an offset curve (ADR-0105). This is the
634 // default and it is bit-for-bit the arithmetic that shipped.
635 d = mark_distance(p, shape, points, star);
636 } else {
637 // Mode 1 — a band of the coordinate is a band of constant SCALING, so
638 // its level sets are scaled copies of the outline (ADR-0111). On a
639 // polygon that keeps the corners the offsets round off; on a heart it
640 // keeps the notch, which is the construction the reference images are.
641 d = length(p) / max(mark_boundary_radius(p, shape, points, star), 1e-6);
642 }
643
644 // **The stroke's screen width, taken before any branch.** A derivative has
645 // to be evaluated in uniform control flow, and hoisting it is what keeps
646 // that true however the branch below is compiled — `band_contour` hoists
647 // its own for the same reason.
648 let d_width = max(fwidth(d), 1e-5);
649 // The response exponent, applied to the distance BEFORE it becomes a palette
650 // coordinate — so it reshapes where the contours sit rather than which
651 // colours they take. Above 1 the bands crowd toward the centre, which is what
652 // the reference images do and what a raw (evenly spaced) distance cannot.
653 // `select` rather than a branch, and the identity is exact: `pow(x, 1.0)` is
654 // not bit-exact, so an unbound preset must not go through it (ADR-0092).
655 let shaped = select(pow(d, gamma), d, gamma == 1.0);
656 let coord = shaped * color_span + color_center;
657
658 // Hard bands, then the contour drawn from the SAME coordinate (ADR-0078).
659 let banded = band_coord(coord, palette_steps);
660 let ca = textureSample(lut_a, lut_samp, vec2<f32>(banded, 0.5)).rgb;
661 let cb = textureSample(lut_b, lut_samp, vec2<f32>(banded, 0.5)).rgb;
662 var col = mix(ca, cb, clamp(palette_mix, 0.0, 1.0));
663 col = col * band_contour(
664 coord, palette_steps, palette_contour, lut_a, lut_b, lut_samp, palette_mix
665 );
666 col = apply_saturation(col, saturation);
667
668 // **Fill and stroke are one field, not two routes** (ADR-0107). `d` is the
669 // single evaluation above; the interior is `d < 1` and the outline is
670 // `abs(d - 1) < w`, so a stroke cannot drift off the fill it belongs to
671 // because there is nothing for it to drift from. (The ADR writes the pair
672 // as `d < 0` and `abs(d) < w` against a raw signed distance; this scene's
673 // coordinate is that distance normalized to 1 on the outline, so the two
674 // tests are the same two tests shifted by one.)
675 //
676 // Exactly 0 is the identity and takes the branch away, which is what keeps
677 // every shipped preset and every golden baseline on the arithmetic it has.
678 if (stroke > 0.0) {
679 col = col * (1.0 - smoothstep(stroke - d_width, stroke + d_width, abs(d - 1.0)));
680 }
681
682 // Alpha: this field covers every pixel, which is the coverage it honestly
683 // has (ADR-0056). `occlude` scales how much of that the backdrop underneath
684 // resolves against (ADR-0085). Reached only when no post stage is active;
685 // the chain owns the seam otherwise and the renderer hands a literal 1.0.
686 return vec4<f32>(col, params.d.x);
687}
688"#;
689
690#[repr(C)]
691#[derive(Clone, Copy, bytemuck::Pod, bytemuck::Zeroable)]
692struct Params {
693 a: [f32; 4],
694 b: [f32; 4],
695 c: [f32; 4],
696 d: [f32; 4],
697 e: [f32; 4],
698 f: [f32; 4],
699 path: [[f32; 4]; PATH_VEC4S],
705}
706
707pub struct ShapeFieldScene {
710 gpu: gpu::FullscreenScene,
714 shape: f32,
719 points: f32,
720 star_valley: f32,
725 star_curve: f32,
726 star_jitter: f32,
727 scale: f32,
728 colour: common::PaletteParams,
731 pan: common::PanParams,
733 color_span: f32,
734 color_center: f32,
735 gamma: f32,
738 coord_mode: f32,
742 rotation: f32,
745 stroke: f32,
749 path_from: Vec<[f32; 2]>,
759 path_to: Vec<[f32; 2]>,
760 pieces: Vec<crate::render::scenes::lines::biarc::Piece>,
765 path: Box<[[f32; 4]; PATH_VEC4S]>,
772 path_inradius: f32,
789 path_inradius_to: f32,
790 morph: f32,
794 occlude: f32,
798}
799
800impl ShapeFieldScene {
801 pub fn new(device: &wgpu::Device, surface_format: wgpu::TextureFormat) -> Self {
803 let source = format!("{}{SHADER}", marks::sdf_wgsl());
807 let shader = gpu::fullscreen_shader(
808 device,
809 "shape-field-shader",
810 gpu::FULLSCREEN_VS_NDC,
811 &source,
812 );
813 let parts = gpu::FullscreenParts::new(device, "shape-field", std::mem::size_of::<Params>());
814 let bind_layout = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
820 label: Some("shape-field-bind-layout"),
821 entries: &[
822 gpu::sampler(0),
823 gpu::texture(1, true),
824 gpu::texture(2, true),
825 wgpu::BindGroupLayoutEntry {
826 binding: 3,
827 visibility: wgpu::ShaderStages::VERTEX_FRAGMENT,
828 ty: wgpu::BindingType::Buffer {
829 ty: wgpu::BufferBindingType::Uniform,
830 has_dynamic_offset: false,
831 min_binding_size: wgpu::BufferSize::new(
832 std::mem::size_of::<Params>() as u64
833 ),
834 },
835 count: None,
836 },
837 ],
838 });
839 let [lut_a, lut_b, lut_sampler] = parts.luts().bind_entries(1, 2, 0);
842 let bind_group = device.create_bind_group(&wgpu::BindGroupDescriptor {
843 label: Some("shape-field-bind-group"),
844 layout: &bind_layout,
845 entries: &[
846 lut_sampler,
847 lut_a,
848 lut_b,
849 wgpu::BindGroupEntry {
850 binding: 3,
851 resource: parts.uniforms().as_entire_binding(),
852 },
853 ],
854 });
855
856 Self {
857 gpu: parts.finish(
858 device,
859 &shader,
860 &[&bind_layout],
861 bind_group,
862 None,
863 surface_format,
864 wgpu::BlendState::REPLACE,
865 "shape-field",
866 ),
867 shape: marks::DEFAULT_SHAPE,
868 points: marks::DEFAULT_POINTS,
869 star_valley: marks::DEFAULT_STAR_VALLEY,
870 star_curve: marks::DEFAULT_STAR_CURVE,
871 star_jitter: marks::DEFAULT_STAR_JITTER,
872 scale: DEFAULT_SCALE,
873 colour: common::PaletteParams::new(0.0, common::DEFAULT_BRIGHTNESS),
874 pan: common::PanParams::default(),
875 color_span: DEFAULT_COLOR_SPAN,
876 color_center: DEFAULT_COLOR_CENTER,
877 gamma: DEFAULT_GAMMA,
878 coord_mode: DEFAULT_COORD_MODE,
879 rotation: DEFAULT_ROTATION,
880 stroke: DEFAULT_STROKE,
881 morph: DEFAULT_MORPH,
882 path_from: Vec::new(),
883 path_to: Vec::new(),
884 pieces: Vec::new(),
885 path: Box::new([[0.0; 4]; PATH_VEC4S]),
886 path_inradius: 1.0,
887 path_inradius_to: 1.0,
888 occlude: crate::render::post::DEFAULT_OCCLUDE,
889 }
890 }
891}
892
893fn applied_scale(scale: f32) -> f32 {
901 if scale.is_finite() {
902 scale.clamp(MIN_SCALE, MAX_SCALE)
903 } else {
904 DEFAULT_SCALE
905 }
906}
907
908fn applied_rotation(rotation: f32) -> f32 {
916 if rotation.is_finite() {
917 rotation
918 } else {
919 DEFAULT_ROTATION
920 }
921}
922
923fn applied_coord_mode(mode: f32, shape: f32) -> f32 {
951 if shape == marks::RING_SHAPE {
952 return DEFAULT_COORD_MODE;
953 }
954 if mode.is_finite() {
955 mode.clamp(MIN_COORD_MODE, MAX_COORD_MODE).round()
956 } else {
957 DEFAULT_COORD_MODE
958 }
959}
960
961fn applied_gamma(gamma: f32) -> f32 {
970 if gamma.is_finite() {
971 gamma.clamp(MIN_GAMMA, MAX_GAMMA)
972 } else {
973 DEFAULT_GAMMA
974 }
975}
976
977fn applied_stroke(stroke: f32) -> f32 {
985 if stroke.is_finite() {
986 stroke.clamp(0.0, MAX_STROKE)
987 } else {
988 DEFAULT_STROKE
989 }
990}
991
992fn applied_morph(morph: f32) -> f32 {
1000 if morph.is_finite() {
1001 morph.clamp(0.0, 1.0)
1002 } else {
1003 DEFAULT_MORPH
1004 }
1005}
1006
1007fn contour_inradius(points: &[[f32; 2]]) -> f32 {
1020 const GRID: i32 = 96;
1022 const REFINE: u32 = 12;
1024
1025 let depth_at = |p: [f32; 2]| -> f32 {
1026 let n = points.len();
1031 let mut best = f32::INFINITY;
1032 let mut inside = false;
1033 for i in 0..n {
1034 let (Some(&a), Some(&b)) = (points.get(i), points.get((i + n - 1) % n)) else {
1035 continue;
1036 };
1037 let e = [b[0] - a[0], b[1] - a[1]];
1038 let w = [p[0] - a[0], p[1] - a[1]];
1039 let ee = (e[0] * e[0] + e[1] * e[1]).max(1e-20);
1040 let t = ((w[0] * e[0] + w[1] * e[1]) / ee).clamp(0.0, 1.0);
1041 let q = [w[0] - e[0] * t, w[1] - e[1] * t];
1042 best = best.min(q[0] * q[0] + q[1] * q[1]);
1043 let c1 = p[1] >= a[1];
1044 let c2 = p[1] < b[1];
1045 let c3 = e[0] * w[1] > e[1] * w[0];
1046 if (c1 && c2 && c3) || (!c1 && !c2 && !c3) {
1047 inside = !inside;
1048 }
1049 }
1050 if inside { best.sqrt() } else { 0.0 }
1051 };
1052
1053 let mut best_p = [0.0f32, 0.0];
1054 let mut best_d = depth_at(best_p);
1055 for gy in 0..=GRID {
1056 for gx in 0..=GRID {
1057 let p = [
1058 (gx as f32 / GRID as f32) * 2.0 - 1.0,
1059 (gy as f32 / GRID as f32) * 2.0 - 1.0,
1060 ];
1061 let d = depth_at(p);
1062 if d > best_d {
1063 best_d = d;
1064 best_p = p;
1065 }
1066 }
1067 }
1068 let mut radius = 2.0 / GRID as f32;
1069 for _ in 0..REFINE {
1070 for (dx, dy) in [
1071 (-1.0f32, 0.0f32),
1072 (1.0, 0.0),
1073 (0.0, -1.0),
1074 (0.0, 1.0),
1075 (-1.0, -1.0),
1076 (1.0, -1.0),
1077 (-1.0, 1.0),
1078 (1.0, 1.0),
1079 ] {
1080 let p = [best_p[0] + dx * radius, best_p[1] + dy * radius];
1081 let d = depth_at(p);
1082 if d > best_d {
1083 best_d = d;
1084 best_p = p;
1085 }
1086 }
1087 radius *= 0.5;
1088 }
1089 best_d.max(1e-4)
1093}
1094
1095#[cfg(test)]
1099pub(crate) fn coord(distance: f32, gamma: f32, color_span: f32, color_center: f32) -> f32 {
1100 let g = applied_gamma(gamma);
1101 let shaped = if g == 1.0 { distance } else { distance.powf(g) };
1102 shaped * color_span + color_center
1103}
1104
1105pub const PARAMS: &[ParamSpec] = &[
1110 crate::render::scenes::marks::SHAPE,
1111 crate::render::scenes::marks::POINTS,
1112 crate::render::scenes::marks::STAR_VALLEY,
1113 crate::render::scenes::marks::STAR_CURVE,
1114 crate::render::scenes::marks::STAR_JITTER,
1115 ParamSpec {
1116 name: "scale",
1117 default: 0.6,
1118 range: Some([0.05, 2.0]),
1119 doc: "Size of the shape within the frame.",
1120 kind: ParamKind::Modal,
1121 },
1122 crate::render::scenes::common::PAN_X,
1123 crate::render::scenes::common::PAN_Y,
1124 ParamSpec {
1125 name: "color_span",
1126 default: 0.6,
1127 range: Some([0.0, 1.0]),
1128 doc: "How much of the palette the field's range covers.",
1129 kind: ParamKind::Modal,
1130 },
1131 ParamSpec {
1132 name: "color_center",
1133 default: 0.0,
1134 range: Some([-1.0, 1.0]),
1135 doc: "Shifts which part of that range lands in the middle of the palette.",
1136 kind: ParamKind::Modal,
1137 },
1138 crate::render::scenes::common::SATURATION,
1139 crate::render::scenes::common::PALETTE_MIX,
1140 crate::render::scenes::common::PALETTE_STEPS,
1141 crate::render::scenes::common::PALETTE_CONTOUR,
1142 ParamSpec {
1143 name: "gamma",
1144 default: 1.0,
1145 range: Some([0.25, 4.0]),
1146 doc: "Shapes the falloff from the shape's edge; below 1 it bites sooner.",
1147 kind: ParamKind::Modal,
1148 },
1149 ParamSpec {
1150 name: "coord_mode",
1151 default: 0.0,
1152 range: Some([MIN_COORD_MODE, MAX_COORD_MODE]),
1156 doc: "Which coordinate frame the distance is measured in, which changes the shape's whole geometry.",
1157 kind: ParamKind::Structural,
1158 },
1159 ParamSpec {
1160 name: "rotation",
1161 default: 0.0,
1162 range: Some([0.0, 1.0]),
1163 doc: "Turns the shape, as a fraction of a full turn.",
1164 kind: ParamKind::Modal,
1165 },
1166 ParamSpec {
1167 name: "stroke",
1168 default: 0.0,
1169 range: Some([0.0, 1.0]),
1170 doc: "Draws the outline instead of the filled figure, at this half-width; 0 fills.",
1171 kind: ParamKind::Modal,
1172 },
1173 ParamSpec {
1174 name: "morph",
1175 default: 0.0,
1176 range: Some([0.0, 1.0]),
1177 doc: "Travels the authored path towards its morph_to silhouette; inert without one.",
1178 kind: ParamKind::Modal,
1179 },
1180];
1181
1182impl ShapeFieldScene {
1183 fn pack_path(&mut self, coord_mode: f32) -> (usize, usize, f32) {
1196 let n = self.path_from.len().min(MAX_SAMPLES);
1197 if n < 3 {
1198 return (0, 0, 1.0);
1199 }
1200 let morphing = self.path_to.len() == self.path_from.len();
1201
1202 if !morphing && coord_mode < 0.5 && !self.pieces.is_empty() {
1214 let pieces = self.pieces.len().min(MAX_ARC_PIECES);
1215 self.pack_pieces(pieces);
1216 return (0, pieces, self.path_inradius);
1217 }
1218
1219 let t = if morphing {
1220 applied_morph(self.morph)
1221 } else {
1222 0.0
1223 };
1224 for i in 0..n {
1225 let Some(&a) = self.path_from.get(i) else {
1226 continue;
1227 };
1228 let p = match self.path_to.get(i) {
1229 Some(&b) if t != 0.0 => [a[0] + (b[0] - a[0]) * t, a[1] + (b[1] - a[1]) * t],
1230 _ => a,
1231 };
1232 let slot = i >> 1;
1233 let half = (i & 1) * 2;
1234 if let Some(v) = self.path.get_mut(slot) {
1235 if let Some(x) = v.get_mut(half) {
1236 *x = p[0];
1237 }
1238 if let Some(y) = v.get_mut(half + 1) {
1239 *y = p[1];
1240 }
1241 }
1242 }
1243 let inradius = self.path_inradius + (self.path_inradius_to - self.path_inradius) * t;
1244 (n, 0, inradius.max(1e-4))
1245 }
1246
1247 fn pack_pieces(&mut self, count: usize) {
1254 use crate::render::scenes::lines::biarc::Piece;
1255 for i in 0..count {
1256 let Some(&piece) = self.pieces.get(i) else {
1257 continue;
1258 };
1259 let base = i * VEC4S_PER_PIECE;
1260 let (a, b) = (piece.start_point(), piece.end_point());
1261 let (head, sector, sweep) = match piece {
1262 Piece::Arc {
1263 centre,
1264 radius,
1265 start,
1266 sweep,
1267 } => {
1268 let mid = start + sweep * 0.5;
1269 (
1270 [1.0, centre[0], centre[1], radius],
1271 [mid.cos(), mid.sin(), (sweep.abs() * 0.5).cos(), 0.0],
1272 [start, sweep, 0.0, 0.0],
1273 )
1274 }
1275 Piece::Line { .. } => ([0.0; 4], [0.0; 4], [0.0; 4]),
1276 };
1277 for (offset, value) in [
1278 (0, head),
1279 (1, sector),
1280 (2, [a[0], a[1], b[0], b[1]]),
1281 (3, sweep),
1282 ] {
1283 if let Some(slot) = self.path.get_mut(base + offset) {
1284 *slot = value;
1285 }
1286 }
1287 }
1288 }
1289}
1290
1291impl Scene for ShapeFieldScene {
1292 fn name(&self) -> &'static str {
1293 "shape field"
1294 }
1295
1296 fn set_occlude(&mut self, occlude: f32) {
1297 self.occlude = occlude;
1298 }
1299
1300 fn set_palette(&mut self, palette: &Palette) {
1301 self.gpu.set_palette(palette);
1302 }
1303
1304 fn reset_params(&mut self) {
1305 self.shape = marks::DEFAULT_SHAPE;
1306 self.points = marks::DEFAULT_POINTS;
1307 self.star_valley = marks::DEFAULT_STAR_VALLEY;
1308 self.star_curve = marks::DEFAULT_STAR_CURVE;
1309 self.star_jitter = marks::DEFAULT_STAR_JITTER;
1310 self.scale = DEFAULT_SCALE;
1311 self.colour.reset();
1312 self.pan.reset();
1313 self.color_span = DEFAULT_COLOR_SPAN;
1314 self.color_center = DEFAULT_COLOR_CENTER;
1315 self.gamma = DEFAULT_GAMMA;
1316 self.coord_mode = DEFAULT_COORD_MODE;
1317 self.rotation = DEFAULT_ROTATION;
1318 self.stroke = DEFAULT_STROKE;
1319 self.morph = DEFAULT_MORPH;
1320 }
1321
1322 fn configure(
1331 &mut self,
1332 cfg: &super::lines::GeneratorConfig,
1333 ) -> Option<super::lines::CapOverflow> {
1334 if let super::lines::GeneratorConfig::Path { shape, morph_to } = cfg {
1335 self.path_from.clear();
1336 self.path_to.clear();
1337 self.pieces.clear();
1338 self.path_inradius = 1.0;
1339 self.path_inradius_to = 1.0;
1340 if let Some(contour) = shape {
1341 self.path_from
1345 .extend(contour.points().iter().take(MAX_SAMPLES).copied());
1346 self.path_inradius = contour_inradius(&self.path_from);
1347 self.pieces.extend_from_slice(contour.pieces());
1348 }
1349 if let Some(target) = morph_to
1353 .as_ref()
1354 .filter(|t| t.points().len() == self.path_from.len())
1355 {
1356 self.path_to.extend(target.points().iter().copied());
1357 self.path_inradius_to = contour_inradius(&self.path_to);
1358 }
1359 }
1360 None
1361 }
1362
1363 fn set_param(&mut self, name: &str, value: f32) {
1364 if self.colour.set(name, value) || self.pan.set(name, value) {
1367 return;
1368 }
1369 match name {
1370 "shape" => self.shape = value,
1371 "points" => self.points = value,
1372 "star_valley" => self.star_valley = value,
1373 "star_curve" => self.star_curve = value,
1374 "star_jitter" => self.star_jitter = value,
1375 "scale" => self.scale = value,
1376 "color_span" => self.color_span = value,
1377 "color_center" => self.color_center = value,
1378 "gamma" => self.gamma = value,
1379 "coord_mode" => self.coord_mode = value,
1380 "rotation" => self.rotation = value,
1381 "stroke" => self.stroke = value,
1382 "morph" => self.morph = value,
1383 _ => {}
1384 }
1385 }
1386
1387 fn update(&mut self, _frame: &AnalysisFrame) {
1388 }
1391
1392 fn render(
1393 &mut self,
1394 queue: &wgpu::Queue,
1395 encoder: &mut wgpu::CommandEncoder,
1396 view: &wgpu::TextureView,
1397 aspect: f32,
1398 ) {
1399 let shape = marks::mark_shape(self.shape);
1403 self.gpu.flush_palette(queue);
1404 let coord_mode = applied_coord_mode(self.coord_mode, shape);
1405 let (path_count, path_arcs, path_inradius) = self.pack_path(coord_mode);
1406
1407 let params = Params {
1408 a: [
1411 aspect.max(0.1),
1412 shape,
1413 marks::mark_points(self.points),
1414 applied_scale(self.scale),
1415 ],
1416 b: [self.pan.x, self.pan.y, self.color_span, self.color_center],
1417 c: [
1418 self.colour.saturation,
1419 self.colour.mix,
1420 palette::band_steps(self.colour.steps),
1421 palette::band_contour(self.colour.contour),
1422 ],
1423 d: [
1424 self.occlude,
1425 applied_gamma(self.gamma),
1426 coord_mode,
1427 applied_rotation(self.rotation),
1428 ],
1429 e: [
1430 marks::star_valley(self.star_valley),
1431 marks::star_curve(self.star_curve),
1432 marks::star_jitter(self.star_jitter),
1433 0.0,
1434 ],
1435 f: [
1436 path_count as f32,
1437 path_inradius,
1438 applied_stroke(self.stroke),
1439 path_arcs as f32,
1440 ],
1441 path: *self.path,
1442 };
1443 self.gpu.write_uniform(queue, ¶ms);
1444 self.gpu
1445 .draw(encoder, "shape-field-pass", view, wgpu::LoadOp::Load);
1446 }
1447}
1448
1449#[cfg(test)]
1450mod tests;