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}