Introduction
Earlier this year I built @colordx/core, the fastest color library in JavaScript: parsing multiple formats, converting to OKLAB/OKLCH, checking P3-gamut membership, and more. The whole thing weighs 7 KB, has zero dependencies, and its speed comes down to V8-specific optimizations — monomorphism, stable hidden classes, zero extra allocations. I wrote about that work in a separate article.
For everyday use — one color on click, one on hover, one while parsing a CSS value — that's already more than enough. On an Apple M4, converting HEX to OKLCH takes about 200 ns, which works out to roughly 5 million conversions per second on a single core.
Real-time rendering is a different picture. If the screen shows a slice of color space and repaints from scratch on every frame, every pixel needs its own conversion. Even a modest 1024×1024 window at 60 fps is more than a million pixels per frame — over 60 million conversions per second.
The gap between 5M and 60M is an order of magnitude. We're up against plain arithmetic here, and there's no way to shrink the number of operations. What we can change is the architecture — pick the tool that was actually built for this kind of work. That's how @colordx/gpu came about.
CPU vs. GPU: a difference in architecture
A CPU is a small number of very smart cores. Even a top-end CPU in a powerful laptop tops out at a couple dozen cores. Each one predicts branches, executes instructions speculatively, and runs a multi-level cache efficiently. It's the ideal architecture for sequential logic: parsing JSON, walking a tree, querying a database.
A GPU has orders of magnitude more cores — an RTX 5090, for instance, has 21,760 CUDA cores. But each one is individually weaker than a CPU core: GPU clock speeds sit around 1.5–2.5 GHz versus 2–5+ GHz on a CPU, and a single CPU core finishes a single complex task faster than a single GPU core would.
Core for core, the GPU loses. Its advantage isn't quality, it's quantity: thousands of weak cores working on one problem at once, while the CPU works through the same problem sequentially on a handful of strong ones.
That's why using a GPU to convert a single color doesn't pay off — the value still has to be uploaded, computed, and read back, and that round trip between chips costs more than the computation itself. For millions of pixels, that same transfer cost gets spread across all of them at once: thousands of GPU cores compute thousands of pixels simultaneously, and that's where the GPU wins by orders of magnitude.
What fragment shaders are, and why you need them
In WebGL, rendering is a pipeline of two programmable stages:
- The vertex shader handles geometry (in my case, just a rectangle covering the whole canvas).
- The fragment shader (also called the pixel shader) runs separately for every pixel and decides what color to paint it.
All the real work in @colordx/gpu happens in the fragment shader. The geometry is trivial; the GLSL code inside computes a full OKLCH → sRGB conversion for every pixel, plus membership checks against several gamuts at once, independent of any neighboring pixel. The fragment processor is built as an array of simple parallel units, each one running the same shader code on its own pixel.
Broken into steps, an OKLCH → sRGB conversion is a hue rotation, a couple of matrix multiplications, three cube operations, and a transfer curve. No branches, no shared state between pixels — exactly what a fragment shader is for: one simple function, run in parallel across hundreds of pixels per clock cycle.
See it first
This is @colordx/gpu running in your browser right now — a live slice of OKLCH space, repainted from scratch every frame. Drag to scrub the third channel, hover to read the color under your cursor, and switch between the three slice planes:
Your browser has no WebGL2 — imagine a very fast color chart here.
The solution
To paint millions of colors, you don't need to upload per-pixel data to the GPU on every frame. You just need to explain one rule — how the math works — and the GPU expands that rule into millions of values on its own.
This works because every pixel already has everything it needs to compute its own color: its position on screen. If the screen is showing a slice of color space — lightness on one axis, chroma on the other, hue fixed — the pixel's coordinate is its color, just not yet converted out of OKLCH.
All the meaningful work happens in the fragment shader, once per pixel: it takes its coordinate, turns it into lightness and chroma using the rule it was given, adds the fixed hue, and runs the OKLCH → sRGB math.
Gamut checking works the same way. It isn't a separate branch of code — it's just another number: negative inside the gamut, positive outside it, exactly zero right on the boundary. That gives you a nice side effect for free: the gamut boundary line appears on its own, at no extra cost.
On the JS side, almost nothing is left. No array, no loop over pixels, no reading a result back. What used to be latency simply isn't needed here, because the result never has to travel back to the CPU. It gets drawn directly where it belongs — on the screen.
Results in numbers
Benchmarked on a 1024×1024 scene (a full-screen paint() with three filled gamuts and three boundary lines, where hue changes every frame): a 2,000-frame run on an Apple M4 comes in at 0.164–0.171 ms per frame — 6.1–6.4 billion OKLCH → sRGB conversions per second.
A 1024×1024 window at 60 fps needs 63 million conversions per second. The GPU delivers more than 6 billion — about a hundred times more than what's actually needed.
The CPU on the same benchmark manages 13.2–18.5 million conversions per second. The gap to the GPU averages around 400×.
Worth noting: one-time initialization (context creation, shader compilation, first paint()) costs 5–6 ms, but it only happens once per renderer's lifetime.
Important: these are conversions delivered as pixels on screen, not values you can read back into JavaScript. If you need the color under the cursor, that's still a single CPU-library call at 218 ns, not a GPU read — each processor stays in its own lane. And a small 300×200 widget isn't limited by fill rate at all; it's limited by the fixed cost of the draw call itself.
Conclusions
Converting a single color and rendering millions of pixels per frame are different problems, and different tools win them. The first was won by optimizing for V8. The second was won by giving up on moving data at all: instead of sending colors, send the rule the GPU uses to derive them from a pixel's position.
More than 6 billion conversions per second open up new possibilities for rendering demos, charts, and all kinds of dynamic animation. When the tool actually fits the problem, the elaborate workarounds and hacks don't move into the code — they just disappear.
The package is at github.com/dkryaklin/colordx-gpu (npm install @colordx/gpu), and the library whose math it runs is at colordx.dev.




