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