@zakkster/lite-color-engine 1.1.0 → 1.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +52 -100
- package/README.md +72 -337
- package/index.d.ts +113 -11
- package/index.js +11 -2
- package/llms.txt +44 -275
- package/package.json +4 -3
- package/src/authoring.js +47 -6
- package/src/convert.js +127 -5
- package/src/runtime.js +181 -6
package/CHANGELOG.md
CHANGED
|
@@ -1,112 +1,64 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
## [1.3.0] - 2026-07-10
|
|
4
|
+
|
|
5
|
+
### Added
|
|
6
|
+
- **Batch kernels for large particle systems (100k+):**
|
|
7
|
+
- `lerpOklchBufferN(a, offA, b, offB, t, out, offOut, n)` - interpolate `n` OKLCH
|
|
8
|
+
triplets (stride 3) with a shared `t`. Bit-for-bit identical to `n` scalar
|
|
9
|
+
`lerpOklchBuffer` calls; supports in-place and offset addressing.
|
|
10
|
+
- `packOklchBufferToUint32IntoN(src, offSrc, dst, offDst, n, alpha?, useLut?)` -
|
|
11
|
+
pack `n` OKLCH triplets straight into a `Uint32Array` (dst stride 1), the exact
|
|
12
|
+
shape a SoA particle system blits into `ImageData`.
|
|
13
|
+
- **Opt-in 4096-entry sRGB transfer LUT** (`useLut: true` on the batch packer):
|
|
14
|
+
removes `pow()` from the hot path for **~2.7x** packing throughput at **within
|
|
15
|
+
1 LSB** of the exact encoder. Single one-time table allocation.
|
|
16
|
+
|
|
17
|
+
### Performance (measured, Node 22 / V8, 100k in-gamut triplets, median of 80)
|
|
18
|
+
- Packing: accurate ~3.2M colors/s (~32 ms/frame); LUT ~14M colors/s (~7 ms/frame) - **~4.4x**.
|
|
19
|
+
The LUT runs at Fast-packer speed (`sqrt`) while staying within 1 LSB of exact.
|
|
20
|
+
- 100k lerp+pack fits a 60fps frame only with the LUT (~10 ms vs ~35 ms accurate).
|
|
21
|
+
- Honest caveat: the speedup is the **LUT**, not the batching. Accurate batch
|
|
22
|
+
packing runs at the same speed as a scalar loop (pow-bound), and batch lerp is
|
|
23
|
+
not measurably faster than a scalar loop in V8. The batch APIs are primarily
|
|
24
|
+
about ergonomics (whole-buffer ops) and unlocking the LUT path. Reproduce with
|
|
25
|
+
`node bench/benchmark.mjs`.
|
|
26
|
+
|
|
27
|
+
### Notes
|
|
28
|
+
- All hot paths remain zero-GC and allocation-free per frame.
|
|
29
|
+
- No breaking changes; all v1.2 exports (including Display P3 and `sampleColorLUT`)
|
|
30
|
+
are retained.
|
|
4
31
|
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
## [1.1.0] — Unreleased
|
|
9
|
-
|
|
10
|
-
### Added — MINDE gamut mapping, ΔE-OK, and SoA remap kernels
|
|
11
|
-
|
|
12
|
-
Two new sub-exports (`/gamut`, `/remap`) plus one core function. All follow the buffer-and-offset API and the zero-allocation-on-hot-path convention from v1.0.
|
|
13
|
-
|
|
14
|
-
#### Core
|
|
15
|
-
|
|
16
|
-
- **`deltaEOK(bufA, oA, bufB, oB): number`** — ΔE-OK color difference (Euclidean distance in OKLab) between two buffered OKLCH colors. Ten-line pure function, zero allocations. Consumed internally by the `/gamut` and `/remap` sub-exports and available for downstream packages.
|
|
17
|
-
|
|
18
|
-
#### `/gamut` sub-export
|
|
19
|
-
|
|
20
|
-
```js
|
|
21
|
-
import {
|
|
22
|
-
gamutMapToSrgbBuffer,
|
|
23
|
-
packOklchBufferToUint32MINDE,
|
|
24
|
-
} from '@zakkster/lite-color-engine/gamut';
|
|
25
|
-
```
|
|
26
|
-
|
|
27
|
-
- **`gamutMapToSrgbBuffer(inBuf, inOffset, outBuf, outOffset)`** — CSS Color 4 MINDE chroma reduction. Reduces chroma at fixed L and H until the color is in-gamut, using bisection with a JND (0.02) threshold. Source and destination may alias. All scratch is module-level; zero allocation on the hot path.
|
|
28
|
-
- **`packOklchBufferToUint32MINDE(buf, offset, alpha?)`** — accurate sibling of the v1.0 packer. Eliminates the hue-shift artifacts of the hard channel clamp near the gamut boundary. Returns unsigned RGBA-LE Uint32 (matches v1.0 convention). Signature-compatible with the core packer, so it can be passed directly as the `packer` argument to `bakeGradientToUint32`.
|
|
29
|
-
|
|
30
|
-
#### `/remap` sub-export
|
|
31
|
-
|
|
32
|
-
```js
|
|
33
|
-
import {
|
|
34
|
-
sRgba8ToOklabBuffer,
|
|
35
|
-
oklchToOklabBuffer,
|
|
36
|
-
oklabToOklchBuffer,
|
|
37
|
-
nearestPaletteIndexBuffer,
|
|
38
|
-
remapPixelsToPalette,
|
|
39
|
-
} from '@zakkster/lite-color-engine/remap';
|
|
40
|
-
```
|
|
41
|
-
|
|
42
|
-
- **`sRgba8ToOklabBuffer(inU8, outLab, pixelCount)`** — batch RGBA8 → OKLab. Alpha discarded.
|
|
43
|
-
- **`oklchToOklabBuffer` / `oklabToOklchBuffer`** — batch polar/cartesian conversions between OKLCH and OKLab. Hue canonicalized to `[0, 360)`.
|
|
44
|
-
- **`nearestPaletteIndexBuffer(pixelsLab, paletteLab, indicesOut, pixelCount, paletteCount, opts?)`** — core search kernel. For each pixel, writes the index of the nearest palette color by squared Euclidean distance in OKLab. Tie-break: lowest index wins. Accepts any typed-array index output (`Uint32Array`, `Uint16Array`, `Uint8Array`). Option `preserveLightness`: distance uses `(a, b)` only.
|
|
45
|
-
- **`remapPixelsToPalette(inU8, paletteLch, outU32, pixelCount, paletteCount, opts?)`** — one-shot end-to-end: RGBA8 → nearest palette color → RGBA-LE Uint32. Alpha byte passes through unchanged. Palette OKLCH → OKLab conversion happens internally on module-level scratch (grows monotonically, never shrinks). Option `preserveLightness`: synthesizes output per pixel as `(pixel.L, palette.a, palette.b)` — retains shading structure under recolor. ~40% slower than the fast path.
|
|
46
|
-
|
|
47
|
-
### Alignment with `V1.1-PLAN.md`
|
|
48
|
-
|
|
49
|
-
- **P0/#1** (MINDE gamut mapping) — shipped as `/gamut`.
|
|
50
|
-
- **P1/#5** (ΔE-OK) — shipped in core.
|
|
51
|
-
- **P0/#2** (alpha-aware LUT baker), **P0/#3** (ARGB byte-order variant), **P0/#4** (hue interpolation modes), **P1/#6** (gamma LUT packer), **P1/#7** (CSS Color 5 relative-color syntax) — not in this release, tracked for follow-up.
|
|
52
|
-
|
|
53
|
-
### New in this release, not in the plan
|
|
54
|
-
|
|
55
|
-
- **`/remap` sub-export** — added as the engine underlayment for `lite-hueforge v1.3` `remapImageToPalette`. Standalone value: palette quantization for retro / palette-cycling render effects, dithering targets, and demoscene-style palette animations.
|
|
56
|
-
|
|
57
|
-
### Design notes
|
|
58
|
-
|
|
59
|
-
- **Sub-exports are self-contained.** `Gamut.js` and `Remap.js` each carry their own local converters (`_oklchToLinearRgb`, `_linearRgbToOklch`, `_srgbGammaEncode`, `_linearizeSrgbByte`) rather than importing from core. Costs a small amount of duplicated math across the two files, buys loadability in isolation and independence from any future refactor of the internal converters.
|
|
60
|
-
- **Search in OKLab, not OKLCH.** Distance in OKLab is Euclidean; distance in OKLCH would need cos/sin per comparison. Palette is authored in OKLCH (human-natural), converted once at call entry.
|
|
61
|
-
- **Grow-only scratch.** `remapPixelsToPalette` grows its internal palette scratch when a larger palette is passed; it never shrinks. This matches the ecosystem's zero-GC contract — allocation happens at edge cases, not per frame.
|
|
62
|
-
- **Alpha semantics.** In `remapPixelsToPalette`, the palette is pre-packed opaque; input alpha is OR-composited into the output. This lets the palette live alongside a per-pixel alpha channel without double-clamping.
|
|
63
|
-
|
|
64
|
-
### Performance
|
|
65
|
-
|
|
66
|
-
Measured on Node 22 in a conservative sandbox (no JIT warm-up budget); V8 in browsers is typically 2–3× faster. 5-color palette, noise input:
|
|
67
|
-
|
|
68
|
-
| Resolution | Fast path | preserveLightness |
|
|
69
|
-
| ---------- | --------- | ----------------- |
|
|
70
|
-
| 256 × 256 | 60+ fps | 40 fps |
|
|
71
|
-
| 512 × 512 | 16 fps | 10 fps |
|
|
72
|
-
| 1024 × 1024| 4 fps | 2.5 fps |
|
|
73
|
-
|
|
74
|
-
Interpretation: the fast path is real-time up to ~512² in browser conditions; `preserveLightness` is real-time up to ~256². For larger canvases (2048² live recolor), a WebGL shader path is the intended next-generation kernel — flagged as a v1.2 candidate. WASM SIMD is another option worth benching.
|
|
75
|
-
|
|
76
|
-
For the near-term demo, downscale the preview canvas to 512² and upscale for final render — a standard pattern in image-editing tools.
|
|
77
|
-
|
|
78
|
-
### Tests
|
|
79
|
-
|
|
80
|
-
50 new deterministic tests across three files. All 43 existing tests continue to pass; no behavioural changes to v1.0 exports.
|
|
81
|
-
|
|
82
|
-
| File | Tests | Coverage |
|
|
83
|
-
| ---- | ----- | -------- |
|
|
84
|
-
| `gamut.test.js` | 19 | MINDE identity on in-gamut / chroma reduction on out-of-gamut / L endpoint collapse / C=0 achromatic / aliasing safety / arbitrary offsets / determinism / hue sweep in-gamut invariant; packMINDE unsigned uint32 return / default-alpha behaviour / alpha clamping / endpoint byte encoding / no hue-shift on out-of-gamut / bit-for-bit parity with core packer on in-gamut input / drop-in usable with `bakeGradientToUint32` / hot-path smoothness |
|
|
85
|
-
| `remap.test.js` | 21 | OKLCH↔OKLab round-trip within 1e-5 / hue canonicalization; sRgba8ToOklab endpoint correctness / batch stride / plain `Uint8Array` acceptance; nearestPaletteIndex exact match / lowest-index tie-break / preserveLightness path separation / explicit `false` matches default / batch correctness / typed-array flexibility (`Uint32Array`, `Uint16Array`, `Uint8Array`); remapPixels solid-fill correctness / primary-color mapping / preserveLightness output darkness / alpha passthrough (both paths) / determinism / scratch-grow safety |
|
|
86
|
-
| `delta.test.js` | 10 | Identity / symmetry / non-negativity / large ΔE between visually distant colors / small ΔE between near-identical colors / offset correctness / hue wraparound (1° vs 359°) / monotonic growth with diverging chroma / determinism |
|
|
87
|
-
|
|
88
|
-
### Non-breaking
|
|
89
|
-
|
|
90
|
-
Additive only. No existing API surface changed. v1.0 consumers continue to work identically at `1.1.0`.
|
|
91
|
-
|
|
92
|
-
### Packaging
|
|
32
|
+
---
|
|
93
33
|
|
|
94
|
-
|
|
95
|
-
- Added `CHANGELOG.md` to the `files` list.
|
|
96
|
-
- Fixed a pre-existing typo: `files` listed `LICENSE.txt` where the actual file is `LICENSE.md`.
|
|
34
|
+
## [1.2.0] - 2026-07-10
|
|
97
35
|
|
|
98
|
-
###
|
|
36
|
+
### Added
|
|
37
|
+
- **Wide Gamut Support (Display P3)**
|
|
38
|
+
- `parseCSSColor('color(display-p3 r g b / alpha)')` now supported in the universal parser
|
|
39
|
+
- New `displayP3ToOklchBuffer()` conversion function with accurate P3 -> XYZ -> OKLab pipeline
|
|
40
|
+
- `packOklchBufferToUint32P3()` - accurate packer for `display-p3` canvas contexts
|
|
41
|
+
- `packOklchBufferToUint32P3Fast()` - high-speed sqrt approximation for P3 output
|
|
42
|
+
- `oklchToLinearP3()` inverse conversion helper
|
|
99
43
|
|
|
100
|
-
-
|
|
101
|
-
-
|
|
102
|
-
-
|
|
103
|
-
-
|
|
44
|
+
- **Accuracy Tiers** (now clearly documented)
|
|
45
|
+
- Fast (`packOklchBufferToUint32Fast`)
|
|
46
|
+
- Accurate-Clamp (default `packOklchBufferToUint32`)
|
|
47
|
+
- Gamut-Mapped (`packOklchBufferToUint32MINDE`, on the `/gamut` subpath)
|
|
104
48
|
|
|
49
|
+
- `bakeGradientToUint32()` already supported custom packers - now officially documented for P3 usage.
|
|
105
50
|
|
|
106
|
-
|
|
51
|
+
### Changed
|
|
52
|
+
- Improved documentation around color gamut handling and when to use each packer.
|
|
53
|
+
- Minor internal cleanup for better tree-shaking of P3 code paths.
|
|
107
54
|
|
|
108
|
-
|
|
55
|
+
### Notes
|
|
56
|
+
- P3 remains strictly opt-in. Default paths and bundle size are unchanged.
|
|
57
|
+
- All hot paths remain zero-GC and allocation-free.
|
|
109
58
|
|
|
110
59
|
---
|
|
111
60
|
|
|
112
|
-
|
|
61
|
+
## [1.1.0] - Previous
|
|
62
|
+
|
|
63
|
+
- Core zero-GC OKLCH engine: parsing, buffer lerp, packers, delta-E, gradient LUT baking,
|
|
64
|
+
gamut mapping (`/gamut`) and palette remap (`/remap`) subpaths.
|
package/README.md
CHANGED
|
@@ -1,383 +1,118 @@
|
|
|
1
1
|
# @zakkster/lite-color-engine
|
|
2
2
|
|
|
3
3
|
[](https://www.npmjs.com/package/@zakkster/lite-color-engine)
|
|
4
|
+

|
|
4
5
|
[](https://github.com/sponsors/PeshoVurtoleta)
|
|
5
6
|
[](https://bundlephobia.com/result?p=@zakkster/lite-color-engine)
|
|
6
7
|
[](https://www.npmjs.com/package/@zakkster/lite-color-engine)
|
|
7
8
|
[](https://www.npmjs.com/package/@zakkster/lite-color-engine)
|
|
9
|
+

|
|
8
10
|

|
|
9
|
-

|
|
12
|
+
[](https://opensource.org/licenses/MIT)
|
|
11
13
|
|
|
12
|
-
|
|
14
|
+
**Zero-GC, data-oriented OKLCH color engine** for high-performance WebGL / Canvas pipelines.
|
|
13
15
|
|
|
14
|
-
|
|
16
|
+
## v1.3 Highlights — Batch Kernels + 4k Transfer LUT
|
|
15
17
|
|
|
16
|
-
|
|
18
|
+
Built for the 100k-particle case: bulk buffer -> buffer operations plus an opt-in
|
|
19
|
+
transfer LUT that removes `pow()` from the hot path.
|
|
17
20
|
|
|
18
|
-
|
|
21
|
+
- `lerpOklchBufferN(a, offA, b, offB, t, out, offOut, n)` — interpolate `n` triplets in one call.
|
|
22
|
+
- `packOklchBufferToUint32IntoN(src, offSrc, dst, offDst, n, alpha?, useLut?)` — pack `n` OKLCH triplets straight into a `Uint32Array`.
|
|
23
|
+
- `useLut: true` opts into a precomputed 4096-entry sRGB transfer LUT — **~4.4x** the packing throughput of the accurate path, within **1 LSB** of exact. That is essentially Fast-packer speed at near-exact accuracy (the `sqrt` Fast packer drifts ~10/255). One module-load allocation; still zero-GC per frame.
|
|
19
24
|
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
25
|
+
**Honest note on batching.** The LUT is the real win. The batch functions themselves
|
|
26
|
+
mostly save ergonomics, not cycles: in V8 the accurate batch packer runs at essentially
|
|
27
|
+
the same speed as a scalar loop (the cost is `pow()`, not call overhead), and batch lerp
|
|
28
|
+
is only marginally faster. Reach for `useLut: true` when you need the speedup.
|
|
23
29
|
|
|
24
|
-
|
|
30
|
+
| Packing 100k OKLCH -> Uint32 / frame | Throughput | ms/frame |
|
|
31
|
+
|--------------------------------------|-----------|----------|
|
|
32
|
+
| scalar loop / batch accurate | ~3.2M/s | ~32 ms |
|
|
33
|
+
| **batch + `useLut: true`** | **~14M/s**| **~7 ms**|
|
|
34
|
+
| Fast (`sqrt`, ~10/255 error) | ~15M/s | ~7 ms |
|
|
25
35
|
|
|
26
|
-
|
|
36
|
+
*(Node 22 / V8, 100k in-gamut triplets, median of 80 runs; browser V8 is comparable.
|
|
37
|
+
Reproduce with `node bench/benchmark.mjs`. Your numbers will vary.)*
|
|
27
38
|
|
|
28
|
-
|
|
39
|
+
## v1.2 Highlights — Wide Gamut + Accuracy Tiers
|
|
29
40
|
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
E --> F[Uint32Array LUT<br/><sub>RGBA-LE</sub>]:::buffer
|
|
37
|
-
F --> G[sampleColorLUT<br/><sub>zero-GC sampler</sub>]:::runtime
|
|
38
|
-
D --> H[packOklchBufferToUint32<br/><sub>sRGB transfer</sub>]:::runtime
|
|
39
|
-
G --> I[Canvas ImageData<br/>WebGL texImage2D]:::render
|
|
40
|
-
H --> I
|
|
41
|
+
- Full `color(display-p3 r g b / alpha)` parsing support
|
|
42
|
+
- Dedicated high-accuracy `packOklchBufferToUint32P3()` and fast variant
|
|
43
|
+
- Three clear accuracy tiers for sRGB output:
|
|
44
|
+
1. **Fast** — `packOklchBufferToUint32Fast`
|
|
45
|
+
2. **Accurate-Clamp** — default `packOklchBufferToUint32`
|
|
46
|
+
3. **Gamut-Mapped** — `packOklchBufferToUint32MINDE`
|
|
41
47
|
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
classDef lut fill:#dcfce7,stroke:#16a34a,color:#14532d
|
|
46
|
-
classDef runtime fill:#ede9fe,stroke:#7c3aed,color:#4c1d95
|
|
47
|
-
classDef render fill:#fecaca,stroke:#dc2626,color:#7f1d1d
|
|
48
|
-
```
|
|
48
|
+
P3 output is **opt-in only** — never affects default paths or bundle size.
|
|
49
|
+
|
|
50
|
+
## Core Philosophy
|
|
49
51
|
|
|
50
|
-
|
|
52
|
+
Parse once at init → work with `Float32Array` OKLCH buffers → zero allocations on the hot path.
|
|
51
53
|
|
|
52
|
-
##
|
|
54
|
+
## Installation
|
|
53
55
|
|
|
54
56
|
```bash
|
|
55
57
|
npm install @zakkster/lite-color-engine
|
|
56
58
|
```
|
|
57
59
|
|
|
58
|
-
##
|
|
59
|
-
|
|
60
|
-
### Authoring — parse once, store forever
|
|
60
|
+
## Quick Start
|
|
61
61
|
|
|
62
62
|
```js
|
|
63
|
-
import {
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
alphas[0] = parseCSSColor('#ff0000', palette, 0); // hex
|
|
71
|
-
alphas[1] = parseCSSColor('rgba(0, 200, 100, 0.5)', palette, 3); // rgb
|
|
72
|
-
alphas[2] = parseCSSColor('oklch(60% 0.15 250)', palette, 6); // oklch
|
|
73
|
-
alphas[3] = parseCSSColor('rebeccapurple', palette, 9); // named
|
|
74
|
-
```
|
|
75
|
-
|
|
76
|
-
That `Float32Array` is now your color storage for the rest of the program. No object headers, no GC pressure, perfectly cache-friendly.
|
|
77
|
-
|
|
78
|
-
### Runtime — interpolate and pack with zero allocations
|
|
79
|
-
|
|
80
|
-
```js
|
|
81
|
-
import { lerpOklchBuffer, packOklchBufferToUint32 } from '@zakkster/lite-color-engine';
|
|
82
|
-
|
|
83
|
-
const tempColor = new Float32Array(3); // single scratch buffer for the program lifetime
|
|
84
|
-
|
|
85
|
-
// Tween color 0 → color 2 over time, packing for ImageData.
|
|
86
|
-
function tick(t) {
|
|
87
|
-
lerpOklchBuffer(palette, 0, palette, 6, t, tempColor, 0);
|
|
88
|
-
return packOklchBufferToUint32(tempColor, 0, 1.0);
|
|
89
|
-
}
|
|
90
|
-
|
|
91
|
-
// Inside your render loop:
|
|
92
|
-
const u32 = tick(progress);
|
|
93
|
-
imageDataU32View[pixelIndex] = u32; // direct write — no string parsing, no Color object
|
|
94
|
-
```
|
|
95
|
-
|
|
96
|
-
### LUT — bake a gradient once, sample forever
|
|
97
|
-
|
|
98
|
-
```js
|
|
99
|
-
import { bakeGradientToUint32, sampleColorLUT } from '@zakkster/lite-color-engine';
|
|
100
|
-
|
|
101
|
-
// Three-stop gradient: red → gold → blue
|
|
102
|
-
const stops = new Float32Array(9);
|
|
103
|
-
parseCSSColor('#ff0000', stops, 0);
|
|
104
|
-
parseCSSColor('#ffd700', stops, 3);
|
|
105
|
-
parseCSSColor('#0000ff', stops, 6);
|
|
106
|
-
|
|
107
|
-
const lut = bakeGradientToUint32(stops, 3, 256); // 256-entry Uint32Array
|
|
108
|
-
|
|
109
|
-
// Hot path: zero allocations, zero allocations, zero allocations.
|
|
110
|
-
function colorAt(t) {
|
|
111
|
-
return sampleColorLUT(lut, t);
|
|
112
|
-
}
|
|
113
|
-
```
|
|
114
|
-
|
|
115
|
-
## 🧠 Why this exists
|
|
116
|
-
|
|
117
|
-
CSS color is a string-first API. Every existing color library (`color`, `colord`, `culori`, `chroma-js`) treats this seriously: it gives you an object you call methods on. That's correct for tooling. It's wrong for **rendering**.
|
|
118
|
-
|
|
119
|
-
A 60fps loop with 10,000 particles allocates 600,000 color objects per second if each particle calls `.mix()` or `.toRgb()`. Even if each object is 100 bytes, you've handed the GC 60MB/sec of churn. The frame time isn't the math — it's the major GC pause.
|
|
120
|
-
|
|
121
|
-
`lite-color-engine` solves this by **separating the parse from the use**:
|
|
122
|
-
|
|
123
|
-
1. **Authoring time** (once, off the critical path): expand CSS strings into a flat `Float32Array` of OKLCH triplets. Allocate the buffer yourself, at the size you want. Done.
|
|
124
|
-
2. **Runtime** (every frame, every particle): pure scalar math against that buffer. Lerp two triplets into a third. Pack a triplet to `Uint32`. Sample a baked LUT by `t`. Zero allocations, zero closures, zero indirection.
|
|
125
|
-
|
|
126
|
-
The buffer-and-offset API is the contract. It looks like a C-style API in JavaScript because that's what zero-GC actually means — you control the memory, the library reads and writes it.
|
|
127
|
-
|
|
128
|
-
### Why OKLCH
|
|
129
|
-
|
|
130
|
-
Linear RGB interpolation is fast but **wrong**: red → blue passes through muddy purple-gray at the midpoint because RGB isn't perceptually uniform. HSL/HSV are perceptually uniform in name only — the saturation axis interacts non-linearly with lightness. OKLCH (Björn Ottosson, 2020) is the modern fix: hue, chroma, and lightness are independently meaningful, and linear interpolation between any two OKLCH points stays on a perceptually smooth curve.
|
|
131
|
-
|
|
132
|
-
The library uses OKLCH as its **internal representation** so every interpolation and every gradient bake is perceptually correct by construction. Pack-to-RGBA happens only at the moment of pixel write.
|
|
133
|
-
|
|
134
|
-
## 🔥 Algorithm
|
|
135
|
-
|
|
136
|
-
The end-to-end pipeline:
|
|
137
|
-
|
|
138
|
-
```mermaid
|
|
139
|
-
flowchart TD
|
|
140
|
-
A[CSS string<br/>#ff0000]:::start --> B{prefix?}
|
|
141
|
-
B -->|named| N[NAMED_COLORS<br/>lookup]:::lut
|
|
142
|
-
B -->|#| H[HEX_REGEX]:::regex
|
|
143
|
-
B -->|rgb| R[RGB_REGEX]:::regex
|
|
144
|
-
B -->|hsl| S[HSL_REGEX]:::regex
|
|
145
|
-
B -->|oklch| K[OKLCH_REGEX<br/>direct]:::regex
|
|
146
|
-
B -->|oklab| L[OKLAB_REGEX<br/>+ polar]:::regex
|
|
147
|
-
N --> H
|
|
148
|
-
H --> M[sRGB byte 0-255]:::buf
|
|
149
|
-
R --> M
|
|
150
|
-
S --> M
|
|
151
|
-
M --> O[linearize<br/><sub>pow 2.4</sub>]:::math
|
|
152
|
-
O --> P[matrix → LMS]:::math
|
|
153
|
-
P --> Q[cbrt]:::math
|
|
154
|
-
Q --> T[matrix → OKLab]:::math
|
|
155
|
-
T --> U[atan2 / hypot<br/>→ OKLCH]:::math
|
|
156
|
-
U --> V[outBuf L, C, H]:::buf
|
|
157
|
-
K --> V
|
|
158
|
-
L --> V
|
|
159
|
-
|
|
160
|
-
V --> W[lerpOklchBuffer<br/><sub>shortest-arc hue</sub>]:::run
|
|
161
|
-
W --> X[OKLCH → linear sRGB<br/><sub>cube + matrix</sub>]:::math
|
|
162
|
-
X --> Y[hard gamut clamp]:::math
|
|
163
|
-
Y --> Z[sRGB transfer<br/><sub>pow 1/2.4</sub>]:::math
|
|
164
|
-
Z --> AA["byte pack<br/><sub>A<<24 | B<<16 | G<<8 | R</sub>"]:::math
|
|
165
|
-
AA --> AB[Uint32 RGBA-LE]:::buf
|
|
166
|
-
|
|
167
|
-
classDef start fill:#e0f2fe,stroke:#0284c7
|
|
168
|
-
classDef regex fill:#fef3c7,stroke:#d97706,color:#78350f
|
|
169
|
-
classDef lut fill:#dcfce7,stroke:#16a34a,color:#14532d
|
|
170
|
-
classDef math fill:#ede9fe,stroke:#7c3aed,color:#4c1d95
|
|
171
|
-
classDef buf fill:#f3f4f6,stroke:#6b7280,color:#1f2937
|
|
172
|
-
classDef run fill:#fecaca,stroke:#dc2626,color:#7f1d1d
|
|
173
|
-
```
|
|
174
|
-
|
|
175
|
-
**Key invariants:**
|
|
176
|
-
|
|
177
|
-
- Every parser writes exactly **3 contiguous Float32 entries** at the offset you supply: `[L, C, H]`. Alpha is returned by value, not stored in the buffer — this keeps the OKLCH stride at 3, which divides cleanly into SoA layouts.
|
|
178
|
-
- Hue is **always** in `[0, 360)` after a write — every parser, every lerp, every conversion canonicalizes.
|
|
179
|
-
- Lightness is **always** clamped to `[0, 1]` after a write.
|
|
180
|
-
- Chroma is **always** clamped to `[0, +∞)` — never negative.
|
|
181
|
-
- LUT output is **always** little-endian RGBA byte order — drop straight into a `Uint32Array` view of `ImageData.data.buffer`. No byte-swapping. No platform check (browsers are universally LE).
|
|
182
|
-
|
|
183
|
-
## 🏗️ Buffer layout
|
|
63
|
+
import {
|
|
64
|
+
parseCSSColor,
|
|
65
|
+
lerpOklchBuffer,
|
|
66
|
+
packOklchBufferToUint32,
|
|
67
|
+
packOklchBufferToUint32P3
|
|
68
|
+
} from '@zakkster/lite-color-engine';
|
|
184
69
|
|
|
185
|
-
|
|
70
|
+
const buf = new Float32Array(3);
|
|
71
|
+
parseCSSColor('color(display-p3 0.9 0.4 0.1)', buf, 0);
|
|
186
72
|
|
|
73
|
+
// Later in render loop (zero GC)
|
|
74
|
+
lerpOklchBuffer(bufA, 0, bufB, 0, t, temp, 0);
|
|
75
|
+
const pixel = packOklchBufferToUint32P3(temp, 0); // or packOklchBufferToUint32
|
|
187
76
|
```
|
|
188
|
-
palette : [ L0 C0 H0 | L1 C1 H1 | L2 C2 H2 | ... ] // float32 stride = 3
|
|
189
|
-
alphas : [ a0 | a1 | a2 | ... ] // float32 stride = 1
|
|
190
|
-
```
|
|
191
|
-
|
|
192
|
-
Why a separate alpha array? Because **most colors are opaque**, and packing alpha into the OKLCH stride wastes 25% of every cache line on a `1.0` constant. A separate `Float32Array` (or even `Uint8Array`) lets opaque colors skip alpha entirely, and lets you swap alpha layouts (per-color vs per-instance) without changing the color storage.
|
|
193
|
-
|
|
194
|
-
If you genuinely want premultiplied OKLCHa as a stride-4, you can — the API doesn't stop you, you just write `outBuf[offset + 3] = alpha`. The library functions only touch the first three slots.
|
|
195
|
-
|
|
196
|
-
## 📊 Comparison
|
|
197
|
-
|
|
198
|
-
| | **lite-color-engine** | culori | colord | chroma-js | hand-rolled |
|
|
199
|
-
|---|---|---|---|---|---|
|
|
200
|
-
| Bundle (min+gzip) | **<2 KB** | ~14 KB | ~7 KB | ~14 KB | depends |
|
|
201
|
-
| Hot-path allocations | **0** | several / call | 1 / call | several / call | usually 0 |
|
|
202
|
-
| OKLCH-native runtime | **yes** | yes (multi-space) | via plugin | yes | manual |
|
|
203
|
-
| Float32Array I/O | **yes** | no (objects) | no (objects) | no (objects) | yes |
|
|
204
|
-
| Bake-to-Uint32 LUT | **yes** | no | no | no | manual |
|
|
205
|
-
| Direct `ImageData.data.buffer` write | **yes** (LE-RGBA) | no | no | no | manual |
|
|
206
|
-
| CSS Color Level 4 parsing | **yes** | yes | yes | partial | manual |
|
|
207
|
-
| Canonical hue + lightness clamps | **yes** | yes | partial | partial | manual |
|
|
208
|
-
| SoA / ECS friendly | **yes** | no | no | no | yes |
|
|
209
|
-
| TypeScript types | **yes (full)** | yes | yes | yes | n/a |
|
|
210
|
-
| Framework lock-in | **none** | none | none | none | none |
|
|
211
|
-
|
|
212
|
-
`lite-color-engine` is **not** a replacement for `culori` or `colord`. It does not handle every color space, gamut mapping algorithm, or color difference metric. It does **one** thing: turn a CSS string into a per-frame allocation-free pixel writer.
|
|
213
|
-
|
|
214
|
-
## ⚙️ API
|
|
215
|
-
|
|
216
|
-
### Authoring
|
|
217
|
-
|
|
218
|
-
#### `parseCSSColor(str, outBuf, offset): number`
|
|
219
77
|
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
| Param | Type | Description |
|
|
223
|
-
|---|---|---|
|
|
224
|
-
| `str` | `string` | Any CSS color: hex, named, rgb/rgba, hsl/hsla, oklch, oklab. |
|
|
225
|
-
| `outBuf` | `Float32Array` | Pre-allocated destination. Must have length ≥ `offset + 3`. |
|
|
226
|
-
| `offset` | `number` | Start index of the L, C, H triplet. |
|
|
227
|
-
| **Returns** | `number` | Parsed alpha in `[0, 1]`. Defaults to `1.0` when omitted in the string. |
|
|
228
|
-
|
|
229
|
-
**Throws** if `str` is not a recognized format.
|
|
230
|
-
|
|
231
|
-
Format-specific parsers (`parseHexToBuffer`, `parseRgbToBuffer`, `parseHslToBuffer`, `parseOklchToBuffer`, `parseOklabToBuffer`) have identical signatures and skip the dispatch — call directly when you know the format.
|
|
232
|
-
|
|
233
|
-
### Convert
|
|
234
|
-
|
|
235
|
-
#### `sRgbToOklchBuffer(r, g, b, outBuf, outOffset): void`
|
|
236
|
-
|
|
237
|
-
Raw sRGB-byte → OKLCH conversion. The kernel underneath the parsers. Use when you have integer RGB triplets (e.g. from canvas pixel reads) and want to skip the regex layer.
|
|
238
|
-
|
|
239
|
-
### Runtime
|
|
240
|
-
|
|
241
|
-
#### `lerpOklchBuffer(bufA, offsetA, bufB, offsetB, t, outBuf, outOffset): void`
|
|
242
|
-
|
|
243
|
-
Zero-GC OKLCH interpolation. Hue uses **shortest-path** so `350° → 10°` correctly passes through `0°` instead of taking the long way around. Source and destination buffers may alias.
|
|
244
|
-
|
|
245
|
-
| Param | Type | Description |
|
|
246
|
-
|---|---|---|
|
|
247
|
-
| `t` | `number` | Interpolation factor. Values outside `[0, 1]` extrapolate, then clamp. `NaN` propagates. |
|
|
248
|
-
|
|
249
|
-
#### `packOklchBufferToUint32(buf, offset, alpha?): number`
|
|
250
|
-
|
|
251
|
-
Encodes an OKLCH triplet to a 32-bit unsigned integer in **little-endian RGBA** byte order. Uses the proper sRGB transfer function (`pow(c, 1/2.4)`). Round-tripping `sRGB → OKLCH → here` recovers the original byte exactly (within rounding).
|
|
252
|
-
|
|
253
|
-
#### `packOklchBufferToUint32Fast(buf, offset, alpha?): number`
|
|
254
|
-
|
|
255
|
-
Faster, less-accurate sibling. Substitutes `Math.sqrt(c)` for the proper sRGB transfer. ~2× throughput on V8, with these documented costs:
|
|
256
|
-
|
|
257
|
-
- mid-gray (`#808080`) round-trips to about `#767676` (~10/255 darker)
|
|
258
|
-
- warm midtones (browns, golds) shift toward black
|
|
259
|
-
|
|
260
|
-
Use for ephemeral pixels (particle trails, alpha-blended sprite tints) where the round-trip identity isn't observable. **Avoid** for UI tokens, palette previews, or anywhere a designer compares the output to the input.
|
|
261
|
-
|
|
262
|
-
#### `sampleColorLUT(lut, t): number`
|
|
263
|
-
|
|
264
|
-
Zero-GC LUT sampler. Inline `t`-clamp + bitwise-truncated index — no allocations, no function calls beyond this one. Use inside particle systems, fragment-shader-style canvas loops, etc.
|
|
265
|
-
|
|
266
|
-
### LUT
|
|
267
|
-
|
|
268
|
-
#### `bakeGradientToUint32(keyframesBuf, numStops, resolution?, easeFn?, packer?): Uint32Array`
|
|
269
|
-
|
|
270
|
-
Bakes a multi-stop OKLCH gradient into a `Uint32Array` of packed RGBA-LE colors.
|
|
271
|
-
|
|
272
|
-
| Param | Type | Description |
|
|
273
|
-
|---|---|---|
|
|
274
|
-
| `keyframesBuf` | `Float32Array` | Contiguous `[L0, C0, H0, L1, C1, H1, ...]`. |
|
|
275
|
-
| `numStops` | `number` | Stop count; must be `≥ 2`. |
|
|
276
|
-
| `resolution` | `number` | LUT entry count. Default `256`. Must be `≥ 2`. |
|
|
277
|
-
| `easeFn` | `(t) => number` | Optional. Warps `t` before stop selection. Outputs outside `[0, 1]` are clamped. |
|
|
278
|
-
| `packer` | `OklchPackerFn` | Optional. Defaults to the accurate packer. Pass `packOklchBufferToUint32Fast` for ~2× bake throughput. |
|
|
279
|
-
|
|
280
|
-
**Throws** if `numStops < 2` or `resolution < 2`.
|
|
281
|
-
|
|
282
|
-
### Difference
|
|
283
|
-
|
|
284
|
-
#### `deltaEOK(bufA, offsetA, bufB, offsetB): number`
|
|
285
|
-
|
|
286
|
-
ΔE-OK color difference — Euclidean distance in OKLab between two buffered OKLCH colors. Zero allocations, pure primitive math.
|
|
287
|
-
|
|
288
|
-
Typical scale: `0.02` indistinguishable, `0.05` subtle, `0.15+` unambiguously different. Use for palette dedupe, nearest-color lookup, and contrast checks.
|
|
289
|
-
|
|
290
|
-
## 🧭 Sub-exports (v1.1)
|
|
291
|
-
|
|
292
|
-
The core surface stays sub-2 KB. Heavier optional kernels ship as separate entry points so you only pay for what you import.
|
|
293
|
-
|
|
294
|
-
### `@zakkster/lite-color-engine/gamut` — MINDE gamut mapping
|
|
295
|
-
|
|
296
|
-
CSS Color 4 chroma-reduction gamut mapping. Reduces chroma at fixed L and H until the color is inside the sRGB gamut, using bisection with a JND threshold. Fixes the visible hue shifts that the hard channel clamp produces near the gamut boundary.
|
|
78
|
+
### Batch (100k particles)
|
|
297
79
|
|
|
298
80
|
```js
|
|
299
81
|
import {
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
} from '@zakkster/lite-color-engine
|
|
303
|
-
```
|
|
304
|
-
|
|
305
|
-
- **`gamutMapToSrgbBuffer(inBuf, inOffset, outBuf, outOffset)`** — writes a gamut-mapped `[L, C', H]` triplet. Source and destination may alias. Zero allocations on the hot path.
|
|
306
|
-
- **`packOklchBufferToUint32MINDE(buf, offset, alpha?)`** — accurate sibling of the core packer. `~30×` slower — belongs at LUT-build time, not per-frame. Signature-compatible with `packer` in `bakeGradientToUint32`.
|
|
307
|
-
|
|
308
|
-
### `@zakkster/lite-color-engine/remap` — palette-remap kernels
|
|
82
|
+
lerpOklchBufferN,
|
|
83
|
+
packOklchBufferToUint32IntoN
|
|
84
|
+
} from '@zakkster/lite-color-engine';
|
|
309
85
|
|
|
310
|
-
|
|
86
|
+
const N = 100000;
|
|
87
|
+
const from = new Float32Array(N * 3); // OKLCH triplets
|
|
88
|
+
const to = new Float32Array(N * 3);
|
|
89
|
+
const cur = new Float32Array(N * 3);
|
|
90
|
+
const px = new Uint32Array(N); // -> view of ImageData
|
|
311
91
|
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
oklabToOklchBuffer,
|
|
317
|
-
nearestPaletteIndexBuffer,
|
|
318
|
-
remapPixelsToPalette,
|
|
319
|
-
} from '@zakkster/lite-color-engine/remap';
|
|
92
|
+
// Per frame, zero allocations:
|
|
93
|
+
lerpOklchBufferN(from, 0, to, 0, t, cur, 0, N);
|
|
94
|
+
packOklchBufferToUint32IntoN(cur, 0, px, 0, N, 1.0, /* useLut */ true);
|
|
95
|
+
// px now holds N packed RGBA pixels; blit via a Uint32Array view of ImageData.
|
|
320
96
|
```
|
|
321
97
|
|
|
322
|
-
|
|
323
|
-
- **`nearestPaletteIndexBuffer(...)`** — for each pixel in `pixelsLab`, write the index of the nearest palette color into `indicesOut` (accepts `Uint32Array`, `Uint16Array`, or `Uint8Array`). Tie-break: lowest index wins.
|
|
324
|
-
- **`remapPixelsToPalette(inU8, paletteLch, outU32, pixelCount, paletteCount, opts?)`** — end-to-end pipe. Alpha byte passes through unchanged. `opts.preserveLightness` searches by `(a, b)` only and synthesizes output as `(pixel.L, palette.a, palette.b)` — retains shading structure under recolor.
|
|
98
|
+
## Accuracy Tiers
|
|
325
99
|
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
|
329
|
-
|
|
330
|
-
| `
|
|
331
|
-
| `parseCSSColor()` — oklch direct | regex + 3 number parses (no color math) |
|
|
332
|
-
| `lerpOklchBuffer()` | 3 lerps, 1 modulo, 4 comparisons. Zero allocations. |
|
|
333
|
-
| `packOklchBufferToUint32()` | ~30 multiplies, 1 cos, 1 sin, 3 cubes, 3 `pow(_, 1/2.4)`. Zero allocations. |
|
|
334
|
-
| `packOklchBufferToUint32Fast()` | Same minus the 3 `pow`s, plus 3 `sqrt`. Zero allocations. |
|
|
335
|
-
| `sampleColorLUT()` | 2 comparisons, 1 multiply, 1 bitwise truncate, 1 array load. Zero allocations. |
|
|
336
|
-
| `bakeGradientToUint32()` | `resolution × (lerp + pack)`; ~25k ops for a default 256-entry LUT. **One** allocation (the output Uint32Array). |
|
|
337
|
-
| Hot-path allocations | **zero** across the entire runtime API |
|
|
338
|
-
| Module-load allocations | one `Float32Array(3)` scratch (single instance, shared) |
|
|
339
|
-
|
|
340
|
-
## 🛡️ Validation
|
|
341
|
-
|
|
342
|
-
The hot path trusts its inputs. There is no runtime validation in `lerpOklchBuffer`, `packOklchBufferToUint32`, or `sampleColorLUT` — bad data produces predictable bad output, not exceptions.
|
|
343
|
-
|
|
344
|
-
| Input | Behavior |
|
|
345
|
-
|---|---|
|
|
346
|
-
| `t < 0` or `t > 1` in lerp | Extrapolates, then output is clamped (lightness → `[0, 1]`, chroma → `[0, +∞)`) |
|
|
347
|
-
| `t === NaN` | NaN propagates through math; output may be `0` after clamps |
|
|
348
|
-
| Out-of-range OKLCH values | Pack clamps r/g/b channels to `[0, 1]` before encoding |
|
|
349
|
-
| Wrong-length `outBuf` in parser | Throws (`out of bounds`) only if offset is invalid; otherwise writes garbage past the buffer |
|
|
350
|
-
| Invalid CSS string in parser | **Throws** with a descriptive `lite-color-engine: Invalid X` error |
|
|
351
|
-
|
|
352
|
-
The asymmetry is deliberate: the parsers run at init time where exceptions are useful for catching authoring mistakes; the runtime functions run thousands of times per frame where every branch is a tax.
|
|
353
|
-
|
|
354
|
-
## 📦 TypeScript
|
|
355
|
-
|
|
356
|
-
Full TypeScript declarations in `index.d.ts`. Every public function has a single overload — no union types in the API surface, no inference puzzles.
|
|
357
|
-
|
|
358
|
-
```ts
|
|
359
|
-
import {
|
|
360
|
-
parseCSSColor,
|
|
361
|
-
lerpOklchBuffer,
|
|
362
|
-
packOklchBufferToUint32,
|
|
363
|
-
bakeGradientToUint32,
|
|
364
|
-
} from '@zakkster/lite-color-engine';
|
|
365
|
-
import type { OklchPackerFn } from '@zakkster/lite-color-engine';
|
|
366
|
-
|
|
367
|
-
const palette = new Float32Array(12);
|
|
368
|
-
const alpha: number = parseCSSColor('oklch(0.7 0.2 250)', palette, 0);
|
|
369
|
-
```
|
|
100
|
+
| Tier | Function | Use Case | Trade-off |
|
|
101
|
+
|-------------------|----------------------------------|-----------------------------------|--------------------|
|
|
102
|
+
| Fast | `packOklchBufferToUint32Fast` | Particles, high volume | ~10/255 midtone error |
|
|
103
|
+
| Accurate-Clamp | `packOklchBufferToUint32` | Most UI / general use | Hard clamp (hue shift near edge) |
|
|
104
|
+
| Gamut-Mapped | `packOklchBufferToUint32MINDE` | Critical gradients & authoring | ~30x slower, best quality |
|
|
370
105
|
|
|
371
|
-
|
|
106
|
+
> `packOklchBufferToUint32MINDE` ships on the `/gamut` subpath:
|
|
107
|
+
> `import { packOklchBufferToUint32MINDE } from '@zakkster/lite-color-engine/gamut';`
|
|
108
|
+
> The `Fast`, `Accurate-Clamp`, and both `P3` packers come from the main entry.
|
|
372
109
|
|
|
373
|
-
|
|
110
|
+
For Display P3 output, use the `P3` variants.
|
|
374
111
|
|
|
375
|
-
##
|
|
112
|
+
## Documentation
|
|
376
113
|
|
|
377
|
-
|
|
378
|
-
- [`@zakkster/lite-cubic-bezier`](https://www.npmjs.com/package/@zakkster/lite-cubic-bezier) — supplies `cubicBezier(...)` easing functions you can hand directly to `bakeGradientToUint32`.
|
|
379
|
-
- [`@zakkster/lite-ease`](https://www.npmjs.com/package/@zakkster/lite-ease) — the Penner library. Same compatibility — pass any of the 30 functions as the `easeFn` argument.
|
|
114
|
+
See full API in `llms.txt` or the source `src/` files.
|
|
380
115
|
|
|
381
116
|
## License
|
|
382
117
|
|
|
383
|
-
|
|
118
|
+
MIT
|