Skip to content

Refactor gamut-mapping methods to the procedural Color.js API#41

Open
LeaVerou wants to merge 3 commits into
mainfrom
gamut-mapping-procedural
Open

Refactor gamut-mapping methods to the procedural Color.js API#41
LeaVerou wants to merge 3 commits into
mainfrom
gamut-mapping-procedural

Conversation

@LeaVerou

@LeaVerou LeaVerou commented Jun 24, 2026

Copy link
Copy Markdown
Member

Optimize GMAs for a more fair comparison. See discussion in #36.

Changes:

  1. Use procedural API across all GMAs
  • The objects to()/clone() already return are reused rather than re-wrapped into new literals.
  • Color spaces are passed as objects, not string ids, so timed conversions skip registry lookups.
  • 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).

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 Map unbounded — 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. Send COOP: same-origin + COEP: require-corp on /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? 🙃

image

🤖 Generated with Claude Code, edited by this human

LeaVerou and others added 2 commits June 24, 2026 06:40
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>
@netlify

netlify Bot commented Jun 24, 2026

Copy link
Copy Markdown

Deploy Preview for color-apps ready!

Name Link
🔨 Latest commit ca401f8
🔍 Latest deploy log https://app.netlify.com/projects/color-apps/deploys/6a3bab96b8e62500080b2fa6
😎 Deploy Preview https://deploy-preview-41--color-apps.netlify.app
📱 Preview on mobile
Toggle QR Code...

QR Code

Use your smartphone camera to open QR code link.
🤖 Make changes Run an agent on this branch

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>
@LeaVerou
LeaVerou requested a review from facelessuser June 24, 2026 12:25
@LeaVerou

Copy link
Copy Markdown
Member Author

@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.

@facelessuser

Copy link
Copy Markdown
Contributor

Here is another thing that concerns me about the benchmark tool.

I downloaded your commit and tried something a little random. I shuffled the methods dictionary in methods.js. Just to see what would happen:

Screenshot 2026-06-24 at 6 56 56 AM

Björn is now faster than clip (on multiple runs). Then I shuffled it again. Raytrace is faster than Björn on multiple runs.

Screenshot 2026-06-24 at 7 02 45 AM

Here's a weird outlier. Björn is faster than cached, slower than clip...I think this one just happened out of nowhere.

Screenshot 2026-06-24 at 7 06 20 AM

If anything, this tells me the benchmark app is not quite reliable. Maybe some of these changes were from unexpected spikes in CPU usage elsewhere, who knows. Maybe it had nothing to do with shuffling methods.js.

@facelessuser

Copy link
Copy Markdown
Contributor

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.

@lloydk

lloydk commented Jun 24, 2026

Copy link
Copy Markdown

If anything, this tells me the benchmark app is not quite reliable. Maybe some of these changes were from unexpected spikes in CPU usage elsewhere, who knows. Maybe it had nothing to do with shuffling methods.js.

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.

@lloydk

lloydk commented Jun 24, 2026

Copy link
Copy Markdown

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.

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 compute() is measured individually so it makes sense that changing the order of the algorithms or even changing the implementation of an algorithm will affect the times of other algorithms.

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.

@facelessuser

Copy link
Copy Markdown
Contributor

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.

AssertionError: color(display-p3 -0.00008 0.58127 0.64186)

I also think Björn needs a clip at the end. As it can also return colors outside the in-gamut threshold:

AssertionError: color(display-p3 0.98873 1.0001 0.6296)

@LeaVerou

Copy link
Copy Markdown
Member Author

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.

AssertionError: color(display-p3 -0.00008 0.58127 0.64186)

I also think Björn needs a clip at the end. As it can also return colors outside the in-gamut threshold:

AssertionError: color(display-p3 0.98873 1.0001 0.6296)

They are all clipped at the end by normalize(). Are you saying we should bake that into the functions? In the other PR you said the functions should only include what differs (which is my opinion too).

@facelessuser

Copy link
Copy Markdown
Contributor

I think either I wasn't clear or misspoke somehow, so I'll clarify my opinion. Obviously, this is just my opinion.

  1. Methods should ensure the color is in the gamut. The work isn't done if the color isn't in the gamut. All the work should be accounted for under timing.

  2. I feel the method should have some requirement such that all compute should have a similar input and output space requirement. I don't care how this is defined as long as it is consistent. This normalizes the requirement, and all of this should be under timing. You can construct the test(s) to be more in favor of one vs the other, but they would all be subject to the same requirement and would be doing the same thing.

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.

@facelessuser

Copy link
Copy Markdown
Contributor

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.

@facelessuser

Copy link
Copy Markdown
Contributor

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.

@lloydk

lloydk commented Jun 24, 2026

Copy link
Copy Markdown
  1. Methods should ensure the color is in the gamut. The work isn't done if the color isn't in the gamut. All the work should be accounted for under timing.
  2. I feel the method should have some requirement such that all compute should have a similar input and output space requirement. I don't care how this is defined as long as it is consistent. This normalizes the requirement, and all of this should be under timing. You can construct the test(s) to be more in favor of one vs the other, but they would all be subject to the same requirement and would be doing the same thing.

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.

I agree 100% and I also think the output space of the color that compute returns should be P3.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants