Skip to main content

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}