Skip to main content

rlx_core/render/scenes/particles/
mod.rs

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