@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/CHANGELOG.md +202 -0
- package/README.md +465 -68
- package/index.d.ts +110 -5
- package/index.js +8 -2
- package/llms.txt +38 -7
- package/package.json +4 -3
- package/src/Gamut.d.ts +18 -0
- package/src/Gamut.js +33 -0
- package/src/authoring.js +60 -3
- package/src/lut.js +37 -16
- package/src/runtime.js +297 -2
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 =
|
|
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 =
|
|
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
|
*
|