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(crate) mod common;
19pub mod emitter;
20pub mod fragment_field;
21pub mod lines;
22/// The shared mark-silhouette vocabulary the two particle scenes draw through
23/// (ADR-0084). Crate-internal: it is arithmetic and a roster, not a scene.
24pub(crate) mod marks;
25pub mod particles;
26pub mod reaction_diffusion;
27pub mod shape_collage;
28pub mod shape_field;
29pub mod swarm;
30pub mod warp_mesh;
31
32use std::cell::RefCell;
33use std::rc::Rc;
34
35use crate::dsp::AnalysisFrame;
36use crate::preset::SystemKind;
37use crate::render::palette::Palette;
38
39/// The `dt` (seconds) the C ABI's legacy `rlx_render` and the headless capture
40/// primitives inject when a caller has no real elapsed time to supply — the
41/// former fixed scene step, now demoted to a fallback (Plan 0014 Phase 2, ADR-0012).
42/// The live frontends measure and inject real `dt` instead, so animation is
43/// frame-rate-independent; capture uses this fixed value so a render is a pure
44/// function of its inputs.
45pub(crate) const FALLBACK_DT: f32 = 1.0 / 60.0;
46
47/// What a parameter is **for** (ADR-0180 rule 2): whether its value carries an
48/// integer meaning, which decides two things and nothing else — whether the
49/// engine quantizes it before the scene sees it, and which of the generated
50/// reference's two groups it prints under.
51///
52/// Orthogonal to `[hold]`: a hold reaches any bindable parameter whatever its
53/// kind, and a kind quantizes whether or not the binding is held.
54#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
55pub enum ParamKind {
56 /// Continuous — every value in the range means something, and the scene
57 /// reads the fraction. The default, and what every parameter was before
58 /// kinds existed.
59 #[default]
60 Modal,
61 /// Integer meaning: a mode number, a rule index, a count, a family. The
62 /// engine rounds the post-smoothing value **once**, CPU-side, before
63 /// `set_param`, so the scene is never handed 6.4 petals.
64 Structural,
65}
66
67impl ParamKind {
68 /// The name the generated reference and the exported schema print. Two
69 /// readers, one spelling.
70 pub fn as_str(self) -> &'static str {
71 match self {
72 ParamKind::Modal => "modal",
73 ParamKind::Structural => "structural",
74 }
75 }
76
77 /// `value` as the scene should receive it: rounded for a
78 /// [`Structural`](Self::Structural) parameter, untouched for a
79 /// [`Modal`](Self::Modal) one.
80 ///
81 /// **The last step before `set_param`**, after the hold has chosen which
82 /// frame's value stands and the smoother has eased toward it — which is
83 /// why a structural parameter that is *also* smoothed steps through the
84 /// intervening integers rather than landing fractionally. An author who
85 /// wants a clean jump leaves it out of `[smoothing]`.
86 ///
87 /// A non-finite value is passed through rather than rounded: `f32::round`
88 /// leaves `NaN` alone anyway, and the scenes already guard their own
89 /// inputs.
90 pub fn quantize(self, value: f32) -> f32 {
91 match self {
92 ParamKind::Modal => value,
93 ParamKind::Structural => value.round(),
94 }
95 }
96}
97
98/// What one named parameter is, as the engine declares it (ADR-0170).
99///
100/// A scene and an engine stage each declare their parameters as a `&[ParamSpec]`
101/// rather than as a bare `&[&str]`. Three things read the same declaration: the
102/// load-time check that a preset binding names something real, the constant a
103/// scene applies at reset, and the generated reference table in
104/// `presets/README.md`. Before this they were three copies, and only the names
105/// were held together.
106///
107/// **The doc line is the definition; the roster's essay is the discussion.**
108/// One sentence, saying what the parameter does — not restating its name.
109#[derive(Debug, Clone, Copy, PartialEq)]
110pub struct ParamSpec {
111 /// The name a preset binds, exactly as it is spelled in a `.toml`.
112 pub name: &'static str,
113 /// The value `reset_params` applies, and the one the reference prints.
114 ///
115 /// Read back out with [`default_of`], which resolves at compile time — so
116 /// the scene's `DEFAULT_*` constants and this field are one number, not two.
117 pub default: f32,
118 /// The range that **reads**, for the table.
119 ///
120 /// Not a clamp and not a validation bound: it is what an author can expect
121 /// to see a difference across. `None` where the parameter is unbounded or
122 /// world-space, in which case the frame is the bound — inventing a number
123 /// there would be a claim nothing holds.
124 pub range: Option<[f32; 2]>,
125 /// One sentence: what the parameter does.
126 pub doc: &'static str,
127 /// Whether the value carries an integer meaning (ADR-0180 rule 2).
128 ///
129 /// Read back out with [`kind_of`], and enforced by
130 /// `declared_params_match_set_param` in `core/tests/preset.rs`, which
131 /// compares this against a hand-kept roster — a field nothing checks is a
132 /// field that drifts.
133 pub kind: ParamKind,
134}
135
136/// Byte-wise `str` equality, usable in a `const fn`.
137///
138/// `PartialEq` for `str` is not const, and this is only ever called at compile
139/// time over rosters of a few dozen entries.
140#[allow(
141 clippy::indexing_slicing,
142 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"
143)]
144const fn str_eq(a: &str, b: &str) -> bool {
145 let (a, b) = (a.as_bytes(), b.as_bytes());
146 if a.len() != b.len() {
147 return false;
148 }
149 let mut i = 0;
150 while i < a.len() {
151 if a[i] != b[i] {
152 return false;
153 }
154 i += 1;
155 }
156 true
157}
158
159/// The default `name` is declared with, resolved **at compile time**.
160///
161/// This is what makes a scene's `DEFAULT_*` constant and its `ParamSpec` one
162/// number rather than two copies of one: the constant is defined as a call to
163/// this, so a default changed in the spec changes what `reset_params` applies,
164/// and the generated table cannot state a value the engine does not use.
165///
166/// The linear scan costs nothing — it never runs at runtime — and a name with no
167/// spec is a **compile error**, because a const-eval panic is.
168#[allow(
169 clippy::indexing_slicing,
170 clippy::panic,
171 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"
172)]
173pub const fn default_of(specs: &[ParamSpec], name: &str) -> f32 {
174 let mut i = 0;
175 while i < specs.len() {
176 if str_eq(specs[i].name, name) {
177 return specs[i].default;
178 }
179 i += 1;
180 }
181 panic!("default_of: no ParamSpec declares that name");
182}
183
184/// The names in a spec roster, for the places that still want `&[&str]`.
185///
186/// Allocates, so it is for load-time validation and tests rather than a frame.
187pub fn spec_names(specs: &[ParamSpec]) -> Vec<&'static str> {
188 specs.iter().map(|spec| spec.name).collect()
189}
190
191/// Whether `name` is declared in `specs`. The load-time membership test.
192pub fn declares(specs: &[ParamSpec], name: &str) -> bool {
193 specs.iter().any(|spec| spec.name == name)
194}
195
196/// The [`ParamKind`] `name` is declared with in `specs`, or `None` where no
197/// spec here declares it.
198///
199/// Load-time, like [`declares`]: the loader folds the answer onto the binding
200/// so nothing per frame searches a roster by name (`[smoothing]`'s rule, for
201/// its reason). A name no roster declares is already an ADR-0020 warning, and
202/// an unclaimed binding reaches no scene, so its kind never matters.
203pub fn kind_of(specs: &[ParamSpec], name: &str) -> Option<ParamKind> {
204 specs
205 .iter()
206 .find(|spec| spec.name == name)
207 .map(|spec| spec.kind)
208}
209
210/// One integrated animation phase — the only way a bindable rate advances
211/// anything in this engine (ADR-0135, finishing the rule ADR-0132 stated).
212///
213/// **A rate multiplier has to be integrated to be a rate at all.** A phase
214/// computed as `time * rate` lets a rate bound to audio retroactively rescale
215/// *all* elapsed time on every frame: at t = 100 s a swing from `1.0` to `1.5`
216/// moves the phase by fifty seconds in a single frame — the figure snaps to a
217/// new position rather than accelerating toward it, on a lane whose whole
218/// method is binding parameters to audio. Integrated, the same swing bends the
219/// motion.
220///
221/// [`step`](Self::step) is the **only** mutator, and there is deliberately no
222/// `Add`/`AddAssign`/`Deref`/`DerefMut` impl. The constraint is the value: with
223/// one, a scene could write `phase + self.rate * self.time` and compile, which
224/// is exactly the door this type exists to close.
225///
226/// # No constant scale is folded into the accumulation
227///
228/// A scene carrying its own fixed rate — the attractor's `SPIN_RATE` — applies
229/// it where the phase is **read**, never inside the sum. The accumulator is then
230/// `Σ (rate · dt)` with `rate` at its `1.0` default, i.e. `Σ dt` term for term:
231/// bit-for-bit the same summation the renderer performs for its own clock, so
232/// the integrated form reproduces the multiply it replaced *exactly* and no
233/// golden baseline moves. Folding a `0.18` in would sum `0.18 · dt` instead and
234/// drift in the last bits of every capture.
235///
236/// # It steps in `update`, never in `advance`
237///
238/// The per-frame order is `set_time` → `advance` → `reset_params` → `set_param`
239/// → `update` (`core/src/render/mod.rs`), so a scene stores the `dt` that
240/// [`Scene::advance`] hands it and integrates in [`Scene::update`], where *this*
241/// frame's rate has landed. Integrating in `advance` would use the previous
242/// frame's.
243///
244/// The type is arithmetic with no device in it, which is what keeps every rate
245/// in the engine testable on the CPU without rendering anything.
246#[derive(Clone, Copy, Default, Debug, PartialEq)]
247pub(crate) struct Phase(f32);
248
249impl Phase {
250 /// One frame's integration, at *this* frame's rate.
251 pub(crate) fn step(&mut self, rate: f32, dt: f32) {
252 self.0 += rate * dt;
253 }
254
255 /// The accumulated phase, in rate-scaled seconds. A scene with its own
256 /// constant scale applies it here, on the read.
257 pub(crate) fn get(self) -> f32 {
258 self.0
259 }
260}
261
262/// Declarative structural config a scene consumes once at preset load
263/// (ADR-0007): **not** expressions — the family / grammar / tiling the sampler
264/// or generator builds from. Delivered through the optional
265/// `Scene::configure` hook, off the hot path. This is
266/// the shared structural-config enum for every scene that has one: the line
267/// scenes' curve/L-system/star variants, plus the compute-particle attractor
268/// family (Plan 0016) — it lives here rather than in `lines/` so `lines` never has to name a
269/// `particles` type (Plan 0031 Phase 6).
270#[derive(Debug, Clone)]
271pub enum GeneratorConfig {
272 /// A parametric curve: which family to sample.
273 Curve {
274 /// The curve family (Maurer rose, ...).
275 family: lines::CurveFamily,
276 },
277 /// An L-system: a grammar the generator expands and turtle-walks at load,
278 /// caching one segment buffer per depth.
279 LSystem {
280 /// The starting string.
281 axiom: String,
282 /// Production rules `(predecessor, successor)`.
283 rules: Vec<(char, String)>,
284 /// Turn angle in degrees for `+`/`-`.
285 angle_deg: f32,
286 /// Iterations to precompute (`1..=max_depth`), clamped to
287 /// [`lines::MAX_LSYSTEM_DEPTH`] at load.
288 max_depth: u32,
289 /// Reserved seed for future stochastic rules; deterministic today.
290 seed: u64,
291 },
292 /// A Hankin star pattern: an `n`-fold star rosette built at load, with a few
293 /// contact-angle variants a beat can switch between — and, since ADR-0079,
294 /// an optional ring ornament drawn inside it.
295 Star {
296 /// Star order `n` (from the tiling), e.g. 6 or 12. **`0` means no
297 /// interlace at all** (`tiling = "none"`), which the loader accepts only
298 /// alongside a non-empty `rings` roster — the ornament drawn alone.
299 order: u32,
300 /// Contact angle in degrees; variants are precomputed around it.
301 contact_angle_deg: f32,
302 /// The `[generator] rings` roster (ADR-0079): concentric rings of
303 /// repeated motifs filling the interior the rosette leaves hollow. Empty
304 /// — the default, and what an absent `rings` key means — is exactly the
305 /// pre-Plan-0065 scene.
306 rings: Vec<lines::star::RingSpec>,
307 },
308 /// A GPU compute-particle attractor (Plan 0016): which strange-attractor map
309 /// the compute step iterates. Not a line scene — reuses this shared enum so
310 /// the family rides the existing `configure` hook (no new trait method).
311 Particles {
312 /// The attractor family (De Jong, Clifford, Thomas, Lorenz, or one of
313 /// the IFS figures).
314 family: particles::AttractorFamily,
315 /// The figure the bindable `morph` param travels **towards** (ADR-0075),
316 /// from `[particles] morph_to`. `None` — the default — pins the figure,
317 /// so `morph` is inert.
318 ///
319 /// IFS-only, and validated as such at load: `morph_to` on a map family
320 /// is a load error rather than a silent no-op, because the author asked
321 /// for something the engine cannot do.
322 morph_to: Option<particles::ifs::IfsFigure>,
323 /// Fraction of the tier's particle budget actually drawn (ADR-0069),
324 /// validated at load into
325 /// [`MIN_PARTICLE_DENSITY`](particles::MIN_PARTICLE_DENSITY)`..=1.0`.
326 /// Structural, not bindable: an eased integer count would re-decide the
327 /// picture every frame. `1.0` is the whole budget and the default.
328 density: f32,
329 /// The **tuple path** the bindable `morph` param walks along on a map
330 /// family (ADR-0093), as `(from, to)` roster indices from
331 /// `[particles] tuple_from` / `tuple_to`. `None` — the default — means
332 /// there is no path and `morph` is inert, exactly as `morph_to` does for
333 /// the IFS.
334 ///
335 /// **Both ends are structural on purpose.** The walk's framing is
336 /// measured across it at load, which is thousands of map iterations; a
337 /// path whose near end came from the per-frame `tuple` param would have
338 /// to re-measure inside the frame loop every time that param moved.
339 ///
340 /// Map-family-only, and validated as such at load — the IFS reaches its
341 /// own figure-to-figure travel through `morph_to` instead, and a tuple
342 /// path on an IFS is a load error rather than a silent no-op.
343 tuple_path: Option<(u32, u32)>,
344 },
345 /// The spectrum readout's `[spectrum]` table (Plan 0034 / ADR-0036): how many
346 /// elements the frequency axis is divided into, how they are laid out, and how
347 /// fast each one follows its band. All three are structure rather than
348 /// expression — they are fixed for as long as the preset is loaded — so they
349 /// ride the existing `configure` hook like every other declarative config.
350 Spectrum {
351 /// Element count, validated at load into
352 /// `2..=`[`SPECTRUM_BINS`](crate::dsp::SPECTRUM_BINS).
353 elements: usize,
354 /// Which figure the elements form.
355 layout: lines::SpectrumLayout,
356 /// Per-element temporal easing in **seconds**, applied on the injected
357 /// real `dt` — the same [`Easing`](crate::preset::Easing) the `[smoothing]`
358 /// table uses, deliberately reused rather than a second vocabulary
359 /// (ADR-0035).
360 easing: crate::preset::Easing,
361 },
362 /// The warp mesh's `[mesh]` table (Plan 0100 / ADR-0113): the grid, in
363 /// cells, that the per-vertex program is evaluated over.
364 ///
365 /// Structural for `[curve] family`'s reason — the vertex and index buffers
366 /// are built from it, so an eased grid would rebuild them mid-frame — and
367 /// **clamped to the tier at both consumers** rather than at load, since the
368 /// loader does not know which tier will render the preset
369 /// ([`warp_mesh::clamp_grid`]).
370 WarpMesh {
371 /// Requested cells, `(x, y)`, validated at load into
372 /// [`MIN_MESH`](warp_mesh::MIN_MESH)`..=`[`MAX_MESH`](warp_mesh::MAX_MESH).
373 mesh: (u32, u32),
374 /// The compiled EEL2 programs a **converted** preset carries, from a
375 /// `[milk]` table (Plan 0100 Phase 2 / ADR-0113). `None` — a
376 /// hand-authored `warp_mesh` preset — drives the mesh from the ordinary
377 /// `[params]` and `[per_vertex]` bindings instead, and executes no VM at
378 /// all.
379 ///
380 /// Boxed because it is much the largest thing this enum carries and every
381 /// other variant would pay for it by value.
382 milk: Option<Box<crate::milk::MilkBundle>>,
383 /// The salt the bundle's `rand()` draws under (ADR-0051).
384 ///
385 /// **The preset's `pinned_salt`, always** — its declared numeric seed, or
386 /// `0` where it declared `seed = "random"`. So a bundle is a pure
387 /// function of its inputs in the live app as well as in the harness,
388 /// which is stronger than ADR-0051 requires and costs nothing: per-run
389 /// variety is opt-in through `seed = "random"`, and no *converted* preset
390 /// declares one. A hand-written bundle that does gets the pinned
391 /// behaviour, and that is stated rather than discovered.
392 salt: u32,
393 },
394 /// The shape field's `[path]` table (ADR-0107): an authored silhouette, as a
395 /// closed contour parsed once at load from inline SVG path data.
396 ///
397 /// The one variant here carrying **geometry** rather than a selector or a
398 /// size. It rides `configure` for the reason every other structural table
399 /// does — it is fixed for as long as the preset is loaded — and a
400 /// `shape_field` preset declaring no table gets `None` and draws the closed
401 /// `marks` roster exactly as it did before paths existed.
402 Path {
403 /// The contour, normalized into `[-1, 1]` and resampled to the arity
404 /// `[path] samples` asked for. `None` — a `shape_field` preset that
405 /// declares no table — is what makes this config **always `Some`**: it
406 /// is handed over on every preset switch precisely so `configure` runs
407 /// and clears the outgoing preset's contour, the same reason the
408 /// attractor's and the spectrum's configs are unconditional.
409 shape: Option<crate::preset::path::PathShape>,
410 /// The silhouette the bindable `morph` param travels **towards**, from
411 /// `[path] morph_to`. `None` — the default — pins the figure, so `morph`
412 /// is inert, exactly as the attractor's own `morph_to` does.
413 ///
414 /// Already **aligned** to `shape` at load: same arity, same winding, and
415 /// the cyclic start that minimises total displacement (ADR-0107). The
416 /// render layer interpolates the two point lists and re-derives none of
417 /// that, which is what keeps an `O(N^2)` search off the frame.
418 morph_to: Option<crate::preset::path::PathShape>,
419 },
420}
421
422impl GeneratorConfig {
423 /// How many elements a per-element binding should be evaluated for under this
424 /// config, or `0` when the system has no per-element surface (Plan 0034 Phase
425 /// 4). Read once at preset load to size the render layer's scratch.
426 ///
427 /// The count lives here rather than on `Scene` because it is **preset data**:
428 /// it comes off the `[spectrum]` table, not out of the scene's state, and the
429 /// renderer already holds the preset.
430 pub fn element_count(&self) -> usize {
431 match self {
432 GeneratorConfig::Spectrum { elements, .. } => *elements,
433 GeneratorConfig::Curve { .. }
434 | GeneratorConfig::LSystem { .. }
435 | GeneratorConfig::Star { .. }
436 | GeneratorConfig::Particles { .. }
437 | GeneratorConfig::WarpMesh { .. }
438 | GeneratorConfig::Path { .. } => 0,
439 }
440 }
441}
442
443/// Which construction hit the segment cap, for the surfaced message.
444///
445/// An enum rather than a `String` because one of the two producers is **per
446/// frame**: with a `String`, an audio-driven `mirror_order` sitting over the cap
447/// builds a fresh `format!("mirror x{order}")` on every single frame for as long
448/// as it stayed there — a heap allocation on the hot path (Plan 0031 Phase 4).
449/// The formatting now happens only in [`Display`](std::fmt::Display), i.e. only
450/// when something actually prints it.
451#[derive(Debug, Clone, Copy, PartialEq, Eq)]
452pub enum OverflowContext {
453 /// An N-fold geometry mirror replicated past the cap — per frame, from the
454 /// `mirror_order` param (Plan 0018 Phase 4).
455 Mirror(u32),
456 /// An L-system depth expanded past the cap — once, at preset load.
457 Depth(u32),
458}
459
460impl std::fmt::Display for OverflowContext {
461 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
462 // These two renderings are the user-visible text ADR-0007 requires stay
463 // informative; the shell prints them verbatim. Do not reword them
464 // without meaning to change what an operator sees.
465 match self {
466 OverflowContext::Mirror(order) => write!(f, "mirror x{order}"),
467 OverflowContext::Depth(depth) => write!(f, "depth {depth}"),
468 }
469 }
470}
471
472/// Reported when building a line scene's geometry hit the segment cap and
473/// truncated. The cap must never be a silent cut (ADR-0007 Risks), so it travels
474/// to the frontend two ways: out of `Scene::configure` at preset load, and off
475/// `Scene::mirror_overflow` for the per-frame mirror. `None` is the normal case
476/// where geometry fit.
477#[derive(Debug, Clone, Copy, PartialEq, Eq)]
478pub struct CapOverflow {
479 /// How many draw segments were dropped at the cap.
480 pub dropped: usize,
481 /// Where the drop happened, for the surfaced message.
482 pub context: OverflowContext,
483 /// **The cap that bit**, carried rather than read from a constant: it is a
484 /// tier value now (Plan 0044), so the same preset overflows at 20 000
485 /// segments on the floor and not at all at 60 000 on rich. A message naming a
486 /// cap the run was not using would be worse than no message.
487 pub cap: usize,
488}
489
490impl std::fmt::Display for CapOverflow {
491 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
492 write!(
493 f,
494 "geometry exceeded the {}-segment cap at {} (dropped {} segment(s)); \
495 reduce the structure or its depth",
496 self.cap, self.context, self.dropped
497 )
498 }
499}
500
501/// One visual. `update` advances state from the analysis frame; `render` draws
502/// with the state it has.
503///
504/// Both built-in systems (fragment field, swarm) are preset-driven and
505/// implement the named-parameter surface — `set_time`, `reset_params`,
506/// `set_param` — that the preset layer evaluates into per frame (ADR-0002). The
507/// trait carries no-op defaults so a future non-parametric scene need not.
508pub(crate) trait Scene {
509 fn name(&self) -> &'static str;
510 fn update(&mut self, frame: &AnalysisFrame);
511 fn render(
512 &mut self,
513 queue: &wgpu::Queue,
514 encoder: &mut wgpu::CommandEncoder,
515 view: &wgpu::TextureView,
516 aspect: f32,
517 );
518
519 /// The pixel size of the target **this scene renders into this frame**
520 /// (ADR-0030). That is *not* always the surface: the composite chain routes
521 /// the scene into the first active post stage's input, which is a fixed
522 /// internal grid for the trails and kaleidoscope stages and the surface
523 /// otherwise, so the only correct value is the one the chain reports back
524 /// (`PostChain::begin`).
525 /// A scene that accumulates into an internal offscreen field sizes that field
526 /// from here, so it matches its target instead of upscaling from a fixed grid
527 /// or supersampling into a smaller offscreen; every other scene ignores it.
528 ///
529 /// **Called unconditionally every frame**, immediately before
530 /// [`render`](Self::render) — it is named for what it carries, not for an
531 /// event, and there is no resize event behind it. So ADR-0030 condition 2
532 /// binds every implementor: **compare against what you already built and do
533 /// nothing when unchanged**, and never allocate or build GPU resources here.
534 /// The attractor records the requested grid and lets the next `render` notice
535 /// the difference.
536 ///
537 /// Default no-op, in the same spirit as [`advance`](Self::advance): the
538 /// renderer already holds the size in `draw_frame`, and `Scene` is a `dyn`
539 /// trait, so this is the only channel that reaches a scene with it (Plan 0027
540 /// Phase 2, the third and first hot-path widening — ADR-0030).
541 fn set_target_size(&mut self, _width: u32, _height: u32) {}
542
543 /// How much of this scene's coverage the **backdrop** resolves against, for
544 /// the frame it is about to render (ADR-0085). `1.0` is coverage-as-occlusion
545 /// — what every frame did before `occlude` existed — and `0.0` is light that
546 /// adds without covering.
547 ///
548 /// **Called unconditionally every frame**, immediately before
549 /// [`render`](Self::render), in the same spirit as
550 /// [`set_target_size`](Self::set_target_size). The renderer hands a literal
551 /// `1.0` whenever a post stage is active, because then the scene draws into a
552 /// scratch offscreen with no backdrop under it and the chain's last stage owns
553 /// the seam instead — a scene must never apply this twice.
554 ///
555 /// Only a scene that **presents premultiplied over the backdrop** (ADR-0026 —
556 /// the reaction-diffusion, attractor and fragment-field presents) has anything
557 /// to do here. The additive families draw through
558 /// [`gpu::ADDITIVE_LIGHT_SATURATING_COVERAGE`](crate::render::gpu::ADDITIVE_LIGHT_SATURATING_COVERAGE),
559 /// whose colour destination factor is `One`: with no stage active their light
560 /// already adds to the backdrop rather than replacing it, so there is no
561 /// occlusion at that seam for this to scale. Default no-op.
562 fn set_occlude(&mut self, _occlude: f32) {}
563
564 /// The scene's feedback field, for a probe that needs the value **before**
565 /// this scene's present pass. `None` for every scene without one, which is
566 /// every scene but the warp mesh.
567 ///
568 /// **`#[cfg(test)]`, and that gate is the whole justification.** ADR-0002
569 /// keeps this trait thin and a real widening of it is ADR-worthy; this method
570 /// does not exist in a shipped build, so the extension seam is unchanged. It
571 /// exists because Plan 0111 Phase 2's bisect requires its five seams to be
572 /// read from **one run** — same signal, same hop, same size, same adapter —
573 /// and a `Box<dyn Scene>` cannot otherwise be asked for the one quantity that
574 /// sits upstream of everything the bisect covers. Measuring seam A on a
575 /// separately-driven scene would satisfy the arithmetic and quietly break
576 /// that requirement.
577 #[cfg(test)]
578 fn feedback_field(&self) -> Option<&wgpu::Texture> {
579 None
580 }
581
582 /// Advance simulation state by `dt` real seconds (Plan 0014 Phase 2). The
583 /// renderer injects the elapsed time each frame; a feedback scene steps its
584 /// fixed-timestep accumulator here and a CPU-integrated scene (the swarm)
585 /// scales its motion by `dt`, so both look identical over wall-clock time on
586 /// any refresh rate. Stateless, purely `time`-driven scenes ignore it.
587 ///
588 /// **`dt` is finite and strictly positive.** The renderer guarantees it,
589 /// substituting [`FALLBACK_DT`] for a degenerate delta before this is called
590 /// (ADR-0152), so an implementor may store it, integrate it, or divide by it
591 /// without checking. **Do not re-check it per scene**: the guarantee is
592 /// invisible from inside a scene, and this sentence is the whole of what
593 /// stands between a shell's raw delta and every accumulator behind this
594 /// trait — a second copy at one site makes it a rule enforced by a list of
595 /// sites again, which is the failure the seam exists to end.
596 fn advance(&mut self, _dt: f32) {}
597
598 /// Set the shared scene clock (seconds). The renderer owns the single clock
599 /// so an expression's `time` and the system's animation never diverge.
600 fn set_time(&mut self, _time: f32) {}
601 /// Reset every named parameter to its default (called each frame before the
602 /// active preset's bindings are applied, so unbound params don't leak).
603 fn reset_params(&mut self) {}
604 /// Apply one named parameter; unknown names are ignored.
605 fn set_param(&mut self, _name: &str, _value: f32) {}
606
607 /// Apply one named parameter as a **per-element series** (Plan 0034 Phase 4,
608 /// ADR-0036): `values` holds one evaluation of the binding per element, in
609 /// element order. Reached only for a binding whose expression names `index`.
610 ///
611 /// **This is the whole channel, and it is deliberately this narrow.** It
612 /// carries `(name, &[f32])` in one direction and returns nothing. A scene
613 /// cannot ask the preset layer for anything, cannot see the expression, and
614 /// cannot learn which preset is loaded — so this is `set_param` with a slice,
615 /// not an inversion in which scenes read presets. The slice borrows the
616 /// renderer's scratch, which is sized at preset load, so nothing here
617 /// allocates.
618 ///
619 /// The default takes the **first** value and routes it through
620 /// [`set_param`](Self::set_param) — exactly the `index = 0` reading a binding
621 /// gets outside a per-element evaluation. So a scene with no per-element
622 /// surface degrades a series to a scalar instead of dropping it, and a scene
623 /// that never opts in behaves byte-for-byte as before. Only the spectrum
624 /// readout overrides this.
625 fn set_param_series(&mut self, name: &str, values: &[f32]) {
626 if let Some(&first) = values.first() {
627 self.set_param(name, first);
628 }
629 }
630
631 /// Apply one named parameter as a **per-vertex series** (Plan 0100 Phase 1,
632 /// ADR-0113): `values` holds one evaluation of the binding per mesh vertex,
633 /// in row-major order from the top-left, `(meshx + 1) * (meshy + 1) ` long.
634 ///
635 /// The per-element channel one axis up, and deliberately just as narrow: it
636 /// carries `(name, &[f32])` in one direction and returns nothing. Reached
637 /// only for a binding in a `[per_vertex]` table, so a scene that never opts
638 /// in is never called.
639 ///
640 /// Unlike [`set_param_series`](Self::set_param_series) the default does
641 /// **nothing** rather than degrading to the first value. A per-vertex series
642 /// varies over space and its first element is the top-left corner, which is
643 /// not a sensible whole-scene reading of anything; the loader already warns
644 /// that a `[per_vertex]` table on another system is inert.
645 ///
646 /// The slice borrows the renderer's scratch, sized at preset load from the
647 /// same [`clamp_grid`](warp_mesh::clamp_grid) the scene uses, so nothing here
648 /// allocates.
649 fn set_per_vertex(&mut self, _name: &str, _values: &[f32]) {}
650
651 /// Consume a preset's declarative structural config (ADR-0007). Invoked
652 /// **once at preset load, off the hot path** — a generator builds and caches
653 /// its geometry here; a parametric scene records its family. Default no-op,
654 /// so non-line scenes (fragment field, swarm) never implement it. The one
655 /// optional widening of this trait ADR-0007 sanctions — keep it to this.
656 ///
657 /// Returns [`Some`](lines::CapOverflow) when building the geometry hit the
658 /// segment cap and truncated, so the frontend can surface it — the cap is
659 /// never a silent cut (ADR-0007 Risks). `None` means it fit (the norm).
660 fn configure(&mut self, _cfg: &lines::GeneratorConfig) -> Option<lines::CapOverflow> {
661 None
662 }
663
664 /// Consume a preset's baked color [`Palette`] (ADR-0021). Invoked **once at
665 /// preset load, off the hot path** — a shader-colored scene stores the baked
666 /// LUT and uploads it to its 256×1 texture (or samples it on the CPU) on the
667 /// next frame; a non-colored scene (the line scenes) ignores it. Default
668 /// no-op. The second and last thin off-hot-path widening of this trait after
669 /// ADR-0007's [`configure`](Scene::configure).
670 fn set_palette(&mut self, _palette: &Palette) {}
671
672 /// Consume a preset's `[feedback]` structural table (ADR-0048). Invoked
673 /// **once at preset load, off the hot path**, like
674 /// [`configure`](Scene::configure) and [`set_palette`](Scene::set_palette),
675 /// and load-time for `configure`'s reason: a warp kind is a shader path, not
676 /// a scalar. Default no-op — the third and last thin off-hot-path widening of
677 /// this trait.
678 ///
679 /// # One vocabulary, two buffers
680 ///
681 /// **This is the routing contract, and it is worth stating plainly because it
682 /// will surprise someone.** The `fb_*` params and this table are consumed by
683 /// *two* sinks: the engine [`Trails`](crate::render::trails::Trails) stage,
684 /// which transforms the accumulation every scene composites through, and the
685 /// attractor scene's own internal trail field, which is what reaches here.
686 /// A preset may have **both** active at once — an attractor with `trails` on —
687 /// and then a single `fb_rotate` turns *both* accumulations, each about its
688 /// own buffer. Neither transforms the other's, and neither transforms the
689 /// present deposit: the transform applies to the past.
690 ///
691 /// That is a deliberate design (ADR-0048's Alternative D was to give the
692 /// engine stage the vocabulary and leave the attractor out), and the reason it
693 /// is safe is that the two answer the same param names with the same
694 /// arithmetic — [`feedback::Transform`](crate::render::feedback::Transform)
695 /// and one shared WGSL snippet, not two implementations that must agree.
696 fn set_feedback(&mut self, _cfg: crate::render::feedback::FeedbackConfig) {}
697
698 /// The per-frame geometry-mirror cap overflow (Plan 0018 Phase 4), if this
699 /// frame's N-fold replication exceeded the segment cap and truncated. Reuses
700 /// the ADR-0007 [`CapOverflow`](lines::CapOverflow) so the frontend surfaces
701 /// it — the cap is never a silent cut. Default `None`: only the line scenes
702 /// mirror, and only when `mirror_order` pushes past the cap.
703 fn mirror_overflow(&self) -> Option<&lines::CapOverflow> {
704 None
705 }
706
707 /// The scene's **resolved sample budget** for the target it was last given,
708 /// where it has one (ADR-0140) — the attractor, and nothing else today.
709 ///
710 /// A hook rather than a field on the roster because it is a scene's own
711 /// arithmetic: only a scene knows what its budget resolved to. `None` is the
712 /// honest answer for every scene whose count is a flat capacity, and for the
713 /// attractor before a target size has reached it.
714 ///
715 /// **The seam exists so the factory's choice of ceiling is readable off the
716 /// built scene**, which is what makes that wiring testable at all — a test
717 /// that recomputed the law would pass with both modes handed the same
718 /// number. It is not the reporting path: `shot --render` prints its budget
719 /// from [`TierConfig::attractor_budget_offline`] instead, because the header
720 /// is written before the renderer is constructed and this would answer
721 /// `None` there.
722 ///
723 /// `#[cfg(test)]` because that leaves nothing but the test: no shipped path
724 /// asks a scene what its budget resolved to, and a trait method the engine
725 /// never calls is a widened seam pretending to be an API.
726 #[cfg(test)]
727 fn sample_budget(&self) -> Option<u32> {
728 None
729 }
730}
731
732/// The registry: every built-in scene, **keyed by the [`SystemKind`] it drives**,
733/// in [`SystemKind::ALL`] order. All scenes are created up front so switching
734/// mid-show is a lookup, never a hitch.
735///
736/// The keying is the point: the renderer addresses a scene by the kind its preset
737/// names, so a scene cannot silently end up in the wrong slot. Nothing here is
738/// positional — reordering [`SystemKind::ALL`] reorders construction and nothing
739/// else.
740pub(crate) fn create_all(
741 device: &wgpu::Device,
742 surface_format: wgpu::TextureFormat,
743 tier: &crate::render::TierConfig,
744 budget: crate::render::SampleBudget,
745) -> Vec<(SystemKind, Box<dyn Scene>)> {
746 // One shared line renderer for every line scene (ADR-0007: "one line
747 // renderer"). A single instanced-quad pipeline + segment buffer, borrowed by
748 // whichever line scene is active — only one draws per frame. (Two separate
749 // line pipelines with byte-identical vertex layouts also mis-render on the
750 // DX12 WARP software adapter the capture tests use; one renderer avoids it.)
751 // `new_with_arcs`, not `new`: `star_pattern`'s circular motifs are one arc
752 // instance each (ADR-0098), and the arc buffer holds
753 // `max_segments` because the two kinds share **one** budget — everything
754 // that passes `build_rings`'s cap check must reach the GPU, or a cap would
755 // be silently cutting geometry, which ADR-0007 forbids.
756 // `new_split_with_arcs`: any of the four line systems may ask for the
757 // opacity-preserving seam through `stroke_blend` (ADR-0138), and the
758 // pipelines are built here rather than when a preset first selects one —
759 // building a GPU resource mid-run changes what a later pass resolves to on
760 // the DX12 software adapter.
761 let line_renderer = Rc::new(RefCell::new(lines::LineRenderer::new_split_with_arcs(
762 device,
763 surface_format,
764 tier.max_segments,
765 tier.max_segments,
766 "lines",
767 )));
768 SystemKind::ALL
769 .iter()
770 .map(|&kind| {
771 (
772 kind,
773 create(
774 kind,
775 device,
776 surface_format,
777 &mut || line_renderer.clone(),
778 tier,
779 budget,
780 ),
781 )
782 })
783 .collect()
784}
785
786/// A scene constructed **for one preset's `[layer]`** (ADR-0090 point 4, Plan
787/// 0076 Phase 2), never taken from the roster — which is what makes same-system
788/// pairs legal and keeps two dissolving sides' layers from sharing anything.
789/// The stateful families duplicate their GPU state by construction: their
790/// constructors are already self-contained (a second reaction-diffusion
791/// ping-pong field, a second particle buffer), so this is the same exhaustive
792/// [`create`] the roster uses, differing only in where a line scene gets its
793/// renderer.
794///
795/// # The `LineRenderer` answer, recorded (the Phase 2 discovery duty)
796///
797/// **A layer line scene gets its own `LineRenderer`; the shared one is not
798/// shareable between two live line draws in one frame.** `LineRenderer::draw`
799/// uploads its instance and uniform buffers through `Queue::write_buffer`, and
800/// queued writes are applied before the submission's passes execute — so two
801/// draws through one renderer in one frame would both rasterize the *second*
802/// draw's segments under the second draw's uniforms. Making it shareable would
803/// need a partitioned instance buffer and per-draw uniform slots — a redesign
804/// of the idiom, not constructor plumbing — so duplication is the answer, at
805/// one pipeline plus one `max_segments` instance buffer per layered line
806/// preset.
807///
808/// The duplicate is built **only when the layer is a line system**: a
809/// fragment/swarm/particle layer pays no line pipeline, and WARP's documented
810/// sensitivity to coexisting identical pipeline layouts (ADR-0058 / Plan 0053)
811/// is only ever exercised by a preset that actually declares a line layer.
812pub(crate) fn create_layer_scene(
813 kind: SystemKind,
814 device: &wgpu::Device,
815 surface_format: wgpu::TextureFormat,
816 tier: &crate::render::TierConfig,
817 budget: crate::render::SampleBudget,
818) -> Box<dyn Scene> {
819 create(
820 kind,
821 device,
822 surface_format,
823 &mut || {
824 // Arcs too — a `[layer]` may be a `star_pattern`, and a layer that
825 // could not draw them would render a mandala with its circles
826 // missing rather than fail.
827 Rc::new(RefCell::new(lines::LineRenderer::new_split_with_arcs(
828 device,
829 surface_format,
830 tier.max_segments,
831 tier.max_segments,
832 "layer-lines",
833 )))
834 },
835 tier,
836 budget,
837 )
838}
839
840/// Whether two systems' scenes share mutable GPU state, so **one frame must not
841/// render both**.
842///
843/// Two facts make this true, and only one of them is obvious. The roster is keyed
844/// by kind, so the same kind is literally the same `Box<dyn Scene>`. Less
845/// obviously, the three **line** scenes deliberately share one `LineRenderer` —
846/// "borrowed by whichever line scene is active, only one draws per frame" (see
847/// [`create_all`]) — so two *different* line kinds are just as unrenderable in one
848/// frame as one kind twice.
849///
850/// Plan 0023's dual-live dissolve is the first caller: it composites two presets
851/// in a single frame, which is exactly what this forbids. A pair that shares
852/// resources falls back to the frozen snapshot.
853///
854/// **This is a statement about the roster's instances only.** A `[layer]`
855/// scene ([`create_layer_scene`], Plan 0076 Phase 2) is constructed per preset
856/// and shares nothing with the roster or with another preset's layer by
857/// construction — so a preset's own main-plus-layer pair never consults this,
858/// whatever the two systems are.
859pub(crate) fn shares_resources(a: SystemKind, b: SystemKind) -> bool {
860 a == b || (draws_through_shared_line_renderer(a) && draws_through_shared_line_renderer(b))
861}
862
863/// Whether a system draws through the shared `LineRenderer`. **Exhaustive** with
864/// no wildcard arm, like [`create`] itself: a new scene fails to compile here
865/// until someone says which side of the sharing it is on.
866fn draws_through_shared_line_renderer(kind: SystemKind) -> bool {
867 match kind {
868 SystemKind::ParametricCurve
869 | SystemKind::LSystem
870 | SystemKind::StarPattern
871 | SystemKind::Spectrum => true,
872 SystemKind::FragmentField
873 | SystemKind::Swarm
874 | SystemKind::ReactionDiffusion
875 | SystemKind::Attractor
876 | SystemKind::Emitter
877 | SystemKind::ShapeField
878 | SystemKind::WarpMesh
879 | SystemKind::ShapeCollage => false,
880 }
881}
882
883/// Build the scene a [`SystemKind`] drives.
884///
885/// An **exhaustive** `match` with no wildcard arm — the same guard the golden
886/// drift fixtures use: adding a variant fails to compile here until its scene is
887/// constructed, so a new system cannot ship unbuilt or wired to the wrong scene.
888///
889/// `line_renderer` is a **source**, called only by the line arms: the roster
890/// hands out clones of its one shared renderer, a layer construction builds a
891/// fresh one on demand ([`create_layer_scene`]) — and a non-line kind builds
892/// none at all.
893fn create(
894 kind: SystemKind,
895 device: &wgpu::Device,
896 surface_format: wgpu::TextureFormat,
897 line_renderer: &mut dyn FnMut() -> Rc<RefCell<lines::LineRenderer>>,
898 tier: &crate::render::TierConfig,
899 budget: crate::render::SampleBudget,
900) -> Box<dyn Scene> {
901 match kind {
902 SystemKind::FragmentField => Box::new(fragment_field::FragmentFieldScene::new(
903 device,
904 surface_format,
905 )),
906 SystemKind::Swarm => Box::new(swarm::SwarmScene::new(
907 device,
908 surface_format,
909 tier.swarm_particles,
910 )),
911 SystemKind::ParametricCurve => Box::new(lines::ParametricCurveScene::new(
912 line_renderer(),
913 tier.max_segments,
914 )),
915 SystemKind::LSystem => {
916 Box::new(lines::LSystemScene::new(line_renderer(), tier.max_segments))
917 }
918 SystemKind::StarPattern => Box::new(lines::StarPatternScene::new(
919 line_renderer(),
920 tier.max_segments,
921 )),
922 SystemKind::ReactionDiffusion => Box::new(reaction_diffusion::ReactionDiffusionScene::new(
923 device,
924 surface_format,
925 )),
926 SystemKind::Attractor => Box::new(particles::AttractorScene::new(
927 device,
928 surface_format,
929 tier.attractor_particles,
930 match budget {
931 crate::render::SampleBudget::Live => tier.attractor_particles_live_ceiling,
932 crate::render::SampleBudget::Offline => tier.attractor_particles_offline_ceiling,
933 },
934 tier.attractor_trail_cap,
935 )),
936 SystemKind::Spectrum => Box::new(lines::SpectrumScene::new(
937 line_renderer(),
938 tier.max_segments,
939 )),
940 SystemKind::Emitter => Box::new(emitter::EmitterScene::new(
941 device,
942 surface_format,
943 tier.emitter_objects,
944 )),
945 SystemKind::ShapeField => {
946 Box::new(shape_field::ShapeFieldScene::new(device, surface_format))
947 }
948 SystemKind::WarpMesh => Box::new(warp_mesh::WarpMeshScene::new(
949 device,
950 surface_format,
951 tier.mesh_grid,
952 tier.max_segments,
953 )),
954 SystemKind::ShapeCollage => Box::new(shape_collage::ShapeCollageScene::new(
955 device,
956 surface_format,
957 tier.collage_elements,
958 )),
959 }
960}
961
962/// Tiny deterministic RNG (splitmix64) so visual randomness is explicitly
963/// seeded (NFR 6) without pulling a rand crate.
964pub(crate) struct SeededRng(u64);
965
966impl SeededRng {
967 pub(crate) fn new(seed: u64) -> Self {
968 Self(seed)
969 }
970
971 fn next_u64(&mut self) -> u64 {
972 self.0 = self.0.wrapping_add(0x9E37_79B9_7F4A_7C15);
973 let mut z = self.0;
974 z = (z ^ (z >> 30)).wrapping_mul(0xBF58_476D_1CE4_E5B9);
975 z = (z ^ (z >> 27)).wrapping_mul(0x94D0_49BB_1331_11EB);
976 z ^ (z >> 31)
977 }
978
979 /// Uniform in [0, 1).
980 pub(crate) fn next_f32(&mut self) -> f32 {
981 (self.next_u64() >> 40) as f32 / (1u64 << 24) as f32
982 }
983
984 /// Uniform in [lo, hi).
985 pub(crate) fn range(&mut self, lo: f32, hi: f32) -> f32 {
986 lo + (hi - lo) * self.next_f32()
987 }
988}
989
990#[cfg(test)]
991mod tests {
992 //! The scene-keying contract (Plan 0030 Phase 3), and the parameter-kind
993 //! quantizer declared beside it. Test asserts panic freely; this is not the
994 //! render path.
995 #![allow(clippy::panic)]
996
997 use super::{ParamKind, create_all};
998 use crate::preset::SystemKind;
999 use crate::render::context::{RenderContext, RenderError};
1000
1001 /// `Structural` rounds and `Modal` does not — the whole of what a kind
1002 /// changes about a value.
1003 ///
1004 /// **Nothing else can see this work or fail.** The engine's `Structural`
1005 /// roster is confined to parameters whose scene already clamps and rounds
1006 /// the value itself, so `quantize` composes to the identity everywhere it
1007 /// currently runs and no rendered assertion distinguishes it from being
1008 /// absent (design-backlog 0197). This covers it directly instead.
1009 #[test]
1010 fn a_structural_kind_rounds_and_a_modal_one_hands_the_value_through() {
1011 // Rust rounds a half AWAY FROM ZERO, and a scene indexing a closed
1012 // roster with the result depends on which way 6.5 goes, so the
1013 // direction is pinned here rather than assumed.
1014 for (value, rounded) in [
1015 (6.4_f32, 6.0_f32),
1016 (6.5, 7.0),
1017 (6.6, 7.0),
1018 (-6.4, -6.0),
1019 (-6.5, -7.0),
1020 (3.0, 3.0),
1021 (0.0, 0.0),
1022 ] {
1023 assert_eq!(
1024 ParamKind::Structural.quantize(value),
1025 rounded,
1026 "Structural must round {value}"
1027 );
1028 assert_eq!(
1029 ParamKind::Modal.quantize(value),
1030 value,
1031 "Modal must hand {value} through untouched"
1032 );
1033 }
1034
1035 // The property behind the table, over a sweep that lands on no integer
1036 // by construction: a Structural value equals its own round, and
1037 // quantizing it again moves nothing.
1038 for i in -400..400 {
1039 let value = i as f32 * 0.0137;
1040 let q = ParamKind::Structural.quantize(value);
1041 assert_eq!(q, q.round(), "Structural produced the non-integral {q}");
1042 assert_eq!(
1043 ParamKind::Structural.quantize(q),
1044 q,
1045 "quantizing an already-quantized {q} moved it"
1046 );
1047 assert_eq!(
1048 ParamKind::Modal.quantize(value),
1049 value,
1050 "Modal moved {value}"
1051 );
1052 }
1053
1054 // A non-finite value survives, which is what the type's own doc claims:
1055 // `f32::round` has no special case for one and neither does this.
1056 assert!(ParamKind::Structural.quantize(f32::NAN).is_nan());
1057 assert_eq!(ParamKind::Structural.quantize(f32::INFINITY), f32::INFINITY);
1058 assert_eq!(
1059 ParamKind::Structural.quantize(f32::NEG_INFINITY),
1060 f32::NEG_INFINITY
1061 );
1062 }
1063
1064 /// The scene each system is *supposed* to drive, written independently of the
1065 /// factory so the two can disagree. This is the mapping the old magic-index
1066 /// `system_slot` lookup could never assert: it named a position, and nothing
1067 /// checked the position held the right scene.
1068 fn expected_scene_name(system: SystemKind) -> &'static str {
1069 match system {
1070 SystemKind::FragmentField => "fragment field",
1071 SystemKind::ShapeField => "shape field",
1072 SystemKind::Swarm => "swarm",
1073 SystemKind::ParametricCurve => "parametric curve",
1074 SystemKind::LSystem => "l-system",
1075 SystemKind::StarPattern => "star pattern",
1076 SystemKind::ReactionDiffusion => "reaction diffusion",
1077 SystemKind::Attractor => "attractor",
1078 SystemKind::Spectrum => "spectrum",
1079 SystemKind::Emitter => "emitter",
1080 SystemKind::WarpMesh => "warp mesh",
1081 SystemKind::ShapeCollage => "shape collage",
1082 }
1083 }
1084
1085 /// **The factory is what chooses the ceiling**, and the two choices resolve
1086 /// different budgets at the same target (ADR-0140).
1087 ///
1088 /// Read off the scene rather than recomputed: this is the wiring under test,
1089 /// so a recomputation of the law here would pass with the factory handing
1090 /// both modes the same number. 1920x1080 is the size the whole plan is
1091 /// about — nine times the reference — and it is past the live ceiling and
1092 /// under the offline one, which is what makes the two answers differ.
1093 ///
1094 /// No frame is rendered: `set_target_size` is CPU arithmetic, so this costs
1095 /// one scene build on WARP and nothing else.
1096 #[test]
1097 fn the_render_path_resolves_a_larger_budget_than_a_window_does() {
1098 use crate::render::{SampleBudget, TierConfig};
1099
1100 let ctx = match RenderContext::new_headless(64, 64, true) {
1101 Ok(ctx) => ctx,
1102 Err(RenderError::RequestAdapter(_)) => {
1103 eprintln!("skipped: no GPU adapter on this runner (ADR-0016)");
1104 return;
1105 }
1106 Err(e) => panic!("headless context build failed: {e}"),
1107 };
1108
1109 let resolved = |budget: SampleBudget, tier: &TierConfig, w: u32, h: u32| -> Option<u32> {
1110 let mut scenes = create_all(&ctx.device, ctx.surface_format(), tier, budget);
1111 let (_, scene) = scenes
1112 .iter_mut()
1113 .find(|(kind, _)| *kind == SystemKind::Attractor)?;
1114 // Before a target size reaches it, a scene has no resolved budget to
1115 // report - which is the distinction the hook's `None` carries.
1116 assert_eq!(scene.sample_budget(), None);
1117 scene.set_target_size(w, h);
1118 scene.sample_budget()
1119 };
1120
1121 let rich = TierConfig::RICH;
1122 assert_eq!(
1123 resolved(SampleBudget::Offline, &rich, 1920, 1080),
1124 Some(1_350_000),
1125 "a render at 1080p must reach the law's own value, not the live cap"
1126 );
1127 assert_eq!(
1128 resolved(SampleBudget::Live, &rich, 1920, 1080),
1129 Some(rich.attractor_particles_live_ceiling),
1130 "a window at 1080p is held at the live ceiling"
1131 );
1132
1133 // Deposits per output pixel, stated as sample arithmetic (an image
1134 // statistic would be the wrong instrument - the ones this repo has are
1135 // themselves resolution-bound). The render reaches the 640x360
1136 // reference density exactly; today's flat count delivers a ninth of it.
1137 const PX: u32 = 1920 * 1080;
1138 let render = 1_350_000_f64 / f64::from(PX);
1139 let reference =
1140 f64::from(rich.attractor_particles) / f64::from(crate::render::REFERENCE_PX);
1141 assert!(
1142 (render - reference).abs() < 1e-9,
1143 "a 1080p render deposits {render} samples per pixel against the reference {reference}"
1144 );
1145 assert_eq!(1_350_000 / rich.attractor_particles, 9);
1146
1147 // And the small captures are untouched at BOTH ceilings, which is the
1148 // half that keeps every baseline where it is.
1149 for tier in [TierConfig::FLOOR, TierConfig::RICH] {
1150 for budget in [SampleBudget::Live, SampleBudget::Offline] {
1151 assert_eq!(
1152 resolved(budget, &tier, 128, 128),
1153 Some(tier.attractor_particles),
1154 "{:?}/{budget:?} moved the golden suite's count",
1155 tier.tier
1156 );
1157 }
1158 }
1159 }
1160
1161 /// Every `SystemKind::ALL` entry builds the scene that kind is supposed to
1162 /// drive, and the roster covers exactly the roster — so transposing two
1163 /// factory arms — which silently points every preset of one system at
1164 /// another's scene — fails here.
1165 ///
1166 /// Needs a GPU adapter to build the scenes, so it skips on runners without
1167 /// one (ADR-0016).
1168 #[test]
1169 fn every_kind_builds_the_scene_it_drives() {
1170 let ctx = match RenderContext::new_headless(64, 64, true) {
1171 Ok(ctx) => ctx,
1172 Err(RenderError::RequestAdapter(_)) => {
1173 eprintln!("skipped: no GPU adapter on this runner (ADR-0016)");
1174 return;
1175 }
1176 Err(e) => panic!("headless context build failed: {e}"),
1177 };
1178
1179 let scenes = create_all(
1180 &ctx.device,
1181 ctx.surface_format(),
1182 &crate::render::TierConfig::FLOOR,
1183 crate::render::SampleBudget::Live,
1184 );
1185
1186 let kinds: Vec<SystemKind> = scenes.iter().map(|(kind, _)| *kind).collect();
1187 assert_eq!(
1188 kinds,
1189 SystemKind::ALL.to_vec(),
1190 "the roster is exactly SystemKind::ALL, in its order"
1191 );
1192
1193 for (kind, scene) in &scenes {
1194 assert_eq!(
1195 scene.name(),
1196 expected_scene_name(*kind),
1197 "system {} must drive its own scene",
1198 kind.as_str()
1199 );
1200 }
1201 }
1202
1203 /// The **freeze veto** a dual-live dissolve rests on (Plan 0023 Phase 4): a
1204 /// pair of systems that would have to render one mutable object twice in a
1205 /// frame must report shared resources, so the governor never upgrades it.
1206 ///
1207 /// GPU-free — this is the mapping, not the rendering. It closes the half the
1208 /// governor's own test has to assume: `dual_live_eligible` is asserted to
1209 /// refuse a shared pair, and here is what makes a pair shared.
1210 #[test]
1211 fn a_pair_that_cannot_render_twice_reports_shared_resources() {
1212 // Same kind is the same `Box<dyn Scene>` — the same-scene case that must
1213 // always freeze, whatever the frame budget says.
1214 for kind in SystemKind::ALL {
1215 assert!(
1216 super::shares_resources(kind, kind),
1217 "{} against itself is one scene object",
1218 kind.as_str()
1219 );
1220 }
1221
1222 // Two *different* line systems are just as unrenderable together: they
1223 // borrow one shared `LineRenderer` (see `create_all`).
1224 let lines = [
1225 SystemKind::ParametricCurve,
1226 SystemKind::LSystem,
1227 SystemKind::StarPattern,
1228 SystemKind::Spectrum,
1229 ];
1230 for a in lines {
1231 for b in lines {
1232 assert!(
1233 super::shares_resources(a, b),
1234 "{} and {} share the line renderer",
1235 a.as_str(),
1236 b.as_str()
1237 );
1238 }
1239 }
1240
1241 // Everything else holds independent state, so a dissolve between them may
1242 // run both sides live.
1243 let independent = [
1244 SystemKind::FragmentField,
1245 SystemKind::Swarm,
1246 SystemKind::ReactionDiffusion,
1247 SystemKind::Attractor,
1248 SystemKind::Emitter,
1249 SystemKind::ShapeField,
1250 SystemKind::WarpMesh,
1251 SystemKind::ShapeCollage,
1252 ];
1253 for (i, a) in independent.iter().enumerate() {
1254 for b in independent.iter().skip(i + 1).chain(lines.iter()) {
1255 assert!(
1256 !super::shares_resources(*a, *b),
1257 "{} and {} hold independent GPU state",
1258 a.as_str(),
1259 b.as_str()
1260 );
1261 }
1262 }
1263 }
1264}