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}