rlx_core/render/scenes/mod.rs
1//! Built-in scenes and the thin trait the renderer cycles through.
2//!
3//! Per ADR-0002 this stays crate-internal and minimal: it is the vocabulary
4//! the future preset engine will drive, not a public extension point — no
5//! plugin registration, no dynamic dispatch beyond what cycling needs.
6
7// Hot-path panic-denial pragma (Plan 0002 Phase 2, extended to scenes by Plan
8// 0003 Phase 0). Scene update/render run every displayed frame; a panic here
9// is a visible crash mid-show.
10#![deny(
11 clippy::unwrap_used,
12 clippy::expect_used,
13 clippy::indexing_slicing,
14 clippy::panic,
15 clippy::unreachable
16)]
17
18pub mod analytic_field;
19pub mod cellular;
20pub(crate) mod common;
21pub mod emitter;
22pub mod fragment_field;
23pub mod lines;
24/// The shared mark-silhouette vocabulary the two particle scenes draw through
25/// (ADR-0084). Crate-internal: it is arithmetic and a roster, not a scene.
26pub(crate) mod marks;
27pub mod particles;
28pub mod plexus;
29pub mod reaction_diffusion;
30pub mod shape_collage;
31pub mod shape_field;
32pub mod swarm;
33pub mod warp_mesh;
34
35use std::cell::RefCell;
36use std::rc::Rc;
37
38use crate::dsp::AnalysisFrame;
39use crate::preset::SystemKind;
40use crate::render::palette::Palette;
41
42/// The `dt` (seconds) the C ABI's legacy `rlx_render` and the headless capture
43/// primitives inject when a caller has no real elapsed time to supply — the
44/// former fixed scene step, now demoted to a fallback (Plan 0014 Phase 2, ADR-0012).
45/// The live frontends measure and inject real `dt` instead, so animation is
46/// frame-rate-independent; capture uses this fixed value so a render is a pure
47/// function of its inputs.
48pub(crate) const FALLBACK_DT: f32 = 1.0 / 60.0;
49
50/// What a parameter is **for** (ADR-0180 rule 2): whether its value carries an
51/// integer meaning, which decides two things and nothing else — whether the
52/// engine quantizes it before the scene sees it, and which of the generated
53/// reference's two groups it prints under.
54///
55/// Orthogonal to `[hold]`: a hold reaches any bindable parameter whatever its
56/// kind, and a kind quantizes whether or not the binding is held.
57#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
58pub enum ParamKind {
59 /// Continuous — every value in the range means something, and the scene
60 /// reads the fraction. The default, and what every parameter was before
61 /// kinds existed.
62 #[default]
63 Modal,
64 /// Integer meaning: a mode number, a rule index, a count, a family. The
65 /// engine rounds the post-smoothing value **once**, CPU-side, before
66 /// `set_param`, so the scene is never handed 6.4 petals.
67 Structural,
68}
69
70impl ParamKind {
71 /// The name the generated reference and the exported schema print. Two
72 /// readers, one spelling.
73 pub fn as_str(self) -> &'static str {
74 match self {
75 ParamKind::Modal => "modal",
76 ParamKind::Structural => "structural",
77 }
78 }
79
80 /// `value` as the scene should receive it: rounded for a
81 /// [`Structural`](Self::Structural) parameter, untouched for a
82 /// [`Modal`](Self::Modal) one.
83 ///
84 /// **The last step before `set_param`**, after the hold has chosen which
85 /// frame's value stands and the smoother has eased toward it — which is
86 /// why a structural parameter that is *also* smoothed steps through the
87 /// intervening integers rather than landing fractionally. An author who
88 /// wants a clean jump leaves it out of `[smoothing]`.
89 ///
90 /// A non-finite value is passed through rather than rounded: `f32::round`
91 /// leaves `NaN` alone anyway, and the scenes already guard their own
92 /// inputs.
93 pub fn quantize(self, value: f32) -> f32 {
94 match self {
95 ParamKind::Modal => value,
96 ParamKind::Structural => value.round(),
97 }
98 }
99}
100
101/// What one named parameter is, as the engine declares it (ADR-0170).
102///
103/// A scene and an engine stage each declare their parameters as a `&[ParamSpec]`
104/// rather than as a bare `&[&str]`. Three things read the same declaration: the
105/// load-time check that a preset binding names something real, the constant a
106/// scene applies at reset, and the generated reference table in
107/// `presets/README.md`. Before this they were three copies, and only the names
108/// were held together.
109///
110/// **The doc line is the definition; the roster's essay is the discussion.**
111/// One sentence, saying what the parameter does — not restating its name.
112#[derive(Debug, Clone, Copy, PartialEq)]
113pub struct ParamSpec {
114 /// The name a preset binds, exactly as it is spelled in a `.toml`.
115 pub name: &'static str,
116 /// The value `reset_params` applies, and the one the reference prints.
117 ///
118 /// Read back out with [`default_of`], which resolves at compile time — so
119 /// the scene's `DEFAULT_*` constants and this field are one number, not two.
120 pub default: f32,
121 /// The range that **reads**, for the table.
122 ///
123 /// Not a clamp and not a validation bound: it is what an author can expect
124 /// to see a difference across. `None` where the parameter is unbounded or
125 /// world-space, in which case the frame is the bound — inventing a number
126 /// there would be a claim nothing holds.
127 pub range: Option<[f32; 2]>,
128 /// One sentence: what the parameter does.
129 pub doc: &'static str,
130 /// Whether the value carries an integer meaning (ADR-0180 rule 2).
131 ///
132 /// Read back out with [`kind_of`], and enforced by
133 /// `declared_params_match_set_param` in `core/tests/suite/preset.rs`, which
134 /// compares this against a hand-kept roster — a field nothing checks is a
135 /// field that drifts.
136 pub kind: ParamKind,
137 /// Which of the five groups an editor files the parameter under (ADR-0256).
138 pub group: ParamGroup,
139 /// Whether the parameter is one of the few that most decide its system's
140 /// look — listed first when its group is opened. One flag per declaration,
141 /// not per preset: a parameter central to one preset is shown by the
142 /// preset binding it.
143 pub main: bool,
144}
145
146/// The group a parameter is filed under in an editor (ADR-0256), in the order
147/// an editor lists them.
148///
149/// The engine-wide stages declare [`Post`](Self::Post), except the overall
150/// exposure and the backdrop's brightness, which are [`Light`](Self::Light).
151#[derive(Debug, Clone, Copy, PartialEq, Eq)]
152pub enum ParamGroup {
153 /// What is drawn: form, count, mode, extent, framing.
154 Shape,
155 /// How it moves: speed, spin, drift, flow, anything over time.
156 Motion,
157 /// Hue, saturation, the palette and how the picture reads it.
158 Colour,
159 /// Brightness, glow, fade and the other amounts of light.
160 Light,
161 /// A pass over the finished frame: the backdrop, trails, mirroring,
162 /// bloom, the ink remap.
163 Post,
164}
165
166impl ParamGroup {
167 /// Every group, in listing order.
168 pub const ALL: [ParamGroup; 5] = [
169 ParamGroup::Shape,
170 ParamGroup::Motion,
171 ParamGroup::Colour,
172 ParamGroup::Light,
173 ParamGroup::Post,
174 ];
175
176 /// The name the generated reference and the exported schema print.
177 pub fn as_str(self) -> &'static str {
178 match self {
179 ParamGroup::Shape => "shape",
180 ParamGroup::Motion => "motion",
181 ParamGroup::Colour => "colour",
182 ParamGroup::Light => "light",
183 ParamGroup::Post => "post",
184 }
185 }
186}
187
188/// Byte-wise `str` equality, usable in a `const fn`.
189///
190/// `PartialEq` for `str` is not const, and this is only ever called at compile
191/// time over rosters of a few dozen entries.
192#[allow(
193 clippy::indexing_slicing,
194 reason = "compile-time only; a const fn cannot call `slice::get`, the bound is checked above each index, and a violation is a compile error"
195)]
196const fn str_eq(a: &str, b: &str) -> bool {
197 let (a, b) = (a.as_bytes(), b.as_bytes());
198 if a.len() != b.len() {
199 return false;
200 }
201 let mut i = 0;
202 while i < a.len() {
203 if a[i] != b[i] {
204 return false;
205 }
206 i += 1;
207 }
208 true
209}
210
211/// The default `name` is declared with, resolved **at compile time**.
212///
213/// This is what makes a scene's `DEFAULT_*` constant and its `ParamSpec` one
214/// number rather than two copies of one: the constant is defined as a call to
215/// this, so a default changed in the spec changes what `reset_params` applies,
216/// and the generated table cannot state a value the engine does not use.
217///
218/// The linear scan costs nothing — it never runs at runtime — and a name with no
219/// spec is a **compile error**, because a const-eval panic is.
220#[allow(
221 clippy::indexing_slicing,
222 clippy::panic,
223 reason = "compile-time only; the panic is the point, since a name no spec declares is then a compile error rather than anything a frame reaches"
224)]
225pub const fn default_of(specs: &[ParamSpec], name: &str) -> f32 {
226 let mut i = 0;
227 while i < specs.len() {
228 if str_eq(specs[i].name, name) {
229 return specs[i].default;
230 }
231 i += 1;
232 }
233 panic!("default_of: no ParamSpec declares that name");
234}
235
236/// The names in a spec roster, for the places that still want `&[&str]`.
237///
238/// Allocates, so it is for load-time validation and tests rather than a frame.
239pub fn spec_names(specs: &[ParamSpec]) -> Vec<&'static str> {
240 specs.iter().map(|spec| spec.name).collect()
241}
242
243/// Whether `name` is declared in `specs`. The load-time membership test.
244pub fn declares(specs: &[ParamSpec], name: &str) -> bool {
245 specs.iter().any(|spec| spec.name == name)
246}
247
248/// The [`ParamKind`] `name` is declared with in `specs`, or `None` where no
249/// spec here declares it.
250///
251/// Load-time, like [`declares`]: the loader folds the answer onto the binding
252/// so nothing per frame searches a roster by name (`[smoothing]`'s rule, for
253/// its reason). A name no roster declares is already an ADR-0020 warning, and
254/// an unclaimed binding reaches no scene, so its kind never matters.
255pub fn kind_of(specs: &[ParamSpec], name: &str) -> Option<ParamKind> {
256 specs
257 .iter()
258 .find(|spec| spec.name == name)
259 .map(|spec| spec.kind)
260}
261
262/// Where one family-dependent parameter reads, on one family of a
263/// family-bearing system (ADR-0180 rule 4).
264#[derive(Debug, Clone, Copy, PartialEq)]
265pub struct FamilyRange {
266 /// The family, spelled as a preset names it (`[curve] family = "..."`).
267 pub family: &'static str,
268 /// The range that reads on this family, in [`ParamSpec::range`]'s sense.
269 /// `None` where the family **does not read the parameter at all** — the
270 /// reference prints it as inert there rather than leaving it to be found.
271 pub range: Option<[f32; 2]>,
272}
273
274/// A parameter whose meaning — and so whose reading range — depends on the
275/// family its system draws.
276///
277/// The generated reference prints [`ranges`](Self::ranges) in place of the
278/// single [`ParamSpec::range`], because one pair would be a claim nothing holds:
279/// `parametric_curve`'s `n` reads `1..24` on a rose and `-8..8` on a
280/// hypotrochoid. The spec's own range stays declared and is the one a family in
281/// this list reads — the exported schema still carries one pair per parameter.
282#[derive(Debug, Clone, Copy, PartialEq)]
283pub struct FamilyParam {
284 /// The parameter, as its [`ParamSpec`] names it.
285 pub name: &'static str,
286 /// One entry per family of the system, in the system's roster order.
287 pub ranges: &'static [FamilyRange],
288}
289
290/// The family-dependent parameters of the roster `label` names — as
291/// `export::param_rosters` labels it — or nothing, for a system whose
292/// parameters read the same on every family it draws.
293pub fn family_params(label: &str) -> &'static [FamilyParam] {
294 match label {
295 "parametric_curve" => lines::parametric::FAMILY_PARAMS,
296 "analytic_field" => analytic_field::FAMILY_PARAMS,
297 "cellular" => cellular::FAMILY_PARAMS,
298 "plexus" => plexus::FAMILY_PARAMS,
299 "attractor" => particles::FAMILY_PARAMS,
300 _ => &[],
301 }
302}
303
304/// One integrated animation phase — the only way a bindable rate advances
305/// anything in this engine (ADR-0135, finishing the rule ADR-0132 stated).
306///
307/// **A rate multiplier has to be integrated to be a rate at all.** A phase
308/// computed as `time * rate` lets a rate bound to audio retroactively rescale
309/// *all* elapsed time on every frame: at t = 100 s a swing from `1.0` to `1.5`
310/// moves the phase by fifty seconds in a single frame — the figure snaps to a
311/// new position rather than accelerating toward it, on a lane whose whole
312/// method is binding parameters to audio. Integrated, the same swing bends the
313/// motion.
314///
315/// [`step`](Self::step) is the **only** mutator, and there is deliberately no
316/// `Add`/`AddAssign`/`Deref`/`DerefMut` impl. The constraint is the value: with
317/// one, a scene could write `phase + self.rate * self.time` and compile, which
318/// is exactly the door this type exists to close.
319///
320/// # No constant scale is folded into the accumulation
321///
322/// A scene carrying its own fixed rate — the attractor's `SPIN_RATE` — applies
323/// it where the phase is **read**, never inside the sum. The accumulator is then
324/// `Σ (rate · dt)` with `rate` at its `1.0` default, i.e. `Σ dt` term for term:
325/// bit-for-bit the same summation the renderer performs for its own clock, so
326/// the integrated form reproduces the multiply it replaced *exactly* and no
327/// golden baseline moves. Folding a `0.18` in would sum `0.18 · dt` instead and
328/// drift in the last bits of every capture.
329///
330/// # It steps where this frame's rate has landed
331///
332/// The per-frame order is `reset_params` → `set_param` → `set_time` → `advance`
333/// → `update` (`core/src/render/evaluate.rs`), so by the time either
334/// [`Scene::advance`] or [`Scene::update`] runs, *this* frame's rate is the one
335/// the scene holds. A scene may step a phase in either; the roster's rate
336/// scenes store the `dt` `advance` hands them and step in `update`. The trap is
337/// the sequence itself: moving `advance` back ahead of the bindings would make a
338/// step taken there integrate the last frame's rate.
339///
340/// The type is arithmetic with no device in it, which is what keeps every rate
341/// in the engine testable on the CPU without rendering anything.
342#[derive(Clone, Copy, Default, Debug, PartialEq)]
343pub(crate) struct Phase(f32);
344
345impl Phase {
346 /// One frame's integration, at *this* frame's rate.
347 pub(crate) fn step(&mut self, rate: f32, dt: f32) {
348 self.0 += rate * dt;
349 }
350
351 /// The accumulated phase, in rate-scaled seconds. A scene with its own
352 /// constant scale applies it here, on the read.
353 pub(crate) fn get(self) -> f32 {
354 self.0
355 }
356}
357
358/// Declarative structural config a scene consumes once at preset load
359/// (ADR-0007): **not** expressions — the family / grammar / tiling the sampler
360/// or generator builds from. Delivered through the optional
361/// `Scene::configure` hook, off the hot path. This is
362/// the shared structural-config enum for every scene that has one: the line
363/// scenes' curve/L-system/star variants, plus the compute-particle attractor
364/// family (Plan 0016) — it lives here rather than in `lines/` so `lines` never has to name a
365/// `particles` type (Plan 0031 Phase 6).
366#[derive(Debug, Clone)]
367pub enum GeneratorConfig {
368 /// A parametric curve: which family to sample.
369 Curve {
370 /// The curve family (Maurer rose, ...).
371 family: lines::CurveFamily,
372 },
373 /// An L-system: a grammar the generator expands and turtle-walks at load,
374 /// caching one segment buffer per depth.
375 LSystem {
376 /// The starting string.
377 axiom: String,
378 /// Production rules `(predecessor, successor)`.
379 rules: Vec<(char, String)>,
380 /// Turn angle in degrees for `+`/`-`.
381 angle_deg: f32,
382 /// Iterations to precompute (`1..=max_depth`), clamped to
383 /// [`lines::MAX_LSYSTEM_DEPTH`] at load.
384 max_depth: u32,
385 /// Reserved seed for future stochastic rules; deterministic today.
386 seed: u64,
387 },
388 /// A Hankin star pattern: an `n`-fold star rosette built at load, with a few
389 /// contact-angle variants a beat can switch between — and, since ADR-0079,
390 /// an optional ring ornament drawn inside it.
391 Star {
392 /// Star order `n` (from the tiling), e.g. 6 or 12. **`0` means no
393 /// interlace at all** (`tiling = "none"`), which the loader accepts only
394 /// alongside a non-empty `rings` roster — the ornament drawn alone.
395 order: u32,
396 /// Contact angle in degrees; variants are precomputed around it.
397 contact_angle_deg: f32,
398 /// The `[generator] rings` roster (ADR-0079): concentric rings of
399 /// repeated motifs filling the interior the rosette leaves hollow. Empty
400 /// — the default, and what an absent `rings` key means — is exactly the
401 /// pre-Plan-0065 scene.
402 rings: Vec<lines::star::RingSpec>,
403 },
404 /// A GPU compute-particle attractor (Plan 0016): which strange-attractor map
405 /// the compute step iterates. Not a line scene — reuses this shared enum so
406 /// the family rides the existing `configure` hook (no new trait method).
407 Particles {
408 /// The attractor family (De Jong, Clifford, Thomas, Lorenz, or one of
409 /// the IFS figures).
410 family: particles::AttractorFamily,
411 /// The figure the bindable `morph` param travels **towards** (ADR-0075),
412 /// from `[particles] morph_to`. `None` — the default — pins the figure,
413 /// so `morph` is inert.
414 ///
415 /// IFS-only, and validated as such at load: `morph_to` on a map family
416 /// is a load error rather than a silent no-op, because the author asked
417 /// for something the engine cannot do.
418 morph_to: Option<particles::ifs::IfsFigure>,
419 /// How much of the tier's particle budget is actually drawn (ADR-0069)
420 /// — a count against the tier's anchor for a trace and a fraction of
421 /// the target-scaled budget for a cloud, the band between them resolved
422 /// by `active_particles` (ADR-0195) —
423 /// validated at load into
424 /// [`MIN_PARTICLE_DENSITY`](particles::MIN_PARTICLE_DENSITY)`..=1.0`.
425 /// Structural, not bindable: an eased integer count would re-decide the
426 /// picture every frame. `1.0` is the whole budget and the default.
427 density: f32,
428 /// The **tuple path** the bindable `morph` param walks along on a map
429 /// family (ADR-0093), as `(from, to)` roster indices from
430 /// `[particles] tuple_from` / `tuple_to`. `None` — the default — means
431 /// there is no path and `morph` is inert, exactly as `morph_to` does for
432 /// the IFS.
433 ///
434 /// **Both ends are structural on purpose.** The walk's framing is
435 /// measured across it at load, which is thousands of map iterations; a
436 /// path whose near end came from the per-frame `tuple` param would have
437 /// to re-measure inside the frame loop every time that param moved.
438 ///
439 /// Map-family-only, and validated as such at load — the IFS reaches its
440 /// own figure-to-figure travel through `morph_to` instead, and a tuple
441 /// path on an IFS is a load error rather than a silent no-op.
442 tuple_path: Option<(u32, u32)>,
443 },
444 /// The spectrum readout's `[spectrum]` table (Plan 0034 / ADR-0036): how many
445 /// elements the frequency axis is divided into, how they are laid out, and how
446 /// fast each one follows its band. All three are structure rather than
447 /// expression — they are fixed for as long as the preset is loaded — so they
448 /// ride the existing `configure` hook like every other declarative config.
449 Spectrum {
450 /// Element count, validated at load into
451 /// `2..=`[`SPECTRUM_BINS`](crate::dsp::SPECTRUM_BINS).
452 elements: usize,
453 /// Which figure the elements form.
454 layout: lines::SpectrumLayout,
455 /// Per-element temporal easing in **seconds**, applied on the injected
456 /// real `dt` — the same [`Easing`](crate::preset::Easing) the `[smoothing]`
457 /// table uses, deliberately reused rather than a second vocabulary
458 /// (ADR-0035).
459 easing: crate::preset::Easing,
460 },
461 /// The warp mesh's `[mesh]` table (Plan 0100 / ADR-0113): the grid, in
462 /// cells, that the per-vertex program is evaluated over.
463 ///
464 /// Structural for `[curve] family`'s reason — the vertex and index buffers
465 /// are built from it, so an eased grid would rebuild them mid-frame — and
466 /// **clamped to the tier at both consumers** rather than at load, since the
467 /// loader does not know which tier will render the preset
468 /// ([`warp_mesh::clamp_grid`]).
469 WarpMesh {
470 /// Requested cells, `(x, y)`, validated at load into
471 /// [`MIN_MESH`](warp_mesh::MIN_MESH)`..=`[`MAX_MESH`](warp_mesh::MAX_MESH).
472 mesh: (u32, u32),
473 /// The compiled EEL2 programs a **converted** preset carries, from a
474 /// `[milk]` table (Plan 0100 Phase 2 / ADR-0113). `None` — a
475 /// hand-authored `warp_mesh` preset — drives the mesh from the ordinary
476 /// `[params]` and `[per_vertex]` bindings instead, and executes no VM at
477 /// all.
478 ///
479 /// Boxed because it is much the largest thing this enum carries and every
480 /// other variant would pay for it by value.
481 milk: Option<Box<crate::milk::MilkBundle>>,
482 /// The salt the bundle's `rand()` draws under (ADR-0051).
483 ///
484 /// **The preset's `pinned_salt`, always** — its declared numeric seed, or
485 /// `0` where it declared `seed = "random"`. So a bundle is a pure
486 /// function of its inputs in the live app as well as in the harness,
487 /// which is stronger than ADR-0051 requires and costs nothing: per-run
488 /// variety is opt-in through `seed = "random"`, and no *converted* preset
489 /// declares one. A hand-written bundle that does gets the pinned
490 /// behaviour, and that is stated rather than discovered.
491 salt: u32,
492 },
493 /// The shape field's `[path]` table (ADR-0107): an authored silhouette, as a
494 /// closed contour parsed once at load from inline SVG path data.
495 ///
496 /// The one variant here carrying **geometry** rather than a selector or a
497 /// size. It rides `configure` for the reason every other structural table
498 /// does — it is fixed for as long as the preset is loaded — and a
499 /// `shape_field` preset declaring no table gets `None` and draws the closed
500 /// `marks` roster exactly as it did before paths existed.
501 Path {
502 /// The contour, normalized into `[-1, 1]` and resampled to the arity
503 /// `[path] samples` asked for. `None` — a `shape_field` preset that
504 /// declares no table — is what makes this config **always `Some`**: it
505 /// is handed over on every preset switch precisely so `configure` runs
506 /// and clears the outgoing preset's contour, the same reason the
507 /// attractor's and the spectrum's configs are unconditional.
508 shape: Option<crate::preset::path::PathShape>,
509 /// The silhouette the bindable `morph` param travels **towards**, from
510 /// `[path] morph_to`. `None` — the default — pins the figure, so `morph`
511 /// is inert, exactly as the attractor's own `morph_to` does.
512 ///
513 /// Already **aligned** to `shape` at load: same arity, same winding, and
514 /// the cyclic start that minimises total displacement (ADR-0107). The
515 /// render layer interpolates the two point lists and re-derives none of
516 /// that, which is what keeps an `O(N^2)` search off the frame.
517 morph_to: Option<crate::preset::path::PathShape>,
518 },
519 /// The analytic field's `[field]` table (ADR-0180 rule 1): which closed-form
520 /// family the pass draws. Always `Some` for that system, so `configure` runs
521 /// on every preset switch and the outgoing preset's family never survives
522 /// into the incoming one.
523 Field(analytic_field::FieldConfig),
524 /// The cellular system's `[cellular]` table (ADR-0180 rule 1): which
525 /// automaton runs, on how large a grid, and the salt its seeding draws from.
526 /// Always `Some` for that system, so `configure` runs on every preset switch
527 /// and the incoming preset starts from its own seed.
528 Cellular(cellular::CellularConfig),
529 /// The plexus system's `[plexus]` table (ADR-0257): the layout, the point
530 /// count and the seed. Always `Some` for that system, so `configure` runs on
531 /// every preset switch and the incoming preset starts from its own points.
532 Plexus(plexus::PlexusConfig),
533}
534
535impl GeneratorConfig {
536 /// How many elements a per-element binding should be evaluated for under this
537 /// config, or `0` when the system has no per-element surface (Plan 0034 Phase
538 /// 4). Read once at preset load to size the render layer's scratch.
539 ///
540 /// The count lives here rather than on `Scene` because it is **preset data**:
541 /// it comes off the `[spectrum]` table, not out of the scene's state, and the
542 /// renderer already holds the preset.
543 pub fn element_count(&self) -> usize {
544 match self {
545 GeneratorConfig::Spectrum { elements, .. } => *elements,
546 GeneratorConfig::Curve { .. }
547 | GeneratorConfig::LSystem { .. }
548 | GeneratorConfig::Star { .. }
549 | GeneratorConfig::Particles { .. }
550 | GeneratorConfig::WarpMesh { .. }
551 | GeneratorConfig::Path { .. }
552 | GeneratorConfig::Field(_)
553 | GeneratorConfig::Cellular(_)
554 | GeneratorConfig::Plexus(_) => 0,
555 }
556 }
557}
558
559/// Which construction hit the segment cap, for the surfaced message.
560///
561/// An enum rather than a `String` because one of the two producers is **per
562/// frame**: with a `String`, an audio-driven `mirror_order` sitting over the cap
563/// builds a fresh `format!("mirror x{order}")` on every single frame for as long
564/// as it stayed there — a heap allocation on the hot path (Plan 0031 Phase 4).
565/// The formatting now happens only in [`Display`](std::fmt::Display), i.e. only
566/// when something actually prints it.
567#[derive(Debug, Clone, Copy, PartialEq, Eq)]
568pub enum OverflowContext {
569 /// An N-fold geometry mirror replicated past the cap — per frame, from the
570 /// `mirror_order` param (Plan 0018 Phase 4).
571 Mirror(u32),
572 /// An L-system depth expanded past the cap — once, at preset load.
573 Depth(u32),
574 /// An escape-time `iterations` budget asked for past the tier's
575 /// [`field_iterations`](crate::render::TierConfig::field_iterations) —
576 /// per frame, since the budget is bindable. Carries what was asked; the
577 /// [`CapOverflow`] carries the cap it was clamped to.
578 ///
579 /// Not a truncation of geometry but a clamp of a structural parameter, and
580 /// the one case where the tier changes a preset's picture rather than its
581 /// density (ADR-0045) — which is exactly why it must not be silent.
582 Iterations(u32),
583 /// A cellular `[cellular] grid` asked for past the tier's
584 /// [`cellular_grid`](crate::render::TierConfig::cellular_grid) — once, at
585 /// preset load, since the grid is structural. Carries what was asked.
586 ///
587 /// A clamp of content, like [`Iterations`](Self::Iterations): a pattern is
588 /// a fixed number of cells, so a smaller grid draws every pattern larger.
589 Grid(u32),
590 /// A `larger_than_life` `radius` asked for past the tier's
591 /// [`cellular_radius`](crate::render::TierConfig::cellular_radius) — per
592 /// frame, since the radius is bindable. Carries what was asked.
593 Radius(u32),
594 /// A `[plexus] points` asked for past the tier's
595 /// [`plexus_points`](crate::render::TierConfig::plexus_points) — at preset
596 /// load, since the count is structural. Carries what was asked. A clamp of
597 /// content: fewer points is a sparser network.
598 Points(u32),
599 /// A plexus graph that linked more pairs than the tier's
600 /// [`plexus_edges`](crate::render::TierConfig::plexus_edges) — per frame,
601 /// since `link_distance` is bindable. Carries how many linked; the surplus
602 /// is dropped in index order.
603 Edges(u32),
604 /// An `aperture` asked for past the tier's
605 /// [`max_coc_px`](crate::render::TierConfig::max_coc_px) — per frame, since
606 /// `aperture` is bindable. Carries the aperture, in whole pixels: the blur
607 /// of the far field, which the drawn blur stops short of.
608 ///
609 /// **Not the widest blur the lens draws.** In front of the focal plane the
610 /// circle of confusion grows without bound as depth shrinks, so a close
611 /// camera saturates the cap on every tier with any aperture at all; that
612 /// saturation is the lens's ceiling, not an overflow, and is never
613 /// announced (ADR-0257).
614 Blur(u32),
615}
616
617impl std::fmt::Display for OverflowContext {
618 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
619 // These renderings are the user-visible text ADR-0007 requires stay
620 // informative; the shell prints them verbatim. Do not reword them
621 // without meaning to change what an operator sees.
622 match self {
623 OverflowContext::Mirror(order) => write!(f, "mirror x{order}"),
624 OverflowContext::Depth(depth) => write!(f, "depth {depth}"),
625 OverflowContext::Iterations(asked) => write!(f, "iterations {asked}"),
626 OverflowContext::Grid(asked) => write!(f, "grid {asked}"),
627 OverflowContext::Radius(asked) => write!(f, "radius {asked}"),
628 OverflowContext::Points(asked) => write!(f, "points {asked}"),
629 OverflowContext::Edges(linked) => write!(f, "{linked} links"),
630 OverflowContext::Blur(asked) => write!(f, "an aperture of {asked} px"),
631 }
632 }
633}
634
635/// Reported when building a line scene's geometry hit the segment cap and
636/// truncated. The cap must never be a silent cut (ADR-0007 Risks), so it travels
637/// to the frontend two ways: out of `Scene::configure` at preset load, and off
638/// `Scene::mirror_overflow` for the per-frame mirror. `None` is the normal case
639/// where geometry fit.
640#[derive(Debug, Clone, Copy, PartialEq, Eq)]
641pub struct CapOverflow {
642 /// How many draw segments were dropped at the cap.
643 pub dropped: usize,
644 /// Where the drop happened, for the surfaced message.
645 pub context: OverflowContext,
646 /// **The cap that bit**, carried rather than read from a constant: it is a
647 /// tier value now (Plan 0044), so the same preset overflows at 20 000
648 /// segments on the floor and not at all at 60 000 on rich. A message naming a
649 /// cap the run was not using would be worse than no message.
650 pub cap: usize,
651}
652
653impl std::fmt::Display for CapOverflow {
654 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
655 // Pinning the top tier is offered only where it would lift the cap: on
656 // Rich it is the cap that bit, and a remedy naming the tier the run is
657 // already on sends the operator nowhere.
658 let pin = if self.top_tier_lifts() {
659 ", or pin --tier rich"
660 } else {
661 ""
662 };
663 match self.context {
664 // A clamp, not a cut: nothing is dropped, the picture is drawn at
665 // the cap, and the operator is told which lever gets it back.
666 OverflowContext::Iterations(_) => write!(
667 f,
668 "{} is past this quality tier's cap of {}; drawn at {} instead, so the \
669 set's boundary resolves less detail than the preset asked for \
670 (ask for {} or fewer{pin})",
671 self.context, self.cap, self.cap, self.cap
672 ),
673 OverflowContext::Grid(_) => write!(
674 f,
675 "{} is past this quality tier's cap of {}; the automaton runs on a {}-cell \
676 grid instead, so every pattern draws larger than the preset asked \
677 (ask for {} or fewer{pin})",
678 self.context, self.cap, self.cap, self.cap
679 ),
680 OverflowContext::Radius(_) => write!(
681 f,
682 "{} is past this quality tier's cap of {}; the neighbourhood is drawn at {} \
683 instead, which runs a different rule than the preset asked \
684 (ask for {} or fewer{pin})",
685 self.context, self.cap, self.cap, self.cap
686 ),
687 OverflowContext::Points(_) => write!(
688 f,
689 "{} is past this quality tier's cap of {}; the network is drawn with {} \
690 points instead, so it is sparser than the preset asked \
691 (ask for {} or fewer{pin})",
692 self.context, self.cap, self.cap, self.cap
693 ),
694 OverflowContext::Edges(_) => write!(
695 f,
696 "{} exceeded this quality tier's {}-link cap (dropped {}); lower \
697 link_distance or the point count{pin}",
698 self.context, self.cap, self.dropped
699 ),
700 OverflowContext::Blur(_) => write!(
701 f,
702 "{} is past this quality tier's cap of {} px; drawn at {} px instead, so \
703 the background is sharper than the preset asked \
704 (ask for {} or fewer{pin})",
705 self.context, self.cap, self.cap, self.cap
706 ),
707 OverflowContext::Mirror(_) | OverflowContext::Depth(_) => write!(
708 f,
709 "geometry exceeded the {}-segment cap at {} (dropped {} segment(s)); \
710 reduce the structure or its depth",
711 self.cap, self.context, self.dropped
712 ),
713 }
714 }
715}
716
717impl CapOverflow {
718 /// Whether the top tier's cap for this context is above the one that bit,
719 /// so that pinning it would draw more of what the preset asked.
720 ///
721 /// Read off [`TierConfig::RICH`](crate::render::TierConfig::RICH) rather
722 /// than carried, so no producer has to know which tier it runs on: a cap
723 /// below Rich's can only have come from a lower tier, and a cap equal to it
724 /// is the top tier's own.
725 pub fn top_tier_lifts(&self) -> bool {
726 let rich = crate::render::TierConfig::RICH;
727 let top = match self.context {
728 OverflowContext::Mirror(_) | OverflowContext::Depth(_) => rich.max_segments,
729 OverflowContext::Iterations(_) => rich.field_iterations as usize,
730 OverflowContext::Grid(_) => rich.cellular_grid as usize,
731 OverflowContext::Radius(_) => rich.cellular_radius as usize,
732 OverflowContext::Points(_) => rich.plexus_points as usize,
733 OverflowContext::Edges(_) => rich.plexus_edges as usize,
734 OverflowContext::Blur(_) => rich.max_coc_px as usize,
735 };
736 self.cap < top
737 }
738
739 /// The clearing of this overflow, worded in the same terms its onset was.
740 ///
741 /// Three of the five contexts clamp a structural parameter rather than
742 /// cutting geometry, so one sentence cannot speak for all five: what came
743 /// back is an iteration budget, a grid or a neighbourhood, not geometry and
744 /// not segments. The shell holds the overflow that last bit and renders this
745 /// off it, which keeps every word of the pair in this file.
746 pub fn recovered(&self) -> Recovered<'_> {
747 Recovered(self)
748 }
749}
750
751/// The recovery sentence for a [`CapOverflow`], as a [`Display`](std::fmt::Display)
752/// adapter rather than a `String` — the same reason [`OverflowContext`] is an
753/// enum: formatting happens only where something prints.
754pub struct Recovered<'a>(&'a CapOverflow);
755
756impl std::fmt::Display for Recovered<'_> {
757 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
758 // User-visible text the shell prints verbatim, like the onset above.
759 // No wildcard arm: a sixth context must choose its own sentence rather
760 // than inherit one that does not describe it.
761 let cap = self.0.cap;
762 match self.0.context {
763 OverflowContext::Mirror(_) | OverflowContext::Depth(_) => {
764 write!(f, "geometry is back within this tier's {cap}-segment cap")
765 }
766 OverflowContext::Iterations(_) => write!(
767 f,
768 "the iteration budget is back within this tier's cap of {cap}"
769 ),
770 OverflowContext::Grid(_) => {
771 write!(f, "the grid is back within this tier's cap of {cap}")
772 }
773 OverflowContext::Radius(_) => {
774 write!(
775 f,
776 "the neighbourhood is back within this tier's cap of {cap}"
777 )
778 }
779 OverflowContext::Points(_) => {
780 write!(f, "the point count is back within this tier's cap of {cap}")
781 }
782 OverflowContext::Edges(_) => {
783 write!(f, "the links are back within this tier's cap of {cap}")
784 }
785 OverflowContext::Blur(_) => {
786 write!(f, "the aperture is back within this tier's cap of {cap} px")
787 }
788 }
789 }
790}
791
792/// A scene that binds a parameter **per mesh vertex** (Plan 0100 Phase 1,
793/// ADR-0113).
794///
795/// Reached through [`Scene::as_per_vertex_bound`], whose default is `None`
796/// (ADR-0238): a `[per_vertex]` table aimed at a scene that has no vertices is
797/// an absence the caller can act on, rather than a no-op body that returns and
798/// leaves no trace. The loader already warns that such a table is inert.
799pub(crate) trait PerVertexBound {
800 /// Apply one named parameter as a per-vertex series: `values` holds one
801 /// evaluation of the binding per mesh vertex, in row-major order from the
802 /// top-left, `(meshx + 1) * (meshy + 1)` long.
803 ///
804 /// The per-element channel one axis up, and deliberately just as narrow: it
805 /// carries `(name, &[f32])` in one direction and returns nothing.
806 ///
807 /// The slice borrows the renderer's scratch, sized at preset load from the
808 /// same [`clamp_grid`](warp_mesh::clamp_grid) the scene uses, so nothing
809 /// here allocates.
810 fn set_per_vertex(&mut self, name: &str, values: &[f32]);
811}
812
813/// A scene that binds a parameter **per element** (Plan 0034 Phase 4,
814/// ADR-0036).
815///
816/// Reached through [`Scene::as_series_bound`], whose default is `None`
817/// (ADR-0238). A scene without a per-element surface does not silently swallow
818/// the series: the caller takes element 0 through
819/// [`Scene::set_param`](Scene::set_param) instead, which is the `index = 0`
820/// reading the binding would have got outside a per-element evaluation.
821pub(crate) trait SeriesBound {
822 /// Apply one named parameter as a per-element series, in element order.
823 /// Reached only for a binding whose expression names `index`.
824 ///
825 /// **This is the whole channel, and it is deliberately this narrow.** It
826 /// carries `(name, &[f32])` in one direction and returns nothing. A scene
827 /// cannot ask the preset layer for anything, cannot see the expression, and
828 /// cannot learn which preset is loaded — so this is `set_param` with a
829 /// slice, not an inversion in which scenes read presets. The slice borrows
830 /// the renderer's scratch, which is sized at preset load, so nothing here
831 /// allocates.
832 ///
833 /// A name this scene has no per-element row for is the implementor's own
834 /// fallback, not the caller's: only the scene knows which of its parameters
835 /// vary across elements.
836 fn set_param_series(&mut self, name: &str, values: &[f32]);
837}
838
839/// A scene keeping a feedback field a probe can read **before** that scene's
840/// present pass.
841///
842/// **`#[cfg(test)]`, and that gate is the whole justification.** ADR-0002 keeps
843/// the scene seam thin and a real widening of it is ADR-worthy; this does not
844/// exist in a shipped build, so the extension seam is unchanged. It exists
845/// because Plan 0111 Phase 2's bisect requires its five seams to be read from
846/// **one run** — same signal, same hop, same size, same adapter — and a
847/// `Box<dyn Scene>` cannot otherwise be asked for the one quantity that sits
848/// upstream of everything the bisect covers. Measuring seam A on a
849/// separately-driven scene would satisfy the arithmetic and quietly break that
850/// requirement.
851#[cfg(test)]
852pub(crate) trait FeedbackSource {
853 /// The field, or `None` when this scene's GPU resources have not been built
854 /// yet — so the outer [`Option`] says "no such capability" and the inner one
855 /// says "not yet".
856 fn feedback_field(&self) -> Option<&wgpu::Texture>;
857}
858
859/// A scene with a feedback accumulation of **its own**, which takes a preset's
860/// `[feedback]` table (ADR-0048).
861///
862/// # One vocabulary, two buffers
863///
864/// **This is the routing contract, and it is worth stating plainly because it
865/// will surprise someone.** The `fb_*` params and that table are consumed by
866/// *two* sinks: the engine [`Trails`](crate::render::trails::Trails) stage,
867/// which transforms the accumulation every scene composites through, and the
868/// attractor scene's own internal trail field, which is what reaches here. A
869/// preset may have **both** active at once — an attractor with `trails` on —
870/// and then a single `fb_rotate` turns *both* accumulations, each about its own
871/// buffer. Neither transforms the other's, and neither transforms the present
872/// deposit: the transform applies to the past.
873///
874/// That is a deliberate design (ADR-0048's Alternative D was to give the engine
875/// stage the vocabulary and leave the attractor out), and the reason it is safe
876/// is that the two answer the same param names with the same arithmetic —
877/// [`feedback::Transform`](crate::render::feedback::Transform) and one shared
878/// WGSL snippet, not two implementations that must agree.
879pub(crate) trait FeedbackSink {
880 /// Take the active preset's `[feedback]` table. Invoked **once at preset
881 /// load, off the hot path**, like [`Scene::configure`] and
882 /// [`Scene::set_palette`]: a warp kind is a shader path, not a scalar.
883 fn set_feedback(&mut self, cfg: crate::render::feedback::FeedbackConfig);
884}
885
886/// One visual. `update` advances state from the analysis frame; `render` draws
887/// with the state it has.
888///
889/// Both built-in systems (fragment field, swarm) are preset-driven and
890/// implement the named-parameter surface — `set_time`, `reset_params`,
891/// `set_param` — that the preset layer evaluates into per frame (ADR-0002). The
892/// trait carries no-op defaults so a future non-parametric scene need not.
893///
894/// # Capabilities are declared, not defaulted
895///
896/// A surface only some scenes have is a narrow trait of its own, reached
897/// through an `as_*` accessor here whose default is `None` (ADR-0238). The
898/// difference from a defaulted method is what the seam can *say*: a caller
899/// asking a scene for a capability it lacks gets an absence it can act on
900/// rather than a body that returns having done nothing.
901pub(crate) trait Scene {
902 fn name(&self) -> &'static str;
903 fn update(&mut self, frame: &AnalysisFrame);
904 fn render(
905 &mut self,
906 queue: &wgpu::Queue,
907 encoder: &mut wgpu::CommandEncoder,
908 view: &wgpu::TextureView,
909 aspect: f32,
910 );
911
912 /// The pixel size of the target **this scene renders into this frame**
913 /// (ADR-0030). That is *not* always the surface: the composite chain routes
914 /// the scene into the first active post stage's input, which is a fixed
915 /// internal grid for the trails and kaleidoscope stages and the surface
916 /// otherwise, so the only correct value is the one the chain reports back
917 /// (`PostChain::begin`).
918 /// A scene that accumulates into an internal offscreen field sizes that field
919 /// from here, so it matches its target instead of upscaling from a fixed grid
920 /// or supersampling into a smaller offscreen; every other scene ignores it.
921 ///
922 /// **Called unconditionally every frame**, immediately before
923 /// [`render`](Self::render) — it is named for what it carries, not for an
924 /// event, and there is no resize event behind it. So ADR-0030 condition 2
925 /// binds every implementor: **compare against what you already built and do
926 /// nothing when unchanged**, and never allocate or build GPU resources here.
927 /// The attractor records the requested grid and lets the next `render` notice
928 /// the difference.
929 ///
930 /// Default no-op, in the same spirit as [`advance`](Self::advance): the
931 /// renderer already holds the size in `draw_frame`, and `Scene` is a `dyn`
932 /// trait, so this is the only channel that reaches a scene with it (Plan 0027
933 /// Phase 2, the third and first hot-path widening — ADR-0030).
934 fn set_target_size(&mut self, _width: u32, _height: u32) {}
935
936 /// How much of the renderer's grid scale (ADR-0245) the size handed to
937 /// [`set_target_size`](Self::set_target_size) does **not** already carry —
938 /// the fraction a scene sizing an internal field of its own still applies.
939 ///
940 /// The value depends on the frame's route, which is why it cannot ride
941 /// `configure`: into a post stage the target is that stage's grid, already
942 /// scaled, and this is `1.0`; straight into the destination the target is
943 /// the display's own size and this is the whole scale. A field sized from the
944 /// target alone would therefore be drawn at the square of the scale whenever
945 /// a stage is active.
946 ///
947 /// **Called unconditionally every frame**, immediately before
948 /// `set_target_size`, and under the same ADR-0030 obligations: record the
949 /// value and compare, never build or allocate here. Default no-op — only the
950 /// attractor has a field to size.
951 fn set_grid_scale(&mut self, _scale: crate::render::tier::GridScale) {}
952
953 /// How much of this scene's coverage the **backdrop** resolves against, for
954 /// the frame it is about to render (ADR-0085). `1.0` is coverage-as-occlusion
955 /// — what every frame did before `occlude` existed — and `0.0` is light that
956 /// adds without covering.
957 ///
958 /// **Called unconditionally every frame**, immediately before
959 /// [`render`](Self::render), in the same spirit as
960 /// [`set_target_size`](Self::set_target_size). The renderer hands a literal
961 /// `1.0` whenever a post stage is active, because then the scene draws into a
962 /// scratch offscreen with no backdrop under it and the chain's last stage owns
963 /// the seam instead — a scene must never apply this twice.
964 ///
965 /// Only a scene that **presents premultiplied over the backdrop** (ADR-0026 —
966 /// the reaction-diffusion, cellular, attractor and warp-mesh presents, and the
967 /// fragment-field, analytic-field, shape-field and shape-collage fullscreen
968 /// fields) has anything to do here. Such a scene writes `occlude` into its
969 /// alpha and its present pipeline blends `PREMULTIPLIED_ALPHA_BLENDING`, so
970 /// the backdrop resolves as `scene + bg * (1 - occlude)`; at the literal `1.0`
971 /// that blend is exactly a replace. The additive families draw through
972 /// [`gpu::ADDITIVE_LIGHT_SATURATING_COVERAGE`](crate::render::gpu::ADDITIVE_LIGHT_SATURATING_COVERAGE),
973 /// whose colour destination factor is `One`: with no stage active their light
974 /// already adds to the backdrop rather than replacing it, so there is no
975 /// occlusion at that seam for this to scale. Default no-op.
976 fn set_occlude(&mut self, _occlude: f32) {}
977
978 /// This scene as a [`FeedbackSource`], or `None` — which is every scene but
979 /// the warp mesh.
980 #[cfg(test)]
981 fn as_feedback_source(&self) -> Option<&dyn FeedbackSource> {
982 None
983 }
984
985 /// Advance simulation state by `dt` real seconds (Plan 0014 Phase 2). The
986 /// renderer injects the elapsed time each frame; a feedback scene steps its
987 /// fixed-timestep accumulator here and a CPU-integrated scene (the swarm)
988 /// scales its motion by `dt`, so both look identical over wall-clock time on
989 /// any refresh rate. Stateless, purely `time`-driven scenes ignore it.
990 ///
991 /// **Called after this frame's parameters are applied** — after
992 /// [`reset_params`](Self::reset_params), every binding, the live overrides
993 /// and the per-vertex table, and after [`set_time`](Self::set_time) —
994 /// and immediately before [`update`](Self::update). A value a scene reads
995 /// here is the one this frame bound, so a rate may be integrated in
996 /// `advance` or in `update` and both are correct (ADR-0198).
997 ///
998 /// **`dt` is finite and strictly positive.** The renderer guarantees it,
999 /// substituting [`FALLBACK_DT`] for a degenerate delta before this is called
1000 /// (ADR-0152), so an implementor may store it, integrate it, or divide by it
1001 /// without checking. **Do not re-check it per scene**: the guarantee is
1002 /// invisible from inside a scene, and this sentence is the whole of what
1003 /// stands between a shell's raw delta and every accumulator behind this
1004 /// trait — a second copy at one site makes it a rule enforced by a list of
1005 /// sites again, which is the failure the seam exists to end.
1006 fn advance(&mut self, _dt: f32) {}
1007
1008 /// Set the shared scene clock (seconds). The renderer owns the single clock
1009 /// so an expression's `time` and the system's animation never diverge.
1010 fn set_time(&mut self, _time: f32) {}
1011 /// Reset every named parameter to its default (called each frame before the
1012 /// active preset's bindings are applied, so unbound params don't leak).
1013 fn reset_params(&mut self) {}
1014 /// Apply one named parameter; unknown names are ignored.
1015 fn set_param(&mut self, _name: &str, _value: f32) {}
1016
1017 /// This scene as a [`SeriesBound`], or `None` — which is every scene but the
1018 /// spectrum readout.
1019 ///
1020 /// The caller degrades a series to element 0 on `None`, so a scene that
1021 /// never opts in behaves byte-for-byte as it did when that fallback was a
1022 /// default body here.
1023 fn as_series_bound(&mut self) -> Option<&mut dyn SeriesBound> {
1024 None
1025 }
1026
1027 /// This scene as a [`PerVertexBound`], or `None` — which is every scene but
1028 /// the warp mesh.
1029 ///
1030 /// Unlike [`as_series_bound`](Self::as_series_bound) there is no degrading
1031 /// on `None`: a per-vertex series varies over space and its first element is
1032 /// the top-left corner, which is not a sensible whole-scene reading of
1033 /// anything. The caller stops evaluating the table instead.
1034 fn as_per_vertex_bound(&mut self) -> Option<&mut dyn PerVertexBound> {
1035 None
1036 }
1037
1038 /// Consume a preset's declarative structural config (ADR-0007). Invoked
1039 /// **once at preset load, off the hot path** — a generator builds and caches
1040 /// its geometry here; a parametric scene records its family. Default no-op,
1041 /// so non-line scenes (fragment field, swarm) never implement it. The one
1042 /// optional widening of this trait ADR-0007 sanctions — keep it to this.
1043 ///
1044 /// Returns [`Some`](lines::CapOverflow) when building the geometry hit the
1045 /// segment cap and truncated, so the frontend can surface it — the cap is
1046 /// never a silent cut (ADR-0007 Risks). `None` means it fit (the norm).
1047 fn configure(&mut self, _cfg: &lines::GeneratorConfig) -> Option<lines::CapOverflow> {
1048 None
1049 }
1050
1051 /// Consume a preset's baked color [`Palette`] (ADR-0021). Invoked **once at
1052 /// preset load, off the hot path** — a shader-colored scene stores the baked
1053 /// LUT and uploads it to its 256×1 texture (or samples it on the CPU) on the
1054 /// next frame; a non-colored scene (the line scenes) ignores it. Default
1055 /// no-op. The second and last thin off-hot-path widening of this trait after
1056 /// ADR-0007's [`configure`](Scene::configure).
1057 fn set_palette(&mut self, _palette: &Palette) {}
1058
1059 /// This scene as a [`FeedbackSink`], or `None` — which is every scene but
1060 /// the attractor.
1061 fn as_feedback_sink(&mut self) -> Option<&mut dyn FeedbackSink> {
1062 None
1063 }
1064
1065 /// The per-frame cap overflow, if this frame hit one: the line scenes'
1066 /// geometry mirror (Plan 0018 Phase 4) when its N-fold replication exceeded
1067 /// the segment cap and truncated, the analytic field's escape-time budget
1068 /// when a bound `iterations` passed the tier's cap and was clamped, or the
1069 /// cellular system's `radius` likewise. Reuses the ADR-0007
1070 /// [`CapOverflow`](lines::CapOverflow) so the frontend surfaces any of them
1071 /// — a cap is never silent. Default `None`.
1072 fn mirror_overflow(&self) -> Option<&lines::CapOverflow> {
1073 None
1074 }
1075}
1076
1077/// The registry: every built-in scene, **keyed by the [`SystemKind`] it drives**,
1078/// in [`SystemKind::ALL`] order. All scenes are created up front so switching
1079/// mid-show is a lookup, never a hitch.
1080///
1081/// The keying is the point: the renderer addresses a scene by the kind its preset
1082/// names, so a scene cannot silently end up in the wrong slot. Nothing here is
1083/// positional — reordering [`SystemKind::ALL`] reorders construction and nothing
1084/// else.
1085pub(crate) fn create_all(
1086 device: &wgpu::Device,
1087 surface_format: wgpu::TextureFormat,
1088 tier: &crate::render::TierConfig,
1089 budget: crate::render::SampleBudget,
1090) -> Vec<(SystemKind, Box<dyn Scene>)> {
1091 // One shared line renderer for every line scene (ADR-0007: "one line
1092 // renderer"). A single instanced-quad pipeline + segment buffer, borrowed by
1093 // whichever line scene is active — only one draws per frame. (Two separate
1094 // line pipelines with byte-identical vertex layouts also mis-render on the
1095 // DX12 WARP software adapter the capture tests use; one renderer avoids it.)
1096 // `new_with_arcs`, not `new`: `star_pattern`'s circular motifs are one arc
1097 // instance each (ADR-0098), and the arc buffer holds
1098 // `max_segments` because the two kinds share **one** budget — everything
1099 // that passes `build_rings`'s cap check must reach the GPU, or a cap would
1100 // be silently cutting geometry, which ADR-0007 forbids.
1101 // `new_split_with_arcs`: any of the four line systems may ask for the
1102 // opacity-preserving seam through `stroke_blend` (ADR-0138), and the
1103 // pipelines are built here rather than when a preset first selects one —
1104 // building a GPU resource mid-run changes what a later pass resolves to on
1105 // the DX12 software adapter.
1106 let line_renderer = Rc::new(RefCell::new(lines::LineRenderer::new_split_with_arcs(
1107 device,
1108 surface_format,
1109 tier.max_segments,
1110 tier.max_segments,
1111 "lines",
1112 )));
1113 SystemKind::ALL
1114 .iter()
1115 .map(|&kind| {
1116 (
1117 kind,
1118 create(
1119 kind,
1120 device,
1121 surface_format,
1122 &mut || line_renderer.clone(),
1123 tier,
1124 budget,
1125 ),
1126 )
1127 })
1128 .collect()
1129}
1130
1131/// A scene constructed **for one preset's `[layer]`** (ADR-0090 point 4, Plan
1132/// 0076 Phase 2), never taken from the roster — which is what makes same-system
1133/// pairs legal and keeps two dissolving sides' layers from sharing anything.
1134/// The stateful families duplicate their GPU state by construction: their
1135/// constructors are already self-contained (a second reaction-diffusion
1136/// ping-pong field, a second particle buffer), so this is the same exhaustive
1137/// [`create`] the roster uses, differing only in where a line scene gets its
1138/// renderer.
1139///
1140/// # The `LineRenderer` answer, recorded (the Phase 2 discovery duty)
1141///
1142/// **A layer line scene gets its own `LineRenderer`; the shared one is not
1143/// shareable between two live line draws in one frame.** `LineRenderer::draw`
1144/// uploads its instance and uniform buffers through `Queue::write_buffer`, and
1145/// queued writes are applied before the submission's passes execute — so two
1146/// draws through one renderer in one frame would both rasterize the *second*
1147/// draw's segments under the second draw's uniforms. Making it shareable would
1148/// need a partitioned instance buffer and per-draw uniform slots — a redesign
1149/// of the idiom, not constructor plumbing — so duplication is the answer, at
1150/// one pipeline plus one `max_segments` instance buffer per layered line
1151/// preset.
1152///
1153/// The duplicate is built **only when the layer is a line system**: a
1154/// fragment/swarm/particle layer pays no line pipeline, and WARP's documented
1155/// sensitivity to coexisting identical pipeline layouts (ADR-0058 / Plan 0053)
1156/// is only ever exercised by a preset that actually declares a line layer.
1157pub(crate) fn create_layer_scene(
1158 kind: SystemKind,
1159 device: &wgpu::Device,
1160 surface_format: wgpu::TextureFormat,
1161 tier: &crate::render::TierConfig,
1162 budget: crate::render::SampleBudget,
1163) -> Box<dyn Scene> {
1164 create(
1165 kind,
1166 device,
1167 surface_format,
1168 &mut || {
1169 // Arcs too — a `[layer]` may be a `star_pattern`, and a layer that
1170 // could not draw them would render a mandala with its circles
1171 // missing rather than fail.
1172 Rc::new(RefCell::new(lines::LineRenderer::new_split_with_arcs(
1173 device,
1174 surface_format,
1175 tier.max_segments,
1176 tier.max_segments,
1177 "layer-lines",
1178 )))
1179 },
1180 tier,
1181 budget,
1182 )
1183}
1184
1185/// Whether two systems' scenes share mutable GPU state, so **one frame must not
1186/// render both**.
1187///
1188/// Two facts make this true, and only one of them is obvious. The roster is keyed
1189/// by kind, so the same kind is literally the same `Box<dyn Scene>`. Less
1190/// obviously, the three **line** scenes deliberately share one `LineRenderer` —
1191/// "borrowed by whichever line scene is active, only one draws per frame" (see
1192/// [`create_all`]) — so two *different* line kinds are just as unrenderable in one
1193/// frame as one kind twice.
1194///
1195/// Plan 0023's dual-live dissolve is the first caller: it composites two presets
1196/// in a single frame, which is exactly what this forbids. A pair that shares
1197/// resources falls back to the frozen snapshot.
1198///
1199/// **This is a statement about the roster's instances only.** A `[layer]`
1200/// scene ([`create_layer_scene`], Plan 0076 Phase 2) is constructed per preset
1201/// and shares nothing with the roster or with another preset's layer by
1202/// construction — so a preset's own main-plus-layer pair never consults this,
1203/// whatever the two systems are.
1204pub(crate) fn shares_resources(a: SystemKind, b: SystemKind) -> bool {
1205 a == b || (kind_info(a).shares_line_renderer && kind_info(b).shares_line_renderer)
1206}
1207
1208/// The render-side static facts about a [`SystemKind`] — what is true of the
1209/// *kind*, as opposed to what a built scene can be asked (ADR-0238 part 3).
1210///
1211/// These stay kind facts because they are asked where no scene exists:
1212/// [`shares_resources`] is consulted about a **roster preset** whose scene may
1213/// never have been constructed, which is why no accessor on `Scene` can replace
1214/// it. They stay in `render/` because each is a render implementation detail
1215/// and none belongs in the preset schema.
1216pub(crate) struct SceneKindInfo {
1217 /// Whether this kind draws through the roster's one shared
1218 /// [`LineRenderer`](lines::LineRenderer) — "borrowed by whichever line scene
1219 /// is active, only one draws per frame" (see [`create_all`]). So two
1220 /// *different* line kinds are as unrenderable in one frame as one kind
1221 /// twice.
1222 pub(crate) shares_line_renderer: bool,
1223}
1224
1225/// The static facts about `kind`, in **one exhaustive table** with no wildcard
1226/// arm, like [`create`] itself: a new system fails to compile here until
1227/// someone says which side of every fact it is on.
1228///
1229/// The next such fact is a **field on [`SceneKindInfo`]**, not a fifteenth
1230/// fourteen-arm match of its own — that accumulation is what ADR-0238 part 3
1231/// ended.
1232pub(crate) fn kind_info(kind: SystemKind) -> SceneKindInfo {
1233 let shares_line_renderer = match kind {
1234 SystemKind::ParametricCurve
1235 | SystemKind::LSystem
1236 | SystemKind::StarPattern
1237 | SystemKind::Spectrum => true,
1238 SystemKind::FragmentField
1239 | SystemKind::Swarm
1240 | SystemKind::ReactionDiffusion
1241 | SystemKind::Attractor
1242 | SystemKind::Emitter
1243 | SystemKind::ShapeField
1244 | SystemKind::WarpMesh
1245 | SystemKind::ShapeCollage
1246 | SystemKind::AnalyticField
1247 | SystemKind::Cellular
1248 | SystemKind::Plexus => false,
1249 };
1250 SceneKindInfo {
1251 shares_line_renderer,
1252 }
1253}
1254
1255/// Build the scene a [`SystemKind`] drives.
1256///
1257/// An **exhaustive** `match` with no wildcard arm — the same guard the golden
1258/// drift fixtures use: adding a variant fails to compile here until its scene is
1259/// constructed, so a new system cannot ship unbuilt or wired to the wrong scene.
1260///
1261/// `line_renderer` is a **source**, called only by the line arms: the roster
1262/// hands out clones of its one shared renderer, a layer construction builds a
1263/// fresh one on demand ([`create_layer_scene`]) — and a non-line kind builds
1264/// none at all.
1265fn create(
1266 kind: SystemKind,
1267 device: &wgpu::Device,
1268 surface_format: wgpu::TextureFormat,
1269 line_renderer: &mut dyn FnMut() -> Rc<RefCell<lines::LineRenderer>>,
1270 tier: &crate::render::TierConfig,
1271 budget: crate::render::SampleBudget,
1272) -> Box<dyn Scene> {
1273 match kind {
1274 SystemKind::FragmentField => Box::new(fragment_field::FragmentFieldScene::new(
1275 device,
1276 surface_format,
1277 )),
1278 SystemKind::Swarm => Box::new(swarm::SwarmScene::new(
1279 device,
1280 surface_format,
1281 tier.swarm_particles,
1282 )),
1283 SystemKind::ParametricCurve => Box::new(lines::ParametricCurveScene::new(
1284 line_renderer(),
1285 tier.max_segments,
1286 )),
1287 SystemKind::LSystem => {
1288 Box::new(lines::LSystemScene::new(line_renderer(), tier.max_segments))
1289 }
1290 SystemKind::StarPattern => Box::new(lines::StarPatternScene::new(
1291 line_renderer(),
1292 tier.max_segments,
1293 )),
1294 SystemKind::ReactionDiffusion => Box::new(reaction_diffusion::ReactionDiffusionScene::new(
1295 device,
1296 surface_format,
1297 )),
1298 SystemKind::Attractor => Box::new(create_attractor(device, surface_format, tier, budget)),
1299 SystemKind::Spectrum => Box::new(lines::SpectrumScene::new(
1300 line_renderer(),
1301 tier.max_segments,
1302 )),
1303 SystemKind::Emitter => Box::new(emitter::EmitterScene::new(
1304 device,
1305 surface_format,
1306 tier.emitter_objects,
1307 )),
1308 SystemKind::ShapeField => {
1309 Box::new(shape_field::ShapeFieldScene::new(device, surface_format))
1310 }
1311 SystemKind::WarpMesh => Box::new(warp_mesh::WarpMeshScene::new(
1312 device,
1313 surface_format,
1314 tier.mesh_grid,
1315 tier.max_segments,
1316 )),
1317 SystemKind::ShapeCollage => Box::new(shape_collage::ShapeCollageScene::new(
1318 device,
1319 surface_format,
1320 tier.collage_elements,
1321 )),
1322 SystemKind::AnalyticField => Box::new(analytic_field::AnalyticFieldScene::new(
1323 device,
1324 surface_format,
1325 tier.field_iterations,
1326 )),
1327 SystemKind::Cellular => Box::new(cellular::CellularScene::new(
1328 device,
1329 surface_format,
1330 tier.cellular_radius,
1331 tier.cellular_grid,
1332 )),
1333 SystemKind::Plexus => Box::new(plexus::PlexusScene::new(
1334 device,
1335 surface_format,
1336 tier.plexus_points as usize,
1337 tier.plexus_edges as usize,
1338 tier.max_coc_px as f32,
1339 )),
1340 }
1341}
1342
1343/// The attractor scene [`create`] builds, as its concrete type.
1344///
1345/// **The ceiling choice lives here and nowhere else** (ADR-0140): a window gets
1346/// the tier's live cap, a headless render its offline one. Named rather than
1347/// inlined into the factory arm so a test can build exactly what the factory
1348/// builds and read the resolved budget off it, without restating the choice it
1349/// is asserting about.
1350fn create_attractor(
1351 device: &wgpu::Device,
1352 surface_format: wgpu::TextureFormat,
1353 tier: &crate::render::TierConfig,
1354 budget: crate::render::SampleBudget,
1355) -> particles::AttractorScene {
1356 particles::AttractorScene::new(
1357 device,
1358 surface_format,
1359 tier.attractor_particles,
1360 match budget {
1361 crate::render::SampleBudget::Live => tier.attractor_particles_live_ceiling,
1362 crate::render::SampleBudget::Offline => tier.attractor_particles_offline_ceiling,
1363 },
1364 tier.attractor_trail_cap,
1365 tier.max_coc_px as f32,
1366 )
1367}
1368
1369/// Tiny deterministic RNG (splitmix64) so visual randomness is explicitly
1370/// seeded (NFR 6) without pulling a rand crate.
1371pub(crate) struct SeededRng(u64);
1372
1373impl SeededRng {
1374 pub(crate) fn new(seed: u64) -> Self {
1375 Self(seed)
1376 }
1377
1378 fn next_u64(&mut self) -> u64 {
1379 self.0 = self.0.wrapping_add(0x9E37_79B9_7F4A_7C15);
1380 let mut z = self.0;
1381 z = (z ^ (z >> 30)).wrapping_mul(0xBF58_476D_1CE4_E5B9);
1382 z = (z ^ (z >> 27)).wrapping_mul(0x94D0_49BB_1331_11EB);
1383 z ^ (z >> 31)
1384 }
1385
1386 /// Uniform in [0, 1).
1387 pub(crate) fn next_f32(&mut self) -> f32 {
1388 (self.next_u64() >> 40) as f32 / (1u64 << 24) as f32
1389 }
1390
1391 /// Uniform in [lo, hi).
1392 pub(crate) fn range(&mut self, lo: f32, hi: f32) -> f32 {
1393 lo + (hi - lo) * self.next_f32()
1394 }
1395}
1396
1397#[cfg(test)]
1398mod tests {
1399 //! The scene-keying contract (Plan 0030 Phase 3), and the parameter-kind
1400 //! quantizer declared beside it. Test asserts panic freely; this is not the
1401 //! render path.
1402 #![allow(clippy::panic, clippy::expect_used)]
1403
1404 use super::{CapOverflow, OverflowContext, ParamKind, Scene, create_all, create_attractor};
1405 use crate::preset::SystemKind;
1406 use crate::render::context::{RenderContext, RenderError};
1407
1408 /// **`pin --tier rich` is offered only where Rich would lift the cap.** At
1409 /// a cap below the top tier's the remedy names it; at the top tier's own
1410 /// cap it does not, in every context that offers it.
1411 #[test]
1412 fn the_tier_remedy_is_offered_only_below_the_top_tier() {
1413 let rich = crate::render::TierConfig::RICH;
1414 for (context, top) in [
1415 (
1416 OverflowContext::Iterations(5000),
1417 rich.field_iterations as usize,
1418 ),
1419 (OverflowContext::Grid(4096), rich.cellular_grid as usize),
1420 (OverflowContext::Radius(40), rich.cellular_radius as usize),
1421 (OverflowContext::Points(9000), rich.plexus_points as usize),
1422 (OverflowContext::Edges(90_000), rich.plexus_edges as usize),
1423 (OverflowContext::Blur(30), rich.max_coc_px as usize),
1424 ] {
1425 let below = CapOverflow {
1426 dropped: 1,
1427 context,
1428 cap: top / 2,
1429 };
1430 assert!(
1431 below.to_string().contains("pin --tier rich"),
1432 "{context} under a lower tier's cap: {below}"
1433 );
1434 let at_top = CapOverflow {
1435 dropped: 1,
1436 context,
1437 cap: top,
1438 };
1439 assert!(
1440 !at_top.to_string().contains("--tier"),
1441 "{context} at the top tier's cap: {at_top}"
1442 );
1443 }
1444 }
1445
1446 /// **Each context's recovery is worded in that context's own terms.** Three
1447 /// of the five clamp a structural parameter rather than cutting geometry, so
1448 /// a line telling an operator that "geometry is back within the segment cap"
1449 /// after an iteration, grid or radius clamp names something that never
1450 /// overflowed.
1451 #[test]
1452 fn each_overflow_context_recovers_in_its_own_words() {
1453 for (context, word) in [
1454 (OverflowContext::Mirror(8), "geometry"),
1455 (OverflowContext::Depth(9), "geometry"),
1456 (OverflowContext::Iterations(600), "iteration budget"),
1457 (OverflowContext::Grid(512), "grid"),
1458 (OverflowContext::Radius(7), "neighbourhood"),
1459 (OverflowContext::Points(900), "point count"),
1460 (OverflowContext::Edges(30_000), "links"),
1461 (OverflowContext::Blur(30), "aperture"),
1462 ] {
1463 let overflow = CapOverflow {
1464 dropped: 0,
1465 context,
1466 cap: 20_000,
1467 };
1468 let text = overflow.recovered().to_string();
1469 assert!(
1470 text.contains(word),
1471 "{context}'s recovery does not name {word}: {text}"
1472 );
1473 assert!(
1474 text.contains("20000"),
1475 "{context}'s recovery does not carry the cap that bit: {text}"
1476 );
1477 let structural = matches!(
1478 context,
1479 OverflowContext::Iterations(_)
1480 | OverflowContext::Grid(_)
1481 | OverflowContext::Radius(_)
1482 | OverflowContext::Points(_)
1483 | OverflowContext::Edges(_)
1484 | OverflowContext::Blur(_)
1485 );
1486 if structural {
1487 assert!(
1488 !text.contains("segment") && !text.contains("geometry"),
1489 "{context} is a clamp of a structural parameter, and its recovery \
1490 still speaks of geometry: {text}"
1491 );
1492 }
1493 }
1494 }
1495
1496 /// `Structural` rounds and `Modal` does not — the whole of what a kind
1497 /// changes about a value.
1498 ///
1499 /// **Nothing else can see this work or fail.** The engine's `Structural`
1500 /// roster is confined to parameters whose scene already clamps and rounds
1501 /// the value itself, so `quantize` composes to the identity everywhere it
1502 /// currently runs and no rendered assertion distinguishes it from being
1503 /// absent (design-backlog 0197). This covers it directly instead.
1504 #[test]
1505 fn a_structural_kind_rounds_and_a_modal_one_hands_the_value_through() {
1506 // Rust rounds a half AWAY FROM ZERO, and a scene indexing a closed
1507 // roster with the result depends on which way 6.5 goes, so the
1508 // direction is pinned here rather than assumed.
1509 for (value, rounded) in [
1510 (6.4_f32, 6.0_f32),
1511 (6.5, 7.0),
1512 (6.6, 7.0),
1513 (-6.4, -6.0),
1514 (-6.5, -7.0),
1515 (3.0, 3.0),
1516 (0.0, 0.0),
1517 ] {
1518 assert_eq!(
1519 ParamKind::Structural.quantize(value),
1520 rounded,
1521 "Structural must round {value}"
1522 );
1523 assert_eq!(
1524 ParamKind::Modal.quantize(value),
1525 value,
1526 "Modal must hand {value} through untouched"
1527 );
1528 }
1529
1530 // The property behind the table, over a sweep that lands on no integer
1531 // by construction: a Structural value equals its own round, and
1532 // quantizing it again moves nothing.
1533 for i in -400..400 {
1534 let value = i as f32 * 0.0137;
1535 let q = ParamKind::Structural.quantize(value);
1536 assert_eq!(q, q.round(), "Structural produced the non-integral {q}");
1537 assert_eq!(
1538 ParamKind::Structural.quantize(q),
1539 q,
1540 "quantizing an already-quantized {q} moved it"
1541 );
1542 assert_eq!(
1543 ParamKind::Modal.quantize(value),
1544 value,
1545 "Modal moved {value}"
1546 );
1547 }
1548
1549 // A non-finite value survives, which is what the type's own doc claims:
1550 // `f32::round` has no special case for one and neither does this.
1551 assert!(ParamKind::Structural.quantize(f32::NAN).is_nan());
1552 assert_eq!(ParamKind::Structural.quantize(f32::INFINITY), f32::INFINITY);
1553 assert_eq!(
1554 ParamKind::Structural.quantize(f32::NEG_INFINITY),
1555 f32::NEG_INFINITY
1556 );
1557 }
1558
1559 /// The scene each system is *supposed* to drive, written independently of the
1560 /// factory so the two can disagree. This is the mapping the old magic-index
1561 /// `system_slot` lookup could never assert: it named a position, and nothing
1562 /// checked the position held the right scene.
1563 fn expected_scene_name(system: SystemKind) -> &'static str {
1564 match system {
1565 SystemKind::FragmentField => "fragment field",
1566 SystemKind::ShapeField => "shape field",
1567 SystemKind::Swarm => "swarm",
1568 SystemKind::ParametricCurve => "parametric curve",
1569 SystemKind::LSystem => "l-system",
1570 SystemKind::StarPattern => "star pattern",
1571 SystemKind::ReactionDiffusion => "reaction diffusion",
1572 SystemKind::Attractor => "attractor",
1573 SystemKind::Spectrum => "spectrum",
1574 SystemKind::Emitter => "emitter",
1575 SystemKind::WarpMesh => "warp mesh",
1576 SystemKind::ShapeCollage => "shape collage",
1577 SystemKind::AnalyticField => "analytic field",
1578 SystemKind::Cellular => "cellular",
1579 SystemKind::Plexus => "plexus",
1580 }
1581 }
1582
1583 /// **The factory is what chooses the ceiling**, and the two choices resolve
1584 /// different budgets at the same target (ADR-0140).
1585 ///
1586 /// Read off the scene rather than recomputed: this is the wiring under test,
1587 /// so a recomputation of the law here would pass with the factory handing
1588 /// both modes the same number. [`create_attractor`] is the factory's own
1589 /// ceiling choice, reached for its concrete type because the budget is a
1590 /// property of this scene and not of the `Scene` seam (ADR-0238).
1591 /// 1920x1080 is the size the whole plan is about — nine times the
1592 /// reference — and it is past the live ceiling and under the offline one,
1593 /// which is what makes the two answers differ.
1594 ///
1595 /// No frame is rendered: `set_target_size` is CPU arithmetic, so this costs
1596 /// one scene build on WARP and nothing else.
1597 #[test]
1598 fn the_render_path_resolves_a_larger_budget_than_a_window_does() {
1599 use crate::render::{SampleBudget, TierConfig};
1600
1601 let ctx = match RenderContext::new_headless(64, 64, true) {
1602 Ok(ctx) => ctx,
1603 Err(RenderError::RequestAdapter(_)) => {
1604 eprintln!("skipped: no GPU adapter on this runner (ADR-0016)");
1605 return;
1606 }
1607 Err(e) => panic!("headless context build failed: {e}"),
1608 };
1609
1610 let resolved = |budget: SampleBudget, tier: &TierConfig, w: u32, h: u32| -> Option<u32> {
1611 let mut scene = create_attractor(&ctx.device, ctx.surface_format(), tier, budget);
1612 // Before a target size reaches it, a scene has no resolved budget to
1613 // report - which is the distinction the `None` carries.
1614 assert_eq!(scene.sample_budget(), None);
1615 scene.set_target_size(w, h);
1616 scene.sample_budget()
1617 };
1618
1619 let rich = TierConfig::RICH;
1620 assert_eq!(
1621 resolved(SampleBudget::Offline, &rich, 1920, 1080),
1622 Some(1_350_000),
1623 "a render at 1080p must reach the law's own value, not the live cap"
1624 );
1625 assert_eq!(
1626 resolved(SampleBudget::Live, &rich, 1920, 1080),
1627 Some(rich.attractor_particles_live_ceiling),
1628 "a window at 1080p is held at the live ceiling"
1629 );
1630
1631 // Deposits per output pixel, stated as sample arithmetic (an image
1632 // statistic would be the wrong instrument - the ones this repo has are
1633 // themselves resolution-bound). The render reaches the 640x360
1634 // reference density exactly; today's flat count delivers a ninth of it.
1635 const PX: u32 = 1920 * 1080;
1636 let render = 1_350_000_f64 / f64::from(PX);
1637 let reference =
1638 f64::from(rich.attractor_particles) / f64::from(crate::render::REFERENCE_PX);
1639 assert!(
1640 (render - reference).abs() < 1e-9,
1641 "a 1080p render deposits {render} samples per pixel against the reference {reference}"
1642 );
1643 assert_eq!(1_350_000 / rich.attractor_particles, 9);
1644
1645 // And the small captures are untouched at BOTH ceilings, which is the
1646 // half that keeps every baseline where it is.
1647 for tier in [TierConfig::FLOOR, TierConfig::RICH] {
1648 for budget in [SampleBudget::Live, SampleBudget::Offline] {
1649 assert_eq!(
1650 resolved(budget, &tier, 128, 128),
1651 Some(tier.attractor_particles),
1652 "{:?}/{budget:?} moved the golden suite's count",
1653 tier.tier
1654 );
1655 }
1656 }
1657 }
1658
1659 /// **At a full grid scale the budget is today's at every size this suite
1660 /// names** (ADR-0245), and a fraction is what moves it.
1661 ///
1662 /// "Before" is the density law against the target alone —
1663 /// [`attractor_budget`](crate::render::attractor_budget) of `w * h`, the
1664 /// expression the scene evaluated before the grid scale existed — and
1665 /// "after" is read off the factory's scene with the scale handed to it the
1666 /// way the composite hands it. Every tier and both ceilings, because the
1667 /// floor's live ceiling is its anchor and would pass whatever the law did:
1668 /// the rich rows are where a count could move.
1669 ///
1670 /// The half-scale row is the non-vacuity: a 1080p field at 0.5 holds a
1671 /// quarter of the target's texels, so the rich live budget falls from its
1672 /// ceiling to 2.25x the anchor.
1673 #[test]
1674 fn a_full_grid_scale_resolves_todays_budget_at_every_suite_size() {
1675 use crate::render::tier::GridScale;
1676 use crate::render::{SampleBudget, TierConfig, attractor_budget};
1677
1678 let ctx = match RenderContext::new_headless(64, 64, true) {
1679 Ok(ctx) => ctx,
1680 Err(RenderError::RequestAdapter(_)) => {
1681 eprintln!("skipped: no GPU adapter on this runner (ADR-0016)");
1682 return;
1683 }
1684 Err(e) => panic!("headless context build failed: {e}"),
1685 };
1686
1687 for tier in [TierConfig::FLOOR, TierConfig::RICH] {
1688 for budget in [SampleBudget::Live, SampleBudget::Offline] {
1689 let ceiling = match budget {
1690 SampleBudget::Live => tier.attractor_particles_live_ceiling,
1691 SampleBudget::Offline => tier.attractor_particles_offline_ceiling,
1692 };
1693 for (w, h) in [(128, 128), (640, 360), (1920, 1080)] {
1694 let before = attractor_budget(tier.attractor_particles, w * h, ceiling);
1695 let mut scene =
1696 create_attractor(&ctx.device, ctx.surface_format(), &tier, budget);
1697 scene.set_grid_scale(GridScale::FULL);
1698 scene.set_target_size(w, h);
1699 assert_eq!(
1700 scene.sample_budget(),
1701 Some(before),
1702 "{:?}/{budget:?} at {w}x{h}: the full-scale budget moved",
1703 tier.tier
1704 );
1705 }
1706 }
1707 }
1708
1709 let rich = TierConfig::RICH;
1710 let mut scene =
1711 create_attractor(&ctx.device, ctx.surface_format(), &rich, SampleBudget::Live);
1712 scene.set_grid_scale(GridScale::new(0.5).expect("in range"));
1713 scene.set_target_size(1920, 1080);
1714 assert_eq!(
1715 scene.sample_budget(),
1716 Some(337_500),
1717 "a half-scale 1080p field holds 518 400 texels, 2.25x the reference"
1718 );
1719 }
1720
1721 /// **The built scene draws a trace's anchor count out of an unchanged
1722 /// budget** (ADR-0195), at 1920x1080 under both ceilings.
1723 ///
1724 /// The sibling above pins the budget; this pins what is drawn out of it, and
1725 /// the pair is the statement that only the drawn count moved. Read off the
1726 /// scene for that test's reason — a recomputation here would pass with
1727 /// `configure` never reaching `active_count` at all, which is one of the two
1728 /// call sites that has to pass the anchor.
1729 ///
1730 /// No frame is rendered: both accessors are CPU arithmetic.
1731 #[test]
1732 fn a_trace_preset_draws_its_anchor_count_at_1080p() {
1733 use crate::render::{SampleBudget, TierConfig};
1734
1735 let ctx = match RenderContext::new_headless(64, 64, true) {
1736 Ok(ctx) => ctx,
1737 Err(RenderError::RequestAdapter(_)) => {
1738 eprintln!("skipped: no GPU adapter on this runner (ADR-0016)");
1739 return;
1740 }
1741 Err(e) => panic!("headless context build failed: {e}"),
1742 };
1743
1744 // `density = 0.02` is the shipped trace value — `attractor_lorenzknot`
1745 // and `fragment_sumi`'s layer among them.
1746 const DENSITY: f32 = 0.02;
1747 let rich = TierConfig::RICH;
1748
1749 for (budget, expected) in [
1750 (SampleBudget::Live, rich.attractor_particles_live_ceiling),
1751 (SampleBudget::Offline, 1_350_000),
1752 ] {
1753 let mut scene = create_attractor(&ctx.device, ctx.surface_format(), &rich, budget);
1754 // The preset switch order: `configure` carries the density, and the
1755 // first frame's `set_target_size` carries the target.
1756 scene.configure(&super::lines::GeneratorConfig::Particles {
1757 family: crate::render::scenes::particles::AttractorFamily::Thomas,
1758 density: DENSITY,
1759 morph_to: None,
1760 tuple_path: None,
1761 });
1762 scene.set_target_size(1920, 1080);
1763
1764 assert_eq!(
1765 scene.active_sample_count(),
1766 Some(3_000),
1767 "{budget:?} at 1080p Rich draws the anchor's count for a trace"
1768 );
1769 assert_eq!(
1770 scene.sample_budget(),
1771 Some(expected),
1772 "{budget:?} at 1080p Rich resolves an unchanged budget"
1773 );
1774 // Non-vacuity: the budget is what the old expression took the
1775 // fraction of, and it is nine times (or four times) the count above.
1776 assert_ne!((expected as f32 * DENSITY).round() as u32, 3_000);
1777 }
1778 }
1779
1780 /// Every `SystemKind::ALL` entry builds the scene that kind is supposed to
1781 /// drive, and the roster covers exactly the roster — so transposing two
1782 /// factory arms — which silently points every preset of one system at
1783 /// another's scene — fails here.
1784 ///
1785 /// Needs a GPU adapter to build the scenes, so it skips on runners without
1786 /// one (ADR-0016).
1787 #[test]
1788 fn every_kind_builds_the_scene_it_drives() {
1789 let ctx = match RenderContext::new_headless(64, 64, true) {
1790 Ok(ctx) => ctx,
1791 Err(RenderError::RequestAdapter(_)) => {
1792 eprintln!("skipped: no GPU adapter on this runner (ADR-0016)");
1793 return;
1794 }
1795 Err(e) => panic!("headless context build failed: {e}"),
1796 };
1797
1798 let scenes = create_all(
1799 &ctx.device,
1800 ctx.surface_format(),
1801 &crate::render::TierConfig::FLOOR,
1802 crate::render::SampleBudget::Live,
1803 );
1804
1805 let kinds: Vec<SystemKind> = scenes.iter().map(|(kind, _)| *kind).collect();
1806 assert_eq!(
1807 kinds,
1808 SystemKind::ALL.to_vec(),
1809 "the roster is exactly SystemKind::ALL, in its order"
1810 );
1811
1812 for (kind, scene) in &scenes {
1813 assert_eq!(
1814 scene.name(),
1815 expected_scene_name(*kind),
1816 "system {} must drive its own scene",
1817 kind.as_str()
1818 );
1819 }
1820 }
1821
1822 /// The **freeze veto** a dual-live dissolve rests on (Plan 0023 Phase 4): a
1823 /// pair of systems that would have to render one mutable object twice in a
1824 /// frame must report shared resources, so the governor never upgrades it.
1825 ///
1826 /// GPU-free — this is the mapping, not the rendering. It closes the half the
1827 /// governor's own test has to assume: `dual_live_eligible` is asserted to
1828 /// refuse a shared pair, and here is what makes a pair shared.
1829 #[test]
1830 fn a_pair_that_cannot_render_twice_reports_shared_resources() {
1831 // Same kind is the same `Box<dyn Scene>` — the same-scene case that must
1832 // always freeze, whatever the frame budget says.
1833 for kind in SystemKind::ALL {
1834 assert!(
1835 super::shares_resources(kind, kind),
1836 "{} against itself is one scene object",
1837 kind.as_str()
1838 );
1839 }
1840
1841 // Two *different* line systems are just as unrenderable together: they
1842 // borrow one shared `LineRenderer` (see `create_all`).
1843 let lines = [
1844 SystemKind::ParametricCurve,
1845 SystemKind::LSystem,
1846 SystemKind::StarPattern,
1847 SystemKind::Spectrum,
1848 ];
1849 for a in lines {
1850 for b in lines {
1851 assert!(
1852 super::shares_resources(a, b),
1853 "{} and {} share the line renderer",
1854 a.as_str(),
1855 b.as_str()
1856 );
1857 }
1858 }
1859
1860 // Everything else holds independent state, so a dissolve between them may
1861 // run both sides live.
1862 let independent = [
1863 SystemKind::FragmentField,
1864 SystemKind::Swarm,
1865 SystemKind::ReactionDiffusion,
1866 SystemKind::Attractor,
1867 SystemKind::Emitter,
1868 SystemKind::ShapeField,
1869 SystemKind::WarpMesh,
1870 SystemKind::ShapeCollage,
1871 SystemKind::AnalyticField,
1872 SystemKind::Cellular,
1873 SystemKind::Plexus,
1874 ];
1875 for (i, a) in independent.iter().enumerate() {
1876 for b in independent.iter().skip(i + 1).chain(lines.iter()) {
1877 assert!(
1878 !super::shares_resources(*a, *b),
1879 "{} and {} hold independent GPU state",
1880 a.as_str(),
1881 b.as_str()
1882 );
1883 }
1884 }
1885 }
1886}