rlx_core/render/palette.rs
1//! Shared palette system (ADR-0021, Plan 0020): one gradient baked once at load
2//! into a 256-entry RGB lookup table (LUT) that every shader-colored scene
3//! samples, replacing the per-scene hardcoded iq cosine `palette()`.
4//!
5//! A preset selects a built-in **named** palette (`spectrum`, `ember`, `ice`,
6//! `mono`, `aurora`) or (Plan 0020 Phase 2) a list of custom **stops**. Named
7//! palettes are themselves defined as built-in gradients — some generated from
8//! the cosine model, some as stop lists — so named and custom share one baked-LUT
9//! representation. The LUT is delivered to the GPU scenes (fragment field,
10//! reaction-diffusion, attractor) as a 256×1 texture and sampled on the CPU by
11//! the swarm; one bake, two consumers, no drift.
12//!
13//! **Baking is pure and off the hot path** — a function of the config only (no
14//! clock, no randomness), run once on preset load — so it is deterministic
15//! (NFR 6). [`Palette::sample`] is allocation-free and runs per particle per
16//! frame (swarm), so this module carries the hot-path panic pragma.
17//!
18//! ## The colour space (ADR-0151)
19//!
20//! A `[palette]` stop in a `.toml` is **sRGB**, and [`srgb_to_linear`] decodes it
21//! at the load boundary, so the LUT below holds linear light and a stop written
22//! `#c81423` renders `#c81423`. The gradients defined *here* — the cosine and the
23//! named stop lists — are engine values already in that space and go through no
24//! decode; the cosine could not, being a generator rather than a triple.
25//!
26//! **The `spectrum` default *is* the cosine model exactly**, so a
27//! preset that declares no `[palette]` is unaffected by this module —
28//! the load-bearing no-regression guarantee, gated by a unit test
29//! comparing sampled colors.
30//!
31//! ## Saturation (the single source of truth)
32//!
33//! `saturation` is a bindable modulation applied to the *sampled* color, not
34//! baked into the LUT. It must be applied **identically** on the CPU (swarm) and
35//! in every scene's WGSL, so the canonical definition lives here and each shader
36//! mirrors it verbatim:
37//!
38//! ```text
39//! luma = 0.299*r + 0.587*g + 0.114*b (Rec. 601 luma)
40//! out = luma + (rgb - luma) * saturation (1.0 = unchanged, 0.0 = grayscale)
41//! ```
42//!
43//! `hue` is the other shared modulation: it offsets the LUT *sample coordinate*
44//! (pre-sample), so it is applied where the coordinate is computed, not here.
45//!
46//! ## Banding (ADR-0078) — the other single source of truth
47//!
48//! `palette_steps` turns the smooth ramp into hard graphic bands by quantizing the
49//! **palette coordinate** rather than the baked LUT: `t' = (floor(t·N) + 0.5)/N`
50//! immediately before the sample. The bake above is untouched, which is the whole
51//! point — the band count has to be *bindable to audio*, and quantizing during the
52//! bake would cost a re-bake and a texture upload every frame, exactly the
53//! per-frame work the bake exists to remove.
54//!
55//! [`band_coord`] is the canonical definition and every sample site mirrors it —
56//! the CPU sites call it, the WGSL sites carry a commented verbatim copy, the way
57//! `apply_saturation` mirrors [`desaturate`]. A test in this module asserts the
58//! copies have not drifted.
59//!
60//! **`palette_contour` is scoped, and the scoping is a fact about the pipeline
61//! rather than a policy.** A screen-constant contour width needs `fwidth`, which
62//! exists only in a fragment shader — and the attractor and the swarm sample the
63//! LUT once *per particle*, in the vertex stage and on the CPU respectively, where
64//! a point sprite has a single palette coordinate and so there is no gradient
65//! across it to contour. So **banding reaches every scene; contours reach the
66//! continuous-field scenes** (the fragment field and reaction-diffusion).
67//! `palette_contour` elsewhere is inert and nothing warns, because the param *is*
68//! known — which is why `presets/README.md` says so beside it.
69
70// Hot-path panic-denial pragma (Plan 0002 Phase 2; render/ is scanned by the
71// hygiene guard). `sample` runs per particle per frame in the swarm.
72#![deny(
73 clippy::unwrap_used,
74 clippy::expect_used,
75 clippy::indexing_slicing,
76 clippy::panic,
77 clippy::unreachable
78)]
79
80use std::f32::consts::TAU;
81
82/// LUT resolution: 256 entries span the gradient's `t`, one texel per entry.
83pub const LUT_SIZE: usize = 256;
84
85/// One RGB entry — **linear light** in `[0, 1]`, used directly as color. An
86/// authored `[palette]` stop is sRGB and is decoded into this space once at the
87/// load boundary by [`srgb_to_linear`] (ADR-0151); the engine's own gradients
88/// below are written in it directly.
89pub type Rgb = [f32; 3];
90
91/// Decode one sRGB-encoded channel in `[0, 1]` to linear light — the IEC
92/// 61966-2-1 transfer function, exactly.
93///
94/// **This is the whole of what a `[palette]` stop goes through** (ADR-0151). The
95/// LUT holds light and the display write encodes it again on the way to 8-bit, so
96/// a stop consumed raw arrives lifted: `#c81423` renders `#dd4c64`, its green
97/// channel nearly quadrupled. Applying the decode at the load boundary — where a
98/// stop is validated, once per preset — leaves the LUT and every sample site
99/// exactly as they were; the table is constant for its lifetime, so per-sample
100/// decoding would buy nothing and cost the hot path.
101///
102/// Out-of-range input is clamped, so the function is total: the load boundary
103/// already rejects a non-finite channel and clamps the array form, and this makes
104/// the contract hold without depending on that.
105pub fn srgb_to_linear(c: f32) -> f32 {
106 let c = c.clamp(0.0, 1.0);
107 if c <= 0.04045 {
108 c / 12.92
109 } else {
110 ((c + 0.055) / 1.055).powf(2.4)
111 }
112}
113
114/// [`srgb_to_linear`] per channel — the form the load boundary calls.
115pub fn srgb_to_linear_rgb(rgb: Rgb) -> Rgb {
116 let [r, g, b] = rgb;
117 [srgb_to_linear(r), srgb_to_linear(g), srgb_to_linear(b)]
118}
119
120/// Rec. 601 luma weights — the single definition of "brightness" the shared
121/// `saturation` desaturates toward, mirrored verbatim in every scene's WGSL.
122const LUMA: Rgb = [0.299, 0.587, 0.114];
123
124/// How a built-in palette's gradient is generated. Named palettes map to one of
125/// these; custom `stops` (Phase 2) reuse [`Gradient::Stops`], so named and custom
126/// bake through the same path.
127enum Gradient<'a> {
128 /// The iq cosine model: `channel = a + b*cos(2π*(c*t + d))`.
129 Cosine { a: Rgb, b: Rgb, c: Rgb, d: Rgb },
130 /// A piecewise-linear gradient through `(at, color)` stops sorted by `at`.
131 Stops(&'a [(f32, Rgb)]),
132}
133
134/// A built-in named palette. Extend as later work curates more; unknown names are
135/// rejected at the load boundary (`schema.rs`).
136#[derive(Debug, Clone, Copy, PartialEq, Eq)]
137pub enum NamedPalette {
138 /// The exact current iq cosine — the **default**, so shipped presets are
139 /// unchanged. `d = (0.10, 0.42, 0.62)` reproduces `fragment_field`/`swarm`.
140 Spectrum,
141 /// Warm embers: deep red through orange to pale gold.
142 Ember,
143 /// Cool ice: deep blue through cyan to near-white.
144 Ice,
145 /// Grayscale black → white.
146 Mono,
147 /// Aurora: deep green through teal to violet.
148 Aurora,
149}
150
151impl NamedPalette {
152 /// Every built-in palette, in roster order — the closed set, and the list
153 /// the schema export renders rather than restating.
154 pub const ALL: [NamedPalette; 5] = [
155 NamedPalette::Spectrum,
156 NamedPalette::Ember,
157 NamedPalette::Ice,
158 NamedPalette::Mono,
159 NamedPalette::Aurora,
160 ];
161
162 /// The `[palette] name` this parses from — [`from_name`](Self::from_name)'s
163 /// inverse.
164 pub fn as_str(self) -> &'static str {
165 match self {
166 NamedPalette::Spectrum => "spectrum",
167 NamedPalette::Ember => "ember",
168 NamedPalette::Ice => "ice",
169 NamedPalette::Mono => "mono",
170 NamedPalette::Aurora => "aurora",
171 }
172 }
173
174 /// Parse a `[palette] name` string, or `None` if unknown.
175 pub fn from_name(name: &str) -> Option<Self> {
176 Some(match name {
177 "spectrum" => NamedPalette::Spectrum,
178 "ember" => NamedPalette::Ember,
179 "ice" => NamedPalette::Ice,
180 "mono" => NamedPalette::Mono,
181 "aurora" => NamedPalette::Aurora,
182 _ => return None,
183 })
184 }
185
186 /// The gradient this named palette bakes from.
187 fn gradient(self) -> Gradient<'static> {
188 match self {
189 // The exact fragment/swarm cosine (a=b=0.5, c=1, d as below).
190 NamedPalette::Spectrum => Gradient::Cosine {
191 a: [0.5, 0.5, 0.5],
192 b: [0.5, 0.5, 0.5],
193 c: [1.0, 1.0, 1.0],
194 d: [0.10, 0.42, 0.62],
195 },
196 NamedPalette::Ember => Gradient::Stops(&[
197 (0.0, [0.05, 0.01, 0.0]),
198 (0.45, [0.6, 0.12, 0.02]),
199 (0.75, [1.0, 0.42, 0.06]),
200 (1.0, [1.0, 0.86, 0.52]),
201 ]),
202 NamedPalette::Ice => Gradient::Stops(&[
203 (0.0, [0.0, 0.05, 0.18]),
204 (0.5, [0.09, 0.42, 0.72]),
205 (0.8, [0.4, 0.75, 0.92]),
206 (1.0, [0.85, 0.95, 1.0]),
207 ]),
208 NamedPalette::Mono => {
209 Gradient::Stops(&[(0.0, [0.0, 0.0, 0.0]), (1.0, [1.0, 1.0, 1.0])])
210 }
211 NamedPalette::Aurora => Gradient::Stops(&[
212 (0.0, [0.0, 0.1, 0.06]),
213 (0.4, [0.0, 0.8, 0.45]),
214 (0.7, [0.0, 0.5, 0.7]),
215 (1.0, [0.5, 0.1, 0.75]),
216 ]),
217 }
218 }
219}
220
221/// A validated, ready-to-bake palette selection from a preset's `[palette]`
222/// table — constructed at the load boundary (`schema.rs`), then trusted by
223/// [`Palette::bake`] (validate-at-the-boundary).
224#[derive(Debug, Clone)]
225pub enum PaletteConfig {
226 /// A built-in named palette.
227 Named(NamedPalette),
228 /// Custom gradient stops (`(at, color)`), pre-validated at the load boundary:
229 /// sorted `at` in `0..=1`, ≥2 entries, parseable colors. Baked through the
230 /// same stop path the named stop-list palettes use.
231 Custom(Vec<(f32, Rgb)>),
232}
233
234impl PaletteConfig {
235 /// The default when a preset declares no `[palette]` — the exact current
236 /// cosine, so shipped presets are unchanged.
237 pub fn default_spectrum() -> Self {
238 PaletteConfig::Named(NamedPalette::Spectrum)
239 }
240}
241
242/// An **A/B palette pair** baked into two 256-entry RGB LUTs (Plan 0020 Phase 4).
243/// A preset declares palette A (`[palette]`) and, optionally, palette B
244/// (`[palette_b]`); a bindable `palette_mix` (`0..1`) crossfades between them per
245/// frame. With no `[palette_b]`, `lut_b == lut_a`, so `palette_mix` is a no-op and
246/// a single-palette preset is unchanged. Sampled on the GPU (two 256×1 textures,
247/// lerped in-shader) and on the CPU (via [`sample`](Palette::sample)) from the
248/// same tables.
249///
250/// **`Clone`, deliberately not `Copy`** (Plan 0031 Phase 6): the struct is 6144
251/// bytes (`[Rgb; 256]` twice), so `Copy` made any accidental by-value use a silent
252/// 6 KB memcpy. A scene still holds its own baked copy for deferred upload — it
253/// just has to say `.clone()` to get one.
254#[derive(Clone)]
255pub struct Palette {
256 lut_a: [Rgb; LUT_SIZE],
257 lut_b: [Rgb; LUT_SIZE],
258}
259
260impl Palette {
261 /// Bake a single palette (A) into both LUTs, so `palette_mix` is a no-op.
262 /// Pure; off the hot path (preset load only).
263 pub fn bake(cfg: &PaletteConfig) -> Palette {
264 let lut = bake_config(cfg);
265 Palette {
266 lut_a: lut,
267 lut_b: lut,
268 }
269 }
270
271 /// Bake an A/B pair for a `palette_mix` crossfade. Pure; off the hot path.
272 pub fn bake_pair(a: &PaletteConfig, b: &PaletteConfig) -> Palette {
273 Palette {
274 lut_a: bake_config(a),
275 lut_b: bake_config(b),
276 }
277 }
278
279 /// The default palette (`spectrum`), used when a preset declares no
280 /// `[palette]` table.
281 pub fn default_spectrum() -> Palette {
282 Palette::bake(&PaletteConfig::default_spectrum())
283 }
284
285 /// Sample the crossfaded palette at `t` with A/B `mix` (`0` = A, `1` = B),
286 /// linearly interpolated with the same texel-center convention (and wrap) the
287 /// GPU texture sampler uses, so the CPU (swarm) and GPU scenes color
288 /// consistently. Allocation-free — the swarm calls this per particle per
289 /// frame. `mix <= 0` returns palette A exactly (matching the GPU `mix` at 0),
290 /// so `palette_mix = 0` is identical to palette A alone.
291 pub fn sample(&self, t: f32, mix: f32) -> Rgb {
292 let a = sample_lut(&self.lut_a, t);
293 if mix <= 0.0 {
294 return a;
295 }
296 let b = sample_lut(&self.lut_b, t);
297 let m = mix.min(1.0);
298 let [ar, ag, ab] = a;
299 let [br, bg, bb] = b;
300 [ar + (br - ar) * m, ag + (bg - ag) * m, ab + (bb - ab) * m]
301 }
302
303 /// Palette A's LUT as tight RGBA8 bytes for a 256×1 `Rgba8Unorm` texture
304 /// upload. Alpha is opaque; the display surface is 8-bit, so 8-bit LUT storage
305 /// adds no visible banding over the analytic cosine.
306 pub fn lut_a_bytes(&self) -> [u8; LUT_SIZE * 4] {
307 lut_to_bytes(&self.lut_a)
308 }
309
310 /// Palette B's LUT as tight RGBA8 bytes (the crossfade target texture).
311 pub fn lut_b_bytes(&self) -> [u8; LUT_SIZE * 4] {
312 lut_to_bytes(&self.lut_b)
313 }
314}
315
316/// Sample one LUT at `t`, linearly interpolated with the texel-center convention
317/// (and wrap) the GPU sampler uses. Shared by [`Palette::sample`] for both sides.
318fn sample_lut(lut: &[Rgb; LUT_SIZE], t: f32) -> Rgb {
319 // Texel centers sit at (i + 0.5)/N (matching `bake_gradient` and hardware
320 // filtering), so map `t` to x = t*N - 0.5 and lerp the bracketing texels.
321 let tw = t - t.floor(); // wrap to [0, 1)
322 let x = tw * LUT_SIZE as f32 - 0.5;
323 let i0 = x.floor().rem_euclid(LUT_SIZE as f32) as usize;
324 let i1 = (i0 + 1) % LUT_SIZE;
325 let frac = x - x.floor();
326 let a = lut.get(i0).copied().unwrap_or([0.0; 3]);
327 let b = lut.get(i1).copied().unwrap_or([0.0; 3]);
328 let [ar, ag, ab] = a;
329 let [br, bg, bb] = b;
330 [
331 ar + (br - ar) * frac,
332 ag + (bg - ag) * frac,
333 ab + (bb - ab) * frac,
334 ]
335}
336
337/// One baked LUT as tight RGBA8 bytes (opaque alpha) for a 256×1 texture upload.
338fn lut_to_bytes(lut: &[Rgb; LUT_SIZE]) -> [u8; LUT_SIZE * 4] {
339 let mut out = [0u8; LUT_SIZE * 4];
340 for (px, rgb) in out.chunks_exact_mut(4).zip(lut.iter()) {
341 let [r, g, b] = *rgb;
342 if let [pr, pg, pb, pa] = px {
343 *pr = to_u8(r);
344 *pg = to_u8(g);
345 *pb = to_u8(b);
346 *pa = 255;
347 }
348 }
349 out
350}
351
352/// Bake a [`PaletteConfig`] into a single LUT (the named or custom gradient).
353fn bake_config(cfg: &PaletteConfig) -> [Rgb; LUT_SIZE] {
354 match cfg {
355 PaletteConfig::Named(named) => bake_gradient(&named.gradient()),
356 PaletteConfig::Custom(stops) => bake_gradient(&Gradient::Stops(stops)),
357 }
358}
359
360/// The GPU LUT texture format. `Rgba8Unorm` is trivially filterable everywhere
361/// and — since the display surface is itself 8-bit — adds no visible banding
362/// over the analytic cosine (the no-regression concern), while needing no
363/// half-float conversion on upload.
364pub const LUT_TEXTURE_FORMAT: wgpu::TextureFormat = wgpu::TextureFormat::Rgba8Unorm;
365
366/// Create the shared 256×1 LUT texture a shader-colored scene binds and uploads
367/// its baked palette into. Centralized here so the fragment, reaction-diffusion,
368/// and attractor scenes stay byte-for-byte consistent (ADR-0021: one source both
369/// the GPU and CPU sample). Seed it with [`write_lut`] before first use.
370pub fn lut_texture(device: &wgpu::Device, label: &str) -> wgpu::Texture {
371 device.create_texture(&wgpu::TextureDescriptor {
372 label: Some(label),
373 size: wgpu::Extent3d {
374 width: LUT_SIZE as u32,
375 height: 1,
376 depth_or_array_layers: 1,
377 },
378 mip_level_count: 1,
379 sample_count: 1,
380 dimension: wgpu::TextureDimension::D2,
381 format: LUT_TEXTURE_FORMAT,
382 usage: wgpu::TextureUsages::TEXTURE_BINDING | wgpu::TextureUsages::COPY_DST,
383 view_formats: &[],
384 })
385}
386
387/// The LUT sampler: linear filtering, **repeat** across `u` (so a hue rotation
388/// past the gradient edge wraps like the cosine's periodic wheel) and clamp on
389/// the single-row `v`.
390pub fn lut_sampler(device: &wgpu::Device) -> wgpu::Sampler {
391 device.create_sampler(&wgpu::SamplerDescriptor {
392 label: Some("rlx-lut-sampler"),
393 address_mode_u: wgpu::AddressMode::Repeat,
394 address_mode_v: wgpu::AddressMode::ClampToEdge,
395 address_mode_w: wgpu::AddressMode::ClampToEdge,
396 mag_filter: wgpu::FilterMode::Linear,
397 min_filter: wgpu::FilterMode::Linear,
398 ..Default::default()
399 })
400}
401
402/// Upload one baked LUT (`palette.lut_a_bytes()` / `lut_b_bytes()`) into its 256×1
403/// texture. Off the hot path — called from a scene's deferred `set_palette`
404/// upload (first frame after a preset switch).
405pub fn write_lut(queue: &wgpu::Queue, texture: &wgpu::Texture, bytes: &[u8; LUT_SIZE * 4]) {
406 queue.write_texture(
407 wgpu::TexelCopyTextureInfo {
408 texture,
409 mip_level: 0,
410 origin: wgpu::Origin3d::ZERO,
411 aspect: wgpu::TextureAspect::All,
412 },
413 bytes,
414 wgpu::TexelCopyBufferLayout {
415 offset: 0,
416 bytes_per_row: Some(LUT_SIZE as u32 * 4),
417 rows_per_image: Some(1),
418 },
419 wgpu::Extent3d {
420 width: LUT_SIZE as u32,
421 height: 1,
422 depth_or_array_layers: 1,
423 },
424 );
425}
426
427// ---------------------------------------------------------------------------
428// The A/B LUT pair a shader-coloured scene owns
429// ---------------------------------------------------------------------------
430
431/// The two LUT textures, their views, the sampler, the baked palette awaiting
432/// upload and the dirty flag — the set every shader-coloured scene owns to
433/// render a `palette_mix` crossfade.
434///
435/// # The upload is deferred, and that is the invariant this type keeps
436///
437/// [`set`](LutPair::set) is called from `Scene::set_palette`, which has no
438/// `Queue`: a preset switch bakes a palette on the CPU and the GPU upload has to
439/// wait for the next frame. So `set` stores the palette and raises `dirty`, and
440/// [`flush`](LutPair::flush) — called at the top of `render`, where a `Queue`
441/// exists — uploads and clears it. Two `set`s between frames cost one upload;
442/// a frame with no `set` costs none.
443///
444/// A **freshly constructed pair is dirty**, because its textures are empty. A
445/// scene that builds its GPU resources lazily (or rebuilds them on a resize)
446/// therefore gets the upload for free, but must re-`set` the palette it is
447/// actually holding — [`new`](LutPair::new) can only seed the default.
448///
449/// # It owns resources, never a layout shape
450///
451/// [`bind_entries`](LutPair::bind_entries) takes all three binding numbers from
452/// the caller and the caller's own `create_bind_group_layout` still spells the
453/// entries. Nothing here can make two layouts share a shape, which is what
454/// ADR-0058 forbids without recorded evidence — and the six scenes bind this
455/// triple at genuinely different indices and in different orders.
456pub struct LutPair {
457 texture_a: wgpu::Texture,
458 texture_b: wgpu::Texture,
459 view_a: wgpu::TextureView,
460 view_b: wgpu::TextureView,
461 sampler: wgpu::Sampler,
462 palette: Palette,
463 dirty: bool,
464}
465
466impl LutPair {
467 /// Both textures, both views and the sampler, seeded with the default
468 /// palette and **dirty** — the textures hold no bytes until the first
469 /// [`flush`](LutPair::flush).
470 ///
471 /// `stem` names the pair: the textures are labelled `<stem>-lut-a` and
472 /// `<stem>-lut-b`.
473 pub fn new(device: &wgpu::Device, stem: &str) -> Self {
474 let texture_a = lut_texture(device, &format!("{stem}-lut-a"));
475 let texture_b = lut_texture(device, &format!("{stem}-lut-b"));
476 let view_a = texture_a.create_view(&wgpu::TextureViewDescriptor::default());
477 let view_b = texture_b.create_view(&wgpu::TextureViewDescriptor::default());
478 Self {
479 texture_a,
480 texture_b,
481 view_a,
482 view_b,
483 sampler: lut_sampler(device),
484 palette: Palette::default_spectrum(),
485 dirty: true,
486 }
487 }
488
489 /// Hold `palette` for upload on the next [`flush`](LutPair::flush). A
490 /// 6 KB array copy, off the hot path (preset switch or resource build).
491 pub fn set(&mut self, palette: &Palette) {
492 self.palette = palette.clone();
493 self.dirty = true;
494 }
495
496 /// Upload the held palette into both textures if anything has changed since
497 /// the last call, and report whether it did.
498 ///
499 /// Called once per frame from `render`. The return value is what the unit
500 /// test reads; the scenes ignore it.
501 pub fn flush(&mut self, queue: &wgpu::Queue) -> bool {
502 if !self.dirty {
503 return false;
504 }
505 write_lut(queue, &self.texture_a, &self.palette.lut_a_bytes());
506 write_lut(queue, &self.texture_b, &self.palette.lut_b_bytes());
507 self.dirty = false;
508 true
509 }
510
511 /// The palette A texture's view.
512 pub fn view_a(&self) -> &wgpu::TextureView {
513 &self.view_a
514 }
515
516 /// The palette B texture's view.
517 pub fn view_b(&self) -> &wgpu::TextureView {
518 &self.view_b
519 }
520
521 /// The shared LUT sampler.
522 pub fn sampler(&self) -> &wgpu::Sampler {
523 &self.sampler
524 }
525
526 /// The three bind-group entries, at the binding numbers the caller names.
527 ///
528 /// **The array is ordered by LUT role — A, B, sampler — not by binding
529 /// number**, because the callers disagree on both. `shape_field` and
530 /// `shape_collage` bind the sampler at 0 and the textures at 1 and 2;
531 /// `fragment_field` and `warp_mesh` bind A, B, sampler at 0, 1, 2; the
532 /// attractor at 1, 2, 3 and reaction-diffusion at 3, 4, 5. Each entry
533 /// carries its own `binding`, which is what wgpu matches against the layout,
534 /// so spreading this array into an `entries` list in role order is correct at
535 /// every one of them.
536 pub fn bind_entries(
537 &self,
538 binding_a: u32,
539 binding_b: u32,
540 binding_sampler: u32,
541 ) -> [wgpu::BindGroupEntry<'_>; 3] {
542 [
543 wgpu::BindGroupEntry {
544 binding: binding_a,
545 resource: wgpu::BindingResource::TextureView(&self.view_a),
546 },
547 wgpu::BindGroupEntry {
548 binding: binding_b,
549 resource: wgpu::BindingResource::TextureView(&self.view_b),
550 },
551 wgpu::BindGroupEntry {
552 binding: binding_sampler,
553 resource: wgpu::BindingResource::Sampler(&self.sampler),
554 },
555 ]
556 }
557}
558
559// --- Banding (ADR-0078) -----------------------------------------------------
560
561/// `palette_steps` default — 0, which is off: the smooth ramp every preset drew
562/// before this existed.
563pub const DEFAULT_PALETTE_STEPS: f32 = 0.0;
564/// `palette_contour` default — 0, no contour.
565pub const DEFAULT_PALETTE_CONTOUR: f32 = 0.0;
566/// At or below this band count the banding is **off**, and off is the exact
567/// identity rather than a degenerate case of the quantized path: one band would
568/// snap the whole palette to `(0 + 0.5)/1`, a single flat colour.
569pub const MIN_ACTIVE_STEPS: f32 = 1.0;
570/// Ceiling on the band count. Past a few dozen bands over the range a preset's
571/// `color_span` covers, the steps are narrower than the gradient's own 256-entry
572/// resolution and the banding stops being visible as banding.
573pub const MAX_PALETTE_STEPS: f32 = 64.0;
574
575/// The band count the sample sites are handed: clamped into `[0, MAX]`, then
576/// **rounded to an integer**, with a non-finite binding falling back to off.
577///
578/// This is `kaleidoscope.rs`'s `fold_order` treatment for `fold_order`'s reason,
579/// on a different seam. `[smoothing]` and preset dissolves sweep a binding
580/// *continuously* between two settings, and a fractional band count does not step
581/// — it leaves every band boundary crawling across the field, one per frame, which
582/// reads as shimmer rather than as a colour change. Rounding on the CPU keeps that
583/// precondition on the CPU, where it is visible.
584pub fn band_steps(steps: f32) -> f32 {
585 if steps.is_finite() {
586 steps.clamp(0.0, MAX_PALETTE_STEPS).round()
587 } else {
588 DEFAULT_PALETTE_STEPS
589 }
590}
591
592/// Quantize a palette coordinate onto `steps` hard bands — **the canonical
593/// definition** every LUT sample site in the engine mirrors (module docs).
594///
595/// `t' = (floor(t·N) + 0.5)/N` lands on each band's *centre*, so the colour a band
596/// takes is the one the smooth ramp had in the middle of it rather than at its
597/// edge. Below [`MIN_ACTIVE_STEPS`] (tested as `< 1.5`, since [`band_steps`] has
598/// already rounded) the coordinate passes through **untouched** — the exact
599/// identity, which is what keeps every shipped preset and every golden baseline
600/// byte-identical.
601///
602/// Negative and above-1 coordinates are fine and are the common case: the LUT is
603/// repeat-addressed, so a `color_span` above 1 wraps it, and `floor` keeps the
604/// quantization aligned across every wrap.
605pub fn band_coord(t: f32, steps: f32) -> f32 {
606 if steps < 1.5 {
607 return t;
608 }
609 ((t * steps).floor() + 0.5) / steps
610}
611
612/// The contour depth the fragment sites are handed: clamped to `[0, 1]`, with a
613/// non-finite binding falling back to none.
614///
615/// The contour itself has no CPU definition to be canonical — it is drawn from
616/// `fwidth`, which exists only in a fragment shader — so the WGSL is the
617/// implementation and its two copies are what the drift test compares. This is the
618/// part of it that *can* live on the CPU.
619pub fn band_contour(contour: f32) -> f32 {
620 if contour.is_finite() {
621 contour.clamp(0.0, 1.0)
622 } else {
623 DEFAULT_PALETTE_CONTOUR
624 }
625}
626
627/// Apply the shared `saturation` modulation to a sampled color — the canonical
628/// CPU definition the WGSL mirrors (see the module docs). `1.0` is unchanged,
629/// `0.0` is grayscale, `> 1.0` oversaturates.
630pub fn desaturate(rgb: Rgb, saturation: f32) -> Rgb {
631 let [r, g, b] = rgb;
632 let [lr, lg, lb] = LUMA;
633 let luma = r * lr + g * lg + b * lb;
634 [
635 luma + (r - luma) * saturation,
636 luma + (g - luma) * saturation,
637 luma + (b - luma) * saturation,
638 ]
639}
640
641/// Bake a gradient into the 256-entry LUT. Entry `i` holds the color at the
642/// texel center `t = (i + 0.5)/N`, so sampling the resulting texture (or
643/// [`Palette::sample`]) at a coordinate `u` returns the gradient at `u` with
644/// sub-texel accuracy.
645fn bake_gradient(g: &Gradient<'_>) -> [Rgb; LUT_SIZE] {
646 let mut lut = [[0.0f32; 3]; LUT_SIZE];
647 for (i, slot) in lut.iter_mut().enumerate() {
648 let t = (i as f32 + 0.5) / LUT_SIZE as f32;
649 *slot = match g {
650 Gradient::Cosine { a, b, c, d } => cosine_at(*a, *b, *c, *d, t),
651 Gradient::Stops(stops) => stops_at(stops, t),
652 };
653 }
654 lut
655}
656
657/// The iq cosine palette `a + b*cos(2π*(c*t + d))` per channel, clamped to
658/// `[0, 1]`.
659fn cosine_at(a: Rgb, b: Rgb, c: Rgb, d: Rgb, t: f32) -> Rgb {
660 let [ar, ag, ab] = a;
661 let [br, bg, bb] = b;
662 let [cr, cg, cb] = c;
663 let [dr, dg, db] = d;
664 [
665 (ar + br * (TAU * (cr * t + dr)).cos()).clamp(0.0, 1.0),
666 (ag + bg * (TAU * (cg * t + dg)).cos()).clamp(0.0, 1.0),
667 (ab + bb * (TAU * (cb * t + db)).cos()).clamp(0.0, 1.0),
668 ]
669}
670
671/// Sample a sorted `(at, color)` stop list at `t`, clamping below the first / above
672/// the last stop and linearly interpolating between the bracketing pair.
673fn stops_at(stops: &[(f32, Rgb)], t: f32) -> Rgb {
674 let mut lo: Option<(f32, Rgb)> = None;
675 for &(at, color) in stops {
676 if at <= t {
677 lo = Some((at, color));
678 } else {
679 // First stop past `t`: interpolate from `lo` (or clamp if none).
680 let Some((lat, lcol)) = lo else {
681 return color;
682 };
683 let span = (at - lat).max(1e-6);
684 let f = ((t - lat) / span).clamp(0.0, 1.0);
685 let [lr, lg, lb] = lcol;
686 let [hr, hg, hb] = color;
687 return [lr + (hr - lr) * f, lg + (hg - lg) * f, lb + (hb - lb) * f];
688 }
689 }
690 // `t` is at or past the last stop: clamp to it (or black if the list is empty,
691 // which the load boundary rejects — ≥2 stops).
692 lo.map(|(_, color)| color).unwrap_or([0.0; 3])
693}
694
695/// Round a `[0, 1]` channel to an 8-bit value.
696fn to_u8(x: f32) -> u8 {
697 (x.clamp(0.0, 1.0) * 255.0 + 0.5) as u8
698}
699
700#[cfg(test)]
701mod tests {
702 #![allow(clippy::unwrap_used, clippy::indexing_slicing, clippy::panic)]
703
704 use super::*;
705 use crate::render::RenderError;
706 use crate::render::context::RenderContext;
707
708 /// **The deferred upload costs one `write_texture` pair per change and none
709 /// otherwise** — the contract every scene's `render` leans on when it calls
710 /// `flush` unconditionally on the hot path.
711 ///
712 /// A fresh pair is dirty because its textures hold no bytes yet, so the first
713 /// flush after construction uploads. After that only a `set` can make one
714 /// upload again, and two `set`s between frames still cost one — which is what
715 /// makes a preset dissolve, which re-`set`s both sides every frame it runs,
716 /// bounded rather than proportional to how often the palette is touched.
717 ///
718 /// Needs a GPU adapter to create the textures, so it skips on runners without
719 /// one (ADR-0016).
720 #[test]
721 fn the_lut_pair_uploads_once_per_set_and_never_otherwise() {
722 let ctx = match RenderContext::new_headless(16, 16, true) {
723 Ok(ctx) => ctx,
724 Err(RenderError::RequestAdapter(_)) => {
725 eprintln!("skipped: no GPU adapter on this runner (ADR-0016)");
726 return;
727 }
728 Err(e) => panic!("headless context build failed: {e}"),
729 };
730
731 let mut luts = LutPair::new(&ctx.device, "lut-pair-test");
732 assert!(
733 luts.flush(&ctx.queue),
734 "a fresh pair's textures are empty, so its first flush uploads"
735 );
736 assert!(
737 !luts.flush(&ctx.queue),
738 "nothing changed since, so the second flush uploads nothing"
739 );
740
741 luts.set(&Palette::bake(&PaletteConfig::Named(NamedPalette::Ember)));
742 assert!(luts.flush(&ctx.queue), "one set, one upload");
743 assert!(!luts.flush(&ctx.queue), "and only one");
744
745 // Two sets between frames: still one upload, of the LAST palette set.
746 luts.set(&Palette::bake(&PaletteConfig::Named(NamedPalette::Ice)));
747 luts.set(&Palette::bake(&PaletteConfig::Named(NamedPalette::Mono)));
748 assert!(luts.flush(&ctx.queue), "two sets still cost one upload");
749 assert!(!luts.flush(&ctx.queue));
750
751 // Setting the same palette again is still a set: the pair compares
752 // nothing, deliberately — a 6 KB array comparison per switch would buy
753 // an upload nobody measured as costly.
754 let same = Palette::bake(&PaletteConfig::Named(NamedPalette::Mono));
755 luts.set(&same);
756 assert!(luts.flush(&ctx.queue));
757 }
758
759 /// The exact analytic cosine the fragment field / swarm used before this
760 /// module — the no-regression reference.
761 fn cosine_reference(t: f32) -> Rgb {
762 cosine_at(
763 [0.5, 0.5, 0.5],
764 [0.5, 0.5, 0.5],
765 [1.0, 1.0, 1.0],
766 [0.10, 0.42, 0.62],
767 t,
768 )
769 }
770
771 /// The load-bearing no-regression guarantee (Plan 0020 Phase 1): the default
772 /// `spectrum` palette baked into the LUT reproduces the prior analytic cosine
773 /// (`d = 0.10, 0.42, 0.62`) within a small tolerance at several sampled `t`.
774 /// That cosine is the one the **fragment field, swarm, and attractor** all
775 /// used before this module, so this single assertion is their shared default-
776 /// path no-regression proof (each also has a golden fixture within tolerance).
777 /// Reaction-diffusion used a *different* cosine and was deliberately unified
778 /// onto `spectrum` in Phase 5 (its golden baseline re-blessed), so it is the
779 /// one scene whose default look intentionally changed. If this drifts, every
780 /// shipped preset on those three scenes shifts color.
781 #[test]
782 fn spectrum_reproduces_the_prior_cosine() {
783 let pal = Palette::default_spectrum();
784 // Eight `t` across the range, including the fragment field's actual
785 // operating band (field*0.6 -> [0, 0.6]) and the wrap edges.
786 let samples = [0.0, 0.1, 0.2, 0.3, 0.45, 0.6, 0.75, 0.95];
787 for &t in &samples {
788 let got = pal.sample(t, 0.0);
789 let want = cosine_reference(t);
790 for k in 0..3 {
791 assert!(
792 (got[k] - want[k]).abs() < 0.01,
793 "spectrum LUT drifts from the cosine at t={t} channel {k}: \
794 got {} want {}",
795 got[k],
796 want[k]
797 );
798 }
799 }
800 }
801
802 /// The stop-list bake path (used by every named palette except `spectrum`):
803 /// `mono` (black → white) is exact at the ends and linear between, so the
804 /// midpoint is mid-gray. This exercises the same `bake_gradient` path the
805 /// Phase 2 custom stops reuse.
806 #[test]
807 fn stops_interpolate_between_control_points() {
808 let pal = Palette::bake(&PaletteConfig::Named(NamedPalette::Mono));
809 let lo = pal.sample(0.002, 0.0);
810 assert!(
811 lo[0] < 0.05 && lo[1] < 0.05 && lo[2] < 0.05,
812 "start ~black: {lo:?}"
813 );
814 let hi = pal.sample(0.998, 0.0);
815 assert!(
816 hi[0] > 0.95 && hi[1] > 0.95 && hi[2] > 0.95,
817 "end ~white: {hi:?}"
818 );
819 let mid = pal.sample(0.5, 0.0);
820 assert!(
821 (mid[0] - 0.5).abs() < 0.05
822 && (mid[1] - 0.5).abs() < 0.05
823 && (mid[2] - 0.5).abs() < 0.05,
824 "midpoint is mid-gray: {mid:?}"
825 );
826 }
827
828 /// `saturation = 1` is identity; `saturation = 0` collapses to luma (gray);
829 /// the shared definition both CPU and GPU use.
830 #[test]
831 fn saturation_endpoints() {
832 let c = [0.8, 0.2, 0.1];
833 let same = desaturate(c, 1.0);
834 for k in 0..3 {
835 assert!(
836 (same[k] - c[k]).abs() < 1e-5,
837 "saturation 1 is unchanged: {same:?} vs {c:?}"
838 );
839 }
840 let gray = desaturate(c, 0.0);
841 assert!(
842 (gray[0] - gray[1]).abs() < 1e-6 && (gray[1] - gray[2]).abs() < 1e-6,
843 "saturation 0 is gray: {gray:?}"
844 );
845 }
846
847 /// The A/B crossfade (Plan 0020 Phase 4): `mix = 0` is exactly palette A,
848 /// `mix = 1` is palette B, and `mix = 0.5` lands between — the bindable
849 /// `palette_mix` behaviour, with the `mix = 0` = A-alone guarantee.
850 #[test]
851 fn palette_mix_crossfades_a_to_b() {
852 // A = mono (black->white), B = a solid mid-gray via two equal stops, so at
853 // a fixed `t` the two sides differ and the mix is easy to reason about.
854 let a = PaletteConfig::Named(NamedPalette::Mono);
855 let b = PaletteConfig::Named(NamedPalette::Ember);
856 let pair = Palette::bake_pair(&a, &b);
857 let a_only = Palette::bake(&a);
858
859 let t = 0.85;
860 // mix = 0 is exactly palette A alone (byte-for-byte with the single bake).
861 assert_eq!(
862 pair.sample(t, 0.0),
863 a_only.sample(t, 0.0),
864 "mix=0 is palette A alone"
865 );
866 // mix = 1 is palette B.
867 let b_only = Palette::bake(&b);
868 let at_one = pair.sample(t, 1.0);
869 let want_b = b_only.sample(t, 0.0);
870 for k in 0..3 {
871 assert!((at_one[k] - want_b[k]).abs() < 1e-6, "mix=1 is palette B");
872 }
873 // mix = 0.5 is the midpoint of A and B per channel.
874 let a_col = a_only.sample(t, 0.0);
875 let mid = pair.sample(t, 0.5);
876 for k in 0..3 {
877 let expected = a_col[k] + (want_b[k] - a_col[k]) * 0.5;
878 assert!(
879 (mid[k] - expected).abs() < 1e-6,
880 "mix=0.5 is the A/B midpoint"
881 );
882 }
883 }
884
885 // --- Banding (ADR-0078) ---------------------------------------------
886
887 /// `palette_steps = N` leaves exactly `N` distinct palette coordinates over
888 /// the gradient's range. Asserted on the CPU-side expression rather than on
889 /// a capture, because a pixel count would also see the bloom, the backdrop
890 /// and the 8-bit round-trip.
891 #[test]
892 fn six_steps_leave_exactly_six_palette_coordinates() {
893 for n in [2.0f32, 4.0, 6.0, 8.0, 16.0] {
894 let mut seen: Vec<f32> = Vec::new();
895 // A dense sweep of the unit range, which is what a field level
896 // multiplied by a `color_span` of 1 delivers.
897 for i in 0..10_000 {
898 let t = i as f32 / 10_000.0;
899 let q = band_coord(t, n);
900 if !seen.iter().any(|v| (v - q).abs() < 1e-6) {
901 seen.push(q);
902 }
903 }
904 assert_eq!(
905 seen.len(),
906 n as usize,
907 "palette_steps = {n} produced {} distinct coordinates, not {n}",
908 seen.len()
909 );
910 // ...and each one is a band CENTRE, not an edge.
911 for (k, q) in seen.iter().enumerate() {
912 let _ = k;
913 let centre = ((q * n).floor() + 0.5) / n;
914 assert!(
915 (q - centre).abs() < 1e-6,
916 "quantized coordinate {q} is not a band centre at N = {n}"
917 );
918 }
919 }
920 }
921
922 /// Off is the **exact** identity, which is what keeps every shipped preset
923 /// and every golden baseline byte-identical. Not approximately: the
924 /// coordinate is returned untouched rather than run through a one-band
925 /// quantization, which would snap the whole palette to a single colour.
926 #[test]
927 fn banding_below_two_steps_is_the_exact_identity() {
928 for steps in [0.0f32, 1.0] {
929 for i in -50..150 {
930 let t = i as f32 / 100.0;
931 assert_eq!(
932 band_coord(t, steps),
933 t,
934 "palette_steps = {steps} is not the identity at t = {t}"
935 );
936 }
937 }
938 // The quantization does reach a coordinate at 2, or the above is a
939 // statement about a function that never does anything.
940 assert_ne!(band_coord(0.1, 2.0), 0.1);
941 }
942
943 /// The band count never reaches a sample site fractional, for
944 /// `kaleidoscope.rs`'s `fold_order` reason: an eased binding sweeps
945 /// continuously, and a fractional band count leaves every boundary crawling
946 /// rather than stepping.
947 #[test]
948 fn band_steps_is_always_integral_and_in_range() {
949 for &raw in &[-9.0f32, 0.0, 0.4, 3.5, 6.0, 6.4, 6.6, 1e9] {
950 let n = band_steps(raw);
951 assert_eq!(n, n.round(), "band_steps({raw}) = {n} is not an integer");
952 assert!((0.0..=MAX_PALETTE_STEPS).contains(&n));
953 }
954 assert_eq!(band_steps(6.4), 6.0);
955 assert_eq!(band_steps(6.6), 7.0);
956 assert_eq!(band_steps(f32::NAN), DEFAULT_PALETTE_STEPS);
957 assert_eq!(band_steps(f32::INFINITY), DEFAULT_PALETTE_STEPS);
958 assert_eq!(band_contour(2.0), 1.0);
959 assert_eq!(band_contour(-1.0), 0.0);
960 assert_eq!(band_contour(f32::NAN), DEFAULT_PALETTE_CONTOUR);
961 }
962
963 // --- The WGSL copies have not drifted --------------------------------
964 //
965 // ADR-0078's accepted cost: this project has no shader include mechanism, so
966 // the banding expression is a commented verbatim copy at every WGSL sample
967 // site, exactly as `apply_saturation` mirrors `desaturate`. This is the
968 // mitigation, and it is weaker than not having copies — it can only see that
969 // the copies agree with the text below, not that the text is right.
970 //
971 // The scene sources are pulled in with `include_str!` rather than read from
972 // disk, so a moved or renamed file fails to COMPILE here instead of silently
973 // checking nothing.
974
975 /// The canonical WGSL banding function. Must appear byte-for-byte in every
976 /// shader that samples the LUT.
977 const BAND_COORD_WGSL: &str = "\
978fn band_coord(t: f32, steps: f32) -> f32 {
979 if (steps < 1.5) {
980 return t;
981 }
982 return (floor(t * steps) + 0.5) / steps;
983}";
984
985 /// The canonical WGSL contour function. **Fragment stage only** — it calls
986 /// `fwidth`.
987 const BAND_CONTOUR_WGSL: &str = "\
988fn band_contour(
989 t: f32,
990 steps: f32,
991 amount: f32,
992 lut_a: texture_2d<f32>,
993 lut_b: texture_2d<f32>,
994 lut_samp: sampler,
995 mix_ab: f32,
996) -> f32 {
997 let f = t * steps;
998 let w = max(fwidth(f), 1e-5);
999 if (steps < 1.5 || amount <= 0.0) {
1000 return 1.0;
1001 }
1002 let n = round(f);
1003 let m = clamp(mix_ab, 0.0, 1.0);
1004 let lo = mix(
1005 textureSampleLevel(lut_a, lut_samp, vec2<f32>((n - 0.5) / steps, 0.5), 0.0).rgb,
1006 textureSampleLevel(lut_b, lut_samp, vec2<f32>((n - 0.5) / steps, 0.5), 0.0).rgb,
1007 m
1008 );
1009 let hi = mix(
1010 textureSampleLevel(lut_a, lut_samp, vec2<f32>((n + 0.5) / steps, 0.5), 0.0).rgb,
1011 textureSampleLevel(lut_b, lut_samp, vec2<f32>((n + 0.5) / steps, 0.5), 0.0).rgb,
1012 m
1013 );
1014 if (all(abs(hi - lo) < vec3<f32>(0.5 / 255.0))) {
1015 return 1.0;
1016 }
1017 let d = min(fract(f), 1.0 - fract(f));
1018 return 1.0 - clamp(amount, 0.0, 1.0) * (1.0 - smoothstep(0.0, w, d));
1019}";
1020
1021 const FRAGMENT_FIELD_SRC: &str = include_str!("scenes/fragment_field.rs");
1022 const REACTION_DIFFUSION_SRC: &str = include_str!("scenes/reaction_diffusion.rs");
1023 const PARTICLE_SHADERS_SRC: &str = include_str!("scenes/particles/shaders.rs");
1024 const SHAPE_FIELD_SRC: &str = include_str!("scenes/shape_field.rs");
1025 /// The **fourth** contour site. It was missing from the list below until Plan
1026 /// 0121 Phase 5, and its copy had drifted (`dd` for `d`) — so the test that
1027 /// exists to catch drift could not have caught this one, because the site it
1028 /// lived at was never iterated.
1029 const WARP_MESH_SRC: &str = include_str!("scenes/warp_mesh/shaders.rs");
1030
1031 #[test]
1032 fn every_wgsl_sample_site_carries_the_same_banding_expression() {
1033 for (name, src) in [
1034 ("fragment_field.rs", FRAGMENT_FIELD_SRC),
1035 ("reaction_diffusion.rs", REACTION_DIFFUSION_SRC),
1036 ("particles/shaders.rs", PARTICLE_SHADERS_SRC),
1037 ("shape_field.rs", SHAPE_FIELD_SRC),
1038 ] {
1039 assert!(
1040 src.contains(BAND_COORD_WGSL),
1041 "{name}'s copy of the WGSL `band_coord` has drifted from \
1042 palette.rs::band_coord — the two must stay one function written \
1043 twice, not two functions that agree on some inputs"
1044 );
1045 }
1046 }
1047
1048 /// ...and the contour reaches the **fragment-stage** scenes only. Asserted,
1049 /// not merely documented: the attractor's LUT read is in the vertex stage,
1050 /// where `fwidth` does not exist, so a copy landing there is a compile error
1051 /// at best and a silent nothing at worst.
1052 #[test]
1053 fn the_contour_reaches_the_fragment_sites_and_not_the_vertex_one() {
1054 for (name, src) in [
1055 ("fragment_field.rs", FRAGMENT_FIELD_SRC),
1056 ("reaction_diffusion.rs", REACTION_DIFFUSION_SRC),
1057 ("shape_field.rs", SHAPE_FIELD_SRC),
1058 ("warp_mesh/shaders.rs", WARP_MESH_SRC),
1059 ] {
1060 assert!(
1061 src.contains(BAND_CONTOUR_WGSL),
1062 "{name}'s copy of the WGSL `band_contour` has drifted"
1063 );
1064 }
1065 assert!(
1066 !PARTICLE_SHADERS_SRC.contains("fn band_contour"),
1067 "particles/shaders.rs grew a `band_contour` — its LUT read is in the \
1068 VERTEX stage, which has no derivatives and no gradient across a point \
1069 sprite to contour (ADR-0078)"
1070 );
1071 }
1072}