@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 CHANGED
@@ -5,9 +5,13 @@
5
5
  [![zero dependencies](https://img.shields.io/badge/dependencies-0-ffc200?labelColor=764be5)](https://github.com/dkryaklin/colordx-gpu/blob/main/package.json)
6
6
  [![MIT license](https://img.shields.io/badge/license-MIT-ffc200?labelColor=764be5)](https://github.com/dkryaklin/colordx-gpu/blob/main/LICENSE)
7
7
 
8
- GPU companion for [**@colordx/core**](https://github.com/dkryaklin/colordx) — renders OKLCH / CIE LCH gamut-slice charts in a single WebGL2 draw call, with sRGB / Display-P3 / Rec.2020 classification and crisp gamut-boundary lines. The shader math is generated from colordx's own constants, so the GPU renders exactly the colors the library computes.
8
+ [**@colordx/core**](https://github.com/dkryaklin/colordx)'s color math, running on the GPU.
9
9
 
10
- A full chart repaint every pixel converted from OKLCH, classified against three gamuts, boundary lines drawn takes **well under a millisecond** on the GPU. The same work on the CPU costs tens of milliseconds across a whole worker pool. That turns "repaint after the slider settles" into "repaint every frame while dragging", and removes pixel count as a constraint: charts can be fullscreen.
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), so they stay ~1.5 px crisp at any canvas size or DPR.
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.1.0",
4
- "description": "GPU companion for @colordx/core WebGL2 gamut-slice chart rendering with sRGB/Display-P3/Rec.2020 classification, generated from colordx's own color math",
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
- vec2 uv = gl_FragCoord.xy / u_res;
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, ~1.5px crisp contours of the gamut overflow fields
108
- float wS = 0.75 * fwidth(fs);
109
- float wP = 0.75 * fwidth(fp);
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 (abs(fs) < wS && inP) col = u_borderP3;
112
- if (u_showRec2020 && abs(fp) < wP && inR && !inS) col = u_borderRec2020;
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 (abs(fs) < wS && inR) col = u_borderRec2020;
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
- /** Release the WebGL context */
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', 'u_borderP3', 'u_borderRec2020',
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
- gl.getExtension('WEBGL_lose_context')?.loseContext()
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
  },