@zakkster/lite-color-engine 1.2.0 → 1.4.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 +61 -7
- package/README.md +460 -38
- package/index.d.ts +49 -0
- package/index.js +5 -1
- package/llms.txt +16 -6
- package/package.json +4 -3
- package/src/authoring.js +60 -3
- package/src/runtime.js +119 -4
package/CHANGELOG.md
CHANGED
|
@@ -1,21 +1,74 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## [1.4.0] - 2026-07-10
|
|
4
|
+
|
|
5
|
+
### Added
|
|
6
|
+
- **CSS formatters (round-trip emit):**
|
|
7
|
+
- `formatOklchCss(buf, off, alpha?)` - emits `oklch(L% C H)` (or `.../ alpha` when
|
|
8
|
+
alpha is supplied and < 1). Round-trips through `parseCSSColor`.
|
|
9
|
+
- `formatHex(buf, off)` - emits `#rrggbb` via the accurate sRGB pack path.
|
|
10
|
+
Round-trips with `parseHexToBuffer` to within 1 LSB per channel.
|
|
11
|
+
- Both are authoring-time helpers (they allocate a string) and are not for
|
|
12
|
+
per-frame hot paths.
|
|
13
|
+
|
|
14
|
+
### Fixed
|
|
15
|
+
- Named-color table: `darkslateggrey` -> `darkslategrey` and `navajawhite` ->
|
|
16
|
+
`navajowhite` (previously unreachable typo keys), and `lightsteelblue` now
|
|
17
|
+
returns its correct value `#b0c4de` instead of powderblue's `#b0e0e6`.
|
|
18
|
+
|
|
19
|
+
### Notes
|
|
20
|
+
- No breaking changes; all v1.3 exports (batch kernels, 4k LUT, Display P3,
|
|
21
|
+
`sampleColorLUT`) are retained.
|
|
22
|
+
|
|
23
|
+
---
|
|
24
|
+
|
|
25
|
+
## [1.3.0] - 2026-07-10
|
|
26
|
+
|
|
27
|
+
### Added
|
|
28
|
+
- **Batch kernels for large particle systems (100k+):**
|
|
29
|
+
- `lerpOklchBufferN(a, offA, b, offB, t, out, offOut, n)` - interpolate `n` OKLCH
|
|
30
|
+
triplets (stride 3) with a shared `t`. Bit-for-bit identical to `n` scalar
|
|
31
|
+
`lerpOklchBuffer` calls; supports in-place and offset addressing.
|
|
32
|
+
- `packOklchBufferToUint32IntoN(src, offSrc, dst, offDst, n, alpha?, useLut?)` -
|
|
33
|
+
pack `n` OKLCH triplets straight into a `Uint32Array` (dst stride 1), the exact
|
|
34
|
+
shape a SoA particle system blits into `ImageData`.
|
|
35
|
+
- **Opt-in 4096-entry sRGB transfer LUT** (`useLut: true` on the batch packer):
|
|
36
|
+
removes `pow()` from the hot path for **~2.7x** packing throughput at **within
|
|
37
|
+
1 LSB** of the exact encoder. Single one-time table allocation.
|
|
38
|
+
|
|
39
|
+
### Performance (measured, Node 22 / V8, 100k in-gamut triplets, median of 80)
|
|
40
|
+
- Packing: accurate ~3.2M colors/s (~32 ms/frame); LUT ~14M colors/s (~7 ms/frame) - **~4.4x**.
|
|
41
|
+
The LUT runs at Fast-packer speed (`sqrt`) while staying within 1 LSB of exact.
|
|
42
|
+
- 100k lerp+pack fits a 60fps frame only with the LUT (~10 ms vs ~35 ms accurate).
|
|
43
|
+
- Honest caveat: the speedup is the **LUT**, not the batching. Accurate batch
|
|
44
|
+
packing runs at the same speed as a scalar loop (pow-bound), and batch lerp is
|
|
45
|
+
not measurably faster than a scalar loop in V8. The batch APIs are primarily
|
|
46
|
+
about ergonomics (whole-buffer ops) and unlocking the LUT path. Reproduce with
|
|
47
|
+
`node bench/benchmark.mjs`.
|
|
48
|
+
|
|
49
|
+
### Notes
|
|
50
|
+
- All hot paths remain zero-GC and allocation-free per frame.
|
|
51
|
+
- No breaking changes; all v1.2 exports (including Display P3 and `sampleColorLUT`)
|
|
52
|
+
are retained.
|
|
53
|
+
|
|
54
|
+
---
|
|
55
|
+
|
|
3
56
|
## [1.2.0] - 2026-07-10
|
|
4
57
|
|
|
5
58
|
### Added
|
|
6
59
|
- **Wide Gamut Support (Display P3)**
|
|
7
60
|
- `parseCSSColor('color(display-p3 r g b / alpha)')` now supported in the universal parser
|
|
8
|
-
- New `displayP3ToOklchBuffer()` conversion function with accurate P3
|
|
9
|
-
- `packOklchBufferToUint32P3()`
|
|
10
|
-
- `packOklchBufferToUint32P3Fast()`
|
|
61
|
+
- New `displayP3ToOklchBuffer()` conversion function with accurate P3 -> XYZ -> OKLab pipeline
|
|
62
|
+
- `packOklchBufferToUint32P3()` - accurate packer for `display-p3` canvas contexts
|
|
63
|
+
- `packOklchBufferToUint32P3Fast()` - high-speed sqrt approximation for P3 output
|
|
11
64
|
- `oklchToLinearP3()` inverse conversion helper
|
|
12
65
|
|
|
13
66
|
- **Accuracy Tiers** (now clearly documented)
|
|
14
67
|
- Fast (`packOklchBufferToUint32Fast`)
|
|
15
68
|
- Accurate-Clamp (default `packOklchBufferToUint32`)
|
|
16
|
-
- Gamut-Mapped (`packOklchBufferToUint32MINDE
|
|
69
|
+
- Gamut-Mapped (`packOklchBufferToUint32MINDE`, on the `/gamut` subpath)
|
|
17
70
|
|
|
18
|
-
- `bakeGradientToUint32()` already supported custom packers
|
|
71
|
+
- `bakeGradientToUint32()` already supported custom packers - now officially documented for P3 usage.
|
|
19
72
|
|
|
20
73
|
### Changed
|
|
21
74
|
- Improved documentation around color gamut handling and when to use each packer.
|
|
@@ -28,5 +81,6 @@
|
|
|
28
81
|
---
|
|
29
82
|
|
|
30
83
|
## [1.1.0] - Previous
|
|
31
|
-
|
|
32
|
-
-
|
|
84
|
+
|
|
85
|
+
- Core zero-GC OKLCH engine: parsing, buffer lerp, packers, delta-E, gradient LUT baking,
|
|
86
|
+
gamut mapping (`/gamut`) and palette remap (`/remap`) subpaths.
|
package/README.md
CHANGED
|
@@ -1,64 +1,486 @@
|
|
|
1
1
|
# @zakkster/lite-color-engine
|
|
2
2
|
|
|
3
|
-
|
|
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
4
|
|
|
5
|
-
|
|
5
|
+
[](https://www.npmjs.com/package/@zakkster/lite-color-engine)
|
|
6
|
+
[](https://github.com/sponsors/PeshoVurtoleta)
|
|
7
|
+

|
|
8
|
+
[](https://bundlephobia.com/result?p=@zakkster/lite-color-engine)
|
|
9
|
+
[](https://www.npmjs.com/package/@zakkster/lite-color-engine)
|
|
10
|
+
[](https://www.npmjs.com/package/@zakkster/lite-color-engine)
|
|
11
|
+

|
|
12
|
+

|
|
13
|
+

|
|
14
|
+

|
|
15
|
+
[](./LICENSE.md)
|
|
6
16
|
|
|
7
|
-
|
|
8
|
-
- Dedicated high-accuracy `packOklchBufferToUint32P3()` and fast variant
|
|
9
|
-
- Three clear accuracy tiers for sRGB output:
|
|
10
|
-
1. **Fast** — `packOklchBufferToUint32Fast`
|
|
11
|
-
2. **Accurate-Clamp** — default `packOklchBufferToUint32`
|
|
12
|
-
3. **Gamut-Mapped** — `packOklchBufferToUint32MINDE`
|
|
17
|
+
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.
|
|
13
18
|
|
|
14
|
-
|
|
19
|
+
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).
|
|
15
20
|
|
|
16
|
-
|
|
21
|
+
```bash
|
|
22
|
+
npm install @zakkster/lite-color-engine
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
```js
|
|
26
|
+
import {
|
|
27
|
+
parseCSSColor,
|
|
28
|
+
lerpOklchBuffer,
|
|
29
|
+
packOklchBufferToUint32
|
|
30
|
+
} from "@zakkster/lite-color-engine";
|
|
31
|
+
|
|
32
|
+
const a = new Float32Array(3);
|
|
33
|
+
const b = new Float32Array(3);
|
|
34
|
+
const t = new Float32Array(3); // scratch, allocated once
|
|
35
|
+
|
|
36
|
+
parseCSSColor("oklch(0.72 0.15 30)", a, 0); // warm
|
|
37
|
+
parseCSSColor("oklch(0.66 0.18 265)", b, 0); // cool
|
|
38
|
+
|
|
39
|
+
// Per frame, zero allocations:
|
|
40
|
+
lerpOklchBuffer(a, 0, b, 0, 0.5, t, 0); // perceptual midpoint (shortest-hue)
|
|
41
|
+
const pixel = packOklchBufferToUint32(t, 0); // 0xAABBGGRR for a Uint32Array/ImageData
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
Perceptually-uniform blends, correct sRGB output, no heap traffic. That is the whole pitch.
|
|
45
|
+
|
|
46
|
+
---
|
|
47
|
+
|
|
48
|
+
## Table of contents
|
|
49
|
+
|
|
50
|
+
- [Why this exists](#why-this-exists)
|
|
51
|
+
- [What you get](#what-you-get)
|
|
52
|
+
- [The buffer model](#the-buffer-model)
|
|
53
|
+
- [The pipeline in one diagram](#the-pipeline-in-one-diagram)
|
|
54
|
+
- [Accuracy tiers](#accuracy-tiers)
|
|
55
|
+
- [Batch kernels and the 100k particle case](#batch-kernels-and-the-100k-particle-case)
|
|
56
|
+
- [Benchmarks](#benchmarks)
|
|
57
|
+
- [API reference](#api-reference)
|
|
58
|
+
- [Wide gamut (Display P3)](#wide-gamut-display-p3)
|
|
59
|
+
- [Gamut mapping (`/gamut`)](#gamut-mapping-gamut)
|
|
60
|
+
- [Palette remap (`/remap`)](#palette-remap-remap)
|
|
61
|
+
- [Formatters](#formatters)
|
|
62
|
+
- [Allocation profile](#allocation-profile)
|
|
63
|
+
- [Tree-shaking and subpaths](#tree-shaking-and-subpaths)
|
|
64
|
+
- [Testing](#testing)
|
|
65
|
+
- [Integration recipes](#integration-recipes)
|
|
66
|
+
- [Browser and runtime support](#browser-and-runtime-support)
|
|
67
|
+
- [What this is not](#what-this-is-not)
|
|
68
|
+
- [FAQ](#faq)
|
|
69
|
+
|
|
70
|
+
---
|
|
71
|
+
|
|
72
|
+
## Why this exists
|
|
73
|
+
|
|
74
|
+
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.
|
|
75
|
+
|
|
76
|
+
`lite-color-engine` was written under three constraints at once:
|
|
77
|
+
|
|
78
|
+
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`.
|
|
79
|
+
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.
|
|
80
|
+
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.
|
|
81
|
+
|
|
82
|
+
OKLCH is the right space for interpolation. Flat typed arrays are the right representation for throughput. This library is the intersection.
|
|
83
|
+
|
|
84
|
+
---
|
|
85
|
+
|
|
86
|
+
## What you get
|
|
87
|
+
|
|
88
|
+
**Parse (authoring-time)**
|
|
89
|
+
- `parseCSSColor(str, out, off)` -- universal: hex, `rgb()/rgba()`, `hsl()/hsla()`, `oklch()`, `oklab()`, `color(display-p3 ...)`, and 140+ named colors.
|
|
90
|
+
- `parseHexToBuffer`, `parseRgbToBuffer`, `parseHslToBuffer`, `parseOklchToBuffer`, `parseOklabToBuffer`, `parseDisplayP3ToBuffer` -- direct parsers when you already know the format.
|
|
91
|
+
|
|
92
|
+
**Interpolate (hot path)**
|
|
93
|
+
- `lerpOklchBuffer(a, offA, b, offB, t, out, off)` -- one perceptual blend, shortest-path hue.
|
|
94
|
+
- `lerpOklchBufferN(a, offA, b, offB, t, out, off, n)` -- bulk blend of `n` triplets, one call.
|
|
95
|
+
|
|
96
|
+
**Pack to pixels (hot path)**
|
|
97
|
+
- `packOklchBufferToUint32(buf, off, alpha?)` -- accurate sRGB, round-trip exact.
|
|
98
|
+
- `packOklchBufferToUint32Fast(buf, off, alpha?)` -- `sqrt` approximation, ~2x, ~10/255 drift.
|
|
99
|
+
- `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).
|
|
100
|
+
- `packOklchBufferToUint32P3` / `...P3Fast` -- Display P3 output.
|
|
101
|
+
|
|
102
|
+
**Bake and sample (hot path)**
|
|
103
|
+
- `bakeGradientToUint32(keyframes, numStops, resolution?, easeFn?, packer?)` -- pre-render a gradient into a `Uint32Array` LUT once.
|
|
104
|
+
- `sampleColorLUT(lut, t)` -- branch-light O(1) lookup, no allocation.
|
|
105
|
+
|
|
106
|
+
**Format (authoring-time)**
|
|
107
|
+
- `formatOklchCss(buf, off, alpha?)` -- back to `oklch(60.0% 0.150 250.0)`.
|
|
108
|
+
- `formatHex(buf, off)` -- back to `#rrggbb`.
|
|
109
|
+
|
|
110
|
+
**Measure**
|
|
111
|
+
- `deltaEOK(a, offA, b, offB)` -- perceptual color difference (Euclidean in OKLab).
|
|
112
|
+
|
|
113
|
+
**Subpaths (opt-in, tree-shaken separately)**
|
|
114
|
+
- `@zakkster/lite-color-engine/gamut` -- MINDE gamut mapping.
|
|
115
|
+
- `@zakkster/lite-color-engine/remap` -- palette quantization for whole pixel buffers.
|
|
116
|
+
|
|
117
|
+
Full types ship in [`index.d.ts`](./index.d.ts); every public symbol has JSDoc.
|
|
118
|
+
|
|
119
|
+
---
|
|
120
|
+
|
|
121
|
+
## The buffer model
|
|
122
|
+
|
|
123
|
+
A color is three `Float32` lanes -- **L, C, H** -- somewhere in a `Float32Array` you own. That's it. There is no `Color` class.
|
|
124
|
+
|
|
125
|
+
```js
|
|
126
|
+
// Store 1,000 colors in one flat buffer (Structure-of-Arrays friendly).
|
|
127
|
+
const colors = new Float32Array(1000 * 3);
|
|
128
|
+
parseCSSColor("crimson", colors, 42 * 3); // write color #42 at offset 126
|
|
129
|
+
```
|
|
130
|
+
|
|
131
|
+
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:
|
|
132
|
+
|
|
133
|
+
```js
|
|
134
|
+
const img = ctx.createImageData(w, h);
|
|
135
|
+
const px = new Uint32Array(img.data.buffer); // px[i] = one pixel
|
|
136
|
+
px[i] = packOklchBufferToUint32(colors, i * 3); // write directly, no copy
|
|
137
|
+
ctx.putImageData(img, 0, 0);
|
|
138
|
+
```
|
|
139
|
+
|
|
140
|
+
---
|
|
141
|
+
|
|
142
|
+
## The pipeline in one diagram
|
|
143
|
+
|
|
144
|
+
```mermaid
|
|
145
|
+
flowchart LR
|
|
146
|
+
A["CSS string<br/>oklch(...) or hex"] -->|parse, once| B["OKLCH buffer<br/>Float32Array L,C,H"]
|
|
147
|
+
B -->|lerpOklchBuffer / N| C["blended OKLCH<br/>Float32Array"]
|
|
148
|
+
B -->|bakeGradientToUint32| D["gradient LUT<br/>Uint32Array"]
|
|
149
|
+
C -->|packOklchBufferToUint32*| E["packed pixel<br/>Uint32 RGBA"]
|
|
150
|
+
D -->|sampleColorLUT| E
|
|
151
|
+
E -->|Uint32Array view| F["ImageData"]
|
|
152
|
+
F --> G["canvas"]
|
|
153
|
+
```
|
|
154
|
+
|
|
155
|
+
Everything left of `E` is `Float32`; everything from `E` right is integer pixels. The boundary is a single pack call. No object crosses it.
|
|
156
|
+
|
|
157
|
+
---
|
|
158
|
+
|
|
159
|
+
## Accuracy tiers
|
|
160
|
+
|
|
161
|
+
The same OKLCH triplet can be packed several ways. Pick the trade-off per workload:
|
|
162
|
+
|
|
163
|
+
| Tier | Function | Transfer | Speed (100k) | Error vs exact | Use when |
|
|
164
|
+
|---|---|---|---|---|---|
|
|
165
|
+
| **Fast** | `packOklchBufferToUint32Fast` | `sqrt` approx | ~fastest | ~10/255 midtone | huge counts, alpha-blended over content |
|
|
166
|
+
| **Accurate** | `packOklchBufferToUint32` | true sRGB `pow` | baseline (~3M/s) | exact (round-trips) | correctness, exports, single colors |
|
|
167
|
+
| **LUT** | `packOklchBufferToUint32IntoN(..., true)` | 4096-entry LUT | **~4x accurate (~14M/s)** | <= 1 LSB | 100k/frame, batched |
|
|
168
|
+
| **P3** | `packOklchBufferToUint32P3` / `Fast` | P3 primaries | ~accurate / ~fast | -- | `display-p3` canvas contexts |
|
|
169
|
+
| **Gamut** | `packOklchBufferToUint32MINDE` (`/gamut`) | chroma reduction | slowest | hue-preserving | vivid OKLCH that overflows sRGB |
|
|
170
|
+
|
|
171
|
+
The **LUT tier gives you Fast-packer speed at Accurate-packer quality** -- it is the one to reach for in particle systems.
|
|
172
|
+
|
|
173
|
+
---
|
|
174
|
+
|
|
175
|
+
## Batch kernels and the 100k particle case
|
|
176
|
+
|
|
177
|
+
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.
|
|
178
|
+
|
|
179
|
+
```js
|
|
180
|
+
import { lerpOklchBufferN, packOklchBufferToUint32IntoN } from "@zakkster/lite-color-engine";
|
|
181
|
+
|
|
182
|
+
const N = 100_000;
|
|
183
|
+
const from = new Float32Array(N * 3);
|
|
184
|
+
const to = new Float32Array(N * 3);
|
|
185
|
+
const cur = new Float32Array(N * 3);
|
|
186
|
+
const px = new Uint32Array(N); // -> Uint32Array view of ImageData
|
|
187
|
+
|
|
188
|
+
// Per frame, zero allocations:
|
|
189
|
+
lerpOklchBufferN(from, 0, to, 0, t, cur, 0, N);
|
|
190
|
+
packOklchBufferToUint32IntoN(cur, 0, px, 0, N, 1.0, /* useLut */ true);
|
|
191
|
+
```
|
|
192
|
+
|
|
193
|
+
**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.
|
|
194
|
+
|
|
195
|
+
---
|
|
196
|
+
|
|
197
|
+
## Benchmarks
|
|
198
|
+
|
|
199
|
+
Reproduce with `node bench/benchmark.mjs` (median of 80 runs, 100k in-gamut triplets):
|
|
200
|
+
|
|
201
|
+
| Packing 100k OKLCH -> Uint32 / frame | Throughput | ms/frame |
|
|
202
|
+
|---|---|---|
|
|
203
|
+
| scalar loop / batch accurate | ~3.2M/s | ~31 ms |
|
|
204
|
+
| **batch + `useLut: true`** | **~13-14M/s** | **~7 ms** |
|
|
205
|
+
| Fast (`sqrt`, ~10/255 error) | ~15M/s | ~7 ms |
|
|
206
|
+
|
|
207
|
+
**What holds across machines (the safe headlines):**
|
|
208
|
+
- LUT packing is **~4x** the accurate path.
|
|
209
|
+
- LUT is **within 1 LSB** of exact -- i.e. *Fast-packer speed at Accurate-packer quality*.
|
|
210
|
+
- Batching **by itself** is a wash (lerp ~1.0x, accurate pack ~1.0x); both are `pow`-bound, not call-bound.
|
|
211
|
+
- 100k lerp+pack fits a 60fps frame budget **only with the LUT** (~10 ms vs ~35 ms accurate).
|
|
212
|
+
|
|
213
|
+
Absolute milliseconds vary by device; the benchmark prints your machine's numbers and a paste-ready table.
|
|
214
|
+
|
|
215
|
+
---
|
|
216
|
+
|
|
217
|
+
## API reference
|
|
218
|
+
|
|
219
|
+
### Parsing
|
|
220
|
+
|
|
221
|
+
```ts
|
|
222
|
+
parseCSSColor(str: string, out: Float32Array, off: number): number; // returns alpha
|
|
223
|
+
parseHexToBuffer(str, out, off): number;
|
|
224
|
+
parseRgbToBuffer(str, out, off): number;
|
|
225
|
+
parseHslToBuffer(str, out, off): number;
|
|
226
|
+
parseOklchToBuffer(str, out, off): number;
|
|
227
|
+
parseOklabToBuffer(str, out, off): number;
|
|
228
|
+
parseDisplayP3ToBuffer(str, out, off): number;
|
|
229
|
+
```
|
|
230
|
+
|
|
231
|
+
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.
|
|
232
|
+
|
|
233
|
+
### Interpolation
|
|
234
|
+
|
|
235
|
+
```ts
|
|
236
|
+
lerpOklchBuffer(a, offA, b, offB, t: number, out, off): void;
|
|
237
|
+
lerpOklchBufferN(a, offA, b, offB, t: number, out, off, n: number): void;
|
|
238
|
+
```
|
|
239
|
+
|
|
240
|
+
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.
|
|
241
|
+
|
|
242
|
+
### Packing
|
|
243
|
+
|
|
244
|
+
```ts
|
|
245
|
+
packOklchBufferToUint32(buf, off, alpha = 1): number; // accurate sRGB
|
|
246
|
+
packOklchBufferToUint32Fast(buf, off, alpha = 1): number; // sqrt approx
|
|
247
|
+
packOklchBufferToUint32IntoN(src, offSrc, dst, offDst, n, alpha = 1, useLut = false): void;
|
|
248
|
+
packOklchBufferToUint32P3(buf, off, alpha = 1): number; // Display P3
|
|
249
|
+
packOklchBufferToUint32P3Fast(buf, off, alpha = 1): number;
|
|
250
|
+
```
|
|
17
251
|
|
|
18
|
-
|
|
252
|
+
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.
|
|
19
253
|
|
|
20
|
-
|
|
254
|
+
### Gradient LUT
|
|
255
|
+
|
|
256
|
+
```ts
|
|
257
|
+
bakeGradientToUint32(
|
|
258
|
+
keyframes: Float32Array, // contiguous [L0,C0,H0, L1,C1,H1, ...]
|
|
259
|
+
numStops: number, // >= 2
|
|
260
|
+
resolution = 256, // >= 2
|
|
261
|
+
easeFn?: (t: number) => number,
|
|
262
|
+
packer = packOklchBufferToUint32
|
|
263
|
+
): Uint32Array;
|
|
264
|
+
|
|
265
|
+
sampleColorLUT(lut: Uint32Array, t: number): number; // O(1), clamped, no alloc
|
|
266
|
+
```
|
|
267
|
+
|
|
268
|
+
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.
|
|
269
|
+
|
|
270
|
+
### Difference
|
|
271
|
+
|
|
272
|
+
```ts
|
|
273
|
+
deltaEOK(a, offA, b, offB): number; // Euclidean distance in OKLab
|
|
274
|
+
```
|
|
275
|
+
|
|
276
|
+
---
|
|
277
|
+
|
|
278
|
+
## Wide gamut (Display P3)
|
|
279
|
+
|
|
280
|
+
Parse `color(display-p3 ...)` and pack for a P3 canvas context. Colors outside sRGB but inside P3 keep their saturation.
|
|
281
|
+
|
|
282
|
+
```js
|
|
283
|
+
const c = new Float32Array(3);
|
|
284
|
+
parseCSSColor("color(display-p3 0.9 0.2 0.35)", c, 0);
|
|
285
|
+
|
|
286
|
+
const ctx = canvas.getContext("2d", { colorSpace: "display-p3" });
|
|
287
|
+
const img = ctx.createImageData(w, h, { colorSpace: "display-p3" });
|
|
288
|
+
const px = new Uint32Array(img.data.buffer);
|
|
289
|
+
px[i] = packOklchBufferToUint32P3(c, 0);
|
|
290
|
+
```
|
|
291
|
+
|
|
292
|
+
P3 is strictly opt-in. The default sRGB path and bundle are untouched if you never import the P3 packers.
|
|
293
|
+
|
|
294
|
+
---
|
|
295
|
+
|
|
296
|
+
## Gamut mapping (`/gamut`)
|
|
297
|
+
|
|
298
|
+
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.
|
|
299
|
+
|
|
300
|
+
```js
|
|
301
|
+
import { packOklchBufferToUint32MINDE, gamutMapToSrgbBuffer } from "@zakkster/lite-color-engine/gamut";
|
|
302
|
+
|
|
303
|
+
const px = packOklchBufferToUint32MINDE(buf, 0); // pack with gamut mapping
|
|
304
|
+
gamutMapToSrgbBuffer(inBuf, 0, outBuf, 0); // or map in OKLCH space
|
|
305
|
+
```
|
|
306
|
+
|
|
307
|
+
`gamutMapToSrgbBuffer` is `@zakkster/lite-lerp`-free and self-contained, so the subpath loads standalone.
|
|
308
|
+
|
|
309
|
+
---
|
|
310
|
+
|
|
311
|
+
## Palette remap (`/remap`)
|
|
312
|
+
|
|
313
|
+
Quantize a whole RGBA8 pixel buffer to a fixed palette using perceptual (OKLab) nearest-color -- for dithering-free posterization, theming, and retro/CRT looks.
|
|
314
|
+
|
|
315
|
+
```js
|
|
316
|
+
import { remapPixelsToPalette } from "@zakkster/lite-color-engine/remap";
|
|
317
|
+
|
|
318
|
+
// paletteLch: Float32Array of [L,C,H] triplets; inU8: RGBA8 pixels; outU32: packed result
|
|
319
|
+
remapPixelsToPalette(inU8, paletteLch, outU32, pixelCount, paletteCount, opts);
|
|
320
|
+
```
|
|
321
|
+
|
|
322
|
+
Lower-level building blocks are exported too: `sRgba8ToOklabBuffer`, `oklchToOklabBuffer`, `oklabToOklchBuffer`, `nearestPaletteIndexBuffer`.
|
|
323
|
+
|
|
324
|
+
---
|
|
325
|
+
|
|
326
|
+
## Formatters
|
|
327
|
+
|
|
328
|
+
Round-trip OKLCH buffers back to CSS for exports, design tools, and telemetry (authoring-time; they allocate a string).
|
|
329
|
+
|
|
330
|
+
```js
|
|
331
|
+
import { formatOklchCss, formatHex } from "@zakkster/lite-color-engine";
|
|
332
|
+
|
|
333
|
+
const b = new Float32Array(3);
|
|
334
|
+
parseCSSColor("#3fa9c8", b, 0);
|
|
335
|
+
formatOklchCss(b, 0); // "oklch(68.7% 0.105 221.0)"
|
|
336
|
+
formatOklchCss(b, 0, 0.8); // "oklch(68.7% 0.105 221.0) / 0.800"
|
|
337
|
+
formatHex(b, 0); // "#3fa9c8"
|
|
338
|
+
```
|
|
339
|
+
|
|
340
|
+
`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.
|
|
341
|
+
|
|
342
|
+
---
|
|
343
|
+
|
|
344
|
+
## Allocation profile
|
|
345
|
+
|
|
346
|
+
Honest accounting of where memory is spent:
|
|
347
|
+
|
|
348
|
+
| Operation | Allocates | Per-frame allocation |
|
|
349
|
+
|---|---|---|
|
|
350
|
+
| `parseCSSColor` / `parse*` | regex + temporaries | (setup only) |
|
|
351
|
+
| `bakeGradientToUint32` | one `Uint32Array` LUT | (setup only) |
|
|
352
|
+
| `formatOklchCss` / `formatHex` | one string | (authoring only) |
|
|
353
|
+
| `lerpOklchBuffer` / `N` | -- | **0** |
|
|
354
|
+
| `packOklchBufferToUint32*` | -- | **0** |
|
|
355
|
+
| `packOklchBufferToUint32IntoN` | -- | **0** |
|
|
356
|
+
| `sampleColorLUT` | -- | **0** |
|
|
357
|
+
| `deltaEOK` | -- | **0** |
|
|
358
|
+
|
|
359
|
+
The 4k sRGB LUT behind `useLut` is a single module-load allocation, shared process-wide.
|
|
360
|
+
|
|
361
|
+
---
|
|
362
|
+
|
|
363
|
+
## Tree-shaking and subpaths
|
|
364
|
+
|
|
365
|
+
`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:
|
|
366
|
+
|
|
367
|
+
```js
|
|
368
|
+
import { lerpOklchBuffer } from "@zakkster/lite-color-engine"; // core
|
|
369
|
+
import { packOklchBufferToUint32MINDE } from "@zakkster/lite-color-engine/gamut";
|
|
370
|
+
import { remapPixelsToPalette } from "@zakkster/lite-color-engine/remap";
|
|
371
|
+
```
|
|
372
|
+
|
|
373
|
+
One runtime dependency: [`@zakkster/lite-lerp`](https://www.npmjs.com/package/@zakkster/lite-lerp) (zero-dep itself).
|
|
374
|
+
|
|
375
|
+
---
|
|
376
|
+
|
|
377
|
+
## Testing
|
|
378
|
+
|
|
379
|
+
117 tests across 7 files (`node --test` via Vitest):
|
|
380
|
+
|
|
381
|
+
- **Parsing / authoring** -- every format, named colors, alpha, malformed input.
|
|
382
|
+
- **Convert** -- sRGB and P3 round-trips against reference math.
|
|
383
|
+
- **Runtime** -- pack byte order, transfer accuracy, `Fast` error bounds.
|
|
384
|
+
- **Batch** -- `lerpOklchBufferN` and `packOklchBufferToUint32IntoN` proven **bit-for-bit** equal to scalar loops; LUT within 1 LSB.
|
|
385
|
+
- **Formatters** -- hex/OKLCH round-trip, alpha rules, buffer purity.
|
|
386
|
+
- **Gamut / Remap** -- MINDE hue preservation, palette nearest-color.
|
|
387
|
+
- **DeltaE** -- perceptual difference against known pairs.
|
|
21
388
|
|
|
22
389
|
```bash
|
|
23
|
-
npm
|
|
390
|
+
npm test # full suite
|
|
391
|
+
node bench/benchmark.mjs # reproduce the perf numbers
|
|
24
392
|
```
|
|
25
393
|
|
|
26
|
-
|
|
394
|
+
---
|
|
395
|
+
|
|
396
|
+
## Integration recipes
|
|
397
|
+
|
|
398
|
+
### Scrolling canvas gradient (zero-GC)
|
|
27
399
|
|
|
28
400
|
```js
|
|
29
|
-
import {
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
401
|
+
import { bakeGradientToUint32, sampleColorLUT, parseCSSColor } from "@zakkster/lite-color-engine";
|
|
402
|
+
|
|
403
|
+
const stops = ["oklch(0.7 0.18 30)", "oklch(0.8 0.2 120)", "oklch(0.65 0.2 265)"];
|
|
404
|
+
const kf = new Float32Array(stops.length * 3);
|
|
405
|
+
stops.forEach((s, i) => parseCSSColor(s, kf, i * 3));
|
|
406
|
+
const lut = bakeGradientToUint32(kf, stops.length, 512);
|
|
407
|
+
|
|
408
|
+
function frame(t) {
|
|
409
|
+
for (let x = 0; x < w; x++) {
|
|
410
|
+
px[x] = sampleColorLUT(lut, (x / w + t * 0.0002) % 1);
|
|
411
|
+
}
|
|
412
|
+
for (let y = 1; y < h; y++) px.copyWithin(y * w, 0, w); // replicate row
|
|
413
|
+
ctx.putImageData(img, 0, 0);
|
|
414
|
+
requestAnimationFrame(frame);
|
|
415
|
+
}
|
|
416
|
+
```
|
|
417
|
+
|
|
418
|
+
### 100k particles colored by life
|
|
35
419
|
|
|
36
|
-
|
|
37
|
-
|
|
420
|
+
```js
|
|
421
|
+
import { bakeGradientToUint32, sampleColorLUT } from "@zakkster/lite-color-engine";
|
|
38
422
|
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
423
|
+
const lut = bakeGradientToUint32(kf, kf.length / 3, 256);
|
|
424
|
+
function render() {
|
|
425
|
+
px.fill(0xFF000000); // px = Uint32Array view of ImageData
|
|
426
|
+
for (let i = 0; i < N; i++) {
|
|
427
|
+
const color = sampleColorLUT(lut, life[i] / maxLife[i]);
|
|
428
|
+
px[(partY[i] | 0) * w + (partX[i] | 0)] = color; // direct pixel write
|
|
429
|
+
}
|
|
430
|
+
ctx.putImageData(img, 0, 0);
|
|
431
|
+
}
|
|
42
432
|
```
|
|
43
433
|
|
|
44
|
-
|
|
434
|
+
(A full oscilloscope-themed demo with this scene ships in `demo/index.html` -- run `npx serve .` from the package root.)
|
|
435
|
+
|
|
436
|
+
---
|
|
437
|
+
|
|
438
|
+
## Browser and runtime support
|
|
439
|
+
|
|
440
|
+
<details>
|
|
441
|
+
<summary>Support matrix.</summary>
|
|
442
|
+
|
|
443
|
+
Pure ES2020 typed arrays + `Math`. Runs anywhere modern JS runs.
|
|
444
|
+
|
|
445
|
+
| Target | Supported |
|
|
446
|
+
|---|---|
|
|
447
|
+
| Chrome / Edge (last 2 majors) | yes |
|
|
448
|
+
| Firefox (last 2 majors) | yes |
|
|
449
|
+
| Safari 15+ | yes |
|
|
450
|
+
| Node.js 18+ | yes |
|
|
451
|
+
| Bun / Deno | yes |
|
|
452
|
+
| Twitch Extensions (1MB / 3s) | yes |
|
|
453
|
+
| Cloudflare Workers | yes |
|
|
454
|
+
|
|
455
|
+
Display-P3 canvas output additionally needs a browser with `{ colorSpace: "display-p3" }` support (Chrome/Safari). ESM-only; no CommonJS build.
|
|
456
|
+
|
|
457
|
+
</details>
|
|
458
|
+
|
|
459
|
+
---
|
|
460
|
+
|
|
461
|
+
## What this is not
|
|
462
|
+
|
|
463
|
+
- **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.
|
|
464
|
+
- **Not a CSS engine.** It parses the color grammar it needs, not `calc()`, relative color syntax, or `color-mix()`.
|
|
465
|
+
- **Not a full ICC color-management stack.** It handles sRGB and Display P3. No CMYK, no arbitrary ICC profiles.
|
|
466
|
+
- **Not a renderer.** It produces pixels; you own the canvas, the loop, and the physics.
|
|
467
|
+
|
|
468
|
+
---
|
|
469
|
+
|
|
470
|
+
## FAQ
|
|
471
|
+
|
|
472
|
+
**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.
|
|
45
473
|
|
|
46
|
-
|
|
47
|
-
|-------------------|----------------------------------|-----------------------------------|--------------------|
|
|
48
|
-
| Fast | `packOklchBufferToUint32Fast` | Particles, high volume | ~10/255 midtone error |
|
|
49
|
-
| Accurate-Clamp | `packOklchBufferToUint32` | Most UI / general use | Hard clamp (hue shift near edge) |
|
|
50
|
-
| Gamut-Mapped | `packOklchBufferToUint32MINDE` | Critical gradients & authoring | ~30x slower, best quality |
|
|
474
|
+
**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.
|
|
51
475
|
|
|
52
|
-
|
|
53
|
-
> `import { packOklchBufferToUint32MINDE } from '@zakkster/lite-color-engine/gamut';`
|
|
54
|
-
> The `Fast`, `Accurate-Clamp`, and both `P3` packers come from the main entry.
|
|
476
|
+
**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.
|
|
55
477
|
|
|
56
|
-
|
|
478
|
+
**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.
|
|
57
479
|
|
|
58
|
-
|
|
480
|
+
**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.
|
|
59
481
|
|
|
60
|
-
|
|
482
|
+
---
|
|
61
483
|
|
|
62
484
|
## License
|
|
63
485
|
|
|
64
|
-
MIT
|
|
486
|
+
MIT (c) Zahary Shinikchiev. See [LICENSE.md](./LICENSE.md).
|
package/index.d.ts
CHANGED
|
@@ -87,6 +87,25 @@ export function parseCSSColor(str: string, outBuf: Float32Array, offset: number)
|
|
|
87
87
|
*/
|
|
88
88
|
export function parseDisplayP3ToBuffer(str: string, outBuf: Float32Array, offset: number): number;
|
|
89
89
|
|
|
90
|
+
// ============================================================================
|
|
91
|
+
// Formatters (round-trip emit - authoring layer)
|
|
92
|
+
// ============================================================================
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* Emits a modern CSS `oklch(L% C H)` string (or with `/ alpha`) from a buffered
|
|
96
|
+
* OKLCH triplet. Precision is chosen for good visual and numeric round-tripping
|
|
97
|
+
* through `parseCSSColor`. Alpha is omitted when not supplied or >= 0.9995.
|
|
98
|
+
* Authoring-time helper (allocates a string); not for per-frame hot paths.
|
|
99
|
+
*/
|
|
100
|
+
export function formatOklchCss(buf: Float32Array, off: number, alpha?: number): string;
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* Converts an OKLCH triplet to a `#rrggbb` hex string via the accurate sRGB
|
|
104
|
+
* pack path. Round-trips with `parseHexToBuffer` to within 1 LSB per channel.
|
|
105
|
+
* Authoring-time helper (allocates a string); not for per-frame hot paths.
|
|
106
|
+
*/
|
|
107
|
+
export function formatHex(buf: Float32Array, off: number): string;
|
|
108
|
+
|
|
90
109
|
// ============================================================================
|
|
91
110
|
// Convert (Raw Math)
|
|
92
111
|
// ============================================================================
|
|
@@ -155,6 +174,22 @@ export function lerpOklchBuffer(
|
|
|
155
174
|
outOffset: number
|
|
156
175
|
): void;
|
|
157
176
|
|
|
177
|
+
/**
|
|
178
|
+
* Batch lerp for particle systems / high-N use cases. Amortizes JS call overhead.
|
|
179
|
+
*
|
|
180
|
+
* Lerps n triplets with shared `t`: a[offA + i*3...] <-> b[offB + i*3...] -> out[offOut + i*3...]
|
|
181
|
+
*/
|
|
182
|
+
export function lerpOklchBufferN(
|
|
183
|
+
a: Float32Array,
|
|
184
|
+
offA: number,
|
|
185
|
+
b: Float32Array,
|
|
186
|
+
offB: number,
|
|
187
|
+
t: number,
|
|
188
|
+
out: Float32Array,
|
|
189
|
+
offOut: number,
|
|
190
|
+
n: number
|
|
191
|
+
): void;
|
|
192
|
+
|
|
158
193
|
/**
|
|
159
194
|
* Encodes an OKLCH triplet to a 32-bit unsigned integer in **little-endian
|
|
160
195
|
* RGBA** byte order - the format consumed directly by `Canvas ImageData` via
|
|
@@ -196,6 +231,20 @@ export function packOklchBufferToUint32Fast(
|
|
|
196
231
|
alpha?: number
|
|
197
232
|
): number;
|
|
198
233
|
|
|
234
|
+
/**
|
|
235
|
+
* Batch packer: n OKLCH triplets -> n Uint32 packed colors (stride 1 in dst).
|
|
236
|
+
* Opt-in 4k sRGB LUT (`useLut=true`) for fast-packer throughput at near-exact accuracy.
|
|
237
|
+
*/
|
|
238
|
+
export function packOklchBufferToUint32IntoN(
|
|
239
|
+
src: Float32Array,
|
|
240
|
+
offSrc: number,
|
|
241
|
+
dst: Uint32Array,
|
|
242
|
+
offDst: number,
|
|
243
|
+
n: number,
|
|
244
|
+
alpha?: number,
|
|
245
|
+
useLut?: boolean
|
|
246
|
+
): void;
|
|
247
|
+
|
|
199
248
|
/**
|
|
200
249
|
* Encodes an OKLCH triplet to a 32-bit unsigned integer in little-endian RGBA
|
|
201
250
|
* byte order for a **Display P3** canvas context
|
package/index.js
CHANGED
|
@@ -5,15 +5,19 @@ export {
|
|
|
5
5
|
parseOklabToBuffer,
|
|
6
6
|
parseOklchToBuffer,
|
|
7
7
|
parseRgbToBuffer,
|
|
8
|
-
parseDisplayP3ToBuffer
|
|
8
|
+
parseDisplayP3ToBuffer,
|
|
9
|
+
formatOklchCss,
|
|
10
|
+
formatHex
|
|
9
11
|
} from './src/authoring.js';
|
|
10
12
|
|
|
11
13
|
export { bakeGradientToUint32 } from './src/lut.js';
|
|
12
14
|
|
|
13
15
|
export {
|
|
14
16
|
lerpOklchBuffer,
|
|
17
|
+
lerpOklchBufferN,
|
|
15
18
|
packOklchBufferToUint32,
|
|
16
19
|
packOklchBufferToUint32Fast,
|
|
20
|
+
packOklchBufferToUint32IntoN,
|
|
17
21
|
packOklchBufferToUint32P3,
|
|
18
22
|
packOklchBufferToUint32P3Fast,
|
|
19
23
|
sampleColorLUT
|
package/llms.txt
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
# lite-color-engine
|
|
1
|
+
# lite-color-engine - LLM Reference (v1.4)
|
|
2
2
|
|
|
3
3
|
## Core Exports
|
|
4
4
|
|
|
@@ -6,17 +6,27 @@
|
|
|
6
6
|
- `parseCSSColor(str, outBuf, offset)` — Universal parser. Now supports `color(display-p3 r g b / alpha)`
|
|
7
7
|
- `parseDisplayP3ToBuffer(str, outBuf, offset)` — Direct P3 parser
|
|
8
8
|
|
|
9
|
+
### Formatters (v1.4, authoring-time - allocate a string)
|
|
10
|
+
- `formatOklchCss(buf, off, alpha?)` - OKLCH triplet -> `oklch(L% C H)` (or `/ alpha`); round-trips via parseCSSColor.
|
|
11
|
+
- `formatHex(buf, off)` - OKLCH triplet -> `#rrggbb` via the accurate pack path; round-trips with parseHexToBuffer within 1 LSB.
|
|
12
|
+
|
|
9
13
|
### Conversion
|
|
10
14
|
- `sRgbToOklchBuffer(r, g, b, out, off)`
|
|
11
15
|
- `displayP3ToOklchBuffer(r, g, b, out, off)` — New in v1.2
|
|
12
16
|
- `oklchToLinearP3(L, C, H, out)` — Inverse for P3 packing
|
|
13
17
|
|
|
14
|
-
### Runtime (Hot Path
|
|
18
|
+
### Runtime (Hot Path - Zero GC)
|
|
15
19
|
- `lerpOklchBuffer(...)`
|
|
16
|
-
- `packOklchBufferToUint32(buf, off, alpha?)`
|
|
17
|
-
- `packOklchBufferToUint32Fast(...)`
|
|
18
|
-
- `packOklchBufferToUint32P3(buf, off, alpha?)`
|
|
19
|
-
- `packOklchBufferToUint32P3Fast(...)`
|
|
20
|
+
- `packOklchBufferToUint32(buf, off, alpha?)` - Accurate sRGB
|
|
21
|
+
- `packOklchBufferToUint32Fast(...)` - Fast approx
|
|
22
|
+
- `packOklchBufferToUint32P3(buf, off, alpha?)` - Accurate P3 output
|
|
23
|
+
- `packOklchBufferToUint32P3Fast(...)` - Fast P3 output
|
|
24
|
+
- `sampleColorLUT(lut, t)` - O(1) lookup into a baked gradient LUT
|
|
25
|
+
|
|
26
|
+
### Batch kernels (v1.3, for 100k+ particle systems)
|
|
27
|
+
- `lerpOklchBufferN(a, offA, b, offB, t, out, offOut, n)` - bulk lerp n triplets (stride 3), bit-exact to n scalar calls.
|
|
28
|
+
- `packOklchBufferToUint32IntoN(src, offSrc, dst, offDst, n, alpha?, useLut?)` - bulk pack n OKLCH -> Uint32 (dst stride 1). `useLut=true` uses the 4k transfer LUT (~4.4x throughput vs accurate, within 1 LSB; Fast-packer speed at near-exact accuracy).
|
|
29
|
+
- Note: the batch win comes from `useLut`, not from call-amortization (accurate batch ~= scalar loop in V8; pow-bound).
|
|
20
30
|
|
|
21
31
|
### Gamut mapping (subpath: `@zakkster/lite-color-engine/gamut`, NOT hot-path)
|
|
22
32
|
- `packOklchBufferToUint32MINDE(buf, off, alpha?)` — MINDE chroma-reduction gamut map. ~30x slower than the core packer; use at LUT-build/authoring time.
|
package/package.json
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@zakkster/lite-color-engine",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.4.0",
|
|
4
4
|
"author": "Zahary Shinikchiev <shinikchiev@yahoo.com>",
|
|
5
|
-
"description": "Zero-GC, data-oriented OKLCH color engine
|
|
5
|
+
"description": "Zero-GC, data-oriented OKLCH color engine and WebGL/Canvas buffer pipeline.",
|
|
6
6
|
"type": "module",
|
|
7
7
|
"sideEffects": false,
|
|
8
8
|
"main": "./index.js",
|
|
@@ -59,7 +59,8 @@
|
|
|
59
59
|
"game-dev"
|
|
60
60
|
],
|
|
61
61
|
"scripts": {
|
|
62
|
-
"test": "vitest run"
|
|
62
|
+
"test": "vitest run",
|
|
63
|
+
"bench": "node bench/benchmark.mjs"
|
|
63
64
|
},
|
|
64
65
|
"dependencies": {
|
|
65
66
|
"@zakkster/lite-lerp": "^1.0.0"
|
package/src/authoring.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { sRgbToOklchBuffer, displayP3ToOklchBuffer } from './convert.js';
|
|
2
|
+
import { packOklchBufferToUint32 } from './runtime.js';
|
|
2
3
|
|
|
3
4
|
const RAD_TO_DEG = 180 / Math.PI;
|
|
4
5
|
|
|
@@ -80,7 +81,7 @@ const NAMED_COLORS = {
|
|
|
80
81
|
darkgray: '#a9a9a9', darkgreen: '#006400', darkgrey: '#a9a9a9', darkkhaki: '#bdb76b',
|
|
81
82
|
darkmagenta: '#8b008b', darkolivegreen: '#556b2f', darkorange: '#ff8c00', darkorchid: '#9932cc',
|
|
82
83
|
darkred: '#8b0000', darksalmon: '#e9967a', darkseagreen: '#8fbc8f', darkslateblue: '#483d8b',
|
|
83
|
-
darkslategray: '#2f4f4f',
|
|
84
|
+
darkslategray: '#2f4f4f', darkslategrey: '#2f4f4f', darkturquoise: '#00ced1', darkviolet: '#9400d3',
|
|
84
85
|
deeppink: '#ff1493', deepskyblue: '#00bfff', dimgray: '#696969', dimgrey: '#696969',
|
|
85
86
|
dodgerblue: '#1e90ff', firebrick: '#b22222', floralwhite: '#fffaf0', forestgreen: '#228b22',
|
|
86
87
|
fuchsia: '#ff00ff', gainsboro: '#dcdcdc', ghostwhite: '#f8f8ff', gold: '#ffd700',
|
|
@@ -91,12 +92,12 @@ const NAMED_COLORS = {
|
|
|
91
92
|
lightcoral: '#f08080', lightcyan: '#e0ffff', lightgoldenrodyellow: '#fafad2', lightgray: '#d3d3d3',
|
|
92
93
|
lightgreen: '#90ee90', lightgrey: '#d3d3d3', lightpink: '#ffb6c1', lightsalmon: '#ffa07a',
|
|
93
94
|
lightseagreen: '#20b2aa', lightskyblue: '#87cefa', lightslategray: '#778899', lightslategrey: '#778899',
|
|
94
|
-
lightsteelblue: '#
|
|
95
|
+
lightsteelblue: '#b0c4de', lightyellow: '#ffffe0', lime: '#00ff00', limegreen: '#32cd32',
|
|
95
96
|
linen: '#faf0e6', magenta: '#ff00ff', maroon: '#800000', mediumaquamarine: '#66cdaa',
|
|
96
97
|
mediumblue: '#0000cd', mediumorchid: '#ba55d3', mediumpurple: '#9370db', mediumseagreen: '#3cb371',
|
|
97
98
|
mediumslateblue: '#7b68ee', mediumspringgreen: '#00fa9a', mediumturquoise: '#48d1cc', mediumvioletred: '#c71585',
|
|
98
99
|
midnightblue: '#191970', mintcream: '#f5fffa', mistyrose: '#ffe4e1', moccasin: '#ffe4b5',
|
|
99
|
-
|
|
100
|
+
navajowhite: '#ffdead', navy: '#000080', oldlace: '#fdf5e6', olive: '#808000',
|
|
100
101
|
olivedrab: '#6b8e23', orange: '#ffa500', orangered: '#ff4500', orchid: '#da70d6',
|
|
101
102
|
palegoldenrod: '#eee8aa', palegreen: '#98fb98', paleturquoise: '#afeeee', palevioletred: '#db7093',
|
|
102
103
|
papayawhip: '#ffefd5', peachpuff: '#ffdab9', peru: '#cd853f', pink: '#ffc0cb',
|
|
@@ -301,3 +302,59 @@ export const parseCSSColor = (str, outBuf, offset) => {
|
|
|
301
302
|
|
|
302
303
|
throw new Error(`lite-color-engine: Unsupported color format "${str}".`);
|
|
303
304
|
};
|
|
305
|
+
|
|
306
|
+
// ============================================================================
|
|
307
|
+
// 6. FORMATTERS (round-trip emit - authoring layer, string allocation is fine)
|
|
308
|
+
// ============================================================================
|
|
309
|
+
|
|
310
|
+
/**
|
|
311
|
+
* Emits a modern CSS `oklch(...)` string from an OKLCH buffer triplet.
|
|
312
|
+
* Suitable for exports, CSS custom properties, gradient studios, telemetry, etc.
|
|
313
|
+
*
|
|
314
|
+
* Lightness is emitted as a percentage (e.g. `60.0%`), chroma and hue with
|
|
315
|
+
* enough precision to round-trip through `parseOklchToBuffer`/`parseCSSColor`.
|
|
316
|
+
* Alpha is included only when supplied and below 1.
|
|
317
|
+
*
|
|
318
|
+
* This is an authoring-time helper: it allocates a string and is not intended
|
|
319
|
+
* for per-frame hot paths.
|
|
320
|
+
*
|
|
321
|
+
* @param {Float32Array} buf - Buffer containing [L, C, H]
|
|
322
|
+
* @param {number} off - Offset of the triplet
|
|
323
|
+
* @param {number} [alpha] - Optional alpha [0, 1]. Omitted from output when >= 0.9995 or not supplied.
|
|
324
|
+
* @returns {string} e.g. "oklch(60.0% 0.150 250.0)" or with " / 0.800"
|
|
325
|
+
*/
|
|
326
|
+
export const formatOklchCss = (buf, off, alpha) => {
|
|
327
|
+
const l = buf[off] * 100;
|
|
328
|
+
const c = buf[off + 1];
|
|
329
|
+
const h = buf[off + 2];
|
|
330
|
+
let s = `oklch(${l.toFixed(1)}% ${c.toFixed(3)} ${h.toFixed(1)})`;
|
|
331
|
+
if (alpha != null && alpha < 0.9995) {
|
|
332
|
+
s += ` / ${alpha.toFixed(3)}`;
|
|
333
|
+
}
|
|
334
|
+
return s;
|
|
335
|
+
};
|
|
336
|
+
|
|
337
|
+
/**
|
|
338
|
+
* Converts an OKLCH buffer triplet to a 6-digit `#rrggbb` hex string (no alpha).
|
|
339
|
+
*
|
|
340
|
+
* Uses the accurate sRGB transfer + pack path, so it round-trips with
|
|
341
|
+
* `parseHexToBuffer` + `packOklchBufferToUint32` to within 1 LSB per channel
|
|
342
|
+
* (the accurate packer's rounding tolerance). Out-of-sRGB-gamut input is hard
|
|
343
|
+
* clamped, matching the packer.
|
|
344
|
+
*
|
|
345
|
+
* Authoring-time helper: allocates a string, not for per-frame hot paths.
|
|
346
|
+
*
|
|
347
|
+
* @param {Float32Array} buf - Buffer containing [L, C, H]
|
|
348
|
+
* @param {number} off - Offset of the triplet
|
|
349
|
+
* @returns {string} e.g. "#ff0000"
|
|
350
|
+
*/
|
|
351
|
+
export const formatHex = (buf, off) => {
|
|
352
|
+
const u = packOklchBufferToUint32(buf, off);
|
|
353
|
+
const r = u & 0xff;
|
|
354
|
+
const g = (u >>> 8) & 0xff;
|
|
355
|
+
const b = (u >>> 16) & 0xff;
|
|
356
|
+
return '#' +
|
|
357
|
+
r.toString(16).padStart(2, '0') +
|
|
358
|
+
g.toString(16).padStart(2, '0') +
|
|
359
|
+
b.toString(16).padStart(2, '0');
|
|
360
|
+
};
|
package/src/runtime.js
CHANGED
|
@@ -36,9 +36,50 @@ export const lerpOklchBuffer = (bufA, offsetA, bufB, offsetB, t, outBuf, outOffs
|
|
|
36
36
|
outBuf[outOffset + 2] = h < 0 ? h + 360 : h;
|
|
37
37
|
};
|
|
38
38
|
|
|
39
|
+
/**
|
|
40
|
+
* Batch sibling of {@link lerpOklchBuffer}. Interpolates `n` consecutive OKLCH
|
|
41
|
+
* triplets (stride 3) with a single shared `t`, amortizing per-call overhead
|
|
42
|
+
* across large particle systems (100k+ entities).
|
|
43
|
+
*
|
|
44
|
+
* For each i in [0, n): lerps a[offA + i*3 ..] with b[offB + i*3 ..] into
|
|
45
|
+
* out[offOut + i*3 ..]. Per-triplet math is identical to the scalar version,
|
|
46
|
+
* so results are bit-for-bit equal to calling it n times.
|
|
47
|
+
*
|
|
48
|
+
* In-place is safe (a === out, offA === offOut): each iteration reads its three
|
|
49
|
+
* source lanes before writing them. Zero allocations; monomorphic hot loop.
|
|
50
|
+
*
|
|
51
|
+
* @param {Float32Array} a - Source buffer A
|
|
52
|
+
* @param {number} offA - Base offset of the first color in A
|
|
53
|
+
* @param {Float32Array} b - Source buffer B
|
|
54
|
+
* @param {number} offB - Base offset of the first color in B
|
|
55
|
+
* @param {number} t - Interpolation factor [0, 1] (extrapolates then clamps)
|
|
56
|
+
* @param {Float32Array} out - Destination buffer
|
|
57
|
+
* @param {number} offOut - Base offset of the first output color
|
|
58
|
+
* @param {number} n - Number of triplets to process (n <= 0 is a no-op)
|
|
59
|
+
* @returns {void}
|
|
60
|
+
*/
|
|
61
|
+
export const lerpOklchBufferN = (a, offA, b, offB, t, out, offOut, n) => {
|
|
62
|
+
if (n <= 0) return;
|
|
63
|
+
for (let i = 0; i < n; i++) {
|
|
64
|
+
const ia = offA + i * 3;
|
|
65
|
+
const ib = offB + i * 3;
|
|
66
|
+
const io = offOut + i * 3;
|
|
67
|
+
|
|
68
|
+
const l = lerp(a[ia], b[ib], t);
|
|
69
|
+
out[io] = l < 0 ? 0 : (l > 1 ? 1 : l);
|
|
70
|
+
|
|
71
|
+
const c = lerp(a[ia + 1], b[ib + 1], t);
|
|
72
|
+
out[io + 1] = c < 0 ? 0 : c;
|
|
73
|
+
|
|
74
|
+
let h = lerpAngle(a[ia + 2], b[ib + 2], t);
|
|
75
|
+
h = h % 360;
|
|
76
|
+
out[io + 2] = h < 0 ? h + 360 : h;
|
|
77
|
+
}
|
|
78
|
+
};
|
|
79
|
+
|
|
39
80
|
/**
|
|
40
81
|
* Internal: shared OKLCH -> linear sRGB kernel.
|
|
41
|
-
* Inlined into
|
|
82
|
+
* Inlined into every sRGB pack variant so V8 monomorphizes it.
|
|
42
83
|
*
|
|
43
84
|
* Returns a 3-tuple via `outRgb` (length 3). Strictly clamped to [0, 1].
|
|
44
85
|
* @internal
|
|
@@ -60,7 +101,7 @@ const oklchToLinearSrgbClamped = (l, c, h, outRgb) => {
|
|
|
60
101
|
const g = -1.2684380046 * lms_l + 2.6097574011 * lms_m - 0.3413193965 * lms_s;
|
|
61
102
|
const b = -0.0041960863 * lms_l - 0.7034186147 * lms_m + 1.7076147010 * lms_s;
|
|
62
103
|
|
|
63
|
-
// Hard gamut clamp (fast path;
|
|
104
|
+
// Hard gamut clamp (fast path; MINDE chroma reduction lives on the /gamut subpath).
|
|
64
105
|
outRgb[0] = r < 0 ? 0 : (r > 1 ? 1 : r);
|
|
65
106
|
outRgb[1] = g < 0 ? 0 : (g > 1 ? 1 : g);
|
|
66
107
|
outRgb[2] = b < 0 ? 0 : (b > 1 ? 1 : b);
|
|
@@ -70,6 +111,34 @@ const oklchToLinearSrgbClamped = (l, c, h, outRgb) => {
|
|
|
70
111
|
const _scratchRgb = new Float32Array(3);
|
|
71
112
|
const _scratchRgbP3 = new Float32Array(3);
|
|
72
113
|
|
|
114
|
+
/**
|
|
115
|
+
* Precomputed 4096-entry LUT for the sRGB transfer (linear -> encoded byte).
|
|
116
|
+
* Entry i corresponds to linear value i/4095, so the table spans [0, 1] exactly.
|
|
117
|
+
* The linear branch (slope 12.92) is the steepest region; at 4096 samples its
|
|
118
|
+
* per-step delta is < 1 byte, so nearest-index lookups stay within ~1 LSB of
|
|
119
|
+
* the exact pow() encoding. One-time module-load cost, then O(1) lookup.
|
|
120
|
+
* @internal
|
|
121
|
+
*/
|
|
122
|
+
const SRGB_LUT = new Uint8ClampedArray(4096);
|
|
123
|
+
{
|
|
124
|
+
for (let i = 0; i < 4096; i++) {
|
|
125
|
+
const c = i / 4095;
|
|
126
|
+
const enc = c <= 0.0031308 ? 12.92 * c : 1.055 * Math.pow(c, 1 / 2.4) - 0.055;
|
|
127
|
+
SRGB_LUT[i] = (enc * 255 + 0.5) | 0;
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* LUT-based linear-to-sRGB byte using nearest-index lookup. Near-exact (~1 LSB)
|
|
133
|
+
* versus {@link linearToSrgbByte} but branch-free and pow-free on the hot path.
|
|
134
|
+
* @internal
|
|
135
|
+
*/
|
|
136
|
+
const linearToSrgbByteLut = (c) => {
|
|
137
|
+
if (c <= 0) return 0;
|
|
138
|
+
if (c >= 1) return 255;
|
|
139
|
+
return SRGB_LUT[(c * 4095 + 0.5) | 0];
|
|
140
|
+
};
|
|
141
|
+
|
|
73
142
|
/**
|
|
74
143
|
* Encodes a linear-light channel (already clamped to [0, 1]) to an sRGB
|
|
75
144
|
* 8-bit byte using the proper IEC 61966-2-1 transfer function. Round-tripping
|
|
@@ -136,8 +205,54 @@ export const packOklchBufferToUint32Fast = (buf, offset, alpha = 1.0) => {
|
|
|
136
205
|
};
|
|
137
206
|
|
|
138
207
|
/**
|
|
139
|
-
*
|
|
140
|
-
*
|
|
208
|
+
* Batch sibling of {@link packOklchBufferToUint32}. Packs `n` OKLCH triplets
|
|
209
|
+
* (stride 3, from `src`) into `n` consecutive Uint32 pixels (stride 1, into
|
|
210
|
+
* `dst`) - the shape a SoA particle system feeds straight into `ImageData`.
|
|
211
|
+
*
|
|
212
|
+
* With `useLut=false` the output is bit-for-bit identical to calling
|
|
213
|
+
* {@link packOklchBufferToUint32} n times. With `useLut=true` it uses the 4k
|
|
214
|
+
* transfer LUT: near-exact (~1 LSB) at close to fast-packer throughput, with a
|
|
215
|
+
* single one-time table allocation and no per-color pow(). The branch is hoisted
|
|
216
|
+
* out of the loop so each variant stays monomorphic.
|
|
217
|
+
*
|
|
218
|
+
* @param {Float32Array} src - Source OKLCH buffer (stride 3)
|
|
219
|
+
* @param {number} offSrc - Base offset of the first triplet in src
|
|
220
|
+
* @param {Uint32Array} dst - Destination packed-color buffer (stride 1)
|
|
221
|
+
* @param {number} offDst - Base offset of the first packed color in dst
|
|
222
|
+
* @param {number} n - Number of colors to pack (n <= 0 is a no-op)
|
|
223
|
+
* @param {number} [alpha=1.0] - Shared alpha for all n colors
|
|
224
|
+
* @param {boolean} [useLut=false] - Opt in to the 4k LUT transfer (near-exact, faster)
|
|
225
|
+
* @returns {void}
|
|
226
|
+
*/
|
|
227
|
+
export const packOklchBufferToUint32IntoN = (src, offSrc, dst, offDst, n, alpha = 1.0, useLut = false) => {
|
|
228
|
+
if (n <= 0) return;
|
|
229
|
+
const a8 = alpha <= 0 ? 0 : (alpha >= 1 ? 255 : (alpha * 255 + 0.5) | 0);
|
|
230
|
+
const aHi = a8 << 24;
|
|
231
|
+
|
|
232
|
+
if (useLut) {
|
|
233
|
+
for (let i = 0; i < n; i++) {
|
|
234
|
+
const io = offSrc + i * 3;
|
|
235
|
+
oklchToLinearSrgbClamped(src[io], src[io + 1], src[io + 2], _scratchRgb);
|
|
236
|
+
const r8 = linearToSrgbByteLut(_scratchRgb[0]);
|
|
237
|
+
const g8 = linearToSrgbByteLut(_scratchRgb[1]);
|
|
238
|
+
const b8 = linearToSrgbByteLut(_scratchRgb[2]);
|
|
239
|
+
dst[offDst + i] = (aHi | (b8 << 16) | (g8 << 8) | r8) >>> 0;
|
|
240
|
+
}
|
|
241
|
+
} else {
|
|
242
|
+
for (let i = 0; i < n; i++) {
|
|
243
|
+
const io = offSrc + i * 3;
|
|
244
|
+
oklchToLinearSrgbClamped(src[io], src[io + 1], src[io + 2], _scratchRgb);
|
|
245
|
+
const r8 = linearToSrgbByte(_scratchRgb[0]);
|
|
246
|
+
const g8 = linearToSrgbByte(_scratchRgb[1]);
|
|
247
|
+
const b8 = linearToSrgbByte(_scratchRgb[2]);
|
|
248
|
+
dst[offDst + i] = (aHi | (b8 << 16) | (g8 << 8) | r8) >>> 0;
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
};
|
|
252
|
+
|
|
253
|
+
/**
|
|
254
|
+
* Internal helper: OKLCH -> linear Display P3 with hard clamp to [0, 1].
|
|
255
|
+
* Mirrors oklchToLinearSrgbClamped but targets the wider P3 gamut.
|
|
141
256
|
* @internal
|
|
142
257
|
*/
|
|
143
258
|
const oklchToLinearP3Clamped = (l, c, h, outRgb) => {
|