Skip to main content

rlx_core/render/scenes/particles/
mod.rs

1//! GPU compute-particle scenes: strange attractors (ADR-0015, Plan 0016). The
2//! engine's **first compute pipeline** — a storage buffer of particles stepped
3//! through an attractor map each frame by a compute shader, then drawn as
4//! additive point-sprites with fading trails. This is idiom B of the four render
5//! idioms; the CPU [`swarm`](super::swarm) is idiom B's ~10k CPU precursor,
6//! replaced here by GPU-resident state that scales to 100k+ points with no CPU
7//! round-trip.
8//!
9//! Trails reuse Plan 0014's [`PingPongField`](crate::render::feedback) rather
10//! than a second feedback mechanism: each frame the previous accumulation texture
11//! is drawn back faded (decay pass), the fresh points are added on top
12//! (additive), and the result is composited to the surface (present pass). Trail
13//! persistence is the named `fade` parameter; `fade = 0` clears the accumulation
14//! each frame, reproducing the trail-free look.
15//!
16//! Every knob is an ADR-0002 layer-2 named parameter — the attractor
17//! coefficients (`a`,`b`,`c`,`d`), look scalars (`size`,`hue`,`fade`), and a
18//! beat-driven `reseed` — so a preset steers the cloud's shape and a beat
19//! disturbs it. All randomness is the seeded initial scatter plus the reseed's
20//! deterministic per-particle kick (NFR 6): the
21//! point cloud is a pure function of the seed and the fixed-`dt` step sequence,
22//! so a capture reproduces bit-for-bit on one adapter.
23//!
24//! **GPU resources are built lazily, on first render** — the same discipline the
25//! reaction-diffusion scene uses (see its module docs). `create_all` builds every
26//! scene up front, but the compute pipeline + storage buffer + trail field are
27//! constructed only when this scene is first drawn, so a capture that never
28//! activates it never builds them (keeping the other scenes' WARP captures
29//! unperturbed).
30//!
31//! The accumulation field is sized to the render target and capped (Plan 0027
32//! Phase 2, now the tier's `attractor_trail_cap`) rather than fixed at
33//! 640x360, so the present is close to 1:1 up to the cap instead of a soft
34//! upscale on a 1080p+ display. That size is quantized to `TRAIL_GRID_STEP`,
35//! so a live window drag re-allocates the field a handful of times rather
36//! than once per frame.
37//!
38//! **The field's own aspect is not the projection's** (Plan 0029 Phase 5). The
39//! present is a plain stretch (aspect ignored, as the reaction-diffusion present
40//! does), so a point at field NDC `x` lands at target NDC `x` — the field's aspect
41//! cancels out and the projection must use the **target's**. Quantization makes
42//! the two genuinely differ (a 1920x1080 target takes a 1920x1024 grid), so
43//! [`trail_grid_size`] scaling both axes by one factor at the cap is about keeping
44//! the field's *sampling* near-isotropic, not about the shape on screen.
45
46// Hot-path panic-denial pragma (Plan 0002 Phase 2, extended to scenes by Plan
47// 0003 Phase 0). Steps + draws every displayed frame.
48#![deny(
49    clippy::unwrap_used,
50    clippy::expect_used,
51    clippy::indexing_slicing,
52    clippy::panic,
53    clippy::unreachable
54)]
55
56// The four concerns this directory always was (Plan 0061 Phase 6). `family` is
57// the GPU-free ODE/basis math, `shaders` the four WGSL programs, `resources` the
58// wgpu buffers/pipelines/bind groups; what stays here is the scene, its `Scene`
59// impl, the param surface and the `encode_*` passes.
60mod encode;
61pub mod family;
62pub mod ifs;
63pub mod resources;
64mod shaders;
65
66// `AttractorFamily` and `Basis` were `pub` here before the split and are named
67// from outside `particles`, so they keep their old path rather than gaining a
68// `family::` segment. Everything else is `pub(super)` in its new file: exactly
69// the visibility it had as a private member of this module, not `pub(crate)`,
70// which would widen it.
71pub use family::{AttractorFamily, Basis, FAMILY_PARAMS};
72
73use encode::*;
74use family::*;
75use resources::*;
76use shaders::*;
77
78use crate::render::gpu;
79use crate::render::tier::{GridScale, attractor_budget};
80
81use ifs::{FitLut, IfsFigure, IfsPacked, IfsTable, Levers};
82
83use super::common;
84use super::{FeedbackSink, Phase, Scene, SeededRng};
85use crate::dsp::AnalysisFrame;
86use crate::render::feedback::{self, FeedbackConfig, PingPongField};
87use crate::render::palette::{self, Palette};
88use crate::render::scenes::{
89    FamilyParam, FamilyRange, ParamGroup, ParamKind, ParamSpec, default_of,
90};
91
92/// Compute workgroup size (1D). 64 is a safe, portable default across DX12/Metal.
93const WORKGROUP: u32 = 64;
94const SEED: u64 = 0x4C4D_5641_5454_5231; // "LMVATTR1"
95
96/// Grid size before the first
97/// [`Scene::set_target_size`](crate::render::scenes::Scene::set_target_size) —
98/// only reached if a scene renders without one, which the renderer never does.
99const TRAIL_FALLBACK_W: u32 = 1280;
100const TRAIL_FALLBACK_H: u32 = 720;
101/// Quantization step for each axis of the trail grid (Plan 0029 Phase 2).
102///
103/// A grid change costs a texture-pair reallocation, four bind groups, and a trail
104/// restart, and the standalone forwards **every** `WindowEvent::Resized` — so at
105/// pixel granularity a live drag pays that hundreds of times across a screen. At
106/// 128 px per axis a full-screen-width drag crosses a dozen or so grids and most
107/// frames of it cost only a compare. Coarser wastes fill — at 256 a 1080-tall
108/// window took a 1280-tall grid, 18.5 % of the axis for nothing, and this grid's
109/// whole frame cost is its area (ADR-0245); finer defeats the point. The axis
110/// never falls below `grid::MIN_AXIS` whatever the step. Purely a constant —
111/// no wall clock, so a fixed-size headless capture stays byte-reproducible.
112const TRAIL_GRID_STEP: u32 = 128;
113
114/// The trail accumulation grid for a render target of `width` x `height` — this
115/// scene's **cap and step** over the one shared policy
116/// (`grid::grid_size`).
117///
118/// A thin wrapper on purpose (Plan 0035 Phase 3). A line-for-line copy of this
119/// arithmetic in `post.rs` is how the aspect lesson this scene had already paid for
120/// failed to reach the post stages and shipped as a defect a second time (ADR-0037).
121/// The **numbers** stay here, because they are genuinely this call site's — see
122/// [`TierConfig::attractor_trail_cap`](crate::render::TierConfig::attractor_trail_cap)
123/// for why the attractor may take a larger grid than a post stage.
124///
125/// **Still `pub`, deliberately.** Plan 0029's close logged this as a nit (public
126/// API widened for a test's benefit), and Plan 0035 re-examined it while touching
127/// the function: `core/tests/attractor.rs` is an integration test and can only
128/// reach a `pub` item, so narrowing to `pub(crate)` means moving that test set
129/// into the crate — a change to a file outside that scope, for no behavioral
130/// gain. `core/` is not a published API surface; the cost of the widening is a
131/// doc-comment's worth of noise, and the cost of the churn is a silent scope
132/// expansion. Kept.
133pub fn trail_grid_size(width: u32, height: u32, cap: (u32, u32)) -> (u32, u32) {
134    scaled_trail_grid_size(width, height, GridScale::FULL, cap)
135}
136
137/// [`trail_grid_size`] for a field drawn at `scale` of the target (ADR-0245) —
138/// what the scene itself resolves, and what a caller reporting the trail grid
139/// of a scaled renderer asks.
140pub fn scaled_trail_grid_size(
141    width: u32,
142    height: u32,
143    scale: GridScale,
144    cap: (u32, u32),
145) -> (u32, u32) {
146    crate::render::grid::grid_size((width, height), scale, cap, TRAIL_GRID_STEP)
147}
148
149/// Wall-clock duration of one attractor iteration (Plan 0014 injected `dt`). The
150/// fixed-timestep accumulator runs one compute step per `FIXED_STEP` of injected
151/// real `dt`, so the cloud evolves at the same rate on any refresh — at the
152/// live/capture `dt` of 1/60 s this is exactly one step per frame. Continuous
153/// (ODE) families added later integrate by this fixed sub-step, so the map is
154/// frame-rate-independent without the shader reading a clock.
155const FIXED_STEP: f32 = 1.0 / 60.0;
156/// Max steps encoded in one frame — a long stall drops its backlog rather than
157/// queueing unbounded compute work (accumulator spiral-of-death guard, as the
158/// reaction-diffusion scene does). One step per frame is the norm at 60 fps.
159const MAX_SUBSTEPS: u32 = 6;
160
161/// Dynamically-offset slots in the step uniform buffer: one per possible
162/// sub-step, plus the jitter dispatch's.
163///
164/// **One slot per sub-step and not one slot reused**, because the IFS's map
165/// choice reads a per-step counter off this uniform (ADR-0075). A frame encodes
166/// its `pending_steps` dispatches into one command buffer, and a
167/// `queue.write_buffer` between two `encoder` calls does not interleave with them
168/// — it lands before the whole submission — so a single slot would hand every
169/// sub-step of a stalled frame the *same* step index, and a particle would apply
170/// the same map two or three times running. Harmless for the four map families,
171/// which do not read it; a quality loss precisely when the frame budget is
172/// already blown. Only `pending_steps` slots are written per frame, so the
173/// steady-state 60 fps cost is the one write it always was.
174const STEP_SLOTS: u32 = MAX_SUBSTEPS + 1;
175/// The jitter dispatch's slot — one past the sub-step slots.
176const JITTER_SLOT: u32 = MAX_SUBSTEPS;
177
178/// `morph`'s default: the configured figure, unmixed (ADR-0075). An IFS preset
179/// that never binds it draws exactly the figure it named.
180const DEFAULT_MORPH: f32 = default_of(PARAMS, "morph");
181
182/// `tuple`'s default: roster entry 0 (ADR-0093), which is the family's canonical
183/// coefficients with the framing this scene shipped with — so a preset that never
184/// binds it renders byte-identically to the build before the roster existed, and
185/// no golden baseline moves.
186const DEFAULT_TUPLE: f32 = default_of(PARAMS, "tuple");
187
188/// Parameter defaults — a calm idle look when nothing is bound.
189const DEFAULT_SIZE: f32 = default_of(PARAMS, "size");
190const DEFAULT_HUE: f32 = 0.0;
191/// `brightness`'s default (ADR-0080): a multiply by **literal `1.0`**, which is
192/// the identity in IEEE-754 — so an unbound preset renders byte-identically to
193/// the build before this param existed, and no golden baseline moves.
194///
195/// The name matches [`swarm`](super::swarm::PARAMS) and
196/// [`emitter`](super::emitter::PARAMS) exactly. Three scenes draw additive
197/// particle marks and this is the one lever that says how bright; a fourth name
198/// for it would be a vocabulary an author has to re-learn per scene.
199const DEFAULT_BRIGHTNESS: f32 = 1.0;
200/// Depth-cue defaults (ADR-0076): **exactly the pre-ADR-0076 behaviour**. At
201/// `perspective = 0` the magnification is `1 / (1 - 0 * d_n)` = `1`, and a
202/// multiply by `1.0` is exact — so an unbound preset is byte-identical and no
203/// golden baseline moves.
204const DEFAULT_PERSPECTIVE: f32 = default_of(PARAMS, "perspective");
205/// The two atmospheric cues, likewise inert at their defaults: `depth_fade = 0`
206/// leaves the brightness multiplier exactly `1`, and `depth_hue = 0` adds an
207/// exact `0` to the palette coordinate.
208///
209/// **They come as a pair on purpose.** Distance washing out contrast is the
210/// oldest depth cue there is (ADR-0044), but dimness alone is ambiguous with a
211/// thing simply *being dimmer*; a hue shift is what makes it read as **distance**,
212/// because real atmospheric perspective moves colour as well as contrast. They
213/// are also the substitute for the occlusion ADR-0076 declines to do: far
214/// material is attenuated until it stops competing with near material, which is
215/// what reads as depth for a diffuse cloud that cannot hide anything.
216const DEFAULT_DEPTH_FADE: f32 = default_of(PARAMS, "depth_fade");
217const DEFAULT_FOCUS: f32 = default_of(PARAMS, "focus");
218const DEFAULT_APERTURE: f32 = default_of(PARAMS, "aperture");
219const DEFAULT_DEPTH_HUE: f32 = default_of(PARAMS, "depth_hue");
220/// ADR-0087's colour channels, all four inert at their default. `*_tint` adds an
221/// exact `0` to the palette coordinate; `*_hue` compares equal to literal `0.0`
222/// and takes `shift_hue`'s early return, so no capture moves through a
223/// round trip that is not bit-exact.
224///
225/// One constant for all four rather than four spellings of `0.0`: they are the
226/// same claim — *the default is the identity* — and it is the claim, not the
227/// number, that has to hold.
228const DEFAULT_CHANNEL_COLOUR: f32 = 0.0;
229/// Ceiling on `perspective`, applied silently where the uniform is packed.
230///
231/// `perspective` means **the figure's depth half-extent as a fraction of the
232/// camera distance**, so the near-to-far magnification ratio is
233/// `(1 + p) / (1 - p)`: `0.5` gives 3:1 and this value gives 9:1 (the far end at
234/// 0.556, the near end at 5.0). The singularity — a point reaching the camera
235/// plane — sits at exactly `1`, and this is well short of it. The arithmetic
236/// holds because `d_n` is clamped to `[-1, 1]` before it is used — see
237/// `depth_norm` in the draw shader for why that clamp is not decoration.
238const MAX_PERSPECTIVE: f32 = 0.8;
239// Shared palette color knobs (ADR-0021 / Plan 0020 Phase 5). The per-particle
240// seed jitter occupies `hue_center + (seed - 0.5)*hue_spread`; the defaults
241// (`spread = 0.15`, `center = 0.075`) reduce to `seed*0.15` — the prior hardcoded
242// jitter — so an unbound attractor is unchanged (`saturation = 1`, `mix = 0`).
243const DEFAULT_HUE_SPREAD: f32 = default_of(PARAMS, "hue_spread");
244const DEFAULT_HUE_CENTER: f32 = default_of(PARAMS, "hue_center");
245/// View transform defaults (ADR-0018): identity — `zoom` = 1 unscaled, `pan` = 0
246/// unshifted, so an unbound preset is byte-unchanged.
247const DEFAULT_ZOOM: f32 = 1.0;
248/// Trail persistence: the fraction of the accumulation retained per 1/60 s frame.
249/// ~0.94 gives glowing trails that fade over ~1 s; `fade = 0` clears each frame
250/// (trail-free). Applied frame-rate-independently (raised to the `dt`-relative
251/// power), so the trail length is the same wall-clock duration on any refresh.
252const DEFAULT_FADE: f32 = default_of(PARAMS, "fade");
253/// Base point half-size in world units (before the `size` multiplier), matching
254/// the swarm's small-glowing-point scale.
255const POINT_BASE: f32 = 0.006;
256/// `reseed` rises past this to disturb the cloud once (edge-triggered, so a
257/// sustained beat flag doesn't disturb it every frame).
258const RESEED_THRESHOLD: f32 = 0.5;
259
260/// Fraction of a family's own seed-box spread that one `reseed` kick spans
261/// (ADR-0066). See [`AttractorFamily::jitter_extent`] for why this is one
262/// constant rather than a per-family number, and why its value is provisional.
263const JITTER_FRACTION: f32 = 0.06;
264
265/// The compute shader's family selector value meaning **jitter, do not step**.
266/// One past the real families, so adding a family is still a matter of extending
267/// [`AttractorFamily`] and its `shader_id`.
268///
269/// Moved from 4 to 5 by Plan 0062's IFS family, which is what this comment
270/// anticipated a fifth family would do.
271const JITTER_MODE: u32 = 5;
272
273/// Per-particle weight of the additive deposit, so the **total** light laid into
274/// the accumulation each frame is invariant to the particle count (ADR-0065).
275///
276/// The draw blends `One, One` into a linear accumulation and everything
277/// downstream to the tonemap is linear, so without this the figure moves up by
278/// exactly the count ratio: `attractor_particles` is 50 000 at `Floor` and
279/// 150 000 at `Rich`, and `Rich` therefore rendered the same preset **three stops
280/// hot**. ADR-0045 and `presets/README.md` both promise a tier changes capacity
281/// and not behavior; for an accumulating additive scene that was false, because
282/// capacity *is* the picture.
283///
284/// So a tier now buys what a capacity tier should buy — the same figure sampled
285/// three times as densely at a third the weight each, i.e. **less shot noise in
286/// the same picture** rather than more light.
287///
288/// At `Floor` the factor is exactly `1.0` by construction, which is why no golden
289/// baseline moves and why that is assertable on the value rather than inferred
290/// from pixels. A future tier with a count *below* `Floor` would put this above
291/// `1.0` and amplify shot noise instead of reducing it — bounded and predictable,
292/// but worth knowing before a third tier is added.
293pub fn deposit_scale(active_count: u32) -> f32 {
294    // `max(1)` rather than a branch: a zero-particle scene draws nothing, so the
295    // value is unobservable, and a division by zero here would reach the shader.
296    crate::render::TierConfig::FLOOR.attractor_particles as f32 / active_count.max(1) as f32
297}
298
299/// The sanitized `brightness` multiplier on that deposit (ADR-0080).
300///
301/// Two guards, both for the same reason — the value arrives from an eased
302/// expression and lands in an **accumulation** the trail carries across frames,
303/// so a bad frame's value is not a bad frame, it is a permanently poisoned field:
304///
305/// - **Negative is floored to zero.** The draw blends `One, One`, so negative
306///   light would *subtract* from whatever the trail already holds — the same trap
307///   `depth_fade`'s clamp exists for.
308/// - **Non-finite falls back to the default.** An infinite deposit writes `inf`
309///   into the field and every later decay multiplies it back to `inf`; nothing
310///   downstream recovers.
311///
312/// At the default this returns exactly `1.0`, so the multiply at the packing site
313/// is the identity.
314fn brightness_factor(value: f32) -> f32 {
315    if value.is_finite() {
316        value.max(0.0)
317    } else {
318        DEFAULT_BRIGHTNESS
319    }
320}
321
322/// Whether a **reseed's** kick is drawn as a streak (ADR-0069).
323///
324/// **Provisional, and Plan 0059 Phase 4 decides it — not this phase.** A jitter
325/// displaces a particle by far more than a step does (ADR-0069 measures roughly
326/// 15x a frame's travel), so drawing the segment renders a long stroke along a
327/// path the particle never traversed: arguably a legitimate "whip" on the beat,
328/// arguably a bright artifact laid over the figure. It cannot be settled by
329/// argument, only by watching a beat land.
330///
331/// Shipped `false` — the kick moves the particle and the *next* step's segment is
332/// the first one drawn. That is the conservative default: it is what the scene
333/// did before segments existed, so nothing about the reseed's look changes in
334/// this phase.
335///
336/// Flipping it is a one-constant edit and no shader change: it rides the jitter
337/// dispatch's otherwise-unused `coeffs.w`, so an A/B for the content pass is a
338/// rebuild rather than a shader rewrite.
339const RESEED_DRAWS_STREAK: bool = false;
340
341/// Encode a streak choice into the `f32` slot a uniform carries it in.
342///
343/// Trivial, and named anyway: both the draw uniform's `x.w` and the jitter
344/// dispatch's `coeffs.w` mean "non-zero draws a segment", and a shader comparing
345/// `!= 0.0` will read *any* stray value as yes. One helper is what keeps the two
346/// call sites agreeing, and gives the encoding somewhere to be tested.
347fn streak_flag(on: bool) -> f32 {
348    if on { 1.0 } else { 0.0 }
349}
350
351/// The smallest `[particles] density` a preset may ask for (ADR-0069).
352///
353/// At this fraction the scene draws **25** particles at `Floor` (50 000) and
354/// **75** at `Rich` (150 000) — and that is deliberately far sparser than it
355/// first looks like it should be, because **the sparse end is the point of the
356/// key rather than its degenerate edge**.
357///
358/// **This value is set from rendered captures, and the first arithmetic argument
359/// for it was wrong.** The reasoning that picked `0.01` (500 particles) ran:
360/// ADR-0065 holds total light invariant by weighting each particle `50 000 /
361/// active`, so a hundredth of the budget already concentrates a hundred
362/// particles' worth of light into every point, and an order of magnitude below
363/// that must clip to white before it reads as a curve. Rendered at `fade = 0.95`,
364/// it does not. The banding first appears around `0.01`, and at `0.002` (100
365/// particles) and `0.0005` (25) the Lorenz lobes resolve into visibly *cleaner*
366/// spiral traces. The prediction missed that the trail spreads each particle's
367/// deposit along its whole path rather than piling it on one texel, so
368/// concentrating light into fewer particles buys contrast against the background
369/// instead of clipping.
370///
371/// So the floor is not protecting a look — it is rejecting a mis-typed magnitude.
372/// `0.0005` is the sparsest fraction that has actually been captured rendering
373/// as the attractor; below it a preset is asking for single-digit trajectories,
374/// which is a few orbits rather than a figure. `active_particles` separately
375/// guarantees at least one particle, so nothing here can produce an empty draw.
376pub const MIN_PARTICLE_DENSITY: f32 = 0.0005;
377
378/// At or below this `density`, the drawn count is a fraction of the tier's
379/// **anchor** — the same number of trajectories at every render target
380/// (ADR-0195).
381///
382/// The boundary brackets the gap the authored population leaves: the densest
383/// trace world in `presets/` sits at `0.06` and the sparsest figure at `0.18`,
384/// so both ends of the band `TRACE_DENSITY..=`[`CLOUD_DENSITY`] fall in empty
385/// space. A density inside the band is legal and scales partly with the target.
386pub const TRACE_DENSITY: f32 = 0.08;
387
388/// At or above this `density`, the drawn count is a fraction of the
389/// target-scaled budget [`attractor_budget`] resolved — ADR-0140's law
390/// untouched. See [`TRACE_DENSITY`] for where the boundaries come from.
391pub const CLOUD_DENSITY: f32 = 0.16;
392
393/// Resolve a validated `density` against a tier's particle budget.
394///
395/// The fraction is taken of an **effective budget** that is the tier's `anchor`
396/// for a trace, the target-scaled `budget` for a cloud, and a linear blend of
397/// the two across the band between them (ADR-0195):
398///
399/// ```text
400/// w         = clamp((density - TRACE_DENSITY) / (CLOUD_DENSITY - TRACE_DENSITY), 0, 1)
401/// effective = anchor + (budget - anchor) * w
402/// drawn     = clamp(round(effective * density), 1, budget)
403/// ```
404///
405/// Both outer arms are written as the bare `(n as f32 * density).round()`
406/// product rather than as the blend evaluated at `w = 0` or `w = 1`, so each is
407/// exact rather than within one particle of a `.5` case. Where
408/// `budget == anchor` — every target at or under
409/// [`REFERENCE_PX`](crate::render::REFERENCE_PX), and every
410/// `Floor` window — all three arms coincide, so no density resolves differently
411/// there.
412///
413/// `budget >= anchor` holds by [`attractor_budget`]'s own lower clamp, so
414/// `budget - anchor` cannot underflow.
415///
416/// **The blend arm is steep by construction.** Across the band the drawn count
417/// rises by `(CLOUD_DENSITY / TRACE_DENSITY) * (budget / anchor)` — 8x at a
418/// 1080p `Rich` window, 36x at a 4K `Rich` render — while `density` only
419/// doubles. The `f32` product stays exact: `effective * density` is bounded by
420/// `budget`, itself far inside `f32`'s 2^24 integer range.
421///
422/// Rounds rather than truncates, and floors at one particle: `density` is already
423/// range-checked at load, so this cannot be handed a zero, but a scene that drew
424/// zero instances would silently render nothing rather than fail.
425fn active_particles(anchor: u32, budget: u32, density: f32) -> u32 {
426    let drawn = if density <= TRACE_DENSITY {
427        (anchor as f32 * density).round()
428    } else if density >= CLOUD_DENSITY {
429        (budget as f32 * density).round()
430    } else {
431        let w = (density - TRACE_DENSITY) / (CLOUD_DENSITY - TRACE_DENSITY);
432        let effective = anchor as f32 + (budget - anchor) as f32 * w;
433        (effective * density).round()
434    };
435    (drawn as u32).clamp(1, budget)
436}
437
438/// The CPU transcription of [`DRAW_SHADER`]'s depth projection (ADR-0076).
439///
440/// **The WGSL above is the source and this is the mirror** — the same discipline
441/// `apply_saturation` follows against `palette.rs::desaturate`, and `project()`
442/// up there names this module outright. If you edit one, edit the other.
443///
444/// It exists because the property this whole change rests on is *dimensionless
445/// algebra*, not a picture: under orthography the projection at rotation `π` is
446/// the exact `x`-mirror of the projection at `0`, and under perspective it is
447/// not, because `m(h) ≠ m(−h)` for any `h ≠ 0`. That holds on every machine,
448/// every adapter and every resolution. A capture-level check could only say the
449/// picture changed; this says *what* changed and why it matters.
450///
451/// Test-only: nothing on the render path projects on the CPU, and the point of
452/// the module is to be the thing the assertions run.
453#[cfg(test)]
454mod projection_mirror;
455
456/// One particle, GPU storage-buffer layout (std430). **48 bytes**: the current
457/// 3D attractor position (2D families keep `z = 0`), a per-particle seed jitter
458/// set once at init, the position this particle held *before* the current step
459/// (which the continuous families draw a segment back to, ADR-0069), and the two
460/// channels ADR-0087 added — how old the particle is and which map last moved it.
461///
462/// Each of the first two `f32`s packs into the preceding `vec3`'s trailing slot
463/// (offsets 12 and 28), so std430 lays the first 32 bytes out as two tight
464/// 16-byte halves. **The same packing argument settled the struct at a tight 16
465/// before it settled it at a tight 32**, which is why the note stays; `_pad` is
466/// the explicit name for the slot `seed` occupies in the first half.
467///
468/// **Why 48 and not 40.** WGSL rounds a struct to a multiple of its alignment
469/// and `vec3<f32>` aligns to 16, so `age` and `map` land at offsets 32 and 36
470/// and the whole rounds to 48. The two words that follow are **not slack to be
471/// reclaimed** — they are the budget for the next per-particle channel, which is
472/// why they are named rather than left implicit (and why `bytemuck::Pod`, which
473/// forbids implicit padding, agrees).
474///
475/// The price is one more 16 bytes per particle: at the tier budgets **2.4 MB**
476/// at `Floor` (50 000) and **7.2 MB** at `Rich` (150 000), up from 1.6 and 4.8.
477/// Paid by all five families including the four that never read the new fields
478/// (ADR-0087 Consequences). It is GPU storage, allocated once at build and never
479/// resized — `[particles] density` narrows what is *drawn*, not what is allocated
480/// (ADR-0069), so a sparse preset pays the full figure.
481#[repr(C)]
482#[derive(Clone, Copy, bytemuck::Pod, bytemuck::Zeroable)]
483struct Particle {
484    pos: [f32; 3],
485    seed: f32,
486    prev: [f32; 3],
487    _pad: f32,
488    /// Steps since this particle last respawned (ADR-0087). Written by the IFS
489    /// arm of the step shader; left at its seeded `0.0` by every other family.
490    age: f32,
491    /// Index of the map applied on the **most recent** step — a property of
492    /// *position*, not of history: it names which sub-copy `fₖ(A)` the particle
493    /// currently sits in (the fern's stem, body, left frond, right frond), which
494    /// is what makes a one-value channel refreshed every step partition the
495    /// figure into its parts rather than read as noise (ADR-0087).
496    ///
497    /// `0.0` on every non-IFS family, where nothing writes it.
498    map: f32,
499    /// Distance from this point to the nearest of the **drawn** maps' fixed
500    /// points, normalised by the skeleton's own diameter (ADR-0088). **Offset
501    /// 40**, which [`PARTICLE_ATTRIBUTES`] spells out by hand.
502    ///
503    /// A pure function of *position*, recomputed every step rather than
504    /// accumulated — which is the whole difference from [`age`](Self::age), and
505    /// the reason this gradient is permanent where that one decayed: an old
506    /// particle near a fixed point reads the same near-zero a fresh one does.
507    ///
508    /// **May exceed `1`, and that is not an error.** The fixed-point set's
509    /// diameter is not an upper bound on how far the attractor reaches, so the
510    /// stored value is a faithful measurement and the *draw* clamps.
511    ///
512    /// `0.0` on every non-IFS family, where nothing writes it.
513    root: f32,
514    /// The **last** spare word — the next per-particle channel after this one is
515    /// a struct change to a type four families share (ADR-0088 Consequences).
516    /// Explicit so it reads as budget rather than as slack.
517    _spare: f32,
518}
519
520/// Mean particle lifetime, in fixed steps (ADR-0087) — 3 s at [`FIXED_STEP`].
521///
522/// **A look constant with no principled value**, in the same position ADR-0075's
523/// `0.97` occupies, and the acceptance is the look rather than the number. At the
524/// 150 000-particle ceiling this restarts ~0.56 % of the buffer per step. Plan
525/// 0073 Phase 6 judges it live; if the churn reads as *twinkle*, **this constant
526/// and [`DEFAULT_EMERGENCE`] are the lever, and making the rate bindable is not**.
527const CHURN_LIFETIME: f32 = 180.0;
528
529/// The per-particle spread on [`CHURN_LIFETIME`], as multipliers of it.
530///
531/// **The spread is what makes the churn continuous rather than a pulse.** With
532/// one shared lifetime every particle seeded together would restart together —
533/// a bulk respawn, which is the artifact this whole plan exists to remove, just
534/// on a 3-second period. Drawn from the particle's own fixed `seed`, so the
535/// phases stay spread for the life of the session and the restart rate is flat.
536const CHURN_LIFETIME_SPREAD: [f32; 2] = [0.5, 1.5];
537
538/// Steps a respawned particle takes to reach full brightness (ADR-0087) — the
539/// default the `emergence` param falls back to.
540///
541/// **Load-bearing rather than polish.** A fixed rate at the particle ceiling
542/// lands on the order of a thousand particles per frame onto exactly **four
543/// points**, and the trail field integrates that into four bright dots. Ramping
544/// from zero means those points deposit almost nothing, and by the time a
545/// particle is bright it has been iterated enough to have spread across the
546/// figure. Without it the churn is four blobs; with it the churn is invisible,
547/// which is the whole intent.
548///
549/// Bindable (Plan 0074 Phase 4), and **for ADR-0087's reason rather than the
550/// colour one**: a ramp sufficient at `fade = 0.86` may not be at `0.94`,
551/// because a longer trail integrates the four restart points over more frames.
552/// The *other* motivation for exposing it — letting the age gradient show — died
553/// with the age channel and is not why this shipped.
554const DEFAULT_EMERGENCE: f32 = default_of(PARAMS, "emergence");
555
556/// The shortest ramp that is still a ramp.
557///
558/// **The guard here is arithmetic, not taste.** `em.x` is `1 / emergence`, so a
559/// zero binding divides by zero and a negative one *inverts* the ramp — a
560/// just-respawned particle would start at full brightness and dim, which is the
561/// four-blob artifact the ramp exists to remove, brought back through the front
562/// door. Below `1` nothing further changes: a particle's `age` is a whole number
563/// of steps, so a rate at or above `1.0` already means the first step after a
564/// respawn is fully bright.
565const MIN_EMERGENCE: f32 = 1.0;
566
567/// The per-step brightness increment the draw uniform carries, from a bound
568/// `emergence` in **steps**.
569///
570/// Clamped here rather than in the shader for the reason `perspective` is
571/// (ADR-0076): this is the one place the value crosses into the GPU, so a preset
572/// asking for something the maths does not accept gets the floor rather than a
573/// divisor approaching zero. **A smoothing curve makes this necessary rather
574/// than defensive** — an eased param is continuous even when its own maths is
575/// not, so a binding easing from `8` toward `0` sweeps *through* the invalid
576/// range whatever its endpoints are.
577///
578/// **There is deliberately no upper clamp.** Past the longest lifetime a
579/// particle can draw, no particle completes the ramp and the figure simply gets
580/// dimmer — that is a look an author can ask for, not an arithmetic hazard, and
581/// capping it would be taste. Non-finite is the one case a clamp cannot handle:
582/// `f32::clamp` propagates `NaN`, which would reach the division and black the
583/// figure out, so it falls back to the default instead.
584fn emergence_rate(steps: f32) -> f32 {
585    if !steps.is_finite() {
586        return 1.0 / DEFAULT_EMERGENCE;
587    }
588    1.0 / steps.max(MIN_EMERGENCE)
589}
590
591/// This particle's lifetime in steps, from its own fixed `seed`.
592///
593/// **The CPU mirror of `ifs_lifetime` in [`STEP_SHADER`]**, and the two must
594/// agree exactly or a particle's seeded age would not sit inside the life the
595/// GPU measures it against. `the_churn_constants_agree_between_rust_and_wgsl`
596/// holds the shader's literals to these constants;
597/// [`hash_unit`] is the transcription of the hash itself.
598fn churn_lifetime(seed: f32) -> f32 {
599    let [lo, hi] = CHURN_LIFETIME_SPREAD;
600    CHURN_LIFETIME * (lo + hash_unit(seed.to_bits() ^ LIFETIME_SALT) * (hi - lo))
601}
602
603/// Salt separating the lifetime draw from every other hash on the particle's
604/// seed — the map choice, the reseed kick, and the respawn slot each use their
605/// own, so two of them cannot correlate.
606const LIFETIME_SALT: u32 = 0x9E37_79B1;
607
608/// **The CPU mirror of `mix32` + `unit01` in
609/// [`gpu::HASH_WGSL`](crate::render::gpu::HASH_WGSL)** — one round of the
610/// lowbias32 bit-mixer, then the top 24 bits as a fraction in `[0, 1)`.
611///
612/// Plan 0082 promoted the two out of [`STEP_SHADER`] into a shared home
613/// when the tonemap's dither became their second caller, and
614/// `resources.rs` concatenates that text in front of the step shader.
615///
616/// The same discipline `projection_mirror` follows: **the WGSL is the source and
617/// this is the mirror**. It exists because `seed()` has to place a particle at a
618/// point in a life the *shader* computes, and a CPU that disagreed about the
619/// lifetime would seed ages outside it.
620fn hash_unit(v: u32) -> f32 {
621    let mut h = v;
622    h ^= h >> 16;
623    h = h.wrapping_mul(0x7FEB_352D);
624    h ^= h >> 15;
625    h = h.wrapping_mul(0x846C_A68B);
626    h ^= h >> 16;
627    (h >> 8) as f32 / 16_777_216.0
628}
629
630/// GPU compute-particle strange-attractor scene (ADR-0015). A storage buffer of
631/// particles is stepped through the De Jong map by a compute shader each frame,
632/// drawn as additive point-sprites into a fading trail field, and composited to
633/// the surface. Every knob is an ADR-0002 layer-2 named parameter — the attractor
634/// coefficients (`a`,`b`,`c`,`d`), the look scalars (`size`,`hue`,`fade`), and a
635/// beat-driven `reseed` — so a preset binds them to the audio bands and beat.
636pub struct AttractorScene {
637    /// Cloned device handle (an `Arc` inside wgpu) that builds
638    /// [`Resources`] lazily on first render — see the module docs for
639    /// why.
640    device: wgpu::Device,
641    surface_format: wgpu::TextureFormat,
642    res: Option<Resources>,
643    /// The accumulation grid the next build should use — the render target's size
644    /// through [`trail_grid_size`], updated by
645    /// [`Scene::set_target_size`](crate::render::scenes::Scene::set_target_size).
646    /// Held separately from [`FieldResources::trail_w`]/`trail_h` so a size change
647    /// is a compare here and a field re-allocation on the next render, never GPU
648    /// work inside the hook (ADR-0030 condition 2).
649    trail_w: u32,
650    trail_h: u32,
651    /// The render target's height in pixels, as handed to
652    /// [`Scene::set_target_size`](crate::render::scenes::Scene::set_target_size)
653    /// — **not** the trail grid's. The lens states a circle of confusion in
654    /// pixels of the target (ADR-0257), and the trail grid is a resolution that
655    /// the grid scale and the tier cap shrink below the target (ADR-0037), so a
656    /// blur measured in its pixels would widen on screen as the grid shrank.
657    target_h: u32,
658    /// The active tier's cap on the trail grid
659    /// ([`TierConfig::attractor_trail_cap`](crate::render::TierConfig::attractor_trail_cap)),
660    /// resolved once at construction. Read only in `set_target_size`, so the grid
661    /// stays a pure function of the target and the cap.
662    trail_cap: (u32, u32),
663    /// The part of the grid scale the target size does not already carry
664    /// ([`Scene::set_grid_scale`](crate::render::scenes::Scene::set_grid_scale),
665    /// ADR-0245), recorded each frame before the size and read only in
666    /// `set_target_size`. [`GridScale::FULL`] until something says otherwise, so
667    /// a scene driven without it sizes exactly as it did before it existed.
668    field_scale: GridScale,
669    /// The **allocation**: how many particles the storage buffer and the seeded
670    /// scatter hold. The tier's own ceiling
671    /// ([`attractor_particles_live_ceiling`](crate::render::TierConfig::attractor_particles_live_ceiling),
672    /// or the offline one on a headless render path), fixed for the life of the
673    /// scene — a tier change rebuilds the scene.
674    ///
675    /// **Sized at the ceiling and not at the current budget, deliberately**
676    /// (ADR-0140): a resize then moves [`budget`](Self::budget) and
677    /// [`active_count`](Self::active_count) and rebuilds no GPU resource. Building
678    /// one mid-run is what shifts what a later pass resolves to on the DX12
679    /// software adapter, and the field block stays the only rebuild a resize
680    /// costs.
681    particle_count: u32,
682    /// The tier's sample budget **at [`REFERENCE_PX`](crate::render::tier::REFERENCE_PX)**
683    /// ([`TierConfig::attractor_particles`](crate::render::TierConfig::attractor_particles)) —
684    /// the density law's anchor, and the floor of what
685    /// [`budget`](Self::budget) can resolve to.
686    anchor: u32,
687    /// Whether a target size has reached this scene yet, so
688    /// [`sample_budget`](Self::sample_budget) can tell "the anchor, because the
689    /// target is small" from "the anchor, because nothing has asked yet".
690    targeted: bool,
691    /// This target's resolved sample budget:
692    /// `clamp(round(anchor * target_px / REFERENCE_PX), anchor, particle_count)`
693    /// ([`attractor_budget`], ADR-0140). Updated in
694    /// [`set_target_size`](Scene::set_target_size), which is why a resize changes
695    /// how densely the figure is sampled and nothing else.
696    budget: u32,
697    /// How many of [`budget`](Self::budget) are actually stepped and drawn —
698    /// `round(effective * density)`, where the effective budget is the
699    /// [`anchor`](Self::anchor) for a trace and `budget` for a cloud
700    /// (`active_particles`, ADR-0069 + ADR-0195).
701    ///
702    /// **Nothing is reallocated when this moves.** The storage buffer, the seeded
703    /// scatter and every bind group stay sized to `particle_count`; this only
704    /// narrows the dispatch, the draw's instance count, and the `count` the step
705    /// shader early-returns against. Particles beyond it keep their seeded
706    /// positions untouched for the life of the preset — asserted directly, since
707    /// "rebuilds nothing" is otherwise a claim about code that is easy to break.
708    active_count: u32,
709    /// How much of the tier budget the loaded preset asked for, from
710    /// `[particles] density` — a count against the anchor at or below
711    /// [`TRACE_DENSITY`], a fraction of [`budget`](Self::budget) at or above
712    /// [`CLOUD_DENSITY`]. Structural: set in `configure`, never per frame.
713    density: f32,
714    /// The deterministic seeded scatter, uploaded on the first frame after a
715    /// (re)build so a rebuilt scene restarts identically (capture determinism).
716    seed_particles: Vec<Particle>,
717    /// Re-upload the seed scatter next render. Set on first build and on a family
718    /// change — the two places there is no existing cloud to disturb. A `reseed`
719    /// does not set it (ADR-0066); see [`Self::pending_jitter`].
720    needs_upload: bool,
721    /// A `reseed` rising edge is pending: the next render encodes **one** jitter
722    /// dispatch before its steps, kicking each particle where it already is.
723    /// A bool rather than a count, because two reseeds inside one frame are one
724    /// disturbance — the parameter is edge-triggered, not integrated.
725    pending_jitter: bool,
726    /// How many reseeds have fired. Salts the jitter hash so successive reseeds
727    /// kick a particle in different directions, and advances only on the edge —
728    /// so it is a function of the input sequence and the cloud stays reproducible.
729    reseed_count: u32,
730    /// Clear the trail field to black next render. Set only on first build (not on
731    /// reseed, so a beat's disturbance blooms over the existing trails).
732    needs_clear: bool,
733    /// Fixed-timestep accumulator: unspent injected `dt`, drained one
734    /// [`FIXED_STEP`] at a time into compute steps.
735    fixed_step: gpu::FixedStep,
736    /// Steps `advance` scheduled for the next `render` to encode.
737    pending_steps: u32,
738    /// The index of the **next** fixed step to run — the IFS's map-choice salt
739    /// (ADR-0075), advanced by the number of steps actually encoded.
740    ///
741    /// Determinism is preserved exactly: it is a pure function of the injected
742    /// `dt` sequence, which captures pin at 1/60 s, and it starts at zero on
743    /// every rebuild. It wraps rather than saturating — at 60 steps per second a
744    /// `u32` takes 2.3 years to go round, and the value's only job is to
745    /// decorrelate successive draws.
746    step_index: u32,
747    /// Real elapsed seconds for this frame, injected via `advance`,
748    /// which makes the trail decay frame-rate-independent.
749    dt: f32,
750    /// The integrated spin, in **spin-scaled seconds** — advanced once per frame
751    /// in [`update`](Scene::update), where this frame's `spin` is already
752    /// resolved, and turned into radians by [`spin_phase`] at the uniform.
753    ///
754    /// **This scene does not read the shared clock at all**, and has no
755    /// `set_time`: the display rotation was its only reader, and a rate
756    /// multiplier has to be integrated rather than multiplied against elapsed
757    /// time (see [`Phase`]). Determinism is unaffected — the phase is a pure
758    /// function of the injected `dt` sequence, which captures pin at 1/60 s, and
759    /// it starts at zero on every rebuild.
760    spin_time: Phase,
761    /// The active attractor map, selected data-driven via `[particles]`
762    /// (ADR-0007 `configure`); its default coefficients seed `a`..`d`.
763    family: AttractorFamily,
764    /// The active family's tuple roster, framing resolved (ADR-0093). Built in
765    /// [`configure`](Scene::configure) — off the hot path, for the reason
766    /// [`ifs_ends`](Self::ifs_ends) is cached there: entry framing is a pure
767    /// function of the family and the coefficients, and measuring it inside the
768    /// frame loop would spend a hitch on the exact frame a `tuple` cut lands.
769    roster: Vec<RosterEntry>,
770    /// Which entry [`tuple`](Self::tuple) last resolved to. Always a valid index
771    /// into [`roster`](Self::roster) — [`roster_index`] clamps.
772    tuple_index: usize,
773    /// The measured path `morph` walks on a map family (ADR-0093), from
774    /// `[particles] tuple_from`/`tuple_to`. `None` — the default — is what makes
775    /// `morph` inert on the four map families, exactly as an absent `morph_to`
776    /// makes it inert on an IFS.
777    ///
778    /// Built at [`configure`](Scene::configure) for [`ifs_fit`](Self::ifs_fit)'s
779    /// reason: measuring nine framings across the walk is thousands of map
780    /// iterations, and the walk is a pure function of the pair.
781    tuple_walk: Option<TupleWalk>,
782    /// The roster selector (ADR-0093), raw as the preset bound it;
783    /// [`roster_index`] quantizes it on the way to an entry.
784    ///
785    /// **Resolved in [`update`](Scene::update) rather than in
786    /// [`set_param`](Scene::set_param)**, because the renderer routes a frame's
787    /// bindings in an unspecified order: an entry's coefficients landing in
788    /// `a`..`d` mid-routing would be overwritten by — or would overwrite — a
789    /// preset's own `a` binding depending on which name the map yielded first.
790    /// `update` runs after every binding and before the render, so the entry, its
791    /// coefficients and its framing all reach the GPU from the same frame's value.
792    tuple: f32,
793    /// The two ends of the IFS morph, **decomposed once at `configure`**
794    /// (ADR-0075). `None` on the four map families.
795    ///
796    /// Cached rather than derived per frame because the decomposition is the
797    /// expensive half — four hypotenuses and four `atan2`s per map — and it is a
798    /// function of the figure pair alone. What the frame pays is the lerp and
799    /// the recompose, which is what has to be per-frame because `morph` is
800    /// bindable. When `[particles] morph_to` is absent both ends are the same
801    /// table, so `morph` is exactly inert rather than conditionally applied.
802    ifs_ends: Option<(IfsTable, IfsTable)>,
803    /// The figure pair's framing over `morph`, measured once at `configure`
804    /// with every lever at neutral (ADR-0075). `None` on the four map families,
805    /// which keep their single hand-fitted world scale.
806    ifs_fit: Option<FitLut>,
807    /// Position along the morph from the configured figure to `morph_to`
808    /// (ADR-0075). Bindable; clamped to `[0, 1]` inside
809    /// [`ifs::resolve`](ifs::resolve), where extrapolation would be the one
810    /// operation that can leave the contractive ball.
811    morph: f32,
812    /// The four IFS shape levers (ADR-0075), applied in SVD space by
813    /// [`ifs::resolve`]. Inert on the four map families, which have no table for
814    /// them to act on.
815    ///
816    /// Held as the lever struct rather than four loose floats so the one thing
817    /// that must not happen — a lever reaching [`FitLut::build`] — is a type
818    /// error rather than a discipline.
819    levers: Levers,
820    /// Attractor coefficients — named params, so a preset can steer the cloud's
821    /// shape with the bands. Their meaning is family-specific.
822    a: f32,
823    b: f32,
824    c: f32,
825    d: f32,
826    size: f32,
827    /// The shared palette knobs (ADR-0021).
828    colour: common::PaletteParams,
829    /// The shared view transform (ADR-0018).
830    pan: common::PanParams,
831    fade: f32,
832    /// How much of this cloud's coverage the backdrop resolves against
833    /// (ADR-0085). Set by the renderer every frame through
834    /// [`Scene::set_occlude`](super::Scene::set_occlude) — not a named param, so
835    /// `reset_params` leaves it alone.
836    occlude: f32,
837    /// This frame's `fb_*` transform (ADR-0048), reset with every other param.
838    feedback_transform: feedback::Transform,
839    /// The active preset's `[feedback]` table. **Structural**, so unlike
840    /// [`feedback_transform`](Self::feedback_transform) it is set once at preset
841    /// load and is not reset per frame.
842    ///
843    /// Only its `warp` reaches this scene. `blend` is a choice about how *this
844    /// frame's* light lands on the past, and this scene's deposit has been
845    /// additive since it was written — the points draw through an additive
846    /// pipeline over the decayed bed, in one pass. There is no `max` to select
847    /// here without a second draw pipeline, which is exactly the WARP hazard
848    /// ADR-0048 kept the warp family out of.
849    feedback: FeedbackConfig,
850    /// Shared palette color knobs (ADR-0021 / Plan 0020 Phase 5): the per-particle
851    /// seed jitter band + shared desaturation + A/B crossfade.
852    hue_spread: f32,
853    hue_center: f32,
854    /// Shared view transform (ADR-0018 / Plan 0025 Phase 4): `zoom` scales the
855    /// projected cloud about the screen centre, `pan_*` offsets it.
856    zoom: f32,
857    /// Perspective strength (ADR-0076): the figure's depth half-extent as a
858    /// fraction of the camera distance, clamped to [`MAX_PERSPECTIVE`] where the
859    /// uniform is packed. `0` is the orthographic projection this scene shipped
860    /// with, and it is inert on the 2D families whatever it is set to.
861    perspective: f32,
862    /// The shared lens (ADR-0257): `focus` normalized to the figure's depth, 0
863    /// nearest, and `aperture` in pixels. Inert on the 2D families, and inert
864    /// everywhere at `aperture = 0`.
865    focus: f32,
866    aperture: f32,
867    /// The tier's cap on the circle of confusion, in pixels.
868    max_coc: f32,
869    /// This frame's blur clamp, if the lens asked past [`max_coc`](Self::max_coc),
870    /// for the renderer to announce (ADR-0007: a cap is never silent).
871    blur_clamp: Option<super::lines::CapOverflow>,
872    /// Atmospheric depth cues (ADR-0076), the substitute for occlusion:
873    /// `depth_fade` attenuates a particle's brightness with distance (clamped to
874    /// `[0, 1]` where the uniform is packed — past `1` the multiplier would go
875    /// negative and *subtract* light from the additive accumulation), and
876    /// `depth_hue` shifts its palette coordinate by `±depth_hue/2` across the
877    /// depth range. Both inert on the 2D families, like `perspective`.
878    depth_fade: f32,
879    depth_hue: f32,
880    /// The last-map colour channel's two routes (ADR-0087), **IFS-only** — every
881    /// other family leaves `Particle::map` at `0.0`, so both are exactly inert
882    /// there without a branch, the way `perspective` is inert on a 2D family.
883    ///
884    /// `map_tint` shifts the particle's palette coordinate by `±map_tint/2`
885    /// across the four maps, so the colour comes from the preset's own
886    /// `[palette]` ramp and `palette_mix`/`saturation` reach it for free.
887    /// `map_hue` instead rotates the hue of the colour that ramp produced,
888    /// leaving the coordinate alone — the route for a preset that wants its
889    /// fronds nudged off its body without editing its gradient.
890    map_tint: f32,
891    map_hue: f32,
892    /// The root channel's two routes (ADR-0088), IFS-only for the same structural
893    /// reason the two above are: only the IFS arm writes [`Particle::root`].
894    ///
895    /// `root_tint` shifts the palette coordinate by `root_tint · root01` across
896    /// the figure's own skeleton — dark at the stem base and the frond origins,
897    /// bright at the tips. `root_hue` rotates the hue of the colour that ramp
898    /// produced instead, leaving the coordinate untouched.
899    ///
900    /// **Both anchored at `0`, not centred like `map_*`** (ADR-0088's Anchoring
901    /// section): the fixed points keep the preset's chosen colour exactly and
902    /// the figure ramps away from them. Centring assumes the channel spans
903    /// `[0, 1]`, and this one does not — its ceiling is a property of each
904    /// figure's own invariant measure, from `0.41` on the spiral to `1.05` on
905    /// the dragon, so the **same binding is not the same look across figures**.
906    ///
907    /// **`root_hue` is the escape from a full palette coordinate.** Three params
908    /// write that coordinate and it is a fixed budget — Plan 0074's gate measured
909    /// the Barnsley fern preset needing `map_tint` cut `0.46 -> 0.22` before
910    /// `root_tint` improved on stock. This route costs it nothing.
911    ///
912    /// These replaced `age_tint`/`age_hue`, which read the decaying age proxy and
913    /// never produced a gradient.
914    root_tint: f32,
915    root_hue: f32,
916    /// Length of the emergence ramp in **steps** - how long a just-respawned
917    /// particle takes to reach full brightness (ADR-0087), IFS-only because
918    /// nothing else respawns.
919    ///
920    /// At [`FIXED_STEP`] the default 8 steps is ~0.13 s. Raise it when a longer
921    /// `fade` lets the four restart points accumulate: a trail integrates them
922    /// over more frames, so a ramp sufficient at `fade = 0.86` may not be at
923    /// `0.94`. Clamped silently at the pack site by [`emergence_rate`].
924    emergence: f32,
925    /// Rate multiplier on [`SPIN_RATE`] (ADR-0076). Unlike the depth cues this is
926    /// **not** inert on the 2D families: the discrete maps rotate in-plane
927    /// through the same angle, so `spin` reaches all four families where
928    /// `perspective`, `depth_fade` and `depth_hue` reach two. That asymmetry is
929    /// deliberate — an in-plane spin is a real look on De Jong today.
930    spin: f32,
931    /// The active baked palette. Held here rather than only in the pipelines'
932    /// [`palette::LutPair`] because the resources build lazily: `set_palette` can
933    /// arrive with `res` still `None`, and this is what seeds the pair.
934    palette: Palette,
935    /// This frame's `reseed` level (bound to a beat/onset expression); its rising
936    /// edge disturbs the cloud in place (ADR-0066).
937    reseed: f32,
938    /// Previous frame's `reseed`, for rising-edge detection.
939    prev_reseed: f32,
940}
941
942impl AttractorScene {
943    /// Build the CPU-side seeded scatter. GPU resources are deferred to the first
944    /// render (module docs).
945    /// `anchor` is the tier's budget at [`REFERENCE_PX`](crate::render::tier::REFERENCE_PX) and `ceiling` is the
946    /// most the density law may resolve for this scene — the tier's live ceiling
947    /// on a surface, its offline one on a headless render path. The buffer is
948    /// sized at `ceiling`; the budget starts at `anchor` and moves on the first
949    /// `Scene::set_target_size`, whose trait is crate-private and so is named
950    /// here rather than linked.
951    pub fn new(
952        device: &wgpu::Device,
953        surface_format: wgpu::TextureFormat,
954        anchor: u32,
955        ceiling: u32,
956        trail_cap: (u32, u32),
957        max_coc: f32,
958    ) -> Self {
959        // A ceiling under the anchor would allocate less than the law's own floor
960        // resolves to and index past the buffer; the law clamps the same way.
961        let particle_count = ceiling.max(anchor);
962        let family = AttractorFamily::DeJong;
963        // Entry 0's box by construction rather than by index: nothing has been
964        // configured yet, so the canonical framing IS the roster's first entry
965        // and reaching for it directly keeps this constructor infallible.
966        let seed_particles = Self::seed(
967            family,
968            family.canonical_framing().seed_box,
969            &[],
970            particle_count,
971        );
972        let [a, b, c, d] = family.default_coeffs();
973        Self {
974            device: device.clone(),
975            surface_format,
976            res: None,
977            trail_cap,
978            field_scale: GridScale::FULL,
979            particle_count,
980            anchor,
981            targeted: false,
982            // The law's own lower clamp until a target size arrives — never fewer
983            // than the tier's count, which is what every small capture resolves.
984            budget: anchor,
985            // The whole budget until a `[particles] density` says otherwise, so a
986            // preset that never mentions the key is byte-identical to before it.
987            active_count: anchor,
988            density: 1.0,
989            trail_w: TRAIL_FALLBACK_W,
990            trail_h: TRAIL_FALLBACK_H,
991            target_h: TRAIL_FALLBACK_H,
992            seed_particles,
993            needs_upload: true,
994            pending_jitter: false,
995            reseed_count: 0,
996            needs_clear: true,
997            fixed_step: gpu::FixedStep::new(FIXED_STEP, MAX_SUBSTEPS),
998            pending_steps: 0,
999            step_index: 0,
1000            dt: FIXED_STEP,
1001            spin_time: Phase::default(),
1002            family,
1003            roster: family::resolve_roster(family),
1004            tuple_walk: None,
1005            tuple_index: 0,
1006            tuple: DEFAULT_TUPLE,
1007            ifs_ends: None,
1008            ifs_fit: None,
1009            morph: DEFAULT_MORPH,
1010            levers: Levers::NEUTRAL,
1011            a,
1012            b,
1013            c,
1014            d,
1015            size: DEFAULT_SIZE,
1016            colour: common::PaletteParams::new(DEFAULT_HUE, DEFAULT_BRIGHTNESS),
1017            pan: common::PanParams::default(),
1018            fade: DEFAULT_FADE,
1019            occlude: crate::render::post::DEFAULT_OCCLUDE,
1020            feedback_transform: feedback::Transform::IDENTITY,
1021            feedback: FeedbackConfig::default(),
1022            hue_spread: DEFAULT_HUE_SPREAD,
1023            hue_center: DEFAULT_HUE_CENTER,
1024            zoom: DEFAULT_ZOOM,
1025            perspective: DEFAULT_PERSPECTIVE,
1026            focus: DEFAULT_FOCUS,
1027            aperture: DEFAULT_APERTURE,
1028            max_coc,
1029            blur_clamp: None,
1030            depth_fade: DEFAULT_DEPTH_FADE,
1031            depth_hue: DEFAULT_DEPTH_HUE,
1032            map_tint: DEFAULT_CHANNEL_COLOUR,
1033            map_hue: DEFAULT_CHANNEL_COLOUR,
1034            root_tint: DEFAULT_CHANNEL_COLOUR,
1035            root_hue: DEFAULT_CHANNEL_COLOUR,
1036            emergence: DEFAULT_EMERGENCE,
1037            spin: DEFAULT_SPIN,
1038            palette: Palette::default_spectrum(),
1039            reseed: 0.0,
1040            prev_reseed: 0.0,
1041        }
1042    }
1043
1044    /// The budget the density law resolved for the target this scene was last
1045    /// given (ADR-0140) — `None` until [`set_target_size`](Scene::set_target_size)
1046    /// has been called, which is the first frame.
1047    ///
1048    /// The **budget**, not `active_count`: a preset's `[particles] density`
1049    /// narrows what is drawn out of it (ADR-0069), and that is a look choice
1050    /// rather than a property of the target.
1051    ///
1052    /// **Inherent and `#[cfg(test)]`, not a `Scene` method** (ADR-0238): only a
1053    /// test asks, and only of this scene. It is not the reporting path either —
1054    /// `shot --render` prints its budget from
1055    /// [`TierConfig::attractor_budget_offline`](crate::render::TierConfig::attractor_budget_offline)
1056    /// instead, because the header is written before the renderer exists and
1057    /// this would answer `None` there.
1058    #[cfg(test)]
1059    pub(crate) fn sample_budget(&self) -> Option<u32> {
1060        self.targeted.then_some(self.budget)
1061    }
1062
1063    /// What `density` resolved to out of that budget — `None` until a target
1064    /// size has reached the scene, so the two answer together.
1065    ///
1066    /// Distinct from [`sample_budget`](Self::sample_budget) for the reason that
1067    /// method's doc gives: the budget is a property of the target, and this is
1068    /// the look choice taken out of it. Both are needed to state that a density
1069    /// change moved the drawn count and left the allocation alone.
1070    #[cfg(test)]
1071    pub(crate) fn active_sample_count(&self) -> Option<u32> {
1072        self.targeted.then_some(self.active_count)
1073    }
1074
1075    /// Build the GPU resources on the first frame, and re-allocate the
1076    /// grid-dependent half when `set_target_size` asked for a different grid than
1077    /// the live one (Plan 0027 Phase 2). In the steady state — every frame of a
1078    /// static window — this is the two integer compares below and nothing else
1079    /// (ADR-0030 condition 2).
1080    ///
1081    /// A grid change rebuilds **only** `FieldResources` (Plan 0029 Phase 1): the
1082    /// shaders, pipelines, particle buffer and LUT textures do not depend on the
1083    /// grid and survive, so a resize costs a texture pair plus four bind groups
1084    /// instead of four shader compilations. The rebuilt field is undefined, so the
1085    /// **clear** is re-flagged — a resize restarts the trail rather than carrying a
1086    /// differently-sized accumulation across. The palette is not re-flagged: the
1087    /// LUT textures survive.
1088    ///
1089    /// Neither is the particle buffer (Plan 0031 Phase 4, closing Plan 0029's
1090    /// close-review minor 1). It survives the split, so re-uploading the seed
1091    /// scatter on a grid change is **not** necessary — and re-uploading is
1092    /// the surviving half of "a fullscreen toggle pops the cloud back to its
1093    /// seed scatter": the points keep iterating across a resize, then jump
1094    /// back. Determinism does not need it either, since a headless capture
1095    /// holds one target size for its whole run.
1096    fn rebuild_if_stale(&mut self) {
1097        let grid_stale = self
1098            .res
1099            .as_ref()
1100            .is_none_or(|res| res.grid.trail_w != self.trail_w || res.grid.trail_h != self.trail_h);
1101        if !grid_stale {
1102            return;
1103        }
1104        let res = match self.res.take() {
1105            Some(mut res) => {
1106                res.rebuild_grid(&self.device, self.trail_w, self.trail_h);
1107                res
1108            }
1109            None => {
1110                // First build: the LUT pair is fresh and holds the default palette,
1111                // so hand it the one the scene is carrying, and the particle buffer
1112                // has never been written — this is the arm the seed upload belongs
1113                // to.
1114                self.needs_upload = true;
1115                let mut built = Resources::build(
1116                    &self.device,
1117                    self.surface_format,
1118                    self.trail_w,
1119                    self.trail_h,
1120                    self.particle_count,
1121                );
1122                built.pipelines.luts.set(&self.palette);
1123                built
1124            }
1125        };
1126        self.res = Some(res);
1127        self.needs_clear = true;
1128    }
1129
1130    /// The trail accumulation's readable texture, or `None` before the first
1131    /// render has built the GPU resources.
1132    ///
1133    /// **A test instrument**, the same tap `warp_mesh` opens on its own field and
1134    /// for the same reason: the claim ADR-0065's normalization makes is about the
1135    /// **total light laid into the accumulation**, and a composite readback
1136    /// cannot state it — everything downstream of the field applies a tonemap, so
1137    /// a picture that looks equally bright is not a measurement that the light is
1138    /// equal. `PingPongField` already carries `COPY_SRC` for exactly this.
1139    #[cfg(test)]
1140    pub(crate) fn field_texture(&self) -> Option<&wgpu::Texture> {
1141        Some(self.res.as_ref()?.grid.field.read_texture())
1142    }
1143
1144    /// Read every live particle position back off the GPU.
1145    ///
1146    /// **A test instrument** (Plan 0057 Phase 3), not a render path: it blocks on a
1147    /// buffer map, which the frame loop must never do. It exists because the
1148    /// property ADR-0066 changes is a property of *the cloud*, and a pixel
1149    /// differential cannot state it — "the reseed does not put particles outside
1150    /// the attractor's extent" is a claim about positions, and a frame diff would
1151    /// only say the picture moved, which the wipe also did.
1152    ///
1153    /// `None` before the first render, when there are no GPU resources yet.
1154    ///
1155    /// Returns whole [`Particle`]s rather than a `(pos, prev)` pair: ADR-0087's
1156    /// `age` and `map` are the same kind of claim — a property of the buffer
1157    /// that a capture can only report indirectly — so the readback hands back
1158    /// the struct and each caller takes the fields its own assertion is about.
1159    #[cfg(test)]
1160    fn read_particles(&self, queue: &wgpu::Queue) -> Option<Vec<Particle>> {
1161        let res = self.res.as_ref()?;
1162        let size = (self.particle_count as usize * std::mem::size_of::<Particle>()) as u64;
1163        let staging = self.device.create_buffer(&wgpu::BufferDescriptor {
1164            label: Some("attractor-particle-readback"),
1165            size,
1166            usage: wgpu::BufferUsages::COPY_DST | wgpu::BufferUsages::MAP_READ,
1167            mapped_at_creation: false,
1168        });
1169        let mut encoder = self
1170            .device
1171            .create_command_encoder(&wgpu::CommandEncoderDescriptor {
1172                label: Some("attractor-particle-readback"),
1173            });
1174        encoder.copy_buffer_to_buffer(&res.pipelines.particles, 0, &staging, 0, size);
1175        queue.submit(std::iter::once(encoder.finish()));
1176
1177        // The same idiom as `capture::read_back`, which is the readback this
1178        // project already trusts.
1179        let slice = staging.slice(..);
1180        let (tx, rx) = std::sync::mpsc::channel();
1181        slice.map_async(wgpu::MapMode::Read, move |res| {
1182            let _ = tx.send(res);
1183        });
1184        self.device
1185            .poll(wgpu::PollType::wait_indefinitely())
1186            .expect("particle readback poll");
1187        rx.recv()
1188            .expect("particle readback callback")
1189            .expect("particle readback map");
1190
1191        let out = {
1192            let mapped = slice.get_mapped_range().expect("particle readback range");
1193            let particles: &[Particle] = bytemuck::cast_slice(&mapped);
1194            particles.to_vec()
1195        };
1196        staging.unmap();
1197        Some(out)
1198    }
1199
1200    /// The CPU-side initial fill: a seeded scatter in a small box, each particle
1201    /// carrying a hue jitter of its own. Points converge onto the attractor
1202    /// within a few iterations, so the starting positions only need to differ.
1203    ///
1204    /// The `x`/`y`/`seed` draws come first — that order is what keeps De Jong
1205    /// and Clifford byte-identical against the 2D scatter the 3D families
1206    /// generalized — and `z` is drawn in a second pass for the 3D families.
1207    ///
1208    /// The box is the **active roster entry's** (ADR-0093), handed in rather
1209    /// than read off the family: a wild tuple's figure can be twice the
1210    /// canonical one's, and filling the canonical box would leave its particles
1211    /// bunched in the middle of it — the clumping [`Framing::seed_box`] exists to
1212    /// prevent, on a figure that has no other way to ask for a bigger fill.
1213    ///
1214    /// **The IFS does not fill a box, and that is where backlog 0064 dies**
1215    /// (ADR-0087). Every other family scatters uniformly over its entry's box
1216    /// and contracts onto its attractor over the
1217    /// following second, which showed as a legible, hard-edged, axis-aligned
1218    /// rectangle for roughly two thirds of a second on every switch into the
1219    /// family — the same artifact class ADR-0066 removed from `reseed`, back on
1220    /// a different path. An IFS has somewhere legal to start instead: its maps'
1221    /// fixed points are **on** the attractor by construction
1222    /// ([`ifs::fixed_points`]), so its fill is on the figure at step zero and
1223    /// there is no rectangle to fade out at any frame.
1224    ///
1225    /// **`seed_box` is deliberately untouched.**
1226    /// [`Framing::jitter_extent`] is derived from it as a fraction of its
1227    /// spread, so collapsing that spread would make `reseed` silently inert on
1228    /// the whole family. What changes is what this function *writes*.
1229    ///
1230    /// The figure's **base** table, not the resolved one: `configure` runs on a
1231    /// preset switch, before that preset's `morph` and levers have been routed,
1232    /// so there is no resolved table to ask. Phase 3's continuous respawn targets
1233    /// the live resolved table, which is what carries the fill to wherever a
1234    /// bound `morph` has taken the figure — within one particle lifetime.
1235    ///
1236    /// # The scatter is a function of `count`, not only of a particle's index
1237    ///
1238    /// **The trap, for whoever changes a ceiling.** `x`, `y`, `seed` and `age` are
1239    /// drawn in the first pass and `z` in the second, from **one** stream — so
1240    /// `z` for particle `i` sits at stream position `3 * count + i`, and asking
1241    /// for a different `count` moves the depth of *every* particle, including the
1242    /// ones the caller then draws. The buffer is allocated at the tier's ceiling
1243    /// (ADR-0140), so that ceiling is an input to every attractor picture at a
1244    /// tier where it differs from the anchor — and moving it re-renders every one
1245    /// of them.
1246    ///
1247    /// It is not fixable by giving `z` its own stream: that was tried and it
1248    /// moves a committed golden baseline, which is the one thing the law is built
1249    /// not to do. `Tier::Floor` is unaffected either way, because its ceiling
1250    /// **is** its anchor — which is why every baseline in this repo, all of them
1251    /// `Floor` by construction, is untouched.
1252    fn seed(
1253        family: AttractorFamily,
1254        seed_box: ([f32; 3], [f32; 3]),
1255        fill: &[[f32; 3]],
1256        count: u32,
1257    ) -> Vec<Particle> {
1258        let (spread, center) = seed_box;
1259        let fixed = family.figure().map(|f| ifs::fixed_points(&f.table()));
1260        let mut rng = SeededRng::new(SEED);
1261        let mut particles: Vec<Particle> = (0..count as usize)
1262            .map(|index| {
1263                let (x, y, seed, age) = match fixed {
1264                    // Drawn from the particle's own fixed seed, so which point a
1265                    // given particle starts at is a pure function of the seeded
1266                    // scatter — the same property every other determinism claim
1267                    // in this scene rests on.
1268                    Some(points) => {
1269                        let seed = rng.next_f32();
1270                        let slot = ((seed * ifs::MAPS as f32) as usize).min(ifs::MAPS - 1);
1271                        let [x, y] = points.get(slot).copied().unwrap_or([0.0, 0.0]);
1272                        // **Age starts spread, and that is not a refinement of
1273                        // the churn — it is the churn's first frame.** Seeded at
1274                        // zero, every particle would reach the end of its life
1275                        // within one lifetime-spread of every other, and the
1276                        // population would hold a single age for the first
1277                        // ~1.5 s of every preset. The colour gradient Phase 4
1278                        // builds on this would be flat for exactly as long.
1279                        //
1280                        // Strictly below the particle's own life (`next_f32` is
1281                        // `[0, 1)`), so nothing respawns on its first step and
1282                        // there is no bulk restart at startup either.
1283                        let age = rng.next_f32() * churn_lifetime(seed);
1284                        (x, y, seed, age)
1285                    }
1286                    // A measured roster entry starts **on** its own attractor
1287                    // (ADR-0093), from the bank the framing measurement collected
1288                    // — the IFS argument above, on a figure with no closed-form
1289                    // fixed points to reach for. The box fill below is what a
1290                    // measured entry cannot use: most of a chaotic figure's
1291                    // bounding box is empty space, and the cloud's way back onto
1292                    // the attractor is a transient that overruns the frame for
1293                    // seconds (measured: 2.2x the figure's own extent at
1294                    // rho ~ 100, for its first several seconds).
1295                    //
1296                    // Walked in order rather than drawn at random: the bank is
1297                    // already an even sample of the attractor, so a draw would
1298                    // only leave gaps and pile up duplicates.
1299                    None if !fill.is_empty() => {
1300                        let [x, y, _] = fill.get(index % fill.len()).copied().unwrap_or_default();
1301                        (x, y, rng.next_f32(), 0.0)
1302                    }
1303                    None => {
1304                        let x = center[0] + rng.range(-spread[0], spread[0]);
1305                        let y = center[1] + rng.range(-spread[1], spread[1]);
1306                        // Nothing ages a map family: no respawn, and the draw's
1307                        // emergence ramp is a flat 1.0 there.
1308                        (x, y, rng.next_f32(), 0.0)
1309                    }
1310                };
1311                Particle {
1312                    pos: [x, y, 0.0],
1313                    seed,
1314                    // Seeded equal to `pos`, so a particle that has not stepped
1315                    // yet spans a zero-length segment and draws as the point it
1316                    // would have drawn before ADR-0069. A zeroed `prev` would
1317                    // instead streak every particle from the origin on the first
1318                    // frame — a starburst that the trail would then keep.
1319                    prev: [x, y, 0.0],
1320                    _pad: 0.0,
1321                    // Staggered on the IFS (see above), flat zero everywhere
1322                    // else. `map` stays 0.0 forever on the four map families,
1323                    // which never write it.
1324                    age,
1325                    map: 0.0,
1326                    // Exact, not a placeholder: an IFS seeds every particle AT a
1327                    // fixed point, so its distance from the nearest one really
1328                    // is zero, and the first step overwrites it anyway. On a map
1329                    // family nothing ever writes it (ADR-0088).
1330                    root: 0.0,
1331                    _spare: 0.0,
1332                }
1333            })
1334            .collect();
1335        for (index, p) in particles.iter_mut().enumerate() {
1336            // The bank's own `z` where there is one, so a 3D figure starts on the
1337            // attractor in all three axes rather than in two — the `x`/`y` above
1338            // came from the same banked point, and a re-drawn `z` would put the
1339            // particle off the figure exactly as a box fill does.
1340            p.pos[2] = match fill.get(index % fill.len().max(1)) {
1341                Some([_, _, z]) => *z,
1342                None => center[2] + rng.range(-spread[2], spread[2]),
1343            };
1344            p.prev[2] = p.pos[2];
1345        }
1346        particles
1347    }
1348
1349    /// The active roster entry (ADR-0093).
1350    ///
1351    /// Total rather than fallible: [`roster_index`] clamps into the roster and
1352    /// [`resolve_roster`] never returns an empty one, so the fallback below is
1353    /// unreachable — it is here because this file denies `unwrap_used`, and
1354    /// because the honest value for "no entry" is the canonical one rather than
1355    /// a panic on a render path.
1356    fn entry(&self) -> ResolvedTuple {
1357        self.roster
1358            .get(self.tuple_index)
1359            .map(|entry| entry.tuple)
1360            .unwrap_or(ResolvedTuple {
1361                coeffs: self.family.default_coeffs(),
1362                framing: self.family.canonical_framing(),
1363            })
1364    }
1365
1366    /// The active entry's on-attractor fill, or empty on the canonical entry —
1367    /// which is every entry the four shipped families have today, and is what
1368    /// keeps their seeded scatter (and every golden blessed against it) exactly
1369    /// as it was.
1370    fn entry_fill(&self) -> &[[f32; 3]] {
1371        self.roster
1372            .get(self.tuple_index)
1373            .map_or(&[][..], |entry| entry.fill.as_slice())
1374    }
1375
1376    /// Re-point the scene at whichever entry this frame's `tuple` selects
1377    /// (ADR-0093), and hand back whether it moved.
1378    ///
1379    /// The entry's coefficients become `a`..`d`, which is what makes selecting an
1380    /// entry change the *figure* rather than only its framing. A preset that binds
1381    /// both `tuple` and a coefficient loses that coefficient for the single frame
1382    /// the cut lands on, and gets it back on the next one — [`reset_params`]
1383    /// re-reads the new entry before the following frame's bindings are routed.
1384    /// That is the ordering cost of resolving after routing, and it is a frame of
1385    /// a cut the ADR-0066 disturbance and the ADR-0024 dissolve are already
1386    /// covering.
1387    fn select_tuple(&mut self) -> bool {
1388        // **A preset either steps the roster or walks a path, never both.** The
1389        // walk's ends are structural and its framing was measured across them at
1390        // load; letting `tuple` move the near end underneath that would either
1391        // re-measure inside the frame loop or render the walk against a framing
1392        // belonging to a different pair.
1393        if self.tuple_walk.is_some() {
1394            return false;
1395        }
1396        let index = family::roster_index(self.tuple, self.roster.len());
1397        if index == self.tuple_index {
1398            return false;
1399        }
1400        self.tuple_index = index;
1401        let entry = self.entry();
1402        let [a, b, c, d] = entry.coeffs;
1403        self.a = a;
1404        self.b = b;
1405        self.c = c;
1406        self.d = d;
1407        // **Only while the scatter is still pending upload**, which is the first
1408        // frame after a build or a family change — there, the entry's own box is
1409        // what the fill should have used and nothing has been drawn yet. A *live*
1410        // cut deliberately keeps the cloud it has: re-seeding mid-preset replaces
1411        // the figure with a uniform axis-aligned rectangle, which is precisely the
1412        // artifact ADR-0066 removed from `reseed`, and the entry's map pulls the
1413        // existing points onto its own attractor within a second anyway.
1414        if self.needs_upload {
1415            self.seed_particles = Self::seed(
1416                self.family,
1417                entry.framing.seed_box,
1418                self.entry_fill(),
1419                self.particle_count,
1420            );
1421        }
1422        true
1423    }
1424}
1425
1426/// How many entries a family's roster holds — what `[particles] tuple_to` is
1427/// validated against at load (ADR-0093).
1428///
1429/// `pub` because the boundary check lives in `preset/schema.rs`, which is where
1430/// a structural key belongs: an index past the end should be a load error naming
1431/// the roster's size, not a silent clamp onto a figure the author did not ask
1432/// for.
1433pub fn roster_len(family: AttractorFamily) -> usize {
1434    family.extra_tuples().len() + 1
1435}
1436
1437/// Parameter vocabulary — see [`fragment_field::PARAMS`](super::fragment_field::PARAMS).
1438/// **Keep in sync with `set_param` below.**
1439pub const PARAMS: &[ParamSpec] = &[
1440    ParamSpec {
1441        name: "a",
1442        default: 0.0,
1443        range: None,
1444        doc: "First of the four family coefficients; what it means depends on the attractor family the tuple picked.",
1445        kind: ParamKind::Modal,
1446        group: ParamGroup::Shape,
1447        main: false,
1448    },
1449    ParamSpec {
1450        name: "b",
1451        default: 0.0,
1452        range: None,
1453        doc: "Second family coefficient - see the roster's attractor essay for what each family does with it.",
1454        kind: ParamKind::Modal,
1455        group: ParamGroup::Shape,
1456        main: false,
1457    },
1458    ParamSpec {
1459        name: "c",
1460        default: 0.0,
1461        range: None,
1462        doc: "Third family coefficient, and on the IFS figures it means nothing at all.",
1463        kind: ParamKind::Modal,
1464        group: ParamGroup::Shape,
1465        main: false,
1466    },
1467    ParamSpec {
1468        name: "d",
1469        default: 0.0,
1470        range: None,
1471        doc: "Fourth family coefficient; like the other three it is inert on the IFS figures.",
1472        kind: ParamKind::Modal,
1473        group: ParamGroup::Shape,
1474        main: false,
1475    },
1476    ParamSpec {
1477        name: "tuple",
1478        default: 0.0,
1479        range: None,
1480        doc: "Picks a whole known-good figure - family, coefficients and framing together.",
1481        kind: ParamKind::Structural,
1482        group: ParamGroup::Shape,
1483        main: true,
1484    },
1485    ParamSpec {
1486        name: "size",
1487        default: 1.0,
1488        range: Some([0.0, 4.0]),
1489        doc: "Size of each particle's deposit into the accumulation.",
1490        kind: ParamKind::Modal,
1491        group: ParamGroup::Shape,
1492        main: true,
1493    },
1494    crate::render::scenes::common::hue(DEFAULT_HUE),
1495    crate::render::scenes::common::brightness(DEFAULT_BRIGHTNESS),
1496    ParamSpec {
1497        name: "fade",
1498        default: 0.94,
1499        range: Some([0.0, 1.0]),
1500        doc: "How much of the accumulation survives each second; near 1 the figure builds up for a long time.",
1501        kind: ParamKind::Modal,
1502        group: ParamGroup::Light,
1503        main: false,
1504    },
1505    ParamSpec {
1506        name: "hue_spread",
1507        default: 0.15,
1508        range: Some([0.0, 1.0]),
1509        doc: "How far across the palette the particle band reaches.",
1510        kind: ParamKind::Modal,
1511        group: ParamGroup::Colour,
1512        main: false,
1513    },
1514    ParamSpec {
1515        name: "hue_center",
1516        default: 0.075,
1517        range: Some([0.0, 1.0]),
1518        doc: "Where that band sits along the palette.",
1519        kind: ParamKind::Modal,
1520        group: ParamGroup::Colour,
1521        main: false,
1522    },
1523    crate::render::scenes::common::SATURATION,
1524    crate::render::scenes::common::PALETTE_MIX,
1525    crate::render::scenes::common::PALETTE_STEPS,
1526    crate::render::scenes::common::PALETTE_CONTOUR,
1527    crate::render::scenes::common::zoom(DEFAULT_ZOOM),
1528    crate::render::scenes::common::PAN_X,
1529    crate::render::scenes::common::PAN_Y,
1530    ParamSpec {
1531        name: "reseed",
1532        default: 0.0,
1533        range: Some([0.0, 1.0]),
1534        doc: "Crossing zero throws every particle back onto a fresh start position.",
1535        kind: ParamKind::Modal,
1536        group: ParamGroup::Motion,
1537        main: false,
1538    },
1539    ParamSpec {
1540        name: "perspective",
1541        default: 0.0,
1542        range: Some([0.0, 1.0]),
1543        doc: "How strongly depth shrinks a particle, turning a flat figure into a solid one.",
1544        kind: ParamKind::Modal,
1545        group: ParamGroup::Shape,
1546        main: false,
1547    },
1548    ParamSpec {
1549        name: "focus",
1550        default: 0.5,
1551        range: Some([0.0, 1.0]),
1552        doc: "Where the focal plane sits in a 3D figure's depth: 0 at its nearest point, 1 at its farthest.",
1553        kind: ParamKind::Modal,
1554        group: ParamGroup::Light,
1555        main: false,
1556    },
1557    ParamSpec {
1558        name: "aperture",
1559        default: 0.0,
1560        range: Some([0.0, crate::render::TierConfig::RICH.max_coc_px as f32]),
1561        doc: "The blur of a 3D figure's far side, in pixels; its near side blurs more, up to the tier's cap. Inert on the flat maps.",
1562        kind: ParamKind::Modal,
1563        group: ParamGroup::Light,
1564        main: false,
1565    },
1566    ParamSpec {
1567        name: "depth_fade",
1568        default: 0.0,
1569        range: Some([0.0, 1.0]),
1570        doc: "How much depth dims a particle, which is what reads as air between the layers.",
1571        kind: ParamKind::Modal,
1572        group: ParamGroup::Light,
1573        main: false,
1574    },
1575    ParamSpec {
1576        name: "depth_hue",
1577        default: 0.0,
1578        range: Some([-1.0, 1.0]),
1579        doc: "Shifts colour with depth, so far parts of the figure sit elsewhere on the palette.",
1580        kind: ParamKind::Modal,
1581        group: ParamGroup::Colour,
1582        main: false,
1583    },
1584    ParamSpec {
1585        name: "spin",
1586        default: 0.0,
1587        range: Some([-2.0, 2.0]),
1588        doc: "Turns per second the figure rotates by about its vertical axis.",
1589        kind: ParamKind::Modal,
1590        group: ParamGroup::Motion,
1591        main: true,
1592    },
1593    ParamSpec {
1594        name: "morph",
1595        default: 0.0,
1596        range: Some([0.0, 1.0]),
1597        doc: "Travels between the tuple's figure and the next one; the visible rate is steepest near zero.",
1598        kind: ParamKind::Modal,
1599        group: ParamGroup::Motion,
1600        main: false,
1601    },
1602    ParamSpec {
1603        name: "curl",
1604        default: 0.0,
1605        range: Some([-2.0, 2.0]),
1606        doc: "Adds a rotational term to the map, curling the trajectories.",
1607        kind: ParamKind::Modal,
1608        group: ParamGroup::Motion,
1609        main: false,
1610    },
1611    ParamSpec {
1612        name: "vigor",
1613        default: 1.0,
1614        range: Some([0.0, 4.0]),
1615        doc: "How far a particle moves per step, so higher spreads the figure and thins it.",
1616        kind: ParamKind::Modal,
1617        group: ParamGroup::Motion,
1618        main: false,
1619    },
1620    ParamSpec {
1621        name: "lean",
1622        default: 0.0,
1623        range: Some([-1.0, 1.0]),
1624        doc: "Tilts the map, breaking the figure's symmetry.",
1625        kind: ParamKind::Modal,
1626        group: ParamGroup::Motion,
1627        main: false,
1628    },
1629    ParamSpec {
1630        name: "bias",
1631        default: 0.0,
1632        range: Some([-1.0, 1.0]),
1633        doc: "Offsets the map, sliding the figure within its own attractor.",
1634        kind: ParamKind::Modal,
1635        group: ParamGroup::Motion,
1636        main: false,
1637    },
1638    ParamSpec {
1639        name: "map_tint",
1640        default: DEFAULT_CHANNEL_COLOUR,
1641        range: Some([0.0, 1.0]),
1642        doc: "How much a particle's colour follows which branch of the map produced it.",
1643        kind: ParamKind::Modal,
1644        group: ParamGroup::Colour,
1645        main: false,
1646    },
1647    ParamSpec {
1648        name: "map_hue",
1649        default: 0.0,
1650        range: Some([-1.0, 1.0]),
1651        doc: "How far apart on the palette those branches are placed.",
1652        kind: ParamKind::Modal,
1653        group: ParamGroup::Colour,
1654        main: false,
1655    },
1656    ParamSpec {
1657        name: "root_tint",
1658        default: DEFAULT_CHANNEL_COLOUR,
1659        range: Some([0.0, 1.0]),
1660        doc: "How much a particle's colour follows the seed it started from.",
1661        kind: ParamKind::Modal,
1662        group: ParamGroup::Colour,
1663        main: false,
1664    },
1665    ParamSpec {
1666        name: "root_hue",
1667        default: 0.0,
1668        range: Some([-1.0, 1.0]),
1669        doc: "How far apart on the palette those seeds are placed.",
1670        kind: ParamKind::Modal,
1671        group: ParamGroup::Colour,
1672        main: false,
1673    },
1674    ParamSpec {
1675        name: "emergence",
1676        default: 8.0,
1677        range: Some([0.0, 60.0]),
1678        doc: "How many seconds the figure takes to settle out of its starting cloud.",
1679        kind: ParamKind::Modal,
1680        group: ParamGroup::Motion,
1681        main: false,
1682    },
1683    ParamSpec {
1684        name: "fb_zoom",
1685        default: crate::render::feedback::DEFAULT_FB_ZOOM,
1686        range: Some([0.9, 1.1]),
1687        doc: "Scale the attractor's own accumulation is grown by each second.",
1688        kind: ParamKind::Modal,
1689        group: ParamGroup::Post,
1690        main: false,
1691    },
1692    ParamSpec {
1693        name: "fb_rotate",
1694        default: crate::render::feedback::DEFAULT_FB_RATE,
1695        range: Some([-1.0, 1.0]),
1696        doc: "Turns per second that accumulation is rotated by.",
1697        kind: ParamKind::Modal,
1698        group: ParamGroup::Post,
1699        main: false,
1700    },
1701    ParamSpec {
1702        name: "fb_dx",
1703        default: crate::render::feedback::DEFAULT_FB_RATE,
1704        range: Some([-1.0, 1.0]),
1705        doc: "Sideways drift of that accumulation, in frame widths per second.",
1706        kind: ParamKind::Modal,
1707        group: ParamGroup::Post,
1708        main: false,
1709    },
1710    ParamSpec {
1711        name: "fb_dy",
1712        default: crate::render::feedback::DEFAULT_FB_RATE,
1713        range: Some([-1.0, 1.0]),
1714        doc: "Vertical drift of that accumulation, in frame heights per second.",
1715        kind: ParamKind::Modal,
1716        group: ParamGroup::Post,
1717        main: false,
1718    },
1719    ParamSpec {
1720        name: "fb_center_x",
1721        default: crate::render::feedback::DEFAULT_FB_CENTER,
1722        range: Some([0.0, 1.0]),
1723        doc: "The horizontal point its zoom and rotation pivot about, in uv.",
1724        kind: ParamKind::Modal,
1725        group: ParamGroup::Post,
1726        main: false,
1727    },
1728    ParamSpec {
1729        name: "fb_center_y",
1730        default: crate::render::feedback::DEFAULT_FB_CENTER,
1731        range: Some([0.0, 1.0]),
1732        doc: "The vertical point its zoom and rotation pivot about, in uv.",
1733        kind: ParamKind::Modal,
1734        group: ParamGroup::Post,
1735        main: false,
1736    },
1737    ParamSpec {
1738        name: "fb_warp",
1739        default: crate::render::feedback::DEFAULT_FB_RATE,
1740        range: Some([0.0, 0.5]),
1741        doc: "Amplitude of a swirl added to its feedback sample, so the trail curls.",
1742        kind: ParamKind::Modal,
1743        group: ParamGroup::Post,
1744        main: false,
1745    },
1746];
1747
1748impl FeedbackSink for AttractorScene {
1749    /// The scene's own internal trail field is the second sink of the preset's
1750    /// `[feedback]` table; the engine trails stage is the first.
1751    fn set_feedback(&mut self, cfg: FeedbackConfig) {
1752        self.feedback = cfg;
1753    }
1754}
1755
1756impl Scene for AttractorScene {
1757    fn name(&self) -> &'static str {
1758        "attractor"
1759    }
1760
1761    fn mirror_overflow(&self) -> Option<&super::lines::CapOverflow> {
1762        self.blur_clamp.as_ref()
1763    }
1764
1765    fn as_feedback_sink(&mut self) -> Option<&mut dyn FeedbackSink> {
1766        Some(self)
1767    }
1768
1769    fn set_occlude(&mut self, occlude: f32) {
1770        self.occlude = occlude;
1771    }
1772
1773    fn advance(&mut self, dt: f32) {
1774        self.dt = dt;
1775        // Drain the accumulator one fixed step at a time, clamped so a long stall
1776        // can't queue unbounded compute work (the reaction-diffusion discipline,
1777        // and now literally the same code). The sub-`FIXED_STEP` remainder
1778        // carries to the next frame.
1779        self.pending_steps = self.fixed_step.advance(dt);
1780    }
1781
1782    /// Size the trail accumulation grid **and** the sample budget to the render
1783    /// target (Plan 0027 Phase 2, Plan 0029 Phase 2; ADR-0140). Called every
1784    /// frame, so the unchanged case must stay free (ADR-0030 condition 2): this
1785    /// only records the grid request — no allocation, no GPU work — and `render`
1786    /// re-allocates the field when it differs from what the live one was built
1787    /// for.
1788    ///
1789    /// **The budget moves here and allocates nothing.** The buffer is already
1790    /// sized at the ceiling, so a resize is this arithmetic plus the field
1791    /// rebuild the grid change was always going to cost.
1792    ///
1793    /// **Both are read against the field, not the target** (ADR-0245): the grid
1794    /// is the recorded `field_scale` of the target, and the budget counts
1795    /// the texels that fraction asks for, so a field drawn smaller is sampled at
1796    /// the reference density rather than over-sampled. At
1797    /// [`GridScale::FULL`] both are exactly what the target alone resolves.
1798    ///
1799    /// The pixel count saturates rather than wrapping: `u32` overflows past a
1800    /// 65 535-square target, and the law clamps at the ceiling long before that,
1801    /// so saturating is exact everywhere it matters.
1802    fn set_target_size(&mut self, width: u32, height: u32) {
1803        let (w, h) = scaled_trail_grid_size(width, height, self.field_scale, self.trail_cap);
1804        self.trail_w = w;
1805        self.trail_h = h;
1806        self.target_h = height.max(1);
1807        self.targeted = true;
1808        self.budget = attractor_budget(
1809            self.anchor,
1810            self.field_scale.texels(width.saturating_mul(height)),
1811            self.particle_count,
1812        );
1813        self.active_count = active_particles(self.anchor, self.budget, self.density);
1814    }
1815
1816    fn set_grid_scale(&mut self, scale: GridScale) {
1817        self.field_scale = scale;
1818    }
1819
1820    // No `set_time`. The display rotation was this scene's only reader of the
1821    // shared clock, and since ADR-0076 it is an integrated phase instead — so
1822    // the trait's no-op default is the honest implementation.
1823
1824    fn set_palette(&mut self, palette: &Palette) {
1825        // Uploaded to the draw LUT textures in `render` (deferred — resources build
1826        // lazily on first render). Cheap array copy, off the hot path.
1827        self.palette = palette.clone();
1828        if let Some(res) = self.res.as_mut() {
1829            res.pipelines.luts.set(palette);
1830        }
1831    }
1832
1833    fn reset_params(&mut self) {
1834        // Defaults are the **active roster entry's** coefficients + the calm look,
1835        // so an unbound preset (or a param a preset leaves out) falls back here
1836        // rather than leaking last frame's. At entry 0 those are the family's
1837        // canonical coefficients, exactly as before the roster existed.
1838        let [a, b, c, d] = self.entry().coeffs;
1839        self.a = a;
1840        self.b = b;
1841        self.c = c;
1842        self.d = d;
1843        self.size = DEFAULT_SIZE;
1844        self.colour.reset();
1845        self.pan.reset();
1846        self.fade = DEFAULT_FADE;
1847        self.hue_spread = DEFAULT_HUE_SPREAD;
1848        self.hue_center = DEFAULT_HUE_CENTER;
1849        self.zoom = DEFAULT_ZOOM;
1850        self.perspective = DEFAULT_PERSPECTIVE;
1851        self.depth_fade = DEFAULT_DEPTH_FADE;
1852        self.focus = DEFAULT_FOCUS;
1853        self.aperture = DEFAULT_APERTURE;
1854        self.depth_hue = DEFAULT_DEPTH_HUE;
1855        self.map_tint = DEFAULT_CHANNEL_COLOUR;
1856        self.map_hue = DEFAULT_CHANNEL_COLOUR;
1857        self.root_tint = DEFAULT_CHANNEL_COLOUR;
1858        self.root_hue = DEFAULT_CHANNEL_COLOUR;
1859        self.emergence = DEFAULT_EMERGENCE;
1860        self.spin = DEFAULT_SPIN;
1861        self.tuple = DEFAULT_TUPLE;
1862        self.morph = DEFAULT_MORPH;
1863        self.levers = Levers::NEUTRAL;
1864        self.reseed = 0.0;
1865        self.feedback_transform = feedback::Transform::IDENTITY;
1866    }
1867
1868    fn set_param(&mut self, name: &str, value: f32) {
1869        // The shared param blocks first, this scene's own names after
1870        // (`scenes::common`).
1871        if self.colour.set(name, value) || self.pan.set(name, value) {
1872            return;
1873        }
1874        match name {
1875            "a" => self.a = value,
1876            "b" => self.b = value,
1877            "c" => self.c = value,
1878            "d" => self.d = value,
1879            // Stored raw; quantized and applied in `update`, once every binding
1880            // this frame has been routed — see the field's doc comment.
1881            "tuple" => self.tuple = value,
1882            "size" => self.size = value,
1883            "fade" => self.fade = value,
1884            "hue_spread" => self.hue_spread = value,
1885            "hue_center" => self.hue_center = value,
1886            "zoom" => self.zoom = value,
1887            "perspective" => self.perspective = value,
1888            "depth_fade" => self.depth_fade = value,
1889            "focus" => self.focus = value,
1890            "aperture" => self.aperture = value,
1891            "depth_hue" => self.depth_hue = value,
1892            "map_tint" => self.map_tint = value,
1893            "map_hue" => self.map_hue = value,
1894            "root_tint" => self.root_tint = value,
1895            "root_hue" => self.root_hue = value,
1896            "emergence" => self.emergence = value,
1897            "spin" => self.spin = value,
1898            "morph" => self.morph = value,
1899            "curl" => self.levers.curl = value,
1900            "vigor" => self.levers.vigor = value,
1901            "lean" => self.levers.lean = value,
1902            "bias" => self.levers.bias = value,
1903            "reseed" => self.reseed = value,
1904            // ADR-0048's shared vocabulary — delegated rather than re-matched,
1905            // so this sink and the trails stage cannot disagree about what
1906            // `fb_dx` means.
1907            _ => {
1908                self.feedback_transform.set_param(name, value);
1909            }
1910        }
1911    }
1912
1913    fn update(&mut self, _frame: &AnalysisFrame) {
1914        // Resolve the roster selector first, because it rewrites `a`..`d`: this
1915        // runs after every one of the frame's bindings and before the render, so
1916        // the entry's coefficients and its framing reach the GPU together
1917        // (ADR-0093). A cut, by design — chaos forbids a general walk between two
1918        // tuples, and the ADR-0066 disturbance and the ADR-0024 dissolve are what
1919        // soften it.
1920        self.select_tuple();
1921        // A walk overrides the coefficients the entry handed out, for the reason
1922        // `select_tuple` overrides a bound `a` on a cut: the figure and its
1923        // framing have to reach the GPU from the same frame's `morph`.
1924        if let Some(walk) = self.tuple_walk.as_ref() {
1925            let [a, b, c, d] = walk.coeffs_at(self.morph);
1926            self.a = a;
1927            self.b = b;
1928            self.c = c;
1929            self.d = d;
1930        }
1931
1932        // Integrate the spin. The renderer routes this frame's bindings before it
1933        // calls `advance` and then this `update` (ADR-0198), so `self.spin` is this
1934        // frame's value here. `self.dt` is the real elapsed seconds `advance`
1935        // recorded, so the phase stays a pure function of the injected `dt`
1936        // sequence.
1937        self.spin_time.step(self.spin, self.dt);
1938
1939        // Rising-edge detect on `reseed` (a beat/onset expression): **disturb** the
1940        // cloud once, where it is (ADR-0066). Edge-triggered so a sustained flag
1941        // doesn't disturb it every frame; deterministic because the kick is a pure
1942        // function of each particle's fixed seed and the reseed counter. The trail
1943        // field is kept, so the disturbance blooms through the trails.
1944        //
1945        // Re-uploading the seed scatter instead does not scatter the
1946        // cloud — it *replaces* it, with a uniform fill of an
1947        // axis-aligned box that then takes a visible number of
1948        // iterations to converge back onto the attractor. Every shipped
1949        // preset header describes reseed as a percussive accent, and
1950        // that wipe is not one.
1951        if self.reseed >= RESEED_THRESHOLD && self.prev_reseed < RESEED_THRESHOLD {
1952            self.pending_jitter = true;
1953            self.reseed_count = self.reseed_count.wrapping_add(1);
1954        }
1955        self.prev_reseed = self.reseed;
1956    }
1957
1958    /// Select the attractor family from the preset's `[particles]` table (ADR-0007
1959    /// `configure`, off the hot path). Reuses the shared [`GeneratorConfig`] enum
1960    /// rather than a new trait method. A family change re-seeds and clears the
1961    /// trail so the new attractor forms cleanly rather than iterating the old
1962    /// family's points. Never truncates, so it never reports a [`CapOverflow`].
1963    fn configure(
1964        &mut self,
1965        cfg: &super::lines::GeneratorConfig,
1966    ) -> Option<super::lines::CapOverflow> {
1967        if let super::lines::GeneratorConfig::Particles {
1968            family,
1969            density,
1970            morph_to,
1971            tuple_path,
1972        } = cfg
1973        {
1974            // `density` is resolved unconditionally, not behind the family guard:
1975            // two presets can share a family and differ only in how much of it
1976            // they draw, and `configure` runs on every preset switch.
1977            self.density = *density;
1978            self.active_count = active_particles(self.anchor, self.budget, *density);
1979            // Likewise the morph ends: two presets can share a figure and morph
1980            // it towards different partners. **Decomposed here**, off the hot
1981            // path, so a frame pays only the lerp and the recompose.
1982            //
1983            // An absent `morph_to` gives both ends the same table rather than a
1984            // `None` the render path has to branch on — so `morph` resolves to
1985            // the identity by arithmetic instead of by a special case.
1986            self.ifs_ends = family.figure().map(|figure| {
1987                let start = figure.table();
1988                let end = morph_to.unwrap_or(figure).table();
1989                (start, end)
1990            });
1991            // ...and the framing that follows the morph, measured here for the
1992            // same reason: it is a function of the figure pair alone, so a frame
1993            // pays one lerp rather than 33 chaos games.
1994            self.ifs_fit = self
1995                .ifs_ends
1996                .as_ref()
1997                .map(|(start, end)| FitLut::build(start, end));
1998            // The tuple path, resolved unconditionally for `density`'s reason:
1999            // two presets can share a family and walk different pairs. Built
2000            // AFTER the family block below would be wrong — the roster it
2001            // indexes is rebuilt there — so it is done once the family is
2002            // settled, at the end of this block.
2003            let requested_path = *tuple_path;
2004            if *family != self.family {
2005                self.family = *family;
2006                // The new family's roster, framing and all — **here**, off the
2007                // hot path, for the reason the morph ends above are decomposed
2008                // here: measuring a tuple's extent is thousands of map
2009                // iterations, and it is a pure function of the family.
2010                self.roster = family::resolve_roster(*family);
2011                // Back to the canonical entry: an index is only meaningful
2012                // against the roster it indexes, so carrying the old family's
2013                // across would name a different figure. `update` re-selects from
2014                // this frame's `tuple` before anything renders.
2015                self.tuple_index = 0;
2016                let entry = self.entry();
2017                let [a, b, c, d] = entry.coeffs;
2018                self.a = a;
2019                self.b = b;
2020                self.c = c;
2021                self.d = d;
2022                // Re-seed with the new family's box (its scale differs) and clear
2023                // the trail so the new attractor forms cleanly. Entry 0 has no
2024                // on-attractor bank by construction — this is the box fill it
2025                // always was, and `select_tuple` re-seeds from the bank if this
2026                // preset's `tuple` picks a measured entry.
2027                self.seed_particles =
2028                    Self::seed(*family, entry.framing.seed_box, &[], self.particle_count);
2029                self.needs_upload = true;
2030                self.needs_clear = true;
2031            }
2032            // ...and now the roster is the right family's, so the path can index
2033            // it. A pair that cannot be measured end to end yields `None` and the
2034            // preset simply has no walk — the same shape a missing `morph_to`
2035            // leaves the IFS in, and a finding rather than an error: a pair whose
2036            // middle diverges has no path, whatever its endpoints look like.
2037            self.tuple_walk = requested_path.and_then(|(from, to)| {
2038                let reference = family::family_reference(*family)?;
2039                let (a, b) = (
2040                    self.roster.get(from as usize)?.tuple,
2041                    self.roster.get(to as usize)?.tuple,
2042                );
2043                TupleWalk::build(*family, a, b, reference)
2044            });
2045            // Park on the near end so the initial fill is that entry's own
2046            // on-attractor bank rather than entry 0's. `select_tuple` leaves the
2047            // index alone once a walk exists, so this is where it is set.
2048            if self.tuple_walk.is_some()
2049                && let Some((from, _)) = requested_path
2050            {
2051                self.tuple_index = (from as usize).min(self.roster.len().saturating_sub(1));
2052                let entry = self.entry();
2053                self.seed_particles = Self::seed(
2054                    *family,
2055                    entry.framing.seed_box,
2056                    self.entry_fill(),
2057                    self.particle_count,
2058                );
2059                self.needs_upload = true;
2060                self.needs_clear = true;
2061            }
2062        }
2063        None
2064    }
2065
2066    /// The frame, in the order the GPU must see it: rebuild if the grid moved,
2067    /// flush the deferred one-shot uploads, write this frame's uniforms, dispatch
2068    /// the compute steps, lay the trail, swap, present.
2069    ///
2070    /// **The order and the `swap()` placement are load-bearing** — the decay reads
2071    /// the field's current read side and the present reads the freshly-written one,
2072    /// so the swap sits exactly between those two passes. The steps are separate
2073    /// functions for readability only; nothing here may be reordered or merged.
2074    fn render(
2075        &mut self,
2076        queue: &wgpu::Queue,
2077        encoder: &mut wgpu::CommandEncoder,
2078        view: &wgpu::TextureView,
2079        aspect: f32,
2080    ) {
2081        self.rebuild_if_stale();
2082        // Read before the destructure below, which borrows the fields
2083        // individually: `Framing` is `Copy`, so this is the active entry's
2084        // framing by value and the roster stays where it is.
2085        let framing = match self.tuple_walk.as_ref() {
2086            Some(walk) => walk.framing_at(self.morph),
2087            None => self.entry().framing,
2088        };
2089        self.blur_clamp = blur_overflow(
2090            asked_blur(self.aperture, framing.inv_depth_extent(self.family) != 0.0),
2091            self.max_coc,
2092        );
2093        let Self {
2094            res,
2095            active_count,
2096            seed_particles,
2097            needs_upload,
2098            pending_jitter,
2099            reseed_count,
2100            needs_clear,
2101            pending_steps,
2102            step_index,
2103            ifs_ends,
2104            ifs_fit,
2105            morph,
2106            levers,
2107            dt,
2108            spin_time,
2109            family,
2110            a,
2111            b,
2112            c,
2113            d,
2114            size,
2115            colour,
2116            fade,
2117            occlude,
2118            feedback_transform,
2119            feedback,
2120            hue_spread,
2121            hue_center,
2122            zoom,
2123            pan,
2124            perspective,
2125            focus,
2126            aperture,
2127            max_coc,
2128            target_h,
2129            depth_fade,
2130            depth_hue,
2131            map_tint,
2132            map_hue,
2133            root_tint,
2134            root_hue,
2135            emergence,
2136            ..
2137        } = self;
2138        let Some(Resources { pipelines, grid }) = res.as_mut() else {
2139            return;
2140        };
2141
2142        flush_deferred_uploads(
2143            queue,
2144            encoder,
2145            pipelines,
2146            grid,
2147            seed_particles,
2148            needs_clear,
2149            needs_upload,
2150        );
2151        upload_uniforms(
2152            queue,
2153            pipelines,
2154            *active_count,
2155            &UniformInputs {
2156                aspect,
2157                coeffs: [*a, *b, *c, *d],
2158                family: *family,
2159                framing,
2160                spin_time: spin_time.get(),
2161                dt: *dt,
2162                pending_steps: *pending_steps,
2163                step_index: *step_index,
2164                // The frame's whole IFS cost: one lerp of two cached
2165                // decompositions and four recomposes. `map_or` rather than a
2166                // branch on the family — a map family has no ends cached, and
2167                // that is the same question asked once.
2168                ifs: ifs_ends.as_ref().map_or(IfsPacked::ZERO, |(a, b)| {
2169                    ifs::pack(&ifs::resolve(a, b, *morph, *levers))
2170                }),
2171                // **The levers are deliberately absent here** (ADR-0075
2172                // Alternative C): the fit is a function of `morph` and the
2173                // figure pair only, so `vigor` surges the figure instead of
2174                // being re-framed back to a net zero.
2175                ifs_frame: ifs_fit.as_ref().map(|fit| fit.sample(*morph)),
2176                size: *size,
2177                hue: colour.hue,
2178                brightness: colour.brightness,
2179                fade: *fade,
2180                occlude: *occlude,
2181                feedback_transform: *feedback_transform,
2182                feedback: *feedback,
2183                hue_spread: *hue_spread,
2184                hue_center: *hue_center,
2185                saturation: colour.saturation,
2186                palette_mix: colour.mix,
2187                palette_steps: colour.steps,
2188                zoom: *zoom,
2189                pan: [pan.x, pan.y],
2190                perspective: *perspective,
2191                aperture: *aperture,
2192                focus: *focus,
2193                max_coc: *max_coc,
2194                target_height: *target_h,
2195                depth_fade: *depth_fade,
2196                depth_hue: *depth_hue,
2197                map_tint: *map_tint,
2198                map_hue: *map_hue,
2199                root_tint: *root_tint,
2200                root_hue: *root_hue,
2201                emergence: *emergence,
2202            },
2203        );
2204        // Before the steps, and only on the frame a `reseed` edge landed: kick each
2205        // particle where it is. Ahead of the steps so the map immediately begins
2206        // pulling the disturbed points back onto the attractor within the same
2207        // frame, which is what makes the disturbance read as the figure being
2208        // shaken rather than as a separate layer of noise over it.
2209        encode_jitter(
2210            queue,
2211            encoder,
2212            pipelines,
2213            *active_count,
2214            framing,
2215            reseed_count,
2216            pending_jitter,
2217        );
2218        encode_steps(encoder, pipelines, *active_count, *pending_steps);
2219        // Advanced by what was actually encoded, and here rather than in
2220        // `advance`: the uniforms above are written against this frame's base
2221        // index, so the counter cannot move before they are.
2222        *step_index = step_index.wrapping_add((*pending_steps).min(MAX_SUBSTEPS));
2223        encode_trail_pass(encoder, pipelines, *active_count, grid);
2224        // Between the trail pass and the present, and nowhere else: the trail wrote
2225        // the write side, so the present must read it.
2226        grid.field.swap();
2227        encode_present(encoder, pipelines, grid, view);
2228    }
2229}
2230
2231// ---------------------------------------------------------------------------
2232// The frame, step by step (Plan 0031 Phase 5)
2233// ---------------------------------------------------------------------------
2234//
2235// `AttractorScene::render` was 228 lines. These are the paragraphs its own
2236// comments already marked, lifted out verbatim: same calls, same order, same
2237// `swap()` placement. Free functions rather than methods because `render`
2238// destructures `self` to borrow the resources and the params at once.
2239
2240/// Mirrors `FIGURE_DISTANCE` / `FIGURE_RADIUS` in [`DRAW_SHADER`]: the virtual
2241/// lens a figure's normalized depth is laid on (ADR-0257), its orbit target one
2242/// unit away and the figure half a unit deep either side of it. Only the
2243/// shader's CPU transcription reads them; the scene judges the bare aperture.
2244#[cfg(test)]
2245pub(super) const FIGURE_DISTANCE: f32 = 1.0;
2246#[cfg(test)]
2247pub(super) const FIGURE_RADIUS: f32 = 0.5;
2248
2249/// The blur, in pixels, the tier's cap is judged against: the aperture, which
2250/// is the circle of confusion of the far field — behind focus the lens rises
2251/// toward it and never passes it. In front of focus the blur grows past it
2252/// without bound and saturates at the cap by design, so that side is never
2253/// judged (ADR-0257). `aperture` is the raw bound value, sanitized as the
2254/// uniform packing sanitizes it.
2255///
2256/// **Exactly 0 on a family without depth**, which `figure_coc()` in the draw
2257/// shader zeroes whatever the aperture, so a flat map never announces a blur.
2258pub(super) fn asked_blur(aperture: f32, has_depth: bool) -> f32 {
2259    if !has_depth || !aperture.is_finite() {
2260        return 0.0;
2261    }
2262    aperture.max(0.0)
2263}
2264
2265/// The overflow to announce when an aperture of `asked` pixels is past the tier's
2266/// `cap`, or `None` when the cap does not bite.
2267pub(super) fn blur_overflow(asked: f32, cap: f32) -> Option<super::lines::CapOverflow> {
2268    (asked > cap).then(|| super::lines::CapOverflow {
2269        dropped: 0,
2270        context: super::OverflowContext::Blur(asked.ceil() as u32),
2271        cap: cap.max(0.0) as usize,
2272    })
2273}
2274
2275#[cfg(test)]
2276mod tests;