Zero-GC OKLCH gradient baking and hot-path color sampling for high-throughput render loops.
Author your gradients in perceptually-uniform OKLCH space. Ship them as raw Uint32Array lookup tables. Sample millions of colors per second with pure integer math — zero allocations in the hot path.
┌─────────────────────────────┐ ┌──────────────────────────────┐
│ AUTHORING (init-time) │ │ RUNTIME (per frame) │
│ │ │ │
│ OKLCH stops │ bake │ sampleColorLUT(lut, t) │
│ ↓ │ ──────▶ │ ↓ │
│ Ottosson 2020 → sRGB │ once │ Uint32 ARGB / RGBA-LE │
│ ↓ │ │ ↓ │
│ Uint32Array LUT │ │ Direct write to ImageData │
└─────────────────────────────┘ └──────────────────────────────┘
Modern color theory has converged on OKLCH (Björn Ottosson, 2020) for one reason: equal numerical steps in OKLCH space produce equal perceptual steps to the human eye. RGB and HSL are not perceptually uniform — interpolating through them produces dead grey midpoints, banding, and unintended hue shifts.
The catch: OKLCH → sRGB conversion is heavy. A trig call, three matrix multiplications, three cubings, three gamma transfers, and four quantizations — per pixel, per frame. For a 100k particle system at 60fps that's 24 million OKLCH conversions per second. Even with a JIT, the per-call object allocations from naive implementations will tank the GC.
lite-color-lerp solves this by separating authoring from runtime. You bake a perceptually-correct gradient once into a flat Uint32Array. Per frame, you do an integer multiply, an | 0, and an array read. That's the entire hot path.
npm install @zakkster/lite-color-lerpESM only. Requires Node 18+ for development; the published artifact runs anywhere modern ES modules work (browser, Bun, Deno, Node).
Peer of @zakkster/lite-color, which provides the OKLCH multistop interpolator.
import {
bakeGradientLUT,
bakeGradientLUTRGBA,
sampleColorLUT,
} from '@zakkster/lite-color-lerp';
// 1. Author your gradient in OKLCH.
const sunset = [
{ l: 0.45, c: 0.20, h: 25 }, // deep crimson
{ l: 0.75, c: 0.18, h: 60 }, // amber
{ l: 0.92, c: 0.12, h: 95 }, // pale gold
];
// 2. Bake once at init.
const lut = bakeGradientLUT(sunset, 256); // 1 KB. That's your gradient.
// 3. Sample in your hot loop. Zero allocations, zero method dispatch.
function tick() {
for (let i = 0; i < entityCount; i++) {
const t = age[i] / lifespan[i];
const argb = sampleColorLUT(lut, t);
// ... use argb ...
}
}Bakes an OKLCH gradient into a Uint32Array packed as ARGB (0xAARRGGBB).
Use this variant when you intend to:
- Build CSS hex strings via
'#' + (v & 0xFFFFFF).toString(16) - Unpack channels manually for a WebGL uniform
- Store colors in a debugger or human-readable log
| Parameter | Type | Default | Notes |
|---|---|---|---|
stops |
OklchColor[] |
— | Non-empty. { l, c, h, a? }. |
resolution |
number |
256 |
LUT size. Min 2. 256 is plenty for most gradients. |
Throws on empty stops or resolution < 2.
Same input, same output type — but packed for direct writes into Canvas2D ImageData.
Browsers store ImageData.data as [R, G, B, A] bytes. On any little-endian machine (every browser ever), reading those four bytes as a Uint32 yields (A<<24) | (B<<16) | (G<<8) | R. This variant pre-packs the channels so you can skip channel-swapping at runtime:
const lut = bakeGradientLUTRGBA(sunset, 256);
const pixels = new Uint32Array(imageData.data.buffer);
// Drop a colored pixel at (x, y) — no channel math, no allocations.
pixels[y * width + x] = sampleColorLUT(lut, t);
ctx.putImageData(imageData, 0, 0);The hot path. Maps progress t ∈ [0, 1] to a packed Uint32 color.
Behavior:
t < 0clamps tolut[0].t > 1clamps tolut[lut.length - 1].NaNreturnslut[0](NaN comparisons are false;NaN | 0 === 0).- Sampling is nearest-neighbor. At 256 entries this is visually indistinguishable from interpolation for any sane gradient.
Zero allocations. No bounds check. No method dispatch. This function is six instructions on the JIT.
| You're doing this... | Use |
|---|---|
Writing pixels to ImageData via Uint32Array view |
bakeGradientLUTRGBA |
| Hex strings, CSS, debug logs, manual channel math | bakeGradientLUT |
| WebGL — depends on your unpack convention | Whichever matches yours |
Canvas2D fillStyle = '#rrggbb' |
bakeGradientLUT + format |
If you're unsure, bakeGradientLUTRGBA is the right default for browser graphics — it's the only path that lets you skip a per-pixel channel swap.
The bake phase is O(resolution) and runs once. At resolution 256 you're doing 256 OKLCH→sRGB conversions — sub-millisecond on any device made this decade.
The runtime phase per call:
sampleColorLUT(lut, t):
cmp t, 0 ; clamp branch
cmp t, 1 ; clamp branch
fmul tc, maxIdx ; scale
cvtss ; | 0 → truncate-to-int
mov eax, [lut+ecx*4]
ret
No object allocation. No closure. No prototype lookup. The result is a primitive number — V8 will generally keep it in a register through your inner loop.
Memory cost: 4 bytes per LUT entry. A 256-entry LUT is 1 KB. Even at 4096 entries you're at 16 KB — a single CPU L1 cache line cluster.
See Demo.html in the repo. The hot loop:
for (let i = 0; i < COUNT; i = (i + 1) | 0) {
pX[i] += pVx[i];
pY[i] += pVy[i];
pLife[i]++;
const t = pLife[i] / pMaxLife[i];
const color = sampleColorLUT(colorLUT, t);
const px = pX[i] | 0;
const py = pY[i] | 0;
pixels[py * width + px] = color;
}
ctx.putImageData(imageData, 0, 0);100,000 particles, perceptually-graded color, direct-to-memory pixel writes. On modern hardware this runs at a stable 60 fps with zero garbage collection events.
- Out-of-gamut colors (chroma values outside the sRGB triangle) are clamped during bake. Currently, this is a hard channel-clamp; gamut-mapped variants (preserving lightness or hue) are a future addition.
- Nearest-neighbor sampling is the default. For most gradients at resolution 256 the artifacts are imperceptible. A linear-interpolated sampler would require unpacking, lerping in linear-light space, and repacking — defeating the zero-GC contract. Don't ask for it unless you have a measured artifact.
- Single-source-of-truth math. The Ottosson coefficients used here match the reference C implementation exactly.
MIT. See LICENSE.txt.