@zakkster/lite-color-engine 1.4.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/CHANGELOG.md CHANGED
@@ -1,5 +1,185 @@
1
1
  # Changelog
2
2
 
3
+ ## [1.5.0] - 2026-07-13
4
+
5
+ ### Added
6
+
7
+ - **Blue-noise dither kernels** — shared kernel for the whole ecosystem
8
+ (studio, hueforge, gradient consumers) so there is exactly one
9
+ implementation to reason about:
10
+ - `getBlueNoise64()` — returns a shared, read-only `Uint8Array(4096)`
11
+ holding a 64x64 void-and-cluster blue-noise tile. Lazily decoded from
12
+ an inlined base64 blob on first call (sub-millisecond); subsequent
13
+ calls return the same reference. Histogram is exactly uniform (each
14
+ byte 0..255 appears 16 times). Index via
15
+ `tile[((y & 63) << 6) | (x & 63)]`; the tile is torus-tileable.
16
+ Generator committed as `tools/generate-bluenoise.mjs` with a fixed
17
+ seed. The tile is gated on its *spectral* properties, not just its
18
+ fingerprint — see `test/bluenoise-spectral.test.js`.
19
+ - `packOklchBufferToUint32Dithered(buf, off, alpha = 1.0, noise01 = 0.5)`
20
+ — scalar dithered packer. Threshold offset applied in gamma-encoded space, with
21
+ the same `noise01` shared across R, G, B (luminance-patterned dither,
22
+ no chroma speckle). Alpha is undithered. Parity guarantee:
23
+ `noise01 === 0.5` reproduces the byte output of
24
+ `packOklchBufferToUint32` bit-for-bit.
25
+ - `packOklchBufferToUint32IntoNDithered(src, offSrc, dst, offDst, n,
26
+ alpha, noiseTile, x0, y0, rowWidth)` — batch variant that walks the
27
+ tile in row-major order over an arbitrary destination rectangle. Zero
28
+ allocations; no modulo/division in the hot loop.
29
+
30
+ **Honest note on `useLut`:** the batch dithered packer does *not* accept
31
+ the 4k transfer LUT flag. `SRGB_LUT` stores rounded bytes, which
32
+ discards the sub-integer information the dither threshold shift needs;
33
+ enabling it would silently collapse dither to plain rounding. A
34
+ companion `Float32Array` LUT of encoded floats (~16 KB, lazy) is a
35
+ v1.6 candidate that would restore this axis.
36
+
37
+ - **Batch P3 packer:** `packOklchBufferToUint32P3IntoN(src, offSrc, dst,
38
+ offDst, n, alpha?, useLut?)` — P3 sibling of the v1.3 sRGB batch. The
39
+ P3 transfer function is identical to sRGB (IEC 61966-2-1), so the same
40
+ `SRGB_LUT` is reused when `useLut=true` — no separate 4 KB table is
41
+ allocated. Same accuracy story as the sRGB LUT path (within 1 LSB of
42
+ exact) and same fast-packer throughput class.
43
+
44
+ - **Batch MINDE:** `gamutMapToSrgbBufferN(inBuf, off, outBuf, offOut, n)`
45
+ (on the `./gamut` subpath) — batch sibling of the scalar MINDE mapper.
46
+ Bit-for-bit identical to `n` scalar calls; the batch amortizes call
47
+ overhead for authoring / LUT-building large color runs (hueforge P3
48
+ exporters, studio bake paths) that today loop the scalar. Setup-time /
49
+ bake-time convenience — MINDE stays out of the per-frame hot path.
50
+
51
+ - **Cyclic LUT bake:** `bakeGradientToUint32` gained a trailing `opts`
52
+ parameter with `{ closed: true }`. In closed mode, stops are treated
53
+ cyclically (last stop wraps back to first), sample positions become
54
+ `i / resolution` (period spacing, no duplicated endpoint), and
55
+ `easeFn` outputs are period-wrapped via `t - floor(t)` instead of
56
+ clamped. Pairs with `sampleColorLUTWrapped` on the consumer side. The
57
+ seam continuity is asserted: `|lut[res-1] - lut[0]|` per channel is
58
+ bounded by the maximum adjacent-cell interior delta.
59
+
60
+ - **Preflight gate (stale-base cure):** `npm run preflight` fetches the
61
+ latest published tarball via `npm pack` and hash-compares every file
62
+ in `files[]` against the working tree. Hard-fails on code drift
63
+ (`index.js`, `index.d.ts`, `src/**`, `package.json`); warns for doc
64
+ drift (`README.md`, `CHANGELOG.md`, `LICENSE.md`, `llms.txt`). This is
65
+ the structural cure for the v1.3/v1.4 regressions that started from an
66
+ unrebased working tree. Session protocol: run preflight before feature
67
+ work.
68
+
69
+ ### Fixed (pre-release, against the 1.5.0 release candidate)
70
+
71
+ - **Blue-noise tile: Phase 3 of the void-and-cluster generator was
72
+ inverted.** The RC selected the 0-cell with the *highest* filtered
73
+ ones-field — the cell most surrounded by 1s, which is an **isolated**
74
+ zero (the largest void of the complement), not its tightest cluster.
75
+ The correct selector is the *minimum*: because the filter is toroidal
76
+ and constant-sum, `zerosField = sum(K) - onesField`, so
77
+ `argmax(zerosField) === argmin(onesField)` and Phase 3 reduces to the
78
+ same `findLargestVoid()` call as Phase 2.
79
+
80
+ Effect: ranks below 2048 were unaffected, so every threshold pattern at
81
+ or below mid-gray was correct — and every threshold pattern **above**
82
+ it was 5–7x over-clustered. Dithering was sound in the shadows and
83
+ broken in the highlights, which is exactly where a dither earns its
84
+ keep. Radial power at `r=1` was 2.05 against a white-noise floor of
85
+ 0.083: at the tile period the RC tile was *worse than random*.
86
+
87
+ The tile fingerprint therefore rotates:
88
+ `78926ec1…` → `8867ddb65e16379ad42244fa24b82dcbd813c684ce14944f86a9e3e8f6b72968`.
89
+ Same seed (`0xc0ffee1e`), same generator, one comparator.
90
+
91
+ - **The RC gates could not see any of it.** All 17 tests in
92
+ `dither.test.js` passed the defective tile. A uniform histogram is
93
+ invariant under *any* permutation of ranks (a linear ramp satisfies
94
+ it); the SHA-256 assertion pins the artifact rather than validating it,
95
+ and was cementing the defect into the contract; and "dithered
96
+ run-length halved vs undithered" is satisfied by white noise too — it
97
+ tests *that* there is noise, not that the noise is blue. The
98
+ generator's own self-check measured field variance at `T=128` **only**,
99
+ which is precisely the one threshold an inverted Phase 3 cannot affect,
100
+ and it reported an identical healthy 0.5133 for the broken and the
101
+ fixed tile.
102
+
103
+ New `test/bluenoise-spectral.test.js` gates the property the feature is
104
+ named after: minority-phase clumpiness under 0.70 across `T=16..240`,
105
+ that curve symmetric about `T=128` (the assertion that fails the RC
106
+ first), radial power for `r<=4` below the white-noise floor, high-band
107
+ power above it, and wrap-edge decorrelation within 82% of interior. The
108
+ generator now runs the same sweep as a hard gate and refuses to emit a
109
+ tile that fails it.
110
+
111
+ - `packOklchBufferToUint32Dithered` had no parameter defaults while every
112
+ sibling packer defaults `alpha = 1.0`. Calling it with the plain
113
+ packer's arity returned `0x00000000` — silent transparent black. It now
114
+ defaults `alpha = 1.0, noise01 = 0.5`, so a 2-argument call is exactly
115
+ `packOklchBufferToUint32`.
116
+
117
+ - **The sRGB transfer function had 8 copies in `src/runtime.js`** — 6 of them
118
+ added by the F1 dither work, which inlined the encode per channel in both
119
+ dithered packers. They now derive from a single `srgbEncode(c)` helper, which
120
+ `SRGB_LUT` and `linearToSrgbByte` also route through: one definition of the
121
+ IEC 61966-2-1 constants in the module. The dither packers genuinely cannot use
122
+ `linearToSrgbByte` (it rounds) or `SRGB_LUT` (it stores rounded bytes) — they
123
+ need the sub-integer encoded value — but that argued for extracting the shared
124
+ step, not for copying it six times. The real exposure was the
125
+ `noise01 === 0.5` parity guarantee: it holds only while the dither path and
126
+ the plain path agree on the encode, and nothing structural was keeping them in
127
+ step. Now they are the same function, and the parity test guards it.
128
+
129
+ Measured, since "the duplication forces V8 to monomorphize the loop" is a
130
+ testable claim: interleaved A/B, 40 rounds, 12 primer passes, N=100k. The
131
+ helper form came in at 0.9978x / 0.9981x / 0.9967x / 0.9983x of the inlined
132
+ form on the dithered batch, and within +/-0.3% on the scalar. V8 inlines it
133
+ outright. The duplication bought nothing.
134
+
135
+ `src/Gamut.js` and `src/Remap.js` keep their own single copies **on purpose**.
136
+ Both modules currently have zero imports, and `Gamut.js` is a subpath export
137
+ (`./gamut`); making it import from `runtime.js` would drag the module-level
138
+ `SRGB_LUT` build and the 5.4 KB blue-noise blob into the gamut consumer's
139
+ graph. One duplicated line each is the cheaper trade. A shared `src/transfer.js`
140
+ would collapse the last two without that cost, if it is ever worth a file.
141
+
142
+ - `getBlueNoise64()` read `Buffer` as a free identifier. Webpack 4 and
143
+ browserify pattern-match that token and inject the ~20 KB buffer
144
+ polyfill into browser bundles — for a package whose pitch is a
145
+ single zero-dependency file. Now reads `globalThis.Buffer`, which is
146
+ not matched; browser builds stay on the `atob` path.
147
+
148
+ ### Demo
149
+
150
+ - `demo/index.html` particle cull guard rewritten from `(out of range)` to
151
+ `!(in range)`. Identical for every finite value and the same four compares —
152
+ but NaN makes every comparison false, so the old form let a NaN coordinate
153
+ through to `(pY[i] | 0) * w + (pX[i] | 0)`, where `NaN | 0` is `0` and the
154
+ particle silently paints pixel (0,0) forever. Not reachable today (the `+ 1`
155
+ in `d2` rules out the divide-by-zero and `F_DAMP` bounds the integration);
156
+ this is a free guard against a future edit to the force math.
157
+
158
+ ### Known / deferred
159
+
160
+ - **Dither DC bias.** `noise01 = byte / 256` has mean `127.5/256 =
161
+ 0.498046875`, not `0.5`, so `floor(v + noise)` darkens by a constant
162
+ `-1/512` LSB on every pixel. It is deterministic, not noise, and
163
+ invisible (0.2% of one code value) — but it is a bias, and the JSDoc
164
+ leans on "the mean and variance of a uniform distribution" as if that
165
+ mean were 0.5. The fix, `(byte + 0.5) / 256`, would centre it exactly —
166
+ at the cost of the `noise01 === 0.5` parity guarantee, since no byte
167
+ maps to 127.5. Kept as-is for v1.5; the parity property is worth more
168
+ than 1/512 of a code value. Revisit alongside the v1.6 float-LUT.
169
+
170
+ ### Notes
171
+
172
+ - No breaking changes. All v1.4 exports (batch kernels, 4k LUT, Display
173
+ P3, CSS formatters, `sampleColorLUT`) are retained with identical
174
+ byte-level output.
175
+ - Existing open-mode `bakeGradientToUint32` calls are byte-identical to
176
+ v1.4 (verified by regression tests) — closed mode is opt-in via the
177
+ new trailing `opts` argument.
178
+ - Zero-GC audit: every new hot-path function is allocation-free after
179
+ the one-time lazy noise decode.
180
+
181
+ ---
182
+
3
183
  ## [1.4.0] - 2026-07-10
