@colordx/gpu 0.1.0 → 0.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/README.md +8 -3
- package/package.json +2 -2
- package/src/glsl.js +29 -8
- package/src/index.d.ts +11 -1
- package/src/index.js +19 -4
package/README.md
CHANGED
|
@@ -5,9 +5,13 @@
|
|
|
5
5
|
[](https://github.com/dkryaklin/colordx-gpu/blob/main/package.json)
|
|
6
6
|
[](https://github.com/dkryaklin/colordx-gpu/blob/main/LICENSE)
|
|
7
7
|
|
|
8
|
-
|
|
8
|
+
[**@colordx/core**](https://github.com/dkryaklin/colordx)'s color math, running on the GPU.
|
|
9
9
|
|
|
10
|
-
|
|
10
|
+
**The idea:** a color library converts one color at a time, and on the CPU that's the right tool for app logic. But a whole class of color work is *per-pixel*: gamut visualizations, picker charts, gradients, image filters. There you don't want to convert a color — you want to convert **millions of them, every frame**. `@colordx/gpu` takes colordx's conversions — OKLCH/OKLab, CIE LCH/Lab (D50), Display-P3, Rec.2020, gamut tests — and generates GLSL from the library's own constants, so the exact same math runs in a shader at GPU speed. A parity test suite locks the two implementations together: if colordx's math ever changes, the build fails before the shader can drift.
|
|
11
|
+
|
|
12
|
+
What that buys you in practice: work that costs tens of milliseconds across a full worker pool on the CPU takes **well under a millisecond** as a single GPU draw — fast enough to recompute every visible pixel on every frame of a slider drag, at any canvas size up to fullscreen.
|
|
13
|
+
|
|
14
|
+
**What ships today** is the first module built on that foundation: a gamut-slice chart renderer (the core of every OKLCH/LCH picker UI) — slice planes through the color space with sRGB / Display-P3 / Rec.2020 classification and crisp gamut-boundary lines. Next up: the GLSL chunks as public API so you can compose colordx math into your own shaders, gradient strips, and a WebGPU batch-conversion path — see the [Roadmap](#roadmap).
|
|
11
15
|
|
|
12
16
|
## Performance
|
|
13
17
|
|
|
@@ -75,9 +79,10 @@ Renders one gamut slice. Returns `false` while the WebGL context is lost (it re-
|
|
|
75
79
|
| `showP3` | Also paint the P3-only region (sRGB-only pixels otherwise) |
|
|
76
80
|
| `showRec2020` | Also paint the Rec.2020-only region |
|
|
77
81
|
| `borderP3`, `borderRec2020` | RGBA arrays (0–1) for the boundary lines |
|
|
82
|
+
| `borderWidth` | Boundary line width in device pixels (default `1` — a hairline, half a CSS pixel on a 2× display) |
|
|
78
83
|
| `p3Output` | Encode output as Display-P3 and switch the drawing buffer to `display-p3` (Chrome 104+, Safari 16.4+; silently stays sRGB elsewhere) |
|
|
79
84
|
|
|
80
|
-
Pixels outside every enabled gamut are transparent. Boundary lines are analytic contours of the gamut overflow field (`fwidth`-based),
|
|
85
|
+
Pixels outside every enabled gamut are transparent. Boundary lines are anti-aliased analytic contours of the gamut overflow field (`fwidth`-based), `borderWidth` device pixels wide at any canvas size or DPR.
|
|
81
86
|
|
|
82
87
|
### `renderer.destroy()`
|
|
83
88
|
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@colordx/gpu",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"description": "GPU
|
|
3
|
+
"version": "0.2.0",
|
|
4
|
+
"description": "colordx color math on the GPU — OKLCH/LCH conversions and gamut tests as generated GLSL, parity-tested against @colordx/core. Ships a WebGL2 gamut-slice chart renderer; shader chunks and WebGPU batch conversion on the roadmap",
|
|
5
5
|
"author": "Dmitrii Kriaklin",
|
|
6
6
|
"license": "MIT",
|
|
7
7
|
"homepage": "https://colordx.dev",
|
package/src/glsl.js
CHANGED
|
@@ -64,6 +64,7 @@ uniform float u_value;
|
|
|
64
64
|
uniform float u_xMax, u_yMax;
|
|
65
65
|
uniform bool u_showP3, u_showRec2020, u_p3Out;
|
|
66
66
|
uniform vec4 u_borderP3, u_borderRec2020;
|
|
67
|
+
uniform float u_borderWidth; // device pixels
|
|
67
68
|
|
|
68
69
|
const float GAP = 1e-7; // RENDER_GAP twin
|
|
69
70
|
|
|
@@ -76,10 +77,25 @@ float overflow(vec3 c) {
|
|
|
76
77
|
vec3 d = max(c - 1.0, -c);
|
|
77
78
|
return max(d.r, max(d.g, d.b));
|
|
78
79
|
}
|
|
80
|
+
// coverage of a u_borderWidth-px-wide band centered on the field's zero
|
|
81
|
+
// contour: fwidth(field) is the field's change across one device pixel, so
|
|
82
|
+
// dividing by it converts field distance to pixel distance
|
|
83
|
+
float contour(float field) {
|
|
84
|
+
float px = max(fwidth(field), 1e-12);
|
|
85
|
+
return clamp((0.5 * u_borderWidth + 0.5) - abs(field) / px, 0.0, 1.0);
|
|
86
|
+
}
|
|
87
|
+
// composite the border over the fill by its pixel coverage; alpha only
|
|
88
|
+
// grows so a translucent border can't punch a hole in an opaque fill
|
|
89
|
+
vec4 blendBorder(vec4 fill, vec4 border, float cov) {
|
|
90
|
+
float a = cov * border.a;
|
|
91
|
+
return vec4(mix(fill.rgb, border.rgb, a), max(fill.a, a));
|
|
92
|
+
}
|
|
79
93
|
|
|
80
94
|
void main() {
|
|
81
|
-
// gl_FragCoord y is bottom-up, matching the picker's paintPixel flip
|
|
82
|
-
|
|
95
|
+
// gl_FragCoord y is bottom-up, matching the picker's paintPixel flip.
|
|
96
|
+
// Sample where the CPU painter did: column corner on x, and its
|
|
97
|
+
// one-pixel y offset (paintPixel wrote row y to height - y).
|
|
98
|
+
vec2 uv = (gl_FragCoord.xy + vec2(-0.5, 0.5)) / u_res;
|
|
83
99
|
float l, c, h;
|
|
84
100
|
if (u_plane == 0) { l = uv.x * u_xMax; c = uv.y * u_yMax; h = u_value; }
|
|
85
101
|
else if (u_plane == 1) { h = uv.x * u_xMax; c = uv.y * u_yMax; l = u_value; }
|
|
@@ -101,17 +117,22 @@ void main() {
|
|
|
101
117
|
vec3 enc = u_p3Out
|
|
102
118
|
? srgbEncode(clamp(linP3, 0.0, 1.0))
|
|
103
119
|
: srgbEncode(clamp(lin, 0.0, 1.0));
|
|
120
|
+
// match the CPU painter's Math.floor(255 * v) quantization; the small
|
|
121
|
+
// bias absorbs fp32 jitter where 255 * v lands on an integer
|
|
122
|
+
enc = floor(enc * 255.0 + 1e-3) / 255.0;
|
|
104
123
|
col = vec4(enc, 1.0);
|
|
105
124
|
}
|
|
106
125
|
|
|
107
|
-
// boundary lines
|
|
108
|
-
|
|
109
|
-
|
|
126
|
+
// boundary lines: anti-aliased contours of the gamut overflow fields,
|
|
127
|
+
// u_borderWidth device pixels wide (a hairline by default, like the
|
|
128
|
+
// 1-device-pixel lines of a CPU painter)
|
|
129
|
+
float covS = contour(fs);
|
|
130
|
+
float covP = contour(fp);
|
|
110
131
|
if (u_showP3) {
|
|
111
|
-
if (
|
|
112
|
-
if (u_showRec2020 &&
|
|
132
|
+
if (inP) col = blendBorder(col, u_borderP3, covS);
|
|
133
|
+
if (u_showRec2020 && inR && !inS) col = blendBorder(col, u_borderRec2020, covP);
|
|
113
134
|
} else if (u_showRec2020) {
|
|
114
|
-
if (
|
|
135
|
+
if (inR) col = blendBorder(col, u_borderRec2020, covS);
|
|
115
136
|
}
|
|
116
137
|
|
|
117
138
|
frag = vec4(col.rgb * col.a, col.a); // premultiplied alpha
|
package/src/index.d.ts
CHANGED
|
@@ -13,6 +13,12 @@ export interface ChartPaintOptions {
|
|
|
13
13
|
borderP3: BorderRgba
|
|
14
14
|
/** RGBA 0–1 for the P3↔Rec2020 (or sRGB↔Rec2020) boundary line */
|
|
15
15
|
borderRec2020: BorderRgba
|
|
16
|
+
/**
|
|
17
|
+
* Boundary line width in device pixels (default 1 — a hairline, half a
|
|
18
|
+
* CSS pixel on a 2× display). Lines are anti-aliased, so fractional
|
|
19
|
+
* widths work.
|
|
20
|
+
*/
|
|
21
|
+
borderWidth?: number
|
|
16
22
|
/** Encode output for a display-p3 drawing buffer (wide-gamut displays) */
|
|
17
23
|
p3Output?: boolean
|
|
18
24
|
plane: ChartPlane
|
|
@@ -28,7 +34,11 @@ export interface ChartPaintOptions {
|
|
|
28
34
|
|
|
29
35
|
export interface ChartRenderer {
|
|
30
36
|
readonly canvas: HTMLCanvasElement
|
|
31
|
-
/**
|
|
37
|
+
/**
|
|
38
|
+
* Release GPU resources and make the renderer inert. Does not lose the
|
|
39
|
+
* WebGL context (a canvas can only ever produce one), so a new renderer
|
|
40
|
+
* can be created on the same canvas afterwards.
|
|
41
|
+
*/
|
|
32
42
|
destroy(): void
|
|
33
43
|
/** Render a slice; returns false while the WebGL context is lost */
|
|
34
44
|
paint(opts: ChartPaintOptions): boolean
|
package/src/index.js
CHANGED
|
@@ -18,11 +18,16 @@ export function createChartRenderer(canvas, options = {}) {
|
|
|
18
18
|
premultipliedAlpha: true,
|
|
19
19
|
stencil: false,
|
|
20
20
|
})
|
|
21
|
-
if (!gl) return null
|
|
21
|
+
if (!gl || gl.isContextLost()) return null
|
|
22
|
+
|
|
23
|
+
// dithering is on by default and driver-dependent; disable it so the
|
|
24
|
+
// float → 8-bit conversion is deterministic everywhere
|
|
25
|
+
gl.disable(gl.DITHER)
|
|
22
26
|
|
|
23
27
|
let program = null
|
|
24
28
|
let uniforms = null
|
|
25
29
|
let contextLost = false
|
|
30
|
+
let destroyed = false
|
|
26
31
|
|
|
27
32
|
function init() {
|
|
28
33
|
const compile = (type, src) => {
|
|
@@ -45,17 +50,20 @@ export function createChartRenderer(canvas, options = {}) {
|
|
|
45
50
|
uniforms = {}
|
|
46
51
|
for (const name of [
|
|
47
52
|
'u_res', 'u_plane', 'u_value', 'u_xMax', 'u_yMax',
|
|
48
|
-
'u_showP3', 'u_showRec2020', 'u_p3Out',
|
|
53
|
+
'u_showP3', 'u_showRec2020', 'u_p3Out',
|
|
54
|
+
'u_borderP3', 'u_borderRec2020', 'u_borderWidth',
|
|
49
55
|
]) {
|
|
50
56
|
uniforms[name] = gl.getUniformLocation(program, name)
|
|
51
57
|
}
|
|
52
58
|
}
|
|
53
59
|
|
|
54
60
|
canvas.addEventListener('webglcontextlost', e => {
|
|
61
|
+
if (destroyed) return
|
|
55
62
|
e.preventDefault()
|
|
56
63
|
contextLost = true
|
|
57
64
|
})
|
|
58
65
|
canvas.addEventListener('webglcontextrestored', () => {
|
|
66
|
+
if (destroyed) return
|
|
59
67
|
contextLost = false
|
|
60
68
|
init()
|
|
61
69
|
})
|
|
@@ -66,10 +74,16 @@ export function createChartRenderer(canvas, options = {}) {
|
|
|
66
74
|
return {
|
|
67
75
|
canvas,
|
|
68
76
|
destroy() {
|
|
69
|
-
|
|
77
|
+
// Do NOT force-lose the context: a canvas can only ever produce one
|
|
78
|
+
// WebGL context, so losing it would break any later renderer on the
|
|
79
|
+
// same canvas (e.g. a React StrictMode remount). Just release the
|
|
80
|
+
// program and go inert; the context is reclaimed with the canvas.
|
|
81
|
+
destroyed = true
|
|
82
|
+
gl.deleteProgram(program)
|
|
83
|
+
program = null
|
|
70
84
|
},
|
|
71
85
|
paint(opts) {
|
|
72
|
-
if (contextLost) return false
|
|
86
|
+
if (destroyed || contextLost || !program) return false
|
|
73
87
|
|
|
74
88
|
if ('drawingBufferColorSpace' in gl) {
|
|
75
89
|
try {
|
|
@@ -92,6 +106,7 @@ export function createChartRenderer(canvas, options = {}) {
|
|
|
92
106
|
gl.uniform1i(uniforms.u_p3Out, opts.p3Output ? 1 : 0)
|
|
93
107
|
gl.uniform4fv(uniforms.u_borderP3, opts.borderP3)
|
|
94
108
|
gl.uniform4fv(uniforms.u_borderRec2020, opts.borderRec2020)
|
|
109
|
+
gl.uniform1f(uniforms.u_borderWidth, opts.borderWidth ?? 1)
|
|
95
110
|
gl.drawArrays(gl.TRIANGLES, 0, 3)
|
|
96
111
|
return true
|
|
97
112
|
},
|