palette-shader 0.1.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/index.ts ADDED
@@ -0,0 +1,485 @@
1
+ import {
2
+ ColorString,
3
+ ColorList,
4
+ PaletteVizOptions,
5
+ SupportedColorModels,
6
+ Axis,
7
+ DistanceMetric,
8
+ } from "./types.ts";
9
+
10
+ // @ts-ignore
11
+ import shaderSRGB2RGB from "./shaders/srgb2rgb.frag.glsl?raw" assert { type: "raw" };
12
+ // @ts-ignore
13
+ import shaderOKLab from "./shaders/oklab.frag.glsl?raw" assert { type: "raw" };
14
+ // @ts-ignore
15
+ import shaderHSL2RGB from "./shaders/hsl2rgb.frag.glsl?raw" assert { type: "raw" };
16
+ // @ts-ignore
17
+ import shaderHSV2RGB from "./shaders/hsv2rgb.frag.glsl?raw" assert { type: "raw" };
18
+ // @ts-ignore
19
+ import shaderLCH2RGB from "./shaders/lch2rgb.frag.glsl?raw" assert { type: "raw" };
20
+ // @ts-ignore
21
+ import shaderDeltaE from "./shaders/deltaE.frag.glsl?raw" assert { type: "raw" };
22
+ // @ts-ignore
23
+ import shaderClosestColor from "./shaders/closestColor.frag.glsl?raw" assert { type: "raw" };
24
+
25
+ // Include order matters:
26
+ // srgb2rgb – srgb2rgb()
27
+ // oklab – M_PI, cbrt(), srgb_transfer_function(), okhsv/okhsl_to_srgb(), …
28
+ // hsl2rgb, hsv2rgb, lch2rgb – color model conversions (lch2rgb uses M_PI + srgb_transfer_function)
29
+ // deltaE – srgb_to_cielab(), deltaE76/94/2000() (uses srgb2rgb, cbrt, M_PI, TWO_PI)
30
+ // closestColor – branches on DISTANCE_METRIC define; uses everything above
31
+ //
32
+ // Defines (compile-time, prepended to shader source — trigger recompile, no runtime branching):
33
+ // DISTANCE_METRIC int 0=rgb 1=oklab 2=deltaE76 3=deltaE2000 4=kotsarenkoRamos 5=deltaE94
34
+ // COLOR_MODEL int 0=hsv 1=okhsv 2=hsl 3=okhsl 4=oklch
35
+ // PROGRESS_AXIS int 0=x 1=y 2=z
36
+ // IS_POLAR flag (defined = true)
37
+ // INVERT_Z flag (defined = true)
38
+ // SHOW_RAW flag (defined = true)
39
+
40
+ const vertexShaderSrc = `
41
+ precision highp float;
42
+ layout(location = 0) in vec2 a_position;
43
+ out vec2 vUv;
44
+ void main() {
45
+ vUv = a_position * 0.5 + 0.5;
46
+ gl_Position = vec4(a_position, 0.0, 1.0);
47
+ }`;
48
+
49
+ // fragmentShader is exported so users can inspect or reuse the GLSL source.
50
+ // Defines are NOT embedded here — they are prepended at compile time via buildProgram().
51
+ // Note: #version 300 es is prepended by buildProgram() (must be first line,
52
+ // before defines). Do not add it here.
53
+ export const fragmentShader = `
54
+ precision highp float;
55
+ #define TWO_PI 6.28318530718
56
+ in vec2 vUv;
57
+ out vec4 fragColor;
58
+ uniform float progress;
59
+ uniform sampler2D paletteTexture;
60
+
61
+ ${shaderSRGB2RGB}
62
+ ${shaderOKLab}
63
+ ${shaderHSL2RGB}
64
+ ${shaderHSV2RGB}
65
+ ${shaderLCH2RGB}
66
+ ${shaderDeltaE}
67
+ ${shaderClosestColor}
68
+
69
+ // COLOR_MODEL: 0=hsv, 1=okhsv, 2=hsl, 3=okhsl, 4=oklch
70
+ vec3 polarToRGB(vec3 colorCoords) {
71
+ #if COLOR_MODEL == 0
72
+ return hsv2rgb(colorCoords);
73
+ #elif COLOR_MODEL == 2
74
+ return hsl2rgb(colorCoords);
75
+ #elif COLOR_MODEL == 3
76
+ return okhsl_to_srgb(colorCoords);
77
+ #elif COLOR_MODEL == 4
78
+ return lch2rgb(vec3(colorCoords.z, colorCoords.y, colorCoords.x));
79
+ #else
80
+ return okhsv_to_srgb(colorCoords);
81
+ #endif
82
+ }
83
+
84
+ void main(){
85
+ #if PROGRESS_AXIS == 1
86
+ vec3 colorCoords = vec3(vUv.x, progress, vUv.y);
87
+ #elif PROGRESS_AXIS == 2
88
+ vec3 colorCoords = vec3(vUv.x, vUv.y, 1. - progress);
89
+ #else
90
+ vec3 colorCoords = vec3(progress, vUv.x, vUv.y);
91
+ #endif
92
+
93
+ #ifdef IS_POLAR
94
+ vec2 toCenter = vUv - 0.5;
95
+ float angle = atan(toCenter.y, toCenter.x);
96
+ float radius = length(toCenter) * 2.0;
97
+
98
+ #if PROGRESS_AXIS == 2
99
+ colorCoords = vec3((angle / TWO_PI), radius, 1. - progress);
100
+ #elif PROGRESS_AXIS == 1
101
+ colorCoords = vec3((angle / TWO_PI), 1. - progress, radius);
102
+ if (radius > 1.0) { discard; }
103
+ #else
104
+ float hue = 1.0 - abs(0.5 - progress * .5) * 2.0;
105
+ if (vUv.x > 0.5) { hue += 0.5; }
106
+ colorCoords = vec3(hue, abs(0.5 - vUv.x) * 2.0, vUv.y);
107
+ #endif
108
+ #endif
109
+
110
+ #ifdef INVERT_Z
111
+ colorCoords.z = 1. - colorCoords.z;
112
+ #endif
113
+
114
+ vec3 rgb = polarToRGB(colorCoords);
115
+
116
+ #ifdef SHOW_RAW
117
+ fragColor = vec4(rgb, 1.);
118
+ #else
119
+ fragColor = vec4(closestColor(rgb, paletteTexture), 1.);
120
+ #endif
121
+ }`;
122
+
123
+ // ── Color parsing ──────────────────────────────────────────────────────────────
124
+ // Use a canvas 2D context as a free CSS color parser — handles hex, rgb(),
125
+ // hsl(), named colors, etc. Lazy-initialised to avoid issues at module load time.
126
+
127
+ let _colorCtx: CanvasRenderingContext2D | null = null;
128
+
129
+ function cssToSRGB(color: string): [number, number, number] {
130
+ if (!_colorCtx) {
131
+ const c = document.createElement("canvas");
132
+ c.width = c.height = 1;
133
+ _colorCtx = c.getContext("2d")!;
134
+ }
135
+ _colorCtx.fillStyle = "#000000"; // reset before setting
136
+ _colorCtx.fillStyle = color;
137
+ const v = _colorCtx.fillStyle; // browser normalises to '#rrggbb' or 'rgba(...)'
138
+ if (v[0] === "#") {
139
+ return [
140
+ parseInt(v.slice(1, 3), 16) / 255,
141
+ parseInt(v.slice(3, 5), 16) / 255,
142
+ parseInt(v.slice(5, 7), 16) / 255,
143
+ ];
144
+ }
145
+ // rgba(r, g, b, a) fallback
146
+ const m = v.match(/[\d.]+/g)!;
147
+ return [+m[0] / 255, +m[1] / 255, +m[2] / 255];
148
+ }
149
+
150
+ // ── Palette helpers ────────────────────────────────────────────────────────────
151
+
152
+ // Returns the palette as a flat RGBA Uint8Array (sRGB, 1×N texture row).
153
+ // Useful for building your own WebGL texture or inspecting raw color data.
154
+ export const paletteToRGBA = (palette: ColorList): Uint8Array => {
155
+ const data = new Uint8Array(palette.length * 4);
156
+ palette.forEach((color, i) => {
157
+ try {
158
+ const [r, g, b] = cssToSRGB(color);
159
+ data[i * 4 + 0] = Math.round(r * 255);
160
+ data[i * 4 + 1] = Math.round(g * 255);
161
+ data[i * 4 + 2] = Math.round(b * 255);
162
+ data[i * 4 + 3] = 255;
163
+ } catch {
164
+ console.error(`Invalid color: ${color}`);
165
+ }
166
+ });
167
+ return data;
168
+ };
169
+
170
+ // Backwards-compatible alias (previously returned a Three.js DataTexture)
171
+ export const paletteToTexture = paletteToRGBA;
172
+
173
+ export const randomPalette = (size = 20): ColorList =>
174
+ Array.from({ length: size }, () =>
175
+ `rgb(${Math.floor(Math.random() * 256)}, ${Math.floor(Math.random() * 256)}, ${Math.floor(Math.random() * 256)})`
176
+ );
177
+
178
+ // ── WebGL helpers ──────────────────────────────────────────────────────────────
179
+
180
+ type Defines = Record<string, number | false>;
181
+
182
+ function compileShader(gl: WebGL2RenderingContext, type: number, src: string): WebGLShader {
183
+ const shader = gl.createShader(type)!;
184
+ gl.shaderSource(shader, src);
185
+ gl.compileShader(shader);
186
+ if (!gl.getShaderParameter(shader, gl.COMPILE_STATUS)) {
187
+ const log = gl.getShaderInfoLog(shader);
188
+ gl.deleteShader(shader);
189
+ throw new Error(`Shader compile error:\n${log}`);
190
+ }
191
+ return shader;
192
+ }
193
+
194
+ function buildProgram(gl: WebGL2RenderingContext, defines: Defines, fragSrc: string, vertSrc: string): WebGLProgram {
195
+ // #version 300 es must be the very first line — prepend it before defines.
196
+ const defineStr = Object.entries(defines)
197
+ .filter(([, v]) => v !== false)
198
+ .map(([k, v]) => `#define ${k} ${v}`)
199
+ .join("\n") + "\n";
200
+ const prefix = "#version 300 es\n" + defineStr;
201
+
202
+ const vert = compileShader(gl, gl.VERTEX_SHADER, prefix + vertSrc);
203
+ const frag = compileShader(gl, gl.FRAGMENT_SHADER, prefix + fragSrc);
204
+
205
+ const prog = gl.createProgram()!;
206
+ gl.attachShader(prog, vert);
207
+ gl.attachShader(prog, frag);
208
+ gl.linkProgram(prog);
209
+ gl.deleteShader(vert);
210
+ gl.deleteShader(frag);
211
+
212
+ if (!gl.getProgramParameter(prog, gl.LINK_STATUS)) {
213
+ const log = gl.getProgramInfoLog(prog);
214
+ gl.deleteProgram(prog);
215
+ throw new Error(`Program link error:\n${log}`);
216
+ }
217
+ return prog;
218
+ }
219
+
220
+ function uploadPaletteTexture(gl: WebGL2RenderingContext, tex: WebGLTexture, palette: ColorList): void {
221
+ gl.bindTexture(gl.TEXTURE_2D, tex);
222
+ // RGBA8: sized internal format required by WebGL2 spec
223
+ gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA8, palette.length, 1, 0, gl.RGBA, gl.UNSIGNED_BYTE, paletteToRGBA(palette));
224
+ gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.NEAREST);
225
+ gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.NEAREST);
226
+ gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE);
227
+ gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE);
228
+ }
229
+
230
+ // ── PaletteViz ─────────────────────────────────────────────────────────────────
231
+
232
+ export class PaletteViz {
233
+ #palette: ColorList = [];
234
+ #width = 512;
235
+ #height = 512;
236
+ #pixelRatio = 1;
237
+
238
+ // shader state
239
+ #position = 0.0;
240
+ #axis: Axis = "y";
241
+ #colorModel: SupportedColorModels = "okhsv";
242
+ #distanceMetric: DistanceMetric = "oklab";
243
+ #isPolar = true;
244
+ #invertLightness = false;
245
+ #showRaw = false;
246
+
247
+ // uniform value maps
248
+ readonly #axisMap = { x: 0, y: 1, z: 2 } as const;
249
+ readonly #colorModelMap = { hsv: 0, okhsv: 1, hsl: 2, okhsl: 3, oklch: 4 } as const;
250
+ readonly #distanceMetricMap = { rgb: 0, oklab: 1, deltaE76: 2, deltaE2000: 3, kotsarenkoRamos: 4, deltaE94: 5 } as const;
251
+
252
+ // WebGL
253
+ #canvas: HTMLCanvasElement;
254
+ #gl: WebGL2RenderingContext;
255
+ #program: WebGLProgram | null = null;
256
+ #texture: WebGLTexture | null = null;
257
+ #quadBuffer: WebGLBuffer | null = null;
258
+ #vao: WebGLVertexArrayObject | null = null;
259
+ #animationFrame: number | null = null;
260
+
261
+ // cached uniform locations (re-queried after each program rebuild)
262
+ #uProgress: WebGLUniformLocation | null = null;
263
+ #uPaletteTexture: WebGLUniformLocation | null = null;
264
+
265
+ // dom
266
+ #container: HTMLElement | undefined;
267
+
268
+ constructor({
269
+ palette = randomPalette(),
270
+ width = 512,
271
+ height = 512,
272
+ pixelRatio = window.devicePixelRatio,
273
+ container,
274
+ colorModel = "okhsv",
275
+ distanceMetric = "oklab",
276
+ isPolar = true,
277
+ axis = "y",
278
+ position = 0.0,
279
+ invertLightness = false,
280
+ showRaw = false,
281
+ }: PaletteVizOptions = {}) {
282
+ this.#palette = palette;
283
+ this.#width = width;
284
+ this.#height = height;
285
+ this.#pixelRatio = pixelRatio;
286
+ this.#colorModel = colorModel;
287
+ this.#distanceMetric = distanceMetric;
288
+ this.#isPolar = isPolar;
289
+ this.#axis = axis;
290
+ this.#position = position;
291
+ this.#invertLightness = invertLightness;
292
+ this.#showRaw = showRaw;
293
+ this.#container = container;
294
+
295
+ this.#canvas = document.createElement("canvas");
296
+ this.#canvas.classList.add("palette-viz");
297
+ const gl = this.#canvas.getContext("webgl2");
298
+ if (!gl) throw new Error("WebGL2 not supported");
299
+ this.#gl = gl;
300
+
301
+ // Quad buffer + VAO — set up once, reused every frame.
302
+ // layout(location=0) in the vertex shader pins a_position to slot 0,
303
+ // so the VAO remains valid across shader recompiles.
304
+ this.#quadBuffer = gl.createBuffer()!;
305
+ gl.bindBuffer(gl.ARRAY_BUFFER, this.#quadBuffer);
306
+ gl.bufferData(gl.ARRAY_BUFFER, new Float32Array([-1, -1, 1, -1, -1, 1, 1, 1]), gl.STATIC_DRAW);
307
+
308
+ this.#vao = gl.createVertexArray()!;
309
+ gl.bindVertexArray(this.#vao);
310
+ gl.enableVertexAttribArray(0);
311
+ gl.vertexAttribPointer(0, 2, gl.FLOAT, false, 0, 0);
312
+ gl.bindVertexArray(null);
313
+
314
+ this.#texture = gl.createTexture()!;
315
+ uploadPaletteTexture(gl, this.#texture, this.#palette);
316
+
317
+ this.#buildProgram();
318
+ this.#setSize(this.#width, this.#height);
319
+ this.#container?.appendChild(this.#canvas);
320
+ this.#paint();
321
+ }
322
+
323
+ #defines(): Defines {
324
+ return {
325
+ DISTANCE_METRIC: this.#distanceMetricMap[this.#distanceMetric],
326
+ COLOR_MODEL: this.#colorModelMap[this.#colorModel],
327
+ PROGRESS_AXIS: this.#axisMap[this.#axis],
328
+ IS_POLAR: this.#isPolar ? 1 : false,
329
+ INVERT_Z: this.#invertLightness ? 1 : false,
330
+ SHOW_RAW: this.#showRaw ? 1 : false,
331
+ };
332
+ }
333
+
334
+ #buildProgram(): void {
335
+ const gl = this.#gl;
336
+ if (this.#program) gl.deleteProgram(this.#program);
337
+ this.#program = buildProgram(gl, this.#defines(), fragmentShader, vertexShaderSrc);
338
+ this.#uProgress = gl.getUniformLocation(this.#program, "progress");
339
+ this.#uPaletteTexture = gl.getUniformLocation(this.#program, "paletteTexture");
340
+ }
341
+
342
+ #setSize(w: number, h: number): void {
343
+ const pw = Math.round(w * this.#pixelRatio);
344
+ const ph = Math.round(h * this.#pixelRatio);
345
+ this.#canvas.width = pw;
346
+ this.#canvas.height = ph;
347
+ this.#canvas.style.width = `${w}px`;
348
+ this.#canvas.style.height = `${h}px`;
349
+ this.#gl.viewport(0, 0, pw, ph);
350
+ }
351
+
352
+ #paint(): void {
353
+ if (this.#animationFrame !== null) cancelAnimationFrame(this.#animationFrame);
354
+ this.#animationFrame = requestAnimationFrame(() => {
355
+ const gl = this.#gl;
356
+ gl.useProgram(this.#program);
357
+
358
+ gl.uniform1f(this.#uProgress, this.#position);
359
+
360
+ gl.activeTexture(gl.TEXTURE0);
361
+ gl.bindTexture(gl.TEXTURE_2D, this.#texture);
362
+ gl.uniform1i(this.#uPaletteTexture, 0);
363
+
364
+ gl.bindVertexArray(this.#vao);
365
+ gl.drawArrays(gl.TRIANGLE_STRIP, 0, 4);
366
+ gl.bindVertexArray(null);
367
+ });
368
+ }
369
+
370
+ // ── Public API ──────────────────────────────────────────────────────────────
371
+
372
+ get canvas(): HTMLCanvasElement { return this.#canvas; }
373
+ get width() { return this.#width; }
374
+ get height() { return this.#height; }
375
+
376
+ resize(width: number, height: number | null = null): void {
377
+ this.#width = width;
378
+ this.#height = height ?? width;
379
+ this.#setSize(this.#width, this.#height);
380
+ this.#paint();
381
+ }
382
+
383
+ destroy(): void {
384
+ if (this.#animationFrame !== null) {
385
+ cancelAnimationFrame(this.#animationFrame);
386
+ this.#animationFrame = null;
387
+ }
388
+ const gl = this.#gl;
389
+ gl.deleteProgram(this.#program);
390
+ gl.deleteTexture(this.#texture);
391
+ gl.deleteBuffer(this.#quadBuffer);
392
+ gl.deleteVertexArray(this.#vao);
393
+ this.#canvas.remove();
394
+ gl.getExtension("WEBGL_lose_context")?.loseContext();
395
+ }
396
+
397
+ // ── Palette ─────────────────────────────────────────────────────────────────
398
+
399
+ set palette(palette: ColorList) {
400
+ this.#palette = palette;
401
+ uploadPaletteTexture(this.#gl, this.#texture!, palette);
402
+ this.#paint();
403
+ }
404
+ get palette() { return this.#palette; }
405
+
406
+ setColor(color: ColorString, index: number): void {
407
+ if (index < 0 || index >= this.#palette.length) throw new Error(`Index ${index} out of range`);
408
+ this.#palette[index] = color;
409
+ uploadPaletteTexture(this.#gl, this.#texture!, this.#palette);
410
+ this.#paint();
411
+ }
412
+
413
+ addColor(color: ColorString, index?: number): void {
414
+ this.#palette.splice(index ?? this.#palette.length, 0, color);
415
+ uploadPaletteTexture(this.#gl, this.#texture!, this.#palette);
416
+ this.#paint();
417
+ }
418
+
419
+ removeColor(index: number): void;
420
+ removeColor(color: ColorString): void;
421
+ removeColor(indexOrColor: number | ColorString): void {
422
+ const index = typeof indexOrColor === "number"
423
+ ? indexOrColor
424
+ : this.#palette.indexOf(indexOrColor);
425
+ if (index === -1) throw new Error("Color not found in palette");
426
+ if (index < 0 || index >= this.#palette.length) throw new Error(`Index ${index} out of range`);
427
+ this.#palette.splice(index, 1);
428
+ uploadPaletteTexture(this.#gl, this.#texture!, this.#palette);
429
+ this.#paint();
430
+ }
431
+
432
+ // ── Shader properties ────────────────────────────────────────────────────────
433
+
434
+ set position(value: number) { this.#position = value; this.#paint(); }
435
+ get position() { return this.#position; }
436
+
437
+ set axis(axis: Axis) {
438
+ if (!(axis in this.#axisMap)) throw new Error("axis must be 'x', 'y', or 'z'");
439
+ this.#axis = axis;
440
+ this.#buildProgram();
441
+ this.#paint();
442
+ }
443
+ get axis() { return this.#axis; }
444
+
445
+ set colorModel(model: SupportedColorModels) {
446
+ if (!(model in this.#colorModelMap)) throw new Error("colorModel must be 'hsv', 'okhsv', 'hsl', 'okhsl', or 'oklch'");
447
+ this.#colorModel = model;
448
+ this.#buildProgram();
449
+ this.#paint();
450
+ }
451
+ get colorModel() { return this.#colorModel; }
452
+
453
+ set distanceMetric(metric: DistanceMetric) {
454
+ if (!(metric in this.#distanceMetricMap)) throw new Error("distanceMetric must be 'rgb', 'oklab', 'deltaE76', 'deltaE94', 'deltaE2000', or 'kotsarenkoRamos'");
455
+ this.#distanceMetric = metric;
456
+ this.#buildProgram();
457
+ this.#paint();
458
+ }
459
+ get distanceMetric() { return this.#distanceMetric; }
460
+
461
+ set isPolar(value: boolean) {
462
+ this.#isPolar = value;
463
+ this.#buildProgram();
464
+ this.#paint();
465
+ }
466
+ get isPolar() { return this.#isPolar; }
467
+
468
+ set invertLightness(value: boolean) {
469
+ this.#invertLightness = value;
470
+ this.#buildProgram();
471
+ this.#paint();
472
+ }
473
+ get invertLightness() { return this.#invertLightness; }
474
+
475
+ set showRaw(value: boolean) {
476
+ this.#showRaw = value;
477
+ this.#buildProgram();
478
+ this.#paint();
479
+ }
480
+ get showRaw() { return this.#showRaw; }
481
+
482
+ static paletteToRGBA = paletteToRGBA;
483
+ /** @deprecated use PaletteViz.paletteToRGBA */
484
+ static paletteToTexture = paletteToRGBA;
485
+ }
@@ -0,0 +1,41 @@
1
+ // DISTANCE_METRIC define: 0=rgb, 1=oklab, 2=deltaE76, 3=deltaE2000, 4=kotsarenkoRamos, 5=deltaE94
2
+ vec3 closestColor(vec3 color, sampler2D paletteTexture) {
3
+ int paletteSize = textureSize(paletteTexture, 0).x;
4
+ float minDist = 1000000.0;
5
+ vec3 closest = vec3(0.0);
6
+
7
+ // Pre-convert the input color once — palette entries are converted inside the loop.
8
+ #if DISTANCE_METRIC == 1
9
+ vec3 colorConverted = linear_srgb_to_oklab(srgb2rgb(color));
10
+ #elif DISTANCE_METRIC == 2 || DISTANCE_METRIC == 3 || DISTANCE_METRIC == 5
11
+ vec3 colorConverted = srgb_to_cielab(color);
12
+ #else
13
+ vec3 colorConverted = color;
14
+ #endif
15
+
16
+ for (int i = 0; i < paletteSize; i++) {
17
+ vec3 paletteColor = texelFetch(paletteTexture, ivec2(i, 0), 0).rgb;
18
+
19
+ float dist;
20
+ #if DISTANCE_METRIC == 1
21
+ dist = distance(colorConverted, linear_srgb_to_oklab(srgb2rgb(paletteColor)));
22
+ #elif DISTANCE_METRIC == 2
23
+ dist = deltaE76(colorConverted, srgb_to_cielab(paletteColor));
24
+ #elif DISTANCE_METRIC == 3
25
+ dist = deltaE2000(colorConverted, srgb_to_cielab(paletteColor));
26
+ #elif DISTANCE_METRIC == 4
27
+ dist = kotsarenkoRamos(color, paletteColor);
28
+ #elif DISTANCE_METRIC == 5
29
+ dist = deltaE94(colorConverted, srgb_to_cielab(paletteColor));
30
+ #else
31
+ dist = distance(colorConverted, paletteColor);
32
+ #endif
33
+
34
+ if (dist < minDist) {
35
+ minDist = dist;
36
+ closest = paletteColor;
37
+ }
38
+ }
39
+
40
+ return closest;
41
+ }
@@ -0,0 +1,146 @@
1
+ // Kotsarenko/Ramos weighted RGB distance.
2
+ // Operates on sRGB values directly (no linearisation needed).
3
+ // Weights red and blue channels by the mean red value, which improves
4
+ // perceptual uniformity compared to plain Euclidean RGB at minimal cost.
5
+ float kotsarenkoRamos(vec3 c1, vec3 c2) {
6
+ float rMean = (c1.r + c2.r) * 0.5;
7
+ vec3 d = c1 - c2;
8
+ return sqrt((2.0 + rMean) * d.r*d.r + 4.0 * d.g*d.g + (3.0 - rMean) * d.b*d.b);
9
+ }
10
+
11
+ // ── CIELab ────────────────────────────────────────────────────────────────────
12
+ // sRGB -> XYZ (D65) -> CIELab
13
+ // Depends on: srgb2rgb() from srgb2rgb.frag.glsl, cbrt() from oklab.frag.glsl
14
+
15
+ float _lab_f(float t) {
16
+ float delta = 6.0 / 29.0;
17
+ return t > delta * delta * delta
18
+ ? cbrt(t)
19
+ : t / (3.0 * delta * delta) + 4.0 / 29.0;
20
+ }
21
+
22
+ vec3 srgb_to_cielab(vec3 srgb) {
23
+ vec3 lin = srgb2rgb(srgb);
24
+
25
+ // Linear sRGB -> XYZ (D65 illuminant)
26
+ vec3 xyz = vec3(
27
+ 0.4124564 * lin.r + 0.3575761 * lin.g + 0.1804375 * lin.b,
28
+ 0.2126729 * lin.r + 0.7151522 * lin.g + 0.0721750 * lin.b,
29
+ 0.0193339 * lin.r + 0.1191920 * lin.g + 0.9503041 * lin.b
30
+ );
31
+
32
+ // XYZ -> Lab (D65 white point: 0.95047, 1.00000, 1.08883)
33
+ float fx = _lab_f(xyz.x / 0.95047);
34
+ float fy = _lab_f(xyz.y);
35
+ float fz = _lab_f(xyz.z / 1.08883);
36
+
37
+ return vec3(
38
+ 116.0 * fy - 16.0, // L*
39
+ 500.0 * (fx - fy), // a*
40
+ 200.0 * (fy - fz) // b*
41
+ );
42
+ }
43
+
44
+ // CIE76: plain Euclidean distance in CIELab
45
+ float deltaE76(vec3 lab1, vec3 lab2) {
46
+ return distance(lab1, lab2);
47
+ }
48
+
49
+ // CIE94: weighted chroma/hue corrections, cheaper than CIEDE2000
50
+ // Uses graphics application constants: kL=1, K1=0.045, K2=0.015
51
+ float deltaE94(vec3 lab1, vec3 lab2) {
52
+ float dL = lab1.x - lab2.x;
53
+ float da = lab1.y - lab2.y;
54
+ float db = lab1.z - lab2.z;
55
+ float C1 = sqrt(lab1.y * lab1.y + lab1.z * lab1.z);
56
+ float C2 = sqrt(lab2.y * lab2.y + lab2.z * lab2.z);
57
+ float dC = C1 - C2;
58
+ float dH = sqrt(max(0.0, da*da + db*db - dC*dC));
59
+ float SC = 1.0 + 0.045 * C1;
60
+ float SH = 1.0 + 0.015 * C1;
61
+ return sqrt(dL*dL + (dC/SC)*(dC/SC) + (dH/SH)*(dH/SH));
62
+ }
63
+
64
+ // CIEDE2000
65
+ float deltaE2000(vec3 lab1, vec3 lab2) {
66
+ float L1 = lab1.x, a1 = lab1.y, b1 = lab1.z;
67
+ float L2 = lab2.x, a2 = lab2.y, b2 = lab2.z;
68
+
69
+ // Chroma
70
+ float C1 = sqrt(a1*a1 + b1*b1);
71
+ float C2 = sqrt(a2*a2 + b2*b2);
72
+ float Cavg = (C1 + C2) * 0.5;
73
+ float Cavg7 = pow(Cavg, 7.0);
74
+
75
+ // G factor: adjustment to a* axis
76
+ float G = 0.5 * (1.0 - sqrt(Cavg7 / (Cavg7 + 6103515625.0))); // 25^7
77
+
78
+ float a1p = a1 * (1.0 + G);
79
+ float a2p = a2 * (1.0 + G);
80
+ float C1p = sqrt(a1p*a1p + b1*b1);
81
+ float C2p = sqrt(a2p*a2p + b2*b2);
82
+
83
+ // Guard atan(0,0): GLSL ES leaves that undefined, so skip it for achromatic colors.
84
+ // When a color has no chroma its hue angle is meaningless — we just need it to
85
+ // be a well-defined number so it doesn't corrupt the rest of the formula.
86
+ bool c1Achromatic = C1p < 1e-6;
87
+ bool c2Achromatic = C2p < 1e-6;
88
+
89
+ float h1p = c1Achromatic ? 0.0 : atan(b1, a1p);
90
+ if (h1p < 0.0) h1p += TWO_PI;
91
+ float h2p = c2Achromatic ? 0.0 : atan(b2, a2p);
92
+ if (h2p < 0.0) h2p += TWO_PI;
93
+
94
+ // Deltas
95
+ float dLp = L2 - L1;
96
+ float dCp = C2p - C1p;
97
+
98
+ float dhp = 0.0;
99
+ if (!c1Achromatic && !c2Achromatic) {
100
+ dhp = h2p - h1p;
101
+ if (dhp > M_PI) dhp -= TWO_PI;
102
+ else if (dhp < -M_PI) dhp += TWO_PI;
103
+ }
104
+ float dHp = 2.0 * sqrt(C1p * C2p) * sin(dhp * 0.5);
105
+
106
+ // Averages
107
+ float Lp = (L1 + L2) * 0.5;
108
+ float Cp = (C1p + C2p) * 0.5;
109
+
110
+ // When one color is achromatic, its hue is 0 and the average is simply the other's hue
111
+ float hp;
112
+ if (c1Achromatic || c2Achromatic) {
113
+ hp = h1p + h2p;
114
+ } else if (abs(h1p - h2p) <= M_PI) {
115
+ hp = (h1p + h2p) * 0.5;
116
+ } else if (h1p + h2p < TWO_PI) {
117
+ hp = (h1p + h2p + TWO_PI) * 0.5;
118
+ } else {
119
+ hp = (h1p + h2p - TWO_PI) * 0.5;
120
+ }
121
+
122
+ float T = 1.0
123
+ - 0.17 * cos(hp - radians(30.0))
124
+ + 0.24 * cos(2.0 * hp)
125
+ + 0.32 * cos(3.0 * hp + radians(6.0))
126
+ - 0.20 * cos(4.0 * hp - radians(63.0));
127
+
128
+ // Weighting functions
129
+ float Lpm50sq = (Lp - 50.0) * (Lp - 50.0);
130
+ float SL = 1.0 + 0.015 * Lpm50sq / sqrt(20.0 + Lpm50sq);
131
+ float SC = 1.0 + 0.045 * Cp;
132
+ float SH = 1.0 + 0.015 * Cp * T;
133
+
134
+ // Rotation term
135
+ float Cp7 = pow(Cp, 7.0);
136
+ float RC = 2.0 * sqrt(Cp7 / (Cp7 + 6103515625.0));
137
+ float hpDeg = degrees(hp);
138
+ float dTheta = radians(30.0) * exp(-((hpDeg - 275.0) / 25.0) * ((hpDeg - 275.0) / 25.0));
139
+ float RT = -sin(2.0 * dTheta) * RC;
140
+
141
+ float dLn = dLp / SL;
142
+ float dCn = dCp / SC;
143
+ float dHn = dHp / SH;
144
+
145
+ return sqrt(dLn*dLn + dCn*dCn + dHn*dHn + RT * dCn * dHn);
146
+ }
@@ -0,0 +1,4 @@
1
+ vec3 hsl2rgb( in vec3 c ) {
2
+ vec3 rgb = clamp( abs(mod(c.x*6.0+vec3(0.0,4.0,2.0),6.0)-3.0)-1.0, 0.0, 1.0 );
3
+ return c.z + c.y * (rgb-0.5)*(1.0-abs(2.0*c.z-1.0));
4
+ }
@@ -0,0 +1,5 @@
1
+ vec3 hsv2rgb(vec3 c) {
2
+ vec4 K = vec4(1.0, 2.0 / 3.0, 1.0 / 3.0, 3.0);
3
+ vec3 p = abs(fract(c.xxx + K.xyz) * 6.0 - K.www);
4
+ return c.z * mix(K.xxx, clamp(p - K.xxx, 0.0, 1.0), c.y);
5
+ }