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