Skip to main content

rlx_core/render/scenes/cellular/
mod.rs

1//! The cellular system: a discrete cellular automaton stepped on a ping-pong
2//! grid (ADR-0012), with every rule space it runs as a named **family**
3//! selected by `[cellular] family` (ADR-0180 rule 1).
4//!
5//! `life_like` is the birth/survival family: a dead cell with `k` live
6//! neighbours is born when bit `k` of `birth` is set, and a live one survives
7//! when bit `k` of `survive` is. Conway's Life is `birth = 8`, `survive = 12`.
8//!
9//! `larger_than_life` widens the neighbourhood to a box of side
10//! `2 * radius + 1` and replaces the masks with two intervals over the box's
11//! filled fraction: a dead cell is born inside `birth_lo..=birth_hi`, a live one
12//! survives inside `survive_lo..=survive_hi`. The fractions are turned into
13//! whole counts on the CPU (`interval_counts`), so the shader compares
14//! integers. The box is summed **separated** — a row pass, then a column sum in
15//! the step pass — at `2 * (2r + 1)` reads a cell rather than `(2r + 1)^2`, and
16//! `radius` is capped by the tier on top of that
17//! ([`TierConfig::cellular_radius`](crate::render::TierConfig::cellular_radius)).
18//!
19//! `cyclic` is the cyclic automaton: a cell holds one of `states` colours and
20//! advances to the next round the cycle when at least `threshold` of its eight
21//! neighbours already hold it. There is no dead state, so every cell is lit and
22//! its palette coordinate is its state index over `states`, with no remap.
23//!
24//! # The grid is content, not a resolution
25//!
26//! `[cellular] grid` is how many cells a side holds, and a pattern is a fixed
27//! number of cells — a glider is five — so the grid decides how large every
28//! pattern looks. It is declared by the preset and never follows the window.
29//! The tier caps it ([`TierConfig::cellular_grid`](crate::render::TierConfig::cellular_grid)),
30//! and because the cap changes content rather than density, a grid clamped to
31//! it is announced — returned from `configure` as a
32//! [`CapOverflow`](super::CapOverflow) — never silently reduced. A bound
33//! `radius` past the tier's cap is announced the same way, per frame, through
34//! `Scene::mirror_overflow`.
35//! The present is a plain normalized stretch of the grid over the target and
36//! computes no screen-destined geometry, so there is no aspect for it to take
37//! from the wrong place (ADR-0037).
38//!
39//! # Generations, not frames
40//!
41//! `step_rate` is in generations per second. `GenerationClock` integrates it
42//! over the injected `dt` and hands `render` a whole number of generations to
43//! run, so the automaton advances by the same count in the same wall time at
44//! any refresh rate. Because an automaton is path-dependent, two runs at
45//! different rates diverge for good — which is the meaning of the parameter,
46//! not a defect of it.
47//!
48//! # Every cell is drawn from the seed
49//!
50//! The field is seeded, and every `reseed` refills a disc of it, from an
51//! integer hash of the cell's coordinates and a seed derived from the preset's
52//! pinned salt (ADR-0051) — never from a clock. The hash is `u32` arithmetic,
53//! exact on every adapter, and each rule step is integer counting over exact
54//! texel values, so a field is a pure function of its config and the sequence
55//! of bound values and `dt`s it was driven with.
56//!
57//! # The age channel
58//!
59//! A binary field reads as noise; what reads as structure is its history. So
60//! the state texture's second channel counts the generations since each cell
61//! last changed state, and the present paints a dead cell by it: it fades out
62//! over `trail` generations while sliding `age_tint` of the way along the
63//! palette. `trail = 0` is the binary field exactly.
64//!
65//! **GPU resources are built lazily, on first render**, as the
66//! reaction-diffusion scene's are and for its reason: a capture that never
67//! activates this scene never builds them, so the software adapter the golden
68//! suite captures on never holds them beside another scene's.
69
70// Hot-path panic-denial pragma (Plan 0002 Phase 2, extended to scenes by Plan
71// 0003 Phase 0). Encodes its passes every displayed frame.
72#![deny(
73    clippy::unwrap_used,
74    clippy::expect_used,
75    clippy::indexing_slicing,
76    clippy::panic,
77    clippy::unreachable
78)]
79
80mod route;
81mod shader;
82
83use super::common;
84use super::lines::GeneratorConfig;
85use super::{FamilyParam, FamilyRange, Scene, SeededRng};
86use crate::dsp::AnalysisFrame;
87use crate::render::feedback::PingPongField;
88use crate::render::gpu;
89use crate::render::palette::{self, Palette};
90use crate::render::scenes::{ParamGroup, ParamKind, ParamSpec, default_of};
91
92/// Which rule space the automaton runs (ADR-0180 rule 1).
93#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
94pub enum CellularFamily {
95    /// Birth and survival as two bitmasks over the eight-neighbour count —
96    /// Conway's Life and every rule in its space.
97    #[default]
98    LifeLike,
99    /// Birth and survival as two intervals over the filled fraction of a box of
100    /// side `2 * radius + 1` — Evans's Larger than Life, whose rules carry
101    /// travelling bugs and blobs where a radius-1 rule settles.
102    LargerThanLife,
103    /// The cyclic automaton: `states` colours in a cycle, a cell advancing to
104    /// the next when at least `threshold` of its eight neighbours hold it.
105    /// From noise it organizes into interlocking rotating spirals.
106    Cyclic,
107}
108
109impl CellularFamily {
110    /// Every family, in the order the step shader's family index numbers them
111    /// and the generated reference lists them.
112    pub const ALL: [CellularFamily; 3] = [
113        CellularFamily::LifeLike,
114        CellularFamily::LargerThanLife,
115        CellularFamily::Cyclic,
116    ];
117
118    /// Parse the canonical `[cellular] family = "..."` value, or `None`.
119    pub fn from_name(name: &str) -> Option<Self> {
120        Self::ALL.into_iter().find(|family| family.as_str() == name)
121    }
122
123    /// The canonical name — [`from_name`](Self::from_name)'s inverse.
124    pub fn as_str(self) -> &'static str {
125        match self {
126            CellularFamily::LifeLike => "life_like",
127            CellularFamily::LargerThanLife => "larger_than_life",
128            CellularFamily::Cyclic => "cyclic",
129        }
130    }
131
132    /// The integer the step shader's family `switch` selects on: its position
133    /// in [`ALL`](Self::ALL).
134    fn index(self) -> u32 {
135        match self {
136            CellularFamily::LifeLike => 0,
137            CellularFamily::LargerThanLife => 1,
138            CellularFamily::Cyclic => 2,
139        }
140    }
141
142    /// The fraction of cells a seed makes live. A sparse soup dies out under a
143    /// larger neighbourhood's thresholds, so that family seeds denser. Unread
144    /// for `cyclic`, whose seed is uniform over its colours.
145    fn density(self) -> f32 {
146        match self {
147            CellularFamily::LifeLike | CellularFamily::Cyclic => LIFE_DENSITY,
148            CellularFamily::LargerThanLife => LTL_DENSITY,
149        }
150    }
151
152    /// Whether a generation needs the row pass first.
153    fn sums_rows(self) -> bool {
154        match self {
155            CellularFamily::LifeLike | CellularFamily::Cyclic => false,
156            CellularFamily::LargerThanLife => true,
157        }
158    }
159}
160
161/// `[cellular]` — the structural configuration, fixed while the preset is
162/// loaded and delivered through `Scene::configure`.
163#[derive(Debug, Clone, Copy, PartialEq, Eq)]
164pub struct CellularConfig {
165    /// Which automaton runs.
166    pub family: CellularFamily,
167    /// Cells per side of the square grid. A content value — see the module
168    /// docs — validated at load into [`MIN_GRID`]`..=`[`MAX_GRID`].
169    pub grid: u32,
170    /// `true`: the grid is a torus. `false`: every cell past the border is
171    /// dead, and the present leaves the frame outside the grid empty.
172    pub wrap: bool,
173    /// The preset's pinned salt (ADR-0051), which every seeded cell is drawn
174    /// from.
175    pub salt: u32,
176}
177
178impl Default for CellularConfig {
179    fn default() -> Self {
180        Self {
181            family: CellularFamily::default(),
182            grid: DEFAULT_GRID,
183            wrap: true,
184            salt: 0,
185        }
186    }
187}
188
189/// The grid an absent `[cellular] grid` means: `reaction_diffusion`'s own 256,
190/// for the same reason that scene pins it — pattern scale is content.
191pub const DEFAULT_GRID: u32 = 256;
192/// The smallest grid the loader accepts. Below it a glider's wake meets its own
193/// head on a torus before it has read as motion.
194pub const MIN_GRID: u32 = 16;
195/// The largest grid the loader accepts: a million cells, two 8-byte texels each
196/// across the ping-pong pair, 16 MB.
197pub const MAX_GRID: u32 = 1024;
198
199/// The largest rule bitmask: one bit for each neighbour count `0..=8`.
200pub const MAX_RULE: f32 = 511.0;
201
202/// The fastest `step_rate` the clock integrates, in generations per second.
203/// Well past the declared range's top, so it bounds a runaway binding rather
204/// than an author.
205pub const MAX_STEP_RATE: f32 = 240.0;
206
207pub use common::MAX_GENERATIONS_PER_FRAME;
208use common::WHOLE_SLACK;
209
210/// `reseed` rises past this to refill one disc — edge-triggered, so a value held
211/// high refills once, not once per frame.
212const RESEED_THRESHOLD: f32 = 0.5;
213/// The refilled disc's radius, as a fraction of the grid's side.
214const RESEED_RADIUS: f32 = 0.2;
215
216/// The fraction of cells a `life_like` seed makes live.
217const LIFE_DENSITY: f32 = 0.35;
218/// The fraction of cells a `larger_than_life` seed makes live.
219const LTL_DENSITY: f32 = 0.5;
220
221/// The largest `radius` any tier lets a `larger_than_life` preset ask for —
222/// the top of its declared range, and the constant the row and column loops run
223/// to.
224pub const MAX_RADIUS: f32 = 10.0;
225
226/// The most colours a `cyclic` cycle may hold. A half float holds every state
227/// index exactly far past this; the bound is where a cycle stops reading as a
228/// cycle across a 256-texel palette.
229pub const MAX_STATES: f32 = 24.0;
230/// The most neighbours a `cyclic` threshold can ask for: all eight.
231pub const MAX_THRESHOLD: f32 = 8.0;
232
233/// How much slack an interval bound gets when it is turned into a whole count,
234/// so a bound written as an exact fraction — `3/8` at radius 1 — keeps the
235/// count it names on both ends despite `f32` rounding.
236const INTERVAL_SLACK: f32 = 1e-4;
237
238/// Where the age channel stops counting: generations since a cell last
239/// changed, saturating here. A half float holds every whole number to 2048
240/// exactly, so the count stays exact below this, and it is past any `trail`
241/// the scene reads, so a saturated cell is simply "long ago".
242pub(crate) const AGE_CAP: f32 = 1023.0;
243/// The longest `trail` the present reads, in generations: the age channel's
244/// own ceiling, since a wake cannot outlast the count it is read from.
245pub const MAX_TRAIL: f32 = AGE_CAP;
246
247/// Mixed into the preset's salt before it seeds the field, so a salt of `0`
248/// still hashes to a field rather than to the hash's fixed point. The ASCII
249/// bytes of "CELL"; opaque, since changing it moves every seeded field.
250const FIELD_SEED_MIX: u32 = 0x4345_4C4C;
251/// Mixed into the salt for the stream of reseed discs, so the discs are not
252/// drawn from the same sequence as the field. The ASCII bytes of "RSEEDCL1".
253const STAMP_SEED_MIX: u64 = 0x5253_4545_4443_4C31;
254
255const DEFAULT_BIRTH: f32 = default_of(PARAMS, "birth");
256const DEFAULT_SURVIVE: f32 = default_of(PARAMS, "survive");
257const DEFAULT_STEP_RATE: f32 = default_of(PARAMS, "step_rate");
258const DEFAULT_TRAIL: f32 = default_of(PARAMS, "trail");
259const DEFAULT_RADIUS: f32 = default_of(PARAMS, "radius");
260const DEFAULT_BIRTH_LO: f32 = default_of(PARAMS, "birth_lo");
261const DEFAULT_BIRTH_HI: f32 = default_of(PARAMS, "birth_hi");
262const DEFAULT_SURVIVE_LO: f32 = default_of(PARAMS, "survive_lo");
263const DEFAULT_SURVIVE_HI: f32 = default_of(PARAMS, "survive_hi");
264const DEFAULT_STATES: f32 = default_of(PARAMS, "states");
265const DEFAULT_THRESHOLD: f32 = default_of(PARAMS, "threshold");
266const DEFAULT_AGE_TINT: f32 = default_of(PARAMS, "age_tint");
267const DEFAULT_ROUTE: f32 = default_of(PARAMS, "route");
268const DEFAULT_ROUTE_COORD: f32 = default_of(PARAMS, "route_coord");
269const DEFAULT_ROUTE_GRADE: f32 = default_of(PARAMS, "route_grade");
270const DEFAULT_ROUTE_REVEAL: f32 = default_of(PARAMS, "route_reveal");
271const DEFAULT_HUE: f32 = 0.0;
272const DEFAULT_ZOOM: f32 = 1.0;
273
274/// The seed the whole field is hashed from, for a preset whose salt is `salt`.
275pub(crate) fn field_seed(salt: u32) -> u32 {
276    salt ^ FIELD_SEED_MIX
277}
278
279/// A density as the step shader compares it: the fraction of the 24-bit hash
280/// range under which a cell is seeded live.
281pub(crate) fn live_threshold(density: f32) -> u32 {
282    (density.clamp(0.0, 1.0) * 16_777_216.0) as u32
283}
284
285/// A bound rule bitmask as the step shader reads it: clamped into
286/// `0..=`[`MAX_RULE`] and rounded, so every neighbour count names one bit. A
287/// non-finite value falls back to the declared default.
288pub(crate) fn applied_rule(value: f32, default: f32) -> u32 {
289    let v = if value.is_finite() { value } else { default };
290    v.clamp(0.0, MAX_RULE).round() as u32
291}
292
293/// A bound `step_rate` as the clock integrates it: clamped into
294/// `0..=`[`MAX_STEP_RATE`], the declared default where it is not finite.
295pub(crate) fn applied_step_rate(value: f32) -> f32 {
296    if value.is_finite() {
297        value.clamp(0.0, MAX_STEP_RATE)
298    } else {
299        DEFAULT_STEP_RATE
300    }
301}
302
303/// A bound `trail` as the present reads it: clamped into `0..=`[`MAX_TRAIL`],
304/// the declared default where it is not finite. The shader divides by
305/// `trail + 1`, which this keeps at least 1.
306pub(crate) fn applied_trail(value: f32) -> f32 {
307    if value.is_finite() {
308        value.clamp(0.0, MAX_TRAIL)
309    } else {
310        DEFAULT_TRAIL
311    }
312}
313
314/// A bound `radius` as the step shader reads it: clamped into `1..=cap` and
315/// rounded, where `cap` is the tier's
316/// [`cellular_radius`](crate::render::TierConfig::cellular_radius) held inside
317/// [`MAX_RADIUS`]. A non-finite value falls back to the declared default,
318/// itself capped.
319pub(crate) fn applied_radius(value: f32, cap: f32) -> u32 {
320    let cap = cap.clamp(1.0, MAX_RADIUS);
321    let v = if value.is_finite() {
322        value
323    } else {
324        DEFAULT_RADIUS
325    };
326    v.clamp(1.0, cap).round() as u32
327}
328
329/// The clamp to report for a bound radius of `value` under `cap`, or `None`
330/// when the preset asked within it — or runs a family that reads no radius,
331/// where there is nothing to clamp. `value` is compared as the shader would
332/// take it before the cap, so a bound 6.4 under a cap of 6 is not a clamp.
333pub(crate) fn radius_clamp(
334    family: CellularFamily,
335    value: f32,
336    cap: f32,
337) -> Option<super::CapOverflow> {
338    if family != CellularFamily::LargerThanLife {
339        return None;
340    }
341    let asked = applied_radius(value, MAX_RADIUS);
342    let applied = applied_radius(value, cap);
343    (asked > applied).then_some(super::CapOverflow {
344        dropped: (asked - applied) as usize,
345        context: super::OverflowContext::Radius(asked),
346        cap: applied as usize,
347    })
348}
349
350/// `config` with its grid held to `cap`, and the clamp to report if that moved
351/// it. A grid is structural, so this runs once, at `configure`.
352pub(crate) fn grid_clamp(
353    config: CellularConfig,
354    cap: u32,
355) -> (CellularConfig, Option<super::CapOverflow>) {
356    if config.grid <= cap {
357        return (config, None);
358    }
359    let overflow = super::CapOverflow {
360        dropped: (config.grid - cap) as usize,
361        context: super::OverflowContext::Grid(config.grid),
362        cap: cap as usize,
363    };
364    (
365        CellularConfig {
366            grid: cap,
367            ..config
368        },
369        Some(overflow),
370    )
371}
372
373/// A bound `states` as the step and present passes read it: clamped into
374/// `2..=`[`MAX_STATES`] and rounded — a cycle of one colour never advances,
375/// and a fractional count names no cycle. A non-finite value falls back to the
376/// declared default.
377pub(crate) fn applied_states(value: f32) -> u32 {
378    let v = if value.is_finite() {
379        value
380    } else {
381        DEFAULT_STATES
382    };
383    v.clamp(2.0, MAX_STATES).round() as u32
384}
385
386/// A bound `threshold` as the step pass reads it: clamped into
387/// `1..=`[`MAX_THRESHOLD`] and rounded. A non-finite value falls back to the
388/// declared default.
389pub(crate) fn applied_threshold(value: f32) -> u32 {
390    let v = if value.is_finite() {
391        value
392    } else {
393        DEFAULT_THRESHOLD
394    };
395    v.clamp(1.0, MAX_THRESHOLD).round() as u32
396}
397
398/// How many cells the box of `radius` holds besides its centre: the
399/// denominator of every `larger_than_life` fraction.
400pub(crate) fn neighbourhood(radius: u32) -> u32 {
401    let side = 2 * radius + 1;
402    side * side - 1
403}
404
405/// A `larger_than_life` interval of filled fractions `[lo, hi]` as the whole
406/// neighbour counts it admits, `[first, last]` inclusive, over a box of
407/// `radius`. An empty interval — `hi` below `lo`, or one that holds no whole
408/// count — comes out with `first > last`, which the shader's comparison admits
409/// nothing through. Non-finite bounds fall back to `fallback`.
410pub(crate) fn interval_counts(lo: f32, hi: f32, radius: u32, fallback: [f32; 2]) -> [u32; 2] {
411    let n = neighbourhood(radius) as f32;
412    let lo = if lo.is_finite() { lo } else { fallback[0] };
413    let hi = if hi.is_finite() { hi } else { fallback[1] };
414    let first = (lo.clamp(0.0, 1.0) * n - INTERVAL_SLACK).ceil().max(0.0);
415    let last = (hi.clamp(0.0, 1.0) * n + INTERVAL_SLACK).floor();
416    if last < first {
417        // `u32::MAX` then `0`: no count is both at least one and at most the
418        // other.
419        return [u32::MAX, 0];
420    }
421    [first as u32, last as u32]
422}
423
424/// The shared [`common::GenerationClock`], fed this system's applied
425/// `step_rate`: a bound value is clamped into `0..=`[`MAX_STEP_RATE`], and a
426/// non-finite one runs the declared default.
427#[derive(Debug, Clone, Copy, Default, PartialEq)]
428pub(crate) struct GenerationClock(common::GenerationClock);
429
430impl GenerationClock {
431    /// Add `dt` seconds at the bound `rate` and return how many whole
432    /// generations to run now — see [`common::GenerationClock::advance`].
433    pub(crate) fn advance(&mut self, rate: f32, dt: f32) -> u32 {
434        self.0.advance(applied_step_rate(rate), dt)
435    }
436
437    /// Generations owed and not yet run, in `[0, 1)` between calls.
438    fn owed(&self) -> f64 {
439        self.0.owed
440    }
441}
442
443/// One scheduled `reseed`: the disc's centre in cells, its radius squared in
444/// cells², and the seed its cells are hashed from.
445#[derive(Debug, Clone, Copy, PartialEq, Eq)]
446pub(crate) struct Stamp {
447    pub(crate) centre: [u32; 2],
448    pub(crate) radius_sq: u32,
449    pub(crate) seed: u32,
450}
451
452/// The stream a preset's reseed discs are drawn from.
453fn stamp_rng(salt: u32) -> SeededRng {
454    SeededRng::new(u64::from(salt) ^ STAMP_SEED_MIX)
455}
456
457/// Draw the next disc for a grid of `grid` cells a side.
458fn next_stamp(rng: &mut SeededRng, grid: u32) -> Stamp {
459    let side = grid.max(1);
460    let cell = |u: f32| ((u * side as f32) as u32).min(side - 1);
461    let x = cell(rng.next_f32());
462    let y = cell(rng.next_f32());
463    let seed = (rng.next_f32() * 16_777_216.0) as u32;
464    let radius = RESEED_RADIUS * side as f32;
465    Stamp {
466        centre: [x, y],
467        radius_sq: (radius * radius) as u32,
468        seed,
469    }
470}
471
472/// The parameter names this scene consumes — the vocabulary a preset binding is
473/// checked against at load (ADR-0020). **Keep in sync with `set_param` below**;
474/// `declared_params_match_set_param` in `core/tests/suite/preset.rs` fails if the two
475/// drift.
476pub const PARAMS: &[ParamSpec] = &[
477    ParamSpec {
478        name: "birth",
479        default: 8.0,
480        range: Some([0.0, MAX_RULE]),
481        doc: "Which live-neighbour counts bring a dead cell to life, as a bitmask over the \
482              counts 0-8: bit k set means k neighbours give birth. 8 (bit 3) is Conway's.",
483        kind: ParamKind::Structural,
484        group: ParamGroup::Shape,
485        main: true,
486    },
487    ParamSpec {
488        name: "survive",
489        default: 12.0,
490        range: Some([0.0, MAX_RULE]),
491        doc: "Which live-neighbour counts keep a live cell alive, as a bitmask over the counts \
492              0-8. 12 (bits 2 and 3) is Conway's.",
493        kind: ParamKind::Structural,
494        group: ParamGroup::Shape,
495        main: true,
496    },
497    ParamSpec {
498        name: "radius",
499        default: 5.0,
500        range: Some([1.0, MAX_RADIUS]),
501        doc: "How far the neighbourhood reaches, in cells: a square of side 2 x radius + 1 \
502              about each cell. Capped by the quality tier.",
503        kind: ParamKind::Structural,
504        group: ParamGroup::Shape,
505        main: false,
506    },
507    ParamSpec {
508        name: "birth_lo",
509        default: 0.28,
510        range: Some([0.0, 1.0]),
511        doc: "The least filled fraction of its neighbourhood at which a dead cell is born.",
512        kind: ParamKind::Modal,
513        group: ParamGroup::Shape,
514        main: false,
515    },
516    ParamSpec {
517        name: "birth_hi",
518        default: 0.385,
519        range: Some([0.0, 1.0]),
520        doc: "The most filled fraction of its neighbourhood at which a dead cell is born.",
521        kind: ParamKind::Modal,
522        group: ParamGroup::Shape,
523        main: false,
524    },
525    ParamSpec {
526        name: "survive_lo",
527        default: 0.26,
528        range: Some([0.0, 1.0]),
529        doc: "The least filled fraction of its neighbourhood at which a live cell survives.",
530        kind: ParamKind::Modal,
531        group: ParamGroup::Shape,
532        main: false,
533    },
534    ParamSpec {
535        name: "survive_hi",
536        default: 0.47,
537        range: Some([0.0, 1.0]),
538        doc: "The most filled fraction of its neighbourhood at which a live cell survives.",
539        kind: ParamKind::Modal,
540        group: ParamGroup::Shape,
541        main: false,
542    },
543    ParamSpec {
544        name: "states",
545        default: 3.0,
546        range: Some([2.0, MAX_STATES]),
547        doc: "How many colours the cycle holds; each cell advances to the next one round it.",
548        kind: ParamKind::Structural,
549        group: ParamGroup::Shape,
550        main: false,
551    },
552    ParamSpec {
553        name: "threshold",
554        default: 3.0,
555        range: Some([1.0, MAX_THRESHOLD]),
556        doc: "How many of its eight neighbours must already hold the next colour before a cell \
557              advances to it.",
558        kind: ParamKind::Structural,
559        group: ParamGroup::Shape,
560        main: false,
561    },
562    ParamSpec {
563        name: "step_rate",
564        default: 10.0,
565        range: Some([0.0, 60.0]),
566        doc: "How many generations the automaton runs per second, whatever the frame rate; 0 \
567              freezes it.",
568        kind: ParamKind::Modal,
569        group: ParamGroup::Motion,
570        main: true,
571    },
572    ParamSpec {
573        name: "reseed",
574        default: 0.0,
575        range: Some([0.0, 1.0]),
576        doc: "A rise past 0.5 refills one disc of the grid with fresh seeded cells, once per \
577              rise; bind a beat or a latch to it.",
578        kind: ParamKind::Modal,
579        group: ParamGroup::Motion,
580        main: false,
581    },
582    ParamSpec {
583        name: "trail",
584        default: 12.0,
585        range: Some([0.0, 64.0]),
586        doc: "How many generations a dead cell keeps glowing, fading as it goes; 0 draws only \
587              the live cells.",
588        kind: ParamKind::Modal,
589        group: ParamGroup::Motion,
590        main: false,
591    },
592    ParamSpec {
593        name: "age_tint",
594        default: 0.35,
595        range: Some([0.0, 1.0]),
596        doc: "How far along the palette a dead cell's glow travels as it fades; 0 keeps the \
597              wake the live cells' colour.",
598        kind: ParamKind::Modal,
599        group: ParamGroup::Colour,
600        main: false,
601    },
602    ParamSpec {
603        name: "route",
604        default: 0.0,
605        range: Some([0.0, 1.0]),
606        doc: "How strongly the route through the maze is drawn over it: the longest path the \
607              open cells hold, found while the maze stands still. 0 draws none and runs no \
608              search; inert on cyclic, which has no open cells.",
609        kind: ParamKind::Modal,
610        group: ParamGroup::Colour,
611        main: false,
612    },
613    ParamSpec {
614        name: "route_coord",
615        default: 0.5,
616        range: Some([0.0, 1.0]),
617        doc: "Where on the palette the route is painted, at its first end.",
618        kind: ParamKind::Modal,
619        group: ParamGroup::Colour,
620        main: false,
621    },
622    ParamSpec {
623        name: "route_grade",
624        default: 0.0,
625        range: Some([-1.0, 1.0]),
626        doc: "How far along the palette the route's colour travels from one end to the other; \
627              0 paints it one solid colour.",
628        kind: ParamKind::Modal,
629        group: ParamGroup::Colour,
630        main: false,
631    },
632    ParamSpec {
633        name: "route_reveal",
634        default: 1.0,
635        range: Some([0.0, route::MAX_REVEAL]),
636        doc: "Seconds a found route takes to draw in along its length, end to end; it fades \
637              out over a quarter of this when the maze moves. 0 draws it whole at once.",
638        kind: ParamKind::Modal,
639        group: ParamGroup::Motion,
640        main: false,
641    },
642    common::brightness(common::DEFAULT_BRIGHTNESS),
643    common::hue(DEFAULT_HUE),
644    common::zoom(DEFAULT_ZOOM),
645    common::PAN_X,
646    common::PAN_Y,
647    common::SATURATION,
648    common::PALETTE_MIX,
649    common::PALETTE_STEPS,
650    common::PALETTE_CONTOUR,
651    common::PALETTE_CONTOUR_STYLE,
652    common::PALETTE_CONTOUR_INK,
653];
654
655/// One row of [`FAMILY_PARAMS`], its ranges in [`CellularFamily::ALL`]'s
656/// order. `None` is a family that does not read the parameter.
657macro_rules! per_family {
658    ($name:literal: $life_like:expr, $larger_than_life:expr, $cyclic:expr $(,)?) => {
659        FamilyParam {
660            name: $name,
661            ranges: &[
662                FamilyRange {
663                    family: "life_like",
664                    range: $life_like,
665                },
666                FamilyRange {
667                    family: "larger_than_life",
668                    range: $larger_than_life,
669                },
670                FamilyRange {
671                    family: "cyclic",
672                    range: $cyclic,
673                },
674            ],
675        }
676    };
677}
678
679/// Every parameter only some families read (ADR-0180 rule 4), with the range
680/// that reads there — so the generated reference prints `birth` as
681/// `life_like`'s and inert elsewhere. A parameter missing from here reads the
682/// same on every family.
683pub const FAMILY_PARAMS: &[FamilyParam] = &[
684    per_family!("birth": Some([0.0, MAX_RULE]), None, None),
685    per_family!("survive": Some([0.0, MAX_RULE]), None, None),
686    per_family!("radius": None, Some([1.0, MAX_RADIUS]), None),
687    per_family!("birth_lo": None, Some([0.0, 1.0]), None),
688    per_family!("birth_hi": None, Some([0.0, 1.0]), None),
689    per_family!("survive_lo": None, Some([0.0, 1.0]), None),
690    per_family!("survive_hi": None, Some([0.0, 1.0]), None),
691    per_family!("states": None, None, Some([2.0, MAX_STATES])),
692    per_family!("threshold": None, None, Some([1.0, MAX_THRESHOLD])),
693    // Every cyclic cell is lit and coloured by its state, so the wake reads
694    // nothing there.
695    per_family!("trail": Some([0.0, 64.0]), Some([0.0, 64.0]), None),
696    per_family!("age_tint": Some([0.0, 1.0]), Some([0.0, 1.0]), None),
697    // A route walks the dead cells, and cyclic has none.
698    per_family!("route": Some([0.0, 1.0]), Some([0.0, 1.0]), None),
699    per_family!("route_coord": Some([0.0, 1.0]), Some([0.0, 1.0]), None),
700    per_family!("route_grade": Some([-1.0, 1.0]), Some([-1.0, 1.0]), None),
701    per_family!(
702        "route_reveal": Some([0.0, route::MAX_REVEAL]),
703        Some([0.0, route::MAX_REVEAL]),
704        None,
705    ),
706];
707
708/// The one uniform every step-shader pass reads, written once a frame.
709///
710/// **One buffer for all three passes, and that is load-bearing.** Which pass is
711/// running is a constant compiled into its pipeline ([`StepPass`]), never a
712/// field here: on the DX12 WARP software adapter, bind groups built on one
713/// layout that differ only in their uniform buffer are not told apart, and a
714/// seed pass bound to a uniform of its own read the step pass's instead. With
715/// one buffer and one texture pair, every pass is bound to identical resources,
716/// so there is nothing to confuse. (A WARP reading, unverified on lavapipe as
717/// of 2026-10-06.)
718#[repr(C)]
719#[derive(Clone, Copy, bytemuck::Pod, bytemuck::Zeroable)]
720struct StepParams {
721    /// x: family index, y: wrap (1 torus), z: grid (cells per side), w: live
722    /// threshold against the hash's top 24 bits.
723    a: [u32; 4],
724    /// x: birth mask, y: survive mask, z: radius (cells), w: cyclic's states.
725    b: [u32; 4],
726    /// x: the field's seed, y: the stamp's seed, z: stamp radius squared
727    /// (cells²), w: unused.
728    c: [u32; 4],
729    /// xy: stamp centre (cells), z: cyclic's threshold, w: unused.
730    d: [u32; 4],
731    /// `larger_than_life`'s two intervals as whole neighbour counts,
732    /// inclusive: xy births a dead cell, zw keeps a live one.
733    e: [u32; 4],
734}
735
736#[repr(C)]
737#[derive(Clone, Copy, bytemuck::Pod, bytemuck::Zeroable)]
738struct PresentParams {
739    /// x: hue, y: brightness, z: saturation, w: palette_mix.
740    a: [f32; 4],
741    /// x: palette_steps, y: palette_contour, z: occlude, w: grid.
742    b: [f32; 4],
743    /// x: zoom, yz: pan, w: wrap (1 torus).
744    c: [f32; 4],
745    /// x: trail (generations), y: age_tint, z: 1 for `cyclic`, w: its states.
746    d: [f32; 4],
747    /// x: palette_contour_style, y: palette_contour_ink (ADR-0197); zw unused.
748    e: [f32; 4],
749}
750
751/// The two bind groups of one pass over the ping-pong pair — reading texture A
752/// and reading texture B — so nothing is rebuilt on the hot path.
753struct ReadPair {
754    a: wgpu::BindGroup,
755    b: wgpu::BindGroup,
756}
757
758impl ReadPair {
759    fn for_field(&self, field: &PingPongField) -> &wgpu::BindGroup {
760        if field.reading_a() { &self.a } else { &self.b }
761    }
762}
763
764/// The GPU-side state, built lazily on first render (see the module docs) and
765/// rebuilt when a preset asks for a different grid.
766struct Resources {
767    /// The grid these textures were built for.
768    grid: u32,
769    field: PingPongField,
770    /// Each cell's live count along its row, written by the row pass and summed
771    /// down the column by the step pass — `larger_than_life`'s separated box.
772    /// Kept alive so its view stays valid, as `PingPongField` keeps its pair.
773    _rows: wgpu::Texture,
774    rows_view: wgpu::TextureView,
775    /// The step shader compiled once per [`StepPass`], all three over one
776    /// layout and bound to the same resources.
777    seed_pipeline: wgpu::RenderPipeline,
778    stamp_pipeline: wgpu::RenderPipeline,
779    step_pipeline: wgpu::RenderPipeline,
780    rows_pipeline: wgpu::RenderPipeline,
781    present_pipeline: wgpu::RenderPipeline,
782    step_uniform: wgpu::Buffer,
783    present_uniform: wgpu::Buffer,
784    step_bg: ReadPair,
785    rows_bg: ReadPair,
786    present_bg: ReadPair,
787    /// Kept so the route's present pipeline, built later, can bind this group
788    /// beside its own.
789    present_layout: wgpu::BindGroupLayout,
790    /// The shared gradient LUT pair (ADR-0021). A fresh pair is dirty, so a
791    /// (re)build uploads on its first frame.
792    luts: palette::LutPair,
793    /// The route's buffers and passes (ADR-0266), built on the first frame
794    /// `route > 0` and dropped with these textures, which its bind groups read.
795    route: Option<route::RouteResources>,
796}
797
798impl Resources {
799    fn build(device: &wgpu::Device, surface_format: wgpu::TextureFormat, grid: u32) -> Self {
800        let present_shader = gpu::fullscreen_shader(
801            device,
802            "cellular-present-shader",
803            gpu::FULLSCREEN_VS_UV_FLIPPED,
804            &format!("{}{}", shader::PRESENT_SHADER, shader::PRESENT_MAIN),
805        );
806
807        let field = PingPongField::new(device, grid, grid);
808
809        let step_uniform = gpu::uniform_buffer(
810            device,
811            "cellular-step-params",
812            std::mem::size_of::<StepParams>(),
813        );
814        let present_uniform = gpu::uniform_buffer(
815            device,
816            "cellular-present-params",
817            std::mem::size_of::<PresentParams>(),
818        );
819
820        let rows = device.create_texture(&wgpu::TextureDescriptor {
821            label: Some("cellular-rows"),
822            size: wgpu::Extent3d {
823                width: grid,
824                height: grid,
825                depth_or_array_layers: 1,
826            },
827            mip_level_count: 1,
828            sample_count: 1,
829            dimension: wgpu::TextureDimension::D2,
830            // The field's own format: a row count is a whole number no larger
831            // than `2 * MAX_RADIUS + 1`, which a half float holds exactly.
832            format: PingPongField::FORMAT,
833            // `COPY_SRC` only so a probe can read the counts back, as the
834            // field's own textures carry it.
835            usage: wgpu::TextureUsages::TEXTURE_BINDING
836                | wgpu::TextureUsages::RENDER_ATTACHMENT
837                | wgpu::TextureUsages::COPY_SRC,
838            view_formats: &[],
839        });
840        let rows_view = rows.create_view(&wgpu::TextureViewDescriptor::default());
841
842        // `[Texture, Texture, Uniform]` — the field, the row counts, the
843        // uniform — is a shape no other layout in the crate has (ADR-0058).
844        let step_layout = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
845            label: Some("cellular-step-layout"),
846            entries: &[
847                gpu::texture(0, false),
848                gpu::texture(1, false),
849                gpu::uniform(2, wgpu::ShaderStages::FRAGMENT),
850            ],
851        });
852        let step_bg = ReadPair {
853            a: step_bind_group(
854                device,
855                &step_layout,
856                field.view_a(),
857                &rows_view,
858                &step_uniform,
859            ),
860            b: step_bind_group(
861                device,
862                &step_layout,
863                field.view_b(),
864                &rows_view,
865                &step_uniform,
866            ),
867        };
868        // The row pass reads the field and the same uniform. `[Texture,
869        // Uniform]` is unique too; the reaction-diffusion sim binds the same
870        // two entries the other way round, so swapping these would collide.
871        let rows_layout = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
872            label: Some("cellular-rows-layout"),
873            entries: &[
874                gpu::texture(0, false),
875                gpu::uniform(1, wgpu::ShaderStages::FRAGMENT),
876            ],
877        });
878        let rows_bg = ReadPair {
879            a: rows_bind_group(device, &rows_layout, field.view_a(), &step_uniform),
880            b: rows_bind_group(device, &rows_layout, field.view_b(), &step_uniform),
881        };
882        let rows_shader = gpu::fullscreen_shader(
883            device,
884            "cellular-rows",
885            gpu::FULLSCREEN_VS_UV_FLIPPED,
886            &format!(
887                "{}{}{}",
888                radius_const(),
889                shader::STEP_COMMON,
890                shader::ROWS_SHADER
891            ),
892        );
893        let rows_pipeline = gpu::fullscreen_pipeline(
894            device,
895            &rows_shader,
896            &[&rows_layout],
897            PingPongField::FORMAT,
898            wgpu::BlendState::REPLACE,
899            "cellular-rows",
900        );
901        let step_pipeline_for = |pass: StepPass| {
902            let label = pass.label();
903            let shader = gpu::fullscreen_shader(
904                device,
905                label,
906                gpu::FULLSCREEN_VS_UV_FLIPPED,
907                &pass.source(),
908            );
909            gpu::fullscreen_pipeline(
910                device,
911                &shader,
912                &[&step_layout],
913                PingPongField::FORMAT,
914                wgpu::BlendState::REPLACE,
915                label,
916            )
917        };
918        let seed_pipeline = step_pipeline_for(StepPass::Seed);
919        let stamp_pipeline = step_pipeline_for(StepPass::Stamp);
920        let step_pipeline = step_pipeline_for(StepPass::Step);
921
922        let luts = palette::LutPair::new(device, "cellular");
923        // `[Texture, Texture, Texture, Sampler, Uniform]`: the field, the LUT
924        // pair and its sampler, then the uniform — unique in the crate
925        // (ADR-0058).
926        let present_layout = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
927            label: Some("cellular-present-layout"),
928            entries: &[
929                gpu::texture(0, false),
930                gpu::texture(1, true),
931                gpu::texture(2, true),
932                gpu::sampler(3),
933                gpu::uniform(4, wgpu::ShaderStages::FRAGMENT),
934            ],
935        });
936        let present_bg = ReadPair {
937            a: present_bind_group(
938                device,
939                &present_layout,
940                field.view_a(),
941                &luts,
942                &present_uniform,
943            ),
944            b: present_bind_group(
945                device,
946                &present_layout,
947                field.view_b(),
948                &luts,
949                &present_uniform,
950            ),
951        };
952        let present_pipeline = gpu::fullscreen_pipeline(
953            device,
954            &present_shader,
955            &[&present_layout],
956            surface_format,
957            // Premultiplied OVER the backdrop (ADR-0026): a dead cell emits no
958            // light and no coverage, so the `bg_*` backdrop shows through it.
959            wgpu::BlendState::PREMULTIPLIED_ALPHA_BLENDING,
960            "cellular-present",
961        );
962
963        Self {
964            grid,
965            field,
966            _rows: rows,
967            rows_view,
968            seed_pipeline,
969            stamp_pipeline,
970            step_pipeline,
971            rows_pipeline,
972            present_pipeline,
973            step_uniform,
974            present_uniform,
975            step_bg,
976            rows_bg,
977            present_bg,
978            present_layout,
979            luts,
980            route: None,
981        }
982    }
983
984    /// Encode one `pass` of the step shader: read the current field, write the
985    /// other texture, swap.
986    fn encode_step(&mut self, encoder: &mut wgpu::CommandEncoder, pass: StepPass) {
987        let pipeline = match pass {
988            StepPass::Seed => &self.seed_pipeline,
989            StepPass::Stamp => &self.stamp_pipeline,
990            StepPass::Step => &self.step_pipeline,
991        };
992        {
993            // The step pass writes every texel, so what it clears to is never
994            // read.
995            let mut rpass = gpu::color_pass(
996                encoder,
997                pass.label(),
998                self.field.write_view(),
999                wgpu::LoadOp::Clear(wgpu::Color::BLACK),
1000            );
1001            rpass.set_pipeline(pipeline);
1002            rpass.set_bind_group(0, self.step_bg.for_field(&self.field), &[]);
1003            rpass.draw(0..3, 0..1);
1004        }
1005        self.field.swap();
1006    }
1007
1008    /// Encode the row pass over the current field — the first half of a
1009    /// `larger_than_life` generation. Writes the row counts and leaves the
1010    /// field where it was.
1011    fn encode_rows(&self, encoder: &mut wgpu::CommandEncoder) {
1012        let mut rpass = gpu::color_pass(
1013            encoder,
1014            "cellular-rows",
1015            &self.rows_view,
1016            wgpu::LoadOp::Clear(wgpu::Color::BLACK),
1017        );
1018        rpass.set_pipeline(&self.rows_pipeline);
1019        rpass.set_bind_group(0, self.rows_bg.for_field(&self.field), &[]);
1020        rpass.draw(0..3, 0..1);
1021    }
1022}
1023
1024/// The `MAX_RADIUS` constant the row and step shaders' loops run to.
1025fn radius_const() -> String {
1026    format!("const MAX_RADIUS: i32 = {};\n", MAX_RADIUS as i32)
1027}
1028
1029/// What one pass of the step shader does: its `MODE` constant, compiled in.
1030#[derive(Clone, Copy)]
1031enum StepPass {
1032    /// Hash every cell from the field's seed.
1033    Seed,
1034    /// Hash the cells inside the scheduled disc from the stamp's seed.
1035    Stamp,
1036    /// Advance every cell one generation by the family's rule.
1037    Step,
1038}
1039
1040impl StepPass {
1041    /// The `MODE` the step shader's `switch` reads.
1042    fn mode(self) -> u32 {
1043        match self {
1044            StepPass::Step => 0,
1045            StepPass::Seed => 1,
1046            StepPass::Stamp => 2,
1047        }
1048    }
1049
1050    fn label(self) -> &'static str {
1051        match self {
1052            StepPass::Seed => "cellular-seed",
1053            StepPass::Stamp => "cellular-stamp",
1054            StepPass::Step => "cellular-step",
1055        }
1056    }
1057
1058    /// This pass's step shader after the vertex prelude: its constants, the
1059    /// hash, the shared block and the body. Built at construction only.
1060    fn source(self) -> String {
1061        format!(
1062            "const MODE: u32 = {}u;\nconst AGE_CAP: f32 = {:?};\n{}{}{}{}{}",
1063            self.mode(),
1064            AGE_CAP,
1065            radius_const(),
1066            gpu::HASH_WGSL,
1067            common::CELL_HASH_WGSL,
1068            shader::STEP_COMMON,
1069            shader::STEP_SHADER
1070        )
1071    }
1072}
1073
1074fn step_bind_group(
1075    device: &wgpu::Device,
1076    layout: &wgpu::BindGroupLayout,
1077    input: &wgpu::TextureView,
1078    rows: &wgpu::TextureView,
1079    uniform: &wgpu::Buffer,
1080) -> wgpu::BindGroup {
1081    device.create_bind_group(&wgpu::BindGroupDescriptor {
1082        label: Some("cellular-step-bg"),
1083        layout,
1084        entries: &[
1085            wgpu::BindGroupEntry {
1086                binding: 0,
1087                resource: wgpu::BindingResource::TextureView(input),
1088            },
1089            wgpu::BindGroupEntry {
1090                binding: 1,
1091                resource: wgpu::BindingResource::TextureView(rows),
1092            },
1093            wgpu::BindGroupEntry {
1094                binding: 2,
1095                resource: uniform.as_entire_binding(),
1096            },
1097        ],
1098    })
1099}
1100
1101fn rows_bind_group(
1102    device: &wgpu::Device,
1103    layout: &wgpu::BindGroupLayout,
1104    input: &wgpu::TextureView,
1105    uniform: &wgpu::Buffer,
1106) -> wgpu::BindGroup {
1107    device.create_bind_group(&wgpu::BindGroupDescriptor {
1108        label: Some("cellular-rows-bg"),
1109        layout,
1110        entries: &[
1111            wgpu::BindGroupEntry {
1112                binding: 0,
1113                resource: wgpu::BindingResource::TextureView(input),
1114            },
1115            wgpu::BindGroupEntry {
1116                binding: 1,
1117                resource: uniform.as_entire_binding(),
1118            },
1119        ],
1120    })
1121}
1122
1123fn present_bind_group(
1124    device: &wgpu::Device,
1125    layout: &wgpu::BindGroupLayout,
1126    input: &wgpu::TextureView,
1127    luts: &palette::LutPair,
1128    uniform: &wgpu::Buffer,
1129) -> wgpu::BindGroup {
1130    let [lut_a, lut_b, lut_sampler] = luts.bind_entries(1, 2, 3);
1131    device.create_bind_group(&wgpu::BindGroupDescriptor {
1132        label: Some("cellular-present-bg"),
1133        layout,
1134        entries: &[
1135            wgpu::BindGroupEntry {
1136                binding: 0,
1137                resource: wgpu::BindingResource::TextureView(input),
1138            },
1139            lut_a,
1140            lut_b,
1141            lut_sampler,
1142            wgpu::BindGroupEntry {
1143                binding: 4,
1144                resource: uniform.as_entire_binding(),
1145            },
1146        ],
1147    })
1148}
1149
1150/// A discrete cellular automaton on a ping-pong grid, driven by named preset
1151/// parameters and one `[cellular]` table.
1152pub struct CellularScene {
1153    /// Cloned device handle that builds [`Resources`] lazily on first render.
1154    device: wgpu::Device,
1155    surface_format: wgpu::TextureFormat,
1156    res: Option<Resources>,
1157    /// The `[cellular]` table of the preset last configured.
1158    config: CellularConfig,
1159    /// Whether the next `render` seeds the field before stepping it — set by a
1160    /// build and by every `configure`.
1161    needs_seed: bool,
1162    clock: GenerationClock,
1163    /// This frame's `dt`, stored by `advance` and integrated in `update`, where
1164    /// this frame's `step_rate` has landed.
1165    dt: f32,
1166    /// Generations `update` scheduled for the next `render` to encode.
1167    pending_generations: u32,
1168    /// The stream reseed discs are drawn from, reset by `configure` so a
1169    /// preset replays its discs identically.
1170    stamp_rng: SeededRng,
1171    /// A disc scheduled by a `reseed` rising edge for the next `render`.
1172    pending_stamp: Option<Stamp>,
1173    /// Last frame's `reseed`, for the rising edge.
1174    prev_reseed: f32,
1175    /// The tier's [`cellular_radius`](crate::render::TierConfig::cellular_radius):
1176    /// the widest neighbourhood a bound `radius` reaches the shader with.
1177    radius_cap: f32,
1178    /// The tier's [`cellular_grid`](crate::render::TierConfig::cellular_grid):
1179    /// the largest grid a preset runs on.
1180    grid_cap: u32,
1181    /// This frame's clamp of a bound `radius` to [`radius_cap`](Self::radius_cap),
1182    /// or `None` when the preset asked within it — read back through
1183    /// [`Scene::mirror_overflow`] so the frontend announces it. A `Copy` value
1184    /// rewritten each frame, so reporting it allocates nothing.
1185    clamp: Option<super::CapOverflow>,
1186    birth: f32,
1187    survive: f32,
1188    radius: f32,
1189    birth_lo: f32,
1190    birth_hi: f32,
1191    survive_lo: f32,
1192    survive_hi: f32,
1193    states: f32,
1194    threshold: f32,
1195    step_rate: f32,
1196    reseed: f32,
1197    trail: f32,
1198    age_tint: f32,
1199    route: f32,
1200    route_coord: f32,
1201    route_grade: f32,
1202    route_reveal: f32,
1203    /// Route passes owed per second of `dt` (ADR-0266): the tier's
1204    /// [`cellular_route_rate`](crate::render::TierConfig::cellular_route_rate).
1205    route_rate: f32,
1206    route_clock: route::RouteClock,
1207    /// This frame's generations and route passes, scheduled by `update` for
1208    /// `render` to merge into one timeline.
1209    generation_ticks: route::Ticks,
1210    pass_ticks: route::Ticks,
1211    /// Scene time since configure at this frame's start, and after it, in
1212    /// seconds: the sum of injected `dt`, which every route event is stamped
1213    /// against.
1214    frame_start: f64,
1215    scene_time: f64,
1216    /// Whether the next render returns the route's control words to idle — set
1217    /// by every `configure`, so a preset's route starts from nothing.
1218    route_reset: bool,
1219    colour: common::PaletteParams,
1220    pan: common::PanParams,
1221    zoom: f32,
1222    /// How much of this scene's coverage the backdrop resolves against
1223    /// (ADR-0085). Set by the renderer every frame through
1224    /// [`Scene::set_occlude`] — not a named param, so `reset_params` leaves it
1225    /// alone.
1226    occlude: f32,
1227    /// The active baked palette, held here because the resources build lazily
1228    /// and `set_palette` can arrive before they exist.
1229    palette: Palette,
1230}
1231
1232impl CellularScene {
1233    /// The CPU-side state, holding a bound `larger_than_life` radius to
1234    /// `radius_cap` and a preset's grid to `grid_cap` — the tier's
1235    /// [`cellular_radius`](crate::render::TierConfig::cellular_radius) and
1236    /// [`cellular_grid`](crate::render::TierConfig::cellular_grid) — and
1237    /// searching for the route at `route_rate` relax passes a second, the
1238    /// tier's [`cellular_route_rate`](crate::render::TierConfig::cellular_route_rate).
1239    /// GPU resources are deferred to the first render (module docs).
1240    pub fn new(
1241        device: &wgpu::Device,
1242        surface_format: wgpu::TextureFormat,
1243        radius_cap: u32,
1244        grid_cap: u32,
1245        route_rate: u32,
1246    ) -> Self {
1247        let config = CellularConfig::default();
1248        Self {
1249            device: device.clone(),
1250            surface_format,
1251            res: None,
1252            config,
1253            needs_seed: true,
1254            clock: GenerationClock::default(),
1255            dt: 0.0,
1256            pending_generations: 0,
1257            stamp_rng: stamp_rng(config.salt),
1258            pending_stamp: None,
1259            prev_reseed: 0.0,
1260            radius_cap: (radius_cap as f32).clamp(1.0, MAX_RADIUS),
1261            grid_cap: grid_cap.clamp(MIN_GRID, MAX_GRID),
1262            clamp: None,
1263            birth: DEFAULT_BIRTH,
1264            survive: DEFAULT_SURVIVE,
1265            radius: DEFAULT_RADIUS,
1266            birth_lo: DEFAULT_BIRTH_LO,
1267            birth_hi: DEFAULT_BIRTH_HI,
1268            survive_lo: DEFAULT_SURVIVE_LO,
1269            survive_hi: DEFAULT_SURVIVE_HI,
1270            states: DEFAULT_STATES,
1271            threshold: DEFAULT_THRESHOLD,
1272            step_rate: DEFAULT_STEP_RATE,
1273            reseed: 0.0,
1274            trail: DEFAULT_TRAIL,
1275            age_tint: DEFAULT_AGE_TINT,
1276            route: DEFAULT_ROUTE,
1277            route_coord: DEFAULT_ROUTE_COORD,
1278            route_grade: DEFAULT_ROUTE_GRADE,
1279            route_reveal: DEFAULT_ROUTE_REVEAL,
1280            route_rate: route_rate as f32,
1281            route_clock: route::RouteClock::default(),
1282            generation_ticks: route::Ticks::default(),
1283            pass_ticks: route::Ticks::default(),
1284            frame_start: 0.0,
1285            scene_time: 0.0,
1286            route_reset: true,
1287            colour: common::PaletteParams::new(DEFAULT_HUE, common::DEFAULT_BRIGHTNESS),
1288            pan: common::PanParams::default(),
1289            zoom: DEFAULT_ZOOM,
1290            occlude: crate::render::post::DEFAULT_OCCLUDE,
1291            palette: Palette::default_spectrum(),
1292        }
1293    }
1294
1295    /// Whether this frame encodes the route: a bound `route` above zero, on a
1296    /// family with open cells. Off, nothing of the route is built or encoded.
1297    fn route_on(&self) -> bool {
1298        self.route.is_finite() && self.route > 0.0 && self.config.family != CellularFamily::Cyclic
1299    }
1300
1301    /// This frame's step uniform: the rule, the field's seed, and the disc
1302    /// `stamp` refills if one is scheduled.
1303    fn step_params(&self, stamp: Option<Stamp>) -> StepParams {
1304        let stamp = stamp.unwrap_or(Stamp {
1305            centre: [0, 0],
1306            radius_sq: 0,
1307            seed: 0,
1308        });
1309        let radius = applied_radius(self.radius, self.radius_cap);
1310        let birth = interval_counts(
1311            self.birth_lo,
1312            self.birth_hi,
1313            radius,
1314            [DEFAULT_BIRTH_LO, DEFAULT_BIRTH_HI],
1315        );
1316        let survive = interval_counts(
1317            self.survive_lo,
1318            self.survive_hi,
1319            radius,
1320            [DEFAULT_SURVIVE_LO, DEFAULT_SURVIVE_HI],
1321        );
1322        StepParams {
1323            a: [
1324                self.config.family.index(),
1325                u32::from(self.config.wrap),
1326                self.config.grid,
1327                live_threshold(self.config.family.density()),
1328            ],
1329            b: [
1330                applied_rule(self.birth, DEFAULT_BIRTH),
1331                applied_rule(self.survive, DEFAULT_SURVIVE),
1332                radius,
1333                applied_states(self.states),
1334            ],
1335            c: [field_seed(self.config.salt), stamp.seed, stamp.radius_sq, 0],
1336            d: [
1337                stamp.centre[0],
1338                stamp.centre[1],
1339                applied_threshold(self.threshold),
1340                0,
1341            ],
1342            e: [birth[0], birth[1], survive[0], survive[1]],
1343        }
1344    }
1345}
1346
1347impl Scene for CellularScene {
1348    fn name(&self) -> &'static str {
1349        "cellular"
1350    }
1351
1352    fn advance(&mut self, dt: f32) {
1353        self.dt = dt;
1354    }
1355
1356    fn set_occlude(&mut self, occlude: f32) {
1357        self.occlude = occlude;
1358    }
1359
1360    fn set_palette(&mut self, palette: &Palette) {
1361        self.palette = palette.clone();
1362        if let Some(res) = self.res.as_mut() {
1363            res.luts.set(palette);
1364        }
1365    }
1366
1367    fn configure(&mut self, cfg: &GeneratorConfig) -> Option<super::CapOverflow> {
1368        let GeneratorConfig::Cellular(config) = cfg else {
1369            return None;
1370        };
1371        // A switch starts the incoming preset from its own seed: its family
1372        // may read the state channel differently, and its discs are its own
1373        // stream. The grid is held to the tier here, once, and the clamp is
1374        // returned for the renderer to announce with the preset.
1375        let (config, overflow) = grid_clamp(*config, self.grid_cap);
1376        self.config = config;
1377        self.needs_seed = true;
1378        self.clock = GenerationClock::default();
1379        self.pending_generations = 0;
1380        self.stamp_rng = stamp_rng(config.salt);
1381        self.pending_stamp = None;
1382        self.prev_reseed = 0.0;
1383        // The outgoing preset's radius clamp is not this one's to report.
1384        self.clamp = None;
1385        // The route starts from nothing, on a clock and a scene time of its own.
1386        self.route_clock.reset();
1387        self.generation_ticks = route::Ticks::default();
1388        self.pass_ticks = route::Ticks::default();
1389        self.frame_start = 0.0;
1390        self.scene_time = 0.0;
1391        self.route_reset = true;
1392        overflow
1393    }
1394
1395    fn mirror_overflow(&self) -> Option<&super::CapOverflow> {
1396        self.clamp.as_ref()
1397    }
1398
1399    fn reset_params(&mut self) {
1400        self.birth = DEFAULT_BIRTH;
1401        self.survive = DEFAULT_SURVIVE;
1402        self.radius = DEFAULT_RADIUS;
1403        self.birth_lo = DEFAULT_BIRTH_LO;
1404        self.birth_hi = DEFAULT_BIRTH_HI;
1405        self.survive_lo = DEFAULT_SURVIVE_LO;
1406        self.survive_hi = DEFAULT_SURVIVE_HI;
1407        self.states = DEFAULT_STATES;
1408        self.threshold = DEFAULT_THRESHOLD;
1409        self.step_rate = DEFAULT_STEP_RATE;
1410        self.reseed = 0.0;
1411        self.trail = DEFAULT_TRAIL;
1412        self.age_tint = DEFAULT_AGE_TINT;
1413        self.route = DEFAULT_ROUTE;
1414        self.route_coord = DEFAULT_ROUTE_COORD;
1415        self.route_grade = DEFAULT_ROUTE_GRADE;
1416        self.route_reveal = DEFAULT_ROUTE_REVEAL;
1417        self.colour.reset();
1418        self.pan.reset();
1419        self.zoom = DEFAULT_ZOOM;
1420    }
1421
1422    fn set_param(&mut self, name: &str, value: f32) {
1423        // The shared param blocks first, this scene's own names after
1424        // (`scenes::common`).
1425        if self.colour.set(name, value) || self.pan.set(name, value) {
1426            return;
1427        }
1428        match name {
1429            "birth" => self.birth = value,
1430            "survive" => self.survive = value,
1431            "radius" => self.radius = value,
1432            "birth_lo" => self.birth_lo = value,
1433            "birth_hi" => self.birth_hi = value,
1434            "survive_lo" => self.survive_lo = value,
1435            "survive_hi" => self.survive_hi = value,
1436            "states" => self.states = value,
1437            "threshold" => self.threshold = value,
1438            "step_rate" => self.step_rate = value,
1439            "reseed" => self.reseed = value,
1440            "trail" => self.trail = value,
1441            "age_tint" => self.age_tint = value,
1442            "route" => self.route = value,
1443            "route_coord" => self.route_coord = value,
1444            "route_grade" => self.route_grade = value,
1445            "route_reveal" => self.route_reveal = value,
1446            "zoom" => self.zoom = value,
1447            _ => {}
1448        }
1449    }
1450
1451    fn update(&mut self, _frame: &AnalysisFrame) {
1452        let owed_before = self.clock.owed();
1453        self.pending_generations = self.clock.advance(self.step_rate, self.dt);
1454        self.generation_ticks = route::Ticks {
1455            owed_before,
1456            rate: f64::from(applied_step_rate(self.step_rate)),
1457            count: self.pending_generations,
1458        };
1459        self.pass_ticks = if self.route_on() {
1460            self.route_clock.advance(self.route_rate, self.dt)
1461        } else {
1462            self.route_clock.reset();
1463            route::Ticks::default()
1464        };
1465        self.frame_start = self.scene_time;
1466        self.scene_time += f64::from(self.dt);
1467        // Rising edge only, so a beat flag held for several frames — or a
1468        // latch's hold — refills one disc rather than one per frame. A NaN
1469        // compares false both ways and is stored as zero, so it neither fires
1470        // nor arms a spurious edge on the next finite value.
1471        let reseed = if self.reseed.is_finite() {
1472            self.reseed
1473        } else {
1474            0.0
1475        };
1476        if reseed >= RESEED_THRESHOLD && self.prev_reseed < RESEED_THRESHOLD {
1477            self.pending_stamp = Some(next_stamp(&mut self.stamp_rng, self.config.grid));
1478        }
1479        self.prev_reseed = reseed;
1480    }
1481
1482    fn render(
1483        &mut self,
1484        queue: &wgpu::Queue,
1485        encoder: &mut wgpu::CommandEncoder,
1486        view: &wgpu::TextureView,
1487        _aspect: f32,
1488    ) {
1489        let grid = self.config.grid;
1490        if self.res.as_ref().is_none_or(|res| res.grid != grid) {
1491            let mut built = Resources::build(&self.device, self.surface_format, grid);
1492            built.luts.set(&self.palette);
1493            self.res = Some(built);
1494            self.needs_seed = true;
1495        }
1496
1497        let seed = std::mem::replace(&mut self.needs_seed, false);
1498        let stamp = self.pending_stamp.take();
1499        let generations = std::mem::take(&mut self.pending_generations);
1500        let step = self.step_params(stamp);
1501        self.clamp = radius_clamp(self.config.family, self.radius, self.radius_cap);
1502        let present = PresentParams {
1503            a: [
1504                self.colour.hue,
1505                self.colour.brightness,
1506                self.colour.saturation,
1507                self.colour.mix,
1508            ],
1509            b: [
1510                palette::band_steps(self.colour.steps),
1511                palette::band_contour(self.colour.contour),
1512                self.occlude,
1513                grid as f32,
1514            ],
1515            c: [
1516                if self.zoom.is_finite() && self.zoom > 1e-3 {
1517                    self.zoom
1518                } else {
1519                    DEFAULT_ZOOM
1520                },
1521                if self.pan.x.is_finite() {
1522                    self.pan.x
1523                } else {
1524                    0.0
1525                },
1526                if self.pan.y.is_finite() {
1527                    self.pan.y
1528                } else {
1529                    0.0
1530                },
1531                if self.config.wrap { 1.0 } else { 0.0 },
1532            ],
1533            d: [
1534                applied_trail(self.trail),
1535                if self.age_tint.is_finite() {
1536                    self.age_tint
1537                } else {
1538                    DEFAULT_AGE_TINT
1539                },
1540                if self.config.family == CellularFamily::Cyclic {
1541                    1.0
1542                } else {
1543                    0.0
1544                },
1545                applied_states(self.states) as f32,
1546            ],
1547            e: [
1548                palette::band_contour_style(self.colour.contour_style),
1549                self.colour.contour_ink,
1550                0.0,
1551                0.0,
1552            ],
1553        };
1554
1555        let route_on = self.route_on();
1556        let reveal = route::applied_reveal(self.route_reveal, DEFAULT_ROUTE_REVEAL);
1557        let route_present = route::RoutePresent {
1558            a: [
1559                self.route.clamp(0.0, 1.0),
1560                finite_or(self.route_coord, DEFAULT_ROUTE_COORD),
1561                finite_or(self.route_grade, DEFAULT_ROUTE_GRADE),
1562                reveal,
1563            ],
1564            b: [route::scene_ms(self.scene_time), 0, 0, 0],
1565        };
1566        let route_frame = route::FrameParams {
1567            wrap: self.config.wrap,
1568            fade_ms: route::fade_ms(reveal),
1569        };
1570        // The schedules are this frame's, and a second render without an
1571        // update must not run them again.
1572        let generation_ticks = route::Ticks {
1573            count: generations,
1574            ..std::mem::take(&mut self.generation_ticks)
1575        };
1576        let pass_ticks = std::mem::take(&mut self.pass_ticks);
1577
1578        let Some(res) = self.res.as_mut() else {
1579            return;
1580        };
1581        if route_on && res.route.as_ref().is_none_or(|route| route.grid != grid) {
1582            res.route = Some(route::RouteResources::build(
1583                &self.device,
1584                self.surface_format,
1585                grid,
1586                &res.field,
1587                &res.present_layout,
1588            ));
1589            self.route_reset = true;
1590        }
1591        res.luts.flush(queue);
1592        queue.write_buffer(&res.present_uniform, 0, bytemuck::bytes_of(&present));
1593
1594        // One write serves every pass below: the seed, then the disc, then the
1595        // generations, in that order, so a disc scheduled on a preset's first
1596        // frame lands on its seeded field rather than under it.
1597        if seed || stamp.is_some() || generations > 0 {
1598            queue.write_buffer(&res.step_uniform, 0, bytemuck::bytes_of(&step));
1599        }
1600        if seed {
1601            res.encode_step(encoder, StepPass::Seed);
1602        }
1603        if stamp.is_some() {
1604            res.encode_step(encoder, StepPass::Stamp);
1605        }
1606        let sums_rows = self.config.family.sums_rows();
1607        // Out of `res` for the frame, so the step passes can borrow it beside
1608        // the route; put back below. Only while the route is on: off, it is
1609        // neither encoded nor presented.
1610        let mut route_res = if route_on { res.route.take() } else { None };
1611        if let Some(route) = route_res.as_mut() {
1612            if std::mem::take(&mut self.route_reset) {
1613                route.reset(queue);
1614            }
1615            route.begin_frame();
1616            let timeline = route::Timeline::new(
1617                generation_ticks,
1618                pass_ticks,
1619                self.frame_start,
1620                f64::from(self.dt),
1621            );
1622            for (event, ms) in timeline {
1623                match event {
1624                    route::RouteEvent::Generation => {
1625                        if sums_rows {
1626                            res.encode_rows(encoder);
1627                        }
1628                        res.encode_step(encoder, StepPass::Step);
1629                        route.encode_generation(encoder, &res.field, route_frame, ms);
1630                    }
1631                    route::RouteEvent::Pass => {
1632                        route.encode_pass(encoder, &res.field, route_frame, ms)
1633                    }
1634                }
1635            }
1636            route.finish_frame(queue);
1637            queue.write_buffer(
1638                &route.present_uniform,
1639                0,
1640                bytemuck::bytes_of(&route_present),
1641            );
1642        } else {
1643            for _ in 0..generations {
1644                if sums_rows {
1645                    res.encode_rows(encoder);
1646                }
1647                res.encode_step(encoder, StepPass::Step);
1648            }
1649        }
1650        if route_res.is_some() {
1651            res.route = route_res;
1652        }
1653
1654        // Load over the engine backdrop (ADR-0018): dead cells write no
1655        // coverage, so the backdrop survives wherever nothing lives.
1656        let mut pass = gpu::color_pass(encoder, "cellular-present-pass", view, wgpu::LoadOp::Load);
1657        match res.route.as_ref().filter(|_| route_on) {
1658            Some(route) => {
1659                pass.set_pipeline(&route.present_pipeline);
1660                pass.set_bind_group(0, res.present_bg.for_field(&res.field), &[]);
1661                pass.set_bind_group(1, &route.present_bg, &[]);
1662            }
1663            None => {
1664                pass.set_pipeline(&res.present_pipeline);
1665                pass.set_bind_group(0, res.present_bg.for_field(&res.field), &[]);
1666            }
1667        }
1668        pass.draw(0..3, 0..1);
1669    }
1670}
1671
1672/// `value` where it is finite, `fallback` where it is not.
1673fn finite_or(value: f32, fallback: f32) -> f32 {
1674    if value.is_finite() { value } else { fallback }
1675}
1676
1677#[cfg(test)]
1678mod mirror;
1679#[cfg(test)]
1680mod tests;