Skip to main content

rlx_core/render/scenes/warp_mesh/
mod.rs

1//! Warp mesh: a per-vertex UV grid that resamples the previous frame
2//! (ADR-0113).
3//!
4//! # What it generalizes
5//!
6//! ADR-0048 gave the engine *one* affine transform through which an accumulation
7//! reads its own past: a single zoom, rotation and translation applied
8//! identically to every texel. This scene is that transform **per vertex**. The
9//! frame is covered by a grid of cells; each of its vertices carries its own
10//! `zoom`/`rot`/`cx`/`cy`/`dx`/`dy`/ `sx`/`sy`/`warp`, and the rasterizer
11//! interpolates between them — so the past can spiral in one corner and drift in
12//! another, which no single affine can express.
13//!
14//! Those nine outputs come from a preset's `[per_vertex]` table, whose bindings
15//! are evaluated once per vertex per frame with `x`, `y`, `rad` and `ang` bound
16//! to that vertex's own position. A preset that declares no such table gets the
17//! scalar params of the same names applied everywhere, which is exactly ADR-0048's
18//! single shared transform — so the idiom degrades to the one it generalizes.
19//!
20//! # The grid is a resolution, not a shape
21//!
22//! ADR-0037, and this is the most likely place in the engine to get it wrong,
23//! because here the grid is *user-visible*: a preset names `[mesh] x` and `[mesh]
24//! y`, and they are quantized and clamped to a tier capacity. **Every
25//! screen-destined coordinate here takes its aspect from the render target** — the
26//! `rad`/`ang` the per-vertex program reads (computed in `vertex_position`), and
27//! the isotropic space the source-uv transform works in (computed in the vertex
28//! shader from a uniform the CPU fills with the *target's* aspect).
29//! `meshx`/`meshy` appear in neither. A `f32` aspect derived from the mesh size
30//! would be the bug.
31//!
32//! # Three passes
33//!
34//! 1. **warp** — the mesh is drawn into the write half of a ping-pong field,
35//!    sampling the read half through each vertex's source uv and scaling it by
36//!    `decay^dt`. This is the only pass that is not fullscreen.
37//! 2. **deposit** — a fullscreen pass adding this frame's light onto the warped
38//!    past: a palette-coloured gaussian ring with optional angular arms. It runs
39//!    *after* the warp, so the light it lays down is "now" and is warped from the
40//!    next frame onward.
41//! 3. **present** — a fullscreen pass compositing the field over the backdrop,
42//!    premultiplied (ADR-0026), scaled by `brightness` and `occlude`.
43//!
44//! All three rates are **per second** (ADR-0019): `decay`, `zoom`, `sx`, `sy` are
45//! factors per second and `rot`/`dx`/`dy`/`warp`/`deposit` are amounts per second,
46//! so the look is identical at 60 Hz and 144 Hz.
47//!
48//! GPU resources are built lazily on first render, for the reason
49//! `reaction_diffusion.rs` documents: a capture that never activates this scene
50//! never builds this scene's pipelines, so it cannot perturb another scene's
51//! render on the DX12 WARP software adapter.
52
53// Hot-path panic-denial pragma (Plan 0002 Phase 2, extended to scenes by Plan
54// 0003 Phase 0). Encodes its passes every displayed frame.
55#![deny(
56    clippy::unwrap_used,
57    clippy::expect_used,
58    clippy::indexing_slicing,
59    clippy::panic,
60    clippy::unreachable
61)]
62
63use crate::dsp::AnalysisFrame;
64use crate::render::feedback::PingPongField;
65use crate::render::gpu;
66use crate::render::palette::{self, Palette};
67
68use super::common;
69use super::{PerVertexBound, Phase, Scene, lines};
70
71// The five concerns of this scene, taking the shape `particles/` already has.
72// `shaders` is the WGSL and the POD blocks,
73// `mesh` the grid arithmetic and the CPU-side vertex assembly, `resources` the
74// wgpu objects, `encode` the per-frame stages; what stays here is the scene, its
75// `Scene` impl and the param surface.
76mod encode;
77mod mesh;
78mod resources;
79mod shaders;
80
81// The grid bounds and the three grid functions were `pub` here before the split
82// and are named from outside `warp_mesh` -- the renderer sizes its per-vertex
83// scratch off `vertex_count` and evaluates bindings at `vertex_position`, and
84// the preset schema validates a `[mesh]` table against the bounds -- so they
85// keep their old path rather than gaining a `mesh::` segment.
86pub use mesh::{DEFAULT_MESH, MAX_MESH, MIN_MESH, clamp_grid, vertex_count, vertex_position};
87
88use crate::render::scenes::{ParamGroup, ParamKind, ParamSpec, default_of};
89use mesh::*;
90use resources::*;
91use shaders::*;
92
93/// The most vertices the filled-shape buffer holds.
94///
95/// Four shapes at MilkDrop's own limits — 1 024 instances of a 100-sided
96/// polygon each — would be 1.2 M triangles, which is not a picture. This is the
97/// bound that keeps the buffer a fixed allocation: past it the extra triangles
98/// are dropped, which degrades a preset that asks for more rather than letting it
99/// grow a buffer on the render thread.
100pub const MAX_SHAPE_VERTICES: usize = 96 * 1024;
101
102/// The nine outputs a `[per_vertex]` table may bind, in the order this scene
103/// stores them. **Keep in step with `PER_VERTEX_DEFAULTS` and
104/// `WarpMeshScene::set_per_vertex`.**
105///
106/// The same nine names are ordinary scalar [`PARAMS`] as well, and that is the
107/// design: a scalar sets the output for the whole mesh, and a `[per_vertex]`
108/// binding of the same name **replaces** it vertex by vertex. A preset therefore
109/// starts from one shared transform and opts into a spatially-varying one output
110/// at a time.
111pub const PER_VERTEX_PARAMS: &[ParamSpec] = &[
112    ParamSpec {
113        name: "zoom",
114        default: 1.0,
115        range: Some([0.5, 2.0]),
116        doc: "Scale the previous frame is resampled at, per vertex; above 1 the past is magnified and the image travels outward.",
117        kind: ParamKind::Modal,
118        group: ParamGroup::Motion,
119        main: true,
120    },
121    ParamSpec {
122        name: "rot",
123        default: 0.0,
124        range: Some([-1.0, 1.0]),
125        doc: "Turns per second the resample is rotated by, per vertex.",
126        kind: ParamKind::Modal,
127        group: ParamGroup::Motion,
128        main: true,
129    },
130    ParamSpec {
131        name: "cx",
132        default: 0.5,
133        range: Some([0.0, 1.0]),
134        doc: "Horizontal point the per-vertex zoom and rotation pivot about, in uv.",
135        kind: ParamKind::Modal,
136        group: ParamGroup::Shape,
137        main: false,
138    },
139    ParamSpec {
140        name: "cy",
141        default: 0.5,
142        range: Some([0.0, 1.0]),
143        doc: "Vertical point the per-vertex zoom and rotation pivot about, in uv.",
144        kind: ParamKind::Modal,
145        group: ParamGroup::Shape,
146        main: false,
147    },
148    ParamSpec {
149        name: "dx",
150        default: 0.0,
151        range: Some([-1.0, 1.0]),
152        doc: "Sideways offset of the resample, in frame widths.",
153        kind: ParamKind::Modal,
154        group: ParamGroup::Motion,
155        main: false,
156    },
157    ParamSpec {
158        name: "dy",
159        default: 0.0,
160        range: Some([-1.0, 1.0]),
161        doc: "Vertical offset of the resample, in frame heights.",
162        kind: ParamKind::Modal,
163        group: ParamGroup::Motion,
164        main: false,
165    },
166    ParamSpec {
167        name: "sx",
168        default: 1.0,
169        range: Some([0.5, 2.0]),
170        doc: "Horizontal stretch of the resample, independently of `zoom`.",
171        kind: ParamKind::Modal,
172        group: ParamGroup::Shape,
173        main: false,
174    },
175    ParamSpec {
176        name: "sy",
177        default: 1.0,
178        range: Some([0.5, 2.0]),
179        doc: "Vertical stretch of the resample, independently of `zoom`.",
180        kind: ParamKind::Modal,
181        group: ParamGroup::Shape,
182        main: false,
183    },
184    ParamSpec {
185        name: "warp",
186        default: 0.0,
187        range: Some([0.0, 2.0]),
188        doc: "Amplitude of the travelling ripple added to the resample.",
189        kind: ParamKind::Modal,
190        group: ParamGroup::Shape,
191        main: true,
192    },
193];
194
195/// Each [`PER_VERTEX_PARAMS`] entry's identity value, positionally.
196///
197/// The identity of the whole roster is "the past sits still": unit scale, no
198/// rotation, no drift, centred, no procedural warp — the same identity
199/// [`Transform::IDENTITY`](crate::render::feedback::Transform::IDENTITY) names for
200/// the affine this generalizes.
201const PER_VERTEX_DEFAULTS: [f32; 9] = [1.0, 0.0, 0.5, 0.5, 0.0, 0.0, 1.0, 1.0, 0.0];
202
203/// How many per-vertex outputs there are — typed off the roster so the arrays
204/// below cannot drift from it.
205const OUTPUTS: usize = PER_VERTEX_DEFAULTS.len();
206const _: () = assert!(
207    OUTPUTS == PER_VERTEX_PARAMS.len(),
208    "the per-vertex roster and its defaults must be the same length"
209);
210
211/// `decay` default: 0.72 of the past survives each second, so a deposited streak
212/// fades over roughly a second and a half.
213const DEFAULT_DECAY: f32 = default_of(PARAMS, "decay");
214/// The most of the past a preset may keep per second. Not 1.0: at exactly 1 the
215/// field is a perfect integrator and any deposit accumulates without bound, which
216/// in linear light is a slowly whitening frame rather than a clip. Mirrors the
217/// `MAX_FADE` ceiling the trails accumulation takes for the same reason.
218const MAX_DECAY: f32 = 0.995;
219
220/// The `softness` every `warp_mesh` stroke is drawn at — the waveform, every
221/// custom wave, every shape outline, both borders and the motion grid, which all
222/// reach the line fragment through one [`LineRenderer::draw_split`](lines::LineRenderer::draw_split)
223/// call.
224///
225/// **Pinned at `1.0` — the pre-Plan-0114 profile — and it does NOT follow**
226/// [`lines::DEFAULT_SOFTNESS`], which Plan 0114 Phase 5 moves (ADR-0124,
227/// Alternative D0). The two constants exist because there are two judges: the
228/// four line families answer to that plan's look gate, and this surface answers
229/// to **`foo_vis_milk2`**, ADR-0113's fidelity reference, against which the
230/// conversion has already been judged side by side. `draw.rs`'s stroke widths
231/// were chosen *through* this profile — a thick MilkDrop line, drawn there as two
232/// or four offset passes, reproduced here as one stroke of twice the width — so a
233/// number picked by that gate answers a question nobody asked of this surface.
234///
235/// It is also the regime where the profile's `fwidth` term stops describing a
236/// real gradient: `draw.rs`'s `THIN` is a **1.35 px** half-width at 1080p and
237/// **1.0 px** at 1280x800. The pin stays byte-identical there only because the
238/// edge term is capped at 1.0 — see the shared profile in the line renderer.
239///
240/// **Plan 0114 Phase 8 is what sets this**: it puts the reference rig beside a
241/// spread of values and returns a number, and `1.0` — keeping the pin as it
242/// stands — is a legitimate outcome that closes the question rather than a null
243/// result. Until it runs, the pin holds the profile the conversion was judged
244/// under.
245pub const MILKDROP_SOFTNESS: f32 = 1.0;
246
247/// Procedural-warp defaults — the spatial scale of the four sinusoids and how
248/// fast they animate. `1.0` is MilkDrop's own unit scale.
249const DEFAULT_WARP_SCALE: f32 = default_of(PARAMS, "warp_scale");
250const DEFAULT_WARP_SPEED: f32 = default_of(PARAMS, "warp_speed");
251
252/// Deposit defaults: a soft blob at the centre, bright enough to see and small
253/// enough to be dragged into structure rather than filling the frame.
254const DEFAULT_DEPOSIT: f32 = default_of(PARAMS, "deposit");
255const DEFAULT_DEPOSIT_CENTRE: f32 = default_of(PARAMS, "deposit_x");
256const DEFAULT_DEPOSIT_RADIUS: f32 = default_of(PARAMS, "deposit_radius");
257const DEFAULT_DEPOSIT_WIDTH: f32 = default_of(PARAMS, "deposit_width");
258const DEFAULT_DEPOSIT_ARMS: f32 = default_of(PARAMS, "deposit_arms");
259const DEFAULT_DEPOSIT_TWIST: f32 = default_of(PARAMS, "deposit_twist");
260const DEFAULT_DEPOSIT_SPIN: f32 = default_of(PARAMS, "deposit_spin");
261
262/// **MilkDrop's composite roster**, in the order [`COMPOSITE_PARAMS`] declares
263/// it — the six flags and one multiplier its format carries, reachable from a
264/// preset and written by a converted bundle's per-frame program.
265///
266/// They are here rather than warned about because the corpus says so. Counted
267/// over all 10 347 files, 2026-08-16:
268///
269/// ```text
270/// bTexWrap=1       6 014   58 %
271/// bDarken=1        3 686   36 %
272/// bBrighten=1      1 445   14 %
273/// bDarkenCenter=1    711    7 %
274/// bInvert=1          576    6 %
275/// bSolarize=1        445    4 %
276/// ```
277///
278/// Each is one `select` in a shader, and between them they reach most of the
279/// library.
280///
281/// **The video echo joined them in Plan 0109 Phase 3**, and it is the one member
282/// that is not a remap: `echo_alpha`/`echo_zoom`/`echo_orient` blend a second
283/// sampled copy of the finished field over the first. Only 252 files (2.4 %) set
284/// a non-zero echo alpha, which is why it waited — but where it appears it is
285/// load-bearing rather than decorative, and *Songflower (Moss Posy)*'s woven
286/// lattice is only one family of bars without it.
287pub const COMPOSITE_PARAMS: &[ParamSpec] = &[
288    ParamSpec {
289        name: "gamma",
290        default: 1.0,
291        range: Some([0.25, 4.0]),
292        doc: "Shapes the field's tone curve on its way out; below 1 lifts the mid tones.",
293        kind: ParamKind::Modal,
294        group: ParamGroup::Light,
295        main: false,
296    },
297    ParamSpec {
298        name: "wrap",
299        default: DEFAULT_COMPOSITE_FLAG,
300        range: Some([0.0, 1.0]),
301        doc: "Wraps a sample that leaves the frame back in at the opposite edge, instead of clamping.",
302        kind: ParamKind::Modal,
303        group: ParamGroup::Shape,
304        main: false,
305    },
306    ParamSpec {
307        name: "darken_center",
308        default: DEFAULT_COMPOSITE_FLAG,
309        range: Some([0.0, 1.0]),
310        doc: "Pulls brightness down toward the middle of the frame.",
311        kind: ParamKind::Modal,
312        group: ParamGroup::Light,
313        main: false,
314    },
315    ParamSpec {
316        name: "brighten",
317        default: DEFAULT_COMPOSITE_FLAG,
318        range: Some([0.0, 1.0]),
319        doc: "Lifts the field's bright end, MilkDrop's own brighten switch.",
320        kind: ParamKind::Modal,
321        group: ParamGroup::Light,
322        main: false,
323    },
324    ParamSpec {
325        name: "darken",
326        default: DEFAULT_COMPOSITE_FLAG,
327        range: Some([0.0, 1.0]),
328        doc: "Pushes the field's dark end down, MilkDrop's own darken switch.",
329        kind: ParamKind::Modal,
330        group: ParamGroup::Light,
331        main: false,
332    },
333    ParamSpec {
334        name: "solarize",
335        default: DEFAULT_COMPOSITE_FLAG,
336        range: Some([0.0, 1.0]),
337        doc: "Inverts the field above its midpoint, so highlights fold back into shadow.",
338        kind: ParamKind::Modal,
339        group: ParamGroup::Colour,
340        main: false,
341    },
342    ParamSpec {
343        name: "invert",
344        default: DEFAULT_COMPOSITE_FLAG,
345        range: Some([0.0, 1.0]),
346        doc: "Inverts the whole field.",
347        kind: ParamKind::Modal,
348        group: ParamGroup::Colour,
349        main: false,
350    },
351    ParamSpec {
352        name: "echo_alpha",
353        default: 0.0,
354        range: Some([0.0, 1.0]),
355        doc: "How strongly a second, scaled copy of the field is blended over the first.",
356        kind: ParamKind::Modal,
357        group: ParamGroup::Post,
358        main: false,
359    },
360    ParamSpec {
361        name: "echo_zoom",
362        default: 1.0,
363        range: Some([0.25, 4.0]),
364        doc: "How much larger or smaller that echoed copy is.",
365        kind: ParamKind::Modal,
366        group: ParamGroup::Post,
367        main: false,
368    },
369    ParamSpec {
370        name: "echo_orient",
371        default: 0.0,
372        range: Some([0.0, 3.0]),
373        doc: "Which way the echoed copy is flipped before it is blended.",
374        kind: ParamKind::Structural,
375        group: ParamGroup::Post,
376        main: false,
377    },
378];
379
380/// `gamma` default — MilkDrop's `fGammaAdj` at unity.
381const DEFAULT_GAMMA: f32 = default_of(PARAMS, "gamma");
382/// The other six default off, which is the identity for each.
383const DEFAULT_COMPOSITE_FLAG: f32 = 0.0;
384/// The echo's own defaults — no second copy, at unit zoom and unflipped, which
385/// is the identity and is MilkDrop's own resting value for each.
386const DEFAULT_ECHO_ALPHA: f32 = default_of(PARAMS, "echo_alpha");
387const DEFAULT_ECHO_ZOOM: f32 = default_of(PARAMS, "echo_zoom");
388const DEFAULT_ECHO_ORIENT: f32 = default_of(PARAMS, "echo_orient");
389
390/// MilkDrop's `nVideoEchoOrientation` as its two flip bits — `1` flips x, `2`
391/// flips y, `3` both.
392///
393/// **This is where a continuous value becomes one of four states.** The source
394/// format stores an integer, but it reaches here as an `f32` that a per-frame
395/// program can compute and that a preset's own smoothing can sweep *between*
396/// states; deciding what `1.5` means in the shader would mean deciding it four
397/// times. Out of range **wraps** rather than clamping, so a preset animating the
398/// orientation by counting gets a cycle rather than a value stuck at `3`. Total
399/// on every input, `NaN` included, because a non-finite orientation is not a
400/// reason to lose the echo.
401fn echo_orientation(v: f32) -> u8 {
402    if !v.is_finite() {
403        return 0;
404    }
405    match v.round().rem_euclid(4.0) as i32 {
406        1 => 1,
407        2 => 2,
408        3 => 3,
409        _ => 0,
410    }
411}
412
413/// How much `darken_center` takes out of the middle at full strength.
414///
415/// MilkDrop draws a fixed alpha there rather than exposing an amount; this is
416/// that gesture as a multiplier, matched by eye to the reference's blob. A
417/// preset binding a fraction gets a proportional one, which the format cannot
418/// express and costs nothing to allow.
419const DARKEN_CENTER_STRENGTH: f32 = 0.22;
420
421/// Colour defaults (ADR-0021), matching the shared vocabulary every other
422/// scene uses.
423const DEFAULT_HUE: f32 = 0.0;
424const DEFAULT_COLOR_SPAN: f32 = default_of(PARAMS, "color_span");
425const DEFAULT_COLOR_CENTER: f32 = default_of(PARAMS, "color_center");
426const DEFAULT_BRIGHTNESS: f32 = 1.0;
427
428/// Parameter vocabulary — see [`fragment_field::PARAMS`](super::fragment_field::PARAMS).
429/// **Keep in sync with `set_param` below.**
430pub const PARAMS: &[ParamSpec] = &[
431    ParamSpec {
432        name: "zoom",
433        default: 1.0,
434        range: Some([0.5, 2.0]),
435        doc: "Scale the previous frame is resampled at, per vertex; above 1 the past is magnified and the image travels outward.",
436        kind: ParamKind::Modal,
437        group: ParamGroup::Motion,
438        main: true,
439    },
440    ParamSpec {
441        name: "rot",
442        default: 0.0,
443        range: Some([-1.0, 1.0]),
444        doc: "Turns per second the resample is rotated by, per vertex.",
445        kind: ParamKind::Modal,
446        group: ParamGroup::Motion,
447        main: true,
448    },
449    ParamSpec {
450        name: "cx",
451        default: 0.5,
452        range: Some([0.0, 1.0]),
453        doc: "Horizontal point the per-vertex zoom and rotation pivot about, in uv.",
454        kind: ParamKind::Modal,
455        group: ParamGroup::Shape,
456        main: false,
457    },
458    ParamSpec {
459        name: "cy",
460        default: 0.5,
461        range: Some([0.0, 1.0]),
462        doc: "Vertical point the per-vertex zoom and rotation pivot about, in uv.",
463        kind: ParamKind::Modal,
464        group: ParamGroup::Shape,
465        main: false,
466    },
467    ParamSpec {
468        name: "dx",
469        default: 0.0,
470        range: Some([-1.0, 1.0]),
471        doc: "Sideways offset of the resample, in frame widths.",
472        kind: ParamKind::Modal,
473        group: ParamGroup::Motion,
474        main: false,
475    },
476    ParamSpec {
477        name: "dy",
478        default: 0.0,
479        range: Some([-1.0, 1.0]),
480        doc: "Vertical offset of the resample, in frame heights.",
481        kind: ParamKind::Modal,
482        group: ParamGroup::Motion,
483        main: false,
484    },
485    ParamSpec {
486        name: "sx",
487        default: 1.0,
488        range: Some([0.5, 2.0]),
489        doc: "Horizontal stretch of the resample, independently of `zoom`.",
490        kind: ParamKind::Modal,
491        group: ParamGroup::Shape,
492        main: false,
493    },
494    ParamSpec {
495        name: "sy",
496        default: 1.0,
497        range: Some([0.5, 2.0]),
498        doc: "Vertical stretch of the resample, independently of `zoom`.",
499        kind: ParamKind::Modal,
500        group: ParamGroup::Shape,
501        main: false,
502    },
503    ParamSpec {
504        name: "warp",
505        default: 0.0,
506        range: Some([0.0, 2.0]),
507        doc: "Amplitude of the travelling ripple added to the resample.",
508        kind: ParamKind::Modal,
509        group: ParamGroup::Shape,
510        main: true,
511    },
512    ParamSpec {
513        name: "warp_scale",
514        default: 1.0,
515        range: Some([0.1, 4.0]),
516        doc: "Spatial frequency of the ripple; higher makes it finer.",
517        kind: ParamKind::Modal,
518        group: ParamGroup::Shape,
519        main: false,
520    },
521    ParamSpec {
522        name: "warp_speed",
523        default: 1.0,
524        range: Some([0.0, 4.0]),
525        doc: "How fast the ripple travels, as a multiple of its base rate.",
526        kind: ParamKind::Modal,
527        group: ParamGroup::Motion,
528        main: false,
529    },
530    ParamSpec {
531        name: "decay",
532        default: 0.72,
533        range: Some([0.0, 1.0]),
534        doc: "How much of the field survives each second, which is what sets the trail's length.",
535        kind: ParamKind::Modal,
536        group: ParamGroup::Light,
537        main: true,
538    },
539    ParamSpec {
540        name: "deposit",
541        default: 1.6,
542        range: Some([0.0, 8.0]),
543        doc: "How much light the source figure adds into the field each frame.",
544        kind: ParamKind::Modal,
545        group: ParamGroup::Shape,
546        main: true,
547    },
548    ParamSpec {
549        name: "deposit_x",
550        default: 0.5,
551        range: Some([0.0, 1.0]),
552        doc: "Horizontal position of the deposited figure, in uv.",
553        kind: ParamKind::Modal,
554        group: ParamGroup::Shape,
555        main: false,
556    },
557    ParamSpec {
558        name: "deposit_y",
559        default: 0.5,
560        range: Some([0.0, 1.0]),
561        doc: "Vertical position of the deposited figure, in uv.",
562        kind: ParamKind::Modal,
563        group: ParamGroup::Shape,
564        main: false,
565    },
566    ParamSpec {
567        name: "deposit_radius",
568        default: 0.45,
569        range: Some([0.0, 1.0]),
570        doc: "Radius of the deposited ring.",
571        kind: ParamKind::Modal,
572        group: ParamGroup::Shape,
573        main: false,
574    },
575    ParamSpec {
576        name: "deposit_width",
577        default: 0.11,
578        range: Some([0.0, 0.5]),
579        doc: "How thick that ring is; narrow reads as a wire, wide as a disc.",
580        kind: ParamKind::Modal,
581        group: ParamGroup::Shape,
582        main: false,
583    },
584    ParamSpec {
585        name: "deposit_arms",
586        default: 0.0,
587        range: Some([0.0, 16.0]),
588        doc: "How many arms the ring is broken into, as a whole number of arms; 0 leaves it \
589               whole.",
590        kind: ParamKind::Structural,
591        group: ParamGroup::Shape,
592        main: false,
593    },
594    ParamSpec {
595        name: "deposit_twist",
596        default: 0.0,
597        range: Some([-2.0, 2.0]),
598        doc: "Sweeps the arms into a spiral rather than leaving them radial.",
599        kind: ParamKind::Modal,
600        group: ParamGroup::Shape,
601        main: false,
602    },
603    ParamSpec {
604        name: "deposit_spin",
605        default: 0.0,
606        range: Some([-2.0, 2.0]),
607        doc: "Turns per second the deposited figure rotates by.",
608        kind: ParamKind::Modal,
609        group: ParamGroup::Motion,
610        main: false,
611    },
612    ParamSpec {
613        name: "gamma",
614        default: 1.0,
615        range: Some([0.25, 4.0]),
616        doc: "Shapes the field's tone curve on its way out; below 1 lifts the mid tones.",
617        kind: ParamKind::Modal,
618        group: ParamGroup::Light,
619        main: false,
620    },
621    ParamSpec {
622        name: "wrap",
623        default: DEFAULT_COMPOSITE_FLAG,
624        range: Some([0.0, 1.0]),
625        doc: "Wraps a sample that leaves the frame back in at the opposite edge, instead of clamping.",
626        kind: ParamKind::Modal,
627        group: ParamGroup::Shape,
628        main: false,
629    },
630    ParamSpec {
631        name: "darken_center",
632        default: DEFAULT_COMPOSITE_FLAG,
633        range: Some([0.0, 1.0]),
634        doc: "Pulls brightness down toward the middle of the frame.",
635        kind: ParamKind::Modal,
636        group: ParamGroup::Light,
637        main: false,
638    },
639    ParamSpec {
640        name: "brighten",
641        default: DEFAULT_COMPOSITE_FLAG,
642        range: Some([0.0, 1.0]),
643        doc: "Lifts the field's bright end, MilkDrop's own brighten switch.",
644        kind: ParamKind::Modal,
645        group: ParamGroup::Light,
646        main: false,
647    },
648    ParamSpec {
649        name: "darken",
650        default: DEFAULT_COMPOSITE_FLAG,
651        range: Some([0.0, 1.0]),
652        doc: "Pushes the field's dark end down, MilkDrop's own darken switch.",
653        kind: ParamKind::Modal,
654        group: ParamGroup::Light,
655        main: false,
656    },
657    ParamSpec {
658        name: "solarize",
659        default: DEFAULT_COMPOSITE_FLAG,
660        range: Some([0.0, 1.0]),
661        doc: "Inverts the field above its midpoint, so highlights fold back into shadow.",
662        kind: ParamKind::Modal,
663        group: ParamGroup::Colour,
664        main: false,
665    },
666    ParamSpec {
667        name: "invert",
668        default: DEFAULT_COMPOSITE_FLAG,
669        range: Some([0.0, 1.0]),
670        doc: "Inverts the whole field.",
671        kind: ParamKind::Modal,
672        group: ParamGroup::Colour,
673        main: false,
674    },
675    ParamSpec {
676        name: "echo_alpha",
677        default: 0.0,
678        range: Some([0.0, 1.0]),
679        doc: "How strongly a second, scaled copy of the field is blended over the first.",
680        kind: ParamKind::Modal,
681        group: ParamGroup::Post,
682        main: false,
683    },
684    ParamSpec {
685        name: "echo_zoom",
686        default: 1.0,
687        range: Some([0.25, 4.0]),
688        doc: "How much larger or smaller that echoed copy is.",
689        kind: ParamKind::Modal,
690        group: ParamGroup::Post,
691        main: false,
692    },
693    ParamSpec {
694        name: "echo_orient",
695        default: 0.0,
696        range: Some([0.0, 3.0]),
697        doc: "Which way the echoed copy is flipped before it is blended.",
698        kind: ParamKind::Structural,
699        group: ParamGroup::Post,
700        main: false,
701    },
702    crate::render::scenes::common::hue(DEFAULT_HUE),
703    ParamSpec {
704        name: "color_span",
705        default: 1.0,
706        range: Some([0.0, 1.0]),
707        doc: "How much of the palette the field's range covers.",
708        kind: ParamKind::Modal,
709        group: ParamGroup::Colour,
710        main: false,
711    },
712    ParamSpec {
713        name: "color_center",
714        default: 0.0,
715        range: Some([-1.0, 1.0]),
716        doc: "Shifts which part of that range lands in the middle of the palette.",
717        kind: ParamKind::Modal,
718        group: ParamGroup::Colour,
719        main: false,
720    },
721    ParamSpec {
722        name: "color_source",
723        default: 0.0,
724        range: Some([0.0, 1.0]),
725        doc: "Where the field takes its colour: 0 the deposit's own angle, 1 the light it has built up.",
726        kind: ParamKind::Structural,
727        group: ParamGroup::Colour,
728        main: false,
729    },
730    ParamSpec {
731        name: "coverage_threshold",
732        default: 0.0,
733        range: Some([0.0, 1.0]),
734        doc: "In level mode, the coverage a pixel needs to hold the ink: at or above it the palette's colour, below it the backdrop. 0 is off.",
735        kind: ParamKind::Modal,
736        group: ParamGroup::Light,
737        main: false,
738    },
739    crate::render::scenes::common::SATURATION,
740    crate::render::scenes::common::PALETTE_MIX,
741    crate::render::scenes::common::PALETTE_STEPS,
742    crate::render::scenes::common::PALETTE_CONTOUR,
743    crate::render::scenes::common::PALETTE_CONTOUR_STYLE,
744    crate::render::scenes::common::PALETTE_CONTOUR_INK,
745    crate::render::scenes::common::brightness(DEFAULT_BRIGHTNESS),
746];
747
748/// `color_source` at rest: the deposit colours by angle, which is the path every
749/// shipped and every converted `warp_mesh` preset takes.
750const DEFAULT_COLOR_SOURCE: f32 = default_of(PARAMS, "color_source");
751
752/// `coverage_threshold` at rest: **off**, and off renders the exact bytes the
753/// present pass rendered before the parameter existed (ADR-0224).
754///
755/// Zero is the off state rather than a threshold of zero because coverage is
756/// never below zero: read as a threshold it would make every pixel of the frame,
757/// backdrop included, hold the ink.
758const DEFAULT_COVERAGE_THRESHOLD: f32 = default_of(PARAMS, "coverage_threshold");
759
760/// `color_source` as the two shaders read it — **0 or 1 exactly**.
761///
762/// Rounded here for `echo_orientation`'s reason on a smaller set: the value is a
763/// selector between two whole colour paths, a `[smoothing]` entry or a preset
764/// dissolve sweeps a binding continuously between them, and half of one path is
765/// not a picture. Clamped rather than wrapped — two states are not a cycle — and
766/// total on a non-finite input, which falls back to today's path.
767fn colour_source(v: f32) -> f32 {
768    if v.is_finite() {
769        v.clamp(0.0, 1.0).round()
770    } else {
771        DEFAULT_COLOR_SOURCE
772    }
773}
774
775/// The warp mesh scene (ADR-0113).
776pub struct WarpMeshScene {
777    device: wgpu::Device,
778    surface_format: wgpu::TextureFormat,
779    res: Option<Resources>,
780    /// The tier's mesh ceiling, fixed for the life of the scene like every other
781    /// tier capacity — a tier change rebuilds the scene.
782    tier_mesh: (u32, u32),
783    /// The grid the active preset asked for, before the tier clamp.
784    requested_mesh: (u32, u32),
785    state: MeshState,
786    /// This frame's target size, recorded by `set_target_size` and acted on in
787    /// `render` (ADR-0030: never allocate in the setter).
788    target: (u32, u32),
789    /// The **render target's** aspect as of the last `render` (ADR-0037).
790    /// Recorded because a bundle's per-frame program reads `aspectx`/`aspecty`
791    /// and `update` runs before `render` — see `update`.
792    last_aspect: f32,
793    time: f32,
794    dt: f32,
795    /// The nine per-vertex outputs as whole-mesh scalars, in
796    /// [`PER_VERTEX_PARAMS`] order.
797    scalars: [f32; OUTPUTS],
798    warp_scale: f32,
799    warp_speed: f32,
800    /// The integrated warp phase ([`Phase`]): `+= warp_speed * dt` once per
801    /// frame, in `update`, after this frame's parameter values have landed. At a
802    /// constant rate it equals `warp_speed * time`, which is why the `1.0`
803    /// default renders exactly as the multiply it replaced.
804    warp_phase: Phase,
805    decay: f32,
806    deposit: f32,
807    deposit_x: f32,
808    deposit_y: f32,
809    deposit_radius: f32,
810    deposit_width: f32,
811    deposit_arms: f32,
812    deposit_twist: f32,
813    deposit_spin: f32,
814    /// The integrated deposit-arm rotation ([`Phase`]), beside `warp_phase` and
815    /// for the same reason (ADR-0135): a rate multiplying the shared clock lets
816    /// a binding that moves rescale all elapsed time in one frame.
817    deposit_phase: Phase,
818    gamma: f32,
819    wrap: f32,
820    darken_center: f32,
821    brighten: f32,
822    darken: f32,
823    solarize: f32,
824    invert: f32,
825    echo_alpha: f32,
826    echo_zoom: f32,
827    echo_orient: f32,
828    /// The shared palette knobs (ADR-0021). This scene has no `pan_*`.
829    colour: common::PaletteParams,
830    color_span: f32,
831    color_center: f32,
832    /// Which of the two colour paths is live (ADR-0197), raw as the preset bound
833    /// it; [`colour_source`] rounds it on the way to both uniforms.
834    color_source: f32,
835    /// The coverage a pixel needs to hold the ink in level mode (ADR-0224), or
836    /// `0` for off. Read only inside the present pass's level branch, so it is
837    /// inert at `color_source = 0`.
838    coverage_threshold: f32,
839    occlude: f32,
840    /// The active baked palette. Held here rather than only in the resources'
841    /// [`palette::LutPair`] because the resources are rebuilt on a resize and
842    /// built lazily: this is what seeds a fresh pair.
843    palette: Palette,
844    /// The converted MilkDrop preset's live EEL2 state, when the preset carries a
845    /// `[milk]` table (Plan 0100 Phase 2 / ADR-0113). `None` — a hand-authored
846    /// preset — executes no VM at all, so the native systems and a native
847    /// `warp_mesh` preset take exactly the path they took before this existed.
848    ///
849    /// **The bundle drives the scene *after* the ordinary bindings**, and that is
850    /// the composition rule: `set_param` and `set_per_vertex` run during the
851    /// renderer's `evaluate_preset`, and the programs run in
852    /// [`update`](Scene::update) and [`render`](Scene::render), which come later.
853    /// A converted preset is authoritative about its own transform; a `[params]`
854    /// binding alongside one is inert rather than fighting it.
855    milk: Option<crate::milk::MilkRuntime>,
856    /// What the bundle's translated shaders ask the scene to build (Plan 0100
857    /// Phase 6). Extracted at `configure`; `render` compares its key against
858    /// the built resources and rebuilds when a preset switch changes it.
859    shader_spec: Option<shader::ShaderSpec>,
860    /// How many levels the feedback field quantizes to at the end of the warp
861    /// pass (ADR-0118), extracted from the bundle at `configure`. **`0.0` — no
862    /// bundle — is off, and off is an exact identity**, so a native `warp_mesh`
863    /// preset renders exactly what it rendered before this existed. Negative is
864    /// the ADR's Alternative D. Both warp fragments read it: the converted one
865    /// through `MilkUniform.misc.w`, the built-in one through
866    /// `WarpUniform.misc3.x`.
867    quantize_steps: f32,
868    /// The tier's line-segment cap, which the draw layer's own `LineRenderer` is
869    /// sized to — the same capacity every line scene gets (ADR-0045).
870    max_segments: usize,
871    /// This frame's draw-layer outputs, from the bundle's per-frame program.
872    /// `None` for a hand-authored preset, which draws no MilkDrop layer.
873    draw: Option<crate::milk::outputs::FrameOutputs>,
874    /// The CPU-side geometry the draw layer builds each frame. Its capacity is
875    /// reused, so the per-frame path allocates nothing after the first frames.
876    geometry: draw::DrawGeometry,
877    /// This frame's analysis, kept from `update` so `render` can drive the
878    /// per-vertex program with the same frame the per-frame program saw.
879    frame: crate::dsp::AnalysisFrame,
880}
881
882impl WarpMeshScene {
883    /// The feedback field's readable texture, or `None` before the first render
884    /// has built the GPU resources.
885    ///
886    /// **Test-only, and it is the seam-A tap** (Plan 0111 Phase 2): the field is
887    /// what the present pass reads and everything downstream is what the bisect
888    /// covers, so a probe needs the value *before* the present pass to have a
889    /// baseline at all. `PingPongField` already carries `COPY_SRC` for Plan 0109
890    /// Phase 4's probe; this only names it from outside the module, which is what
891    /// lets a `Renderer`-level probe read the same quantity the scene-level one
892    /// does rather than approximating it.
893    #[cfg(test)]
894    pub(crate) fn field_texture(&self) -> Option<&wgpu::Texture> {
895        Some(self.res.as_ref()?.field.read_texture())
896    }
897
898    /// Build the CPU-side state. GPU resources are deferred to the first render
899    /// (module docs). `tier_mesh` is the active tier's
900    /// [`mesh_grid`](crate::render::TierConfig::mesh_grid).
901    pub fn new(
902        device: &wgpu::Device,
903        surface_format: wgpu::TextureFormat,
904        tier_mesh: (u32, u32),
905        max_segments: usize,
906    ) -> Self {
907        let tier = crate::render::TierConfig {
908            mesh_grid: tier_mesh,
909            ..crate::render::TierConfig::FLOOR
910        };
911        let mesh = clamp_grid(DEFAULT_MESH, &tier);
912        Self {
913            device: device.clone(),
914            surface_format,
915            res: None,
916            tier_mesh,
917            requested_mesh: DEFAULT_MESH,
918            state: MeshState::new(mesh),
919            target: (0, 0),
920            last_aspect: 1.0,
921            time: 0.0,
922            dt: super::FALLBACK_DT,
923            scalars: PER_VERTEX_DEFAULTS,
924            warp_scale: DEFAULT_WARP_SCALE,
925            warp_speed: DEFAULT_WARP_SPEED,
926            warp_phase: Phase::default(),
927            decay: DEFAULT_DECAY,
928            deposit: DEFAULT_DEPOSIT,
929            deposit_x: DEFAULT_DEPOSIT_CENTRE,
930            deposit_y: DEFAULT_DEPOSIT_CENTRE,
931            deposit_radius: DEFAULT_DEPOSIT_RADIUS,
932            deposit_width: DEFAULT_DEPOSIT_WIDTH,
933            deposit_arms: DEFAULT_DEPOSIT_ARMS,
934            deposit_twist: DEFAULT_DEPOSIT_TWIST,
935            deposit_spin: DEFAULT_DEPOSIT_SPIN,
936            deposit_phase: Phase::default(),
937            gamma: DEFAULT_GAMMA,
938            wrap: DEFAULT_COMPOSITE_FLAG,
939            darken_center: DEFAULT_COMPOSITE_FLAG,
940            brighten: DEFAULT_COMPOSITE_FLAG,
941            darken: DEFAULT_COMPOSITE_FLAG,
942            solarize: DEFAULT_COMPOSITE_FLAG,
943            invert: DEFAULT_COMPOSITE_FLAG,
944            echo_alpha: DEFAULT_ECHO_ALPHA,
945            echo_zoom: DEFAULT_ECHO_ZOOM,
946            echo_orient: DEFAULT_ECHO_ORIENT,
947            colour: common::PaletteParams::new(DEFAULT_HUE, DEFAULT_BRIGHTNESS),
948            color_span: DEFAULT_COLOR_SPAN,
949            color_center: DEFAULT_COLOR_CENTER,
950            color_source: DEFAULT_COLOR_SOURCE,
951            coverage_threshold: DEFAULT_COVERAGE_THRESHOLD,
952            occlude: crate::render::post::DEFAULT_OCCLUDE,
953            palette: Palette::default_spectrum(),
954            milk: None,
955            shader_spec: None,
956            quantize_steps: 0.0,
957            max_segments,
958            draw: None,
959            geometry: draw::DrawGeometry::default(),
960            frame: crate::dsp::AnalysisFrame::default(),
961        }
962    }
963
964    /// The grid this scene actually draws, after the tier clamp. The renderer
965    /// calls the same [`clamp_grid`] on the same request, so the per-vertex
966    /// series it sends is exactly this long.
967    pub fn mesh(&self) -> (u32, u32) {
968        self.state.mesh
969    }
970}
971
972impl PerVertexBound for WarpMeshScene {
973    fn set_per_vertex(&mut self, name: &str, values: &[f32]) {
974        let Some(index) = PER_VERTEX_PARAMS.iter().position(|spec| spec.name == name) else {
975            return;
976        };
977        let Some(slot) = self.state.values.get_mut(index) else {
978            return;
979        };
980        // A series of the wrong length means the renderer and this scene clamped
981        // the grid differently, which `clamp_grid` exists to prevent. Copy what
982        // fits and leave the rest at the scalar rather than panicking on the hot
983        // path.
984        let n = slot.len().min(values.len());
985        if let (Some(dst), Some(src)) = (slot.get_mut(..n), values.get(..n)) {
986            dst.copy_from_slice(src);
987        }
988        if let Some(flag) = self.state.bound.get_mut(index) {
989            *flag = n > 0;
990        }
991    }
992}
993
994#[cfg(test)]
995impl super::FeedbackSource for WarpMeshScene {
996    fn feedback_field(&self) -> Option<&wgpu::Texture> {
997        self.field_texture()
998    }
999}
1000
1001impl Scene for WarpMeshScene {
1002    fn name(&self) -> &'static str {
1003        "warp mesh"
1004    }
1005
1006    fn as_per_vertex_bound(&mut self) -> Option<&mut dyn PerVertexBound> {
1007        Some(self)
1008    }
1009
1010    #[cfg(test)]
1011    fn as_feedback_source(&self) -> Option<&dyn super::FeedbackSource> {
1012        Some(self)
1013    }
1014
1015    fn set_time(&mut self, time: f32) {
1016        self.time = time;
1017    }
1018
1019    fn advance(&mut self, dt: f32) {
1020        // Every rate in this scene is per second, so the frame's own elapsed time
1021        // is the whole of what `advance` carries.
1022        self.dt = dt;
1023    }
1024
1025    fn set_occlude(&mut self, occlude: f32) {
1026        self.occlude = occlude;
1027    }
1028
1029    fn set_target_size(&mut self, width: u32, height: u32) {
1030        // Record only — ADR-0030 condition 2. `render` notices the difference.
1031        self.target = (width.max(1), height.max(1));
1032    }
1033
1034    fn set_palette(&mut self, palette: &Palette) {
1035        self.palette = palette.clone();
1036        if let Some(res) = self.res.as_mut() {
1037            res.luts.set(palette);
1038        }
1039    }
1040
1041    fn configure(&mut self, cfg: &super::GeneratorConfig) -> Option<super::CapOverflow> {
1042        if let super::GeneratorConfig::WarpMesh { mesh, milk, salt } = cfg {
1043            self.requested_mesh = *mesh;
1044            let tier = crate::render::TierConfig {
1045                mesh_grid: self.tier_mesh,
1046                ..crate::render::TierConfig::FLOOR
1047            };
1048            self.state.resize(clamp_grid(*mesh, &tier));
1049            // Built here, off the hot path, and rebuilt on every preset switch —
1050            // so a bundle never inherits the previous preset's register file,
1051            // `megabuf` or RNG stream. `configure` runs on every switch for
1052            // exactly this reason (the `[particles]` arm's note).
1053            self.milk = milk
1054                .as_ref()
1055                .map(|bundle| crate::milk::MilkRuntime::new((**bundle).clone(), *salt));
1056            // The feedback quantizer's step count (ADR-0118). **A bundle decides
1057            // it; the absence of one is the decision for a native preset**, and
1058            // that split is the whole per-bundle shape — `warp_mesh` is a native
1059            // scene too, and a hand-authored world has no reason to want an
1060            // 8-bit-era feedback field.
1061            self.quantize_steps = milk.as_ref().map_or(0.0, |bundle| bundle.quantize_steps);
1062            // The translated shaders, when the bundle carries any (Phase 6).
1063            self.shader_spec = milk.as_ref().and_then(|bundle| {
1064                (bundle.warp_wgsl.is_some() || bundle.comp_wgsl.is_some()).then(|| {
1065                    shader::ShaderSpec {
1066                        warp: bundle.warp_wgsl.clone(),
1067                        comp: bundle.comp_wgsl.clone(),
1068                        blur: bundle.blur_level,
1069                    }
1070                })
1071            });
1072            // A preset switch must not leave the previous bundle's draw layer
1073            // on screen for a frame.
1074            self.draw = None;
1075            self.geometry.clear();
1076        }
1077        None
1078    }
1079
1080    fn reset_params(&mut self) {
1081        self.scalars = PER_VERTEX_DEFAULTS;
1082        // A `[per_vertex]` binding is re-applied every frame, so clearing the
1083        // flags here is what makes an unbound output fall back to its scalar.
1084        self.state.bound = [false; OUTPUTS];
1085        self.warp_scale = DEFAULT_WARP_SCALE;
1086        self.warp_speed = DEFAULT_WARP_SPEED;
1087        self.decay = DEFAULT_DECAY;
1088        self.deposit = DEFAULT_DEPOSIT;
1089        self.deposit_x = DEFAULT_DEPOSIT_CENTRE;
1090        self.deposit_y = DEFAULT_DEPOSIT_CENTRE;
1091        self.deposit_radius = DEFAULT_DEPOSIT_RADIUS;
1092        self.deposit_width = DEFAULT_DEPOSIT_WIDTH;
1093        self.deposit_arms = DEFAULT_DEPOSIT_ARMS;
1094        self.deposit_twist = DEFAULT_DEPOSIT_TWIST;
1095        self.deposit_spin = DEFAULT_DEPOSIT_SPIN;
1096        self.gamma = DEFAULT_GAMMA;
1097        self.wrap = DEFAULT_COMPOSITE_FLAG;
1098        self.darken_center = DEFAULT_COMPOSITE_FLAG;
1099        self.brighten = DEFAULT_COMPOSITE_FLAG;
1100        self.darken = DEFAULT_COMPOSITE_FLAG;
1101        self.solarize = DEFAULT_COMPOSITE_FLAG;
1102        self.invert = DEFAULT_COMPOSITE_FLAG;
1103        self.echo_alpha = DEFAULT_ECHO_ALPHA;
1104        self.echo_zoom = DEFAULT_ECHO_ZOOM;
1105        self.echo_orient = DEFAULT_ECHO_ORIENT;
1106        self.colour.reset();
1107        self.color_span = DEFAULT_COLOR_SPAN;
1108        self.color_center = DEFAULT_COLOR_CENTER;
1109        self.color_source = DEFAULT_COLOR_SOURCE;
1110        self.coverage_threshold = DEFAULT_COVERAGE_THRESHOLD;
1111    }
1112
1113    fn set_param(&mut self, name: &str, value: f32) {
1114        // The shared param blocks first, this scene's own names after
1115        // (`scenes::common`).
1116        if self.colour.set(name, value) {
1117            return;
1118        }
1119        // The nine per-vertex outputs, as whole-mesh scalars — the fallback a
1120        // `[per_vertex]` binding of the same name overrides.
1121        if let Some(index) = PER_VERTEX_PARAMS.iter().position(|spec| spec.name == name) {
1122            if let Some(slot) = self.scalars.get_mut(index) {
1123                *slot = value;
1124            }
1125            return;
1126        }
1127        match name {
1128            "warp_scale" => self.warp_scale = value,
1129            "warp_speed" => self.warp_speed = value,
1130            "decay" => self.decay = value,
1131            "deposit" => self.deposit = value,
1132            "deposit_x" => self.deposit_x = value,
1133            "deposit_y" => self.deposit_y = value,
1134            "deposit_radius" => self.deposit_radius = value,
1135            "deposit_width" => self.deposit_width = value,
1136            "deposit_arms" => self.deposit_arms = value,
1137            "deposit_twist" => self.deposit_twist = value,
1138            "deposit_spin" => self.deposit_spin = value,
1139            "gamma" => self.gamma = value,
1140            "wrap" => self.wrap = value,
1141            "darken_center" => self.darken_center = value,
1142            "brighten" => self.brighten = value,
1143            "darken" => self.darken = value,
1144            "solarize" => self.solarize = value,
1145            "invert" => self.invert = value,
1146            "echo_alpha" => self.echo_alpha = value,
1147            "echo_zoom" => self.echo_zoom = value,
1148            "echo_orient" => self.echo_orient = value,
1149            "color_span" => self.color_span = value,
1150            "color_center" => self.color_center = value,
1151            "color_source" => self.color_source = value,
1152            "coverage_threshold" => self.coverage_threshold = value,
1153            _ => {}
1154        }
1155    }
1156
1157    fn update(&mut self, frame: &AnalysisFrame) {
1158        // Both phases step here, against this frame's bound rates, and `advance`
1159        // only stores `dt`, so the scene has one integration site (ADR-0132).
1160        self.warp_phase.step(self.warp_speed, self.dt);
1161        self.deposit_phase.step(self.deposit_spin, self.dt);
1162        // Kept for `render`, which drives the per-vertex program and is the only
1163        // place the render target's aspect is known.
1164        self.frame = *frame;
1165        // A converted preset's per-frame program, run after the ordinary
1166        // bindings and overriding them — see the `milk` field.
1167        //
1168        // The aspect is deliberately **not** available here, so the value handed
1169        // to the program is the one `render` recorded last frame (or 1.0 on the
1170        // first). `aspectx`/`aspecty` change only on a resize, so a one-frame lag
1171        // on a window drag is invisible; taking the aspect from the mesh instead
1172        // would be the ADR-0037 bug.
1173        let aspect = self.last_aspect;
1174        let mesh = self.state.mesh;
1175        let (time, dt) = (self.time, self.dt);
1176        if let Some(runtime) = self.milk.as_mut() {
1177            let (transform, out) = runtime.run_frame(&self.frame, time, dt, mesh, aspect);
1178            for (index, value) in transform.iter().enumerate() {
1179                if let Some(slot) = self.scalars.get_mut(index) {
1180                    *slot = *value;
1181                }
1182            }
1183            // The composite roster, by field rather than by a positional table —
1184            // the whole point of `outputs::FrameOutputs` (Plan 0100 Phase 4).
1185            self.decay = out.decay;
1186            self.gamma = out.gamma;
1187            self.wrap = out.wrap;
1188            self.darken_center = out.darken_center;
1189            self.brighten = out.brighten;
1190            self.darken = out.darken;
1191            self.solarize = out.solarize;
1192            self.invert = out.invert;
1193            self.echo_alpha = out.echo_alpha;
1194            self.echo_zoom = out.echo_zoom;
1195            self.echo_orient = out.echo_orient;
1196            // **The deposit is NOT forced off here**, and the omission is
1197            // deliberate. Forcing it off for every bundle would also silence a
1198            // HAND-WRITTEN one that uses the deposit as its light source, which
1199            // is a perfectly good thing for one to do and is what
1200            // `core/tests/fixtures/warp_mesh_milk.toml` does. A CONVERTED bundle
1201            // wants none — it draws its own light from the waveform, its custom
1202            // elements and its borders — so it carries an explicit
1203            // `deposit = "0.0"` in its own `[params]`, written by `milkconv`.
1204            // The two cases are told apart by the bundle, not by the scene.
1205            self.draw = Some(out);
1206            // A bundle's per-vertex program replaces any `[per_vertex]` table's
1207            // series wholesale, so the flags are cleared here and re-set in
1208            // `render` once the vertices are evaluated.
1209            self.state.bound = [false; OUTPUTS];
1210        }
1211    }
1212
1213    fn render(
1214        &mut self,
1215        queue: &wgpu::Queue,
1216        encoder: &mut wgpu::CommandEncoder,
1217        view: &wgpu::TextureView,
1218        aspect: f32,
1219    ) {
1220        let size = if self.target == (0, 0) {
1221            // No `set_target_size` yet (a caller that renders without the
1222            // renderer's per-frame hook). Fall back to a square field rather
1223            // than allocating nothing.
1224            (256, 256)
1225        } else {
1226            self.target
1227        };
1228        if !encode::ensure_resources(self, queue, encoder, size) {
1229            return;
1230        }
1231
1232        self.last_aspect = aspect;
1233        encode::prepare_mesh(self, aspect);
1234
1235        let dt = self.dt;
1236        if let Some(res) = self.res.as_ref() {
1237            encode::upload_uniforms(self, res, queue, aspect, size, dt);
1238            encode::encode_warp(res, encoder);
1239            encode::encode_deposit(res, encoder);
1240        }
1241        encode::encode_draw_layer(self, queue, encoder, aspect, dt);
1242        if let Some(res) = self.res.as_ref() {
1243            encode::encode_blur(res, encoder);
1244            encode::encode_present(res, encoder, view);
1245        }
1246    }
1247}
1248
1249pub mod draw;
1250mod shader;
1251
1252#[cfg(test)]
1253mod tests;