@zakkster/lite-color-engine 1.1.0 → 1.3.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 +52 -100
- package/README.md +72 -337
- package/index.d.ts +113 -11
- package/index.js +11 -2
- package/llms.txt +44 -275
- package/package.json +4 -3
- package/src/authoring.js +47 -6
- package/src/convert.js +127 -5
- package/src/runtime.js +181 -6
package/src/authoring.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { sRgbToOklchBuffer } from './convert.js';
|
|
1
|
+
import { sRgbToOklchBuffer, displayP3ToOklchBuffer } from './convert.js';
|
|
2
2
|
|
|
3
3
|
const RAD_TO_DEG = 180 / Math.PI;
|
|
4
4
|
|
|
@@ -51,6 +51,8 @@ const hslToRgb = (h, s, l) => {
|
|
|
51
51
|
];
|
|
52
52
|
};
|
|
53
53
|
|
|
54
|
+
// (linearize lives in convert.js; the parsers below delegate color-space math there)
|
|
55
|
+
|
|
54
56
|
// ============================================================================
|
|
55
57
|
// 2. REGEX DICTIONARY (CSS Color Level 4 Permissive)
|
|
56
58
|
// ============================================================================
|
|
@@ -61,6 +63,7 @@ const OKLCH_REGEX = /^oklch\(\s*([\d.%]+)\s+([\d.%]+)\s+([\d.%a-z]+)(?:\s*\/\s*(
|
|
|
61
63
|
const OKLAB_REGEX = /^oklab\(\s*([\d.%]+)[,\s]+([\d.-]+)[,\s]+([\d.-]+)(?:[,\s/]+([\d.%]+))?\s*\)$/i;
|
|
62
64
|
const RGB_REGEX = /^rgba?\(\s*([\d.%]+)[,\s]+([\d.%]+)[,\s]+([\d.%]+)(?:(?:,|\s*\/\s*)\s*([\d.%]+))?\s*\)$/i;
|
|
63
65
|
const HSL_REGEX = /^hsla?\(\s*([\d.%a-z]+)[,\s]+([\d.%]+)[,\s]+([\d.%]+)(?:(?:,|\s*\/\s*)\s*([\d.%]+))?\s*\)$/i;
|
|
66
|
+
const DISPLAY_P3_REGEX = /^color\(\s*display-p3\s+([\d.%]+)\s+([\d.%]+)\s+([\d.%]+)(?:\s*\/\s*([\d.%]+))?\s*\)$/i;
|
|
64
67
|
|
|
65
68
|
// ============================================================================
|
|
66
69
|
// 3. NAMED COLORS FAST-PATH
|
|
@@ -77,7 +80,7 @@ const NAMED_COLORS = {
|
|
|
77
80
|
darkgray: '#a9a9a9', darkgreen: '#006400', darkgrey: '#a9a9a9', darkkhaki: '#bdb76b',
|
|
78
81
|
darkmagenta: '#8b008b', darkolivegreen: '#556b2f', darkorange: '#ff8c00', darkorchid: '#9932cc',
|
|
79
82
|
darkred: '#8b0000', darksalmon: '#e9967a', darkseagreen: '#8fbc8f', darkslateblue: '#483d8b',
|
|
80
|
-
darkslategray: '#2f4f4f',
|
|
83
|
+
darkslategray: '#2f4f4f', darkslateggrey: '#2f4f4f', darkturquoise: '#00ced1', darkviolet: '#9400d3',
|
|
81
84
|
deeppink: '#ff1493', deepskyblue: '#00bfff', dimgray: '#696969', dimgrey: '#696969',
|
|
82
85
|
dodgerblue: '#1e90ff', firebrick: '#b22222', floralwhite: '#fffaf0', forestgreen: '#228b22',
|
|
83
86
|
fuchsia: '#ff00ff', gainsboro: '#dcdcdc', ghostwhite: '#f8f8ff', gold: '#ffd700',
|
|
@@ -88,12 +91,12 @@ const NAMED_COLORS = {
|
|
|
88
91
|
lightcoral: '#f08080', lightcyan: '#e0ffff', lightgoldenrodyellow: '#fafad2', lightgray: '#d3d3d3',
|
|
89
92
|
lightgreen: '#90ee90', lightgrey: '#d3d3d3', lightpink: '#ffb6c1', lightsalmon: '#ffa07a',
|
|
90
93
|
lightseagreen: '#20b2aa', lightskyblue: '#87cefa', lightslategray: '#778899', lightslategrey: '#778899',
|
|
91
|
-
lightsteelblue: '#
|
|
94
|
+
lightsteelblue: '#b0e0e6', lightyellow: '#ffffe0', lime: '#00ff00', limegreen: '#32cd32',
|
|
92
95
|
linen: '#faf0e6', magenta: '#ff00ff', maroon: '#800000', mediumaquamarine: '#66cdaa',
|
|
93
96
|
mediumblue: '#0000cd', mediumorchid: '#ba55d3', mediumpurple: '#9370db', mediumseagreen: '#3cb371',
|
|
94
97
|
mediumslateblue: '#7b68ee', mediumspringgreen: '#00fa9a', mediumturquoise: '#48d1cc', mediumvioletred: '#c71585',
|
|
95
98
|
midnightblue: '#191970', mintcream: '#f5fffa', mistyrose: '#ffe4e1', moccasin: '#ffe4b5',
|
|
96
|
-
|
|
99
|
+
navajawhite: '#ffdead', navy: '#000080', oldlace: '#fdf5e6', olive: '#808000',
|
|
97
100
|
olivedrab: '#6b8e23', orange: '#ffa500', orangered: '#ff4500', orchid: '#da70d6',
|
|
98
101
|
palegoldenrod: '#eee8aa', palegreen: '#98fb98', paleturquoise: '#afeeee', palevioletred: '#db7093',
|
|
99
102
|
papayawhip: '#ffefd5', peachpuff: '#ffdab9', peru: '#cd853f', pink: '#ffc0cb',
|
|
@@ -137,7 +140,7 @@ export const parseHexToBuffer = (str, outBuf, offset) => {
|
|
|
137
140
|
};
|
|
138
141
|
|
|
139
142
|
/**
|
|
140
|
-
* Parses a CSS `oklch()` string directly into an OKLCH triplet
|
|
143
|
+
* Parses a CSS `oklch()` string directly into an OKLCH triplet - no color-space
|
|
141
144
|
* conversion needed.
|
|
142
145
|
*
|
|
143
146
|
* Accepts the modern slash-alpha form: `oklch(60% 0.15 250 / 0.5)`. Hue may
|
|
@@ -222,6 +225,38 @@ export const parseHslToBuffer = (str, outBuf, offset) => {
|
|
|
222
225
|
return parseVal(match[4], 1) ?? 1.0;
|
|
223
226
|
};
|
|
224
227
|
|
|
228
|
+
/**
|
|
229
|
+
* Parses a CSS `color(display-p3 r g b / alpha?)` string into an OKLCH triplet.
|
|
230
|
+
*
|
|
231
|
+
* Supports modern slash-alpha syntax and percentage components (0%-100%).
|
|
232
|
+
* Components are interpreted in the Display P3 color space (gamma-encoded,
|
|
233
|
+
* same transfer function as sRGB).
|
|
234
|
+
*
|
|
235
|
+
* This is the authoring-time entry point for wide-gamut colors. The resulting
|
|
236
|
+
* OKLCH may have higher chroma than sRGB-gamut colors.
|
|
237
|
+
*
|
|
238
|
+
* @param {string} str - The `color(display-p3 ...)` string
|
|
239
|
+
* @param {Float32Array} outBuf - Pre-allocated destination
|
|
240
|
+
* @param {number} offset - Start index of the L, C, H triplet
|
|
241
|
+
* @returns {number} Parsed alpha in [0, 1]; defaults to `1.0` when omitted.
|
|
242
|
+
* @throws If `str` does not match the expected syntax.
|
|
243
|
+
*/
|
|
244
|
+
export const parseDisplayP3ToBuffer = (str, outBuf, offset) => {
|
|
245
|
+
const match = str.match(DISPLAY_P3_REGEX);
|
|
246
|
+
if (!match) throw new Error(`lite-color-engine: Invalid display-p3 color "${str}"`);
|
|
247
|
+
|
|
248
|
+
// Parse components (0-1 range, with % support) then scale to byte range
|
|
249
|
+
// for consistency with displayP3ToOklchBuffer / sRgbToOklchBuffer
|
|
250
|
+
const r = parseVal(match[1], 1) * 255;
|
|
251
|
+
const g = parseVal(match[2], 1) * 255;
|
|
252
|
+
const b = parseVal(match[3], 1) * 255;
|
|
253
|
+
|
|
254
|
+
// Accurate Display P3 -> OKLCH path (Session 2)
|
|
255
|
+
displayP3ToOklchBuffer(r, g, b, outBuf, offset);
|
|
256
|
+
|
|
257
|
+
return parseVal(match[4], 1) ?? 1.0;
|
|
258
|
+
};
|
|
259
|
+
|
|
225
260
|
// ============================================================================
|
|
226
261
|
// 5. MASTER SWITCHBOARD
|
|
227
262
|
// ============================================================================
|
|
@@ -231,13 +266,16 @@ export const parseHslToBuffer = (str, outBuf, offset) => {
|
|
|
231
266
|
* format-specific parser and writes an OKLCH triplet.
|
|
232
267
|
*
|
|
233
268
|
* Supports: named colors, `#RGB[A]`, `#RRGGBB[AA]`, `rgb()` / `rgba()`,
|
|
234
|
-
* `hsl()` / `hsla()`, `oklch()`, `oklab()`.
|
|
269
|
+
* `hsl()` / `hsla()`, `oklch()`, `oklab()`, `color(display-p3 r g b / alpha?)`.
|
|
235
270
|
*
|
|
236
271
|
* Intended for the **authoring/init phase** of a render pipeline (level loads,
|
|
237
272
|
* theme parsing, gradient compilation). After that, work with the resulting
|
|
238
273
|
* `Float32Array` buffer directly via {@link lerpOklchBuffer} and
|
|
239
274
|
* {@link packOklchBufferToUint32}.
|
|
240
275
|
*
|
|
276
|
+
* `color(display-p3 ...)` is opt-in wide-gamut input. It never affects the
|
|
277
|
+
* default sRGB hot path.
|
|
278
|
+
*
|
|
241
279
|
* @param {string} str - The CSS color string
|
|
242
280
|
* @param {Float32Array} outBuf - Pre-allocated destination
|
|
243
281
|
* @param {number} offset - Start index of the L, C, H triplet
|
|
@@ -257,6 +295,9 @@ export const parseCSSColor = (str, outBuf, offset) => {
|
|
|
257
295
|
if (cleanStr.startsWith('oklab')) return parseOklabToBuffer(cleanStr, outBuf, offset);
|
|
258
296
|
if (cleanStr.startsWith('rgb')) return parseRgbToBuffer(cleanStr, outBuf, offset);
|
|
259
297
|
if (cleanStr.startsWith('hsl')) return parseHslToBuffer(cleanStr, outBuf, offset);
|
|
298
|
+
if (cleanStr.startsWith('color(') && cleanStr.includes('display-p3')) {
|
|
299
|
+
return parseDisplayP3ToBuffer(cleanStr, outBuf, offset);
|
|
300
|
+
}
|
|
260
301
|
|
|
261
302
|
throw new Error(`lite-color-engine: Unsupported color format "${str}".`);
|
|
262
303
|
};
|
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
|
-
*
|
|
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:
|
|
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
|
*
|
|
@@ -35,9 +36,50 @@ export const lerpOklchBuffer = (bufA, offsetA, bufB, offsetB, t, outBuf, outOffs
|
|
|
35
36
|
outBuf[outOffset + 2] = h < 0 ? h + 360 : h;
|
|
36
37
|
};
|
|
37
38
|
|
|
39
|
+
/**
|
|
40
|
+
* Batch sibling of {@link lerpOklchBuffer}. Interpolates `n` consecutive OKLCH
|
|
41
|
+
* triplets (stride 3) with a single shared `t`, amortizing per-call overhead
|
|
42
|
+
* across large particle systems (100k+ entities).
|
|
43
|
+
*
|
|
44
|
+
* For each i in [0, n): lerps a[offA + i*3 ..] with b[offB + i*3 ..] into
|
|
45
|
+
* out[offOut + i*3 ..]. Per-triplet math is identical to the scalar version,
|
|
46
|
+
* so results are bit-for-bit equal to calling it n times.
|
|
47
|
+
*
|
|
48
|
+
* In-place is safe (a === out, offA === offOut): each iteration reads its three
|
|
49
|
+
* source lanes before writing them. Zero allocations; monomorphic hot loop.
|
|
50
|
+
*
|
|
51
|
+
* @param {Float32Array} a - Source buffer A
|
|
52
|
+
* @param {number} offA - Base offset of the first color in A
|
|
53
|
+
* @param {Float32Array} b - Source buffer B
|
|
54
|
+
* @param {number} offB - Base offset of the first color in B
|
|
55
|
+
* @param {number} t - Interpolation factor [0, 1] (extrapolates then clamps)
|
|
56
|
+
* @param {Float32Array} out - Destination buffer
|
|
57
|
+
* @param {number} offOut - Base offset of the first output color
|
|
58
|
+
* @param {number} n - Number of triplets to process (n <= 0 is a no-op)
|
|
59
|
+
* @returns {void}
|
|
60
|
+
*/
|
|
61
|
+
export const lerpOklchBufferN = (a, offA, b, offB, t, out, offOut, n) => {
|
|
62
|
+
if (n <= 0) return;
|
|
63
|
+
for (let i = 0; i < n; i++) {
|
|
64
|
+
const ia = offA + i * 3;
|
|
65
|
+
const ib = offB + i * 3;
|
|
66
|
+
const io = offOut + i * 3;
|
|
67
|
+
|
|
68
|
+
const l = lerp(a[ia], b[ib], t);
|
|
69
|
+
out[io] = l < 0 ? 0 : (l > 1 ? 1 : l);
|
|
70
|
+
|
|
71
|
+
const c = lerp(a[ia + 1], b[ib + 1], t);
|
|
72
|
+
out[io + 1] = c < 0 ? 0 : c;
|
|
73
|
+
|
|
74
|
+
let h = lerpAngle(a[ia + 2], b[ib + 2], t);
|
|
75
|
+
h = h % 360;
|
|
76
|
+
out[io + 2] = h < 0 ? h + 360 : h;
|
|
77
|
+
}
|
|
78
|
+
};
|
|
79
|
+
|
|
38
80
|
/**
|
|
39
81
|
* Internal: shared OKLCH -> linear sRGB kernel.
|
|
40
|
-
* Inlined into
|
|
82
|
+
* Inlined into every sRGB pack variant so V8 monomorphizes it.
|
|
41
83
|
*
|
|
42
84
|
* Returns a 3-tuple via `outRgb` (length 3). Strictly clamped to [0, 1].
|
|
43
85
|
* @internal
|
|
@@ -59,8 +101,7 @@ const oklchToLinearSrgbClamped = (l, c, h, outRgb) => {
|
|
|
59
101
|
const g = -1.2684380046 * lms_l + 2.6097574011 * lms_m - 0.3413193965 * lms_s;
|
|
60
102
|
const b = -0.0041960863 * lms_l - 0.7034186147 * lms_m + 1.7076147010 * lms_s;
|
|
61
103
|
|
|
62
|
-
// Hard gamut clamp (fast path;
|
|
63
|
-
// Chroma reduction (Lab-space gamut mapping) is planned for v1.1.
|
|
104
|
+
// Hard gamut clamp (fast path; MINDE chroma reduction lives on the /gamut subpath).
|
|
64
105
|
outRgb[0] = r < 0 ? 0 : (r > 1 ? 1 : r);
|
|
65
106
|
outRgb[1] = g < 0 ? 0 : (g > 1 ? 1 : g);
|
|
66
107
|
outRgb[2] = b < 0 ? 0 : (b > 1 ? 1 : b);
|
|
@@ -68,11 +109,42 @@ const oklchToLinearSrgbClamped = (l, c, h, outRgb) => {
|
|
|
68
109
|
|
|
69
110
|
// Module-level scratch (zero-GC: single allocation at module load).
|
|
70
111
|
const _scratchRgb = new Float32Array(3);
|
|
112
|
+
const _scratchRgbP3 = new Float32Array(3);
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* Precomputed 4096-entry LUT for the sRGB transfer (linear -> encoded byte).
|
|
116
|
+
* Entry i corresponds to linear value i/4095, so the table spans [0, 1] exactly.
|
|
117
|
+
* The linear branch (slope 12.92) is the steepest region; at 4096 samples its
|
|
118
|
+
* per-step delta is < 1 byte, so nearest-index lookups stay within ~1 LSB of
|
|
119
|
+
* the exact pow() encoding. One-time module-load cost, then O(1) lookup.
|
|
120
|
+
* @internal
|
|
121
|
+
*/
|
|
122
|
+
const SRGB_LUT = new Uint8ClampedArray(4096);
|
|
123
|
+
{
|
|
124
|
+
for (let i = 0; i < 4096; i++) {
|
|
125
|
+
const c = i / 4095;
|
|
126
|
+
const enc = c <= 0.0031308 ? 12.92 * c : 1.055 * Math.pow(c, 1 / 2.4) - 0.055;
|
|
127
|
+
SRGB_LUT[i] = (enc * 255 + 0.5) | 0;
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* LUT-based linear-to-sRGB byte using nearest-index lookup. Near-exact (~1 LSB)
|
|
133
|
+
* versus {@link linearToSrgbByte} but branch-free and pow-free on the hot path.
|
|
134
|
+
* @internal
|
|
135
|
+
*/
|
|
136
|
+
const linearToSrgbByteLut = (c) => {
|
|
137
|
+
if (c <= 0) return 0;
|
|
138
|
+
if (c >= 1) return 255;
|
|
139
|
+
return SRGB_LUT[(c * 4095 + 0.5) | 0];
|
|
140
|
+
};
|
|
71
141
|
|
|
72
142
|
/**
|
|
73
143
|
* Encodes a linear-light channel (already clamped to [0, 1]) to an sRGB
|
|
74
144
|
* 8-bit byte using the proper IEC 61966-2-1 transfer function. Round-tripping
|
|
75
145
|
* sRGB(255-bytes) -> OKLCH -> here recovers the original byte exactly.
|
|
146
|
+
*
|
|
147
|
+
* This is also the correct transfer function for Display P3.
|
|
76
148
|
* @internal
|
|
77
149
|
*/
|
|
78
150
|
const linearToSrgbByte = (c) => {
|
|
@@ -84,7 +156,7 @@ const linearToSrgbByte = (c) => {
|
|
|
84
156
|
|
|
85
157
|
/**
|
|
86
158
|
* Converts an OKLCH buffer triplet to a 32-bit unsigned integer in
|
|
87
|
-
* little-endian RGBA byte order
|
|
159
|
+
* little-endian RGBA byte order - the format consumed directly by a
|
|
88
160
|
* `Uint32Array` view of `Canvas ImageData`.
|
|
89
161
|
*
|
|
90
162
|
* Uses the proper sRGB transfer function (`pow(c, 1/2.4)` branch). For a
|
|
@@ -132,10 +204,113 @@ export const packOklchBufferToUint32Fast = (buf, offset, alpha = 1.0) => {
|
|
|
132
204
|
return ((a8 << 24) | (b8 << 16) | (g8 << 8) | r8) >>> 0;
|
|
133
205
|
};
|
|
134
206
|
|
|
207
|
+
/**
|
|
208
|
+
* Batch sibling of {@link packOklchBufferToUint32}. Packs `n` OKLCH triplets
|
|
209
|
+
* (stride 3, from `src`) into `n` consecutive Uint32 pixels (stride 1, into
|
|
210
|
+
* `dst`) - the shape a SoA particle system feeds straight into `ImageData`.
|
|
211
|
+
*
|
|
212
|
+
* With `useLut=false` the output is bit-for-bit identical to calling
|
|
213
|
+
* {@link packOklchBufferToUint32} n times. With `useLut=true` it uses the 4k
|
|
214
|
+
* transfer LUT: near-exact (~1 LSB) at close to fast-packer throughput, with a
|
|
215
|
+
* single one-time table allocation and no per-color pow(). The branch is hoisted
|
|
216
|
+
* out of the loop so each variant stays monomorphic.
|
|
217
|
+
*
|
|
218
|
+
* @param {Float32Array} src - Source OKLCH buffer (stride 3)
|
|
219
|
+
* @param {number} offSrc - Base offset of the first triplet in src
|
|
220
|
+
* @param {Uint32Array} dst - Destination packed-color buffer (stride 1)
|
|
221
|
+
* @param {number} offDst - Base offset of the first packed color in dst
|
|
222
|
+
* @param {number} n - Number of colors to pack (n <= 0 is a no-op)
|
|
223
|
+
* @param {number} [alpha=1.0] - Shared alpha for all n colors
|
|
224
|
+
* @param {boolean} [useLut=false] - Opt in to the 4k LUT transfer (near-exact, faster)
|
|
225
|
+
* @returns {void}
|
|
226
|
+
*/
|
|
227
|
+
export const packOklchBufferToUint32IntoN = (src, offSrc, dst, offDst, n, alpha = 1.0, useLut = false) => {
|
|
228
|
+
if (n <= 0) return;
|
|
229
|
+
const a8 = alpha <= 0 ? 0 : (alpha >= 1 ? 255 : (alpha * 255 + 0.5) | 0);
|
|
230
|
+
const aHi = a8 << 24;
|
|
231
|
+
|
|
232
|
+
if (useLut) {
|
|
233
|
+
for (let i = 0; i < n; i++) {
|
|
234
|
+
const io = offSrc + i * 3;
|
|
235
|
+
oklchToLinearSrgbClamped(src[io], src[io + 1], src[io + 2], _scratchRgb);
|
|
236
|
+
const r8 = linearToSrgbByteLut(_scratchRgb[0]);
|
|
237
|
+
const g8 = linearToSrgbByteLut(_scratchRgb[1]);
|
|
238
|
+
const b8 = linearToSrgbByteLut(_scratchRgb[2]);
|
|
239
|
+
dst[offDst + i] = (aHi | (b8 << 16) | (g8 << 8) | r8) >>> 0;
|
|
240
|
+
}
|
|
241
|
+
} else {
|
|
242
|
+
for (let i = 0; i < n; i++) {
|
|
243
|
+
const io = offSrc + i * 3;
|
|
244
|
+
oklchToLinearSrgbClamped(src[io], src[io + 1], src[io + 2], _scratchRgb);
|
|
245
|
+
const r8 = linearToSrgbByte(_scratchRgb[0]);
|
|
246
|
+
const g8 = linearToSrgbByte(_scratchRgb[1]);
|
|
247
|
+
const b8 = linearToSrgbByte(_scratchRgb[2]);
|
|
248
|
+
dst[offDst + i] = (aHi | (b8 << 16) | (g8 << 8) | r8) >>> 0;
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
};
|
|
252
|
+
|
|
253
|
+
/**
|
|
254
|
+
* Internal helper: OKLCH -> linear Display P3 with hard clamp to [0, 1].
|
|
255
|
+
* Mirrors oklchToLinearSrgbClamped but targets the wider P3 gamut.
|
|
256
|
+
* @internal
|
|
257
|
+
*/
|
|
258
|
+
const oklchToLinearP3Clamped = (l, c, h, outRgb) => {
|
|
259
|
+
oklchToLinearP3(l, c, h, outRgb);
|
|
260
|
+
outRgb[0] = outRgb[0] < 0 ? 0 : (outRgb[0] > 1 ? 1 : outRgb[0]);
|
|
261
|
+
outRgb[1] = outRgb[1] < 0 ? 0 : (outRgb[1] > 1 ? 1 : outRgb[1]);
|
|
262
|
+
outRgb[2] = outRgb[2] < 0 ? 0 : (outRgb[2] > 1 ? 1 : outRgb[2]);
|
|
263
|
+
};
|
|
264
|
+
|
|
265
|
+
/**
|
|
266
|
+
* Converts an OKLCH buffer triplet to a 32-bit unsigned integer in
|
|
267
|
+
* little-endian RGBA byte order, encoded for **Display P3** color space.
|
|
268
|
+
*
|
|
269
|
+
* Use this when your canvas context was created with `{ colorSpace: 'display-p3' }`.
|
|
270
|
+
* Colors that are out of sRGB but inside P3 will be preserved with higher
|
|
271
|
+
* saturation than the regular sRGB packers.
|
|
272
|
+
*
|
|
273
|
+
* The transfer function is identical to sRGB (IEC 61966-2-1).
|
|
274
|
+
*
|
|
275
|
+
* @param {Float32Array} buf - Buffer containing the OKLCH triplet
|
|
276
|
+
* @param {number} offset - Start index in the buffer
|
|
277
|
+
* @param {number} [alpha=1.0] - Alpha [0, 1]; values outside the range are clamped
|
|
278
|
+
* @returns {number} 32-bit unsigned integer (little-endian RGBA byte order)
|
|
279
|
+
*/
|
|
280
|
+
export const packOklchBufferToUint32P3 = (buf, offset, alpha = 1.0) => {
|
|
281
|
+
oklchToLinearP3Clamped(buf[offset], buf[offset + 1], buf[offset + 2], _scratchRgbP3);
|
|
282
|
+
const r8 = linearToSrgbByte(_scratchRgbP3[0]);
|
|
283
|
+
const g8 = linearToSrgbByte(_scratchRgbP3[1]);
|
|
284
|
+
const b8 = linearToSrgbByte(_scratchRgbP3[2]);
|
|
285
|
+
const a8 = alpha <= 0 ? 0 : (alpha >= 1 ? 255 : (alpha * 255 + 0.5) | 0);
|
|
286
|
+
return ((a8 << 24) | (b8 << 16) | (g8 << 8) | r8) >>> 0;
|
|
287
|
+
};
|
|
288
|
+
|
|
289
|
+
/**
|
|
290
|
+
* Faster, less accurate sibling of {@link packOklchBufferToUint32P3}.
|
|
291
|
+
*
|
|
292
|
+
* Uses `Math.sqrt` approximation for the transfer function (same trade-off as
|
|
293
|
+
* the sRGB fast path). Useful for high-volume particle systems or when
|
|
294
|
+
* baking many P3 gradients where absolute round-trip precision is not critical.
|
|
295
|
+
*
|
|
296
|
+
* @param {Float32Array} buf - Buffer containing the OKLCH triplet
|
|
297
|
+
* @param {number} offset - Start index in the buffer
|
|
298
|
+
* @param {number} [alpha=1.0] - Alpha [0, 1]; values outside the range are clamped
|
|
299
|
+
* @returns {number} 32-bit unsigned integer (little-endian RGBA byte order)
|
|
300
|
+
*/
|
|
301
|
+
export const packOklchBufferToUint32P3Fast = (buf, offset, alpha = 1.0) => {
|
|
302
|
+
oklchToLinearP3Clamped(buf[offset], buf[offset + 1], buf[offset + 2], _scratchRgbP3);
|
|
303
|
+
const r8 = (Math.sqrt(_scratchRgbP3[0]) * 255) | 0;
|
|
304
|
+
const g8 = (Math.sqrt(_scratchRgbP3[1]) * 255) | 0;
|
|
305
|
+
const b8 = (Math.sqrt(_scratchRgbP3[2]) * 255) | 0;
|
|
306
|
+
const a8 = alpha <= 0 ? 0 : (alpha >= 1 ? 255 : (alpha * 255) | 0);
|
|
307
|
+
return ((a8 << 24) | (b8 << 16) | (g8 << 8) | r8) >>> 0;
|
|
308
|
+
};
|
|
309
|
+
|
|
135
310
|
/**
|
|
136
311
|
* Zero-GC LUT sampler. Looks up a baked gradient color by `t` in [0, 1].
|
|
137
312
|
*
|
|
138
|
-
* Performs an inline clamp + bitwise-truncated index
|
|
313
|
+
* Performs an inline clamp + bitwise-truncated index - no allocations, no
|
|
139
314
|
* function calls, no bounds checks beyond the single comparison pair. Use
|
|
140
315
|
* inside particle systems, shader-style canvas loops, etc.
|
|
141
316
|
*
|