@zakkster/lite-color-engine 1.3.0 → 1.5.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/README.md CHANGED
@@ -1,118 +1,515 @@
1
1
  # @zakkster/lite-color-engine
2
2
 
3
- [![npm version](https://img.shields.io/npm/v/@zakkster/lite-color-engine.svg?style=for-the-badge&color=latest)](https://www.npmjs.com/package/@zakkster/lite-color-engine)
4
- ![Zero-GC](https://img.shields.io/badge/Zero--GC-Hot%20path-00C853?style=for-the-badge&logo=leaf&logoColor=white)
3
+ > Zero-GC OKLCH color engine for hot paths. Parse once into `Float32Array` buffers, then interpolate, gamut-map, and pack straight into `ImageData` with no per-frame allocation. Built for 16ms render budgets, 100k-particle canvases, and 1MB extension bundles.
4
+
5
+ [![npm version](https://img.shields.io/npm/v/@zakkster/lite-color-engine.svg?style=for-the-badge&color=00C853)](https://www.npmjs.com/package/@zakkster/lite-color-engine)
5
6
  [![sponsor](https://img.shields.io/badge/sponsor-PeshoVurtoleta-ea4aaa.svg?logo=github)](https://github.com/sponsors/PeshoVurtoleta)
7
+ ![Zero-GC](https://img.shields.io/badge/Zero--GC-Hot%20Path-00C853?style=for-the-badge&logo=leaf&logoColor=white)
6
8
  [![npm bundle size](https://img.shields.io/bundlephobia/minzip/@zakkster/lite-color-engine?style=for-the-badge)](https://bundlephobia.com/result?p=@zakkster/lite-color-engine)
7
9
  [![npm downloads](https://img.shields.io/npm/dm/@zakkster/lite-color-engine?style=for-the-badge&color=blue)](https://www.npmjs.com/package/@zakkster/lite-color-engine)
8
10
  [![npm total downloads](https://img.shields.io/npm/dt/@zakkster/lite-color-engine?style=for-the-badge&color=blue)](https://www.npmjs.com/package/@zakkster/lite-color-engine)
11
+ [![Coverage Status](https://coveralls.io/repos/github/PeshoVurtoleta/lite-color-engine/badge.svg?branch=main)](https://coveralls.io/github/PeshoVurtoleta/lite-color-engine?branch=main)
9
12
  ![Tree-Shakeable](https://img.shields.io/badge/tree--shakeable-yes-brightgreen)
10
13
  ![TypeScript](https://img.shields.io/badge/TypeScript-Types-informational)
11
14
  ![Dependencies](https://img.shields.io/badge/dependencies-0-brightgreen)
12
- [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg?style=for-the-badge)](https://opensource.org/licenses/MIT)
15
+ ![ESM only](https://img.shields.io/badge/ESM-only-blue)
16
+ [![license](https://img.shields.io/badge/license-MIT-blue?style=flat-square)](./LICENSE.md)
13
17
 
14
- **Zero-GC, data-oriented OKLCH color engine** for high-performance WebGL / Canvas pipelines.
18
+ A color library built the way a game engine is built: **no allocation on the hot path.** Colors live in flat `Float32Array` OKLCH buffers. Interpolation, gamut mapping, and packing read and write those buffers in place and emit `Uint32Array` pixels that drop directly into a canvas `ImageData`. The result is perceptually-uniform OKLCH color at the throughput of raw integer math -- **~14 million colors/second** through the LUT packer, enough to recolor a **100,000-particle** field every frame at 60fps.
15
19
 
16
- ## v1.3 Highlights Batch Kernels + 4k Transfer LUT
20
+ It is not a wrapper around `culori` or `color.js`. There are no objects, no strings, and no garbage per frame. The one thing it depends on is [`@zakkster/lite-lerp`](https://www.npmjs.com/package/@zakkster/lite-lerp).
17
21
 
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.
22
+ ```bash
23
+ npm install @zakkster/lite-color-engine
24
+ ```
20
25
 
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.
26
+ ```js
27
+ import {
28
+ parseCSSColor,
29
+ lerpOklchBuffer,
30
+ packOklchBufferToUint32
31
+ } from "@zakkster/lite-color-engine";
24
32
 
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.
33
+ const a = new Float32Array(3);
34
+ const b = new Float32Array(3);
35
+ const t = new Float32Array(3); // scratch, allocated once
29
36
 
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 |
37
+ parseCSSColor("oklch(0.72 0.15 30)", a, 0); // warm
38
+ parseCSSColor("oklch(0.66 0.18 265)", b, 0); // cool
35
39
 
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.)*
40
+ // Per frame, zero allocations:
41
+ lerpOklchBuffer(a, 0, b, 0, 0.5, t, 0); // perceptual midpoint (shortest-hue)
42
+ const pixel = packOklchBufferToUint32(t, 0); // 0xAABBGGRR for a Uint32Array/ImageData
43
+ ```
38
44
 
39
- ## v1.2 Highlights Wide Gamut + Accuracy Tiers
45
+ Perceptually-uniform blends, correct sRGB output, no heap traffic. That is the whole pitch.
40
46
 
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`
47
+ ---
47
48
 
48
- P3 output is **opt-in only** — never affects default paths or bundle size.
49
+ ## Table of contents
49
50
 
50
- ## Core Philosophy
51
+ - [Why this exists](#why-this-exists)
52
+ - [What you get](#what-you-get)
53
+ - [The buffer model](#the-buffer-model)
54
+ - [The pipeline in one diagram](#the-pipeline-in-one-diagram)
55
+ - [Accuracy tiers](#accuracy-tiers)
56
+ - [Batch kernels and the 100k particle case](#batch-kernels-and-the-100k-particle-case)
57
+ - [Benchmarks](#benchmarks)
58
+ - [API reference](#api-reference)
59
+ - [Wide gamut (Display P3)](#wide-gamut-display-p3)
60
+ - [Gamut mapping (`/gamut`)](#gamut-mapping-gamut)
61
+ - [Palette remap (`/remap`)](#palette-remap-remap)
62
+ - [Formatters](#formatters)
63
+ - [Allocation profile](#allocation-profile)
64
+ - [Tree-shaking and subpaths](#tree-shaking-and-subpaths)
65
+ - [Testing](#testing)
66
+ - [Integration recipes](#integration-recipes)
67
+ - [Browser and runtime support](#browser-and-runtime-support)
68
+ - [What this is not](#what-this-is-not)
69
+ - [FAQ](#faq)
51
70
 
52
- Parse once at init → work with `Float32Array` OKLCH buffers → zero allocations on the hot path.
71
+ ---
53
72
 
54
- ## Installation
73
+ ## Why this exists
55
74
 
56
- ```bash
57
- npm install @zakkster/lite-color-engine
75
+ Color work in animation loops has a hidden cost: most libraries allocate. A `culori` blend returns a fresh object; a `color.js` conversion builds intermediate arrays; `hsl()` string interpolation allocates strings. One color is nothing. Sixty thousand colors, sixty times a second, is a GC storm that shows up as jank on exactly the frames you care about.
76
+
77
+ `lite-color-engine` was written under three constraints at once:
78
+
79
+ 1. **No allocation after warm-up.** Parsing and LUT baking allocate; the per-frame path -- lerp, pack, sample -- touches no heap. All scratch is caller-owned `Float32Array`.
80
+ 2. **Perceptually uniform by default.** Interpolation happens in OKLCH, so a red-to-blue gradient doesn't muddy through gray and lightness stays even. Hue takes the shortest path around the wheel.
81
+ 3. **Output that's actually correct.** The default packer uses the true IEC 61966-2-1 sRGB transfer, so `#rrggbb -> OKLCH -> pack` round-trips to within 1 LSB. Faster, looser tiers are opt-in, never the default.
82
+
83
+ OKLCH is the right space for interpolation. Flat typed arrays are the right representation for throughput. This library is the intersection.
84
+
85
+ ---
86
+
87
+ ## What you get
88
+
89
+ **Parse (authoring-time)**
90
+ - `parseCSSColor(str, out, off)` -- universal: hex, `rgb()/rgba()`, `hsl()/hsla()`, `oklch()`, `oklab()`, `color(display-p3 ...)`, and 140+ named colors.
91
+ - `parseHexToBuffer`, `parseRgbToBuffer`, `parseHslToBuffer`, `parseOklchToBuffer`, `parseOklabToBuffer`, `parseDisplayP3ToBuffer` -- direct parsers when you already know the format.
92
+
93
+ **Interpolate (hot path)**
94
+ - `lerpOklchBuffer(a, offA, b, offB, t, out, off)` -- one perceptual blend, shortest-path hue.
95
+ - `lerpOklchBufferN(a, offA, b, offB, t, out, off, n)` -- bulk blend of `n` triplets, one call.
96
+
97
+ **Pack to pixels (hot path)**
98
+ - `packOklchBufferToUint32(buf, off, alpha?)` -- accurate sRGB, round-trip exact.
99
+ - `packOklchBufferToUint32Fast(buf, off, alpha?)` -- `sqrt` approximation, ~2x, ~10/255 drift.
100
+ - `packOklchBufferToUint32IntoN(src, offSrc, dst, offDst, n, alpha?, useLut?)` -- bulk pack `n` colors into a `Uint32Array`; `useLut` swaps in a 4k transfer LUT (~4x, within 1 LSB).
101
+ - `packOklchBufferToUint32P3` / `...P3Fast` -- Display P3 output.
102
+
103
+ **Bake and sample (hot path)**
104
+ - `bakeGradientToUint32(keyframes, numStops, resolution?, easeFn?, packer?)` -- pre-render a gradient into a `Uint32Array` LUT once.
105
+ - `sampleColorLUT(lut, t)` -- branch-light O(1) lookup, no allocation.
106
+
107
+ **Format (authoring-time)**
108
+ - `formatOklchCss(buf, off, alpha?)` -- back to `oklch(60.0% 0.150 250.0)`.
109
+ - `formatHex(buf, off)` -- back to `#rrggbb`.
110
+
111
+ **Measure**
112
+ - `deltaEOK(a, offA, b, offB)` -- perceptual color difference (Euclidean in OKLab).
113
+
114
+ **Subpaths (opt-in, tree-shaken separately)**
115
+ - `@zakkster/lite-color-engine/gamut` -- MINDE gamut mapping.
116
+ - `@zakkster/lite-color-engine/remap` -- palette quantization for whole pixel buffers.
117
+
118
+ Full types ship in [`index.d.ts`](./index.d.ts); every public symbol has JSDoc.
119
+
120
+ ---
121
+
122
+ ## The buffer model
123
+
124
+ A color is three `Float32` lanes -- **L, C, H** -- somewhere in a `Float32Array` you own. That's it. There is no `Color` class.
125
+
126
+ ```js
127
+ // Store 1,000 colors in one flat buffer (Structure-of-Arrays friendly).
128
+ const colors = new Float32Array(1000 * 3);
129
+ parseCSSColor("crimson", colors, 42 * 3); // write color #42 at offset 126
58
130
  ```
59
131
 
60
- ## Quick Start
132
+ Every function takes `(buffer, offset)` pairs, so the same three functions work on a single color, a gradient row, or a 100k-particle system -- no per-color wrapper object is ever created. Packers emit a 32-bit integer in **little-endian RGBA** byte order, which is exactly the layout a `Uint32Array` view of a canvas `ImageData` expects:
61
133
 
62
134
  ```js
63
- import {
64
- parseCSSColor,
65
- lerpOklchBuffer,
66
- packOklchBufferToUint32,
67
- packOklchBufferToUint32P3
68
- } from '@zakkster/lite-color-engine';
135
+ const img = ctx.createImageData(w, h);
136
+ const px = new Uint32Array(img.data.buffer); // px[i] = one pixel
137
+ px[i] = packOklchBufferToUint32(colors, i * 3); // write directly, no copy
138
+ ctx.putImageData(img, 0, 0);
139
+ ```
140
+
141
+ ---
69
142
 
70
- const buf = new Float32Array(3);
71
- parseCSSColor('color(display-p3 0.9 0.4 0.1)', buf, 0);
143
+ ## The pipeline in one diagram
72
144
 
73
- // Later in render loop (zero GC)
74
- lerpOklchBuffer(bufA, 0, bufB, 0, t, temp, 0);
75
- const pixel = packOklchBufferToUint32P3(temp, 0); // or packOklchBufferToUint32
145
+ ```mermaid
146
+ flowchart LR
147
+ A["CSS string<br/>oklch(...) or hex"] -->|parse, once| B["OKLCH buffer<br/>Float32Array L,C,H"]
148
+ B -->|lerpOklchBuffer / N| C["blended OKLCH<br/>Float32Array"]
149
+ B -->|bakeGradientToUint32| D["gradient LUT<br/>Uint32Array"]
150
+ C -->|packOklchBufferToUint32*| E["packed pixel<br/>Uint32 RGBA"]
151
+ D -->|sampleColorLUT| E
152
+ E -->|Uint32Array view| F["ImageData"]
153
+ F --> G["canvas"]
76
154
  ```
77
155
 
78
- ### Batch (100k particles)
156
+ Everything left of `E` is `Float32`; everything from `E` right is integer pixels. The boundary is a single pack call. No object crosses it.
157
+
158
+ ---
159
+
160
+ ## Accuracy tiers
161
+
162
+ The same OKLCH triplet can be packed several ways. Pick the trade-off per workload:
163
+
164
+ | Tier | Function | Transfer | Speed (100k) | Error vs exact | Use when |
165
+ |---|---|---|---|---|---|
166
+ | **Fast** | `packOklchBufferToUint32Fast` | `sqrt` approx | ~fastest | ~10/255 midtone | huge counts, alpha-blended over content |
167
+ | **Accurate** | `packOklchBufferToUint32` | true sRGB `pow` | baseline (~3M/s) | exact (round-trips) | correctness, exports, single colors |
168
+ | **LUT** | `packOklchBufferToUint32IntoN(..., true)` | 4096-entry LUT | **~4x accurate (~14M/s)** | <= 1 LSB | 100k/frame, batched |
169
+ | **P3** | `packOklchBufferToUint32P3` / `Fast` | P3 primaries | ~accurate / ~fast | -- | `display-p3` canvas contexts |
170
+ | **Gamut** | `packOklchBufferToUint32MINDE` (`/gamut`) | chroma reduction | slowest | hue-preserving | vivid OKLCH that overflows sRGB |
171
+
172
+ The **LUT tier gives you Fast-packer speed at Accurate-packer quality** -- it is the one to reach for in particle systems.
173
+
174
+ ---
175
+
176
+ ## Batch kernels and the 100k particle case
177
+
178
+ v1.3 added buffer-to-buffer kernels for the shape a particle system actually has: one big SoA buffer in, one big pixel buffer out.
79
179
 
80
180
  ```js
81
- import {
82
- lerpOklchBufferN,
83
- packOklchBufferToUint32IntoN
84
- } from '@zakkster/lite-color-engine';
181
+ import { lerpOklchBufferN, packOklchBufferToUint32IntoN } from "@zakkster/lite-color-engine";
85
182
 
86
- const N = 100000;
87
- const from = new Float32Array(N * 3); // OKLCH triplets
183
+ const N = 100_000;
184
+ const from = new Float32Array(N * 3);
88
185
  const to = new Float32Array(N * 3);
89
186
  const cur = new Float32Array(N * 3);
90
- const px = new Uint32Array(N); // -> view of ImageData
187
+ const px = new Uint32Array(N); // -> Uint32Array view of ImageData
91
188
 
92
189
  // Per frame, zero allocations:
93
190
  lerpOklchBufferN(from, 0, to, 0, t, cur, 0, N);
94
191
  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.
96
192
  ```
97
193
 
98
- ## Accuracy Tiers
194
+ **An honest note on where the speed comes from.** The batch functions are mostly an *ergonomics* win, not a raw-throughput win: in V8 a clean monomorphic scalar loop inlines about as well as a hand-batched call, so accurate batch packing runs at the same speed as a scalar loop, and batch lerp is break-even. **The real win is `useLut: true`**, which removes `pow()` from the hot path. Everything below is measured, and the ratios -- not the absolute milliseconds -- are what to trust across machines.
195
+
196
+ ---
197
+
198
+ ## Benchmarks
199
+
200
+ Reproduce with `node bench/benchmark.mjs` (median of 80 runs, 100k in-gamut triplets):
201
+
202
+ | Packing 100k OKLCH -> Uint32 / frame | Throughput | ms/frame |
203
+ |---|---|---|
204
+ | scalar loop / batch accurate | ~3.2M/s | ~31 ms |
205
+ | **batch + `useLut: true`** | **~13-14M/s** | **~7 ms** |
206
+ | Fast (`sqrt`, ~10/255 error) | ~15M/s | ~7 ms |
207
+
208
+ **What holds across machines (the safe headlines):**
209
+ - LUT packing is **~4x** the accurate path.
210
+ - LUT is **within 1 LSB** of exact -- i.e. *Fast-packer speed at Accurate-packer quality*.
211
+ - Batching **by itself** is a wash (lerp ~1.0x, accurate pack ~1.0x); both are `pow`-bound, not call-bound.
212
+ - 100k lerp+pack fits a 60fps frame budget **only with the LUT** (~10 ms vs ~35 ms accurate).
213
+
214
+ Absolute milliseconds vary by device; the benchmark prints your machine's numbers and a paste-ready table.
215
+
216
+ ---
217
+
218
+ ## New in v1.5
219
+
220
+ Three additions that make the engine the ecosystem's shared color kernel for `lite-gradient-studio` v1.2 and `lite-hueforge` v1.5:
221
+
222
+ **Blue-noise dither kernels.** `getBlueNoise64()` returns a shared 64x64 void-and-cluster tile as a `Uint8Array(4096)`, decoded on first call from an inlined base64 blob and reused thereafter (histogram is exactly uniform: each byte 0..255 appears 16 times; torus-tileable). `packOklchBufferToUint32Dithered(buf, off, alpha = 1.0, noise01 = 0.5)` and its batch sibling `packOklchBufferToUint32IntoNDithered(..., noiseTile, x0, y0, rowWidth)` apply a threshold-offset dither in gamma-encoded space, shared across R/G/B — luminance-only, no chroma speckle. Parity: `noise01 === 0.5` reproduces the plain packer bit-for-bit. Zero allocations after the one-time noise decode.
223
+
224
+ ```js
225
+ import { getBlueNoise64, packOklchBufferToUint32IntoNDithered } from "@zakkster/lite-color-engine";
226
+
227
+ const tile = getBlueNoise64();
228
+ // Pack a full row of the destination raster, walking the tile in row-major order:
229
+ packOklchBufferToUint32IntoNDithered(oklchBuf, 0, pxOut, y * W, W, 1.0, tile, 0, y, W);
230
+ ```
231
+
232
+ **Cyclic LUT bake.** `bakeGradientToUint32` gained a trailing `opts` argument: `{ closed: true }` bakes a period-spaced LUT (samples at `i / resolution`, no duplicated endpoint) where the last stop wraps back to the first. Pair with `sampleColorLUTWrapped` (in `@zakkster/lite-color-lerp` v1.1) for hue wheels and cyclic colorways. Open-mode calls are byte-identical to v1.4.
233
+
234
+ **P3 and MINDE batch parity.** `packOklchBufferToUint32P3IntoN` closes the last gap in the batch-kernel family — same shape as the sRGB batch, and `useLut=true` reuses the existing `SRGB_LUT` (the P3 transfer function is IEC 61966-2-1, identical to sRGB), so there is no second table to allocate. `gamutMapToSrgbBufferN` (on the `/gamut` subpath) batches MINDE for bulk authoring / LUT-build workloads that today loop the scalar.
235
+
236
+ **Preflight (`npm run preflight`).** Fetches the latest published tarball via `npm pack` and hash-compares against the working tree. Hard-fails on drift in code (`index.js`, `index.d.ts`, `src/**`, `package.json`); warns for doc drift only. Run it before starting feature work — this is the structural cure for the stale-base regressions that hit v1.3 and v1.4.
237
+
238
+ **The tile is gated on its spectrum, not its fingerprint.** `test/bluenoise-spectral.test.js` asserts what "blue noise" actually means: minority-phase clumpiness stays under 0.70 across thresholds 16..240, that curve is symmetric about T=128, radial power for `r <= 4` sits below the white-noise floor of 1/12, high-band power sits above it, and the wrap edges are as decorrelated as the interior. A uniform histogram does **not** imply blue noise — a linear ramp has a perfectly uniform histogram. The v1.5.0 release candidate shipped a tile that passed every histogram, fingerprint and banding gate and was still 7x over-clustered above mid-gray; the SHA-256 assertion was pinning the defect rather than catching it. The generator now runs the same sweep as a hard gate and refuses to emit a tile that fails it.
239
+
240
+ **Known gap.** There is no dithered *P3* batch — `packOklchBufferToUint32P3IntoNDithered` does not exist in v1.5. Wide-gamut surfaces band too; it is a v1.6 candidate alongside the float LUT.
241
+
242
+ See CHANGELOG for the honest-notes edition, including why the dithered batch does not accept `useLut` (short version: the LUT stores pre-rounded bytes; dither needs sub-integer encoded values — a float LUT that restores this axis is a v1.6 candidate), and the deferred `-1/512` LSB dither DC bias.
243
+
244
+ ---
245
+
246
+ ## API reference
247
+
248
+ ### Parsing
249
+
250
+ ```ts
251
+ parseCSSColor(str: string, out: Float32Array, off: number): number; // returns alpha
252
+ parseHexToBuffer(str, out, off): number;
253
+ parseRgbToBuffer(str, out, off): number;
254
+ parseHslToBuffer(str, out, off): number;
255
+ parseOklchToBuffer(str, out, off): number;
256
+ parseOklabToBuffer(str, out, off): number;
257
+ parseDisplayP3ToBuffer(str, out, off): number;
258
+ ```
259
+
260
+ Each writes `[L, C, H]` at `out[off..off+2]` and returns the parsed alpha (`1.0` when absent). `parseCSSColor` sniffs the format and dispatches. Parsing allocates (regex, temporaries) and is meant for setup, not the frame loop.
261
+
262
+ ### Interpolation
263
+
264
+ ```ts
265
+ lerpOklchBuffer(a, offA, b, offB, t: number, out, off): void;
266
+ lerpOklchBufferN(a, offA, b, offB, t: number, out, off, n: number): void;
267
+ ```
268
+
269
+ Lightness clamps to `[0,1]`, chroma to `[0, +inf)`, hue takes the **shortest path** and is canonicalized to `[0, 360)`. `lerpOklchBufferN` shares one `t` across `n` triplets and is bit-for-bit identical to `n` scalar calls. In-place safe.
270
+
271
+ ### Packing
272
+
273
+ ```ts
274
+ packOklchBufferToUint32(buf, off, alpha = 1): number; // accurate sRGB
275
+ packOklchBufferToUint32Fast(buf, off, alpha = 1): number; // sqrt approx
276
+ packOklchBufferToUint32IntoN(src, offSrc, dst, offDst, n, alpha = 1, useLut = false): void;
277
+ packOklchBufferToUint32P3(buf, off, alpha = 1): number; // Display P3
278
+ packOklchBufferToUint32P3Fast(buf, off, alpha = 1): number;
279
+ ```
280
+
281
+ All return (or write) a 32-bit integer in little-endian RGBA order, hard-clamped to gamut. `IntoN` writes `n` pixels into `dst` at stride 1.
282
+
283
+ ### Gradient LUT
284
+
285
+ ```ts
286
+ bakeGradientToUint32(
287
+ keyframes: Float32Array, // contiguous [L0,C0,H0, L1,C1,H1, ...]
288
+ numStops: number, // >= 2
289
+ resolution = 256, // >= 2
290
+ easeFn?: (t: number) => number,
291
+ packer = packOklchBufferToUint32
292
+ ): Uint32Array;
293
+
294
+ sampleColorLUT(lut: Uint32Array, t: number): number; // O(1), clamped, no alloc
295
+ ```
296
+
297
+ Bake once (uses `lerpOklchBuffer` internally, so gradients are perceptual and hue-correct), then `sampleColorLUT` in the loop. Pass a custom `packer` to bake a P3 or gamut-mapped LUT.
298
+
299
+ ### Difference
300
+
301
+ ```ts
302
+ deltaEOK(a, offA, b, offB): number; // Euclidean distance in OKLab
303
+ ```
304
+
305
+ ---
306
+
307
+ ## Wide gamut (Display P3)
308
+
309
+ Parse `color(display-p3 ...)` and pack for a P3 canvas context. Colors outside sRGB but inside P3 keep their saturation.
310
+
311
+ ```js
312
+ const c = new Float32Array(3);
313
+ parseCSSColor("color(display-p3 0.9 0.2 0.35)", c, 0);
314
+
315
+ const ctx = canvas.getContext("2d", { colorSpace: "display-p3" });
316
+ const img = ctx.createImageData(w, h, { colorSpace: "display-p3" });
317
+ const px = new Uint32Array(img.data.buffer);
318
+ px[i] = packOklchBufferToUint32P3(c, 0);
319
+ ```
320
+
321
+ P3 is strictly opt-in. The default sRGB path and bundle are untouched if you never import the P3 packers.
322
+
323
+ ---
324
+
325
+ ## Gamut mapping (`/gamut`)
326
+
327
+ Vivid OKLCH colors can fall outside sRGB. The default packers hard-clamp (fast, can shift hue). For hue-preserving results, the `/gamut` subpath does **MINDE** (minimum-delta-E) chroma reduction -- it walks chroma down until the color fits, keeping lightness and hue.
328
+
329
+ ```js
330
+ import { packOklchBufferToUint32MINDE, gamutMapToSrgbBuffer } from "@zakkster/lite-color-engine/gamut";
331
+
332
+ const px = packOklchBufferToUint32MINDE(buf, 0); // pack with gamut mapping
333
+ gamutMapToSrgbBuffer(inBuf, 0, outBuf, 0); // or map in OKLCH space
334
+ ```
335
+
336
+ `gamutMapToSrgbBuffer` is `@zakkster/lite-lerp`-free and self-contained, so the subpath loads standalone.
337
+
338
+ ---
339
+
340
+ ## Palette remap (`/remap`)
341
+
342
+ Quantize a whole RGBA8 pixel buffer to a fixed palette using perceptual (OKLab) nearest-color -- for dithering-free posterization, theming, and retro/CRT looks.
343
+
344
+ ```js
345
+ import { remapPixelsToPalette } from "@zakkster/lite-color-engine/remap";
346
+
347
+ // paletteLch: Float32Array of [L,C,H] triplets; inU8: RGBA8 pixels; outU32: packed result
348
+ remapPixelsToPalette(inU8, paletteLch, outU32, pixelCount, paletteCount, opts);
349
+ ```
350
+
351
+ Lower-level building blocks are exported too: `sRgba8ToOklabBuffer`, `oklchToOklabBuffer`, `oklabToOklchBuffer`, `nearestPaletteIndexBuffer`.
352
+
353
+ ---
354
+
355
+ ## Formatters
356
+
357
+ Round-trip OKLCH buffers back to CSS for exports, design tools, and telemetry (authoring-time; they allocate a string).
358
+
359
+ ```js
360
+ import { formatOklchCss, formatHex } from "@zakkster/lite-color-engine";
361
+
362
+ const b = new Float32Array(3);
363
+ parseCSSColor("#3fa9c8", b, 0);
364
+ formatOklchCss(b, 0); // "oklch(68.7% 0.105 221.0)"
365
+ formatOklchCss(b, 0, 0.8); // "oklch(68.7% 0.105 221.0) / 0.800"
366
+ formatHex(b, 0); // "#3fa9c8"
367
+ ```
368
+
369
+ `formatHex` uses the accurate pack path, so it round-trips with `parseHexToBuffer` to within 1 LSB. Alpha is emitted only when supplied and below 1.
370
+
371
+ ---
372
+
373
+ ## Allocation profile
374
+
375
+ Honest accounting of where memory is spent:
376
+
377
+ | Operation | Allocates | Per-frame allocation |
378
+ |---|---|---|
379
+ | `parseCSSColor` / `parse*` | regex + temporaries | (setup only) |
380
+ | `bakeGradientToUint32` | one `Uint32Array` LUT | (setup only) |
381
+ | `formatOklchCss` / `formatHex` | one string | (authoring only) |
382
+ | `lerpOklchBuffer` / `N` | -- | **0** |
383
+ | `packOklchBufferToUint32*` | -- | **0** |
384
+ | `packOklchBufferToUint32IntoN` | -- | **0** |
385
+ | `sampleColorLUT` | -- | **0** |
386
+ | `deltaEOK` | -- | **0** |
387
+
388
+ The 4k sRGB LUT behind `useLut` is a single module-load allocation, shared process-wide.
389
+
390
+ ---
391
+
392
+ ## Tree-shaking and subpaths
393
+
394
+ `sideEffects: false`, pure ESM. Import only what you touch and the rest never enters your bundle. Wide-gamut mapping and palette remap live behind subpaths so they cost nothing unless imported:
395
+
396
+ ```js
397
+ import { lerpOklchBuffer } from "@zakkster/lite-color-engine"; // core
398
+ import { packOklchBufferToUint32MINDE } from "@zakkster/lite-color-engine/gamut";
399
+ import { remapPixelsToPalette } from "@zakkster/lite-color-engine/remap";
400
+ ```
401
+
402
+ One runtime dependency: [`@zakkster/lite-lerp`](https://www.npmjs.com/package/@zakkster/lite-lerp) (zero-dep itself).
403
+
404
+ ---
405
+
406
+ ## Testing
407
+
408
+ 117 tests across 7 files (`node --test` via Vitest):
409
+
410
+ - **Parsing / authoring** -- every format, named colors, alpha, malformed input.
411
+ - **Convert** -- sRGB and P3 round-trips against reference math.
412
+ - **Runtime** -- pack byte order, transfer accuracy, `Fast` error bounds.
413
+ - **Batch** -- `lerpOklchBufferN` and `packOklchBufferToUint32IntoN` proven **bit-for-bit** equal to scalar loops; LUT within 1 LSB.
414
+ - **Formatters** -- hex/OKLCH round-trip, alpha rules, buffer purity.
415
+ - **Gamut / Remap** -- MINDE hue preservation, palette nearest-color.
416
+ - **DeltaE** -- perceptual difference against known pairs.
417
+
418
+ ```bash
419
+ npm test # full suite
420
+ node bench/benchmark.mjs # reproduce the perf numbers
421
+ ```
422
+
423
+ ---
424
+
425
+ ## Integration recipes
426
+
427
+ ### Scrolling canvas gradient (zero-GC)
428
+
429
+ ```js
430
+ import { bakeGradientToUint32, sampleColorLUT, parseCSSColor } from "@zakkster/lite-color-engine";
431
+
432
+ const stops = ["oklch(0.7 0.18 30)", "oklch(0.8 0.2 120)", "oklch(0.65 0.2 265)"];
433
+ const kf = new Float32Array(stops.length * 3);
434
+ stops.forEach((s, i) => parseCSSColor(s, kf, i * 3));
435
+ const lut = bakeGradientToUint32(kf, stops.length, 512);
436
+
437
+ function frame(t) {
438
+ for (let x = 0; x < w; x++) {
439
+ px[x] = sampleColorLUT(lut, (x / w + t * 0.0002) % 1);
440
+ }
441
+ for (let y = 1; y < h; y++) px.copyWithin(y * w, 0, w); // replicate row
442
+ ctx.putImageData(img, 0, 0);
443
+ requestAnimationFrame(frame);
444
+ }
445
+ ```
446
+
447
+ ### 100k particles colored by life
448
+
449
+ ```js
450
+ import { bakeGradientToUint32, sampleColorLUT } from "@zakkster/lite-color-engine";
451
+
452
+ const lut = bakeGradientToUint32(kf, kf.length / 3, 256);
453
+ function render() {
454
+ px.fill(0xFF000000); // px = Uint32Array view of ImageData
455
+ for (let i = 0; i < N; i++) {
456
+ const color = sampleColorLUT(lut, life[i] / maxLife[i]);
457
+ px[(partY[i] | 0) * w + (partX[i] | 0)] = color; // direct pixel write
458
+ }
459
+ ctx.putImageData(img, 0, 0);
460
+ }
461
+ ```
462
+
463
+ (A full oscilloscope-themed demo ships in `demo/index.html` -- run `npx serve .` from the package root. Five scenes: particle field, batch kernels, accuracy tiers + the v1.5 P3 batch on a `display-p3` canvas, blue-noise dither on a shallow highlight ramp, and the open-vs-closed cyclic bake shown as a hue ring with its seam measured live.)
464
+
465
+ ---
466
+
467
+ ## Browser and runtime support
468
+
469
+ <details>
470
+ <summary>Support matrix.</summary>
471
+
472
+ Pure ES2020 typed arrays + `Math`. Runs anywhere modern JS runs.
473
+
474
+ | Target | Supported |
475
+ |---|---|
476
+ | Chrome / Edge (last 2 majors) | yes |
477
+ | Firefox (last 2 majors) | yes |
478
+ | Safari 15+ | yes |
479
+ | Node.js 18+ | yes |
480
+ | Bun / Deno | yes |
481
+ | Twitch Extensions (1MB / 3s) | yes |
482
+ | Cloudflare Workers | yes |
483
+
484
+ Display-P3 canvas output additionally needs a browser with `{ colorSpace: "display-p3" }` support (Chrome/Safari). ESM-only; no CommonJS build.
485
+
486
+ </details>
487
+
488
+ ---
489
+
490
+ ## What this is not
491
+
492
+ - **Not a `Color` object library.** No class, no method chaining. If you want `color("red").lighten(0.1).hex()`, use `culori` or `color.js`; they allocate, and for authoring that's fine.
493
+ - **Not a CSS engine.** It parses the color grammar it needs, not `calc()`, relative color syntax, or `color-mix()`.
494
+ - **Not a full ICC color-management stack.** It handles sRGB and Display P3. No CMYK, no arbitrary ICC profiles.
495
+ - **Not a renderer.** It produces pixels; you own the canvas, the loop, and the physics.
496
+
497
+ ---
498
+
499
+ ## FAQ
500
+
501
+ **Why OKLCH and not HSL or Lab?** HSL interpolation muddies and shifts lightness; Lab hue paths are awkward. OKLCH is perceptually uniform *and* has a natural hue axis, so gradients stay clean and even.
99
502
 
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 |
503
+ **Why `Float32Array` instead of objects?** Objects allocate and pointer-chase. Flat arrays are cache-friendly and let the same function serve one color or a million. It's the difference between 3M and jank, and 14M and smooth.
105
504
 
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.
505
+ **Is the `Fast` packer wrong?** It's approximate -- `sqrt` instead of the sRGB transfer, ~10/255 in midtones. Fine when the pixel is alpha-blended over arbitrary content; not for exact round-trips. Use the LUT tier if you want speed *and* accuracy.
109
506
 
110
- For Display P3 output, use the `P3` variants.
507
+ **Do I have to manage buffers myself?** Yes -- that's the point. You allocate scratch once at setup and reuse it. The library never allocates behind your back on the frame path.
111
508
 
112
- ## Documentation
509
+ **Does `useLut` change my output?** By at most 1 LSB per channel versus the exact packer -- imperceptible, and it removes `pow()` from the loop for ~4x throughput.
113
510
 
114
- See full API in `llms.txt` or the source `src/` files.
511
+ ---
115
512
 
116
513
  ## License
117
514
 
118
- MIT
515
+ MIT (c) Zahary Shinikchiev. See [LICENSE.md](./LICENSE.md).