@zakkster/lite-color-engine 1.3.0 → 1.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/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
  *