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}