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