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_mixcrossfade. - 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 bindablepalette_mix(0..1) crossfades between them per frame. With no[palette_b],lut_b == lut_a, sopalette_mixis 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 (viasample) from the same tables.
Enums§
- Named
Palette - A built-in named palette. Extend as later work curates more; unknown names are
rejected at the load boundary (
schema.rs). - Palette
Config - A validated, ready-to-bake palette selection from a preset’s
[palette]table — constructed at the load boundary (schema.rs), then trusted byPalette::bake(validate-at-the-boundary).
Constants§
- DEFAULT_
PALETTE_ CONTOUR palette_contourdefault — 0, no contour.- DEFAULT_
PALETTE_ STEPS palette_stepsdefault — 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.
Rgba8Unormis 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_spancovers, 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
stepshard 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
saturationmodulation to a sampled color — the canonical CPU definition the WGSL mirrors (see the module docs).1.0is unchanged,0.0is grayscale,> 1.0oversaturates. - 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-rowv. - 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_lutbefore 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_linearper 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 deferredset_paletteupload (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 bysrgb_to_linear(ADR-0151); the engine’s own gradients below are written in it directly.