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