@zakkster/lite-color-engine 1.2.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 CHANGED
@@ -1,21 +1,52 @@
1
1
  # Changelog
2
2
 
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.
31
+
32
+ ---
33
+
3
34
  ## [1.2.0] - 2026-07-10
4
35
 
5
36
  ### Added
6
37
  - **Wide Gamut Support (Display P3)**
7
38
  - `parseCSSColor('color(display-p3 r g b / alpha)')` now supported in the universal parser
8
- - New `displayP3ToOklchBuffer()` conversion function with accurate P3 XYZ OKLab pipeline
9
- - `packOklchBufferToUint32P3()` accurate packer for `display-p3` canvas contexts
10
- - `packOklchBufferToUint32P3Fast()` high-speed sqrt approximation for P3 output
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
11
42
  - `oklchToLinearP3()` inverse conversion helper
12
43
 
13
44
  - **Accuracy Tiers** (now clearly documented)
14
45
  - Fast (`packOklchBufferToUint32Fast`)
15
46
  - Accurate-Clamp (default `packOklchBufferToUint32`)
16
- - Gamut-Mapped (`packOklchBufferToUint32MINDE` / P3 equivalent)
47
+ - Gamut-Mapped (`packOklchBufferToUint32MINDE`, on the `/gamut` subpath)
17
48
 
18
- - `bakeGradientToUint32()` already supported custom packers now officially documented for P3 usage.
49
+ - `bakeGradientToUint32()` already supported custom packers - now officially documented for P3 usage.
19
50
 
20
51
  ### Changed
21
52
  - Improved documentation around color gamut handling and when to use each packer.
@@ -28,5 +59,6 @@
28
59
  ---
29
60
 
30
61
  ## [1.1.0] - Previous
