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;