@zakkster/lite-color-engine 1.1.0 → 1.2.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/convert.js CHANGED
@@ -1,12 +1,14 @@
1
1
  const RAD_TO_DEG = 180 / Math.PI;
2
2
 
3
3
  /**
4
- * IEC 61966-2-1 sRGB linearization.
5
- * @param {number} c - Normalized non-linear sRGB channel in [0, 1]
4
+ * IEC 61966-2-1 sRGB / Display-P3 linearization (EOTF inverse).
5
+ * Both color spaces use the same transfer function.
6
+ *
7
+ * @param {number} c - Normalized non-linear channel in [0, 1]
6
8
  * @returns {number} Linear-light value in [0, 1]
7
9
  * @internal
8
10
  */
9
- const linearize = (c) => (c <= 0.04045 ? c / 12.92 : Math.pow((c + 0.055) / 1.055, 2.4));
11
+ export const linearize = (c) => (c <= 0.04045 ? c / 12.92 : Math.pow((c + 0.055) / 1.055, 2.4));
10
12
 
11
13
  /**
12
14
  * Converts standard sRGB (0-255 byte channels) into a flat OKLCH buffer.
@@ -15,7 +17,7 @@ const linearize = (c) => (c <= 0.04045 ? c / 12.92 : Math.pow((c + 0.055) / 1.05
15
17
  * matrix multiply) and hard-clamps output lightness to [0, 1]. Hue is
16
18
  * canonicalized to [0, 360).
17
19
  *
18
- * Algorithm: Björn Ottosson's OKLab (2020) sRGB -> linear sRGB -> LMS ->
20
+ * Algorithm: Bjorn Ottosson's OKLab (2020) - sRGB -> linear sRGB -> LMS ->
19
21
  * non-linear LMS (cube root) -> OKLab -> polar OKLCH.
20
22
  *
21
23
  * @param {number} r - Red channel [0, 255]
@@ -31,7 +33,7 @@ export const sRgbToOklchBuffer = (r, g, b, outBuf, outOffset) => {
31
33
  const g_lin = linearize(g / 255);
32
34
  const b_lin = linearize(b / 255);
33
35
 
34
- // 2. Linear sRGB -> linear LMS.
36
+ // 2. Linear sRGB -> linear LMS (combined matrix from OKLab reference).
35
37
  const lms_l = 0.4122214708 * r_lin + 0.5363325363 * g_lin + 0.0514459929 * b_lin;
36
38
  const lms_m = 0.2119034982 * r_lin + 0.6806995451 * g_lin + 0.1073969566 * b_lin;
37
39
  const lms_s = 0.0883024619 * r_lin + 0.2817188376 * g_lin + 0.6299787005 * b_lin;
@@ -56,3 +58,123 @@ export const sRgbToOklchBuffer = (r, g, b, outBuf, outOffset) => {
56
58
  outBuf[outOffset + 1] = chroma;
57
59
  outBuf[outOffset + 2] = hue;
58
60
  };
61
+
62
+ // -----------------------------------------------------------------------------
63
+ // Display P3 support (Session 2)
64
+ // High-precision matrices sourced from CSS Color Module Level 4 + OKLab reference
65
+ // implementation by Bjorn Ottosson. Combined for minimal code size and speed.
66
+ // -----------------------------------------------------------------------------
67
+
68
+ /**
69
+ * High-precision linear Display P3 (D65) -> XYZ (D65)
70
+ */
71
+ const P3_TO_XYZ = [
72
+ [0.4865709486482162, 0.26566769316909306, 0.1982172852343625],
73
+ [0.2289745640697488, 0.6917385218365062, 0.079286914093745],
74
+ [0.0000000000000000, 0.0451133816152514, 1.043944368900976]
75
+ ];
76
+
77
+ /**
78
+ * High-precision XYZ (D65) -> LMS (OKLab cone fundamentals)
79
+ */
80
+ const XYZ_TO_LMS = [
81
+ [ 0.8190224379967030, 0.3619062600528904, -0.1288737815209879],
82
+ [ 0.0329836671980271, 0.9292868615863434, 0.0361446681699989],
83
+ [ 0.0481772077560169, 0.2642395317527308, 0.6335478284694309]
84
+ ];
85
+
86
+ /**
87
+ * Converts Display P3 (0-255 byte channels) into a flat OKLCH buffer.
88
+ *
89
+ * Uses the same OKLab pipeline as sRGB but with Display P3 primaries.
90
+ * This allows correct representation of colors outside the sRGB gamut
91
+ * (higher possible chroma values).
92
+ *
93
+ * Defends against negative cube roots and clamps lightness to [0, 1].
94
+ *
95
+ * @param {number} r - Red channel in Display P3 [0, 255]
96
+ * @param {number} g - Green channel in Display P3 [0, 255]
97
+ * @param {number} b - Blue channel in Display P3 [0, 255]
98
+ * @param {Float32Array} outBuf - Destination buffer
99
+ * @param {number} outOffset - Start index for the L, C, H triplet
100
+ * @returns {void}
101
+ */
102
+ export const displayP3ToOklchBuffer = (r, g, b, outBuf, outOffset) => {
103
+ // 1. Normalize and linearize (same EOTF as sRGB)
104
+ const r_lin = linearize(r / 255);
105
+ const g_lin = linearize(g / 255);
106
+ const b_lin = linearize(b / 255);
107
+
108
+ // 2. Linear Display P3 -> XYZ
109
+ const x = P3_TO_XYZ[0][0] * r_lin + P3_TO_XYZ[0][1] * g_lin + P3_TO_XYZ[0][2] * b_lin;
110
+ const y = P3_TO_XYZ[1][0] * r_lin + P3_TO_XYZ[1][1] * g_lin + P3_TO_XYZ[1][2] * b_lin;
111
+ const z = P3_TO_XYZ[2][0] * r_lin + P3_TO_XYZ[2][1] * g_lin + P3_TO_XYZ[2][2] * b_lin;
112
+
113
+ // 3. XYZ -> linear LMS (OKLab)
114
+ const lms_l = XYZ_TO_LMS[0][0] * x + XYZ_TO_LMS[0][1] * y + XYZ_TO_LMS[0][2] * z;
115
+ const lms_m = XYZ_TO_LMS[1][0] * x + XYZ_TO_LMS[1][1] * y + XYZ_TO_LMS[1][2] * z;
116
+ const lms_s = XYZ_TO_LMS[2][0] * x + XYZ_TO_LMS[2][1] * y + XYZ_TO_LMS[2][2] * z;
117
+
118
+ // 4. Non-linear LMS (cube root) - same defense as sRGB path
119
+ const l_ = Math.cbrt(lms_l < 0 ? 0 : lms_l);
120
+ const m_ = Math.cbrt(lms_m < 0 ? 0 : lms_m);
121
+ const s_ = Math.cbrt(lms_s < 0 ? 0 : lms_s);
122
+
123
+ // 5. Non-linear LMS -> OKLab (identical coefficients to sRGB path)
124
+ const lab_l = 0.2104542553 * l_ + 0.7936177850 * m_ - 0.0040720468 * s_;
125
+ const lab_a = 1.9779984951 * l_ - 2.4285922050 * m_ + 0.4505937099 * s_;
126
+ const lab_b = 0.0259040371 * l_ + 0.7827717662 * m_ - 0.8086757660 * s_;
127
+
128
+ // 6. OKLab -> OKLCH (polar) - identical to sRGB path
129
+ const chroma = Math.sqrt(lab_a * lab_a + lab_b * lab_b);
130
+ let hue = Math.atan2(lab_b, lab_a) * RAD_TO_DEG;
131
+ if (hue < 0) hue += 360;
132
+
133
+ // 7. Write with lightness clamp
134
+ outBuf[outOffset] = lab_l < 0 ? 0 : (lab_l > 1 ? 1 : lab_l);
135
+ outBuf[outOffset + 1] = chroma;
136
+ outBuf[outOffset + 2] = hue;
137
+ };
138
+
139
+ /**
140
+ * Converts an OKLCH triplet to linear-light Display P3 RGB.
141
+ * Writes [r, g, b] linear values (can be outside [0,1] for out-of-gamut colors)
142
+ * into the provided `out` Float32Array (length >= 3).
143
+ *
144
+ * This is the inverse of the forward path above. Used by the P3 packers and
145
+ * available for custom P3 gamut-mapping. Output channels may fall outside
146
+ * [0, 1] for colors beyond the P3 gamut; clamp or map before encoding.
147
+ *
148
+ * @param {number} L - Lightness [0, 1]
149
+ * @param {number} C - Chroma [0, +inf)
150
+ * @param {number} H - Hue [0, 360)
151
+ * @param {Float32Array} out - Destination (length >= 3); receives linear [r, g, b]
152
+ * @returns {void}
153
+ */
154
+ export const oklchToLinearP3 = (L, C, H, out) => {
155
+ const hRad = (H * Math.PI) / 180;
156
+ const a = C * Math.cos(hRad);
157
+ const b = C * Math.sin(hRad);
158
+
159
+ // OKLab -> non-linear LMS (inverse of the OKLab matrix)
160
+ const l_ = L + 0.3963377774 * a + 0.2158037573 * b;
161
+ const m_ = L - 0.1055613458 * a - 0.0638541728 * b;
162
+ const s_ = L - 0.0894841775 * a - 1.2914855480 * b;
163
+
164
+ // Non-linear LMS -> linear LMS (cube)
165
+ const lms_l = l_ * l_ * l_;
166
+ const lms_m = m_ * m_ * m_;
167
+ const lms_s = s_ * s_ * s_;
168
+
169
+ // Linear LMS -> XYZ (inverse of XYZ_TO_LMS)
170
+ // Using the analytical inverse for precision
171
+ const x = 1.2268798758459243 * lms_l - 0.5578149944602171 * lms_m + 0.2813910456659647 * lms_s;
172
+ const y = -0.0405757452148008 * lms_l + 1.1122868032803170 * lms_m - 0.0717110580655164 * lms_s;
173
+ const z = -0.0763729366746601 * lms_l - 0.4214933324022432 * lms_m + 1.5869240198367816 * lms_s;
174
+
175
+ // XYZ -> linear Display P3 (inverse of P3_TO_XYZ)
176
+ // Using the analytical inverse matrix
177
+ out[0] = 2.4934969119414263 * x - 0.9313836179191239 * y - 0.4027107844507168 * z;
178
+ out[1] = -0.8294889695615749 * x + 1.7626640603183463 * y + 0.0236246858419436 * z;
179
+ out[2] = 0.0358458302437845 * x - 0.0761723892680418 * y + 0.9568845240076702 * z;
180
+ };
package/src/runtime.js CHANGED
@@ -1,4 +1,5 @@
1
1
  import { lerp, lerpAngle } from '@zakkster/lite-lerp';
