@colordx/gpu 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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Dmitrii Kriaklin
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,125 @@
1
+ # @colordx/gpu
2
+
3
+ [![npm version](https://img.shields.io/npm/v/@colordx/gpu?labelColor=764be5&color=ffc200)](https://www.npmjs.com/package/@colordx/gpu)
4
+ [![bundle size](https://img.shields.io/bundlejs/size/@colordx/gpu?labelColor=764be5&color=ffc200)](https://bundlejs.com/?q=@colordx/gpu)
5
+ [![zero dependencies](https://img.shields.io/badge/dependencies-0-ffc200?labelColor=764be5)](https://github.com/dkryaklin/colordx-gpu/blob/main/package.json)
6
+ [![MIT license](https://img.shields.io/badge/license-MIT-ffc200?labelColor=764be5)](https://github.com/dkryaklin/colordx-gpu/blob/main/LICENSE)
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.
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.
11
+
12
+ ## Performance
13
+
14
+ Measured in the [oklch-picker](https://github.com/evilmartians/oklch-picker) integration (three charts, full repaint on a component change), using the picker's built-in `?bench` overlay. Headless Chrome with software GL — real GPUs are faster still:
15
+
16
+ | | Chart paint | Worker time |
17
+ |---|---|---|
18
+ | **@colordx/gpu** | **~5 ms** | — (single main-thread draw, ~1 ms submit) |
19
+ | CPU worker pool | 30 ms (warm) – 950 ms (cold) | 30–930 ms across all cores |
20
+
21
+ ## Install
22
+
23
+ ```bash
24
+ npm install @colordx/gpu
25
+ ```
26
+
27
+ No runtime dependencies. `@colordx/core` is not required at runtime — the relevant math is baked into the generated shader and verified against the library by the test suite.
28
+
29
+ ## Quick start
30
+
31
+ ```js
32
+ import { createChartRenderer } from '@colordx/gpu';
33
+
34
+ const renderer = createChartRenderer(canvas, { model: 'oklch' });
35
+ if (!renderer) {
36
+ // WebGL2 unavailable — fall back to your CPU painting path.
37
+ }
38
+
39
+ renderer.paint({
40
+ plane: 'cl', // x: lightness, y: chroma, fixed hue
41
+ value: 264, // the fixed component (hue here)
42
+ xMax: 1, // lightness at the right edge
43
+ yMax: 0.37, // chroma at the top edge
44
+ showP3: true,
45
+ showRec2020: false,
46
+ borderP3: [1, 1, 1, 1], // RGBA 0–1 for the sRGB↔P3 line
47
+ borderRec2020: [1, 1, 1, 1], // RGBA 0–1 for the P3↔Rec2020 line
48
+ p3Output: true, // encode for a display-p3 drawing buffer
49
+ });
50
+ ```
51
+
52
+ Each `paint()` is one full-canvas draw — call it as often as you like (every frame during a slider drag is fine).
53
+
54
+ ## API
55
+
56
+ ### `createChartRenderer(canvas, options?)`
57
+
58
+ Creates a WebGL2 renderer on the canvas. Returns `null` when WebGL2 is unavailable — keep a CPU fallback for that case.
59
+
60
+ > **One-way door:** a canvas that has handed out a WebGL context can never provide a `'2d'` context again. Decide GPU vs CPU per canvas *before* the first paint.
61
+
62
+ | Option | Default | Description |
63
+ |---|---|---|
64
+ | `model` | `'oklch'` | `'oklch'` or `'lch'` (CIE LCH, D50 white point, CSS Color 4 semantics) |
65
+
66
+ ### `renderer.paint(opts)`
67
+
68
+ Renders one gamut slice. Returns `false` while the WebGL context is lost (it re-initializes automatically on restore).
69
+
70
+ | Option | Description |
71
+ |---|---|
72
+ | `plane` | `'cl'` (x: L, y: C, fixed H) · `'ch'` (x: H, y: C, fixed L) · `'lh'` (x: H, y: L, fixed C) |
73
+ | `value` | The fixed component, in the model's native scale (OKLCH: L 0–1, C ~0–0.4; LCH: L 0–100, C ~0–150) |
74
+ | `xMax`, `yMax` | Component values at the right / top edges |
75
+ | `showP3` | Also paint the P3-only region (sRGB-only pixels otherwise) |
76
+ | `showRec2020` | Also paint the Rec.2020-only region |
77
+ | `borderP3`, `borderRec2020` | RGBA arrays (0–1) for the boundary lines |
78
+ | `p3Output` | Encode output as Display-P3 and switch the drawing buffer to `display-p3` (Chrome 104+, Safari 16.4+; silently stays sRGB elsewhere) |
79
+
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.
81
+
82
+ ### `renderer.destroy()`
83
+
84
+ Releases the WebGL context.
85
+
86
+ ### `math`
87
+
88
+ The JS twin of the shader math, exported for reference and testing:
89
+
90
+ ```js
91
+ import { math } from '@colordx/gpu';
92
+ math.oklchToLinearSrgb(0.7, 0.1, 150); // [r, g, b] linear, unclamped
93
+ ```
94
+
95
+ ## Parity with @colordx/core
96
+
97
+ The GLSL is generated from [`src/constants.js`](src/constants.js), which mirrors colordx's source constants — OKLab matrices, the Lab D50 path with Bradford adaptation, P3 and Rec.2020 primaries. `npm test` verifies the constants-based math against `@colordx/core` itself over a dense sample grid (agreement < 1e-9; the GPU then runs the same math in float32, more than enough for rendering). If colordx's math ever changes, the parity test fails before the shader can drift.
98
+
99
+ ## Demo
100
+
101
+ ```bash
102
+ npm run demo
103
+ ```
104
+
105
+ opens a live three-plane chart demo (vite). Or see it in production shape inside [oklch-picker](https://github.com/evilmartians/oklch-picker), where this package replaces a `hardwareConcurrency`-wide worker pool — add `?bench` to compare, `?nogpu` to force the CPU path.
106
+
107
+ ## Browser support
108
+
109
+ WebGL2 — all evergreen browsers (~98% global). `display-p3` output additionally needs Chrome 104+ / Safari 16.4+; on other browsers wide-gamut colors are clipped to sRGB for display (classification is unaffected). `createChartRenderer` returns `null` rather than throwing when unsupported.
110
+
111
+ ## Roadmap
112
+
113
+ - **v0.2 — `@colordx/gpu/glsl`**: the conversion and gamut-test shader chunks as documented public API, so you can compose colordx math into your own shaders.
114
+ - **v0.3 — gradient strips**: a 1D renderer for slider tracks and gradient previews (the other half of every color-picker UI).
115
+ - **v0.4 — `@colordx/gpu/batch`**: WebGPU compute path — `convertBatch(Float32Array, from, to)` for bulk numeric conversion with async readback, for workloads that need numbers back, not pixels.
116
+ - **Later**: 3D gamut-body renderer, WGSL chunk variants, OffscreenCanvas/worker rendering, generated-from-source constants (import directly from `@colordx/core` at build time).
117
+
118
+ ## Ecosystem
119
+
120
+ - [**@colordx/core**](https://github.com/dkryaklin/colordx) — the color library this package renders for: parsing, conversion, manipulation, gamut mapping. [colordx.dev](https://colordx.dev)
121
+ - [**oklch-picker**](https://github.com/evilmartians/oklch-picker) — OKLCH color picker; first production consumer of this renderer.
122
+
123
+ ## License
124
+
125
+ [MIT](LICENSE) © Dmitrii Kriaklin
package/package.json ADDED
@@ -0,0 +1,55 @@
1
+ {
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",
5
+ "author": "Dmitrii Kriaklin",
6
+ "license": "MIT",
7
+ "homepage": "https://colordx.dev",
8
+ "repository": {
9
+ "type": "git",
10
+ "url": "git+https://github.com/dkryaklin/colordx-gpu.git"
11
+ },
12
+ "bugs": {
13
+ "url": "https://github.com/dkryaklin/colordx-gpu/issues"
14
+ },
15
+ "type": "module",
16
+ "main": "./src/index.js",
17
+ "types": "./src/index.d.ts",
18
+ "exports": {
19
+ ".": {
20
+ "types": "./src/index.d.ts",
21
+ "default": "./src/index.js"
22
+ }
23
+ },
24
+ "files": [
25
+ "src"
26
+ ],
27
+ "sideEffects": false,
28
+ "publishConfig": {
29
+ "access": "public"
30
+ },
31
+ "scripts": {
32
+ "demo": "npx -y vite --open /demo/",
33
+ "prepublishOnly": "npm test",
34
+ "test": "node --test 'test/*.test.mjs'"
35
+ },
36
+ "keywords": [
37
+ "color",
38
+ "oklch",
39
+ "oklab",
40
+ "lch",
41
+ "webgl",
42
+ "webgl2",
43
+ "gpu",
44
+ "shader",
45
+ "display-p3",
46
+ "rec2020",
47
+ "wide-gamut",
48
+ "gamut",
49
+ "color-picker",
50
+ "colordx"
51
+ ],
52
+ "devDependencies": {
53
+ "@colordx/core": "^5.4.2"
54
+ }
55
+ }
@@ -0,0 +1,60 @@
1
+ // Numeric constants mirrored from @colordx/core source. The parity test in
2
+ // test/parity.test.mjs asserts these stay byte-identical with the library, so
3
+ // the GPU and CPU pipelines can never silently drift apart.
4
+
5
+ // src/colorModels/oklab.ts — OKLab → LMS' (a/b contributions) and LMS → linear sRGB
6
+ export const OKLAB = {
7
+ M2I_A_L: 0.3963377774,
8
+ M2I_B_L: 0.2158037573,
9
+ M2I_A_M: -0.1055613458,
10
+ M2I_B_M: -0.0638541728,
11
+ M2I_A_S: -0.0894841775,
12
+ M2I_B_S: -1.291485548,
13
+ M1I_L_R: 4.0767416613,
14
+ M1I_M_R: -3.3077115904,
15
+ M1I_S_R: 0.2309699287,
16
+ M1I_L_G: -1.2684380041,
17
+ M1I_M_G: 2.6097574007,
18
+ M1I_S_G: -0.3413193963,
19
+ M1I_L_B: -0.0041960865,
20
+ M1I_M_B: -0.7034186145,
21
+ M1I_S_B: 1.7076147009,
22
+ }
23
+
24
+ // src/colorModels/lab.ts + xyz.ts — CIE Lab (D50) path
25
+ export const LAB = {
26
+ EPSILON: 216 / 24389,
27
+ KAPPA: 24389 / 27,
28
+ WX: 96.42956752983539,
29
+ WY: 100,
30
+ WZ: 82.51046025104603,
31
+ }
32
+
33
+ // src/colorModels/xyz.ts — Bradford D50 → D65 (CSS Color 4), row-major
34
+ export const D50_TO_D65 = [
35
+ 0.955473421488075, -0.02309845494876471, 0.06325924320057072,
36
+ -0.0283697093338637, 1.0099953980813041, 0.021041441191917323,
37
+ 0.012314014864481998, -0.020507649298898964, 1.330365926242124,
38
+ ]
39
+
40
+ // src/colorModels/xyz.ts — XYZ D65 (0–100 scale) → linear sRGB, row-major
41
+ export const XYZ_TO_SRGB = [
42
+ 0.032409699419045213, -0.015373831775700935, -0.0049861076029300327,
43
+ -0.0096924363628087984, 0.018759675015077206, 0.00041555057407175612,
44
+ 0.00055630079696993608, -0.0020397695888897657, 0.010569715142428786,
45
+ ]
46
+
47
+ // src/colorModels/p3.ts — linear sRGB → linear Display-P3, row-major
48
+ // (zero blue contributions on the r/g rows are correct per spec)
49
+ export const SRGB_TO_P3 = [
50
+ 0.8224619687, 0.1775380313, 0,
51
+ 0.0331941989, 0.9668058011, 0,
52
+ 0.0170826307, 0.0723974407, 0.9105199286,
53
+ ]
54
+
55
+ // src/colorModels/rec2020.ts — linear sRGB → linear Rec.2020, row-major
56
+ export const SRGB_TO_REC2020 = [
57
+ 0.6274038959, 0.3292830384, 0.0433130657,
58
+ 0.0690972894, 0.9195403951, 0.0113623156,
59
+ 0.0163914389, 0.0880133079, 0.8955952532,
60
+ ]
package/src/glsl.js ADDED
@@ -0,0 +1,119 @@
1
+ // GLSL sources, generated from the same constants module as the JS twin so
2
+ // shader math is colordx math by construction.
3
+
4
+ import { D50_TO_D65, LAB, OKLAB, SRGB_TO_P3, SRGB_TO_REC2020, XYZ_TO_SRGB } from './constants.js'
5
+
6
+ // format a JS number as a GLSL float literal
7
+ const f = n => {
8
+ const s = String(n)
9
+ return /[.e]/.test(s) ? s : s + '.0'
10
+ }
11
+
12
+ // row-major 3x3 → GLSL expression applying it to vec3 `v`
13
+ const mulRow = (M, v, row) =>
14
+ `${f(M[row * 3])} * ${v}.x + ${f(M[row * 3 + 1])} * ${v}.y + ${f(M[row * 3 + 2])} * ${v}.z`
15
+ const mul3 = (M, v) =>
16
+ `vec3(${mulRow(M, v, 0)}, ${mulRow(M, v, 1)}, ${mulRow(M, v, 2)})`
17
+
18
+ export const VERTEX = `#version 300 es
19
+ void main() {
20
+ vec2 p = vec2(gl_VertexID == 1 ? 3.0 : -1.0, gl_VertexID == 2 ? 3.0 : -1.0);
21
+ gl_Position = vec4(p, 0.0, 1.0);
22
+ }`
23
+
24
+ const OKLCH_TO_LINEAR = `
25
+ vec3 toLinearSrgb(float l, float c, float h) {
26
+ float hr = radians(h);
27
+ float a = c * cos(hr);
28
+ float b = c * sin(hr);
29
+ float l_ = l + ${f(OKLAB.M2I_A_L)} * a + ${f(OKLAB.M2I_B_L)} * b;
30
+ float m_ = l + ${f(OKLAB.M2I_A_M)} * a + ${f(OKLAB.M2I_B_M)} * b;
31
+ float s_ = l + ${f(OKLAB.M2I_A_S)} * a + ${f(OKLAB.M2I_B_S)} * b;
32
+ float l3 = l_ * l_ * l_;
33
+ float m3 = m_ * m_ * m_;
34
+ float s3 = s_ * s_ * s_;
35
+ return vec3(
36
+ ${f(OKLAB.M1I_L_R)} * l3 + ${f(OKLAB.M1I_M_R)} * m3 + ${f(OKLAB.M1I_S_R)} * s3,
37
+ ${f(OKLAB.M1I_L_G)} * l3 + ${f(OKLAB.M1I_M_G)} * m3 + ${f(OKLAB.M1I_S_G)} * s3,
38
+ ${f(OKLAB.M1I_L_B)} * l3 + ${f(OKLAB.M1I_M_B)} * m3 + ${f(OKLAB.M1I_S_B)} * s3);
39
+ }`
40
+
41
+ const LCH_TO_LINEAR = `
42
+ vec3 toLinearSrgb(float l, float c, float h) {
43
+ float hr = radians(h);
44
+ float a = c * cos(hr);
45
+ float b = c * sin(hr);
46
+ float fy = (l + 16.0) / 116.0;
47
+ float fx = a / 500.0 + fy;
48
+ float fz = fy - b / 200.0;
49
+ vec3 xyz = vec3(
50
+ (fx * fx * fx > ${f(LAB.EPSILON)} ? fx * fx * fx : (116.0 * fx - 16.0) / ${f(LAB.KAPPA)}) * ${f(LAB.WX)},
51
+ (l > 8.0 ? fy * fy * fy : l / ${f(LAB.KAPPA)}) * ${f(LAB.WY)},
52
+ (fz * fz * fz > ${f(LAB.EPSILON)} ? fz * fz * fz : (116.0 * fz - 16.0) / ${f(LAB.KAPPA)}) * ${f(LAB.WZ)});
53
+ vec3 d65 = ${mul3(D50_TO_D65, 'xyz')};
54
+ return ${mul3(XYZ_TO_SRGB, 'd65')};
55
+ }`
56
+
57
+ export function buildFragment(model) {
58
+ return `#version 300 es
59
+ precision highp float;
60
+ out vec4 frag;
61
+ uniform vec2 u_res;
62
+ uniform int u_plane; // 0 = cl, 1 = ch, 2 = lh
63
+ uniform float u_value;
64
+ uniform float u_xMax, u_yMax;
65
+ uniform bool u_showP3, u_showRec2020, u_p3Out;
66
+ uniform vec4 u_borderP3, u_borderRec2020;
67
+
68
+ const float GAP = 1e-7; // RENDER_GAP twin
69
+
70
+ ${model === 'lch' ? LCH_TO_LINEAR : OKLCH_TO_LINEAR}
71
+
72
+ vec3 srgbEncode(vec3 c) {
73
+ return mix(12.92 * c, 1.055 * pow(c, vec3(1.0 / 2.4)) - 0.055, step(0.0031308, c));
74
+ }
75
+ float overflow(vec3 c) {
76
+ vec3 d = max(c - 1.0, -c);
77
+ return max(d.r, max(d.g, d.b));
78
+ }
79
+
80
+ void main() {
81
+ // gl_FragCoord y is bottom-up, matching the picker's paintPixel flip
82
+ vec2 uv = gl_FragCoord.xy / u_res;
83
+ float l, c, h;
84
+ if (u_plane == 0) { l = uv.x * u_xMax; c = uv.y * u_yMax; h = u_value; }
85
+ else if (u_plane == 1) { h = uv.x * u_xMax; c = uv.y * u_yMax; l = u_value; }
86
+ else { h = uv.x * u_xMax; l = uv.y * u_yMax; c = u_value; }
87
+
88
+ vec3 lin = toLinearSrgb(l, c, h);
89
+ float fs = overflow(lin);
90
+ vec3 linP3 = ${mul3(SRGB_TO_P3, 'lin')};
91
+ float fp = overflow(linP3);
92
+ vec3 linR2 = ${mul3(SRGB_TO_REC2020, 'lin')};
93
+ float f20 = overflow(linR2);
94
+
95
+ bool inS = fs <= GAP;
96
+ bool inP = fp <= GAP;
97
+ bool inR = f20 <= GAP;
98
+
99
+ vec4 col = vec4(0.0);
100
+ if (inS || (u_showP3 && inP) || (u_showRec2020 && inR)) {
101
+ vec3 enc = u_p3Out
102
+ ? srgbEncode(clamp(linP3, 0.0, 1.0))
103
+ : srgbEncode(clamp(lin, 0.0, 1.0));
104
+ col = vec4(enc, 1.0);
105
+ }
106
+
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);
110
+ if (u_showP3) {
111
+ if (abs(fs) < wS && inP) col = u_borderP3;
112
+ if (u_showRec2020 && abs(fp) < wP && inR && !inS) col = u_borderRec2020;
113
+ } else if (u_showRec2020) {
114
+ if (abs(fs) < wS && inR) col = u_borderRec2020;
115
+ }
116
+
117
+ frag = vec4(col.rgb * col.a, col.a); // premultiplied alpha
118
+ }`
119
+ }
package/src/index.d.ts ADDED
@@ -0,0 +1,52 @@
1
+ /**
2
+ * The slice plane to render:
3
+ * - `'cl'` — x: lightness, y: chroma, fixed hue (the H chart)
4
+ * - `'ch'` — x: hue, y: chroma, fixed lightness (the L chart)
5
+ * - `'lh'` — x: hue, y: lightness, fixed chroma (the C chart)
6
+ */
7
+ export type ChartPlane = 'ch' | 'cl' | 'lh'
8
+
9
+ export type BorderRgba = [number, number, number, number] | Float32Array
10
+
11
+ export interface ChartPaintOptions {
12
+ /** RGBA 0–1 for the sRGB↔P3 boundary line */
13
+ borderP3: BorderRgba
14
+ /** RGBA 0–1 for the P3↔Rec2020 (or sRGB↔Rec2020) boundary line */
15
+ borderRec2020: BorderRgba
16
+ /** Encode output for a display-p3 drawing buffer (wide-gamut displays) */
17
+ p3Output?: boolean
18
+ plane: ChartPlane
19
+ showP3: boolean
20
+ showRec2020: boolean
21
+ /** The fixed component, in the model's native scale */
22
+ value: number
23
+ /** Component value at the right edge (e.g. 360 for hue, L_MAX for lightness) */
24
+ xMax: number
25
+ /** Component value at the top edge (e.g. C_MAX) */
26
+ yMax: number
27
+ }
28
+
29
+ export interface ChartRenderer {
30
+ readonly canvas: HTMLCanvasElement
31
+ /** Release the WebGL context */
32
+ destroy(): void
33
+ /** Render a slice; returns false while the WebGL context is lost */
34
+ paint(opts: ChartPaintOptions): boolean
35
+ }
36
+
37
+ export interface ChartRendererOptions {
38
+ /** Color model: OKLCH (default) or CIE LCH (D50) */
39
+ model?: 'lch' | 'oklch'
40
+ }
41
+
42
+ /**
43
+ * Create a WebGL2 chart renderer on the canvas, or null when WebGL2 is
44
+ * unavailable. Note: the canvas can no longer provide a '2d' context after
45
+ * this succeeds — decide GPU vs CPU before the first paint.
46
+ */
47
+ export function createChartRenderer(
48
+ canvas: HTMLCanvasElement,
49
+ options?: ChartRendererOptions
50
+ ): ChartRenderer | null
51
+
52
+ export * as math from './math.js'
package/src/index.js ADDED
@@ -0,0 +1,99 @@
1
+ import { buildFragment, VERTEX } from './glsl.js'
2
+
3
+ export * as math from './math.js'
4
+
5
+ /**
6
+ * Create a WebGL2 chart renderer on the given canvas. Returns null when
7
+ * WebGL2 is unavailable — callers should fall back to a CPU path.
8
+ *
9
+ * The canvas becomes a WebGL canvas: it can no longer hand out a '2d'
10
+ * context, so decide GPU vs CPU before the first paint.
11
+ */
12
+ export function createChartRenderer(canvas, options = {}) {
13
+ const model = options.model === 'lch' ? 'lch' : 'oklch'
14
+ const gl = canvas.getContext('webgl2', {
15
+ alpha: true,
16
+ antialias: false,
17
+ depth: false,
18
+ premultipliedAlpha: true,
19
+ stencil: false,
20
+ })
21
+ if (!gl) return null
22
+
23
+ let program = null
24
+ let uniforms = null
25
+ let contextLost = false
26
+
27
+ function init() {
28
+ const compile = (type, src) => {
29
+ const s = gl.createShader(type)
30
+ gl.shaderSource(s, src)
31
+ gl.compileShader(s)
32
+ if (!gl.getShaderParameter(s, gl.COMPILE_STATUS)) {
33
+ throw new Error('[@colordx/gpu] shader: ' + gl.getShaderInfoLog(s))
34
+ }
35
+ return s
36
+ }
37
+ program = gl.createProgram()
38
+ gl.attachShader(program, compile(gl.VERTEX_SHADER, VERTEX))
39
+ gl.attachShader(program, compile(gl.FRAGMENT_SHADER, buildFragment(model)))
40
+ gl.linkProgram(program)
41
+ if (!gl.getProgramParameter(program, gl.LINK_STATUS)) {
42
+ throw new Error('[@colordx/gpu] link: ' + gl.getProgramInfoLog(program))
43
+ }
44
+ gl.useProgram(program)
45
+ uniforms = {}
46
+ for (const name of [
47
+ 'u_res', 'u_plane', 'u_value', 'u_xMax', 'u_yMax',
48
+ 'u_showP3', 'u_showRec2020', 'u_p3Out', 'u_borderP3', 'u_borderRec2020',
49
+ ]) {
50
+ uniforms[name] = gl.getUniformLocation(program, name)
51
+ }
52
+ }
53
+
54
+ canvas.addEventListener('webglcontextlost', e => {
55
+ e.preventDefault()
56
+ contextLost = true
57
+ })
58
+ canvas.addEventListener('webglcontextrestored', () => {
59
+ contextLost = false
60
+ init()
61
+ })
62
+ init()
63
+
64
+ const PLANES = { ch: 1, cl: 0, lh: 2 }
65
+
66
+ return {
67
+ canvas,
68
+ destroy() {
69
+ gl.getExtension('WEBGL_lose_context')?.loseContext()
70
+ },
71
+ paint(opts) {
72
+ if (contextLost) return false
73
+
74
+ if ('drawingBufferColorSpace' in gl) {
75
+ try {
76
+ gl.drawingBufferColorSpace = opts.p3Output ? 'display-p3' : 'srgb'
77
+ } catch {
78
+ // browser without display-p3 WebGL support: stay in sRGB
79
+ }
80
+ }
81
+
82
+ gl.viewport(0, 0, canvas.width, canvas.height)
83
+ gl.clearColor(0, 0, 0, 0)
84
+ gl.clear(gl.COLOR_BUFFER_BIT)
85
+ gl.uniform2f(uniforms.u_res, canvas.width, canvas.height)
86
+ gl.uniform1i(uniforms.u_plane, PLANES[opts.plane] ?? 0)
87
+ gl.uniform1f(uniforms.u_value, opts.value)
88
+ gl.uniform1f(uniforms.u_xMax, opts.xMax)
89
+ gl.uniform1f(uniforms.u_yMax, opts.yMax)
90
+ gl.uniform1i(uniforms.u_showP3, opts.showP3 ? 1 : 0)
91
+ gl.uniform1i(uniforms.u_showRec2020, opts.showRec2020 ? 1 : 0)
92
+ gl.uniform1i(uniforms.u_p3Out, opts.p3Output ? 1 : 0)
93
+ gl.uniform4fv(uniforms.u_borderP3, opts.borderP3)
94
+ gl.uniform4fv(uniforms.u_borderRec2020, opts.borderRec2020)
95
+ gl.drawArrays(gl.TRIANGLES, 0, 3)
96
+ return true
97
+ },
98
+ }
99
+ }
package/src/math.d.ts ADDED
@@ -0,0 +1,5 @@
1
+ export function oklchToLinearSrgb(l: number, c: number, h: number): [number, number, number]
2
+ export function lchToLinearSrgb(l: number, c: number, h: number): [number, number, number]
3
+ export function srgbLinearToP3Linear(r: number, g: number, b: number): [number, number, number]
4
+ export function srgbLinearToRec2020Linear(r: number, g: number, b: number): [number, number, number]
5
+ export function srgbFromLinear(n: number): number
package/src/math.js ADDED
@@ -0,0 +1,71 @@
1
+ // JS twin of the shader math, built from the same constants module that
2
+ // generates the GLSL. Used by the parity test (vs @colordx/core) and usable
3
+ // as a reference implementation.
4
+
5
+ import { D50_TO_D65, LAB, OKLAB, SRGB_TO_P3, SRGB_TO_REC2020, XYZ_TO_SRGB } from './constants.js'
6
+
7
+ const DEG_TO_RAD = Math.PI / 180
8
+
9
+ export function oklchToLinearSrgb(l, c, h) {
10
+ const hr = h * DEG_TO_RAD
11
+ const a = c * Math.cos(hr)
12
+ const b = c * Math.sin(hr)
13
+ const l_ = l + OKLAB.M2I_A_L * a + OKLAB.M2I_B_L * b
14
+ const m_ = l + OKLAB.M2I_A_M * a + OKLAB.M2I_B_M * b
15
+ const s_ = l + OKLAB.M2I_A_S * a + OKLAB.M2I_B_S * b
16
+ const l3 = l_ ** 3
17
+ const m3 = m_ ** 3
18
+ const s3 = s_ ** 3
19
+ return [
20
+ OKLAB.M1I_L_R * l3 + OKLAB.M1I_M_R * m3 + OKLAB.M1I_S_R * s3,
21
+ OKLAB.M1I_L_G * l3 + OKLAB.M1I_M_G * m3 + OKLAB.M1I_S_G * s3,
22
+ OKLAB.M1I_L_B * l3 + OKLAB.M1I_M_B * m3 + OKLAB.M1I_S_B * s3,
23
+ ]
24
+ }
25
+
26
+ export function lchToLinearSrgb(l, c, h) {
27
+ const hr = h * DEG_TO_RAD
28
+ const a = c * Math.cos(hr)
29
+ const b = c * Math.sin(hr)
30
+ const fy = (l + 16) / 116
31
+ const fx = a / 500 + fy
32
+ const fz = fy - b / 200
33
+ const x = (fx ** 3 > LAB.EPSILON ? fx ** 3 : (116 * fx - 16) / LAB.KAPPA) * LAB.WX
34
+ const y = (l > 8 ? fy ** 3 : l / LAB.KAPPA) * LAB.WY
35
+ const z = (fz ** 3 > LAB.EPSILON ? fz ** 3 : (116 * fz - 16) / LAB.KAPPA) * LAB.WZ
36
+ const M = D50_TO_D65
37
+ const xd65 = M[0] * x + M[1] * y + M[2] * z
38
+ const yd65 = M[3] * x + M[4] * y + M[5] * z
39
+ const zd65 = M[6] * x + M[7] * y + M[8] * z
40
+ const X = XYZ_TO_SRGB
41
+ return [
42
+ X[0] * xd65 + X[1] * yd65 + X[2] * zd65,
43
+ X[3] * xd65 + X[4] * yd65 + X[5] * zd65,
44
+ X[6] * xd65 + X[7] * yd65 + X[8] * zd65,
45
+ ]
46
+ }
47
+
48
+ export function srgbLinearToP3Linear(r, g, b) {
49
+ const M = SRGB_TO_P3
50
+ return [
51
+ M[0] * r + M[1] * g + M[2] * b,
52
+ M[3] * r + M[4] * g + M[5] * b,
53
+ M[6] * r + M[7] * g + M[8] * b,
54
+ ]
55
+ }
56
+
57
+ export function srgbLinearToRec2020Linear(r, g, b) {
58
+ const M = SRGB_TO_REC2020
59
+ return [
60
+ M[0] * r + M[1] * g + M[2] * b,
61
+ M[3] * r + M[4] * g + M[5] * b,
62
+ M[6] * r + M[7] * g + M[8] * b,
63
+ ]
64
+ }
65
+
66
+ // sRGB / Display-P3 transfer, extended sign-preserving per CSS Color 4
67
+ export function srgbFromLinear(n) {
68
+ const abs = Math.abs(n)
69
+ const encoded = abs <= 0.0031308 ? 12.92 * abs : 1.055 * abs ** (1 / 2.4) - 0.055
70
+ return n < 0 ? -encoded : encoded
71
+ }