Skip to main content

Module palette

Module palette 

Source
Expand description

Shared palette system (ADR-0021, Plan 0020): one gradient baked once at load into a 256-entry RGB lookup table (LUT) that every shader-colored scene samples, replacing the per-scene hardcoded iq cosine palette().

A preset selects a built-in named palette (spectrum, ember, ice, mono, aurora) or (Plan 0020 Phase 2) a list of custom stops. Named palettes are themselves defined as built-in gradients — some generated from the cosine model, some as stop lists — so named and custom share one baked-LUT representation. The LUT is delivered to the GPU scenes (fragment field, reaction-diffusion, attractor) as a 256×1 texture and sampled on the CPU by the swarm; one bake, two consumers, no drift.

Baking is pure and off the hot path — a function of the config only (no clock, no randomness), run once on preset load — so it is deterministic (NFR 6). Palette::sample is allocation-free and runs per particle per frame (swarm), so this module carries the hot-path panic pragma.

§The colour space (ADR-0151)

A [palette] stop in a .toml is sRGB, and srgb_to_linear decodes it at the load boundary, so the LUT below holds linear light and a stop written #c81423 renders #c81423. The gradients defined here — the cosine and the named stop lists — are engine values already in that space and go through no decode; the cosine could not, being a generator rather than a triple.

The spectrum default is the cosine model exactly, so a preset that declares no [palette] is unaffected by this module — the load-bearing no-regression guarantee, gated by a unit test comparing sampled colors.

§Saturation (the single source of truth)

saturation is a bindable modulation applied to the sampled color, not baked into the LUT. It must be applied identically on the CPU (swarm) and in every scene’s WGSL, so the canonical definition lives here and each shader mirrors it verbatim:

luma = 0.299*r + 0.587*g + 0.114*b        (Rec. 601 luma)
out  = luma + (rgb - luma) * saturation    (1.0 = unchanged, 0.0 = grayscale)

hue is the other shared modulation: it offsets the LUT sample coordinate (pre-sample), so it is applied where the coordinate is computed, not here.

§Banding (ADR-0078) — the other single source of truth

palette_steps turns the smooth ramp into hard graphic bands by quantizing the palette coordinate rather than the baked LUT: t' = (floor(t·N) + 0.5)/N immediately before the sample. The bake above is untouched, which is the whole point — the band count has to be bindable to audio, and quantizing during the bake would cost a re-bake and a texture upload every frame, exactly the per-frame work the bake exists to remove.

band_coord is the canonical definition and every sample site mirrors it — the CPU sites call it, the WGSL sites carry a commented verbatim copy, the way apply_saturation mirrors desaturate. A test in this module asserts the copies have not drifted.

palette_contour is scoped, and the scoping is a fact about the pipeline rather than a policy. A screen-constant contour width needs fwidth, which exists only in a fragment shader — and the attractor and the swarm sample the LUT once per particle, in the vertex stage and on the CPU respectively, where a point sprite has a single palette coordinate and so there is no gradient across it to contour. So banding reaches every scene; contours reach the continuous-field scenes (the fragment field and reaction-diffusion). palette_contour elsewhere is inert and nothing warns, because the param is known — which is why presets/README.md says so beside it.

Structs§

LutPair
The two LUT textures, their views, the sampler, the baked palette awaiting upload and the dirty flag — the set every shader-coloured scene owns to render a palette_mix crossfade.
Palette
An A/B palette pair baked into two 256-entry RGB LUTs (Plan 0020 Phase 4). A preset declares palette A ([palette]) and, optionally, palette B ([palette_b]); a bindable palette_mix (0..1) crossfades between them per frame. With no [palette_b], lut_b == lut_a, so palette_mix is a no-op and a single-palette preset is unchanged. Sampled on the GPU (two 256×1 textures, lerped in-shader) and on the CPU (via sample) from the same tables.

Enums§

NamedPalette
A built-in named palette. Extend as later work curates more; unknown names are rejected at the load boundary (schema.rs).
PaletteConfig
A validated, ready-to-bake palette selection from a preset’s [palette] table — constructed at the load boundary (schema.rs), then trusted by Palette::bake (validate-at-the-boundary).

Constants§

DEFAULT_PALETTE_CONTOUR
palette_contour default — 0, no contour.
DEFAULT_PALETTE_STEPS
palette_steps default — 0, which is off: the smooth ramp every preset drew before this existed.
LUT_SIZE
LUT resolution: 256 entries span the gradient’s t, one texel per entry.
LUT_TEXTURE_FORMAT
The GPU LUT texture format. Rgba8Unorm is trivially filterable everywhere and — since the display surface is itself 8-bit — adds no visible banding over the analytic cosine (the no-regression concern), while needing no half-float conversion on upload.
MAX_PALETTE_STEPS
Ceiling on the band count. Past a few dozen bands over the range a preset’s color_span covers, the steps are narrower than the gradient’s own 256-entry resolution and the banding stops being visible as banding.
MIN_ACTIVE_STEPS
At or below this band count the banding is off, and off is the exact identity rather than a degenerate case of the quantized path: one band would snap the whole palette to (0 + 0.5)/1, a single flat colour.

Functions§

band_contour
The contour depth the fragment sites are handed: clamped to [0, 1], with a non-finite binding falling back to none.
band_coord
Quantize a palette coordinate onto steps hard bands — the canonical definition every LUT sample site in the engine mirrors (module docs).
band_steps
The band count the sample sites are handed: clamped into [0, MAX], then rounded to an integer, with a non-finite binding falling back to off.
desaturate
Apply the shared saturation modulation to a sampled color — the canonical CPU definition the WGSL mirrors (see the module docs). 1.0 is unchanged, 0.0 is grayscale, > 1.0 oversaturates.
lut_sampler
The LUT sampler: linear filtering, repeat across u (so a hue rotation past the gradient edge wraps like the cosine’s periodic wheel) and clamp on the single-row v.
lut_texture
Create the shared 256×1 LUT texture a shader-colored scene binds and uploads its baked palette into. Centralized here so the fragment, reaction-diffusion, and attractor scenes stay byte-for-byte consistent (ADR-0021: one source both the GPU and CPU sample). Seed it with write_lut before first use.
srgb_to_linear
Decode one sRGB-encoded channel in [0, 1] to linear light — the IEC 61966-2-1 transfer function, exactly.
srgb_to_linear_rgb
srgb_to_linear per channel — the form the load boundary calls.
write_lut
Upload one baked LUT (palette.lut_a_bytes() / lut_b_bytes()) into its 256×1 texture. Off the hot path — called from a scene’s deferred set_palette upload (first frame after a preset switch).

Type Aliases§

Rgb
One RGB entry — linear light in [0, 1], used directly as color. An authored [palette] stop is sRGB and is decoded into this space once at the load boundary by srgb_to_linear (ADR-0151); the engine’s own gradients below are written in it directly.