@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 +180 -0
- package/README.md +30 -1
- package/index.d.ts +91 -5
- package/index.js +5 -1
- package/llms.txt +34 -7
- package/package.json +4 -3
- package/src/Gamut.d.ts +18 -0
- package/src/Gamut.js +33 -0
- package/src/lut.js +37 -16
- package/src/runtime.js +297 -2
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
|
[](https://bundlephobia.com/result?p=@zakkster/lite-color-engine)
|
|
9
9
|
[](https://www.npmjs.com/package/@zakkster/lite-color-engine)
|
|
10
10
|
[](https://www.npmjs.com/package/@zakkster/lite-color-engine)
|
|
11
|
+
[](https://coveralls.io/github/PeshoVurtoleta/lite-color-engine?branch=main)
|
|
11
12
|

|
|
12
13
|

|
|
13
14
|

|
|
@@ -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
|
|
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
|
-
*
|
|
304
|
-
*
|
|
305
|
-
*
|
|
306
|
-
*
|
|
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
|
-
|
|
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.
|
|
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)`
|
|
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
|
|
29
|
-
-
|
|
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
|
-
- `
|
|
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
|
-
|
|
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.
|
|
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.
|
|
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
|
-
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
13
|
-
*
|
|
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
|
-
|
|
43
|
-
|
|
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
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
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 *
|
|
56
|
-
let index = scaledT | 0; // bitwise fast-floor for
|
|
57
|
-
if (index
|
|
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
|
-
|
|
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 =
|
|
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
|
*
|