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