@zakkster/lite-color-engine 1.0.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/LICENSE.md +19 -0
- package/README.md +337 -0
- package/index.d.ts +202 -0
- package/index.js +19 -0
- package/llms.txt +201 -0
- package/package.json +64 -0
- package/src/authoring.js +262 -0
- package/src/convert.js +58 -0
- package/src/lut.js +68 -0
- package/src/runtime.js +149 -0
package/src/runtime.js
ADDED
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
import { lerp, lerpAngle } from '@zakkster/lite-lerp';
|
|
2
|
+
|
|
3
|
+
const DEG_TO_RAD = Math.PI / 180;
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Zero-GC, cache-friendly OKLCH buffer interpolation.
|
|
7
|
+
*
|
|
8
|
+
* Hue is canonicalized to [0, 360) via `lerpAngle` (shortest-path) so gradients
|
|
9
|
+
* never wrap the long way around the color wheel. Lightness is hard-clamped
|
|
10
|
+
* to [0, 1] and chroma to [0, +∞).
|
|
11
|
+
*
|
|
12
|
+
* Source and destination may be the same buffer at different offsets.
|
|
13
|
+
*
|
|
14
|
+
* @param {Float32Array} bufA - Source buffer A (must contain L, C, H at offsetA)
|
|
15
|
+
* @param {number} offsetA - Start index of color A in bufA
|
|
16
|
+
* @param {Float32Array} bufB - Source buffer B (must contain L, C, H at offsetB)
|
|
17
|
+
* @param {number} offsetB - Start index of color B in bufB
|
|
18
|
+
* @param {number} t - Interpolation factor; values outside [0, 1] extrapolate then clamp
|
|
19
|
+
* @param {Float32Array} outBuf - Destination buffer
|
|
20
|
+
* @param {number} outOffset - Start index of output L, C, H
|
|
21
|
+
* @returns {void}
|
|
22
|
+
*/
|
|
23
|
+
export const lerpOklchBuffer = (bufA, offsetA, bufB, offsetB, t, outBuf, outOffset) => {
|
|
24
|
+
// Lightness [0, 1]
|
|
25
|
+
const l = lerp(bufA[offsetA], bufB[offsetB], t);
|
|
26
|
+
outBuf[outOffset] = l < 0 ? 0 : (l > 1 ? 1 : l);
|
|
27
|
+
|
|
28
|
+
// Chroma (clamp >= 0)
|
|
29
|
+
const c = lerp(bufA[offsetA + 1], bufB[offsetB + 1], t);
|
|
30
|
+
outBuf[outOffset + 1] = c < 0 ? 0 : c;
|
|
31
|
+
|
|
32
|
+
// Hue (shortest path, canonicalized)
|
|
33
|
+
let h = lerpAngle(bufA[offsetA + 2], bufB[offsetB + 2], t);
|
|
34
|
+
h = h % 360;
|
|
35
|
+
outBuf[outOffset + 2] = h < 0 ? h + 360 : h;
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Internal: shared OKLCH -> linear sRGB kernel.
|
|
40
|
+
* Inlined into both pack variants for V8 to monomorphize.
|
|
41
|
+
*
|
|
42
|
+
* Returns a 3-tuple via `outRgb` (length 3). Strictly clamped to [0, 1].
|
|
43
|
+
* @internal
|
|
44
|
+
*/
|
|
45
|
+
const oklchToLinearSrgbClamped = (l, c, h, outRgb) => {
|
|
46
|
+
const hRad = h * DEG_TO_RAD;
|
|
47
|
+
const a_lab = c * Math.cos(hRad);
|
|
48
|
+
const b_lab = c * Math.sin(hRad);
|
|
49
|
+
|
|
50
|
+
const l_ = l + 0.3963377774 * a_lab + 0.2158037573 * b_lab;
|
|
51
|
+
const m_ = l - 0.1055613458 * a_lab - 0.0638541728 * b_lab;
|
|
52
|
+
const s_ = l - 0.0894841775 * a_lab - 1.2914855480 * b_lab;
|
|
53
|
+
|
|
54
|
+
const lms_l = l_ * l_ * l_;
|
|
55
|
+
const lms_m = m_ * m_ * m_;
|
|
56
|
+
const lms_s = s_ * s_ * s_;
|
|
57
|
+
|
|
58
|
+
const r = 4.0767416621 * lms_l - 3.3077115913 * lms_m + 0.2309699292 * lms_s;
|
|
59
|
+
const g = -1.2684380046 * lms_l + 2.6097574011 * lms_m - 0.3413193965 * lms_s;
|
|
60
|
+
const b = -0.0041960863 * lms_l - 0.7034186147 * lms_m + 1.7076147010 * lms_s;
|
|
61
|
+
|
|
62
|
+
// Hard gamut clamp (fast path; replaces the expensive MINDE algorithm).
|
|
63
|
+
// Chroma reduction (Lab-space gamut mapping) is planned for v1.1.
|
|
64
|
+
outRgb[0] = r < 0 ? 0 : (r > 1 ? 1 : r);
|
|
65
|
+
outRgb[1] = g < 0 ? 0 : (g > 1 ? 1 : g);
|
|
66
|
+
outRgb[2] = b < 0 ? 0 : (b > 1 ? 1 : b);
|
|
67
|
+
};
|
|
68
|
+
|
|
69
|
+
// Module-level scratch (zero-GC: single allocation at module load).
|
|
70
|
+
const _scratchRgb = new Float32Array(3);
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Encodes a linear-light channel (already clamped to [0, 1]) to an sRGB
|
|
74
|
+
* 8-bit byte using the proper IEC 61966-2-1 transfer function. Round-tripping
|
|
75
|
+
* sRGB(255-bytes) -> OKLCH -> here recovers the original byte exactly.
|
|
76
|
+
* @internal
|
|
77
|
+
*/
|
|
78
|
+
const linearToSrgbByte = (c) => {
|
|
79
|
+
if (c <= 0) return 0;
|
|
80
|
+
if (c >= 1) return 255;
|
|
81
|
+
const enc = c <= 0.0031308 ? 12.92 * c : 1.055 * Math.pow(c, 1 / 2.4) - 0.055;
|
|
82
|
+
return (enc * 255 + 0.5) | 0;
|
|
83
|
+
};
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* Converts an OKLCH buffer triplet to a 32-bit unsigned integer in
|
|
87
|
+
* little-endian RGBA byte order — the format consumed directly by a
|
|
88
|
+
* `Uint32Array` view of `Canvas ImageData`.
|
|
89
|
+
*
|
|
90
|
+
* Uses the proper sRGB transfer function (`pow(c, 1/2.4)` branch). For a
|
|
91
|
+
* `Math.sqrt`-approximated 2x-faster variant (with a ~10/255 mid-tone
|
|
92
|
+
* round-trip error) see {@link packOklchBufferToUint32Fast}.
|
|
93
|
+
*
|
|
94
|
+
* @param {Float32Array} buf - Buffer containing the OKLCH triplet
|
|
95
|
+
* @param {number} offset - Start index in the buffer
|
|
96
|
+
* @param {number} [alpha=1.0] - Alpha [0, 1]; values outside the range are clamped
|
|
97
|
+
* @returns {number} 32-bit unsigned integer (`>>> 0` so the high bit is non-negative)
|
|
98
|
+
*/
|
|
99
|
+
export const packOklchBufferToUint32 = (buf, offset, alpha = 1.0) => {
|
|
100
|
+
oklchToLinearSrgbClamped(buf[offset], buf[offset + 1], buf[offset + 2], _scratchRgb);
|
|
101
|
+
const r8 = linearToSrgbByte(_scratchRgb[0]);
|
|
102
|
+
const g8 = linearToSrgbByte(_scratchRgb[1]);
|
|
103
|
+
const b8 = linearToSrgbByte(_scratchRgb[2]);
|
|
104
|
+
const a8 = alpha <= 0 ? 0 : (alpha >= 1 ? 255 : (alpha * 255 + 0.5) | 0);
|
|
105
|
+
// Little-endian byte order in memory: [R, G, B, A].
|
|
106
|
+
return ((a8 << 24) | (b8 << 16) | (g8 << 8) | r8) >>> 0;
|
|
107
|
+
};
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* Faster, less accurate sibling of {@link packOklchBufferToUint32}.
|
|
111
|
+
*
|
|
112
|
+
* Substitutes `Math.sqrt(c)` for the proper sRGB transfer `pow(c, 1/2.4)`.
|
|
113
|
+
* Roughly 2x throughput on V8 in tight loops. The visual cost is non-trivial:
|
|
114
|
+
* a pure mid-gray (#808080) round-trips to about #767676 (a ~10/255 darkening),
|
|
115
|
+
* and warm midtones (browns, golds) shift toward black.
|
|
116
|
+
*
|
|
117
|
+
* Use this when you are baking thousands of particle colors per frame and the
|
|
118
|
+
* end pixel will be alpha-blended on top of arbitrary content. **Avoid** when
|
|
119
|
+
* you need the OKLCH input to round-trip back to its source sRGB.
|
|
120
|
+
*
|
|
121
|
+
* @param {Float32Array} buf - Buffer containing the OKLCH triplet
|
|
122
|
+
* @param {number} offset - Start index in the buffer
|
|
123
|
+
* @param {number} [alpha=1.0] - Alpha [0, 1]; values outside the range are clamped
|
|
124
|
+
* @returns {number} 32-bit unsigned integer (little-endian RGBA byte order)
|
|
125
|
+
*/
|
|
126
|
+
export const packOklchBufferToUint32Fast = (buf, offset, alpha = 1.0) => {
|
|
127
|
+
oklchToLinearSrgbClamped(buf[offset], buf[offset + 1], buf[offset + 2], _scratchRgb);
|
|
128
|
+
const r8 = (Math.sqrt(_scratchRgb[0]) * 255) | 0;
|
|
129
|
+
const g8 = (Math.sqrt(_scratchRgb[1]) * 255) | 0;
|
|
130
|
+
const b8 = (Math.sqrt(_scratchRgb[2]) * 255) | 0;
|
|
131
|
+
const a8 = alpha <= 0 ? 0 : (alpha >= 1 ? 255 : (alpha * 255) | 0);
|
|
132
|
+
return ((a8 << 24) | (b8 << 16) | (g8 << 8) | r8) >>> 0;
|
|
133
|
+
};
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* Zero-GC LUT sampler. Looks up a baked gradient color by `t` in [0, 1].
|
|
137
|
+
*
|
|
138
|
+
* Performs an inline clamp + bitwise-truncated index — no allocations, no
|
|
139
|
+
* function calls, no bounds checks beyond the single comparison pair. Use
|
|
140
|
+
* inside particle systems, shader-style canvas loops, etc.
|
|
141
|
+
*
|
|
142
|
+
* @param {Uint32Array} lut - LUT produced by {@link bakeGradientToUint32}
|
|
143
|
+
* @param {number} t - Sample position in [0, 1]; values outside are clamped
|
|
144
|
+
* @returns {number} 32-bit packed color (little-endian RGBA)
|
|
145
|
+
*/
|
|
146
|
+
export const sampleColorLUT = (lut, t) => {
|
|
147
|
+
const tc = t < 0 ? 0 : (t > 1 ? 1 : t);
|
|
148
|
+
return lut[(tc * (lut.length - 1)) | 0];
|
|
149
|
+
};
|