Refactor gamut-mapping methods to the procedural Color.js API#41
Refactor gamut-mapping methods to the procedural Color.js API#41LeaVerou wants to merge 3 commits into
Conversation
The benchmark times each method's compute(), but the OOP Color.js API
(new Color, .to(), .set(), .inGamut(), .clone()) re-wraps every result in
a fresh Color — defining per-coordinate getters/setters each time — and
the methods leaned on it to wildly varying degrees. So the timings partly
measured each method's coding style rather than its algorithm (Clip, for
one, looked slower than it is).
Switch the GMA compute() functions and the shared harness (normalize,
restoreLH, getDeltas) to the procedural colorjs.io/fn API, operating on
plain {space, coords, alpha} objects. Pass color-space objects imported
from colorjs.io/fn (OKLCH, P3, P3_Linear, …) rather than string ids, so
timed conversions skip registry lookups, and reuse the objects to()/clone()
already return instead of minting new literals. methods.js imports
colorjs.io/spaces to register spaces (needed for the "oklch.l" coordinate
refs and as a safety net). src/ deep-imports remain only for utilities with
no /fn export (matrix math, white points, angle/okhsl helpers).
The two consumers adapt at their boundaries: the benchmark builds plain
OKLCh inputs and serializes results procedurally; the gradients view
serializes mapped colors for its CSS variable (outside its timed region).
Output is unchanged — verified bit-identical to the OOP version across a
2880-color grid.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
oklch-cubic recomputes its per-hue cubic structure (getHueData) on every call. With the OOP overhead gone, that per-call cost is now visible: the closed-form solve (~510ns) is genuinely slower than Edge Seeker's LUT lookup (~205ns), so it ranks below it in the benchmark. Re-add the per-hue cache as a separate method so the two can be compared. Restore the makeCompute(hueData) factory (removed in f33084d) so the cached and no-cache variants share the whole solver — they differ only by the injected hue-data provider, staying DRY. Key the cache on the hue rounded to 4 significant digits (toPrecision) so arbitrary float hues can't grow the Map unbounded, which is why the original cache was dropped. On the benchmark's sweep (the same hues recur across every lightness row) the cached variant runs ~2.3x faster (508 -> 217ns), landing alongside Edge Seeker. Output matches the exact solver to within hue quantization (~0.1deg, imperceptible). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
✅ Deploy Preview for color-apps ready!
To edit notification comments on pull requests, go to your Netlify project configuration. |
performance.now() is coarsened (~100µs) unless the page is cross-origin isolated, which clamps the benchmark's per-call GMA timings. Send COOP: same-origin + COEP: require-corp on the benchmark path so it gets the ~5µs timer. The page and its entire module graph are same-origin, so require-corp blocks no subresources. Scoped to /gamut-mapping/benchmark/* so COEP isn't imposed on the other apps. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
|
@lloydk I only realized you haven't been invited to the org because I couldn't request a review, fixed that now, sorry about that! And invited you to the maintainers Discord channel. |
|
I have no idea. I removed the rendering of the colors. I ran each one by itself, no other run at the same time. Clip kept coming in as slower, but I don't believe it for a second. |
A couple of possibilities off the top of my head: Garbage collection is being run at different times and different methods are paying for the garbage collection overhead based on their order in methods.js. There is no warmup phase so algorithms run later in the list may be running code that has been fully optimized by the JIT. |
I was going to suggest a different implementation of clip and when I ran the benchmark edge seeker was at the top of the list so just changing the implementation of clip changed the ranking. Note that I was running the benchmark app locally without cross-origin isolation enabled. For some reason I thought the benchmark was timing rows of colors but I complained to Claude about the issue and Claude said that every call to Here's a snippet what Claude said: Root cause: timer quantization in per-call timing The benchmark times each method individually and interleaved: - stats.js time() brackets every compute() with two performance.now() reads. - map.js mapColor() runs all methods back-to-back in a fixed order, timing each one separately. The problem is that browser performance.now() is deliberately quantized (Spectre mitigation: ~100µs in a normal page, 5µs even when cross-origin-isolated) — and a single gamut-map compute() is ~1µs. So each individual measurement reads mostly 0, with an occasional full-quantum jump. That jump gets charged to whichever method's call happens to straddle a timer tick — which depends on the cumulative clock since the loop started, i.e. on every method before it. Crucially, this error does not average out over many colors: because each method sits at a near-constant cumulative offset every iteration, the same methods systematically absorb (or dodge) ticks. The misattribution is correlated with loop position, so it accumulates. That's why: - Changing clip moves edge-seeker — clip's new duration shifts every later method's offset, so a tick that used to land on edge-seeker now lands elsewhere. Edge-seeker has no nested timing or shared state with clip, so this can only be a global timing artifact. - Reordering methods.js reshuffles ranks — different order ⇒ different tick alignment. (A secondary contributor with the same fix: GC pauses get billed to whichever compute() is running when they fire, so changing a method's allocations shifts GC onto a different method.) Claude's explanation certainly sounds plausible, no idea if it's 100% correct. |
|
I still think Cubic needs a clip at the end as the method can return colors still out of gamut beyond the in-gamut threshold. I also think Björn needs a clip at the end. As it can also return colors outside the in-gamut threshold: |
They are all clipped at the end by |
|
I think either I wasn't clear or misspoke somehow, so I'll clarify my opinion. Obviously, this is just my opinion.
Again, if everyone disagrees, that is fine, but to me, this seems like the only truly fair way to compare these. They need to be under the same constraints. |
|
Just to reiterate, Ray Trace clips at the end to make sure colors are in-gamut. I could certainly drop that to match the others, but I am not proposing to remove that, as I've always felt it is part of the algorithm to get the colors into the gamut. What I'm proposing is that any algorithm that can clearly demonstrate out-of-gamut colors on completion, at the very least out-of-gamut with the default threshold, should also clip. |
|
I've never implemented EdgeSeeker locally, so I've never put it through rigorous testing to see if it can produce out-of-gamut, but I'd argue if it also produces out-of-gamut colors, it should as well. |
I agree 100% and I also think the output space of the color that compute returns should be P3. |



Optimize GMAs for a more fair comparison. See discussion in #36.
Changes:
to()/clone()already return are reused rather than re-wrapped into new literals.2. Re-introduce cached OKLCh cubic variant. With the OOP overhead gone, oklch-cubic without the cache ended up being slightly slower than EdgeSeeker, so I re-introduced the cached variant, which is still faster than all GMAs. To avoid unbounded growth, I limited it to 4 significant digits (3600 entries max) so arbitrary float hues can't grow the
Mapunbounded — the reason the original cache was dropped. On the sweep it runs ~2.3× faster (508 → 217 ns), landing alongside Edge Seeker.3. Cross-origin isolate the benchmark. @lloydk flagged this.
performance.now()is coarsened (~100µs) unless the page is cross-origin isolated, which clamps the per-call timings. SendCOOP: same-origin+COEP: require-corpon/gamut-mapping/benchmark/*so it gets the ~5µs timer. Safe because the page and its whole module graph are same-origin; scoped to the benchmark path so COEP isn't imposed on the other apps.Results
Removing the per-method OOP tax (~2–4× across the board) lets the ranking reflect real algorithm cost: the spread widens from ~1–6× to ~1–12×, the genuinely-expensive iterative methods (CSS, Scale) sit at the slow end, and cheap clamps (Clip, Edge Seeker) at the fast end.
Surprisingly, clip is still not first! WTF? 🙃
🤖 Generated with Claude Code, edited by this human