Skip to main content

rlx_core/render/scenes/
fragment_field.rs

1//! Fragment-field scene: a fullscreen Shadertoy-style domain-warped field,
2//! colored through the shared palette LUT (ADR-0021). The first "generative-art"-
3//! tier built-in and one of the two preset-driven systems (ADR-0002 layers 1-2).
4//!
5//! Its look is a set of named parameters — `warp`, `hue`, `zoom`, `glow`,
6//! `flash`, plus the shared color knobs `color_span`/`color_center`/`saturation`
7//! (Plan 0020) — that a preset binds to expressions over the audio analysis (Plan
8//! 0003 Phase 5). With no preset the parameter defaults render a gentle idle
9//! field. The scene reads no audio directly; all reactivity flows through the
10//! parameter values.
11//!
12//! Color: the field level indexes a 256-entry gradient LUT (the preset's
13//! `[palette]`, default `spectrum` = the exact prior cosine) instead of a
14//! hardcoded `palette()`. `color_span` sets how much of the gradient the field
15//! spans (replacing the old fixed `field*0.6`), `color_center`/`hue` slide the
16//! window, and `saturation` desaturates toward luma — all bindable.
17
18// Hot-path panic-denial pragma (Plan 0002 Phase 2, extended to scenes by Plan
19// 0003 Phase 0). Runs every displayed frame.
20#![deny(
21    clippy::unwrap_used,
22    clippy::expect_used,
23    clippy::indexing_slicing,
24    clippy::panic,
25    clippy::unreachable
26)]
27
28use crate::render::gpu;
29
30use super::common;
31use super::{Phase, Scene};
32use crate::dsp::AnalysisFrame;
33use crate::render::palette::{self, Palette};
34use crate::render::scenes::{ParamKind, ParamSpec, default_of};
35
36/// Parameter defaults — a calm idle field when nothing is bound.
37const DEFAULT_WARP: f32 = default_of(PARAMS, "warp");
38const DEFAULT_HUE: f32 = 0.0;
39const DEFAULT_ZOOM: f32 = 1.0;
40const DEFAULT_GLOW: f32 = default_of(PARAMS, "glow");
41const DEFAULT_FLASH: f32 = default_of(PARAMS, "flash");
42// Shared palette color knobs (ADR-0021). `color_span` = 0.6 + `color_center` = 0
43// + `saturation` = 1 reproduce the prior look exactly (the old `field*0.6` sample
44// with no desaturation).
45const DEFAULT_COLOR_SPAN: f32 = default_of(PARAMS, "color_span");
46const DEFAULT_COLOR_CENTER: f32 = default_of(PARAMS, "color_center");
47/// The two rate parameters (ADR-0132), in units of the scene's own default
48/// speed. `1.0` is what this scene has always animated at, so a preset that
49/// binds neither renders exactly as before.
50const DEFAULT_FIELD_SPEED: f32 = default_of(PARAMS, "field_speed");
51const DEFAULT_FOLD_SPEED: f32 = default_of(PARAMS, "fold_speed");
52
53const SHADER: &str = r#"
54struct Params {
55    // x: time (s), y: aspect, z: warp, w: hue
56    a: vec4<f32>,
57    // x: zoom, y: glow, z: flash, w: color_span
58    b: vec4<f32>,
59    // xy: pan (field-space offset, ADR-0018), z: color_center, w: saturation
60    c: vec4<f32>,
61    // x: palette_mix (A/B crossfade), y: occlude (ADR-0085),
62    // z: palette_steps (integral, quantized CPU-side), w: palette_contour (ADR-0078)
63    d: vec4<f32>,
64    // x: fold phase, y: field phase (ADR-0132) — both INTEGRATED on the CPU
65    // (`phase += rate * dt`) rather than derived here from `t * rate`. At a
66    // constant rate a phase equals `rate * t`, so the defaults reproduce the
67    // literals these replaced; what integration buys is that a rate BOUND TO
68    // AUDIO bends the motion instead of teleporting it — at t = 100 s a
69    // `warp_speed`-style multiply would move the phase fifty seconds in one
70    // frame. z, w: unused.
71    e: vec4<f32>,
72}
73
74@group(0) @binding(0) var<uniform> params: Params;
75// The gradient LUTs sit in their own bind group (group 1), so this pipeline's
76// layout stays distinct from the screen-space kaleidoscope's single 3-entry
77// [uniform, texture, sampler] group — two byte-identical layouts mis-render when
78// they coexist on the DX12 WARP software adapter (the same quirk the shared line
79// renderer and the lazy feedback scenes work around). Two LUTs (A/B) for the
80// `palette_mix` crossfade; one shared sampler.
81@group(1) @binding(0) var lut_a: texture_2d<f32>;
82@group(1) @binding(1) var lut_b: texture_2d<f32>;
83@group(1) @binding(2) var lut_samp: sampler;
84
85// Shared `saturation` (mirrors core/src/render/palette.rs::desaturate verbatim):
86// scale chroma around Rec. 601 luma. 1.0 unchanged, 0.0 grayscale.
87fn apply_saturation(c: vec3<f32>, s: f32) -> vec3<f32> {
88    let luma = dot(c, vec3<f32>(0.299, 0.587, 0.114));
89    return vec3<f32>(luma) + (c - vec3<f32>(luma)) * s;
90}
91
92// Shared `palette_steps` (mirrors core/src/render/palette.rs::band_coord
93// verbatim, ADR-0078): snap the palette coordinate to a band centre before the
94// LUT read. Below 1.5 steps it is the exact identity, not a one-band degenerate.
95fn band_coord(t: f32, steps: f32) -> f32 {
96    if (steps < 1.5) {
97        return t;
98    }
99    return (floor(t * steps) + 0.5) / steps;
100}
101
102// Shared `palette_contour` (ADR-0078 / ADR-0133; the WGSL is the implementation,
103// copied verbatim at each fragment-stage site — palette.rs has no CPU
104// counterpart to be canonical, since `fwidth` exists only here).
105//
106// Darkens within one PIXEL of a band edge, so the line has the same weight where
107// the field is shallow and where it is steep — AND ONLY WHERE THE INK ACTUALLY
108// CHANGES (ADR-0133). It samples the two band centres either side of the nearest
109// edge and returns unchanged when they resolve to the same colour within half a
110// code value, which is below the LUT's own 8-bit quantization. On a smooth
111// palette two distinct centres always differ by at least one code value, so
112// every edge draws exactly as it did at any `palette_steps`; inside a plateau
113// the LUT is literally constant and the samples are bit-equal, so the line
114// vanishes there and survives at the run boundaries. One rule, both behaviours,
115// no new parameter.
116//
117// The two LUTs, the sampler and `palette_mix` are EXPLICIT parameters rather
118// than module-scope globals this happens to find: all four sites name them the
119// same today, so implicit capture would compile — and would silently bind the
120// shared function to whatever a future site called its textures.
121//
122// `textureSampleLevel`, not `textureSample`: the LUT has one mip, and an
123// explicit LOD keeps these reads free of the uniformity requirement that a
124// sample after a conditional return would otherwise carry.
125fn band_contour(
126    t: f32,
127    steps: f32,
128    amount: f32,
129    lut_a: texture_2d<f32>,
130    lut_b: texture_2d<f32>,
131    lut_samp: sampler,
132    mix_ab: f32,
133) -> f32 {
134    let f = t * steps;
135    let w = max(fwidth(f), 1e-5);
136    if (steps < 1.5 || amount <= 0.0) {
137        return 1.0;
138    }
139    let n = round(f);
140    let m = clamp(mix_ab, 0.0, 1.0);
141    let lo = mix(
142        textureSampleLevel(lut_a, lut_samp, vec2<f32>((n - 0.5) / steps, 0.5), 0.0).rgb,
143        textureSampleLevel(lut_b, lut_samp, vec2<f32>((n - 0.5) / steps, 0.5), 0.0).rgb,
144        m
145    );
146    let hi = mix(
147        textureSampleLevel(lut_a, lut_samp, vec2<f32>((n + 0.5) / steps, 0.5), 0.0).rgb,
148        textureSampleLevel(lut_b, lut_samp, vec2<f32>((n + 0.5) / steps, 0.5), 0.0).rgb,
149        m
150    );
151    if (all(abs(hi - lo) < vec3<f32>(0.5 / 255.0))) {
152        return 1.0;
153    }
154    let d = min(fract(f), 1.0 - fract(f));
155    return 1.0 - clamp(amount, 0.0, 1.0) * (1.0 - smoothstep(0.0, w, d));
156}
157
158@fragment
159fn fs_main(in: VsOut) -> @location(0) vec4<f32> {
160    let t = params.a.x;
161    let aspect = params.a.y;
162    let warp = params.a.z;
163    let hue = params.a.w;
164    let zoom = params.b.x;
165    let glow = params.b.y;
166    let flash = params.b.z;
167    let color_span = params.b.w;
168    let pan = params.c.xy;
169    let color_center = params.c.z;
170    let saturation = params.c.w;
171    let palette_mix = params.d.x;
172    let palette_steps = params.d.z;
173    let palette_contour = params.d.w;
174    let fold_phase = params.e.x;
175    let field_phase = params.e.y;
176
177    var uv = in.ndc;
178    uv.x = uv.x * aspect;
179
180    // Iterated sine-fold domain warp, scaled by zoom and folded by warp; `pan`
181    // slides the sampled field window (the shared ViewTransform, ADR-0018). The
182    // vignette below stays screen-anchored (uses unshifted `uv`).
183    var p = uv * zoom + pan;
184    // The fold's two rates keep their designed 0.7 : 0.6 quadrature ratio; what
185    // `fold_speed` scales is the phase they share, so slowing the fold does not
186    // flatten it the way `warp` does (ADR-0132).
187    for (var i = 0; i < 5; i = i + 1) {
188        let fi = f32(i);
189        p = p + warp * vec2<f32>(
190            sin(p.y * 1.5 + fold_phase * 0.7 + fi),
191            cos(p.x * 1.5 - fold_phase * 0.6 + fi)
192        ) / (fi + 1.0);
193    }
194
195    let field = 0.5 + 0.5 * sin(p.x + p.y + field_phase * 0.5);
196    // Field level indexes the gradient LUT: `color_span` sets the spanned range
197    // (was a fixed 0.6), `color_center`/`hue` slide the window. Linear-filtered,
198    // repeat-addressed (a hue rotation wraps like the cosine wheel).
199    let coord = field * color_span + color_center + hue;
200    // Hard bands, then the contour drawn from the SAME coordinate (ADR-0078), so
201    // the dark line follows the field's iso-lines and reads as structure rather
202    // than as an outline of the picture's brightness.
203    let banded = band_coord(coord, palette_steps);
204    // Sample both palettes and crossfade by `palette_mix` (0 = A, 1 = B). When a
205    // preset declares no [palette_b] the two LUTs are identical, so mix is a no-op.
206    let ca = textureSample(lut_a, lut_samp, vec2<f32>(banded, 0.5)).rgb;
207    let cb = textureSample(lut_b, lut_samp, vec2<f32>(banded, 0.5)).rgb;
208    var col = mix(ca, cb, clamp(palette_mix, 0.0, 1.0));
209    col = col * band_contour(
210        coord, palette_steps, palette_contour, lut_a, lut_b, lut_samp, palette_mix
211    );
212    col = apply_saturation(col, saturation);
213
214    let r = length(uv);
215    col = col * (glow * (1.0 - 0.25 * r));
216    col = col + vec3<f32>(flash * 0.12);
217
218    // Alpha 1.0: this field covers every pixel, which is the coverage it honestly
219    // has (ADR-0056). `occlude` scales how much of that the backdrop underneath
220    // resolves against (ADR-0085) — at 0 the sky adds through an opaque field.
221    // Reached only when no post stage is active; the chain owns the seam otherwise
222    // and the renderer hands a literal 1.0 here.
223    return vec4<f32>(col, params.d.y);
224}
225"#;
226
227#[repr(C)]
228#[derive(Clone, Copy, bytemuck::Pod, bytemuck::Zeroable)]
229struct Params {
230    a: [f32; 4],
231    b: [f32; 4],
232    c: [f32; 4],
233    d: [f32; 4],
234    e: [f32; 4],
235}
236
237/// Fullscreen domain-warped fragment field, driven by named preset parameters.
238pub struct FragmentFieldScene {
239    /// The pipeline, the uniform buffer, the 256×1 gradient LUT pair (A/B) the
240    /// fragment samples + crossfades for colour (ADR-0021), and the two bind
241    /// groups. The LUT group is **group 1**, separate from the uniform group, so
242    /// this pipeline's layout does not match the kaleidoscope's.
243    gpu: gpu::FullscreenScene,
244    /// Shared scene clock (seconds), set by the renderer each frame.
245    time: f32,
246    /// This frame's elapsed real time, stored by `advance` and consumed by
247    /// `update` — the split ADR-0132 requires, since `advance` runs before this
248    /// frame's parameter values land.
249    dt: f32,
250    /// The integrated fold and field phases ([`Phase`]). **These are the scene's
251    /// only state**: everything else here is derived from `time` and the
252    /// parameters, so a capture's reproducibility now depends on the scene being
253    /// rebuilt with the preset as well as on the clock resetting
254    /// (`reset_for_capture` does both).
255    ///
256    /// They stay two accumulators rather than one, because each follows its own
257    /// rate: `fold_speed` and `field_speed` are separately bindable.
258    fold_phase: Phase,
259    field_phase: Phase,
260    /// Animation rates, in units of the scene's default speed (ADR-0132).
261    field_speed: f32,
262    fold_speed: f32,
263    warp: f32,
264    /// The shared palette knobs (ADR-0021). This scene has no `brightness`.
265    colour: common::PaletteParams,
266    /// The shared view transform (ADR-0018): `pan_*` offset the sampled field
267    /// window, on top of `zoom`.
268    pan: common::PanParams,
269    zoom: f32,
270    glow: f32,
271    flash: f32,
272    color_span: f32,
273    color_center: f32,
274    /// How much of this field's (total) coverage the backdrop resolves against
275    /// (ADR-0085). Set by the renderer every frame through
276    /// [`Scene::set_occlude`](super::Scene::set_occlude) — **not** a named param,
277    /// so it is not reset by `reset_params`.
278    occlude: f32,
279}
280
281impl FragmentFieldScene {
282    /// Build the scene's pipeline and uniform buffer on `device`.
283    pub fn new(device: &wgpu::Device, surface_format: wgpu::TextureFormat) -> Self {
284        let shader = gpu::fullscreen_shader(
285            device,
286            "fragment-field-shader",
287            gpu::FULLSCREEN_VS_NDC,
288            SHADER,
289        );
290        let parts =
291            gpu::FullscreenParts::new(device, "fragment-field", std::mem::size_of::<Params>());
292        let uniform_layout = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
293            label: Some("fragment-field-uniform-layout"),
294            entries: &[gpu::uniform(0, wgpu::ShaderStages::FRAGMENT)],
295        });
296        // The LUT texture + sampler live in their own group (group 1) — see the
297        // WGSL note: keeping this pipeline's layout distinct from the
298        // kaleidoscope's avoids the DX12 WARP identical-layout mis-render.
299        let lut_layout = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
300            label: Some("fragment-field-lut-layout"),
301            entries: &[
302                gpu::texture(0, true),
303                gpu::texture(1, true),
304                gpu::sampler(2),
305            ],
306        });
307        let uniform_bg = device.create_bind_group(&wgpu::BindGroupDescriptor {
308            label: Some("fragment-field-uniform-bg"),
309            layout: &uniform_layout,
310            entries: &[wgpu::BindGroupEntry {
311                binding: 0,
312                resource: parts.uniforms().as_entire_binding(),
313            }],
314        });
315        let lut_bg = device.create_bind_group(&wgpu::BindGroupDescriptor {
316            label: Some("fragment-field-lut-bg"),
317            layout: &lut_layout,
318            entries: &parts.luts().bind_entries(0, 1, 2),
319        });
320
321        Self {
322            gpu: parts.finish(
323                device,
324                &shader,
325                &[&uniform_layout, &lut_layout],
326                uniform_bg,
327                Some(lut_bg),
328                surface_format,
329                wgpu::BlendState::REPLACE,
330                "fragment-field",
331            ),
332            time: 0.0,
333            dt: crate::render::scenes::FALLBACK_DT,
334            fold_phase: Phase::default(),
335            field_phase: Phase::default(),
336            field_speed: DEFAULT_FIELD_SPEED,
337            fold_speed: DEFAULT_FOLD_SPEED,
338            warp: DEFAULT_WARP,
339            colour: common::PaletteParams::new(DEFAULT_HUE, common::DEFAULT_BRIGHTNESS),
340            pan: common::PanParams::default(),
341            zoom: DEFAULT_ZOOM,
342            glow: DEFAULT_GLOW,
343            flash: DEFAULT_FLASH,
344            color_span: DEFAULT_COLOR_SPAN,
345            color_center: DEFAULT_COLOR_CENTER,
346            occlude: crate::render::post::DEFAULT_OCCLUDE,
347        }
348    }
349}
350
351/// The parameter names this scene consumes — the vocabulary a preset binding
352/// is checked against at load, so a typo is warned about instead of silently
353/// doing nothing (ADR-0020). **Keep in sync with `set_param` below**; the
354/// `declared_params_match_set_param` guard in `core/tests/preset.rs` fails if
355/// the two drift.
356pub const PARAMS: &[ParamSpec] = &[
357    ParamSpec {
358        name: "warp",
359        default: 0.4,
360        range: Some([0.0, 1.5]),
361        doc: "Amplitude of the domain fold; 0 flattens the field into plain bands.",
362        kind: ParamKind::Modal,
363    },
364    ParamSpec {
365        name: "field_speed",
366        default: 1.0,
367        range: Some([0.0, 4.0]),
368        doc: "How fast the field itself drifts, as a multiple of its base rate.",
369        kind: ParamKind::Modal,
370    },
371    ParamSpec {
372        name: "fold_speed",
373        default: 1.0,
374        range: Some([0.0, 4.0]),
375        doc: "How fast the fold turns, independently of the field's own drift.",
376        kind: ParamKind::Modal,
377    },
378    crate::render::scenes::common::hue(DEFAULT_HUE),
379    crate::render::scenes::common::zoom(DEFAULT_ZOOM),
380    ParamSpec {
381        name: "glow",
382        default: 0.7,
383        range: Some([0.0, 2.0]),
384        doc: "Overall light the field emits, before the composite sees it.",
385        kind: ParamKind::Modal,
386    },
387    ParamSpec {
388        name: "flash",
389        default: 0.0,
390        range: Some([0.0, 1.0]),
391        doc: "Lifts the whole field toward white, for a beat-driven blink.",
392        kind: ParamKind::Modal,
393    },
394    crate::render::scenes::common::PAN_X,
395    crate::render::scenes::common::PAN_Y,
396    ParamSpec {
397        name: "color_span",
398        default: 0.6,
399        range: Some([0.0, 1.0]),
400        doc: "How much of the palette the field's range covers; 0 is one flat colour.",
401        kind: ParamKind::Modal,
402    },
403    ParamSpec {
404        name: "color_center",
405        default: 0.0,
406        range: Some([-1.0, 1.0]),
407        doc: "Shifts which part of the field's range lands in the middle of the palette.",
408        kind: ParamKind::Modal,
409    },
410    crate::render::scenes::common::SATURATION,
411    crate::render::scenes::common::PALETTE_MIX,
412    crate::render::scenes::common::PALETTE_STEPS,
413    crate::render::scenes::common::PALETTE_CONTOUR,
414];
415
416impl Scene for FragmentFieldScene {
417    fn name(&self) -> &'static str {
418        "fragment field"
419    }
420
421    fn set_time(&mut self, time: f32) {
422        self.time = time;
423    }
424
425    fn advance(&mut self, dt: f32) {
426        // Stored, not integrated: the rates this frame will use have not been
427        // set yet (ADR-0132).
428        self.dt = dt;
429    }
430
431    fn set_occlude(&mut self, occlude: f32) {
432        self.occlude = occlude;
433    }
434
435    fn set_palette(&mut self, palette: &Palette) {
436        // Store the baked LUT; `render` uploads it (deferred so scenes with lazy
437        // GPU resources share this seam). Cheap array copy, off the hot path.
438        self.gpu.set_palette(palette);
439    }
440
441    fn reset_params(&mut self) {
442        // The two rates reset with every other param; the PHASES do not — they
443        // are state, and resetting them each frame would be the multiply this
444        // ADR exists to remove.
445        self.field_speed = DEFAULT_FIELD_SPEED;
446        self.fold_speed = DEFAULT_FOLD_SPEED;
447        self.warp = DEFAULT_WARP;
448        self.colour.reset();
449        self.pan.reset();
450        self.zoom = DEFAULT_ZOOM;
451        self.glow = DEFAULT_GLOW;
452        self.flash = DEFAULT_FLASH;
453        self.color_span = DEFAULT_COLOR_SPAN;
454        self.color_center = DEFAULT_COLOR_CENTER;
455    }
456
457    fn set_param(&mut self, name: &str, value: f32) {
458        // The shared param blocks first, this scene's own names after
459        // (`scenes::common`).
460        if self.colour.set(name, value) || self.pan.set(name, value) {
461            return;
462        }
463        match name {
464            "warp" => self.warp = value,
465            "field_speed" => self.field_speed = value,
466            "fold_speed" => self.fold_speed = value,
467            "zoom" => self.zoom = value,
468            "glow" => self.glow = value,
469            "flash" => self.flash = value,
470            "color_span" => self.color_span = value,
471            "color_center" => self.color_center = value,
472            _ => {}
473        }
474    }
475
476    fn update(&mut self, _frame: &AnalysisFrame) {
477        // Fully parameter-driven; the analysis reaches this scene only through
478        // the preset expressions bound to its parameters. The one thing that
479        // happens here is the phase integration, which runs after `set_param`
480        // so it uses this frame's rates (ADR-0132).
481        self.fold_phase.step(self.fold_speed, self.dt);
482        self.field_phase.step(self.field_speed, self.dt);
483    }
484
485    fn render(
486        &mut self,
487        queue: &wgpu::Queue,
488        encoder: &mut wgpu::CommandEncoder,
489        view: &wgpu::TextureView,
490        aspect: f32,
491    ) {
492        // Upload the active palette LUTs (A + B) if a preset switch changed them
493        // (off the hot path — once per switch, not per frame).
494        self.gpu.flush_palette(queue);
495
496        let params = Params {
497            a: [self.time, aspect.max(0.1), self.warp, self.colour.hue],
498            b: [self.zoom, self.glow, self.flash, self.color_span],
499            c: [
500                self.pan.x,
501                self.pan.y,
502                self.color_center,
503                self.colour.saturation,
504            ],
505            d: [
506                self.colour.mix,
507                self.occlude,
508                palette::band_steps(self.colour.steps),
509                palette::band_contour(self.colour.contour),
510            ],
511            e: [self.fold_phase.get(), self.field_phase.get(), 0.0, 0.0],
512        };
513        self.gpu.write_uniform(queue, &params);
514
515        // Load over the engine backdrop (ADR-0018); this fullscreen field is
516        // opaque, so it covers the backdrop as before.
517        self.gpu
518            .draw(encoder, "fragment-field-pass", view, wgpu::LoadOp::Load);
519    }
520}
521
522#[cfg(test)]
523mod tests {
524    use super::*;
525
526    /// The property ADR-0132 exists for, and the one a `time * rate` multiply
527    /// fails: a rate change moves the phase by `rate * dt` **whatever the
528    /// elapsed scene time**. Under a multiply the same change at t = 100 s
529    /// would move the picture fifty seconds in one frame — a teleport, on a lane
530    /// whose whole method is binding parameters to audio.
531    #[test]
532    fn a_rate_change_advances_the_phase_by_rate_times_dt_at_any_elapsed_time() {
533        let dt = 1.0 / 60.0;
534        let (mut fold, mut field) = (Phase::default(), Phase::default());
535        // Run a long way out, so a multiply-by-elapsed-time would be obvious.
536        for _ in 0..6_000 {
537            fold.step(1.0, dt);
538            field.step(1.0, dt);
539        }
540        let elapsed = fold.get();
541        assert!(
542            elapsed > 99.0,
543            "the fixture must be far from t = 0: {elapsed}"
544        );
545
546        // Now the preset's binding moves, as an audio-bound rate does.
547        let before = fold.get();
548        fold.step(1.5, dt);
549        let fold_step = fold.get() - before;
550        assert!(
551            (fold_step - 1.5 * dt).abs() < 1e-4,
552            "the fold advanced {fold_step}, not {} — scaling with elapsed time is the defect",
553            1.5 * dt
554        );
555
556        // And each accumulator carries its own rate: the pair is not welded.
557        let field_before = field.get();
558        field.step(0.25, dt);
559        // Tolerance is above the f32 ulp at this magnitude (~7.6e-6 near 100),
560        // which is the accumulation cost ADR-0132 accepts.
561        assert!(
562            (field.get() - field_before - 0.25 * dt).abs() < 1e-4,
563            "the field phase must follow field_speed alone: moved {}",
564            field.get() - field_before
565        );
566    }
567
568    /// At a constant rate the integrated phase equals `rate * t` — which is why
569    /// every shipped preset, all of which leave both parameters at their `1.0`
570    /// default, renders unchanged. Asserted rather than assumed, because it is
571    /// what makes moving no golden a property rather than an observation.
572    #[test]
573    fn a_constant_rate_integrates_to_rate_times_elapsed_time() {
574        let dt = 1.0 / 60.0;
575        for rate in [1.0f32, 0.4, 2.5] {
576            let mut phase = Phase::default();
577            let mut clock = 0.0f32;
578            for _ in 0..600 {
579                phase.step(rate, dt);
580                clock += dt;
581            }
582            assert!(
583                (phase.get() - rate * clock).abs() < 1e-3,
584                "rate {rate}: integrated {} against {}",
585                phase.get(),
586                rate * clock
587            );
588        }
589    }
590
591    /// The default is exactly `1.0` on both, so the phase is bit-identical to
592    /// the clock the three shader literals read — the capture path
593    /// accumulates `time` the same way, one `FALLBACK_DT` per frame.
594    #[test]
595    fn the_default_rates_make_the_phase_the_clock() {
596        assert_eq!(DEFAULT_FIELD_SPEED, 1.0);
597        assert_eq!(DEFAULT_FOLD_SPEED, 1.0);
598
599        let dt = crate::render::scenes::FALLBACK_DT;
600        let (mut fold, mut field) = (Phase::default(), Phase::default());
601        let mut clock = 0.0f32;
602        for _ in 0..240 {
603            fold.step(DEFAULT_FOLD_SPEED, dt);
604            field.step(DEFAULT_FIELD_SPEED, dt);
605            clock += dt;
606        }
607        assert_eq!(
608            fold.get(),
609            clock,
610            "at rate 1.0 the accumulation must be bit-identical to the clock's"
611        );
612        assert_eq!(field.get(), clock);
613    }
614}