@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 +21 -0
- package/README.md +125 -0
- package/package.json +55 -0
- package/src/constants.js +60 -0
- package/src/glsl.js +119 -0
- package/src/index.d.ts +52 -0
- package/src/index.js +99 -0
- package/src/math.d.ts +5 -0
- package/src/math.js +71 -0
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
|
+
[](https://www.npmjs.com/package/@colordx/gpu)
|
|
4
|
+
[](https://bundlejs.com/?q=@colordx/gpu)
|
|
5
|
+
[](https://github.com/dkryaklin/colordx-gpu/blob/main/package.json)
|
|
6
|
+
[](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
|
+
}
|
package/src/constants.js
ADDED
|
@@ -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
|
+
}
|