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