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