Skip to content

PeshoVurtoleta/lite-color-lerp

Folders and files

NameName
Last commit message
Last commit date

Latest commit

 

History

5 Commits
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

@zakkster/lite-color-lerp

npm version npm bundle size npm downloads npm total downloads TypeScript Dependencies License: MIT

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-lerp

ESM 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 < 0 clamps to lut[0].
  • t > 1 clamps to lut[lut.length - 1].
  • NaN returns lut[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.


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]
    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.


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.

About

Zero-GC OKLCH to ARGB Uint32 gradient baking and hot-path sampling for high-performance ECS render loops.

Resources

License

Stars

Watchers

Forks

Releases

No releases published

Packages

 
 
 

Contributors