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