rlx_core/render/scenes/particles/ifs.rs
1//! Iterated function systems: the `attractor` scene's fifth family (ADR-0075,
2//! Plan 0062).
3//!
4//! An IFS is the same GPU chaos game the strange-attractor families already run,
5//! with a different step: instead of one map iterated by every particle, there
6//! are **four** affine maps and each particle draws one at random every step. The
7//! orbit converges onto the system's attractor — a Barnsley fern, a bare tree, a
8//! dragon curve, a Sierpinski triangle, a spiral — rather than onto a strange
9//! attractor's filigree.
10//!
11//! **What this module owns is the safety property**, and it is why the IFS lives
12//! in its own file rather than as four more lines of `mod.rs`. De Jong and
13//! Clifford are bounded for *any* coefficients, so a preset can drive them
14//! anywhere; an IFS converges only while every map contracts, and one map past
15//! unit operator norm sends every position to infinity and then to `NaN`, killing
16//! the particle buffer for the rest of the session. Everything here is arranged
17//! so that cliff is **unreachable** rather than guarded against — see ADR-0075.
18//!
19//! The mechanism is the parameterization. Every map is carried as the **singular
20//! value decomposition** of its linear part, `M = R(θ)·diag(sx, sy)·R(φ)` with
21//! `sy` signed, because `R` is an isometry and so contractivity is exactly
22//! `max(|sx|, |sy|) < 1` — a comparison on two numbers rather than a property of
23//! a matrix. Morphing interpolates there (angles cannot affect contractivity, and
24//! an interpolated singular value is below 1 because both endpoints are), and the
25//! levers are built the same way.
26//!
27//! Nothing in this module touches the GPU or a clock. It resolves a figure to a
28//! plain 2x3 affine table plus a cumulative probability table, which is what the
29//! compute step receives.
30
31// Hot-path panic-denial pragma (Plan 0002 Phase 2, extended to scenes by Plan
32// 0003 Phase 0). Resolved once per frame on the render path.
33#![deny(
34 clippy::unwrap_used,
35 clippy::expect_used,
36 clippy::indexing_slicing,
37 clippy::panic,
38 clippy::unreachable
39)]
40
41/// How many maps every curated table carries.
42///
43/// **Exactly four, always** — a figure with fewer duplicates one at probability
44/// `0`. The shader's map choice is an unrolled four-way branch (the reason
45/// `Basis::masks` uses one-hot selectors rather than
46/// indices: WGSL will not dynamically index outside addressable storage and the
47/// backends disagree about the rest), so the count is structural rather than a
48/// convenience.
49pub const MAPS: usize = 4;
50
51/// Which curated figure the IFS draws.
52///
53/// A small closed set on purpose (ADR-0075). Twenty-four free affine
54/// coefficients with a contractivity cliff is close to unauthorable — most
55/// random tables are a blob or a diverging cloud — so the preset surface gets
56/// five hand-authored figures and a continuous path between them instead.
57#[derive(Debug, Clone, Copy, PartialEq, Eq)]
58pub enum IfsFigure {
59 /// Barnsley's fern — the canonical organic fractal.
60 Fern,
61 /// Barnsley's bare tree: a trunk that forks at ±45° all the way down.
62 Tree,
63 /// The Heighway dragon — two maps, and nothing organic about it.
64 Dragon,
65 /// The Sierpinski triangle. **Here as a correctness fixture as much as a
66 /// look**: its exact self-similarity makes a wrong implementation obvious at
67 /// a glance, and it is the least organic thing in a plan whose brief was
68 /// "organic" (ADR-0075).
69 Sierpinski,
70 /// A logarithmic spiral arm with two satellite maps.
71 Spiral,
72}
73
74/// One map's raw affine coefficients: `x' = a·x + b·y + e`, `y' = c·x + d·y + f`.
75///
76/// The **output** form: what [`recompose`] produces and what the GPU is handed.
77/// It is also the form the curated tables are *authored* in, because published
78/// IFS coefficients are quoted this way and a reviewer can check them against a
79/// source. Nothing morphs or levers here — that all happens in [`IfsMap`].
80#[derive(Debug, Clone, Copy, PartialEq)]
81pub struct Affine {
82 /// Linear part, row 0 column 0.
83 pub a: f32,
84 /// Linear part, row 0 column 1.
85 pub b: f32,
86 /// Linear part, row 1 column 0.
87 pub c: f32,
88 /// Linear part, row 1 column 1.
89 pub d: f32,
90 /// Translation in `x` — never enters contractivity (ADR-0075).
91 pub e: f32,
92 /// Translation in `y` — never enters contractivity (ADR-0075).
93 pub f: f32,
94}
95
96/// One map, **decomposed**: `M = R(θ)·diag(sx, sy)·R(φ)`, a translation, and a
97/// selection probability.
98///
99/// This is the space every morph and every lever acts in, and the reason the
100/// whole family is safe by construction rather than by a guard.
101#[derive(Debug, Clone, Copy, PartialEq)]
102pub struct IfsMap {
103 /// The rotation applied **after** the scale.
104 pub theta: f32,
105 /// The rotation applied **before** the scale.
106 pub phi: f32,
107 /// The larger singular value — non-negative, and `>= |sy|` by construction.
108 pub sx: f32,
109 /// The smaller singular value, **signed** so a reflection is representable.
110 /// The fern's `f₄` has determinant `−0.109`; a parameterization that forces
111 /// this non-negative reproduces the fern with its right-hand frond wrong,
112 /// and silently.
113 pub sy: f32,
114 /// The translation `(e, f)`. Does not enter contractivity at all, which is
115 /// what makes `lean` an unconditionally safe lever.
116 pub t: [f32; 2],
117 /// Selection probability. Changes **where** points land, never whether the
118 /// orbit converges — which is what makes `bias` safe too.
119 pub p: f32,
120}
121
122impl IfsMap {
123 /// This map's operator norm — the number the whole safety argument is stated
124 /// against. Contractive exactly when this is below 1.
125 ///
126 /// Written as `max(|sx|, |sy|)` rather than as `sx`, even though `sx` is the
127 /// larger by construction and stays so under every operation here (a lerp of
128 /// two orderings preserves the ordering; a uniform scale preserves it). The
129 /// property is about the operator norm, and spelling it as the property is
130 /// what keeps a later edit from quietly making `sx` the wrong answer.
131 pub fn sigma_max(&self) -> f32 {
132 self.sx.abs().max(self.sy.abs())
133 }
134
135 /// The affine this map recomposes to.
136 pub fn to_affine(&self) -> Affine {
137 let (a, b, c, d) = recompose(self.theta, self.phi, self.sx, self.sy);
138 let [e, f] = self.t;
139 Affine { a, b, c, d, e, f }
140 }
141}
142
143/// A curated figure, fully resolved: four maps in **canonical order** — index 0
144/// the trunk or dominant map, 1 the main body, 2 the left branch, 3 the right
145/// branch.
146///
147/// **The order is load-bearing and nothing enforces it.** Phase 3 pairs maps by
148/// index when it morphs one figure into another, so a table authored with its
149/// trunk at index 2 morphs that trunk into its partner's left branch and every
150/// intermediate figure is ugly. This is a comment-and-review property; the
151/// `authored` tables below each name their four roles.
152#[derive(Debug, Clone, Copy, PartialEq)]
153pub struct IfsTable {
154 /// The four maps, in canonical order.
155 pub maps: [IfsMap; MAPS],
156}
157
158impl IfsTable {
159 /// The largest operator norm in the table — below 1 exactly when the whole
160 /// system converges.
161 pub fn sigma_max(&self) -> f32 {
162 self.maps
163 .iter()
164 .fold(0.0f32, |acc, m| acc.max(m.sigma_max()))
165 }
166}
167
168/// What the compute step receives: the four linear parts, the four translations
169/// packed two per `vec4`, and the **cumulative** probabilities the shader
170/// compares a unit draw against.
171///
172/// Cumulative rather than raw because the shader's job is then three compares
173/// against a rising table instead of a running sum it would have to recompute
174/// per particle per step. The last entry is `1.0` by construction and is never
175/// read — the fourth map is the `else` arm, which is also what makes a draw of
176/// exactly `1.0` land somewhere legal.
177#[derive(Debug, Clone, Copy, PartialEq)]
178pub struct IfsPacked {
179 /// Per map: `a, b, c, d`.
180 pub linear: [[f32; 4]; MAPS],
181 /// Four `(e, f)` translation pairs, two per row.
182 pub translate: [[f32; 4]; 2],
183 /// `c0, c1, c2, 1.0`.
184 pub cumulative_p: [f32; MAPS],
185 /// The four respawn targets (ADR-0087), packed two `(x, y)` per row exactly
186 /// as [`translate`](Self::translate) is. Straight from [`fixed_points`], so
187 /// the padded slots already duplicate a drawn map and the shader picks one of
188 /// four with no branch and no knowledge of the probability table.
189 pub fixed: [[f32; 4]; 2],
190 /// The reciprocal of [`skeleton_scale`] (ADR-0088) — what the step shader
191 /// multiplies a raw nearest-fixed-point distance by to get the `[0, 1]`-ish
192 /// colour coordinate it stores on the particle.
193 ///
194 /// Shipped as a reciprocal rather than as the diameter because the shader
195 /// then multiplies where it would otherwise divide, per particle per step.
196 pub root_recip: f32,
197}
198
199impl IfsPacked {
200 /// The all-zero payload the four map families upload.
201 ///
202 /// They never read it — the step shader's IFS arm is the only consumer — but
203 /// the uniform is one struct for every family, so it has to carry
204 /// *something*. Zeros rather than a stray fern: a family that reached this
205 /// data by mistake then draws nothing rather than a second figure.
206 pub const ZERO: Self = Self {
207 linear: [[0.0; 4]; MAPS],
208 translate: [[0.0; 4]; 2],
209 cumulative_p: [0.0; MAPS],
210 fixed: [[0.0; 4]; 2],
211 // Zero rather than the floor's reciprocal, for the same reason the table
212 // above is zeroed: a family that reached this data by mistake reads a
213 // distance of exactly 0 everywhere, which is an inert channel rather than
214 // a gradient measured against somebody else's figure.
215 root_recip: 0.0,
216 };
217}
218
219/// The singular value decomposition of a 2x2, as `(θ, φ, sx, sy)` with
220/// `M = R(θ)·diag(sx, sy)·R(φ)` and `sy` **signed**.
221///
222/// Closed form, not an iteration. Writing out `R(θ)·diag·R(φ)` and collecting
223/// terms gives four combinations that separate cleanly:
224///
225/// ```text
226/// (a+d)/2 = (sx+sy)/2 · cos(θ+φ) (c-b)/2 = (sx+sy)/2 · sin(θ+φ)
227/// (a-d)/2 = (sx-sy)/2 · cos(θ-φ) (c+b)/2 = (sx-sy)/2 · sin(θ-φ)
228/// ```
229///
230/// so the two magnitudes come from two hypotenuses and the two angle sums from
231/// two `atan2`s. `sx = Q + R` and `sy = Q - R` — and because `Q` and `R` are
232/// both non-negative, **`sy` carries the sign of the determinant for free**,
233/// which is the reflection the fern's `f₄` needs. `sx ≥ |sy|` falls out the same
234/// way, so `sx` is always the larger singular value.
235///
236/// `σ₁σ₂ = det M` checks every row; the round-trip test asserts it.
237pub fn decompose(a: f32, b: f32, c: f32, d: f32) -> (f32, f32, f32, f32) {
238 let e = (a + d) * 0.5;
239 let f = (a - d) * 0.5;
240 let g = (c + b) * 0.5;
241 let h = (c - b) * 0.5;
242 let q = e.hypot(h);
243 let r = f.hypot(g);
244 // `atan2(0, 0)` is 0 rather than a `NaN` in Rust, so a zero map — which is
245 // reachable, since a padded slot may be anything — decomposes to all zeros
246 // instead of poisoning the table.
247 let sum = h.atan2(e);
248 let diff = g.atan2(f);
249 ((sum + diff) * 0.5, (sum - diff) * 0.5, q + r, q - r)
250}
251
252/// The inverse of [`decompose`] — `R(θ)·diag(sx, sy)·R(φ)`, multiplied out.
253///
254/// **The one place the SVD becomes a matrix again**, and the only arithmetic on
255/// the path from a preset's levers to the GPU. Everything upstream of it works
256/// on `(θ, φ, sx, sy)`, where safety is a comparison.
257pub fn recompose(theta: f32, phi: f32, sx: f32, sy: f32) -> (f32, f32, f32, f32) {
258 let (st, ct) = theta.sin_cos();
259 let (sp, cp) = phi.sin_cos();
260 (
261 sx * ct * cp - sy * st * sp,
262 -sx * ct * sp - sy * st * cp,
263 sx * st * cp + sy * ct * sp,
264 -sx * st * sp + sy * ct * cp,
265 )
266}
267
268/// The factor Barnsley's published spiral coefficients are scaled by.
269///
270/// **Not cosmetic.** The published table's dominant map has `σ_max = 0.9865`,
271/// which is contractive — so the figure is correct — but sits *above* the `0.97`
272/// ceiling `vigor` clamps to, so the clamp would fire at neutral levers and
273/// silently shrink the figure the moment a preset touched the lever. At `0.94`
274/// the arm's `σ_max` is `0.9273`, leaving the same order of headroom the fern's
275/// `0.851` has. The visible effect is a spiral whose arm decays faster, i.e.
276/// fewer visible turns.
277///
278/// Applied to the linear part only. Scaling the translations too would move the
279/// figure's fixed points and change what it is; this changes only the pitch.
280const SPIRAL_ARM: f32 = 0.94;
281
282impl IfsFigure {
283 /// Every curated figure, for the sweeps that must cover the whole roster.
284 ///
285 /// A named constant rather than a literal at each call site: the safety
286 /// argument is a *sweep* property (`max σ < 1` for every figure, every pair,
287 /// every lever extreme), and a test that iterates a hand-written list is one
288 /// forgotten entry away from proving it about four of five figures.
289 pub const ALL: [Self; 5] = [
290 IfsFigure::Fern,
291 IfsFigure::Tree,
292 IfsFigure::Dragon,
293 IfsFigure::Sierpinski,
294 IfsFigure::Spiral,
295 ];
296
297 /// Parse a `[particles] family` name, or `None` if unknown.
298 pub fn from_name(name: &str) -> Option<Self> {
299 Some(match name {
300 "fern" => IfsFigure::Fern,
301 "tree" => IfsFigure::Tree,
302 "dragon" => IfsFigure::Dragon,
303 "sierpinski" => IfsFigure::Sierpinski,
304 "spiral" => IfsFigure::Spiral,
305 _ => return None,
306 })
307 }
308
309 /// The `[particles] family` name this figure parses from — the inverse of
310 /// [`from_name`](Self::from_name), for diagnostics and for the round-trip
311 /// test that keeps the two in step.
312 pub fn name(self) -> &'static str {
313 match self {
314 IfsFigure::Fern => "fern",
315 IfsFigure::Tree => "tree",
316 IfsFigure::Dragon => "dragon",
317 IfsFigure::Sierpinski => "sierpinski",
318 IfsFigure::Spiral => "spiral",
319 }
320 }
321
322 /// The curated table **as authored** — raw affine coefficients and
323 /// probabilities, in canonical order, each row named by its role.
324 ///
325 /// **The tables live in this form and are decomposed on the way out**
326 /// ([`table`](Self::table)), rather than being stored as twenty
327 /// hand-transcribed `(θ, φ, sx, sy)` quadruples. The published coefficients
328 /// are checkable against a source by eye and the SVD literals would not be,
329 /// so a transcription slip in the decomposed form would be a wrong figure
330 /// nobody could review — and it would buy nothing, because the decomposition
331 /// is four hypotenuses and four `atan2`s per map, computed once per preset
332 /// switch, off the hot path.
333 ///
334 /// Every table is padded to exactly [`MAPS`] by duplicating a map at
335 /// probability `0`.
336 fn authored(self) -> [(Affine, f32); MAPS] {
337 // Named rather than positional, so a row and its role cannot drift apart
338 // in a diff.
339 let map = |a, b, c, d, e, f, p| (Affine { a, b, c, d, e, f }, p);
340 match self {
341 // Barnsley's canonical fern. Three rows carry properties later
342 // phases lean on: `f₁` is rank 1 (`det = 0` — the stem is a line),
343 // `f₂` has the table's largest singular value at `0.851`, and `f₄`
344 // is orientation-reversing (`det = −0.1088`).
345 IfsFigure::Fern => [
346 // a b c d e f p role
347 map(0.00, 0.00, 0.00, 0.16, 0.0, 0.00, 0.01), // stem
348 map(0.85, 0.04, -0.04, 0.85, 0.0, 1.60, 0.85), // body
349 map(0.20, -0.26, 0.23, 0.22, 0.0, 1.60, 0.07), // left frond
350 map(-0.15, 0.28, 0.26, 0.24, 0.0, 0.44, 0.07), // right frond
351 ],
352 // Barnsley's bare tree. The two branch maps are `0.594·R(±45°)`;
353 // `+45°` turns the upward growth to the left, so it is index 2.
354 IfsFigure::Tree => [
355 map(0.00, 0.00, 0.00, 0.50, 0.0, 0.00, 0.05), // trunk
356 map(0.10, 0.00, 0.00, 0.10, 0.0, 0.20, 0.15), // body
357 map(0.42, -0.42, 0.42, 0.42, 0.0, 0.20, 0.40), // left branch
358 map(0.42, 0.42, -0.42, 0.42, 0.0, 0.20, 0.40), // right branch
359 ],
360 // The Heighway dragon: two maps, each `0.7071·R(45°)` / `R(135°)`.
361 // They *are* the figure's left and right halves, so they take the
362 // branch slots and the two dominant slots duplicate them at zero —
363 // which is what a padded table means.
364 IfsFigure::Dragon => [
365 map(0.50, -0.50, 0.50, 0.50, 0.0, 0.00, 0.00), // (pad of 2)
366 map(-0.50, -0.50, 0.50, -0.50, 1.0, 0.00, 0.00), // (pad of 3)
367 map(0.50, -0.50, 0.50, 0.50, 0.0, 0.00, 0.50), // left half
368 map(-0.50, -0.50, 0.50, -0.50, 1.0, 0.00, 0.50), // right half
369 ],
370 // The Sierpinski triangle: three half-scale copies at the corners.
371 // The apex map takes the trunk slot (it is the one that grows
372 // upward) and the body slot duplicates it at zero.
373 IfsFigure::Sierpinski => [
374 map(0.50, 0.00, 0.00, 0.50, 0.25, 0.433, 1.0 / 3.0), // apex
375 map(0.50, 0.00, 0.00, 0.50, 0.25, 0.433, 0.0), // (pad of 0)
376 map(0.50, 0.00, 0.00, 0.50, 0.00, 0.000, 1.0 / 3.0), // lower left
377 map(0.50, 0.00, 0.00, 0.50, 0.50, 0.000, 1.0 / 3.0), // lower right
378 ],
379 // Barnsley's spiral: one dominant arm map (scaled by [`SPIRAL_ARM`])
380 // plus two small satellites that seed the arm's substructure. The
381 // arm is the dominant map, the satellites are the branches, and the
382 // body slot duplicates the left satellite at zero.
383 IfsFigure::Spiral => [
384 map(
385 0.787879 * SPIRAL_ARM,
386 -0.424242 * SPIRAL_ARM,
387 0.242424 * SPIRAL_ARM,
388 0.859848 * SPIRAL_ARM,
389 1.758647,
390 1.408065,
391 0.90,
392 ), // arm
393 map(
394 0.181818, -0.136364, 0.090909, 0.181818, 6.086107, 1.568035, 0.0,
395 ), // (pad of 3)
396 map(
397 -0.121212, 0.257576, 0.151515, 0.053030, -6.721654, 1.377236, 0.05,
398 ), // left satellite
399 map(
400 0.181818, -0.136364, 0.090909, 0.181818, 6.086107, 1.568035, 0.05,
401 ), // right satellite
402 ],
403 }
404 }
405
406 /// The curated table, decomposed — the form everything downstream works in.
407 pub fn table(self) -> IfsTable {
408 let mut maps = [IfsMap {
409 theta: 0.0,
410 phi: 0.0,
411 sx: 0.0,
412 sy: 0.0,
413 t: [0.0, 0.0],
414 p: 0.0,
415 }; MAPS];
416 for (slot, (affine, p)) in maps.iter_mut().zip(self.authored()) {
417 let (theta, phi, sx, sy) = decompose(affine.a, affine.b, affine.c, affine.d);
418 *slot = IfsMap {
419 theta,
420 phi,
421 sx,
422 sy,
423 t: [affine.e, affine.f],
424 p,
425 };
426 }
427 IfsTable { maps }
428 }
429
430 /// `(world scale, centre)` — the projection's framing for this figure at the
431 /// reference aspect, **as a fallback**.
432 ///
433 /// The render path takes its framing from [`FitLut`] instead, which follows
434 /// the morph and knows the target's aspect. This survives for
435 /// the two callers that have neither: the seeded scatter, and the CPU
436 /// transcription of the draw shader that the projection tests run.
437 ///
438 /// The fern is the reason
439 /// `Basis::projection` carries a full
440 /// three-component centre rather than a z-centre: it spans `y ∈ [0, 10]` and
441 /// is not origin-centred, so a projection that subtracts nothing puts its
442 /// root on the bottom edge and its canopy off the top.
443 pub fn frame(self) -> (f32, [f32; 3]) {
444 let (centre, half) = self.extent();
445 let [cx, cy] = centre;
446 (fit_scale(half, REFERENCE_ASPECT), [cx, cy, 0.0])
447 }
448
449 /// The figure's sampled bounding box as `(centre, half-extent)`, in its own
450 /// world units.
451 ///
452 /// Measured literals rather than a call to [`chaos_extent`], because
453 /// [`frame`](Self::frame) reaches this through
454 /// [`projection`](super::AttractorFamily::projection), which the uniform
455 /// packing calls **every frame** — a few thousand iterations there would be
456 /// a chaos game per frame to answer a question whose answer never changes.
457 /// The 400 000-iteration run they come from is reproduced by
458 /// `the_chaos_reference_is_deterministic_and_measures_the_figure`, so the
459 /// two cannot drift.
460 fn extent(self) -> ([f32; 2], [f32; 2]) {
461 match self {
462 IfsFigure::Fern => ([0.237, 4.999], [2.419, 4.968]),
463 IfsFigure::Tree => ([0.000, 0.226], [0.239, 0.213]),
464 IfsFigure::Dragon => ([0.417, 0.167], [0.750, 0.500]),
465 IfsFigure::Sierpinski => ([0.500, 0.433], [0.500, 0.433]),
466 IfsFigure::Spiral => ([-0.008, 4.352], [7.024, 3.916]),
467 }
468 }
469
470 /// The seeded initial-scatter box, `(half-spread, centre)` per axis.
471 ///
472 /// The figure's own bounding box, so the initial fill lands *over* the
473 /// attractor and converges onto it rather than travelling to it. The
474 /// probability-weighted per-step contraction is `0.742` for the fern, so a
475 /// displacement shrinks a thousandfold in ~23 steps — 0.39 s at the fixed
476 /// step, which is the startup haze ADR-0075 records and the successor plan's
477 /// staggered respawn removes.
478 ///
479 /// `z` is zero: the family is two-dimensional and takes the default
480 /// [`Basis::XY`](super::Basis::XY).
481 pub fn seed_box(self) -> ([f32; 3], [f32; 3]) {
482 let (centre, half) = self.extent();
483 let ([cx, cy], [hx, hy]) = (centre, half);
484 ([hx, hy, 0.0], [cx, cy, 0.0])
485 }
486
487 /// Resolve this figure to the compute step's payload.
488 ///
489 /// Phases 3–5 grow this into `resolve(a, b, morph, levers)`, which is where
490 /// the whole safety argument lives — and it stays a pure function with no GPU
491 /// and no clock, so a sweep asserting `max σ < 1` over every figure pair and
492 /// every lever extreme is an ordinary unit test.
493 pub fn packed(self) -> IfsPacked {
494 pack(&self.table())
495 }
496}
497
498/// The fraction of the frame a fitted figure occupies along its binding axis
499/// **at zero rotation** (ADR-0103).
500///
501/// The remaining `0.12` is margin against what the fit under-measures, not
502/// against what turns: a figure of aspect `a = hx / hy` reaches
503/// `FRAME_FILL · sqrt(1 + a²)` of the frame at its worst spin angle, so only a
504/// figure at or under `sqrt(1/FRAME_FILL² − 1)` — about `1.85x` taller than
505/// wide — stays inside at every angle. Of the shipped roster only the fern
506/// does. See `the_fit_frames_a_figure_that_does_not_turn`.
507const FRAME_FILL: f32 = 0.88;
508/// The aspect [`IfsFigure::frame`]'s fallback fits against.
509const REFERENCE_ASPECT: f32 = 16.0 / 9.0;
510
511/// The world scale that fits a figure of this half-extent inside the frame.
512///
513/// **Aspect-aware, and it has to be** (ADR-0037's lesson in its own costume).
514/// The vertex shader divides world `x` by the target's aspect and leaves `y`
515/// alone, so the horizontal budget is `aspect` world units and the vertical is
516/// `1`. A single scale fitted at 16:9 leaves the dragon and the spiral — both
517/// about twice as wide as they are tall — hanging out of a portrait window.
518/// Taking the smaller of the two fits is what makes "inside the frame" true at
519/// every aspect rather than at one.
520///
521/// **Rotation is a separate axis, and this does not cover it** (ADR-0103). The
522/// box being fitted is axis-aligned, and `project`'s 2D branch rotates it by
523/// the spin phase afterwards — `spin` defaults to on — so what this guarantees
524/// is "inside the frame at neutral levers **and zero rotation**". A rotated
525/// figure reaches `hypot(hx, hy)` on both axes at its worst angle, which for
526/// every shipped figure but the fern is outside the frame. `zoom` is the
527/// recourse, the same one the levers have.
528///
529/// The aspect comes from the **render target**, never from the trail grid: the
530/// grid is a resolution, not a shape (ADR-0037), and the present is a plain
531/// stretch, so the grid's own aspect cancels out.
532pub fn fit_scale(half: [f32; 2], aspect: f32) -> f32 {
533 let [hx, hy] = half;
534 // `half()` floors both axes above zero, so neither division can blow up;
535 // a non-positive aspect would, and is not reachable from a real target.
536 let vertical = FRAME_FILL / hy;
537 let horizontal = FRAME_FILL * aspect.max(1e-3) / hx;
538 vertical.min(horizontal)
539}
540
541/// Interpolate two angles along the **shortest arc**.
542///
543/// Not a plain lerp, and the difference is visible rather than pedantic: two
544/// maps whose rotations are `+3.0` and `−3.0` rad are a tenth of a turn apart,
545/// and a plain lerp would walk the long way round — the branch would sweep
546/// almost all the way through the figure and back instead of nudging across the
547/// discontinuity.
548///
549/// Contractivity is untouched whatever this returns: `R` is an isometry, so an
550/// angle cannot make a map expand. That is why the morph needs no guard.
551fn lerp_angle(a: f32, b: f32, t: f32) -> f32 {
552 use std::f32::consts::{PI, TAU};
553 let mut delta = (b - a) % TAU;
554 if delta > PI {
555 delta -= TAU;
556 } else if delta < -PI {
557 delta += TAU;
558 }
559 a + delta * t
560}
561
562fn lerp(a: f32, b: f32, t: f32) -> f32 {
563 a + (b - a) * t
564}
565
566/// The ceiling every map's operator norm is held under (ADR-0075).
567///
568/// **A look constant with no principled value**, and it is worth being honest
569/// about that. It is far enough below `1.0` that floating-point error cannot
570/// cross it, and it leaves the fern's largest singular value — `0.851` — about
571/// 17 % to grow, which is what `vigor` has to work with. Whether that is enough
572/// to feel like a surge is a question for the content pass; if it is too tight,
573/// **this constant is the lever and widening the parameterization is not**.
574///
575/// A preset asking for more `vigor` than this allows gets silence rather than an
576/// error — the same undiscoverable-ceiling shape `presets/README.md` already
577/// documents for `bloom_threshold` and `perspective`.
578pub const SIGMA_CEILING: f32 = 0.97;
579
580/// How far [`Levers::bias`] may shift the sampling weight, as a fraction.
581///
582/// At `1.0` a full-scale `bias` would take one group's probability to exactly
583/// zero, which stops drawing part of the figure rather than re-weighting it —
584/// the orbit would then converge onto the *sub*-system of the maps that remain,
585/// a much smaller attractor that the neutral-lever fit does not frame. `0.6`
586/// keeps every map drawn at both extremes.
587const BIAS_DEPTH: f32 = 0.6;
588
589/// The four audio-driven shape levers (ADR-0075), applied in SVD space.
590///
591/// **Built to be safe rather than checked**, which is the whole point of the
592/// parameterization: `curl` and `lean` are rotations and cannot affect
593/// contractivity at all, `bias` moves probabilities and changes only where
594/// points land, and `vigor` is the one that touches the singular values — so it
595/// is the one, and the only one, behind a clamp.
596#[derive(Debug, Clone, Copy, PartialEq)]
597pub struct Levers {
598 /// Radians added to **every** map's `θ`. Fronds curl and uncurl.
599 /// Unconditionally safe: `R` is an isometry.
600 pub curl: f32,
601 /// Multiplier on every singular value, under [`SIGMA_CEILING`]. A bushier,
602 /// deeper, denser figure — and the only lever that can reach the cliff.
603 pub vigor: f32,
604 /// Radians every translation vector is rotated about the origin by, bending
605 /// the plant. Translations do not enter contractivity, so this is
606 /// unconditionally safe.
607 pub lean: f32,
608 /// Shifts sampling weight between the **body** maps (canonical indices 0
609 /// and 1) and the **branch** maps (2 and 3), renormalizing. The shape is
610 /// untouched and only the density distribution moves — the cheapest
611 /// genuinely organic response in the set.
612 ///
613 /// Inert on the dragon, whose two real maps both live in the branch slots
614 /// (its body slots are the padding), so there is nothing to shift weight
615 /// away from. Worth a note in the content pass rather than a special case.
616 pub bias: f32,
617}
618
619impl Levers {
620 /// Every lever at rest. The fit is built here, and `resolve` at these values
621 /// is **bit-identical** to `resolve` with no levers at all — every operation
622 /// below is guarded so that neutrality is exact rather than approximate.
623 pub const NEUTRAL: Self = Self {
624 curl: 0.0,
625 vigor: 1.0,
626 lean: 0.0,
627 bias: 0.0,
628 };
629
630 /// The documented extremes, for the sweep that has to cover them.
631 ///
632 /// `curl` and `lean` are angles, so their "extreme" is a look choice rather
633 /// than a limit — a full turn is the same figure. `vigor` is quoted well
634 /// past what [`SIGMA_CEILING`] will grant, precisely so the sweep exercises
635 /// the clamp; `bias` is quoted at the ends of its own range.
636 pub const EXTREMES: [Self; 2] = [
637 Self {
638 curl: -1.5,
639 vigor: 0.4,
640 lean: -1.5,
641 bias: -1.0,
642 },
643 Self {
644 curl: 1.5,
645 vigor: 2.5,
646 lean: 1.5,
647 bias: 1.0,
648 },
649 ];
650}
651
652/// **The pure function everything safety-critical lives in** — no GPU, no clock,
653/// no randomness (ADR-0075).
654///
655/// Interpolates two figures **in SVD space**, map by map, paired by index:
656/// singular values and translations lerped, angles taken along the shortest arc,
657/// probabilities lerped. Then applies the four levers, in the same space.
658///
659/// The morph is contractive by construction and there is no clamp making it so.
660/// A lerp of two values below 1 is below 1, and the angles do not enter
661/// contractivity at all — so if both endpoints converge, so does every point on
662/// the path between them. Of the levers, only `vigor` can reach the cliff, and
663/// it is held under [`SIGMA_CEILING`] by one comparison on one number.
664///
665/// Degenerate maps stay legal rather than being special-cased: the fern's stem
666/// is rank 1 (`sx = 0`), and morphing a reflection into a non-reflection passes
667/// through `sy = 0`. Both are contractions — the branch momentarily collapses to
668/// a line and recovers.
669pub fn resolve(a: &IfsTable, b: &IfsTable, morph: f32, levers: Levers) -> IfsTable {
670 apply_levers(morph_tables(a, b, morph), levers)
671}
672
673/// The morph half of [`resolve`], separated so the fit — which must never see a
674/// lever — can call it and be structurally unable to pass one.
675fn morph_tables(a: &IfsTable, b: &IfsTable, morph: f32) -> IfsTable {
676 // Clamped rather than trusted: `morph` is a bindable param, so a preset
677 // expression can hand this anything. Outside [0, 1] the lerp would
678 // extrapolate, and an extrapolated singular value is exactly the one number
679 // that can leave the contractive ball.
680 //
681 // A `NaN` is reachable too — `0/0` is a legal preset expression — and
682 // `f32::clamp` *propagates* it rather than clamping it, which would put a
683 // `NaN` in the affine table and kill the buffer as surely as a divergence.
684 // It resolves to the start figure.
685 let t = if morph.is_nan() {
686 0.0
687 } else {
688 morph.clamp(0.0, 1.0)
689 };
690 // **The endpoints are returned exactly, not lerped to.** `x + (y - x)·1` is
691 // not `y` in floating point, and the difference is not academic: an absent
692 // `morph_to` makes both ends the same table, and an unbound `morph` must
693 // draw precisely the figure the preset named rather than one a few ulps
694 // away from it. It is also what keeps a golden fixture stable.
695 if t == 0.0 {
696 return *a;
697 }
698 if t == 1.0 {
699 return *b;
700 }
701 let mut maps = a.maps;
702 for (slot, (x, y)) in maps.iter_mut().zip(a.maps.iter().zip(b.maps.iter())) {
703 *slot = IfsMap {
704 theta: lerp_angle(x.theta, y.theta, t),
705 phi: lerp_angle(x.phi, y.phi, t),
706 sx: lerp(x.sx, y.sx, t),
707 sy: lerp(x.sy, y.sy, t),
708 t: [lerp(x.t[0], y.t[0], t), lerp(x.t[1], y.t[1], t)],
709 p: lerp(x.p, y.p, t),
710 };
711 }
712 IfsTable { maps }
713}
714
715/// Apply the four levers to a resolved table, in SVD space.
716///
717/// **Every step is guarded so that neutrality is exact.** That is not tidiness:
718/// the fit LUT is built at neutral, so a lever that perturbed the table by an
719/// ulp at its rest value would make the framing disagree with the figure, and
720/// would move a golden baseline for a preset that binds nothing.
721fn apply_levers(mut table: IfsTable, levers: Levers) -> IfsTable {
722 let Levers {
723 curl,
724 vigor,
725 lean,
726 bias,
727 } = levers;
728
729 // `curl` — a shared rotation added after the scale. Cannot affect
730 // contractivity: `R` is an isometry, so this needs no bound of any kind.
731 if curl != 0.0 && curl.is_finite() {
732 for map in &mut table.maps {
733 map.theta += curl;
734 }
735 }
736
737 // `lean` — the translations rotated about the origin. Translations do not
738 // enter contractivity either, so this is unconditionally safe.
739 if lean != 0.0 && lean.is_finite() {
740 let (sn, cs) = lean.sin_cos();
741 for map in &mut table.maps {
742 let [x, y] = map.t;
743 map.t = [x * cs - y * sn, x * sn + y * cs];
744 }
745 }
746
747 // `vigor` — **the one lever that can reach the cliff**, and the only one
748 // behind a clamp. A non-finite or non-positive value is treated as neutral
749 // rather than propagated: a preset expression can produce either, and a zero
750 // scale would collapse the figure to its fixed points.
751 if vigor != 1.0 && vigor.is_finite() && vigor > 0.0 {
752 for map in &mut table.maps {
753 map.sx *= vigor;
754 map.sy *= vigor;
755 }
756 }
757 // Run unconditionally, so the ceiling holds whatever route the table took
758 // here — the compare is the whole cost, and it removes a dependency on
759 // reasoning about what the morph can produce.
760 //
761 // The **whole table** is scaled by one factor rather than each map clamped
762 // separately: clamping per map would change the figure's proportions, which
763 // is a different shape rather than a smaller one.
764 let sigma = table.sigma_max();
765 if sigma > SIGMA_CEILING {
766 let shrink = SIGMA_CEILING / sigma;
767 for map in &mut table.maps {
768 map.sx *= shrink;
769 map.sy *= shrink;
770 }
771 }
772
773 // `bias` — weight moved between the body maps (canonical 0 and 1) and the
774 // branch maps (2 and 3). Multiplicative and bounded by [`BIAS_DEPTH`], so no
775 // probability can go negative and none can reach zero.
776 if bias != 0.0 && bias.is_finite() {
777 let b = bias.clamp(-1.0, 1.0) * BIAS_DEPTH;
778 let mut total = 0.0;
779 for (i, map) in table.maps.iter_mut().enumerate() {
780 map.p *= if i < 2 { 1.0 - b } else { 1.0 + b };
781 total += map.p;
782 }
783 // A table whose weight is entirely in one group (the dragon's is) can
784 // still renormalize; a table with no weight at all cannot, and dividing
785 // by it would put a NaN in the cumulative table.
786 if total > 0.0 {
787 for map in &mut table.maps {
788 map.p /= total;
789 }
790 }
791 }
792
793 table
794}
795
796/// A sampled bounding box in the figure's own world units.
797#[derive(Debug, Clone, Copy, PartialEq)]
798pub struct Extent {
799 /// Lower corner.
800 pub lo: [f32; 2],
801 /// Upper corner.
802 pub hi: [f32; 2],
803}
804
805impl Extent {
806 /// The box's midpoint.
807 pub fn centre(&self) -> [f32; 2] {
808 [
809 (self.lo[0] + self.hi[0]) * 0.5,
810 (self.lo[1] + self.hi[1]) * 0.5,
811 ]
812 }
813
814 /// Half the box's span on each axis, floored just above zero so a degenerate
815 /// figure (every point identical — reachable at a fully collapsed morph)
816 /// cannot produce a division by zero downstream where a scale is fitted.
817 pub fn half(&self) -> [f32; 2] {
818 [
819 ((self.hi[0] - self.lo[0]) * 0.5).max(1e-6),
820 ((self.hi[1] - self.lo[1]) * 0.5).max(1e-6),
821 ]
822 }
823
824 /// Whether every corner is a real number — the property a diverged table
825 /// fails, and the one the sweep asserts.
826 pub fn is_finite(&self) -> bool {
827 self.lo.iter().chain(self.hi.iter()).all(|v| v.is_finite())
828 }
829}
830
831/// Samples in the framing lookup, spanning `morph` from 0 to 1 inclusive.
832///
833/// A figure's extent moves smoothly along the morph — it is a bounded
834/// continuous function of a table that is itself a lerp — so 33 samples put the
835/// interpolation error far below the `0.12` of frame the fit leaves as margin.
836pub const FIT_STEPS: usize = 33;
837
838/// Chaos-game iterations behind each [`FIT_STEPS`] entry.
839///
840/// The measurement is a **maximum** over the sampled orbit, so it converges
841/// from below: too few iterations under-measure the figure and the fit draws it
842/// slightly too large. What the error has to stay inside is the margin
843/// [`FRAME_FILL`] leaves — see
844/// `the_fit_leaves_margin_for_what_it_under_measures`, which also records why
845/// iterating it to zero is not available.
846///
847/// **Kept low deliberately, because iterating buys almost nothing here.** The
848/// binding figure is the tree, whose `y` is 7.9 % under a long run at this
849/// count — and still 6.6 % at 32 000, for eight times the load cost. The margin
850/// absorbs both; the extra 3.5 ms buys 1.3 points of an error that never
851/// reaches zero.
852const FIT_ITERATIONS: u32 = 4_000;
853
854/// The framing of a figure pair, sampled over `morph` once at `configure`.
855///
856/// **Built with every lever at neutral, and that is the non-obvious half of the
857/// design** (ADR-0075 Alternative C). A fit that saw the levers would cancel its
858/// own most valuable one: `vigor` exists to make the figure surge on a beat, and
859/// a fit that re-framed every frame would shrink it back by exactly as much, for
860/// a net zero. So the fit is a function of `morph` and the figure pair **only** —
861/// which is also what lets it be a load-time table instead of a per-frame chaos
862/// game, and what leaves nothing stochastic to shimmer between frames.
863///
864/// The accepted cost is that a hard `vigor` push can leave the frame. That is
865/// the intended trade — an audible lever that can overshoot beats an inaudible
866/// one that cannot — and `zoom` is the recourse.
867///
868/// **`spin` is the second unmodelled input, and unlike `vigor` it defaults to
869/// on** (ADR-0103). The table holds axis-aligned half-extents; the projection
870/// rotates them. So the framing this buys is "at neutral levers and zero
871/// rotation", `zoom` is the recourse for both, and the three shipped 2D worlds
872/// each carry a sub-1 base `zoom` for exactly this reason.
873#[derive(Debug, Clone, PartialEq)]
874pub struct FitLut {
875 /// `(centre, half-extent)` per sample, evenly spaced over `morph`.
876 entries: [([f32; 2], [f32; 2]); FIT_STEPS],
877}
878
879impl FitLut {
880 /// Measure the figure pair's framing at [`FIT_STEPS`] positions.
881 ///
882 /// Takes the two **neutral** tables and nothing else, so the lever
883 /// independence above is structural rather than a discipline: there is no
884 /// parameter here through which a lever could arrive.
885 pub fn build(a: &IfsTable, b: &IfsTable) -> Self {
886 let mut entries = [([0.0; 2], [1.0; 2]); FIT_STEPS];
887 for (i, slot) in entries.iter_mut().enumerate() {
888 let morph = i as f32 / (FIT_STEPS - 1) as f32;
889 let extent = chaos_extent(&morph_tables(a, b, morph), FIT_ITERATIONS);
890 // A diverged table cannot reach here — the sweep proves no reachable
891 // morph produces one — but the fit is what the *projection* reads, so
892 // a non-finite box would put a NaN in the uniform rather than fail a
893 // test. Falling back to the unit box draws a wrong size, not nothing.
894 *slot = if extent.is_finite() {
895 (extent.centre(), extent.half())
896 } else {
897 ([0.0; 2], [1.0; 2])
898 };
899 }
900 Self { entries }
901 }
902
903 /// The framing at this `morph`, linearly interpolated between the two
904 /// bracketing samples. One lerp per frame; no chaos game, no allocation.
905 pub fn sample(&self, morph: f32) -> ([f32; 2], [f32; 2]) {
906 let t = if morph.is_nan() {
907 0.0
908 } else {
909 morph.clamp(0.0, 1.0)
910 };
911 let last = FIT_STEPS - 1;
912 let pos = t * last as f32;
913 // `min(last - 1)` so `i + 1` is always in range, including at `t == 1`
914 // where `pos` lands exactly on the final sample and `frac` is then 1.
915 let i = (pos.floor() as usize).min(last - 1);
916 let frac = pos - i as f32;
917 let (Some((c0, h0)), Some((c1, h1))) = (self.entries.get(i), self.entries.get(i + 1))
918 else {
919 // Unreachable given the clamp above; this file denies `unreachable`,
920 // and a unit box is a visible wrong size rather than a panic.
921 return ([0.0; 2], [1.0; 2]);
922 };
923 (
924 [lerp(c0[0], c1[0], frac), lerp(c0[1], c1[1], frac)],
925 [lerp(h0[0], h1[0], frac), lerp(h0[1], h1[1], frac)],
926 )
927 }
928}
929
930/// Burn-in iterations discarded before the box is measured.
931///
932/// The orbit starts at the origin, which need not be on the figure. The
933/// probability-weighted per-step contraction is 0.742 for the fern, so a
934/// displacement shrinks a thousandfold in ~23 steps; 256 is far past that for
935/// any table here and costs nothing at load.
936const CHAOS_BURN_IN: u32 = 256;
937
938/// The seed the CPU reference runs from. Fixed, so every measurement this module
939/// makes is reproducible — the fit LUT built from it is part of a capture's
940/// determinism.
941const CHAOS_SEED: u64 = 0x4C4D_5641_4946_5300; // "LMVAIFS\0"
942
943/// **A CPU run of the same step the compute shader runs**, returning the
944/// sampled bounding box of the orbit.
945///
946/// Deliberately CPU-side and deliberately not a render (ADR-0075). The property
947/// this family rests on — that no reachable table diverges — is about
948/// *positions*, and a capture could only report that the picture looked
949/// plausible. Here it is an ordinary assertion over a sweep, provable without a
950/// GPU, before a shader ever runs.
951///
952/// It also feeds the framing: Phase 4's fit is this function at 33 values of
953/// `morph`, run once at `configure`.
954///
955/// Returns a box that may be non-finite. That is the point — the caller asserts
956/// finiteness rather than this silently repairing it.
957pub fn chaos_extent(table: &IfsTable, iterations: u32) -> Extent {
958 let mut rng = super::SeededRng::new(CHAOS_SEED);
959 let packed = pack(table);
960 let [c0, c1, c2, _] = packed.cumulative_p;
961 // Destructured rather than indexed — this file denies `indexing_slicing`,
962 // and the unrolled four-way choice below mirrors the shader's for the same
963 // reason the shader has one.
964 let [m0, m1, m2, m3] = table.maps.map(|m| m.to_affine());
965 let (mut x, mut y) = (0.0f32, 0.0f32);
966 let mut lo = [f32::INFINITY; 2];
967 let mut hi = [f32::NEG_INFINITY; 2];
968 for i in 0..(iterations + CHAOS_BURN_IN) {
969 // The shader's selection, in Rust: a unit draw against the cumulative
970 // table, with the fourth map as the `else` arm.
971 let r = rng.next_f32();
972 let m = if r < c0 {
973 m0
974 } else if r < c1 {
975 m1
976 } else if r < c2 {
977 m2
978 } else {
979 m3
980 };
981 let (nx, ny) = (m.a * x + m.b * y + m.e, m.c * x + m.d * y + m.f);
982 x = nx;
983 y = ny;
984 if i >= CHAOS_BURN_IN {
985 lo[0] = lo[0].min(x);
986 lo[1] = lo[1].min(y);
987 hi[0] = hi[0].max(x);
988 hi[1] = hi[1].max(y);
989 }
990 }
991 Extent { lo, hi }
992}
993
994/// One map's fixed point `(I − M)⁻¹ t` — **a point that is on the attractor**.
995///
996/// That it lies on `A` is a consequence of the parameterization rather than of
997/// anything constructed here: `A = ⋃ fᵢ(A)` with `A` closed makes each `fᵢ`'s
998/// fixed point the limit of `fᵢⁿ(x)` for any `x ∈ A` (ADR-0075's Notes,
999/// ADR-0087). **It does not exist for De Jong, Clifford, Thomas or Lorenz**, and
1000/// that is why the respawn ADR-0087 builds on this is IFS-only structurally
1001/// rather than by default.
1002///
1003/// **Closed form, and unguarded on purpose.** For `M = [[a, b], [c, d]]`,
1004/// `(I − M)⁻¹` is `1/Δ · [[1 − d, b], [c, 1 − a]]` with
1005/// `Δ = (1 − a)(1 − d) − bc`. `Δ ≠ 0` **follows from contractivity rather than
1006/// being checked**: `Δ` is `det(I − M)`, which vanishes only if `M` has an
1007/// eigenvalue of `1`, which `σ_max < 1` forbids — and [`SIGMA_CEILING`] is
1008/// enforced on every reachable table. The magnitude is bounded the same way,
1009/// `‖(I − M)⁻¹‖ ≤ 1/(1 − σ_max)`, at most `33.3` under the `0.97` ceiling. So
1010/// there is nothing here for a caller to fall back to, which is the same shape
1011/// as the rest of this module: safety by construction, not by a guard.
1012fn fixed_point(map: &IfsMap) -> [f32; 2] {
1013 let Affine { a, b, c, d, e, f } = map.to_affine();
1014 let delta = (1.0 - a) * (1.0 - d) - b * c;
1015 [
1016 ((1.0 - d) * e + b * f) / delta,
1017 (c * e + (1.0 - a) * f) / delta,
1018 ]
1019}
1020
1021/// Every map's fixed point, **with the padded slots filled by duplication**.
1022///
1023/// A table always carries [`MAPS`] maps, but a figure with fewer duplicates one
1024/// at probability `0` — and a padded slot's fixed point is on the attractor only
1025/// when the pad happens to duplicate a drawn map. That is true of all five
1026/// curated tables today and is exactly the sort of thing that stops being true
1027/// when a sixth figure is added, so this returns only the `p > 0` maps' points,
1028/// repeated around all four slots. Every consumer then picks one of four
1029/// unconditionally: no branch, and no knowledge of the probability table.
1030///
1031/// The `p > 0` test survives the levers: `bias` is multiplicative and bounded by
1032/// `BIAS_DEPTH`, so it can neither zero a drawn map nor revive a pad.
1033pub fn fixed_points(table: &IfsTable) -> [[f32; 2]; MAPS] {
1034 let mut drawn = [[0.0f32; 2]; MAPS];
1035 let mut count = 0usize;
1036 for map in table.maps.iter().filter(|m| m.p > 0.0) {
1037 if let Some(slot) = drawn.get_mut(count) {
1038 *slot = fixed_point(map);
1039 }
1040 count += 1;
1041 }
1042 let mut out = [[0.0f32; 2]; MAPS];
1043 for i in 0..MAPS {
1044 // A table with no drawn map at all is not reachable — every curated
1045 // figure has at least two — and `count == 0` would be a modulo by zero
1046 // here rather than a wrong picture, so it is spelled out.
1047 let source = if count == 0 {
1048 None
1049 } else {
1050 drawn.get(i % count)
1051 };
1052 if let (Some(slot), Some(point)) = (out.get_mut(i), source) {
1053 *slot = *point;
1054 }
1055 }
1056 out
1057}
1058
1059/// Floor on the fixed-point set's diameter (ADR-0088).
1060///
1061/// **Required rather than defensive.** Two drawn maps' fixed points genuinely
1062/// approach each other as a morph interpolates between two tables, and a
1063/// diameter of zero makes [`skeleton_scale`]'s reciprocal diverge — every
1064/// particle's stored distance would come back `inf` or `NaN` and the whole
1065/// figure would sample one end of the palette.
1066///
1067/// It is **a constant somebody picked**, in the same position ADR-0075's `0.97`
1068/// and ADR-0087's `180` occupy, but it is bounded by measurement rather than by
1069/// taste: `the_skeleton_never_collapses_across_the_morph` sweeps every ordered
1070/// figure pair, every position of the 33-point morph sweep and all three lever
1071/// settings, and asserts the observed minimum diameter sits above this while
1072/// printing the margin and where it occurred. A thin margin there does not mean
1073/// "tune the floor" — it means the channel degenerates somewhere in the morph
1074/// and the figure pair the assertion names is where to look.
1075pub const SKELETON_FLOOR: f32 = 0.05;
1076
1077/// The **diameter of the fixed-point set**: `max over j, k of ‖pⱼ − pₖ‖`.
1078///
1079/// At most six pairwise distances over [`fixed_points`]' four slots. The padded
1080/// slots duplicate drawn maps, so a duplicate contributes a zero distance and
1081/// this is the *drawn* set's diameter exactly — the same property that lets the
1082/// respawn pick one of four with no branch.
1083///
1084/// **Closed form, and deliberately not [`chaos_extent`].** A bounding box is a
1085/// supremum statistic fixed by the single rarest point an orbit reached: two
1086/// runs of `chaos_extent` on the same table disagree by `0.046` at 20 000
1087/// iterations and by `0.143` at 100 000, and the disagreement *grows* (ADR-0087
1088/// Notes, ADR-0088 Alternative D). Normalising a colour coordinate by that would
1089/// make the gradient's scale wobble across a morph by an amount nobody chose,
1090/// and cost a Monte Carlo per preset switch. This is exact, deterministic, and a
1091/// continuous function of the table, so it moves across a morph exactly as
1092/// smoothly as the points themselves do.
1093///
1094/// It is also the *meaningful* scale — the figure's own skeleton rather than a
1095/// box drawn around its excursions — which is why a particle can legitimately
1096/// measure past `1`. That is clamped at the read, not here.
1097pub fn skeleton_diameter(table: &IfsTable) -> f32 {
1098 let points = fixed_points(table);
1099 let mut max = 0.0f32;
1100 for (i, [ax, ay]) in points.into_iter().enumerate() {
1101 for [bx, by] in points.into_iter().skip(i + 1) {
1102 max = max.max((bx - ax).hypot(by - ay));
1103 }
1104 }
1105 max
1106}
1107
1108/// [`skeleton_diameter`] held above [`SKELETON_FLOOR`] — the scale the GPU
1109/// normalises a particle's distance-from-the-skeleton against.
1110///
1111/// The floored value rather than the raw one is what ships; the raw one is what
1112/// the sweep measures, which is the only way to find out whether the floor is
1113/// doing nothing (good) or is load-bearing (a finding).
1114pub fn skeleton_scale(table: &IfsTable) -> f32 {
1115 skeleton_diameter(table).max(SKELETON_FLOOR)
1116}
1117
1118/// Lay a resolved table out for the uniform, recomposing each map and
1119/// accumulating the probabilities.
1120///
1121/// The accumulation is deliberately **not** renormalized here: a table whose
1122/// probabilities do not sum to 1 would leave the last cumulative entry short of
1123/// it, and the shader's `else` arm would then absorb the shortfall into the
1124/// fourth map. Forcing the final entry to `1.0` states that outright rather than
1125/// letting a rounding residue decide.
1126pub fn pack(table: &IfsTable) -> IfsPacked {
1127 let mut linear = [[0.0f32; 4]; MAPS];
1128 let mut translate = [[0.0f32; 4]; 2];
1129 let mut cumulative_p = [0.0f32; MAPS];
1130 let mut running = 0.0f32;
1131 for (i, map) in table.maps.iter().enumerate() {
1132 let affine = map.to_affine();
1133 // `get_mut` rather than an index: this file denies `indexing_slicing`,
1134 // and `enumerate` over a fixed-size array cannot exceed it anyway.
1135 if let Some(row) = linear.get_mut(i) {
1136 *row = [affine.a, affine.b, affine.c, affine.d];
1137 }
1138 if let Some(row) = translate.get_mut(i / 2) {
1139 let half = (i % 2) * 2;
1140 if let Some(slot) = row.get_mut(half) {
1141 *slot = affine.e;
1142 }
1143 if let Some(slot) = row.get_mut(half + 1) {
1144 *slot = affine.f;
1145 }
1146 }
1147 running += map.p;
1148 if let Some(slot) = cumulative_p.get_mut(i) {
1149 *slot = running;
1150 }
1151 }
1152 // The fourth map is the shader's `else`, so this entry is never compared
1153 // against — pinned at 1.0 so nothing downstream has to reason about the sum.
1154 if let Some(last) = cumulative_p.get_mut(MAPS - 1) {
1155 *last = 1.0;
1156 }
1157 // The respawn targets ride the same packing (ADR-0087), so the one function
1158 // that lays a table out for the GPU lays out all of it — a caller cannot
1159 // upload a table and forget its fixed points.
1160 let points = fixed_points(table);
1161 let mut fixed = [[0.0f32; 4]; 2];
1162 for (i, [x, y]) in points.into_iter().enumerate() {
1163 if let Some(row) = fixed.get_mut(i / 2) {
1164 let half = (i % 2) * 2;
1165 if let Some(slot) = row.get_mut(half) {
1166 *slot = x;
1167 }
1168 if let Some(slot) = row.get_mut(half + 1) {
1169 *slot = y;
1170 }
1171 }
1172 }
1173 IfsPacked {
1174 linear,
1175 translate,
1176 cumulative_p,
1177 fixed,
1178 // ...and so does the scale those points are measured against (ADR-0088),
1179 // for the same reason: one function lays a table out for the GPU, so a
1180 // caller cannot upload the skeleton and forget its size.
1181 root_recip: 1.0 / skeleton_scale(table),
1182 }
1183}
1184
1185#[cfg(test)]
1186mod tests;