@colordx/gpu 0.1.1 → 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
@@ -79,9 +79,10 @@ Renders one gamut slice. Returns `false` while the WebGL context is lost (it re-
79
79
  | `showP3` | Also paint the P3-only region (sRGB-only pixels otherwise) |
80
80
  | `showRec2020` | Also paint the Rec.2020-only region |
81
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) |
82
83
  | `p3Output` | Encode output as Display-P3 and switch the drawing buffer to `display-p3` (Chrome 104+, Safari 16.4+; silently stays sRGB elsewhere) |
83
84
 
84
- 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.
85
86
 
86
87
  ### `renderer.destroy()`
87
88
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@colordx/gpu",
3
- "version": "0.1.1",
3
+ "version": "0.2.0",
4
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",
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
  },