Skip to main content

rlx_core/render/scenes/
reaction_diffusion.rs

1//! Reaction-diffusion scene: a Gray-Scott simulation evolving on a fixed
2//! internal grid via the reusable [`PingPongField`](crate::render::feedback)
3//! (ADR-0012). The engine's first *stateful* scene — each frame's field depends
4//! on the previous frame's, held in a texture and stepped by a simulation
5//! shader — unlocking the organic, restructuring look (nested contours, cellular
6//! tissue, a hatched maze) stateless scenes can't produce.
7//!
8//! Phase 1 is a walking skeleton: a fixed number of sub-steps per frame and a
9//! grayscale present. The fixed-timestep accumulator (Phase 2), audio-reactive
10//! named parameters (Phase 3), and the iso-contour/hatch look (Phase 4) land on
11//! top of this. All randomness is the seeded initial scatter (NFR 6): the field
12//! is a pure function of the seed + the fixed-`dt` step sequence.
13//!
14//! **GPU resources are built lazily, on first render.** The scene stores a
15//! device handle and constructs its pipelines/textures only when it is first
16//! drawn (see `Resources`). This keeps the resources off the device until the
17//! scene is actually shown, and — importantly — lets the headless capture tests
18//! build the full roster on the DX12 WARP software adapter: WARP mis-renders the
19//! pre-existing fragment-field pipeline once this scene's *full* set of feedback
20//! resources coexists on the device (a cumulative software-rasterizer quirk with
21//! no wgpu validation error; real hardware is unaffected). Deferring
22//! construction means a capture that never activates this scene never builds
23//! those resources, so the other scenes' captures stay correct; a capture that
24//! *does* activate it builds them and renders this scene normally.
25
26// Hot-path panic-denial pragma (Plan 0002 Phase 2, extended to scenes by Plan
27// 0003 Phase 0). Encodes its passes every displayed frame.
28#![deny(
29    clippy::unwrap_used,
30    clippy::expect_used,
31    clippy::indexing_slicing,
32    clippy::panic,
33    clippy::unreachable
34)]
35
36use crate::render::gpu;
37
38use super::common;
39use super::{Scene, SeededRng};
40use crate::dsp::AnalysisFrame;
41use crate::render::feedback::PingPongField;
42use crate::render::palette::{self, Palette};
43use crate::render::scenes::{ParamKind, ParamSpec, default_of};
44
45/// Fixed internal simulation grid (square). 256² resolves the Gray-Scott
46/// patterns well while staying cheap enough that the headless capture tests run
47/// briskly on the software (WARP) adapter — a 512² grid quadruples the per-step
48/// fragment work the differential tests pay each warm-up frame (ADR-0012 gives
49/// 512² only as an example).
50const GRID: u32 = 256;
51
52/// Wall-clock duration of one Gray-Scott sub-step (Plan 0014 Phase 2). The
53/// fixed-timestep accumulator runs one sub-step per `FIXED_STEP` of injected
54/// real `dt`, so the simulation evolves at the same rate on any refresh — at the
55/// live/​capture `dt` of 1/60 s this is 12 sub-steps per frame.
56const FIXED_STEP: f32 = 1.0 / 720.0;
57
58/// Max sub-steps encoded in a single frame. A long stall would otherwise queue
59/// unbounded work (accumulator spiral-of-death); past this the accumulator's
60/// backlog is dropped, so the sim briefly slows rather than diverging (ADR-0012).
61/// 40 covers a ~55 ms hitch before it bites.
62const MAX_SUBSTEPS: u32 = 40;
63
64/// Seeded initial-scatter blobs, and the uniform array's capacity.
65const SEED_BLOBS: usize = 30;
66const MAX_BLOBS: usize = 32;
67// The ASCII bytes of "LMV_RD_1" read as a number. Re-spelling them to
68// match a renamed prefix changes the seeded scatter and moves this
69// scene's goldens, so the value is opaque and stays as it is.
70const SEED: u64 = 0x4C4D_565F_5244_5F31;
71
72/// Parameter defaults — the "mitosis" Gray-Scott regime (Pearson's
73/// classification): spots that perpetually divide, so the field keeps
74/// restructuring rather than settling into a static pattern.
75const DEFAULT_FEED: f32 = default_of(PARAMS, "feed");
76const DEFAULT_KILL: f32 = default_of(PARAMS, "kill");
77/// Diffusion rates for the two species (classic Karl Sims values at internal
78/// `dt = 1`, paired with the 3×3 Laplacian kernel in the shader). The `flow`
79/// param (Phase 3) scales both, keeping their ratio, so a band can coarsen or
80/// tighten the pattern's spatial scale.
81const DIFFUSE_U: f32 = 0.16;
82const DIFFUSE_V: f32 = 0.08;
83/// `flow` default: unscaled diffusion.
84const DEFAULT_FLOW: f32 = default_of(PARAMS, "flow");
85
86/// Present-look defaults (Phase 4): palette hue offset, iso-contour band count,
87/// hatch stripe spacing in texels, and glow strength.
88const DEFAULT_HUE: f32 = 0.0;
89const DEFAULT_CONTOUR: f32 = default_of(PARAMS, "contour");
90const DEFAULT_HATCH: f32 = default_of(PARAMS, "hatch");
91const DEFAULT_GLOW: f32 = default_of(PARAMS, "glow");
92// Shared palette color knobs (ADR-0021 / Plan 0020 Phase 5). `color_span` = 0.85
93// (the old fixed field-to-gradient coefficient) + `color_center` = 0 +
94// `saturation` = 1 + `palette_mix` = 0 reproduce the prior present-look color math
95// (now sampling the shared LUT instead of the private cosine).
96const DEFAULT_COLOR_SPAN: f32 = default_of(PARAMS, "color_span");
97const DEFAULT_COLOR_CENTER: f32 = default_of(PARAMS, "color_center");
98/// View transform defaults (ADR-0018): identity — `zoom` = 1 leaves the sampled
99/// window unscaled, `pan` = 0 unshifted, so an unbound preset is byte-unchanged.
100const DEFAULT_ZOOM: f32 = 1.0;
101
102/// Beat-stamped seed injection (Phase 3). A rising `inject` edge stamps a blob
103/// of V into the field at the next seeded position, so a beat spawns new growth.
104/// Positions come from a `SeededRng` (NFR 6) so a capture reproduces exactly.
105const INJECT_RADIUS: f32 = 0.045;
106const INJECT_AMOUNT: f32 = 0.85;
107const INJECT_SEED: u64 = 0x4C4D_5244_494E_4A31; // "LMRDINJ1"
108/// `inject` rises past this to fire one stamp (edge-triggered, not per-frame).
109const INJECT_THRESHOLD: f32 = 0.5;
110
111/// Seed pass: U = 1 everywhere, V = 1 inside the scattered blobs.
112const INIT_SHADER: &str = r#"
113struct Init {
114    blobs: array<vec4<f32>, 32>, // xy: center (uv), z: radius, w: unused
115    count: vec4<u32>,            // x: active blob count
116}
117@group(0) @binding(0) var<uniform> init: Init;
118
119@fragment
120fn fs_main(in: VsOut) -> @location(0) vec4<f32> {
121    var v = 0.0;
122    let n = init.count.x;
123    for (var i = 0u; i < n; i = i + 1u) {
124        let b = init.blobs[i];
125        if (distance(in.uv, b.xy) < b.z) {
126            v = 1.0;
127        }
128    }
129    return vec4<f32>(1.0 - v, v, 0.0, 1.0);
130}
131"#;
132
133/// Sim pass: one Gray-Scott step, reading the previous field, writing the next.
134const SIM_SHADER: &str = r#"
135struct Sim {
136    p: vec4<f32>,   // x: feed, y: kill, z: diffuse_u, w: diffuse_v
137    inj: vec4<f32>, // xy: stamp center (uv), z: radius, w: amount (0 = no stamp)
138}
139@group(0) @binding(0) var<uniform> sim: Sim;
140@group(0) @binding(1) var field: texture_2d<f32>;
141
142// Toroidal texel fetch (wrap at the edges) of the (U, V) pair.
143fn ld(c: vec2<i32>, size: vec2<i32>) -> vec2<f32> {
144    let w = ((c % size) + size) % size;
145    return textureLoad(field, w, 0).xy;
146}
147
148@fragment
149fn fs_main(in: VsOut) -> @location(0) vec4<f32> {
150    let size = vec2<i32>(textureDimensions(field));
151    let c = vec2<i32>(i32(in.pos.x), i32(in.pos.y));
152    let m = ld(c, size);
153    let u = m.x;
154    let v = m.y;
155
156    // 3×3 Laplacian: orthogonal 0.2, diagonal 0.05, center -1.
157    var lap = ld(c + vec2<i32>(-1, 0), size) * 0.2
158        + ld(c + vec2<i32>(1, 0), size) * 0.2
159        + ld(c + vec2<i32>(0, -1), size) * 0.2
160        + ld(c + vec2<i32>(0, 1), size) * 0.2
161        + ld(c + vec2<i32>(-1, -1), size) * 0.05
162        + ld(c + vec2<i32>(1, -1), size) * 0.05
163        + ld(c + vec2<i32>(-1, 1), size) * 0.05
164        + ld(c + vec2<i32>(1, 1), size) * 0.05;
165    lap = lap - m;
166
167    let feed = sim.p.x;
168    let kill = sim.p.y;
169    let du = sim.p.z;
170    let dv = sim.p.w;
171    let reaction = u * v * v;
172    let nu = u + du * lap.x - reaction + feed * (1.0 - u);
173    var nv = v + dv * lap.y + reaction - (kill + feed) * v;
174
175    // Beat-stamped seed injection (Phase 3), folded into the sim so no extra
176    // pipeline is needed. `inj.w` is non-zero only on the stamp frame; it is
177    // applied on every sub-step of that frame, so V saturates at the stamp.
178    let stamp = sim.inj.w * (1.0 - smoothstep(sim.inj.z * 0.4, sim.inj.z, distance(in.uv, sim.inj.xy)));
179    nv = nv + stamp;
180
181    return vec4<f32>(clamp(nu, 0.0, 1.0), clamp(nv, 0.0, 1.0), 0.0, 1.0);
182}
183"#;
184
185/// Present pass (Phase 4): the reference aesthetic — analytic iso-contours of
186/// the V field with `fwidth` anti-aliasing, a cosine palette coloring the nested
187/// loops, gradient-aligned hatch/comb ticks, and a soft glow.
188const PRESENT_SHADER: &str = r#"
189struct Present {
190    // x: hue, y: contour density, z: hatch frequency (texels), w: glow
191    a: vec4<f32>,
192    // x: color_span, y: color_center, z: saturation, w: palette_mix
193    b: vec4<f32>,
194    // x: zoom, yz: pan (field-space view transform, ADR-0018), w: occlude (ADR-0085)
195    c: vec4<f32>,
196    // x: palette_steps (integral, quantized CPU-side), y: palette_contour
197    // (ADR-0078), zw: unused
198    d: vec4<f32>,
199}
200@group(0) @binding(0) var present_field: texture_2d<f32>;
201@group(0) @binding(1) var present_samp: sampler;
202@group(0) @binding(2) var<uniform> pp: Present;
203// Shared gradient LUTs (ADR-0021): A/B for the `palette_mix` crossfade, one
204// repeat sampler. Kept in this present bind group (a unique 6-entry layout) so it
205// never matches another pipeline's layout on the DX12 WARP software adapter.
206@group(0) @binding(3) var lut_a: texture_2d<f32>;
207@group(0) @binding(4) var lut_b: texture_2d<f32>;
208@group(0) @binding(5) var lut_samp: sampler;
209
210// Shared `saturation` (mirrors core/src/render/palette.rs::desaturate verbatim).
211fn apply_saturation(c: vec3<f32>, s: f32) -> vec3<f32> {
212    let luma = dot(c, vec3<f32>(0.299, 0.587, 0.114));
213    return vec3<f32>(luma) + (c - vec3<f32>(luma)) * s;
214}
215
216// Shared `palette_steps` (mirrors core/src/render/palette.rs::band_coord
217// verbatim, ADR-0078): snap the palette coordinate to a band centre before the
218// LUT read. Below 1.5 steps it is the exact identity, not a one-band degenerate.
219fn band_coord(t: f32, steps: f32) -> f32 {
220    if (steps < 1.5) {
221        return t;
222    }
223    return (floor(t * steps) + 0.5) / steps;
224}
225
226// Shared `palette_contour` (ADR-0078 / ADR-0133; the WGSL is the implementation,
227// copied verbatim at each fragment-stage site — palette.rs has no CPU
228// counterpart to be canonical, since `fwidth` exists only here).
229//
230// Darkens within one PIXEL of a band edge, so the line has the same weight where
231// the field is shallow and where it is steep — AND ONLY WHERE THE INK ACTUALLY
232// CHANGES (ADR-0133). It samples the two band centres either side of the nearest
233// edge and returns unchanged when they resolve to the same colour within half a
234// code value, which is below the LUT's own 8-bit quantization. On a smooth
235// palette two distinct centres always differ by at least one code value, so
236// every edge draws exactly as it did at any `palette_steps`; inside a plateau
237// the LUT is literally constant and the samples are bit-equal, so the line
238// vanishes there and survives at the run boundaries. One rule, both behaviours,
239// no new parameter.
240//
241// The two LUTs, the sampler and `palette_mix` are EXPLICIT parameters rather
242// than module-scope globals this happens to find: all four sites name them the
243// same today, so implicit capture would compile — and would silently bind the
244// shared function to whatever a future site called its textures.
245//
246// `textureSampleLevel`, not `textureSample`: the LUT has one mip, and an
247// explicit LOD keeps these reads free of the uniformity requirement that a
248// sample after a conditional return would otherwise carry.
249fn band_contour(
250    t: f32,
251    steps: f32,
252    amount: f32,
253    lut_a: texture_2d<f32>,
254    lut_b: texture_2d<f32>,
255    lut_samp: sampler,
256    mix_ab: f32,
257) -> f32 {
258    let f = t * steps;
259    let w = max(fwidth(f), 1e-5);
260    if (steps < 1.5 || amount <= 0.0) {
261        return 1.0;
262    }
263    let n = round(f);
264    let m = clamp(mix_ab, 0.0, 1.0);
265    let lo = mix(
266        textureSampleLevel(lut_a, lut_samp, vec2<f32>((n - 0.5) / steps, 0.5), 0.0).rgb,
267        textureSampleLevel(lut_b, lut_samp, vec2<f32>((n - 0.5) / steps, 0.5), 0.0).rgb,
268        m
269    );
270    let hi = mix(
271        textureSampleLevel(lut_a, lut_samp, vec2<f32>((n + 0.5) / steps, 0.5), 0.0).rgb,
272        textureSampleLevel(lut_b, lut_samp, vec2<f32>((n + 0.5) / steps, 0.5), 0.0).rgb,
273        m
274    );
275    if (all(abs(hi - lo) < vec3<f32>(0.5 / 255.0))) {
276        return 1.0;
277    }
278    let d = min(fract(f), 1.0 - fract(f));
279    return 1.0 - clamp(amount, 0.0, 1.0) * (1.0 - smoothstep(0.0, w, d));
280}
281
282fn tap_v(uv: vec2<f32>) -> f32 {
283    return textureSampleLevel(present_field, present_samp, uv, 0.0).y;
284}
285
286// C1 reconstruction of the finite field (Plan 0033 Phase 3, ADR-0034).
287//
288// Hardware bilinear is C0: the value is continuous, its gradient is not. This
289// pass runs analytic iso-contours and a central-difference gradient over exactly
290// that field, and `line_d` divides by `fwidth`, so a slope discontinuity far
291// below the 8-bit output quantum is amplified into a visible tangent kink —
292// which is why an 8x upscale of a smooth field read as angular facets.
293//
294// Catmull-Rom, not a smoothed texel coordinate. Warping the fractional
295// coordinate by a quintic is the cheap trick and it is *wrong here*: a
296// smoothstep-family warp has zero derivative at both ends, so it pins the
297// reconstruction's gradient to zero at every texel centre. That is C1, but the
298// derivative then oscillates once per cell, and a pass with this much gradient
299// gain renders it as one scalloped step per texel — measurably worse than the
300// faceting it replaces. Only a genuine higher-order filter has a smooth,
301// non-degenerate derivative.
302//
303// Nine taps rather than sixteen: each pair of neighbouring weights is folded
304// into one hardware-bilinear fetch at the weighted midpoint (`offset12`), which
305// is exact for a separable cubic. `w1 + w2 >= 1` over the whole cell, so the
306// division is safe.
307//
308// **Every** read of the field goes through here — value, gradient, contour and
309// hatch — because fixing only the value tap fixes nothing: the gradient is where
310// the discontinuity becomes visible.
311fn sample_v(uv: vec2<f32>) -> f32 {
312    let dims = vec2<f32>(textureDimensions(present_field));
313    let sample_pos = uv * dims;
314    let pos1 = floor(sample_pos - 0.5) + 0.5;
315    let f = sample_pos - pos1;
316
317    let w0 = f * (-0.5 + f * (1.0 - 0.5 * f));
318    let w1 = 1.0 + f * f * (-2.5 + 1.5 * f);
319    let w2 = f * (0.5 + f * (2.0 - 1.5 * f));
320    let w3 = f * f * (-0.5 + 0.5 * f);
321    let w12 = w1 + w2;
322
323    let p0 = (pos1 - 1.0) / dims;
324    let p3 = (pos1 + 2.0) / dims;
325    let p12 = (pos1 + w2 / w12) / dims;
326
327    var acc = 0.0;
328    acc = acc + tap_v(vec2<f32>(p0.x, p0.y)) * w0.x * w0.y;
329    acc = acc + tap_v(vec2<f32>(p12.x, p0.y)) * w12.x * w0.y;
330    acc = acc + tap_v(vec2<f32>(p3.x, p0.y)) * w3.x * w0.y;
331
332    acc = acc + tap_v(vec2<f32>(p0.x, p12.y)) * w0.x * w12.y;
333    acc = acc + tap_v(vec2<f32>(p12.x, p12.y)) * w12.x * w12.y;
334    acc = acc + tap_v(vec2<f32>(p3.x, p12.y)) * w3.x * w12.y;
335
336    acc = acc + tap_v(vec2<f32>(p0.x, p3.y)) * w0.x * w3.y;
337    acc = acc + tap_v(vec2<f32>(p12.x, p3.y)) * w12.x * w3.y;
338    acc = acc + tap_v(vec2<f32>(p3.x, p3.y)) * w3.x * w3.y;
339    return acc;
340}
341
342@fragment
343fn fs_main(in: VsOut) -> @location(0) vec4<f32> {
344    let dims = vec2<f32>(textureDimensions(present_field));
345    let texel = 1.0 / dims;
346
347    // View transform (ADR-0018): scale the sampled window about its centre by
348    // `zoom`, then offset by `pan`. Default zoom = 1, pan = 0 leaves `in.uv`
349    // untouched, so an unbound preset renders identically. Same shape fragment_field
350    // uses for its field-space zoom/pan.
351    let zoom = pp.c.x;
352    // `pan.y` is negated because `in.uv` is now Y-flipped (ADR-0070): this pass
353    // moved from the retired unflipped prelude, which reversed the direction a
354    // positive `pan_y` scrolls the field. Every other scene applies pan in clip
355    // space, where +y is up, and all four RD presets were authored against that
356    // agreement — so the sign restores the shipped behaviour rather than changing
357    // it. Measured both ways: pan_y = +0.12 moves the field 86 px, up before this
358    // and down without this negation.
359    let pan = vec2<f32>(pp.c.y, -pp.c.z);
360    let uv = (in.uv - vec2<f32>(0.5, 0.5)) * zoom + vec2<f32>(0.5, 0.5) + pan;
361
362    let v = sample_v(uv);
363
364    // Central-difference gradient of the field (for hatch orientation + edges).
365    let gx = sample_v(uv + vec2<f32>(texel.x, 0.0)) - sample_v(uv - vec2<f32>(texel.x, 0.0));
366    let gy = sample_v(uv + vec2<f32>(0.0, texel.y)) - sample_v(uv - vec2<f32>(0.0, texel.y));
367    let grad = vec2<f32>(gx, gy);
368    let gmag = length(grad);
369
370    let hue = pp.a.x;
371    let density = pp.a.y;
372    let hatch_freq = pp.a.z;
373    let glow = pp.a.w;
374    let color_span = pp.b.x;
375    let color_center = pp.b.y;
376    let saturation = pp.b.z;
377    let palette_mix = pp.b.w;
378
379    // Slope mask: contours and hatch only appear where the field actually
380    // slopes, so the flat V=0 background stays dark (V=0 is itself an iso-level,
381    // which would otherwise flood the flats).
382    let slope = smoothstep(0.0008, 0.004, gmag);
383
384    // Iso-contour lines: distance (in pixels) to the nearest V = k/density level,
385    // anti-aliased by fwidth. `contour` is ~1 on a line, 0 between them.
386    let f = v * density;
387    let line_d = abs(fract(f - 0.5) - 0.5) / max(fwidth(f), 1e-4);
388    let contour = (1.0 - clamp(line_d, 0.0, 1.0)) * slope;
389
390    // Palette by field level so the nested loops read as coloured bands. The
391    // field level `v` is the gradient coordinate: `color_span` (was a fixed 0.85)
392    // sets the spanned range, `color_center`/`hue` slide the window, and the A/B
393    // LUTs crossfade by `palette_mix` before the shared `saturation`.
394    let coord = v * color_span + color_center + hue;
395    // Hard bands, then the contour from the SAME coordinate (ADR-0078), so the
396    // dark line follows the palette's iso-lines through the field.
397    let banded = band_coord(coord, pp.d.x);
398    let ca = textureSample(lut_a, lut_samp, vec2<f32>(banded, 0.5)).rgb;
399    let cb = textureSample(lut_b, lut_samp, vec2<f32>(banded, 0.5)).rgb;
400    let mixed = mix(ca, cb, clamp(palette_mix, 0.0, 1.0))
401        * band_contour(coord, pp.d.x, pp.d.y, lut_a, lut_b, lut_samp, palette_mix);
402    let col = apply_saturation(mixed, saturation);
403
404    // Hatch/comb: stripes along the contour tangent (perpendicular to grad),
405    // gated to the slopes so flats stay clean.
406    let tang = normalize(vec2<f32>(-grad.y, grad.x) + vec2<f32>(1e-5, 1e-5));
407    let s = dot(uv * dims, tang) / max(hatch_freq, 1.0);
408    let hatch = smoothstep(0.30, 0.5, abs(fract(s) - 0.5));
409    let hatch_amt = hatch * slope;
410
411    // Compose: dark bed, a coloured fill only where the field lives, bright
412    // contour loops, hatch ticks that darken along the slopes, and a soft glow.
413    let structure = smoothstep(0.04, 0.45, v);
414    var out_col = col * structure * 0.5;
415    out_col = out_col + col * contour * 0.9;
416    out_col = out_col * (1.0 - hatch_amt * 0.4);
417    out_col = out_col + col * v * glow * 0.22;
418
419    // Alpha carries scene presence (the V-field `structure` term) so V=0 voids are
420    // transparent and the `bg_*` backdrop shows through (ADR-0026). The present
421    // pipeline blends premultiplied-OVER: `out_col` is emitted as-is (added over the
422    // backdrop, so bright contours keep full brightness), and alpha only gates how
423    // much backdrop reveals. Over the default black backdrop this is byte-identical
424    // to the prior opaque present.
425    //
426    // `occlude` (pp.c.w) scales that coverage: how much of the backdrop the field
427    // holds out where it does have presence (ADR-0085). Reached only when no post
428    // stage is active — the chain's last stage owns the seam otherwise, and the
429    // renderer hands a literal 1.0 here.
430    return vec4<f32>(out_col, structure * pp.c.w);
431}
432"#;
433
434#[repr(C)]
435#[derive(Clone, Copy, bytemuck::Pod, bytemuck::Zeroable)]
436struct InitParams {
437    blobs: [[f32; 4]; MAX_BLOBS],
438    count: [u32; 4],
439}
440
441#[repr(C)]
442#[derive(Clone, Copy, bytemuck::Pod, bytemuck::Zeroable)]
443struct SimParams {
444    /// x: feed, y: kill, z: diffuse_u, w: diffuse_v.
445    p: [f32; 4],
446    /// xy: injection stamp center (uv), z: radius, w: amount (0 = none).
447    inj: [f32; 4],
448}
449
450#[repr(C)]
451#[derive(Clone, Copy, bytemuck::Pod, bytemuck::Zeroable)]
452struct PresentParams {
453    /// x: hue, y: contour density, z: hatch frequency (texels), w: glow.
454    a: [f32; 4],
455    /// x: color_span, y: color_center, z: saturation, w: palette_mix.
456    b: [f32; 4],
457    /// x: zoom, yz: pan (view transform, ADR-0018), w: occlude (ADR-0085).
458    c: [f32; 4],
459    /// x: palette_steps, y: palette_contour (ADR-0078); zw unused.
460    d: [f32; 4],
461}
462
463/// The GPU-side state, built lazily on first render (see the module docs).
464struct Resources {
465    field: PingPongField,
466    sim_pipeline: wgpu::RenderPipeline,
467    init_pipeline: wgpu::RenderPipeline,
468    present_pipeline: wgpu::RenderPipeline,
469    sim_uniform: wgpu::Buffer,
470    init_uniform: wgpu::Buffer,
471    present_uniform: wgpu::Buffer,
472    /// Sim/present bind groups reading texture A / texture B — selected by the
473    /// field's read side each sub-step so nothing is rebuilt on the hot path.
474    sim_bg_a: wgpu::BindGroup,
475    sim_bg_b: wgpu::BindGroup,
476    init_bg: wgpu::BindGroup,
477    present_bg_a: wgpu::BindGroup,
478    present_bg_b: wgpu::BindGroup,
479    /// The shared gradient LUT pair (A/B) the present pass samples + crossfades
480    /// (ADR-0021). A fresh pair is dirty, so a (re)build uploads on its first
481    /// frame; a preset switch re-`set`s it through the scene's `set_palette`.
482    luts: palette::LutPair,
483}
484
485impl Resources {
486    /// Create every pipeline, buffer, bind group, and the ping-pong field.
487    fn build(device: &wgpu::Device, surface_format: wgpu::TextureFormat) -> Self {
488        let init_shader = gpu::fullscreen_shader(
489            device,
490            "rd-init-shader",
491            gpu::FULLSCREEN_VS_UV_FLIPPED,
492            INIT_SHADER,
493        );
494        let sim_shader = gpu::fullscreen_shader(
495            device,
496            "rd-sim-shader",
497            gpu::FULLSCREEN_VS_UV_FLIPPED,
498            SIM_SHADER,
499        );
500        let present_shader = gpu::fullscreen_shader(
501            device,
502            "rd-present-shader",
503            gpu::FULLSCREEN_VS_UV_FLIPPED,
504            PRESENT_SHADER,
505        );
506
507        let field = PingPongField::new(device, GRID, GRID);
508
509        let sim_uniform =
510            gpu::uniform_buffer(device, "rd-sim-params", std::mem::size_of::<SimParams>());
511        let init_uniform =
512            gpu::uniform_buffer(device, "rd-init-params", std::mem::size_of::<InitParams>());
513        let present_uniform = gpu::uniform_buffer(
514            device,
515            "rd-present-params",
516            std::mem::size_of::<PresentParams>(),
517        );
518        // --- init pipeline: one uniform, writes the seed field ---
519        let init_layout = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
520            label: Some("rd-init-layout"),
521            entries: &[gpu::uniform(0, wgpu::ShaderStages::FRAGMENT)],
522        });
523        let init_bg = device.create_bind_group(&wgpu::BindGroupDescriptor {
524            label: Some("rd-init-bg"),
525            layout: &init_layout,
526            entries: &[wgpu::BindGroupEntry {
527                binding: 0,
528                resource: init_uniform.as_entire_binding(),
529            }],
530        });
531        let init_pipeline = gpu::fullscreen_pipeline(
532            device,
533            &init_shader,
534            &[&init_layout],
535            PingPongField::FORMAT,
536            wgpu::BlendState::REPLACE,
537            "rd-init",
538        );
539
540        // --- sim pipeline: uniform + input texture (textureLoad, no sampler) ---
541        let sim_layout = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
542            label: Some("rd-sim-layout"),
543            entries: &[
544                gpu::uniform(0, wgpu::ShaderStages::FRAGMENT),
545                gpu::texture(1, false),
546            ],
547        });
548        let sim_bg_a = sim_bind_group(device, &sim_layout, &sim_uniform, field.view_a());
549        let sim_bg_b = sim_bind_group(device, &sim_layout, &sim_uniform, field.view_b());
550        let sim_pipeline = gpu::fullscreen_pipeline(
551            device,
552            &sim_shader,
553            &[&sim_layout],
554            PingPongField::FORMAT,
555            wgpu::BlendState::REPLACE,
556            "rd-sim",
557        );
558
559        // --- present pipeline: input texture + filtering sampler, to surface ---
560        //
561        // `Repeat`, not `ClampToEdge` (Plan 0033 Phase 5, ADR-0034). The
562        // simulation has always been **toroidal** — `ld()` in the sim shader wraps
563        // with `((c % size) + size) % size`, so the field is seamless — and only
564        // the present sampler refused to wrap. Clamping smeared the edge row
565        // outward into vertical bars and rectangular blocks the moment `zoom`
566        // went above 1, and any real `pan_*` walked off the field, which is why
567        // all four RD presets were pinned at `zoom = 0.99` and lost the whole
568        // view lever. One address mode restores it: `pan_*` becomes a seamless
569        // infinite scroll and `zoom > 1` tiles.
570        //
571        // It also makes the Catmull-Rom taps correct at the boundary: the p0/p3
572        // taps reach one texel outside the cell, and wrapping is what the
573        // toroidal field actually means there.
574        let sampler = device.create_sampler(&wgpu::SamplerDescriptor {
575            label: Some("rd-present-sampler"),
576            address_mode_u: wgpu::AddressMode::Repeat,
577            address_mode_v: wgpu::AddressMode::Repeat,
578            address_mode_w: wgpu::AddressMode::Repeat,
579            mag_filter: wgpu::FilterMode::Linear,
580            min_filter: wgpu::FilterMode::Linear,
581            ..Default::default()
582        });
583        // Shared gradient LUTs (ADR-0021): two 256×1 textures (A/B) + a repeat
584        // sampler, in the present bind group alongside the field.
585        let luts = palette::LutPair::new(device, "rd");
586        let present_layout = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
587            label: Some("rd-present-layout"),
588            entries: &[
589                gpu::texture(0, true),
590                gpu::sampler(1),
591                gpu::uniform(2, wgpu::ShaderStages::FRAGMENT),
592                gpu::texture(3, true),
593                gpu::texture(4, true),
594                gpu::sampler(5),
595            ],
596        });
597        let present_bg_a = present_bind_group(
598            device,
599            &present_layout,
600            field.view_a(),
601            &sampler,
602            &present_uniform,
603            &luts,
604        );
605        let present_bg_b = present_bind_group(
606            device,
607            &present_layout,
608            field.view_b(),
609            &sampler,
610            &present_uniform,
611            &luts,
612        );
613        let present_pipeline = gpu::fullscreen_pipeline(
614            device,
615            &present_shader,
616            &[&present_layout],
617            surface_format,
618            // Premultiplied-alpha OVER the backdrop (ADR-0026): the scene is
619            // emissive, so `out_col` adds over the atmosphere and the present's
620            // alpha (scene presence) reveals `bg_*` in the field's voids. Over
621            // the default black backdrop this equals the prior opaque REPLACE.
622            wgpu::BlendState::PREMULTIPLIED_ALPHA_BLENDING,
623            "rd-present",
624        );
625
626        Self {
627            field,
628            sim_pipeline,
629            init_pipeline,
630            present_pipeline,
631            sim_uniform,
632            init_uniform,
633            present_uniform,
634            sim_bg_a,
635            sim_bg_b,
636            init_bg,
637            present_bg_a,
638            present_bg_b,
639            luts,
640        }
641    }
642
643    /// Encode the one-shot seed pass into the current read texture, filling the
644    /// field with the deterministic initial pattern. Run once after a (re)build.
645    fn encode_seed(&self, encoder: &mut wgpu::CommandEncoder) {
646        let mut pass = gpu::color_pass(
647            encoder,
648            "rd-seed-pass",
649            self.field.read_view(),
650            wgpu::LoadOp::Clear(wgpu::Color::BLACK),
651        );
652        pass.set_pipeline(&self.init_pipeline);
653        pass.set_bind_group(0, &self.init_bg, &[]);
654        pass.draw(0..3, 0..1);
655    }
656}
657
658/// Gray-Scott reaction-diffusion on a ping-pong field, driven by named preset
659/// parameters (ADR-0002 layer 2): `feed`/`kill` pick the regime, `flow` scales
660/// the diffusion, and a rising `inject` edge stamps a seeded blob of growth.
661pub struct ReactionDiffusionScene {
662    /// Cloned device handle (an `Arc` inside wgpu) that builds
663    /// [`Resources`] lazily on first render — see the module docs for
664    /// why.
665    device: wgpu::Device,
666    surface_format: wgpu::TextureFormat,
667    res: Option<Resources>,
668    /// The deterministic seed pattern, uploaded on the first frame after a
669    /// (re)build so a rebuilt scene restarts identically (capture determinism).
670    init_params: InitParams,
671    needs_seed: bool,
672    /// Fixed-timestep accumulator: unspent injected `dt`, drained one
673    /// [`FIXED_STEP`] at a time in [`advance`](Scene::advance).
674    fixed_step: gpu::FixedStep,
675    /// Sub-steps `advance` scheduled for the next `render` to encode.
676    pending_substeps: u32,
677    /// Seeded RNG for injection stamp positions (NFR 6); advanced only when a
678    /// stamp fires, and reset with the scene so a capture reproduces exactly.
679    stamp_rng: SeededRng,
680    /// A stamp scheduled by an `inject` rising edge for the next `render`:
681    /// (cx, cy, radius, amount). `None` when no beat fired this frame.
682    pending_stamp: Option<[f32; 4]>,
683    /// Previous frame's `inject` value, for rising-edge detection.
684    prev_inject: f32,
685    /// Shared scene clock (seconds), set by the renderer each frame.
686    time: f32,
687    feed: f32,
688    kill: f32,
689    /// Diffusion scale (multiplies both species' rates, keeping their ratio).
690    flow: f32,
691    /// This frame's injection level (bound to a beat/onset expression).
692    inject: f32,
693    /// The shared palette knobs (ADR-0021). This scene has no `brightness`.
694    colour: common::PaletteParams,
695    /// The shared view transform (ADR-0018).
696    pan: common::PanParams,
697    contour: f32,
698    hatch: f32,
699    glow: f32,
700    /// Shared palette color knobs (ADR-0021 / Plan 0020 Phase 5).
701    color_span: f32,
702    color_center: f32,
703    /// Shared view transform (ADR-0018 / Plan 0025 Phase 2): `zoom` scales the
704    /// present-pass sample window about its centre, `pan_*` offsets it.
705    zoom: f32,
706    /// How much of this field's coverage the backdrop resolves against
707    /// (ADR-0085). Set by the renderer every frame through
708    /// [`Scene::set_occlude`](super::Scene::set_occlude) — not a named param, so
709    /// `reset_params` leaves it alone.
710    occlude: f32,
711    /// The active baked palette. Held here rather than only in the resources'
712    /// [`palette::LutPair`] because the resources build lazily: `set_palette` can
713    /// arrive with `res` still `None`, and this is what seeds the pair when it
714    /// finally exists.
715    palette: Palette,
716}
717
718impl ReactionDiffusionScene {
719    /// Build the CPU-side state and compute the deterministic seed pattern. GPU
720    /// resources are deferred to the first render (module docs).
721    pub fn new(device: &wgpu::Device, surface_format: wgpu::TextureFormat) -> Self {
722        let mut init_params = InitParams {
723            blobs: [[0.0; 4]; MAX_BLOBS],
724            count: [0; 4],
725        };
726        let mut rng = SeededRng::new(SEED);
727        let n = SEED_BLOBS.min(MAX_BLOBS);
728        for slot in init_params.blobs.iter_mut().take(n) {
729            let x = rng.next_f32();
730            let y = rng.next_f32();
731            let r = rng.range(0.02, 0.045);
732            *slot = [x, y, r, 0.0];
733        }
734        init_params.count = [n as u32, 0, 0, 0];
735
736        Self {
737            device: device.clone(),
738            surface_format,
739            res: None,
740            init_params,
741            needs_seed: true,
742            fixed_step: gpu::FixedStep::new(FIXED_STEP, MAX_SUBSTEPS),
743            pending_substeps: 0,
744            stamp_rng: SeededRng::new(INJECT_SEED),
745            pending_stamp: None,
746            prev_inject: 0.0,
747            time: 0.0,
748            feed: DEFAULT_FEED,
749            kill: DEFAULT_KILL,
750            flow: DEFAULT_FLOW,
751            inject: 0.0,
752            colour: common::PaletteParams::new(DEFAULT_HUE, common::DEFAULT_BRIGHTNESS),
753            pan: common::PanParams::default(),
754            contour: DEFAULT_CONTOUR,
755            hatch: DEFAULT_HATCH,
756            glow: DEFAULT_GLOW,
757            color_span: DEFAULT_COLOR_SPAN,
758            color_center: DEFAULT_COLOR_CENTER,
759            zoom: DEFAULT_ZOOM,
760            occlude: crate::render::post::DEFAULT_OCCLUDE,
761            palette: Palette::default_spectrum(),
762        }
763    }
764}
765
766fn sim_bind_group(
767    device: &wgpu::Device,
768    layout: &wgpu::BindGroupLayout,
769    uniform: &wgpu::Buffer,
770    input: &wgpu::TextureView,
771) -> wgpu::BindGroup {
772    device.create_bind_group(&wgpu::BindGroupDescriptor {
773        label: Some("rd-sim-bg"),
774        layout,
775        entries: &[
776            wgpu::BindGroupEntry {
777                binding: 0,
778                resource: uniform.as_entire_binding(),
779            },
780            wgpu::BindGroupEntry {
781                binding: 1,
782                resource: wgpu::BindingResource::TextureView(input),
783            },
784        ],
785    })
786}
787
788fn present_bind_group(
789    device: &wgpu::Device,
790    layout: &wgpu::BindGroupLayout,
791    input: &wgpu::TextureView,
792    sampler: &wgpu::Sampler,
793    uniform: &wgpu::Buffer,
794    luts: &palette::LutPair,
795) -> wgpu::BindGroup {
796    let [lut_a, lut_b, lut_sampler] = luts.bind_entries(3, 4, 5);
797    device.create_bind_group(&wgpu::BindGroupDescriptor {
798        label: Some("rd-present-bg"),
799        layout,
800        entries: &[
801            wgpu::BindGroupEntry {
802                binding: 0,
803                resource: wgpu::BindingResource::TextureView(input),
804            },
805            wgpu::BindGroupEntry {
806                binding: 1,
807                resource: wgpu::BindingResource::Sampler(sampler),
808            },
809            wgpu::BindGroupEntry {
810                binding: 2,
811                resource: uniform.as_entire_binding(),
812            },
813            lut_a,
814            lut_b,
815            lut_sampler,
816        ],
817    })
818}
819
820/// Parameter vocabulary — see [`fragment_field::PARAMS`](super::fragment_field::PARAMS).
821/// **Keep in sync with `set_param` below.**
822pub const PARAMS: &[ParamSpec] = &[
823    ParamSpec {
824        name: "feed",
825        default: 0.0367,
826        range: Some([0.01, 0.09]),
827        doc: "Feed rate of the reaction - with `kill`, it is what decides whether you get spots, stripes or mitosis.",
828        kind: ParamKind::Modal,
829    },
830    ParamSpec {
831        name: "kill",
832        default: 0.0649,
833        range: Some([0.03, 0.07]),
834        doc: "Kill rate of the reaction; small moves here change the pattern's whole character.",
835        kind: ParamKind::Modal,
836    },
837    ParamSpec {
838        name: "flow",
839        default: 1.0,
840        range: Some([0.0, 4.0]),
841        doc: "How fast the simulation advances per second.",
842        kind: ParamKind::Modal,
843    },
844    ParamSpec {
845        name: "inject",
846        default: 0.0,
847        range: Some([0.0, 1.0]),
848        doc: "Drops fresh reagent into the field, which is how a beat seeds new growth.",
849        kind: ParamKind::Modal,
850    },
851    crate::render::scenes::common::hue(DEFAULT_HUE),
852    ParamSpec {
853        name: "contour",
854        default: 6.0,
855        range: Some([0.0, 24.0]),
856        doc: "How many bands the concentration is drawn as, as a real density: a fraction \
857               slides the whole set of iso-lines. 0 is a smooth gradient.",
858        kind: ParamKind::Modal,
859    },
860    ParamSpec {
861        name: "hatch",
862        default: 5.0,
863        range: Some([0.0, 24.0]),
864        doc: "Density of the hatching drawn along the concentration gradient.",
865        kind: ParamKind::Modal,
866    },
867    ParamSpec {
868        name: "glow",
869        default: 1.0,
870        range: Some([0.0, 2.0]),
871        doc: "Overall light the field emits.",
872        kind: ParamKind::Modal,
873    },
874    ParamSpec {
875        name: "color_span",
876        default: 0.85,
877        range: Some([0.0, 1.0]),
878        doc: "How much of the palette the concentration range covers.",
879        kind: ParamKind::Modal,
880    },
881    ParamSpec {
882        name: "color_center",
883        default: 0.0,
884        range: Some([-1.0, 1.0]),
885        doc: "Shifts which concentration lands in the middle of the palette.",
886        kind: ParamKind::Modal,
887    },
888    crate::render::scenes::common::SATURATION,
889    crate::render::scenes::common::PALETTE_MIX,
890    crate::render::scenes::common::PALETTE_STEPS,
891    crate::render::scenes::common::PALETTE_CONTOUR,
892    crate::render::scenes::common::zoom(DEFAULT_ZOOM),
893    crate::render::scenes::common::PAN_X,
894    crate::render::scenes::common::PAN_Y,
895];
896
897impl Scene for ReactionDiffusionScene {
898    fn name(&self) -> &'static str {
899        "reaction diffusion"
900    }
901
902    fn advance(&mut self, dt: f32) {
903        // Drain the accumulator one fixed sub-step at a time, clamped so a long
904        // stall can't queue unbounded work (ADR-0012). The sub-`FIXED_STEP`
905        // remainder carries to the next frame; a clamp drops the excess backlog
906        // so the sim slows rather than races to catch up. Shared with the
907        // attractor scene, which drains an identical accumulator.
908        self.pending_substeps = self.fixed_step.advance(dt);
909    }
910
911    fn set_time(&mut self, time: f32) {
912        self.time = time;
913    }
914
915    fn set_occlude(&mut self, occlude: f32) {
916        self.occlude = occlude;
917    }
918
919    fn set_palette(&mut self, palette: &Palette) {
920        // Uploaded to the present LUT textures in `render` (deferred — resources
921        // build lazily on first render). Cheap array copy, off the hot path.
922        self.palette = palette.clone();
923        if let Some(res) = self.res.as_mut() {
924            res.luts.set(palette);
925        }
926    }
927
928    fn reset_params(&mut self) {
929        self.feed = DEFAULT_FEED;
930        self.kill = DEFAULT_KILL;
931        self.flow = DEFAULT_FLOW;
932        self.inject = 0.0;
933        self.colour.reset();
934        self.pan.reset();
935        self.contour = DEFAULT_CONTOUR;
936        self.hatch = DEFAULT_HATCH;
937        self.glow = DEFAULT_GLOW;
938        self.color_span = DEFAULT_COLOR_SPAN;
939        self.color_center = DEFAULT_COLOR_CENTER;
940        self.zoom = DEFAULT_ZOOM;
941    }
942
943    fn set_param(&mut self, name: &str, value: f32) {
944        // The shared param blocks first, this scene's own names after
945        // (`scenes::common`).
946        if self.colour.set(name, value) || self.pan.set(name, value) {
947            return;
948        }
949        // ADR-0002 layer 2 knobs. `feed`/`kill` pick the regime; `flow` scales
950        // the diffusion; `inject` is a beat/onset level whose rising edge stamps
951        // a seed (edge detected in `update`). `hue`/`contour`/`hatch`/`glow`
952        // drive the iso-contour present look (Phase 4).
953        match name {
954            "feed" => self.feed = value,
955            "kill" => self.kill = value,
956            "flow" => self.flow = value,
957            "inject" => self.inject = value,
958            "contour" => self.contour = value,
959            "hatch" => self.hatch = value,
960            "glow" => self.glow = value,
961            "color_span" => self.color_span = value,
962            "color_center" => self.color_center = value,
963            "zoom" => self.zoom = value,
964            _ => {}
965        }
966    }
967
968    fn update(&mut self, _frame: &AnalysisFrame) {
969        // Rising-edge detect on `inject` (a beat/onset expression): schedule one
970        // stamp at the next seeded position. Edge-triggered so a sustained beat
971        // flag doesn't stamp every frame; deterministic because the position
972        // comes from the seeded, capture-reset `stamp_rng` (NFR 6).
973        if self.inject >= INJECT_THRESHOLD && self.prev_inject < INJECT_THRESHOLD {
974            let cx = self.stamp_rng.next_f32();
975            let cy = self.stamp_rng.next_f32();
976            self.pending_stamp = Some([cx, cy, INJECT_RADIUS, INJECT_AMOUNT]);
977        }
978        self.prev_inject = self.inject;
979    }
980
981    fn render(
982        &mut self,
983        queue: &wgpu::Queue,
984        encoder: &mut wgpu::CommandEncoder,
985        view: &wgpu::TextureView,
986        _aspect: f32,
987    ) {
988        // Build GPU resources on first use (module docs). A fresh pair's textures
989        // are empty and hold the default palette, so hand it the one the scene is
990        // actually carrying; the upload happens in the flush below.
991        if self.res.is_none() {
992            let mut built = Resources::build(&self.device, self.surface_format);
993            built.luts.set(&self.palette);
994            self.res = Some(built);
995        }
996        let Self {
997            res,
998            init_params,
999            needs_seed,
1000            pending_substeps,
1001            pending_stamp,
1002            feed,
1003            kill,
1004            flow,
1005            contour,
1006            hatch,
1007            glow,
1008            color_span,
1009            color_center,
1010            colour,
1011            zoom,
1012            pan,
1013            occlude,
1014            ..
1015        } = self;
1016        let Some(res) = res.as_mut() else {
1017            return;
1018        };
1019
1020        // Upload the active palette LUTs (A + B) on a preset switch or a fresh
1021        // build — off the hot path, once per change.
1022        res.luts.flush(queue);
1023
1024        queue.write_buffer(
1025            &res.present_uniform,
1026            0,
1027            bytemuck::bytes_of(&PresentParams {
1028                a: [colour.hue, *contour, *hatch, *glow],
1029                b: [*color_span, *color_center, colour.saturation, colour.mix],
1030                c: [*zoom, pan.x, pan.y, *occlude],
1031                d: [
1032                    palette::band_steps(colour.steps),
1033                    palette::band_contour(colour.contour),
1034                    0.0,
1035                    0.0,
1036                ],
1037            }),
1038        );
1039
1040        // A beat scheduled a stamp this frame (consumed here): the sim shader
1041        // applies it on every sub-step, so V saturates at the stamp. `[0; 4]`
1042        // means no injection.
1043        let inj = pending_stamp.take().unwrap_or([0.0; 4]);
1044        queue.write_buffer(
1045            &res.sim_uniform,
1046            0,
1047            bytemuck::bytes_of(&SimParams {
1048                // `flow` scales both diffusion rates, keeping the 2:1 ratio (so
1049                // the pattern coarsens/tightens without changing regime).
1050                p: [*feed, *kill, DIFFUSE_U * *flow, DIFFUSE_V * *flow],
1051                inj,
1052            }),
1053        );
1054
1055        // One-shot deterministic seed on the first frame after a (re)build.
1056        if *needs_seed {
1057            queue.write_buffer(&res.init_uniform, 0, bytemuck::bytes_of(init_params));
1058            res.encode_seed(encoder);
1059            *needs_seed = false;
1060        }
1061
1062        // Run the sub-steps the accumulator scheduled this frame (Phase 2). Each
1063        // reads the current field and writes the other texture, then swaps.
1064        for _ in 0..*pending_substeps {
1065            let sim_bg = if res.field.reading_a() {
1066                &res.sim_bg_a
1067            } else {
1068                &res.sim_bg_b
1069            };
1070            {
1071                // The fullscreen sim pass overwrites every texel.
1072                let mut pass = gpu::color_pass(
1073                    encoder,
1074                    "rd-sim-pass",
1075                    res.field.write_view(),
1076                    wgpu::LoadOp::Clear(wgpu::Color::BLACK),
1077                );
1078                pass.set_pipeline(&res.sim_pipeline);
1079                pass.set_bind_group(0, sim_bg, &[]);
1080                pass.draw(0..3, 0..1);
1081            }
1082            res.field.swap();
1083        }
1084
1085        // Present the latest field to the surface.
1086        let present_bg = if res.field.reading_a() {
1087            &res.present_bg_a
1088        } else {
1089            &res.present_bg_b
1090        };
1091        // Load over the engine backdrop (ADR-0018). The present is **not**
1092        // opaque: it writes premultiplied alpha, with the V-field's `structure`
1093        // term as coverage, so the coral's voids reveal the `bg_*` gradient
1094        // underneath. Over the default black backdrop a premultiplied Load is
1095        // arithmetically an opaque REPLACE, which is why the golden does not
1096        // move.
1097        let mut pass = gpu::color_pass(encoder, "rd-present-pass", view, wgpu::LoadOp::Load);
1098        pass.set_pipeline(&res.present_pipeline);
1099        pass.set_bind_group(0, present_bg, &[]);
1100        pass.draw(0..3, 0..1);
1101    }
1102}