4
184
 
5
185
  ### Added
package/README.md CHANGED
@@ -8,6 +8,7 @@
8
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)
9
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)
10
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)
11
12
  ![Tree-Shakeable](https://img.shields.io/badge/tree--shakeable-yes-brightgreen)
12
13
  ![TypeScript](https://img.shields.io/badge/TypeScript-Types-informational)
13
14
  ![Dependencies](https://img.shields.io/badge/dependencies-0-brightgreen)
@@ -214,6 +215,34 @@ Absolute milliseconds vary by device; the benchmark prints your machine's number
214
215
 
215
216
  ---
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
+
217
246
  ## API reference
218
247
 
219
248
  ### Parsing
@@ -431,7 +460,7 @@ function render() {
431
460
  }
432
461
  ```
433
462
 
434
- (A full oscilloscope-themed demo with this scene ships in `demo/index.html` -- run `npx serve .` from the package root.)
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.)
435
464
 
436
465
  ---
437
466
 
package/index.d.ts CHANGED
@@ -231,6 +231,28 @@ export function packOklchBufferToUint32Fast(
231
231
  alpha?: number
232
232
  ): number;
233
233
 
234
+ /**
235
+ * Dithered sibling of {@link packOklchBufferToUint32}. Applies a threshold-
236
+ * offset dither in gamma-encoded space with a caller-supplied `noise01` value,
237
+ * shared across R/G/B so the dither is a luminance pattern (no chroma speckle).
238
+ * Alpha is undithered.
239
+ *
240
+ * Parity: `noise01 === 0.5` reproduces the byte output of the plain packer
241
+ * exactly (asserted by the test suite).
242
+ *
243
+ * `noise01` is expected in `[0, 1)`. When driven from the shared blue-noise
244
+ * tile, use `getBlueNoise64()[((y & 63) << 6) | (x & 63)] / 256`, which stays
245
+ * strictly below 1 and gives a low-discrepancy sample.
246
+ *
247
+ * @returns 32-bit unsigned integer in little-endian RGBA byte order.
248
+ */
249
+ export function packOklchBufferToUint32Dithered(
250
+ buf: Float32Array,
251
+ offset: number,
252
+ alpha?: number,
253
+ noise01?: number
254
+ ): number;
255
+
234
256
  /**
235
257
  * Batch packer: n OKLCH triplets -> n Uint32 packed colors (stride 1 in dst).
236
258
  * Opt-in 4k sRGB LUT (`useLut=true`) for fast-packer throughput at near-exact accuracy.
@@ -245,6 +267,30 @@ export function packOklchBufferToUint32IntoN(
245
267
  useLut?: boolean
246
268
  ): void;
247
269
 
270
+ /**
271
+ * Dithered batch packer: walks a 64x64 blue-noise tile in row-major order and
272
+ * applies a per-pixel luminance-patterned threshold dither (same noise value
273
+ * across R/G/B; alpha undithered). The tile is torus-indexed
274
+ * (`tile[((y & 63) << 6) | (x & 63)]`) so any `x0`/`y0`/`rowWidth` combination
275
+ * is safe. Zero allocations; no modulo/division in the hot loop.
276
+ *
277
+ * The 4k sRGB transfer LUT is *not* an option here — that LUT stores rounded
278
+ * bytes and would silently degrade the dither to plain rounding. A companion
279
+ * float LUT restoring this axis is a v1.6 candidate.
280
+ */
281
+ export function packOklchBufferToUint32IntoNDithered(
282
+ src: Float32Array,
283
+ offSrc: number,
284
+ dst: Uint32Array,
285
+ offDst: number,
286
+ n: number,
287
+ alpha: number,
288
+ noiseTile: Uint8Array,
289
+ x0: number,
290
+ y0: number,
291
+ rowWidth: number
292
+ ): void;
293
+
248
294
  /**
249
295
  * Encodes an OKLCH triplet to a 32-bit unsigned integer in little-endian RGBA
250
296
  * byte order for a **Display P3** canvas context
@@ -278,6 +324,37 @@ export function packOklchBufferToUint32P3Fast(
278
324
  alpha?: number
279
325
  ): number;
280
326
 
327
+ /**
328
+ * Batch sibling of {@link packOklchBufferToUint32P3}. Packs `n` OKLCH triplets
329
+ * (stride 3) into `n` Uint32 pixels encoded for Display P3. `useLut=true`
330
+ * shares the 4k sRGB transfer LUT (the P3 transfer function is IEC 61966-2-1,
331
+ * identical to sRGB — no separate table is allocated), giving fast-packer
332
+ * throughput at within ~1 LSB of the exact encoder.
333
+ */
334
+ export function packOklchBufferToUint32P3IntoN(
335
+ src: Float32Array,
336
+ offSrc: number,
337
+ dst: Uint32Array,
338
+ offDst: number,
339
+ n: number,
340
+ alpha?: number,
341
+ useLut?: boolean
342
+ ): void;
343
+
344
+ /**
345
+ * Returns the shared 64x64 blue-noise tile as a `Uint8Array(4096)`, lazily
346
+ * decoded from an inlined base64 blob on first call and reused thereafter.
347
+ *
348
+ * Index a pixel via `tile[((y & 63) << 6) | (x & 63)]`. The tile is
349
+ * torus-tileable: repeating it end-to-end on either axis is spectrally
350
+ * seamless. Each byte 0..255 appears exactly 16 times (uniform histogram),
351
+ * so `byte / 256` gives a low-discrepancy sample in `[0, 1)`.
352
+ *
353
+ * The returned reference is shared and must not be mutated; callers that
354
+ * need to modify the values should copy first.
355
+ */
356
+ export function getBlueNoise64(): Uint8Array;
357
+
281
358
  /**
282
359
  * Zero-GC sampler for a baked LUT. Inline `t`-clamp + bitwise-truncated index.
283
360
  *
@@ -300,10 +377,17 @@ export type OklchPackerFn = (buf: Float32Array, offset: number, alpha?: number)
300
377
  * Output bytes are little-endian RGBA - drop into a `Uint32Array` view of
301
378
  * `Canvas ImageData`, or upload as `RGBA / UNSIGNED_BYTE` via `texImage2D`.
302
379
  *
303
- * Stops are evenly distributed: stop _i_ is at `i / (numStops - 1)`. The
304
- * optional `easeFn` warps the parametric position **before** stop selection.
305
- * Easing outputs outside `[0, 1]` are clamped (the LUT is fixed-resolution
306
- * and cannot represent overshoot).
380
+ * **Open mode** (default): stops are evenly distributed stop _i_ at
381
+ * `i / (numStops - 1)`; LUT sample _j_ at `j / (resolution - 1)`. Sample 0
382
+ * lands on stop 0, sample `resolution-1` on the last stop. Overshoot from
383
+ * `easeFn` is clamped to `[0, 1]`.
384
+ *
385
+ * **Closed mode** (`opts.closed = true`, new in v1.5): stops are treated
386
+ * cyclically — the wrap segment runs from `stops[numStops-1]` back to
387
+ * `stops[0]`. Sample _j_ is at `j / resolution` (period spacing, no duplicated
388
+ * endpoint). `easeFn` outputs are wrapped via `t - floor(t)` instead of
389
+ * clamped. Pair with `sampleColorLUTWrapped` on the consumer side (hue wheels,
390
+ * cyclic colorways).
307
391
  *
308
392
  * @param keyframesBuf Contiguous buffer of `[L0, C0, H0, L1, C1, H1, ...]`.
309
393
  * @param numStops Stop count; must be `>= 2`.
@@ -312,6 +396,7 @@ export type OklchPackerFn = (buf: Float32Array, offset: number, alpha?: number)
312
396
  * @param packer Optional packer override. Defaults to the accurate
313
397
  * {@link packOklchBufferToUint32}. Pass
314
398
  * {@link packOklchBufferToUint32Fast} for ~2x bake throughput.
399
+ * @param opts Optional bake options. `{ closed: true }` bakes a cyclic LUT.
315
400
  * @throws If `numStops < 2` or `resolution < 2`.
316
401
  */
317
402
  export function bakeGradientToUint32(
@@ -319,7 +404,8 @@ export function bakeGradientToUint32(
319
404
  numStops: number,
320
405
  resolution?: number,
321
406
  easeFn?: (t: number) => number,
322
- packer?: OklchPackerFn
407
+ packer?: OklchPackerFn,
408
+ opts?: { closed?: boolean }
323
409
  ): Uint32Array;
324
410
 
325
411
  // ============================================================================
package/index.js CHANGED
@@ -17,10 +17,14 @@ export {
17
17
  lerpOklchBufferN,
18
18
  packOklchBufferToUint32,
19
19
  packOklchBufferToUint32Fast,
20
+ packOklchBufferToUint32Dithered,
20
21
  packOklchBufferToUint32IntoN,
22
+ packOklchBufferToUint32IntoNDithered,
21
23
  packOklchBufferToUint32P3,
22
24
  packOklchBufferToUint32P3Fast,
23
- sampleColorLUT
25
+ packOklchBufferToUint32P3IntoN,
26
+ sampleColorLUT,
27
+ getBlueNoise64
24
28
  } from './src/runtime.js';
25
29
 
26
30
  export {
package/llms.txt CHANGED
@@ -1,4 +1,4 @@
1
- # lite-color-engine - LLM Reference (v1.4)
1
+ # lite-color-engine - LLM Reference (v1.5)
2
2
 
3
3
  ## Core Exports
4
4
 
@@ -12,27 +12,54 @@
12
12
 
13
13
  ### Conversion
14
14
  - `sRgbToOklchBuffer(r, g, b, out, off)`
15
- - `displayP3ToOklchBuffer(r, g, b, out, off)` — New in v1.2
15
+ - `displayP3ToOklchBuffer(r, g, b, out, off)`
16
16
  - `oklchToLinearP3(L, C, H, out)` — Inverse for P3 packing
17
17
 
18
18
  ### Runtime (Hot Path - Zero GC)
19
19
  - `lerpOklchBuffer(...)`
20
20
  - `packOklchBufferToUint32(buf, off, alpha?)` - Accurate sRGB
21
21
  - `packOklchBufferToUint32Fast(...)` - Fast approx
22
+ - `packOklchBufferToUint32Dithered(buf, off, alpha?, noise01?)` — defaults `alpha=1.0, noise01=0.5`, so a 2-arg call is exactly `packOklchBufferToUint32` — **v1.5** dithered sRGB packer.
23
+ Luminance-only threshold dither (same `noise01` across R/G/B, no chroma speckle).
24
+ `noise01 = 0.5` reproduces the plain packer exactly.
22
25
  - `packOklchBufferToUint32P3(buf, off, alpha?)` - Accurate P3 output
23
26
  - `packOklchBufferToUint32P3Fast(...)` - Fast P3 output
24
27
  - `sampleColorLUT(lut, t)` - O(1) lookup into a baked gradient LUT
25
28
 
26
29
  ### Batch kernels (v1.3, for 100k+ particle systems)
27
30
  - `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).
31
+ - `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).
32
+ - `packOklchBufferToUint32P3IntoN(src, offSrc, dst, offDst, n, alpha?, useLut?)` — **v1.5** P3 batch. `useLut=true` reuses the same `SRGB_LUT` (identical transfer function per IEC 61966-2-1); no separate table is allocated.
33
+ - `packOklchBufferToUint32IntoNDithered(src, offSrc, dst, offDst, n, alpha, noiseTile, x0, y0, rowWidth)` — **v1.5** dithered batch. Walks a 64x64 blue-noise tile in row-major order over destination `(x0, y0)` with `rowWidth`. Zero allocations, no modulo in hot loop. Exact-encoder only (see note below).
34
+ - Note: the batch win comes from `useLut`, not from call-amortization (accurate batch ~= scalar loop in V8; pow-bound). The dithered batch has no `useLut` axis — `SRGB_LUT` stores rounded bytes, which would collapse the dither threshold; a float LUT restoring this is a v1.6 candidate.
35
+
36
+ ### Blue-noise tile (v1.5)
37
+ - `getBlueNoise64()` — Returns the shared 64x64 blue-noise tile as `Uint8Array(4096)`.
38
+ Lazily decoded from an inlined base64 blob on first call (sub-ms), then shared. Void-and-cluster,
39
+ histogram exactly uniform (16x each byte). Index via `tile[((y & 63) << 6) | (x & 63)]`.
40
+ Do not mutate the returned reference. Torus-tileable on both axes.
41
+ - The tile is gated on SPECTRAL properties, not just its SHA-256 fingerprint
42
+ (`test/bluenoise-spectral.test.js`): minority-phase clumpiness < 0.70 across thresholds
43
+ 16..240, that curve symmetric about T=128, radial power for r<=4 below the white-noise
44
+ floor of 1/12, high-band power above it, wrap edges as decorrelated as the interior.
45
+ A uniform histogram does NOT imply blue noise — a linear ramp has a perfectly uniform
46
+ histogram. The 1.5.0 release candidate shipped a tile that passed every histogram /
47
+ fingerprint / banding gate and was still 7x over-clustered above mid-gray (inverted
48
+ Phase 3 selector in the generator). Do not treat the fingerprint as the gate.
30
49
 
31
50
  ### Gamut mapping (subpath: `@zakkster/lite-color-engine/gamut`, NOT hot-path)
32
- - `packOklchBufferToUint32MINDE(buf, off, alpha?)` — MINDE chroma-reduction gamut map. ~30x slower than the core packer; use at LUT-build/authoring time.
51
+ - `gamutMapToSrgbBuffer(inBuf, off, outBuf, offOut)` — MINDE chroma-reduction gamut map (scalar).
52
+ - `gamutMapToSrgbBufferN(inBuf, off, outBuf, offOut, n)` — **v1.5** batch MINDE. Bit-for-bit identical to n scalar calls; for authoring / LUT-build bulk mapping.
53
+ - `packOklchBufferToUint32MINDE(buf, off, alpha?)` — MINDE + pack. ~30x slower than the core packer; LUT-build/authoring time only.
54
+
55
+ ### LUT (Gradient Baking)
56
+ - `bakeGradientToUint32(keyframesBuf, numStops, resolution?, easeFn?, packer?, opts?)` — accepts any of the above packers.
57
+ - **v1.5:** trailing `opts` object. `{ closed: true }` bakes a cyclic LUT: sample positions `i / resolution` (period, no duplicated endpoint), wrap segment last->first, ease outputs period-wrapped via `t - floor(t)`. Pair with `sampleColorLUTWrapped` on the consumer side (hue wheels, cyclic colorways).
58
+ - Open mode (default) is byte-identical to v1.4.
59
+
60
+ ## Session Gate (v1.5)
33
61
 
34
- ### LUT
35
- - `bakeGradientToUint32(..., packer?)` — Accepts any of the above packers
62
+ - `npm run preflight` — mandatory before feature work. Fetches the published tarball via `npm pack` and hash-compares against HEAD. Hard-fails on code drift (`index.js`, `index.d.ts`, `src/**`, `package.json`); warns for doc drift.
36
63
 
37
64
  ## Key Principles
38
65
  - All hot-path functions are allocation-free
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@zakkster/lite-color-engine",
3
- "version": "1.4.0",
3
+ "version": "1.5.0",
4
4
  "author": "Zahary Shinikchiev <shinikchiev@yahoo.com>",
5
5
  "description": "Zero-GC, data-oriented OKLCH color engine and WebGL/Canvas buffer pipeline.",
6
6
  "type": "module",
@@ -60,13 +60,14 @@
60
60
  ],
61
61
  "scripts": {
62
62
  "test": "vitest run",
63
- "bench": "node bench/benchmark.mjs"
63
+ "bench": "node bench/benchmark.mjs",
64
+ "preflight": "node tools/preflight.mjs"
64
65
  },
65
66
  "dependencies": {
66
67
  "@zakkster/lite-lerp": "^1.0.0"
67
68
  },
68
69
  "devDependencies": {
69
- "vitest": "^1.0.0"
70
+ "vitest": "^4.1.10"
70
71
  },
71
72
  "license": "MIT",
72
73
  "homepage": "https://github.com/PeshoVurtoleta/lite-color-engine#readme",
package/src/Gamut.d.ts CHANGED
@@ -19,6 +19,24 @@ export function gamutMapToSrgbBuffer(
19
19
  outOffset: number
20
20
  ): void;
21
21
 
22
+ /**
23
+ * Batch sibling of `gamutMapToSrgbBuffer`. Maps `n` OKLCH triplets (stride 3)
24
+ * from `inBuf` into `outBuf` at the equivalent stride, using the same CSS
25
+ * Color 4 MINDE bisection. Bit-for-bit identical to `n` scalar calls; the
26
+ * batch exists to amortize call overhead for authoring / LUT-building large
27
+ * color runs (hueforge P3 exporters, studio bake paths).
28
+ *
29
+ * Setup-time / bake-time convenience — MINDE stays out of the per-frame hot
30
+ * path (~30x slower than the core packer). Zero allocations.
31
+ */
32
+ export function gamutMapToSrgbBufferN(
33
+ inBuf: Float32Array,
34
+ inOffset: number,
35
+ outBuf: Float32Array,
36
+ outOffset: number,
37
+ n: number
38
+ ): void;
39
+
22
40
  /**
23
41
  * Accurate sibling of `packOklchBufferToUint32`. Runs MINDE gamut mapping
24
42
  * before packing, eliminating the hue-shift artifacts of the hard channel
package/src/Gamut.js CHANGED
@@ -189,6 +189,39 @@ export function gamutMapToSrgbBuffer(inBuf, inOffset, outBuf, outOffset) {
189
189
  }
190
190
 
191
191
 
192
+ // -----------------------------------------------------------------------------
193
+ // gamutMapToSrgbBufferN
194
+ // -----------------------------------------------------------------------------
195
+
196
+ /**
197
+ * Batch sibling of {@link gamutMapToSrgbBuffer}. Maps `n` OKLCH triplets
198
+ * (stride 3) from `inBuf` into `outBuf` at the equivalent stride, using the
199
+ * same CSS Color 4 MINDE bisection as the scalar path. Bit-for-bit identical
200
+ * to calling {@link gamutMapToSrgbBuffer} `n` times; the batch exists to
201
+ * amortize call overhead when authoring / LUT-building large color runs
202
+ * (hueforge P3 exporters, studio bake paths) that today loop the scalar.
203
+ *
204
+ * MINDE is ~30x slower than the core packer and stays out of the per-frame
205
+ * hot path — this batch is a setup-time / bake-time convenience. Zero
206
+ * allocations (reuses the module-level scratch). In-place is safe when
207
+ * `inBuf === outBuf` at aligned offsets: each iteration reads its three
208
+ * source lanes into `_rgb`/`_clipLch` scratch before writing back.
209
+ *
210
+ * @param {Float32Array} inBuf - Source OKLCH buffer (stride 3)
211
+ * @param {number} inOffset - Base offset of the first triplet in inBuf
212
+ * @param {Float32Array} outBuf - Destination OKLCH buffer (stride 3)
213
+ * @param {number} outOffset - Base offset of the first triplet in outBuf
214
+ * @param {number} n - Number of triplets to map (n <= 0 is a no-op)
215
+ * @returns {void}
216
+ */
217
+ export function gamutMapToSrgbBufferN(inBuf, inOffset, outBuf, outOffset, n) {
218
+ if (n <= 0) return;
219
+ for (let i = 0; i < n; i++) {
220
+ gamutMapToSrgbBuffer(inBuf, inOffset + i * 3, outBuf, outOffset + i * 3);
221
+ }
222
+ }
223
+
224
+
192
225
  // -----------------------------------------------------------------------------
193
226
  // packOklchBufferToUint32MINDE
194
227
  // -----------------------------------------------------------------------------
package/src/lut.js CHANGED
@@ -6,11 +6,17 @@ import { lerpOklchBuffer, packOklchBufferToUint32 } from './runtime.js';
6
6
  * The output is in little-endian RGBA byte order — drop it directly into a
7
7
  * `Uint32Array` view of `Canvas ImageData`, or `texImage2D` with `RGBA / UNSIGNED_BYTE`.
8
8
  *
9
- * Stops are evenly spaced across the gradient (stop _i_ is at `i / (numStops - 1)`).
10
- * The optional `easeFn` warps the parametric position **before** stop selection,
11
- * letting you bias which stop dominates which range of the LUT. Easing outputs
12
- * outside `[0, 1]` are clamped (the LUT is fixed-resolution and cannot
13
- * represent overshoot).
9
+ * **Open mode (default):** stop _i_ is at `i / (numStops - 1)`; LUT sample _j_
10
+ * is at `j / (resolution - 1)`. Sample 0 lands on stop 0, sample `resolution-1`
11
+ * lands on the last stop. Overshoot from `easeFn` is clamped to `[0, 1]`.
12
+ *
13
+ * **Closed mode** (`opts.closed = true`, new in v1.5): stops are treated
14
+ * cyclically — the wrap segment runs from `stops[numStops-1]` back to
15
+ * `stops[0]`. Sample _j_ is at `j / resolution` (period spacing, no duplicated
16
+ * endpoint), so `sample[resolution-1]` sits just before wrapping back to
17
+ * sample 0. This is the LUT baked by hue-wheel gradients / cyclic colorways —
18
+ * pair it with `sampleColorLUTWrapped` on the consumer side. `easeFn` outputs
19
+ * in closed mode are wrapped via `t - floor(t)` (period) instead of clamped.
14
20
  *
15
21
  * @param {Float32Array} keyframesBuf - Contiguous buffer of `[L0, C0, H0, L1, C1, H1, ...]`
16
22
  * @param {number} numStops - Count of color stops in the buffer (must be >= 2)
@@ -19,6 +25,8 @@ import { lerpOklchBuffer, packOklchBufferToUint32 } from './runtime.js';
19
25
  * @param {(buf: Float32Array, offset: number, alpha?: number) => number} [packer]
20
26
  * Optional packer override. Defaults to the accurate `packOklchBufferToUint32`.
21
27
  * Pass `packOklchBufferToUint32Fast` for ~2x throughput at the cost of round-trip accuracy.
28
+ * @param {{ closed?: boolean }} [opts]
29
+ * Optional bake options. `closed: true` bakes a cyclic LUT (see above).
22
30
  * @returns {Uint32Array} LUT of 32-bit packed colors
23
31
  * @throws If `numStops < 2` or `resolution < 2`
24
32
  */
@@ -27,7 +35,8 @@ export const bakeGradientToUint32 = (
27
35
  numStops,
28
36
  resolution = 256,
29
37
  easeFn,
30
- packer = packOklchBufferToUint32
38
+ packer = packOklchBufferToUint32,
39
+ opts
31
40
  ) => {
32
41
  if (numStops < 2) {
33
42
  throw new Error('lite-color-engine: bakeGradientToUint32 requires numStops >= 2');
@@ -36,29 +45,41 @@ export const bakeGradientToUint32 = (
36
45
  throw new Error('lite-color-engine: bakeGradientToUint32 requires resolution >= 2');
37
46
  }
38
47
 
48
+ const closed = opts != null && opts.closed === true;
39
49
  const outLUT = new Uint32Array(resolution);
40
50
  const tempOklch = new Float32Array(3);
41
51
 
42
- const step = 1 / (resolution - 1);
43
- const last = numStops - 1;
52
+ // Open: N-1 segments, samples at i/(res-1), t clamped to [0,1].
53
+ // Closed: N segments (last wraps last->first), samples at i/res, t period-wrapped.
54
+ const step = closed ? 1 / resolution : 1 / (resolution - 1);
55
+ const scale = closed ? numStops : (numStops - 1);
56
+ const maxIndex = closed ? (numStops - 1) : (numStops - 2);
57
+ const wrapIndex = numStops - 1; // only meaningful in closed mode
44
58
  const hasEase = typeof easeFn === 'function';
45
59
 
46
60
  for (let i = 0; i < resolution; i++) {
47
61
  const rawT = i * step;
48
62
  let t = hasEase ? easeFn(rawT) : rawT;
49
63
 
50
- // LUTs are fixed-resolution; overshoot/undershoot is undefined and
51
- // would either NaN-poison the buffer or extrapolate silently.
52
- if (t < 0) t = 0;
53
- else if (t > 1) t = 1;
64
+ if (closed) {
65
+ // Period wrap: `t - floor(t)` maps R to [0, 1). Handles ease overshoot
66
+ // and negative outputs uniformly; the cyclic contract has no clamp.
67
+ t = t - Math.floor(t);
68
+ } else {
69
+ // LUTs are fixed-resolution; overshoot/undershoot is undefined and
70
+ // would either NaN-poison the buffer or extrapolate silently.
71
+ if (t < 0) t = 0;
72
+ else if (t > 1) t = 1;
73
+ }
54
74
 
55
- const scaledT = t * last;
56
- let index = scaledT | 0; // bitwise fast-floor for positive values
57
- if (index >= last) index = last - 1;
75
+ const scaledT = t * scale;
76
+ let index = scaledT | 0; // bitwise fast-floor for non-negative values
77
+ if (index > maxIndex) index = maxIndex;
58
78
 
59
79
  const localT = scaledT - index;
60
80
  const offsetA = index * 3;
61
- const offsetB = offsetA + 3;
81
+ // Closed mode: the wrap segment (last -> first) reads keyframe 0 as B.
82
+ const offsetB = (closed && index === wrapIndex) ? 0 : offsetA + 3;
62
83
 
63
84
  lerpOklchBuffer(keyframesBuf, offsetA, keyframesBuf, offsetB, localT, tempOklch, 0);
64
85
  outLUT[i] = packer(tempOklch, 0, 1.0);
package/src/runtime.js CHANGED
@@ -119,11 +119,23 @@ const _scratchRgbP3 = new Float32Array(3);
119
119
  * the exact pow() encoding. One-time module-load cost, then O(1) lookup.
120
120
  * @internal
121
121
  */
122
+ /**
123
+ * The sRGB / Display P3 transfer function (IEC 61966-2-1), returning the
124
+ * gamma-encoded value in [0, 1] rather than a rounded byte. This is the single
125
+ * definition of the EOTF constants for every encoded-domain caller; the dither
126
+ * packers need the sub-integer value, so they cannot go through
127
+ * linearToSrgbByte (which rounds) or SRGB_LUT (which stores rounded bytes).
128
+ *
129
+ * @param {number} c - Linear-light channel, already clamped to [0, 1]
130
+ * @returns {number} Gamma-encoded channel in [0, 1]
131
+ */
132
+ const srgbEncode = (c) => (c <= 0.0031308 ? 12.92 * c : 1.055 * Math.pow(c, 1 / 2.4) - 0.055);
133
+
122
134
  const SRGB_LUT = new Uint8ClampedArray(4096);
123
135
  {
124
136
  for (let i = 0; i < 4096; i++) {
125
137
  const c = i / 4095;
126
- const enc = c <= 0.0031308 ? 12.92 * c : 1.055 * Math.pow(c, 1 / 2.4) - 0.055;
138
+ const enc = srgbEncode(c);
127
139
  SRGB_LUT[i] = (enc * 255 + 0.5) | 0;
128
140
  }
129
141
  }
@@ -139,6 +151,133 @@ const linearToSrgbByteLut = (c) => {
139
151
  return SRGB_LUT[(c * 4095 + 0.5) | 0];
140
152
  };
141
153
 
154
+ // -- Blue-noise tile (void-and-cluster, 64x64) -------------------------------
155
+ // Generator: tools/generate-bluenoise.mjs v1.1.0, seed 0xc0ffee1e.
156
+ // SHA-256 of decoded tile: 8867ddb65e16379ad42244fa24b82dcbd813c684ce14944f86a9e3e8f6b72968
157
+ // Histogram: each byte 0..255 appears exactly 16 times (4096/256).
158
+ // Spectral: gated by test/bluenoise-spectral.test.js -- minority-phase clumpiness
159
+ // stays under 0.70 across T=16..240 and is symmetric about T=128, and radial power
160
+ // for r<=4 sits below 0.02 (the white-noise floor is 0.083). The fingerprint above
161
+ // pins a tile that PASSES those gates; it is not a substitute for them.
162
+ // The base64 form is 5464 chars; decoded lazily on first access into a shared,
163
+ // read-only Uint8Array(4096). Do not mutate the returned reference.
164
+ const BLUE_NOISE_64_B64 =
165
+ 'hzkkbEEVL7GIKa1W2p9EWybvS8otkbxUoMOPDNB9StuZ+XnUTfYY5WONEeIoY0bbWbHRg8hSnr9Z' +
166
+ 'cPmznvQn3lb9s4Omv/lOC/CUczfAFnnXAKRp/0XkfQFg8nKcuyxjADWwH4osfrcH93G3iPoMvTQU' +
167
+ 'aEcZtC3vEyfXORB2yWqbBk/kDVx6msVnGs8Ei/y5kDu+hAytINA+tiM3Whqp7sFv417G3z5YnMxI' +
168
+ 'HFbJn3vtjKXd85FjicyqhGPCRacZuXnVNZIq3x454Umz6F9MK2fzVSnIW5dp64jdpMzlglGPG0ib' +
169
+ 'Bahs0yEzf+eVOyNpS8UiOnYG2TxzTQvtk9ow7j4jxGD1pMtqtoOjJXidDK3SEJbndtw0qBNQdxZC' +
170
+ 'ago62LL9fDbwEZDrrcRhFNyt1AP/YZrCT60d/Zi5KlYCiF3jj6cYcz8JT/YPXPE9xNqCQ3GqFj6M' +
171
+ 'BfjJL679k8L0pHUvFMJVhbhBdFIBofV0U4cysn8O1ivpXsQz4nyo+3LMD2xI5onZvHuYM9WNE2ou' +
172
+ '9x/B2E/uu1d7lGTVA1YriR1OzGef2iZh3Rn9iC9FtxHnnUbgWKVrlIEESGsY1EgfrjL4ugEvWZ0m' +
173
+ 'z3GuUbvqTZdfji9oniTTOhq9RHTbuGLereqNC0P4lsM4qcpm05Iow2cXvCL4QBG39KPKkTu4jVKc' +
174
+ 'gF/TrP4U4UgE+R93ogCz3g/zfgqxbeSi8SWPpRJBeQU5WserHXoGWnYh5gpY7j958pE3iMDkUXUn' +
175
+ 'WesOZvHI3CA6k3hFZoizXpwy2EDNdDdTq9BE+opNB39U4zbwxZz7hCLtbVDSsPGbQ7RxpYHXBqlc' +
176
+ '3HQamjLWFLF72Ct2CVyq8xHKKL7uOMx+5Y9jJ/6IwhqTXTMXyGOsxQxqhCBLaM+1lzPkhkAq14oZ' +
177
+ '+TYdtk8tyA1M0GWth/A9m02+pUONvkxp6aMHdh+pDk0WvJsKYOsrdem8od4v/D+U1Fis2zATSnkX' +
178
+ 'pwxouRBiv1HQjGX/ln3otCj8B0dixAD+iRfvbgPXhjFbkNFT/GfG83tQ1q9CodgCa0p4kR5zuSz3' +
179
+ 'A4y7oPbVX8X+j+BL8nyYAeNAwRJgPZBUe7qU0SVyNFXKKeU2oRzGrz7jLoOaOyOm4zFvGYJSsSf1' +
180
+ 'DddZ5xCjQ3rlVW4IPYImUTR4nSE7xy5xoiLZr/MKpdY5FemDtJ3bfLaZX/xHePUVb8EC3LN0RhCN' +
181
+ 'u9D7NceGnTq6pkaI0WLHOB7rkr7ioLTKA9Kw516q9lWFOnMpyWkh729ZQeIdZAxFIX+zzwmZWLWN' +
182
+ 'SV4T6cth8VQGmGQV5U7Pfxdr8iScDbOFzShSDmsa8GRCcROLH8sL4b2eWIniSpnBqArIT/er0+4P' +
183
+ 'Ui5q2yvpIPbIlCyEnyarekfapHIHYi3fvjdX4G79RmWsevmWSYUulvpQ2ndHmWob+hA+twSAK/uH' +
184
+ 'NZZxMYtpvJbnq4VEo3s5qW9P/QrEO+sbtS7uk7T+jAF5rhyQL6EB3Ty4K+ap3MYNobstse8zTIDZ' +
185
+ 'ovJi01Mca7DsAsNNKdI9Xx3ABdJnDuEcujZ13WiRzYBaP9QbU0Cgy/ZEwNlT8Ikb1V94CFokez3k' +
186
+ 'ZQSE1qnGXiJ3NK/nmsxWJYHgpXz1DI7udVb0kb1cidKbWbARUCr5DcBpfqzWZhVchQl5I7VvTZ0T' +
187
+ 'zUO8j+9dGZTMVSFuCTuTyBWKRRE63bpiQRKaZbZMyjWvGEsq+D4D7R+G9cOhZY+nJPMPMe6VKuWk' +
188
+ 'X9KVL8f0gq7+aTarw378QLj0nOOy/VDeb773d6AYifzLITjdbySU4ILGn22tfk3HLUR0AOVI2TnG' +
189
+ 'im+9S7HONfsSROkFWzollhffAU4spmsVi0gtZoEAqitXkAVO7TGxU+moBJ7+D2U86gnTIuFio+WU' +
190
+ 'tjfIGntenlHoCH4dbVGHvmqni71x41HPdp3qywzhNtJ4wBvQPZLxGtmpxG2VCHOHSst+Qb2qJXtc' +
191
+ 'RZK6Ng1rGfJYiq78Bd4gqjfa9JkFrCbhUBrtoAqLtj8giVd1kLJdBuykWedpw4NjPB/jQtfBFvBe' +
192
+ 'K9WIVNyc8rQS/XjZyUx91CVsM7uDQc2QVbhAyeh0Osd/MEnULFz0Z7kx90Yj85pPiCexDUkxsv97' +
193
+ 'WaQpYTSYthJs6gLBGTFsiSZWjq4spQmY7U6bZOx1FWgngBhfkxOi2mT7rYDEB6DeFMOl2X4WyTja' +
194
+ 'dJjq0wqTyhSC+K/iRHj2ojRLc4/N4E7E6QJA9mfgPsUY0ysLwqf+0Z7wQ8/5UwKVH20V5Dl9TZNt' +
195
+ 'A2E/uGz1EbwjVKJwK0nfvwxu0AXAI1fMsvs+WwenO59yvoQUslx4jaz3VJUySgJvMLmEKLF5v0DM' +
196
+ 'mFSo/iXWN4fN6S2WTIRe+YY+uvNlnDlUjaJhh9iWC4Akmr2B+Btg2yJWz5Ak6gBoOoPiYrmM3Kha' +
197
+ 'CWneNOpd8i9zwA9mruudIFeqBtOmO8gB4yCOCLR38SdK/zBCb+9d3A7qLHDUkTXxo0f/OLlL274R' +
198
+ 'ziDrE04iyuiSSKUQjLMF3ESMylMWRruC/3Aq7B12r2fSVuwtzhq4fRGp4xyhL69tULZAwQ9+tQd3' +
199
+ 'GdeEoyd3nERuoX36l3I1vRfNbidLfaIh7jF/93DbD0G/imLdl00yoH9CqWiQ32TOjrhNxoVDyo8S' +
200
+ 'nlrrRsxfmMVVaxX5WOayLsNZPLUP8VF8/prgxfhZvGyfAbiYJ2finxdJtw3N/RfAD9hPBD3yKlgB' +
201
+ 'd/oV2SH3euImq3Eq5DysLuK0kjcHh/MU3yfTYY2rKVs9CWg1kQ3gTNFcPMywMlnP9HwtcY5a5W6X' +
202
+ '+sKkGbyD7DJkm1emM2PRigLzn4QQ9Y0EQ8V02GNJlGukg0PaAOK2gr6kHs5ArYQh5o4KgeyVAjum' +
203
+ '5ke0KKQ6HoAtdJjeP6PXugrmccILTjnHVBvWbE/LfGHxHKomudAE8hvAMJ9wHPFS2XbyZRb7Nadw' +
204
+ '9Ucdc7xliR3UBPWE0VWy6EpgDm0eSnkpjj7vgrb7lWmzQb4joOonmk+L/Tp7UbBddfZMyjqSFDCN' +
205
+ 'TrqXdcVQGr1drN0o+sdbmmxQxBHhZwnN66jKibLwyVKvHKAodRLbMY//cw49ttULwWwR44wy35sY' +
206
+ 'hlyr0WLsrQMr6loI3Jgw0Is+o1AKNu29MXZCi6I4hlEz+loFOZgW1WXiV8NHpuwFUqngilh1Nt5V' +
207
+ 'n7cfxUgGuuYl/Ah/wj/dgc06tYX4aAXkFG+F2qd+DpTvsyj/why1eSOb43Nd/30xkgPqiSFigcom' +
208
+ 'Zr8U9qyEJO9BZvp81ms3mXJKpB9wnFMdomImQqh9V8TwJblmRuAeYdUGWnKZC85EuxfQpwq6R86w' +
209
+ 'Nm/StzeW6UfXKplHAct0kQukLJJSs9wax+NY9BK7/33k0RXB6zOwQZgQ/CrLr0mbf8tB7laM6WSG' +
210
+ 'Ry6L6W8YYPSeDE73GHYJo39i57ZZMNu/Wsse9QtfgzuRLLSJNGoJS5F0T5cdimDOeVSgiG4w9hWr' +
211
+ 'LNhvrDUf27X1VyGr24FLJ996xKdcufs7xBeM+KcZgEbicaNDzaryAGjWRuHHqDa89AjTbPgA3zHD' +
212
+ 'FuwDwFXdaJIexgf0o3gAaZ7LM5YHwq2PYSpD1IodUt9zKERq7Da0Ao0y6XYlVcCheg1eI4vnGmGh' +
213
+ 'PLoqn0ywgmFE2niNO7pO+0CDXUnJPN4TfEXkV/w8EvGdBOsxbZ4LstaeCMmWYP7IZrkPmtM/GPex' +
214
+ 'mfBtUa8uhd1VfOdwIPPRpSayG+sMdaAWuOCUJLKGTu2+bB+KdslJ2INYrM3xhzlXeuVOJqoVSiKI' +
215
+ 'TvprhN0uTc48Atp6yfocqQvGPpUHNZBS/2SawynU7y9uD9Va/6crCbCb1C9ntSBwwhZIJGLE/B+4' +
216
+ 'jHDtgtml5ME1G7dYk3EdhbyfQw5sS5DxXbLWab58DM0yTOOBX6xQiuugNBlwjNhcP+gCpO2PNv2Z' +
217
+ 'dueoApUz1RQ8w1gvdwhgjqbqBcTnpv9bGO2XvynVOBODKflF4ahwia8BQpPNBr9BfGbRwTn2exu4' +
218
+ 'gkJbD8pQB7swgd1QdWCu3waRuEXzKtlIeTtmE0Yndc8zYuh/tW6c4lShFV0p8xnZavUhd2P5F7fo' +
219
+ 'A5VLEJ/PUPsisuJmqYfWYEYawqLqR5ln+xzOl2jFDvue1rh/3LFPhq0DVh7+SccefbfLkT+9U5+6' +
220
+ 'OJzUNJJSJ6he5rFpLo5kxZZ5K/RAIO6ujvUoDn4ixzl7V6sZg7BWH4czXZQK9CLGQp/RiwauYzjr' +
221
+ 'BGnmfC4TheZNH69u24H6M3cgwPIH1TgTS88Lc5XHDWo7Xc+371Ol4QPwPeUtcsDoBPjJPmqQ2Hjs' +
222
+ 'LGY98JTXik+wF5nJ+1gMwoL0CbtGE8aP3lY9m3Kn74C3neVMMnu52J6HMHAMhiy8a45RzpY/Y6tK' +
223
+ 'JaHlEjdcDrumes4PKnD9NdRfRK5t2qFCYDiY0GWjQwqpfeEhWtgwVxhl1ab6JEwA+EWw5tFGm9ck' +
224
+ 'tQfzGN2OcL58VLqo+ZRM5xtZtkjCGqJ6BuAcky0C3q3mInnsKdZs7hi2Rb8DkP7DKYYHW5TgZ8Qe' +
225
+ 'XZAXd14R/nlgon4uzhDuGtIthWkjw4g3+J6A3pFT7LaHOPB6xmsVi1O0BFqCuTaTY/mHZ6VAdqzv' +
226
+ 'RM90FK96msw4+arGPKYs2EK7WKQ5XpJC8gHgQdcIbckiZQE9xihoS8+rUP2VMs33QJnB/h1TzA0y' +
227
+ 'zyTpD9JUHJu7N+1CKvIIbk8l3ohRwIsd5QD8h92ubpxatX2eVrONQ/Gw6HQO+ZcSZCVAs1t+Dmrb' +
228
+ 'MEmmieajeq9Rcrgvgd9qKIad11eFp9y7fwDtbg32Xpd3Rb8pDMUj0DcZ+yzhFNN+LlePqdcuvOiF' +
229
+ '1xzuvSiqiBJ33wFrRe8S35pJ9pUSw/wDZLQQ5EUWl1k0ndBErCbOsBdbf/ZOg+iSZsJ2SqhhDZvQ' +
230
+ 'Hj+AVXKhBqlxSp3hVcfwYbE6vyBfizrIAGWxPFumS9EkdcFlL9L1sWQbgdxyTTPumto2arMORqoF' +
231
+ '65Yl9r9H5GC06hHONF+K5RA3cwk+mSbUhfec1LIjgOYo1XXnMX3ykTih/Y5xHkfkvzKgC+GKyG0G' +
232
+ 'p9Mg9XjbWzzMa4Y1rXsIyCmP+kjBJFXIkvPSr31PFlsteAZS/W2lVI4JzJkWvVnoG0wEyaJ+CJNS' +
233
+ '/mGzFFMsvEqLYJwtv44gtAvpVhn/l21RpXcW3D34gLUrYxz8uuejyknilb5CF8D3SCW3aEEIy4K3' +
234
+ '31Q18dFyI8WEPvCigPwY5DnIUhL/etlFksVmMNU97QK5Y5hzG18BTYygOwVvjBC1aDENhuA0fKxc' +
235
+ '7IzWeKpkJpVsriFct+g4A9JxHthbl3MA77VuP6BeqyPegagQvX/SMuqrvdOd7Mfhctle0D/4IYDt' +
236
+ 'y6pklxPachA0+iJJ4zz3EcaGQxGdZ66TSME0rkHOpoEik+gEMPd0CD/mkEoeXY1MCg==';
237
+
238
+ let _blueNoise64 = null;
239
+ /**
240
+ * Returns the shared 64x64 blue-noise tile as a `Uint8Array(4096)`, decoded
241
+ * once on first call from an inlined base64 blob and reused thereafter.
242
+ *
243
+ * Index a pixel via `tile[(y & 63) << 6 | (x & 63)]`. The tile is torus-tileable:
244
+ * repeating it end-to-end on either axis is spectrally seamless. Each byte value
245
+ * 0..255 appears exactly 16 times (uniform histogram, 4096/256), so
246
+ * `noise01 = byte / 256` gives a low-discrepancy sample in `[0, 1)` that has
247
+ * exactly the mean and variance of a uniform distribution over 256 buckets.
248
+ *
249
+ * The tile is generated by `tools/generate-bluenoise.mjs` (void-and-cluster,
250
+ * Ulichney 1993) and committed as a base64 blob; decode is sub-millisecond.
251
+ * The returned reference is shared and must not be mutated; callers that need
252
+ * to modify the values should copy first.
253
+ *
254
+ * @returns {Uint8Array} 4096-entry blue-noise tile (row-major, 64 wide)
255
+ */
256
+ export const getBlueNoise64 = () => {
257
+ if (_blueNoise64 !== null) return _blueNoise64;
258
+ // Node has Buffer; browsers/DOM have atob. Prefer Buffer when available
259
+ // (faster and does not throw on stray whitespace) and fall back to atob.
260
+ const b64 = BLUE_NOISE_64_B64;
261
+ // Read Buffer off globalThis rather than as a free identifier: webpack 4 and
262
+ // browserify pattern-match the bare `Buffer` token and inject the ~20 KB buffer
263
+ // polyfill into browser bundles. `globalThis.Buffer` is not matched, so a
264
+ // browser build stays on the atob path and the package stays zero-cost.
265
+ const NodeBuffer = globalThis.Buffer;
266
+ let bytes;
267
+ if (NodeBuffer !== undefined && typeof NodeBuffer.from === 'function') {
268
+ const buf = NodeBuffer.from(b64, 'base64');
269
+ bytes = new Uint8Array(buf.buffer, buf.byteOffset, buf.byteLength);
270
+ } else {
271
+ // atob path (browsers / non-Node runtimes)
272
+ const bin = atob(b64);
273
+ bytes = new Uint8Array(bin.length);
274
+ for (let i = 0; i < bin.length; i++) bytes[i] = bin.charCodeAt(i);
275
+ }
276
+ // Copy into a stable, standalone Uint8Array (avoids sharing with Buffer's pool)
277
+ _blueNoise64 = new Uint8Array(bytes);
278
+ return _blueNoise64;
279
+ };
280
+
142
281
  /**
143
282
  * Encodes a linear-light channel (already clamped to [0, 1]) to an sRGB
144
283
  * 8-bit byte using the proper IEC 61966-2-1 transfer function. Round-tripping
@@ -150,7 +289,7 @@ const linearToSrgbByteLut = (c) => {
150
289
  const linearToSrgbByte = (c) => {
151
290
  if (c <= 0) return 0;
152
291
  if (c >= 1) return 255;
153
- const enc = c <= 0.0031308 ? 12.92 * c : 1.055 * Math.pow(c, 1 / 2.4) - 0.055;
292
+ const enc = srgbEncode(c);
154
293
  return (enc * 255 + 0.5) | 0;
155
294
  };
156
295
 
@@ -204,6 +343,48 @@ export const packOklchBufferToUint32Fast = (buf, offset, alpha = 1.0) => {
204
343
  return ((a8 << 24) | (b8 << 16) | (g8 << 8) | r8) >>> 0;
205
344
  };
206
345
 
346
+ /**
347
+ * Dithered sibling of {@link packOklchBufferToUint32}. Applies a threshold-
348
+ * offset dither in gamma-encoded space using a caller-supplied `noise01` value.
349
+ *
350
+ * The same `noise01` is applied to all three color channels (R, G, B) so the
351
+ * dither is a **luminance pattern** — no chroma speckle. Alpha is undithered.
352
+ *
353
+ * Parity guarantee: when `noise01 === 0.5`, this returns the exact same 32-bit
354
+ * value as {@link packOklchBufferToUint32} for the same inputs (the round-half
355
+ * behavior collapses into the identical `floor(enc*255 + 0.5)` expression).
356
+ * This is asserted by the test suite.
357
+ *
358
+ * The `noise01` argument is expected to be in `[0, 1)`; values >= 1 will be
359
+ * folded to 255 by the byte clamp. When indexing the shared blue-noise tile,
360
+ * derive it as `noiseTile[(y & 63) << 6 | (x & 63)] / 256` — this yields
361
+ * values in `[0, 255/256]` and never overflows a byte.
362
+ *
363
+ * Both trailing arguments default so that a call with the plain packer's arity
364
+ * behaves like the plain packer instead of silently producing transparent black:
365
+ * `packOklchBufferToUint32Dithered(buf, off)` === `packOklchBufferToUint32(buf, off)`.
366
+ *
367
+ * @param {Float32Array} buf - Buffer containing the OKLCH triplet
368
+ * @param {number} offset - Start index in the buffer
369
+ * @param {number} [alpha=1.0] - Alpha [0, 1]; values outside the range are clamped
370
+ * @param {number} [noise01=0.5] - Dither threshold offset in `[0, 1)`; 0.5 = no dither
371
+ * @returns {number} 32-bit unsigned integer (little-endian RGBA byte order)
372
+ */
373
+ export const packOklchBufferToUint32Dithered = (buf, offset, alpha = 1.0, noise01 = 0.5) => {
374
+ oklchToLinearSrgbClamped(buf[offset], buf[offset + 1], buf[offset + 2], _scratchRgb);
375
+ const cr = _scratchRgb[0], cg = _scratchRgb[1], cb = _scratchRgb[2];
376
+ // Gamma-encode (mirrors linearToSrgbByte but without the final *255-round step)
377
+ const encR = srgbEncode(cr);
378
+ const encG = srgbEncode(cg);
379
+ const encB = srgbEncode(cb);
380
+ // Shared noise offset per pixel; floor via `| 0` (safe for non-negative values).
381
+ let r8 = (encR * 255 + noise01) | 0; if (r8 > 255) r8 = 255;
382
+ let g8 = (encG * 255 + noise01) | 0; if (g8 > 255) g8 = 255;
383
+ let b8 = (encB * 255 + noise01) | 0; if (b8 > 255) b8 = 255;
384
+ const a8 = alpha <= 0 ? 0 : (alpha >= 1 ? 255 : (alpha * 255 + 0.5) | 0);
385
+ return ((a8 << 24) | (b8 << 16) | (g8 << 8) | r8) >>> 0;
386
+ };
387
+
207
388
  /**
208
389
  * Batch sibling of {@link packOklchBufferToUint32}. Packs `n` OKLCH triplets
209
390
  * (stride 3, from `src`) into `n` consecutive Uint32 pixels (stride 1, into
@@ -250,6 +431,74 @@ export const packOklchBufferToUint32IntoN = (src, offSrc, dst, offDst, n, alpha
250
431
  }
251
432
  };
252
433
 
434
+ /**
435
+ * Batch dithered sibling of {@link packOklchBufferToUint32IntoN}. Packs `n`
436
+ * OKLCH triplets into `n` Uint32 pixels while walking a 64x64 blue-noise tile
437
+ * in row-major order, applying a per-pixel luminance-patterned threshold
438
+ * dither (same noise value across R/G/B; alpha undithered).
439
+ *
440
+ * Pixel `i` in the batch is treated as destination position `(x0 + col, y0 + row)`,
441
+ * where `col = i % rowWidth` and `row = (i / rowWidth) | 0`. The tile is indexed
442
+ * torus-style: `tile[((y & 63) << 6) | (x & 63)]`, so any `x0`/`y0`/`rowWidth`
443
+ * combination is safe — the tile just wraps.
444
+ *
445
+ * The dither is applied in gamma-encoded space so it perturbs perceptual (not
446
+ * linear) intensity — visually correct on 8-bit sRGB displays.
447
+ *
448
+ * **On useLut:** the non-dithered batch (`packOklchBufferToUint32IntoN`) has a
449
+ * `useLut` flag that swaps the exact `pow()` transfer for the 4k LUT. That LUT
450
+ * stores *rounded bytes*, which discards the sub-integer information the
451
+ * dither threshold needs. Enabling it here would silently degrade dither to
452
+ * plain rounding. A future v1.6 candidate is a companion `Float32Array` LUT
453
+ * of encoded floats (~16 KB) that would restore this axis; deferred to keep
454
+ * v1.5 surface tight. This function therefore uses the exact encoder only.
455
+ *
456
+ * Zero allocations; the tile-walk state is a pair of integer locals, no modulo
457
+ * or division in the hot loop.
458
+ *
459
+ * @param {Float32Array} src - Source OKLCH buffer (stride 3)
460
+ * @param {number} offSrc - Base offset of the first triplet in src
461
+ * @param {Uint32Array} dst - Destination packed-color buffer (stride 1)
462
+ * @param {number} offDst - Base offset of the first packed color in dst
463
+ * @param {number} n - Number of colors to pack (n <= 0 is a no-op)
464
+ * @param {number} alpha - Shared alpha [0, 1] for all n colors
465
+ * @param {Uint8Array} noiseTile - 64x64 blue-noise tile (from {@link getBlueNoise64})
466
+ * @param {number} x0 - Starting destination column
467
+ * @param {number} y0 - Starting destination row
468
+ * @param {number} rowWidth - Pixels per destination row (wrap point for column)
469
+ * @returns {void}
470
+ */
471
+ export const packOklchBufferToUint32IntoNDithered = (
472
+ src, offSrc, dst, offDst, n, alpha, noiseTile, x0, y0, rowWidth
473
+ ) => {
474
+ if (n <= 0) return;
475
+ const a8 = alpha <= 0 ? 0 : (alpha >= 1 ? 255 : (alpha * 255 + 0.5) | 0);
476
+ const aHi = a8 << 24;
477
+ const NOISE_SCALE = 1 / 256;
478
+ const startCol = x0 | 0;
479
+ const wrapAt = startCol + (rowWidth | 0);
480
+ let px = startCol, py = y0 | 0;
481
+
482
+ for (let i = 0; i < n; i++) {
483
+ const io = offSrc + i * 3;
484
+ oklchToLinearSrgbClamped(src[io], src[io + 1], src[io + 2], _scratchRgb);
485
+ // Blue-noise sample, torus-indexed
486
+ const noise01 = noiseTile[((py & 63) << 6) | (px & 63)] * NOISE_SCALE;
487
+ // Exact gamma encode; shared noise offset across R/G/B (luminance dither)
488
+ const cr = _scratchRgb[0], cg = _scratchRgb[1], cb = _scratchRgb[2];
489
+ const encR = srgbEncode(cr);
490
+ const encG = srgbEncode(cg);
491
+ const encB = srgbEncode(cb);
492
+ let r8 = (encR * 255 + noise01) | 0; if (r8 > 255) r8 = 255;
493
+ let g8 = (encG * 255 + noise01) | 0; if (g8 > 255) g8 = 255;
494
+ let b8 = (encB * 255 + noise01) | 0; if (b8 > 255) b8 = 255;
495
+ dst[offDst + i] = (aHi | (b8 << 16) | (g8 << 8) | r8) >>> 0;
496
+ // Advance pixel position; wrap column at row edge
497
+ px++;
498
+ if (px === wrapAt) { px = startCol; py++; }
499
+ }
500
+ };
501
+
253
502
  /**
254
503
  * Internal helper: OKLCH -> linear Display P3 with hard clamp to [0, 1].
255
504
  * Mirrors oklchToLinearSrgbClamped but targets the wider P3 gamut.
@@ -307,6 +556,52 @@ export const packOklchBufferToUint32P3Fast = (buf, offset, alpha = 1.0) => {
307
556
  return ((a8 << 24) | (b8 << 16) | (g8 << 8) | r8) >>> 0;
308
557
  };
309
558
 
559
+ /**
560
+ * Batch sibling of {@link packOklchBufferToUint32P3}. Packs `n` OKLCH triplets
561
+ * (stride 3) into `n` consecutive Uint32 pixels encoded for **Display P3**.
562
+ *
563
+ * With `useLut=false` the output is bit-for-bit identical to calling
564
+ * {@link packOklchBufferToUint32P3} n times. With `useLut=true` it uses the
565
+ * shared 4k transfer LUT (the P3 transfer function is identical to sRGB per
566
+ * IEC 61966-2-1, so no separate table is allocated). Near-exact within ~1 LSB
567
+ * at close to fast-packer throughput; the branch is hoisted out of the loop
568
+ * so each variant stays monomorphic.
569
+ *
570
+ * @param {Float32Array} src - Source OKLCH buffer (stride 3)
571
+ * @param {number} offSrc - Base offset of the first triplet in src
572
+ * @param {Uint32Array} dst - Destination packed-color buffer (stride 1)
573
+ * @param {number} offDst - Base offset of the first packed color in dst
574
+ * @param {number} n - Number of colors to pack (n <= 0 is a no-op)
575
+ * @param {number} [alpha=1.0] - Shared alpha for all n colors
576
+ * @param {boolean} [useLut=false] - Opt in to the 4k transfer LUT (near-exact, faster)
577
+ * @returns {void}
578
+ */
579
+ export const packOklchBufferToUint32P3IntoN = (src, offSrc, dst, offDst, n, alpha = 1.0, useLut = false) => {
580
+ if (n <= 0) return;
581
+ const a8 = alpha <= 0 ? 0 : (alpha >= 1 ? 255 : (alpha * 255 + 0.5) | 0);
582
+ const aHi = a8 << 24;
583
+
584
+ if (useLut) {
585
+ for (let i = 0; i < n; i++) {
586
+ const io = offSrc + i * 3;
587
+ oklchToLinearP3Clamped(src[io], src[io + 1], src[io + 2], _scratchRgbP3);
588
+ const r8 = linearToSrgbByteLut(_scratchRgbP3[0]);
589
+ const g8 = linearToSrgbByteLut(_scratchRgbP3[1]);
590
+ const b8 = linearToSrgbByteLut(_scratchRgbP3[2]);
591
+ dst[offDst + i] = (aHi | (b8 << 16) | (g8 << 8) | r8) >>> 0;
592
+ }
593
+ } else {
594
+ for (let i = 0; i < n; i++) {
595
+ const io = offSrc + i * 3;
596
+ oklchToLinearP3Clamped(src[io], src[io + 1], src[io + 2], _scratchRgbP3);
597
+ const r8 = linearToSrgbByte(_scratchRgbP3[0]);
598
+ const g8 = linearToSrgbByte(_scratchRgbP3[1]);
599
+ const b8 = linearToSrgbByte(_scratchRgbP3[2]);
600
+ dst[offDst + i] = (aHi | (b8 << 16) | (g8 << 8) | r8) >>> 0;
601
+ }
602
+ }
603
+ };
604
+
310
605
  /**
311
606
  * Zero-GC LUT sampler. Looks up a baked gradient color by `t` in [0, 1].
312
607
  *