Skip to main content

rlx_core/render/scenes/
common.rs

1//! The parameter blocks the scenes share, and the two automata's shared
2//! generation clock and cell hash.
3//!
4//! Every system implements [`Scene`](super::Scene) and most of them accept the
5//! same colour and framing names — `palette_mix`, `palette_steps`,
6//! `palette_contour`, `palette_contour_style`, `palette_contour_ink`,
7//! `saturation`, `hue`, `brightness`, `pan_x`, `pan_y`. Each
8//! spelled its own field, its own `set_param` arm, its own `reset_params` line
9//! and its own `DEFAULT_*` const for them. The names are the preset grammar's,
10//! not any one scene's, so their storage and their resting values belong in one
11//! place; `presets/README.md` is still the roster of **which** system accepts
12//! which name, and this module changes none of it.
13//!
14//! # These hold raw values
15//!
16//! A `set_param` stores what the expression evaluated to, and the reading side —
17//! the uniform packing, or the shader — decides what is in range. That split is
18//! deliberate and predates this module: a `[smoothing]` entry sweeps a binding
19//! *continuously* toward its target, so clamping on the way in would quantize the
20//! sweep rather than the destination. Nothing here clamps, rounds, or rejects.
21//!
22//! # Two of them are not universal
23//!
24//! `saturation` rests at 1.0 and `palette_mix`, `palette_steps`,
25//! `palette_contour`, `palette_contour_style`, `palette_contour_ink`, `pan_x` and
26//! `pan_y` at their off values on every system
27//! that has them, so those resting values are stated once below. `hue` and
28//! `brightness` are not: the line families each open on a different hue (0.3
29//! l-system, 0.5 star, 0.55 spectrum, 0.6 parametric, 0.0 elsewhere) and the
30//! swarm rests at `brightness = 0.8` where everything else rests at 1.0. Those
31//! are the family's signature look, so [`PaletteParams::new`] takes them from the
32//! scene rather than this module inventing one answer.
33
34// Hot-path panic-denial pragma (Plan 0002 Phase 2; `render/` scan set).
35// `set_param` runs once per bound param per frame.
36#![deny(
37    clippy::unwrap_used,
38    clippy::expect_used,
39    clippy::indexing_slicing,
40    clippy::panic,
41    clippy::unreachable
42)]
43
44use crate::render::palette;
45use crate::render::scenes::{ParamGroup, ParamKind, ParamSpec};
46
47/// `saturation` at rest: fully saturated, the palette's own colour.
48pub(crate) const DEFAULT_SATURATION: f32 = 1.0;
49/// `palette_mix` at rest: 0, palette A alone, so the B crossfade is off.
50pub(crate) const DEFAULT_PALETTE_MIX: f32 = 0.0;
51/// `pan_x` / `pan_y` at rest: centred.
52pub(crate) const DEFAULT_PAN: f32 = 0.0;
53/// `brightness` at rest on every system **but the swarm**, which rests at 0.8.
54/// Not a universal default, so [`PaletteParams::new`] takes it as an argument and
55/// this is only the value eleven of the twelve pass — including the three whose
56/// roster has no `brightness` at all, where it is inert.
57pub(crate) const DEFAULT_BRIGHTNESS: f32 = 1.0;
58// ---------------------------------------------------------------------------
59// The automata's shared clock and cell hash (`cellular`, `voxel`)
60// ---------------------------------------------------------------------------
61
62/// The most generations one frame encodes. A stall would otherwise queue
63/// unbounded passes (the accumulator spiral ADR-0012 names); past this the
64/// backlog is dropped and the automaton briefly slows rather than racing. At
65/// 30 fps it still carries 240 generations a second.
66pub const MAX_GENERATIONS_PER_FRAME: u32 = 8;
67
68/// How far below a whole generation the clock still counts one. `dt` arrives as
69/// an `f32`, and `1/144` summed 144 times lands a few ulps either side of 1;
70/// without this slack a rate that should run 12 generations in a second runs
71/// 11 at one refresh rate and 12 at another.
72pub(crate) const WHOLE_SLACK: f64 = 1e-6;
73
74/// Integrates a generation rate over injected `dt` into whole generations.
75///
76/// **No clock**: `dt` is handed in, as every accumulator in this engine takes
77/// it, so a capture stepping a fixed `dt` sequence is reproducible. The sum is
78/// `f64`, because a rate times an `f32` `dt` summed over minutes loses the
79/// fraction that decides whether a generation is owed.
80#[derive(Debug, Clone, Copy, Default, PartialEq)]
81pub(crate) struct GenerationClock {
82    /// Generations owed and not yet run, in `[0, 1)` between calls.
83    pub(crate) owed: f64,
84}
85
86impl GenerationClock {
87    /// Add `dt` seconds at `rate` generations per second and return how many
88    /// whole generations to run now, at most [`MAX_GENERATIONS_PER_FRAME`].
89    /// The fraction carries; a backlog past the cap is dropped rather than
90    /// carried, so the automaton slows instead of racing to catch up.
91    ///
92    /// `rate` is the system's own applied rate — finite, non-negative and held
93    /// to its ceiling by the caller, which owns the default a non-finite
94    /// binding falls back to. `dt` is trusted: the renderer entry that took the
95    /// frame has already replaced a degenerate delta with one nominal step
96    /// (ADR-0191).
97    pub(crate) fn advance(&mut self, rate: f32, dt: f32) -> u32 {
98        self.owed += f64::from(rate) * f64::from(dt);
99        let whole = (self.owed + WHOLE_SLACK).floor();
100        let cap = f64::from(MAX_GENERATIONS_PER_FRAME);
101        if whole > cap {
102            self.owed = 0.0;
103            return MAX_GENERATIONS_PER_FRAME;
104        }
105        self.owed = (self.owed - whole).max(0.0);
106        whole as u32
107    }
108}
109
110/// The automata's cell hash, as WGSL text: a cell's `u32` from its integer
111/// coordinates and a seed, through [`gpu::HASH_WGSL`](crate::render::gpu::HASH_WGSL)'s
112/// `mix32`, which must be prepended ahead of it. Every input is a `u32`, so a
113/// seeded field is the same bit for bit on every adapter.
114///
115/// `cell_hash` is the plane's and `cell_hash3` the volume's; the volume's folds
116/// `z` in over the plane's, so the two never need to agree.
117pub(crate) const CELL_HASH_WGSL: &str = r#"
118fn cell_hash(c: vec2<i32>, seed: u32) -> u32 {
119    return mix32(u32(c.x) ^ mix32(u32(c.y) ^ mix32(seed)));
120}
121
122fn cell_hash3(c: vec3<i32>, seed: u32) -> u32 {
123    return mix32(u32(c.z) ^ cell_hash(c.xy, seed));
124}
125"#;
126
127/// The colour and framing parameters every palette-coloured scene delegates to
128/// this module, declared **once** (ADR-0170).
129///
130/// A scene splices these into its own `PARAMS` by value rather than composing at
131/// runtime, because a roster has to be a `const` the loader and the generated
132/// reference can both read without allocating. What that buys is the thing the
133/// twelve copies could not have: one name, one range and one sentence, wherever
134/// the parameter appears.
135///
136/// [`hue`] and [`brightness`] are functions rather than constants because their
137/// **default** is the scene's own resting value while everything else about them
138/// is shared - `fragment_field` rests at hue 0 and `parametric_curve` at 0.6, and
139/// both mean the same thing by it.
140pub(crate) const SATURATION: ParamSpec = ParamSpec {
141    name: "saturation",
142    default: DEFAULT_SATURATION,
143    range: Some([0.0, 1.0]),
144    doc: "Pulls the scene's colour toward grey; 0 is fully desaturated, 1 is the palette's own.",
145    kind: ParamKind::Modal,
146    group: ParamGroup::Colour,
147    main: false,
148};
149
150/// `palette_mix`, shared: the A/B palette crossfade position.
151pub(crate) const PALETTE_MIX: ParamSpec = ParamSpec {
152    name: "palette_mix",
153    default: DEFAULT_PALETTE_MIX,
154    range: Some([0.0, 1.0]),
155    doc: "Crossfades from the preset's palette to its second one; 0 is the first, 1 the second.",
156    kind: ParamKind::Modal,
157    group: ParamGroup::Colour,
158    main: false,
159};
160
161/// `palette_steps`, shared: how many flat bands the palette is cut into.
162pub(crate) const PALETTE_STEPS: ParamSpec = ParamSpec {
163    name: "palette_steps",
164    default: palette::DEFAULT_PALETTE_STEPS,
165    range: Some([0.0, 16.0]),
166    doc: "Quantizes the palette into this many flat bands; 0 leaves it continuous.",
167    kind: ParamKind::Structural,
168    group: ParamGroup::Colour,
169    main: false,
170};
171
172/// `palette_contour`, shared: the line drawn at each band edge.
173pub(crate) const PALETTE_CONTOUR: ParamSpec = ParamSpec {
174    name: "palette_contour",
175    default: palette::DEFAULT_PALETTE_CONTOUR,
176    range: Some([0.0, 1.0]),
177    doc: "Draws a line at each band edge when the palette is stepped; 0 draws none.",
178    kind: ParamKind::Modal,
179    group: ParamGroup::Colour,
180    main: false,
181};
182
183/// `palette_contour_style`, shared: which of the four lines the contour draws
184/// (ADR-0197). Structural, and rounded at the read side by
185/// [`palette::band_contour_style`] so the shader can compare it exactly.
186pub(crate) const PALETTE_CONTOUR_STYLE: ParamSpec = ParamSpec {
187    name: "palette_contour_style",
188    default: palette::DEFAULT_PALETTE_CONTOUR_STYLE,
189    range: Some([0.0, palette::MAX_PALETTE_CONTOUR_STYLE]),
190    doc: "Which line the contour draws: 0 a soft darkening, 1 a hard one, 2 a soft ink, 3 a hard ink.",
191    kind: ParamKind::Structural,
192    group: ParamGroup::Colour,
193    main: false,
194};
195
196/// `palette_contour_ink`, shared: where on the palette an ink contour takes its
197/// colour (ADR-0197). Absolute — `hue` does not shift it — so it names a stop.
198pub(crate) const PALETTE_CONTOUR_INK: ParamSpec = ParamSpec {
199    name: "palette_contour_ink",
200    default: palette::DEFAULT_PALETTE_CONTOUR_INK,
201    range: Some([0.0, 1.0]),
202    doc: "Where along the palette an ink contour takes its colour; unread by the two black styles.",
203    kind: ParamKind::Modal,
204    group: ParamGroup::Colour,
205    main: false,
206};
207
208/// `pan_x`, shared: the scene's horizontal offset.
209pub(crate) const PAN_X: ParamSpec = ParamSpec {
210    name: "pan_x",
211    default: DEFAULT_PAN,
212    range: None,
213    doc: "Slides the whole scene sideways, in the scene's own units rather than pixels.",
214    kind: ParamKind::Modal,
215    group: ParamGroup::Shape,
216    main: false,
217};
218
219/// `pan_y`, shared: the scene's vertical offset.
220pub(crate) const PAN_Y: ParamSpec = ParamSpec {
221    name: "pan_y",
222    default: DEFAULT_PAN,
223    range: None,
224    doc: "Slides the whole scene vertically, in the scene's own units rather than pixels.",
225    kind: ParamKind::Modal,
226    group: ParamGroup::Shape,
227    main: false,
228};
229
230/// `hue` at the scene's own resting coordinate. See the block docs above.
231pub(crate) const fn hue(default: f32) -> ParamSpec {
232    ParamSpec {
233        name: "hue",
234        default,
235        range: Some([0.0, 1.0]),
236        doc: "Where this scene reads from the palette, as a coordinate along it rather than a colour.",
237        kind: ParamKind::Modal,
238        group: ParamGroup::Colour,
239        main: true,
240    }
241}
242
243/// `brightness` at the scene's own resting level. See the block docs above.
244pub(crate) const fn brightness(default: f32) -> ParamSpec {
245    ParamSpec {
246        name: "brightness",
247        default,
248        range: Some([0.0, 2.0]),
249        doc: "The scene's overall light level, multiplying what it draws before the composite.",
250        kind: ParamKind::Modal,
251        group: ParamGroup::Light,
252        main: true,
253    }
254}
255
256/// `zoom`, the shared view transform, for a scene that scales what it draws.
257/// A scene that scales the window it samples a field through declares
258/// [`field_zoom`] instead: the same key, the opposite direction on screen.
259pub(crate) const fn zoom(default: f32) -> ParamSpec {
260    ParamSpec {
261        name: "zoom",
262        default,
263        range: Some([0.25, 4.0]),
264        doc: "Scales the whole scene about its centre; above 1 fills more of the frame.",
265        kind: ParamKind::Modal,
266        group: ParamGroup::Shape,
267        main: false,
268    }
269}
270
271/// `zoom` on a sampled field (ADR-0018): it scales the window the shader reads
272/// the field through, so a larger value shows more of the field and draws each
273/// feature smaller -- the inverse of [`zoom`]'s on-screen sense.
274pub(crate) const fn field_zoom(default: f32) -> ParamSpec {
275    ParamSpec {
276        name: "zoom",
277        default,
278        range: Some([0.25, 4.0]),
279        doc: "Scales the sampled window of the field; above 1 shows more of the field, each \
280              feature smaller.",
281        kind: ParamKind::Modal,
282        group: ParamGroup::Shape,
283        main: false,
284    }
285}
286
287/// The names [`PaletteParams::set`] answers, as a roster.
288///
289/// **Test-only, and deliberately not what `set` matches against.** The setter
290/// keeps its `match` — it runs once per bound param per frame and a linear scan
291/// of a slice is the wrong shape for that — so this is a second statement of the
292/// vocabulary, and `each_block_answers_exactly_its_roster` below is what holds
293/// the two together. The source-text scan in `core/tests/suite/preset.rs` carries a
294/// third copy, because that is an integration test and this module is
295/// `pub(crate)`.
296#[cfg(test)]
297const PALETTE_PARAMS: &[&str] = &[
298    "palette_mix",
299    "palette_steps",
300    "palette_contour",
301    "palette_contour_style",
302    "palette_contour_ink",
303    "saturation",
304    "hue",
305    "brightness",
306];
307
308/// The names [`PanParams::set`] answers, as a roster. Test-only, like
309/// [`PALETTE_PARAMS`].
310#[cfg(test)]
311const PAN_PARAMS: &[&str] = &["pan_x", "pan_y"];
312
313/// The palette-facing params a shader-coloured scene reads.
314///
315/// [`set`](PaletteParams::set) recognizes every name in the block. A scene whose roster is
316/// narrower — `shape_collage` accepts only `saturation` and `palette_mix`,
317/// `shape_field` adds the two banding names, `fragment_field` has no
318/// `brightness` — still delegates the whole block: the roster that decides what a
319/// preset may bind is the scene's own `PARAMS`, which `is_known_param` reads and
320/// this type does not touch, and a field nothing packs into a uniform is read by
321/// nothing.
322#[derive(Debug, Clone, Copy)]
323pub(crate) struct PaletteParams {
324    /// `palette_mix` — the A/B crossfade position.
325    pub mix: f32,
326    /// `palette_steps` — the band count (ADR-0078); quantized at the read side.
327    pub steps: f32,
328    /// `palette_contour` — the contour line drawn at each band edge.
329    pub contour: f32,
330    /// `palette_contour_style` — which of the four lines it is (ADR-0197);
331    /// rounded at the read side by `palette::band_contour_style`.
332    pub contour_style: f32,
333    /// `palette_contour_ink` — the absolute palette coordinate the two ink
334    /// styles draw in.
335    pub contour_ink: f32,
336    /// `saturation` — 0 desaturates toward grey, 1 is the palette's own colour.
337    pub saturation: f32,
338    /// `hue` — the palette coordinate the scene starts from.
339    pub hue: f32,
340    /// `brightness` — the scene's overall light level.
341    pub brightness: f32,
342    /// What `reset` returns `hue` to. Per scene: see the module docs.
343    hue_rest: f32,
344    /// What `reset` returns `brightness` to. Per scene: see the module docs.
345    brightness_rest: f32,
346}
347
348impl PaletteParams {
349    /// A block at rest, with the scene's own resting `hue` and `brightness`.
350    pub(crate) fn new(hue_rest: f32, brightness_rest: f32) -> Self {
351        Self {
352            mix: DEFAULT_PALETTE_MIX,
353            steps: palette::DEFAULT_PALETTE_STEPS,
354            contour: palette::DEFAULT_PALETTE_CONTOUR,
355            contour_style: palette::DEFAULT_PALETTE_CONTOUR_STYLE,
356            contour_ink: palette::DEFAULT_PALETTE_CONTOUR_INK,
357            saturation: DEFAULT_SATURATION,
358            hue: hue_rest,
359            brightness: brightness_rest,
360            hue_rest,
361            brightness_rest,
362        }
363    }
364
365    /// Store `value` if `name` is one of this block's, and say whether it was.
366    ///
367    /// A scene's `set_param` calls this first and matches its own names when it
368    /// returns `false`, so the shared names cannot drift apart across twelve
369    /// files.
370    pub(crate) fn set(&mut self, name: &str, value: f32) -> bool {
371        match name {
372            "palette_mix" => self.mix = value,
373            "palette_steps" => self.steps = value,
374            "palette_contour" => self.contour = value,
375            "palette_contour_style" => self.contour_style = value,
376            "palette_contour_ink" => self.contour_ink = value,
377            "saturation" => self.saturation = value,
378            "hue" => self.hue = value,
379            "brightness" => self.brightness = value,
380            _ => return false,
381        }
382        true
383    }
384
385    /// Back to rest — what a preset switch leaves behind for any name the
386    /// incoming preset does not bind.
387    pub(crate) fn reset(&mut self) {
388        self.mix = DEFAULT_PALETTE_MIX;
389        self.steps = palette::DEFAULT_PALETTE_STEPS;
390        self.contour = palette::DEFAULT_PALETTE_CONTOUR;
391        self.contour_style = palette::DEFAULT_PALETTE_CONTOUR_STYLE;
392        self.contour_ink = palette::DEFAULT_PALETTE_CONTOUR_INK;
393        self.saturation = DEFAULT_SATURATION;
394        self.hue = self.hue_rest;
395        self.brightness = self.brightness_rest;
396    }
397}
398
399/// `pan_x` / `pan_y` — the view offset every scene but `warp_mesh` accepts.
400///
401/// Its own type rather than two more fields on [`PaletteParams`] because it is
402/// framing, not colour: the two blocks are accepted by different sets of scenes,
403/// and a scene that pans without a palette (or the reverse) would otherwise have
404/// to carry the half it does not use.
405#[derive(Debug, Clone, Copy)]
406pub(crate) struct PanParams {
407    /// `pan_x` — horizontal view offset.
408    pub x: f32,
409    /// `pan_y` — vertical view offset.
410    pub y: f32,
411}
412
413impl Default for PanParams {
414    fn default() -> Self {
415        Self {
416            x: DEFAULT_PAN,
417            y: DEFAULT_PAN,
418        }
419    }
420}
421
422impl PanParams {
423    /// Store `value` if `name` is `pan_x` or `pan_y`, and say whether it was.
424    pub(crate) fn set(&mut self, name: &str, value: f32) -> bool {
425        match name {
426            "pan_x" => self.x = value,
427            "pan_y" => self.y = value,
428            _ => return false,
429        }
430        true
431    }
432
433    /// Back to centred.
434    pub(crate) fn reset(&mut self) {
435        *self = Self::default();
436    }
437}
438
439#[cfg(test)]
440mod tests {
441    // Test asserts panic on failure; allowed here over the file's pragma.
442    #![allow(clippy::panic)]
443
444    use super::*;
445
446    /// **Each block answers exactly its roster** — no more, no less.
447    ///
448    /// `core/tests/suite/preset.rs`'s drift guard cannot see these eight names: it reads
449    /// `set_param`'s match arms out of the source text, and every scene
450    /// *delegates* them here rather than matching them. That is the right
451    /// factoring — one implementation of what `saturation` means — and this is
452    /// what replaces the coverage it costs, together with that guard's own check
453    /// that a scene declaring one of these names carries the delegation.
454    #[test]
455    fn each_block_answers_exactly_its_roster() {
456        let mut palette = PaletteParams::new(0.0, 1.0);
457        for name in PALETTE_PARAMS {
458            assert!(
459                palette.set(name, 0.25),
460                "`{name}` is in the roster but `PaletteParams::set` drops it"
461            );
462        }
463        for name in PAN_PARAMS
464            .iter()
465            .copied()
466            .chain(["zoom", "color_span", "", "Saturation"])
467        {
468            assert!(!palette.set(name, 0.25), "`{name}` is not this block's");
469        }
470
471        let mut pan = PanParams::default();
472        for name in PAN_PARAMS {
473            assert!(
474                pan.set(name, 0.25),
475                "`{name}` is in the roster but `PanParams::set` drops it"
476            );
477        }
478        for name in PALETTE_PARAMS {
479            assert!(!pan.set(name, 0.25), "`{name}` is not this block's");
480        }
481        assert!(pan.set("pan_y", -0.25));
482        assert_eq!((pan.x, pan.y), (0.25, -0.25));
483    }
484
485    /// **No system declares a shared name that neither block answers.**
486    ///
487    /// The scenes' `PARAMS` lists are the roster a preset binds against, and each
488    /// scene now delegates the colour and framing names wholesale. A name that
489    /// *looks* shared but is spelled slightly differently in one scene's `PARAMS`
490    /// would be declared, filtered out of the source-text guard, and answered by
491    /// nothing.
492    #[test]
493    fn every_system_that_declares_a_shared_name_uses_the_shared_spelling() {
494        use crate::preset::SystemKind;
495
496        let mut palette = PaletteParams::new(0.0, 1.0);
497        let mut pan = PanParams::default();
498        let mut seen = 0usize;
499        for system in SystemKind::ALL {
500            for name in system.param_names() {
501                if PALETTE_PARAMS.contains(&name) || PAN_PARAMS.contains(&name) {
502                    seen += 1;
503                    assert!(
504                        palette.set(name, 0.5) || pan.set(name, 0.5),
505                        "`{}` declares `{name}`, which neither shared block answers",
506                        system.as_str()
507                    );
508                }
509            }
510        }
511        assert!(
512            seen > 40,
513            "only {seen} shared-name declarations found across the scene roster — \
514             the scan has stopped seeing them, so this guard would pass vacuously"
515        );
516    }
517
518    /// **`reset` returns `hue` and `brightness` to the scene's resting values,
519    /// not to a shared one** — the swarm rests dimmer and each line family opens
520    /// on its own hue, and a preset switch must land back on that.
521    #[test]
522    fn reset_restores_the_scenes_own_hue_and_brightness() {
523        let mut params = PaletteParams::new(0.55, 0.8);
524        params.set("hue", 0.1);
525        params.set("brightness", 2.0);
526        params.set("saturation", 0.0);
527        params.set("palette_mix", 1.0);
528        params.reset();
529        assert_eq!(params.hue, 0.55);
530        assert_eq!(params.brightness, 0.8);
531        assert_eq!(params.saturation, DEFAULT_SATURATION);
532        assert_eq!(params.mix, DEFAULT_PALETTE_MIX);
533        assert_eq!(params.steps, palette::DEFAULT_PALETTE_STEPS);
534        assert_eq!(params.contour, palette::DEFAULT_PALETTE_CONTOUR);
535
536        let mut pan = PanParams::default();
537        pan.set("pan_x", 1.0);
538        pan.reset();
539        assert_eq!((pan.x, pan.y), (DEFAULT_PAN, DEFAULT_PAN));
540    }
541
542    /// **Nothing here clamps or rounds** — the reading side owns range, so a
543    /// `[smoothing]` sweep passes through untouched (module docs).
544    #[test]
545    fn the_blocks_store_raw_values() {
546        let mut params = PaletteParams::new(0.0, 1.0);
547        params.set("saturation", -3.0);
548        params.set("palette_steps", 1e9);
549        params.set("brightness", f32::INFINITY);
550        assert_eq!(params.saturation, -3.0);
551        assert_eq!(params.steps, 1e9);
552        assert!(params.brightness.is_infinite());
553    }
554}