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