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