31
- - Added `deltaEOK` and MINDE gamut mapping (`/gamut` sub-export)
32
- - Added palette remapping utilities (`/remap`)
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,7 +1,41 @@
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)
5
+ [![sponsor](https://img.shields.io/badge/sponsor-PeshoVurtoleta-ea4aaa.svg?logo=github)](https://github.com/sponsors/PeshoVurtoleta)
6
+ [![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
+ [![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
+ [![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)
9
+ ![Tree-Shakeable](https://img.shields.io/badge/tree--shakeable-yes-brightgreen)
10
+ ![TypeScript](https://img.shields.io/badge/TypeScript-Types-informational)
11
+ ![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)
13
+
3
14
  **Zero-GC, data-oriented OKLCH color engine** for high-performance WebGL / Canvas pipelines.
4
15
 
16
+ ## v1.3 Highlights — Batch Kernels + 4k Transfer LUT
17
+
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.
20
+
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.
24
+
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.
29
+
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 |
35
+
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.)*
38
+
5
39
  ## v1.2 Highlights — Wide Gamut + Accuracy Tiers
6
40
 
7
41
  - Full `color(display-p3 r g b / alpha)` parsing support
@@ -41,6 +75,26 @@ lerpOklchBuffer(bufA, 0, bufB, 0, t, temp, 0);
41
75
  const pixel = packOklchBufferToUint32P3(temp, 0); // or packOklchBufferToUint32
42
76
  ```
43
77
 
78
+ ### Batch (100k particles)
79
+
80
+ ```js
81
+ import {
82
+ lerpOklchBufferN,
83
+ packOklchBufferToUint32IntoN
84
+ } from '@zakkster/lite-color-engine';
85
+
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
91
+
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.
96
+ ```
97
+
44
98
  ## Accuracy Tiers
45
99
 
46
100
  | Tier | Function | Use Case | Trade-off |
package/index.d.ts CHANGED
@@ -155,6 +155,22 @@ export function lerpOklchBuffer(
155
155
  outOffset: number
156
156
  ): void;
157
157
 
158
+ /**
159
+ * Batch lerp for particle systems / high-N use cases. Amortizes JS call overhead.
160
+ *
161
+ * Lerps n triplets with shared `t`: a[offA + i*3...] <-> b[offB + i*3...] -> out[offOut + i*3...]
162
+ */
163
+ export function lerpOklchBufferN(
164
+ a: Float32Array,
165
+ offA: number,
166
+ b: Float32Array,
167
+ offB: number,
168
+ t: number,
169
+ out: Float32Array,
170
+ offOut: number,
171
+ n: number
172
+ ): void;
173
+
158
174
  /**
159
175
  * Encodes an OKLCH triplet to a 32-bit unsigned integer in **little-endian
160
176
  * RGBA** byte order - the format consumed directly by `Canvas ImageData` via
@@ -196,6 +212,20 @@ export function packOklchBufferToUint32Fast(
196
212
  alpha?: number
197
213
  ): number;
198
214
 
215
+ /**
216
+ * Batch packer: n OKLCH triplets -> n Uint32 packed colors (stride 1 in dst).
217
+ * Opt-in 4k sRGB LUT (`useLut=true`) for fast-packer throughput at near-exact accuracy.
218
+ */
219
+ export function packOklchBufferToUint32IntoN(
220
+ src: Float32Array,
221
+ offSrc: number,
222
+ dst: Uint32Array,
223
+ offDst: number,
224
+ n: number,
225
+ alpha?: number,
226
+ useLut?: boolean
227
+ ): void;
228
+
199
229
  /**
200
230
  * Encodes an OKLCH triplet to a 32-bit unsigned integer in little-endian RGBA
201
231
  * byte order for a **Display P3** canvas context
package/index.js CHANGED
@@ -12,8 +12,10 @@ export { bakeGradientToUint32 } from './src/lut.js';
12
12
 
13
13
  export {
14
14
  lerpOklchBuffer,
15
+ lerpOklchBufferN,
15
16
  packOklchBufferToUint32,
16
17
  packOklchBufferToUint32Fast,
18
+ packOklchBufferToUint32IntoN,
17
19
  packOklchBufferToUint32P3,
18
20
  packOklchBufferToUint32P3Fast,
19
21
  sampleColorLUT
package/llms.txt CHANGED
@@ -1,4 +1,4 @@
1
- # lite-color-engine LLM Reference (v1.2)
1
+ # lite-color-engine - LLM Reference (v1.3)
2
2
 
3
3
  ## Core Exports
4
4
 
@@ -11,12 +11,18 @@
11
11
  - `displayP3ToOklchBuffer(r, g, b, out, off)` — New in v1.2
12
12
  - `oklchToLinearP3(L, C, H, out)` — Inverse for P3 packing
13
13
 
14
- ### Runtime (Hot Path Zero GC)
14
+ ### Runtime (Hot Path - Zero GC)
15
15
  - `lerpOklchBuffer(...)`
16
- - `packOklchBufferToUint32(buf, off, alpha?)` Accurate sRGB
17
- - `packOklchBufferToUint32Fast(...)` Fast approx
18
- - `packOklchBufferToUint32P3(buf, off, alpha?)` Accurate P3 output
19
- - `packOklchBufferToUint32P3Fast(...)` Fast P3 output
16
+ - `packOklchBufferToUint32(buf, off, alpha?)` - Accurate sRGB
17
+ - `packOklchBufferToUint32Fast(...)` - Fast approx
18
+ - `packOklchBufferToUint32P3(buf, off, alpha?)` - Accurate P3 output
19
+ - `packOklchBufferToUint32P3Fast(...)` - Fast P3 output
20
+ - `sampleColorLUT(lut, t)` - O(1) lookup into a baked gradient LUT
21
+
22
+ ### Batch kernels (v1.3, for 100k+ particle systems)
23
+ - `lerpOklchBufferN(a, offA, b, offB, t, out, offOut, n)` - bulk lerp n triplets (stride 3), bit-exact to n scalar calls.
24
+ - `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).
25
+ - Note: the batch win comes from `useLut`, not from call-amortization (accurate batch ~= scalar loop in V8; pow-bound).
20
26
 
21
27
  ### Gamut mapping (subpath: `@zakkster/lite-color-engine/gamut`, NOT hot-path)
22
28
  - `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.2.0",
3
+ "version": "1.3.0",
4
4
  "author": "Zahary Shinikchiev <shinikchiev@yahoo.com>",
5
- "description": "Zero-GC, data-oriented OKLCH color engine with MINDE gamut mapping and palette-remap kernels for WebGL/Canvas pipelines.",
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/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 both pack variants for V8 to monomorphize.
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; replaces the expensive MINDE algorithm).
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
- * Internal helper: OKLCH -> linear Display P3 with hard clamp to [0,1].
140
- * Mirrors the structure of oklchToLinearSrgbClamped but targets the wider P3 gamut.
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) => {