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