2
+ import { oklchToLinearP3 } from './convert.js';
2
3
 
3
4
  const DEG_TO_RAD = Math.PI / 180;
4
5
 
@@ -7,7 +8,7 @@ const DEG_TO_RAD = Math.PI / 180;
7
8
  *
8
9
  * Hue is canonicalized to [0, 360) via `lerpAngle` (shortest-path) so gradients
9
10
  * never wrap the long way around the color wheel. Lightness is hard-clamped
10
- * to [0, 1] and chroma to [0, +∞).
11
+ * to [0, 1] and chroma to [0, +inf).
11
12
  *
12
13
  * Source and destination may be the same buffer at different offsets.
13
14
  *
@@ -60,7 +61,6 @@ const oklchToLinearSrgbClamped = (l, c, h, outRgb) => {
60
61
  const b = -0.0041960863 * lms_l - 0.7034186147 * lms_m + 1.7076147010 * lms_s;
61
62
 
62
63
  // Hard gamut clamp (fast path; replaces the expensive MINDE algorithm).
63
- // Chroma reduction (Lab-space gamut mapping) is planned for v1.1.
64
64
  outRgb[0] = r < 0 ? 0 : (r > 1 ? 1 : r);
65
65
  outRgb[1] = g < 0 ? 0 : (g > 1 ? 1 : g);
66
66
  outRgb[2] = b < 0 ? 0 : (b > 1 ? 1 : b);
@@ -68,11 +68,14 @@ const oklchToLinearSrgbClamped = (l, c, h, outRgb) => {
68
68
 
69
69
  // Module-level scratch (zero-GC: single allocation at module load).
70
70
  const _scratchRgb = new Float32Array(3);
71
+ const _scratchRgbP3 = new Float32Array(3);
71
72
 
72
73
  /**
73
74
  * Encodes a linear-light channel (already clamped to [0, 1]) to an sRGB
74
75
  * 8-bit byte using the proper IEC 61966-2-1 transfer function. Round-tripping
75
76
  * sRGB(255-bytes) -> OKLCH -> here recovers the original byte exactly.
77
+ *
78
+ * This is also the correct transfer function for Display P3.
76
79
  * @internal
77
80
  */
78
81
  const linearToSrgbByte = (c) => {
@@ -84,7 +87,7 @@ const linearToSrgbByte = (c) => {
84
87
 
85
88
  /**
86
89
  * Converts an OKLCH buffer triplet to a 32-bit unsigned integer in
87
- * little-endian RGBA byte order the format consumed directly by a
90
+ * little-endian RGBA byte order - the format consumed directly by a
88
91
  * `Uint32Array` view of `Canvas ImageData`.
89
92
  *
90
93
  * Uses the proper sRGB transfer function (`pow(c, 1/2.4)` branch). For a
@@ -132,10 +135,67 @@ export const packOklchBufferToUint32Fast = (buf, offset, alpha = 1.0) => {
132
135
  return ((a8 << 24) | (b8 << 16) | (g8 << 8) | r8) >>> 0;
133
136
  };
134
137
 
138
+ /**
139
+ * Internal helper: OKLCH -> linear Display P3 with hard clamp to [0,1].
140
+ * Mirrors the structure of oklchToLinearSrgbClamped but targets the wider P3 gamut.
141
+ * @internal
142
+ */
143
+ const oklchToLinearP3Clamped = (l, c, h, outRgb) => {
144
+ oklchToLinearP3(l, c, h, outRgb);
145
+ outRgb[0] = outRgb[0] < 0 ? 0 : (outRgb[0] > 1 ? 1 : outRgb[0]);
146
+ outRgb[1] = outRgb[1] < 0 ? 0 : (outRgb[1] > 1 ? 1 : outRgb[1]);
147
+ outRgb[2] = outRgb[2] < 0 ? 0 : (outRgb[2] > 1 ? 1 : outRgb[2]);
148
+ };
149
+
150
+ /**
151
+ * Converts an OKLCH buffer triplet to a 32-bit unsigned integer in
152
+ * little-endian RGBA byte order, encoded for **Display P3** color space.
153
+ *
154
+ * Use this when your canvas context was created with `{ colorSpace: 'display-p3' }`.
155
+ * Colors that are out of sRGB but inside P3 will be preserved with higher
156
+ * saturation than the regular sRGB packers.
157
+ *
158
+ * The transfer function is identical to sRGB (IEC 61966-2-1).
159
+ *
160
+ * @param {Float32Array} buf - Buffer containing the OKLCH triplet
161
+ * @param {number} offset - Start index in the buffer
162
+ * @param {number} [alpha=1.0] - Alpha [0, 1]; values outside the range are clamped
163
+ * @returns {number} 32-bit unsigned integer (little-endian RGBA byte order)
164
+ */
165
+ export const packOklchBufferToUint32P3 = (buf, offset, alpha = 1.0) => {
166
+ oklchToLinearP3Clamped(buf[offset], buf[offset + 1], buf[offset + 2], _scratchRgbP3);
167
+ const r8 = linearToSrgbByte(_scratchRgbP3[0]);
168
+ const g8 = linearToSrgbByte(_scratchRgbP3[1]);
169
+ const b8 = linearToSrgbByte(_scratchRgbP3[2]);
170
+ const a8 = alpha <= 0 ? 0 : (alpha >= 1 ? 255 : (alpha * 255 + 0.5) | 0);
171
+ return ((a8 << 24) | (b8 << 16) | (g8 << 8) | r8) >>> 0;
172
+ };
173
+
174
+ /**
175
+ * Faster, less accurate sibling of {@link packOklchBufferToUint32P3}.
176
+ *
177
+ * Uses `Math.sqrt` approximation for the transfer function (same trade-off as
178
+ * the sRGB fast path). Useful for high-volume particle systems or when
179
+ * baking many P3 gradients where absolute round-trip precision is not critical.
180
+ *
181
+ * @param {Float32Array} buf - Buffer containing the OKLCH triplet
182
+ * @param {number} offset - Start index in the buffer
183
+ * @param {number} [alpha=1.0] - Alpha [0, 1]; values outside the range are clamped
184
+ * @returns {number} 32-bit unsigned integer (little-endian RGBA byte order)
185
+ */
186
+ export const packOklchBufferToUint32P3Fast = (buf, offset, alpha = 1.0) => {
187
+ oklchToLinearP3Clamped(buf[offset], buf[offset + 1], buf[offset + 2], _scratchRgbP3);
188
+ const r8 = (Math.sqrt(_scratchRgbP3[0]) * 255) | 0;
189
+ const g8 = (Math.sqrt(_scratchRgbP3[1]) * 255) | 0;
190
+ const b8 = (Math.sqrt(_scratchRgbP3[2]) * 255) | 0;
191
+ const a8 = alpha <= 0 ? 0 : (alpha >= 1 ? 255 : (alpha * 255) | 0);
192
+ return ((a8 << 24) | (b8 << 16) | (g8 << 8) | r8) >>> 0;
193
+ };
194
+
135
195
  /**
136
196
  * Zero-GC LUT sampler. Looks up a baked gradient color by `t` in [0, 1].
137
197
  *
138
- * Performs an inline clamp + bitwise-truncated index no allocations, no
198
+ * Performs an inline clamp + bitwise-truncated index - no allocations, no
139
199
  * function calls, no bounds checks beyond the single comparison pair. Use
140
200
  * inside particle systems, shader-style canvas loops, etc.
141
201
  *