@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/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
+ };