Skip to main content

rlx_core/render/
feedback.rs

1//! Reusable ping-pong offscreen field for stateful feedback scenes (ADR-0012).
2//!
3//! Two same-format textures a simulation swaps each sub-step: the sim samples
4//! the previous state (the *read* view) and writes the next (the *write* view),
5//! then the field swaps so the fresh state becomes the next read. This is the
6//! engine's first feedback path; the reaction-diffusion scene is its first user
7//! and future warp/feedback variants reuse it (ADR-0002 named it a deferred
8//! follow-up).
9//!
10//! **Composition, not engine machinery.** The field owns only the texture pair
11//! and the read/write selector; the *scene* owns its sim/present pipelines and
12//! the shader that steps the field. That keeps the `Scene` seam thin — ADR-0012
13//! rejected an engine-managed multi-pass pipeline for exactly this reason.
14
15// Hot-path panic-denial pragma (Plan 0002 Phase 2; render/ is scanned by the
16// hygiene guard). A feedback scene encodes its passes every displayed frame.
17#![deny(
18    clippy::unwrap_used,
19    clippy::expect_used,
20    clippy::indexing_slicing,
21    clippy::panic,
22    clippy::unreachable
23)]
24
25/// The curated procedural warp an accumulation resamples its past through, from
26/// the `[feedback] warp` structural key (ADR-0048).
27///
28/// **Load-time, not bindable**, by `[curve] family`'s reasoning: a warp kind is a
29/// shader path, not a quantity, and ADR-0021 already rejected bindable discrete
30/// indexes for the flicker/hard-cut class of reasons. Its *strength* is the
31/// ordinary bindable `fb_warp`, which is where a preset puts the audio.
32///
33/// The family is deliberately small (ADR-0048 Alternative A): an author-defined
34/// per-pixel warp is a grammar-to-WGSL translator and a per-preset pipeline
35/// compile, and it should be decided as that rather than smuggled in as a stage
36/// option.
37#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
38pub enum Warp {
39    /// No procedural warp — the affine alone. The default, and the identity.
40    #[default]
41    None,
42    /// A vortex: the past rotates about the feedback centre by an angle that
43    /// falls off with radius, so the middle spins faster than the rim.
44    Swirl,
45    /// Concentric standing waves in radius — the past breathes in rings.
46    Ripple,
47    /// A radial magnification that grows with radius: the periphery is drawn in
48    /// (positive `fb_warp`) or pushed out (negative).
49    Fisheye,
50}
51
52impl Warp {
53    /// Every kind, in the order the error message lists them.
54    pub const ALL: [Warp; 4] = [Warp::None, Warp::Swirl, Warp::Ripple, Warp::Fisheye];
55
56    /// Parse a `[feedback] warp` value, or `None` if unknown (a load error — the
57    /// preset is rejected rather than silently rendering unwarped).
58    pub fn from_name(name: &str) -> Option<Self> {
59        Some(match name {
60            "none" => Warp::None,
61            "swirl" => Warp::Swirl,
62            "ripple" => Warp::Ripple,
63            "fisheye" => Warp::Fisheye,
64            _ => return None,
65        })
66    }
67
68    /// The canonical name — the exact string [`from_name`](Self::from_name)
69    /// accepts. The two are inverses and the one place the mapping lives.
70    pub fn as_str(self) -> &'static str {
71        match self {
72            Warp::None => "none",
73            Warp::Swirl => "swirl",
74            Warp::Ripple => "ripple",
75            Warp::Fisheye => "fisheye",
76        }
77    }
78
79    /// The selector this kind is written into a shader uniform as.
80    ///
81    /// One shader with a kind uniform, **not one pipeline per kind** (Plan 0046's
82    /// own risk note): the DX12 WARP software adapter mis-renders coexisting
83    /// pipelines whose bind-group layouts match, and four permutations of one
84    /// stage is exactly the shape that bites. (A WARP reading, unverified on
85    /// lavapipe as of 2026-10-06.)
86    pub(crate) fn code(self) -> f32 {
87        match self {
88            Warp::None => 0.0,
89            Warp::Swirl => 1.0,
90            Warp::Ripple => 2.0,
91            Warp::Fisheye => 3.0,
92        }
93    }
94}
95
96/// How this frame's light lands on the transformed past, from the
97/// `[feedback] blend` structural key (ADR-0048).
98#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
99pub enum Deposit {
100    /// `accum = max(cur, prev * fade)` — the engine's only blend until ADR-0048,
101    /// and still the default. Bounded by the source maximum.
102    #[default]
103    Max,
104    /// `accum = cur + prev * fade` — echoes that **sum**. Its geometric series is
105    /// bounded by `1 / (1 - fade)`, which under the `MAX_FADE = 0.98` ceiling is
106    /// 50x, and it rolls off through ADR-0046's tonemap rather than clipping.
107    /// Only viable at all because the composite runs in linear light above 1.0.
108    Add,
109}
110
111impl Deposit {
112    /// Both blends, for the load error's "expected one of" listing and for the
113    /// schema export, which renders this rather than restating it.
114    pub const ALL: [Deposit; 2] = [Deposit::Max, Deposit::Add];
115
116    /// The canonical `[feedback] blend` name.
117    pub fn as_str(self) -> &'static str {
118        match self {
119            Deposit::Max => "max",
120            Deposit::Add => "add",
121        }
122    }
123
124    /// Parse a `[feedback] blend` value, or `None` if unknown.
125    pub fn from_name(name: &str) -> Option<Self> {
126        Some(match name {
127            "max" => Deposit::Max,
128            "add" => Deposit::Add,
129            _ => return None,
130        })
131    }
132}
133
134/// A preset's `[feedback]` table: the two load-time choices about how an
135/// accumulation reads its own past (ADR-0048).
136///
137/// **One vocabulary, two sinks.** This type and the `fb_*` params it accompanies
138/// are consumed by *both* accumulation buffers — the engine trails stage and the
139/// attractor scene's internal trail — and each transforms only its own. It lives
140/// here, beside `PingPongField`, rather than in either of them, because that is
141/// what makes "one vocabulary" structural instead of a convention.
142///
143/// [`Default`] is the identity in both fields, so a preset with no `[feedback]`
144/// table renders exactly what it rendered before the table existed.
145#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
146pub struct FeedbackConfig {
147    /// Which procedural warp, if any, rides on top of the `fb_*` affine.
148    pub warp: Warp,
149    /// How this frame's light is deposited onto the faded past.
150    pub blend: Deposit,
151}
152
153/// `fb_zoom` default — a factor of `1.0` **per second**, i.e. no scaling however
154/// long the frame is.
155pub(crate) const DEFAULT_FB_ZOOM: f32 = 1.0;
156/// `fb_rotate` / `fb_dx` / `fb_dy` / `fb_warp` default — zero rad/s, zero units/s.
157pub(crate) const DEFAULT_FB_RATE: f32 = 0.0;
158/// `fb_center_x` / `fb_center_y` default — the middle of the frame, in uv.
159pub(crate) const DEFAULT_FB_CENTER: f32 = 0.5;
160
161/// **The** `fb_*` vocabulary, in one place (ADR-0048).
162///
163/// Both sinks declare these names in their own `PARAMS` — the trails stage as
164/// part of the composite's global vocabulary, the attractor as part of its
165/// system's — because each has a `set_param` match that must be checkable against
166/// its own list (`core/tests/suite/preset.rs`'s drift guard reads the source text). This
167/// const is what those two lists are checked *against*, so "one vocabulary" is a
168/// test rather than a comment.
169pub(crate) const PARAMS: &[&str] = &[
170    "fb_zoom",
171    "fb_rotate",
172    "fb_dx",
173    "fb_dy",
174    "fb_center_x",
175    "fb_center_y",
176    "fb_warp",
177];
178
179/// The `fb_*` transform as a preset states it: rates per second, centre in uv.
180///
181/// Shared by **both** accumulation sinks (ADR-0048), which is the point: the
182/// trails stage and the attractor's internal trail resolve identical params
183/// through identical arithmetic into identical uniform bytes, and then transform
184/// their own buffer with the same shader snippet ([`TRANSFORM_WGSL`]). A second
185/// copy of this maths is how the two would drift apart.
186#[derive(Clone, Copy, Debug, PartialEq)]
187pub(crate) struct Transform {
188    /// Scale factor **per second**, applied as `zoom^dt`.
189    pub(crate) zoom: f32,
190    /// Radians per second.
191    pub(crate) rotate: f32,
192    /// Translation per second, in units of the target's **height** (so a `1.0`
193    /// crosses the frame vertically in a second, and the same value crosses it
194    /// horizontally in `aspect` seconds — one isotropic vocabulary).
195    pub(crate) dx: f32,
196    pub(crate) dy: f32,
197    /// The fixed point everything above turns about, in uv.
198    pub(crate) centre_x: f32,
199    pub(crate) centre_y: f32,
200    /// `fb_warp` — the strength of whichever [`Warp`] the preset selected, per
201    /// second like every other rate here. Inert at `0`, and inert at any value
202    /// when the selected kind is [`Warp::None`].
203    pub(crate) warp: f32,
204}
205
206impl Transform {
207    /// Every `fb_*` at its default: the past sits still, exactly as it did before
208    /// ADR-0048.
209    pub(crate) const IDENTITY: Self = Self {
210        zoom: DEFAULT_FB_ZOOM,
211        rotate: DEFAULT_FB_RATE,
212        dx: DEFAULT_FB_RATE,
213        dy: DEFAULT_FB_RATE,
214        centre_x: DEFAULT_FB_CENTER,
215        centre_y: DEFAULT_FB_CENTER,
216        warp: DEFAULT_FB_RATE,
217    };
218
219    /// Apply one `fb_*` name, returning whether this type owned it. A sink's
220    /// `set_param` delegates here rather than matching the seven names itself, so
221    /// the two sinks cannot disagree about what `fb_dx` means.
222    pub(crate) fn set_param(&mut self, name: &str, value: f32) -> bool {
223        match name {
224            "fb_zoom" => self.zoom = value,
225            "fb_rotate" => self.rotate = value,
226            "fb_dx" => self.dx = value,
227            "fb_dy" => self.dy = value,
228            "fb_center_x" => self.centre_x = value,
229            "fb_center_y" => self.centre_y = value,
230            "fb_warp" => self.warp = value,
231            _ => return false,
232        }
233        true
234    }
235
236    /// Whether this frame's transform moves nothing — the flag the shader
237    /// `select`s on, and the whole basis of ADR-0048's byte-identity claim.
238    ///
239    /// `kind` is the preset's `[feedback] warp`: `fb_warp` alone moves nothing
240    /// when no kind is selected, and no kind moves anything at zero strength, so
241    /// both have to be off their defaults before the warp is live.
242    ///
243    /// The centre is deliberately **not** tested: it is the fixed point, so with
244    /// no scale, rotation, translation or warp it names a point nothing moves
245    /// about. A non-finite term counts as identity for the same reason a decay
246    /// factor is clamped — a `NaN` uv would sample garbage for the rest of the run.
247    pub(crate) fn is_identity(&self, kind: Warp) -> bool {
248        let finite = self.zoom.is_finite()
249            && self.rotate.is_finite()
250            && self.dx.is_finite()
251            && self.dy.is_finite()
252            && self.centre_x.is_finite()
253            && self.centre_y.is_finite()
254            && self.warp.is_finite();
255        !finite
256            || (self.zoom == DEFAULT_FB_ZOOM
257                && self.rotate == DEFAULT_FB_RATE
258                && self.dx == DEFAULT_FB_RATE
259                && self.dy == DEFAULT_FB_RATE
260                && (kind == Warp::None || self.warp == DEFAULT_FB_RATE))
261    }
262
263    /// Pack `dt` seconds of this transform for [`TRANSFORM_WGSL`], about `aspect`
264    /// — **the render target's**, never an internal grid's (ADR-0037).
265    ///
266    /// Returns the three `vec4`s the snippet reads, in its order: `xf`, `tr`, `wp`.
267    pub(crate) fn pack(&self, dt: f32, aspect: f32, kind: Warp) -> [[f32; 4]; 3] {
268        let theta = self.rotate * dt;
269        let (sin, cos) = theta.sin_cos();
270        // `zoom^dt`: a factor per second, so two half-length frames scale the
271        // past by exactly what one full-length frame would.
272        let scale = self.zoom.powf(dt);
273        // Guard the reciprocal: a preset may sweep `fb_zoom` through 0 (a
274        // `[smoothing]` ease is continuous), and `1/0` is `inf` — every pixel
275        // would then sample the same texel forever.
276        let inv_scale = if scale.is_finite() && scale.abs() > f32::MIN_POSITIVE {
277            1.0 / scale
278        } else {
279            1.0
280        };
281        [
282            [cos, sin, inv_scale, aspect],
283            [self.dx * dt, self.dy * dt, self.centre_x, self.centre_y],
284            // Strength per second like the rest, so a warp's advance per frame is
285            // the same wall-clock gesture at any refresh. Zeroed when no kind is
286            // selected, so the shader's `kind` branch is the only thing that has
287            // to agree with the preset.
288            [
289                kind.code(),
290                if kind == Warp::None {
291                    0.0
292                } else {
293                    self.warp * dt
294                },
295                0.0,
296                0.0,
297            ],
298        ]
299    }
300}
301
302/// **The** transform, as WGSL — concatenated into the feedback body of *both*
303/// accumulation sinks (ADR-0048).
304///
305/// Written as free functions over explicit `vec4` arguments rather than against a
306/// named uniform, because the two sinks pack these terms into different uniform
307/// structs (the trails stage's carries a decay factor and an occlude; the
308/// attractor's carries a retention factor and an occlude). What they share is the
309/// arithmetic, and this is it — the alternative, ADR-0048's own "the cost is a
310/// second shader", would be two copies of a rotation that must agree forever.
311///
312/// The caller supplies `xf`, `tr` and `wp` exactly as [`Transform::pack`] returns
313/// them, and is responsible for the `select` that keeps the identity path on the
314/// literal sample uv.
315pub(crate) const TRANSFORM_WGSL: &str = r#"
316// The `[feedback] warp` roster, as the CPU writes it — keep in step with
317// `feedback::Warp::code`.
318const RLX_WARP_SWIRL:   f32 = 1.0;
319const RLX_WARP_RIPPLE:  f32 = 2.0;
320const RLX_WARP_FISHEYE: f32 = 3.0;
321
322// Radius (in frame-heights) at which the swirl has faded to ~1/e of its centre
323// strength. Just over half a frame-height, so the vortex is a whole-frame gesture
324// that still leaves the corners nearly still.
325const RLX_SWIRL_SIGMA: f32 = 0.35;
326// Ripple spatial frequency, rad per frame-height: ~2.9 wave crests between the
327// centre and the top edge.
328const RLX_RIPPLE_FREQ: f32 = 18.0;
329
330// The curated procedural warp, in the same centred isotropic space the affine
331// works in and about the same `fb_center_*`. Displaces the SOURCE coordinate, so
332// a positive strength moves the past the way the docs say.
333//
334// A `kind` selector rather than four pipelines: coexisting pipelines with matching
335// bind-group layouts mis-render on the DX12 WARP software adapter (ADR-0058; a
336// WARP reading, unverified on lavapipe as of 2026-10-06), and the branch here is
337// uniform across the draw anyway.
338fn rlx_warp_source(p: vec2<f32>, wp: vec4<f32>) -> vec2<f32> {
339    let kind = wp.x;
340    let k = wp.y;
341    let r = length(p);
342    if (kind == RLX_WARP_SWIRL) {
343        // Rotate by an angle that falls off as a Gaussian in radius — smooth
344        // everywhere, unlike a linear falloff's kink at the cutoff radius.
345        let a = k * exp(-(r * r) / (2.0 * RLX_SWIRL_SIGMA * RLX_SWIRL_SIGMA));
346        let c = cos(a);
347        let s = sin(a);
348        return vec2<f32>(p.x * c + p.y * s, p.y * c - p.x * s);
349    }
350    if (kind == RLX_WARP_RIPPLE) {
351        // Radial displacement by a standing wave in r. The guarded divide keeps
352        // the direction defined at the exact centre, where there is no direction.
353        let dir = p / max(r, 1e-4);
354        return p - dir * (k * sin(r * RLX_RIPPLE_FREQ));
355    }
356    if (kind == RLX_WARP_FISHEYE) {
357        return p * (1.0 + k * r * r);
358    }
359    return p;
360}
361
362// Where the pixel at `uv` was one frame ago — the INVERSE of the motion the params
363// name, because a destination pixel asks where its content came from.
364//
365// The centred coordinate is made isotropic by scaling x by `xf.w`, which is the
366// RENDER TARGET's aspect and never the accumulation grid's (ADR-0037), so the
367// rotation is a rotation and not a shear; the scale-back on the way out cancels it.
368fn rlx_source_uv(uv: vec2<f32>, xf: vec4<f32>, tr: vec4<f32>, wp: vec4<f32>) -> vec2<f32> {
369    let aspect = xf.w;
370    let centre = tr.zw;
371    var p = uv - centre;
372    p.x = p.x * aspect;
373    // Undo this frame's translation, then its rotation (by -theta: the transpose
374    // of R(theta)), then its scale — and last the warp, which therefore rides on
375    // top of the affine rather than being carried through it.
376    p = p - tr.xy;
377    p = vec2<f32>(p.x * xf.x + p.y * xf.y, p.y * xf.x - p.x * xf.y);
378    p = p * xf.z;
379    p = rlx_warp_source(p, wp);
380    p.x = p.x / aspect;
381    return p + centre;
382}
383
384// The transparent-border edge policy: `1.0` inside the accumulation, `0.0`
385// outside it. Off-frame reads contribute NOTHING — clamping would re-deposit the
386// border texel every frame until the edge became a permanent bar of colour.
387fn rlx_inside(uv: vec2<f32>) -> f32 {
388    return f32(all(uv >= vec2<f32>(0.0)) && all(uv <= vec2<f32>(1.0)));
389}
390"#;
391
392/// Two offscreen textures a feedback scene ping-pongs between. Held by named
393/// fields (not a `[_; 2]`) so read/write selection needs no array indexing on
394/// the hot path.
395pub(crate) struct PingPongField {
396    // Kept alive so the views stay valid. Read only by `read_texture`, which is
397    // test-only — hence the underscores, which are what keep a shipped build
398    // from calling these fields dead.
399    _tex_a: wgpu::Texture,
400    _tex_b: wgpu::Texture,
401    view_a: wgpu::TextureView,
402    view_b: wgpu::TextureView,
403    /// `true`: read from A, write to B. `swap` flips it each sub-step.
404    reading_a: bool,
405}
406
407impl PingPongField {
408    /// The field's texel format. `Rgba16Float` is renderable and filterable on
409    /// the wgpu targets we ship (DX12/Vulkan/Metal) — the R/G channels hold the
410    /// two Gray-Scott species with the headroom the slow gradients need
411    /// (ADR-0012 Risks: the `Rgba8Unorm` fallback would band).
412    pub(crate) const FORMAT: wgpu::TextureFormat = wgpu::TextureFormat::Rgba16Float;
413
414    /// Allocate the texture pair at a fixed internal `width`×`height` grid,
415    /// decoupled from the surface size (ADR-0012: the simulation is
416    /// resolution-independent). Contents are undefined until the scene's seed
417    /// pass writes every texel before the first sub-step reads it.
418    pub(crate) fn new(device: &wgpu::Device, width: u32, height: u32) -> Self {
419        let make = |label: &str| {
420            device.create_texture(&wgpu::TextureDescriptor {
421                label: Some(label),
422                size: wgpu::Extent3d {
423                    width,
424                    height,
425                    depth_or_array_layers: 1,
426                },
427                mip_level_count: 1,
428                sample_count: 1,
429                dimension: wgpu::TextureDimension::D2,
430                format: Self::FORMAT,
431                // `COPY_SRC` is here for **observability** and costs nothing on
432                // the backends we ship: it is what lets a probe read the field's
433                // own levels back rather than inferring them from the composite,
434                // which is where two plans' worth of tone defects hid (Plan 0109
435                // Phase 4). No shipped path copies from these textures.
436                usage: wgpu::TextureUsages::TEXTURE_BINDING
437                    | wgpu::TextureUsages::RENDER_ATTACHMENT
438                    | wgpu::TextureUsages::COPY_SRC,
439                view_formats: &[],
440            })
441        };
442        let tex_a = make("rlx-ppf-a");
443        let tex_b = make("rlx-ppf-b");
444        let view_a = tex_a.create_view(&wgpu::TextureViewDescriptor::default());
445        let view_b = tex_b.create_view(&wgpu::TextureViewDescriptor::default());
446        Self {
447            _tex_a: tex_a,
448            _tex_b: tex_b,
449            view_a,
450            view_b,
451            reading_a: true,
452        }
453    }
454
455    /// Texture A's view — a scene binds it once to build its A-read bind group.
456    pub(crate) fn view_a(&self) -> &wgpu::TextureView {
457        &self.view_a
458    }
459
460    /// Texture B's view — a scene binds it once to build its B-read bind group.
461    pub(crate) fn view_b(&self) -> &wgpu::TextureView {
462        &self.view_b
463    }
464
465    /// Whether A is the current read source (so the scene picks the matching
466    /// pre-built bind group without rebuilding one each sub-step).
467    pub(crate) fn reading_a(&self) -> bool {
468        self.reading_a
469    }
470
471    /// The view the next sub-step (or the present pass) samples from.
472    pub(crate) fn read_view(&self) -> &wgpu::TextureView {
473        if self.reading_a {
474            &self.view_a
475        } else {
476            &self.view_b
477        }
478    }
479
480    /// The view the next sub-step renders into.
481    pub(crate) fn write_view(&self) -> &wgpu::TextureView {
482        if self.reading_a {
483            &self.view_b
484        } else {
485            &self.view_a
486        }
487    }
488
489    /// The texture behind [`read_view`](Self::read_view) — what the next pass
490    /// samples, and so what the last one wrote. For measurement: a probe copies
491    /// it to a readback buffer and reads the field's own levels in the field's
492    /// own units, instead of reading the composite and arguing backwards through
493    /// `gamma`, `brightness` and the present remaps.
494    #[cfg(test)]
495    pub(crate) fn read_texture(&self) -> &wgpu::Texture {
496        if self.reading_a {
497            &self._tex_a
498        } else {
499            &self._tex_b
500        }
501    }
502
503    /// Flip read and write — call once after each sub-step's render pass.
504    pub(crate) fn swap(&mut self) {
505        self.reading_a = !self.reading_a;
506    }
507}
508
509#[cfg(test)]
510mod tests {
511    //! The **one vocabulary, two buffers** contract (ADR-0048). GPU-free: these are
512    //! facts about the rosters and the arithmetic, not about pixels.
513    #![allow(clippy::panic)]
514
515    use super::{PARAMS, Transform, Warp};
516
517    /// Both sinks declare **exactly** the shared `fb_*` vocabulary — no more, no
518    /// less.
519    ///
520    /// `core/tests/suite/preset.rs`'s drift guard cannot see these names: it reads
521    /// `set_param`'s match arms out of the source text, and both sinks *delegate*
522    /// the seven to [`Transform::set_param`] rather than matching them. That is the
523    /// right factoring — one implementation of what `fb_dx` means — and this is
524    /// what replaces the coverage it costs.
525    #[test]
526    fn both_sinks_declare_exactly_the_shared_fb_vocabulary() {
527        let sinks: [(&str, &[crate::render::scenes::ParamSpec]); 2] = [
528            ("trails stage", crate::render::trails::PARAMS),
529            ("attractor scene", crate::render::scenes::particles::PARAMS),
530        ];
531        for (label, declared) in sinks {
532            for name in PARAMS {
533                assert!(
534                    crate::render::scenes::declares(declared, name),
535                    "the {label} does not declare `{name}`, so a preset binding it \
536                     would only reach the other sink — see ADR-0048's routing \
537                     contract"
538                );
539            }
540            // ...and nothing `fb_`-shaped that the shared roster does not know
541            // about, which would be a name only one sink answered.
542            for spec in declared.iter().filter(|s| s.name.starts_with("fb_")) {
543                let name = spec.name;
544                assert!(
545                    PARAMS.contains(&name),
546                    "the {label} declares `{name}`, which is not in the shared \
547                     `feedback::PARAMS` roster — either add it there (so BOTH \
548                     sinks get it) or it does not belong in an `fb_` namespace"
549                );
550            }
551        }
552    }
553
554    /// [`Transform::set_param`] answers exactly the roster — the other half of the
555    /// guard above, since a declared name that the shared setter drops would be a
556    /// param both sinks list and neither applies.
557    #[test]
558    fn the_shared_setter_answers_exactly_the_roster() {
559        let mut t = Transform::IDENTITY;
560        for name in PARAMS {
561            assert!(
562                t.set_param(name, 0.25),
563                "`{name}` is in the roster but `Transform::set_param` drops it"
564            );
565        }
566        assert!(
567            !t.set_param("trails", 0.5),
568            "`trails` belongs to the stage, not to the shared transform"
569        );
570        assert!(!t.set_param("fb_nonsense", 1.0));
571    }
572
573    /// The identity is the whole basis of ADR-0048's byte-identity claim, so it is
574    /// asserted directly: every default moves nothing, and each term on its own
575    /// moves something.
576    #[test]
577    fn the_identity_is_exactly_the_defaults() {
578        assert!(Transform::IDENTITY.is_identity(Warp::None));
579
580        for name in PARAMS {
581            let mut t = Transform::IDENTITY;
582            t.set_param(name, 0.75);
583            // Two of the seven are still the identity on their own, and for
584            // different reasons. `fb_center_*` names the fixed *point*, and with
585            // nothing moving there is nothing for it to be the fixed point of.
586            // `fb_warp` is a strength for a kind this call does not select.
587            let inert_alone = name.starts_with("fb_center") || *name == "fb_warp";
588            assert_eq!(
589                t.is_identity(Warp::None),
590                inert_alone,
591                "with only `{name}` off its default, is_identity should be \
592                 {inert_alone}"
593            );
594        }
595
596        // `fb_warp` alone is inert — a strength with no kind selected — and comes
597        // alive the moment the preset names one.
598        let mut warped = Transform::IDENTITY;
599        warped.set_param("fb_warp", 2.0);
600        assert!(
601            warped.is_identity(Warp::None),
602            "a warp strength with no `[feedback] warp` kind selected moves nothing"
603        );
604        assert!(!warped.is_identity(Warp::Swirl));
605        assert!(
606            Transform::IDENTITY.is_identity(Warp::Swirl),
607            "a selected kind at zero strength moves nothing either"
608        );
609
610        // A non-finite term reads as identity rather than poisoning the sample uv
611        // for the rest of the run.
612        let mut broken = Transform::IDENTITY;
613        broken.set_param("fb_rotate", f32::NAN);
614        assert!(broken.is_identity(Warp::None));
615    }
616
617    /// The rates are **per second** (ADR-0019): at the capture step the packed
618    /// terms are exactly what one 1/60 s frame of the stated rate should be, and
619    /// the identity packs to a literal identity.
620    #[test]
621    fn the_pack_is_per_second_and_aspect_correcting() {
622        let dt = crate::render::scenes::FALLBACK_DT;
623        let [xf, tr, wp] = Transform::IDENTITY.pack(dt, 1.6, Warp::None);
624        assert_eq!(
625            xf,
626            [1.0, 0.0, 1.0, 1.6],
627            "identity: no rotation, unit scale"
628        );
629        assert_eq!(tr, [0.0, 0.0, 0.5, 0.5], "identity: no shift, centred");
630        assert_eq!(wp, [0.0; 4], "identity: no warp");
631
632        // `fb_zoom` is a factor per second, so a 1/60 s frame takes its 60th root.
633        let mut zoomed = Transform::IDENTITY;
634        zoomed.set_param("fb_zoom", 2.0);
635        let [xf, _, _] = zoomed.pack(dt, 1.0, Warp::None);
636        let scale = 1.0 / xf[2];
637        assert!(
638            (scale.powf(60.0) - 2.0).abs() < 1e-3,
639            "sixty frames of `fb_zoom = 2` must double the past, got {}",
640            scale.powf(60.0)
641        );
642
643        // Two half-length frames compose to one full-length one — the property
644        // that makes the look identical at 120 Hz and 60 Hz.
645        let [half, _, _] = zoomed.pack(dt / 2.0, 1.0, Warp::None);
646        let composed = (1.0 / half[2]) * (1.0 / half[2]);
647        assert!((composed - scale).abs() < 1e-6);
648
649        // `fb_zoom = 0` cannot produce an infinite reciprocal: a `[smoothing]`
650        // ease sweeps continuously and would pass through it.
651        let mut collapsed = Transform::IDENTITY;
652        collapsed.set_param("fb_zoom", 0.0);
653        let [xf, _, _] = collapsed.pack(dt, 1.0, Warp::None);
654        assert!(xf[2].is_finite(), "a zero zoom must not pack an infinity");
655    }
656}