@zakkster/lite-color-lerp
v1.1.0
Published
Zero-GC OKLCH to ARGB Uint32 gradient baking and hot-path sampling for high-performance ECS render loops.
Maintainers
Readme
@zakkster/lite-color-lerp
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 │
└─────────────────────────────┘ └──────────────────────────────┘Why this exists
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.
Install
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.
Quick start
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 ...
}
}API
bakeGradientLUT(stops, resolution = 256) → Uint32Array
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.
bakeGradientLUTRGBA(stops, resolution = 256) → Uint32Array
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);sampleColorLUT(lut, t) → number
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.
v1.1.0 — Cyclic bakes + packer injection
Two additions that expand the shape of what LUTs can represent, without compromising the zero-GC hot path or the standalone (no engine dep) posture.
sampleColorLUTWrapped(lut, t) → number
Companion hot-path sampler for LUTs baked with { closed: true }. Instead
of clamping, t is wrapped via t − Math.floor(t). Any float works — a raw
accumulating animation phase, a negative value, 13.7, all valid.
Cells are period-spaced, so the index formula is (tw * lut.length) | 0
(not lut.length − 1); consequence, sampleColorLUTWrapped(lut, 0) ===
sampleColorLUTWrapped(lut, 1.0).
The index is clamped to lut.length − 1, because t − Math.floor(t) does
not always land in [0, 1): for any tiny negative t (magnitude below
Number.EPSILON / 2, ~1.1e-16), t − (−1) rounds to exactly 1.0 in float64,
and (1.0 * N) | 0 is N — one past the end. An accumulating phase that
decrements through zero hits this. The clamp costs one perfectly-predicted
compare, measured at 0.957× the unguarded form over 31 interleaved rounds of 20M
samples: free. sampleColorLUT (open) was never exposed — its clamp(t, 0, 1)
already covered it.
opts.closed: true on both bakers
Bake a cyclic LUT. Cell spacing switches to i / resolution (no
duplicated endpoint), and the stop list is treated cyclically so the last
cell interpolates last-stop → first-stop. Pair with
sampleColorLUTWrapped at the read site.
const lut = bakeGradientLUTRGBA(
[{ l: 0.65, c: 0.18, h: 0 },
{ l: 0.65, c: 0.18, h: 120 },
{ l: 0.65, c: 0.18, h: 240 }],
256,
{ closed: true }
);
// Animated rotation — raw accumulating phase, no manual mod.
let phase = 0;
function frame(dt) {
phase += dt * 0.0005;
for (let i = 0; i < N; i++) {
pixels[i] = sampleColorLUTWrapped(lut, angleField[i] + phase);
}
}opts.pack: (l, c, h, a) → uint32 on both bakers
Custom uint32 packer override. Receives an OKLCH triplet plus alpha (0..1) and returns the packed pixel. When absent, the baker uses its default packer (ARGB or RGBA-LE per the function you called).
Why: it wires gamut-mapping without making
@zakkster/lite-color-engine a hard dependency. The engine ships a
MINDE (minimum-ΔE) packer that keeps out-of-gamut OKLCH values
perceptually close to their intended color instead of the naïve
per-channel clamp. Five lines to plug it in (one scratch buffer, one
adapter closure):
import { bakeGradientLUTRGBA } from '@zakkster/lite-color-lerp';
import { packOklchBufferToUint32MINDE } from '@zakkster/lite-color-engine';
const scratch = new Float32Array(3);
const lut = bakeGradientLUTRGBA(colors, 256, {
pack: (l, c, h, a) => {
scratch[0] = l; scratch[1] = c; scratch[2] = h;
return packOklchBufferToUint32MINDE(scratch, 0, a);
},
});Bake time is setup — the one scratch allocation stays outside any hot
path. Output byte order matches bakeGradientLUTRGBA's default (RGBA-LE),
so the LUT drops straight into a Canvas2D ImageData Uint32 view like
any other bake.
Both flags compose: { closed: true, pack: myPacker } is legal and
does what you'd expect.
Guarantees. Calling either baker without opts reproduces v1.0.2
output byte-for-byte. The 29 pre-existing tests pass unmodified.
No new dependencies — lite-color remains the only runtime dep.
Picking a byte order
| 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.
Performance characteristics
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]
retNo 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.
Real-world example: 100k-particle gradient field
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.
Notes
- 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.
License
MIT. See LICENSE.txt.
