You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
{{ message }}
Repository navigation
[css-color-4] Evaluate static gamut mapping algorithms for oklab/oklch spaces #10579
Separating the action item "ccameron: review gamut mapping algorithms" from here into a new issue. Tagging in @foolip@LeaVerou@svgeesus for the more limited discussion here (please feel free to include anyone else).
From the linked resolution in the issue, these algorithms are not to be applied to the various RGB spaces. I'd like to scope my comments to just "gamut mapping to apply to oklab/oklch" spaces, since I feel a bit differently about XYZ and Lab spaces (I would want XYZ and Lab to follow RGB, because they are the canonical profile connection spaces used in ICC based color matching, and don't want to mix this discussion with those issues).
The properties that I'd like for the algorithm to apply to just-oklab/oklch are much looser. An iterative algorithm with quadratic convergence is quite fine.
There were lots of algorithms listed in issue 9449, so I'd like to collect a reduced list of candidates here to discuss.
To provide an extremely short list:
Projecting in oklab [edit, fixed typo] to the Rec2020 gamut boundary along constant L
This is what's proposed in the spec (modulo termination conditions and iteration method)
We can write out the details for an exact solution that quadraticly converges (it's a bit of work, but I can do it if desired)
An exact solution for the exact Rec2020 boundary may not be desirable
There is some concave nastiness around the blue corner of the gamut that it would be nice to avoid.
There is also chrome reduction as discussed in the spec
Creating a "nicer-shaped" surface very close to the Rec2020 gamut might be a good solution here
Projecting in Lab to the Rec2020 gamut boundary along constant L
Why Lab and not Oklab, given the hue curvature of Lab (especially in blues, but also in orange to a lesser extent).
Sorry, that was a typo. I fixed up the original comment.
I agree that mapping to a smoothed gamut boundary may well be better.
Projecting in Rec2020 YUV along constant Y
That is video luma, right? Where Y is computed without bothering to undo gamma-encoding first?
In the experiments that I did (which was not very exhaustive), I was doing this in linear space, but that was just because it was easier to plug into the pipeline at that stage.
Another option was to find the nearest point in the L2 sense in YUV space (either linear or gamma encoded). I haven't investigated that thoroughly.
Taking the results for "CSS Rec2020" as the consensus target (CSS gamut map to the BT.2020 gamut, then clip from there to the display) the four contenders are thus:
Looking now at out of gamut colors (including colors even outside ProPhoto) all four give similar results to "CSS Rec2020" and often slightly better results.
For example, for oklch(80% 109% 311) CSS Rec2020 has a ΔH of 4.3, Chromium ΔH 1.7, Edge Seeker ΔH 0.03 while Björn Ottosson and Raytrace both have ΔH 0, which is ideal.
I think the next task is to capture timing information on those four.
Calculating mean runtime over 100 iterations gives similar speed ranking:
CSS 39.285ms
CSS Rec2020 37.438ms
Raytrace 25.618ms
Scale LH 21.532ms
Björn Ottosson 12.361ms
Chromium 10.203ms
Edge Seeker 7.488ms
Clip 5.062ms
The code for edge seeker calculates the maximum chroma for a given lightness and hue. The gamut slice is approximated with a linear segment for the lower (dark) part and an arc for the upper (light) part. This implementation populates a 400-element LUT.
I generated colors in the XYZ space, and for each color outside of Rec2020, I applied the GMA, and then clipped to P3 and sRGB to approximate what color might be displayed. I then calculated average deltas for each channel in OkLCh, taking the absolute value since I was interested in magnitude and not directionality. A drawback is that this obscures how the GMAs behave in different ranges, as well as how much variation there is.
Method
Destination Gamut
ΔL
ΔC
Δh
ΔDelta2000
Edge Seeker p3
p3
0.0004
0.3101
0.0007
17.4262
Edge Seeker p3
srgb
1.4090
0.3329
2.2804
19.2775
Edge Seeker Rec2020
p3
2.1310
0.2910
2.9192
16.2893
Edge Seeker Rec2020
srgb
3.3636
0.3172
5.2885
18.7126
Björn Ottosson
p3
0.000008
0.3101
0.00002
17.4203
Björn Ottosson
srgb
1.4105
0.3328
2.2829
19.2729
Chromium
p3
2.3165
0.2858
3.2774
15.8680
Chromium
srgb
3.4980
0.3139
5.6398
18.4112
Raytrace
p3
0.00002
0.3101
0.0025
17.4188
Raytrace
srgb
1.4106
0.3328
2.2853
19.2720
Clipping
p3
7.2
0.244
8.72074
16.607
Scales:
L = 0-100 (So a 1 would be equivalent to 0.01 or 1%)
C = 0-0.4 (Most of the mapped colors had very high chroma to start with, so we're seeing large shifts here, as expected)
h = 0-360
Some notes-
All are improvements over Clipping
Edge Seeker performs well. Given its speed and performance, I would support this being the method for getting to rec2020.
Björn Ottosson performs much better than Edge Seeker when clipping to p3, but the advantage isn't there when clipping to srgb (Although the implementation is using p3 coefficients, and we could use separate sRGB coefficients, I think?). Is this performance boost worth the almost 2x longer compute time?
I omitted Raytrace since it appears to be slower (added)
The implementations of both Edge Seeker and Björn Ottosson target a displayP3 output in how they are calculated- would we want to target a rec2020 output? I assume that would negatively impact their performance.
UPDATE June 10 - Added Edge Seeker with rec2020-based LUT.
It should be noted that you can decrease the raytrace iteration from 4 to 3 and reduce its speed and still likely have pretty good results, though I wouldn't go lower than 3. I'd suspect with high loads that Björn's method would still prove overall faster.
Raytrace is decent as a generic approach for various RGB gamuts as you don't need to hard code color space specific coefficients, but since it sounds like the plan is to just target a single gamut, Rec. 2020, and clip to everything else, that doesn't matter so much and something like like Björn Ottosson's approach is definitely faster, and Edge Seeker even more so if storing a LUT is acceptable.
EDIT: This test is done with the full 4 iterations of raytrace, no reduction.
Okay, one thing that was bothering me about the Color.js testing of GMAs is that it isn't 100% fair as some algorithms are getting hit heavier by certain API calls of Color.js.
Björn and Edgeseeker bypass the Color.js conversion pipeline which has a lot of overhead. For instance, Björn's converts to linear RGB outside of of the Color.js API.
So optimizing Raytrace to do the same, we actually get faster results than Björn's.
Forcing 1000 gradient steps, ray-trace is shown to be noticeably faster.
It should be noted that you can decrease the raytrace iteration from 4 to 3 and reduce its speed and still likely have pretty good results, though I wouldn't go lower than 3. I'd suspect with high loads that Björn's method would still prove overall faster.
I added in Raytrace, and it looks like it outperforms Edge Seeker on Lightness, but not hue. It also outperforms Björn's method on Delta2000.
@jamesnw : thanks for the clarification on the Chromium GMA implementation in color.js! And glad to know it's not a showstopper here--that's what I was hoping for :) Let me or the Firefox team know if you run into any other cases where either Firefox GMA shows weirdness.
@facelessuser : I'm glad it was a simple one-and-done bug too. Thanks for catching that again. I've been implementing the GMAs separately in my own projects and hadn't considered the precision difference since mine use f64 just like ColorAide, color.js, and the draft assumption. Good to know for future work in Firefox since the CSS code uses f32. And I'll add a note to the raytracing code when I submit some reftests to Firefox hopefully later today.
@jparnzen Super cool to see an implementation! Thank you for working on this 🙇
Is it correct that gamut mapping in the Firefox Nightly always uses srgb as the destination and not rec2020 (#9449) or the device gamut?
@romainmenke : You are correct. To my understanding, Firefox uses sRGB as the destination display color space, and that's the destination space I pass to the GMAs. I saw in some earlier discussions here that there was mention of using Rec2020 during gamut mapping, but I don't think I saw it mentioned in the GMAs themselves in the drafts. Please correct me if I'm wrong.
And many thanks! It's been fun getting these implemented, and great to be able use them and OKLCH in the browser without having to go through an extra layer of a library or manual calculation. And very happy to help the team here work through any implementation details to make the spec more bullet-proof.
BTW, I submitted the fix for the raytracing bugs that were found, and it autolanded in Nightly on 06-09. Not sure if it's gone into the main branch of Nightly yet or not, but should very soon. I'm working on WPT tests for it now. And if it helps you all out, I added initial WPT tests for local MINDE gamut mapping as part of the GMA implementations; I started raytracing WPTs but the patch landed before I could finish them at the time :)
I saw in some earlier discussions here that there was mention of using Rec2020 during gamut mapping, but I don't think I saw it mentioned in the GMAs themselves in the drafts.
That was for a proposal of gamut mapping to Rec2020 and then clipping to the display gamut instead of gamut mapping directlytly to the display gamut. That is not specifically part of the Raytrace or MINDE algorithm (though they could be used to do that). I believe that sRGB, Display P3, and Rec. 2020 are the only supported display gamuts in CSS.
I'm not sure if Firefox yet displays colors in the display gamut (by default at least). I know on my Display P3 monitor that only Safari and Chrome show colors in my display's native capability, Firefox has always only clipped everything to sRGB, but maybe there is a setting that would allow it to 🤷🏻.
There is a compromise to be made between wanting to show as many colors as possible on any given device and avoiding clipping. And at the same time another compromise between showing as many colors on any device and having consistency between various devices.
Gamut mapping to srgb:
as currently implemented in Firefox
avoid clipping entirely on devices that can display all of srgb
makes it impossible to display wider gamut colors (display-p3 and beyond) when using notations like oklch
This forces authors to pick between wider gamut colors and gradients/color transforms in perceptually uniform color spaces.
If we assume that most devices can display all or most of srgb then this method does give consistency between devices.
I don't think this is a good compromise. Especially as screens continue to improve and move beyond srgb.
Gamut mapping to display-p3, rec2020, ...:
there is a resolution to always gamut map to rec2020
This has the same issues as gamut mapping to srgb. But will result in more clipping while making it possible to also display wide gamut colors.
Exactly how much clipping or mapping you get depends on the device.
I think this is marginally better than gamut mapping to srgb but that this not what authors want. The unpredictability of when you get clipping and what the outcome of that clipping will be just isn't great.
Gamut mapping to what the device can actually display
removes all clipping entirely
allows wider gamut colors and will display more of them over time as displays become better
Given that clipped colors tend have a larger difference from the original than gamut mapped colors it should also make colors more consistent between devices if we always gamut map to what the device is capable of.
(this last one is my preferred method)
@jparnzen Is it possible to add another flag in Firefox to control the destination color space?
Can this be a toggle between:
srgb
display-p3
rec2020
the actual display/device
This will give better insights on what is the best approach 🙇
@romainmenke : as of today, from what I see in the code, Firefox hardcodes queries to get the screen color gamut to return sRGB (in the nsScreen and Screen widget code, for reference).
I think I may have seen a ticket in bugzilla to add support for P3 and maybe Rec2020, but I'll have to go back and look, and I don't think it's gotten any traction as yet. I will say that as part of my work on the GMAs, the function/method signatures for the GMAs take a destination color gamut, with the anticipation that someone will want to pass something other than sRGB at some point.
Thanks for the analysis and breakdown of the 3 possible approaches! I will offer that as a designer and dev, I expect that the colors I specify in CSS should display as close as possible to my expectation, especially with lightness. So clipping to sRGB from a wider gamut space wouldn't sit well, unless the difference between clipped and mapped-to-screen-gamut values are within some unnoticeable threshold. Colors with high lightness levels were a big reason I started on the GMA implementations--trying to get colors at 95%-99% lightness (with some sense of chroma or saturation) in OKLCH to use as things like backgrounds or text color on dark backgrounds is problematic without the GMAs. And while I agree with your take and would like the colors mapped to end-display gamuts, at the very least getting them to sRGB for now seems acceptable as that's effectively the baseline for monitors (I still found monitors for sale with less than 98% sRGB coverage when shopping for a new one recently). Just my anecdotal take :)
Btw I just added a benchmark that compares the existing GMAs for both accuracy and speed, using min/max/avg/median error (which is a weighed sum of l, c, h deltas weighing ΔH 8x, ΔL 4x, and ΔC 1x). It also allows customizing the range and granularity of the patches via URL params.
I also added a new GMA called OKLCh Cubic. It is based on the observation that at fixed L and H each linear-P3 channel is exactly a cubic equation in chroma (l' is affine in C, then cubed), so the C boundary is the smallest chroma at which any channel reaches 0 or 1 — found via standard closed form cubic equation solving formulas and optimized further through some observations about the types of equations produced and a lazily populated hue cache (which is not necessary, just an additional optimization).
Because of this:
it is much simpler than the other leading methods (< 150 loc)
does not depend on a LUT
it is not iterative
In the benchmark it consistently scores at the top for all accuracy metrics (min, max, avg, med, iqm) alongside EdgeSeeker, and seems to be 5-30% faster than EdgeSeeker, both of which are even faster than clip!
This is very interesting. But does this only hold true for P3 and not sRGB or Rec. 2020? That would, unfortunately, limit it to P3 only.
Small note, it seems the algorithm kicks out if !(C > 0) and doesn't check lightness at all. This would need to be fixed.
I do think these benchmarks don't completely tell the whole story. The way it is being tested is highly advantageous to the cubic approach as it bypasses all conversions; it never leaves the OkLCh color space. That doesn't discredit its speed, only that the extreme advantage it is showing comes specifically from never having to convert out of OkLCh. Give it a P3 color to start, and then it must incur the conversion cost, dramatically dropping its performance gain. It's still very fast, but not 30% faster. But, if you are in OkLCh, technically, it would be even faster as it skips the conversions. I think in browsers, we would always be converting to the actual gamut at the end, though, for display.
I've updated the algorithms for a fairer comparison. GMAs all return the gamut mapped colors consistently in the origin space. I also fixed the oklch-cubic method so that it actually checks lightness for gamut mapping. color-js/apps#36 (comment).
OkLCh-cubic is still usually the fastest, but results seem more fair now, instead of each algorithm returning the color in inconsistent color spaces.
I implemented the new cubic algorithm on an experimental branch in the ColorAide library to see how it performed in a real-world environment with similar overhead and abstraction for generalization within the library.
Compared against Ray Trace (as I don't have the LUT method implemented), it was the fastest by far.
This was processing an image with varying ranges of lightness and hue with maximum saturation in Rec. 2020. We gamut-mapped them to sRGB.
I didn't see it overpower clip in my library, and that may be for any number of design reasons, but Ray Trace vs Cubic was the most apples-to-apples comparison when it came to generalized abstraction and overhead. Admittedly, our version of Ray Trace has some extra stuff outside of what's specified in the CSS spec that adds a little more overhead, but even if we were to strip those out, there is no way it could overtake the Cubic approach.
Generally, it also provided fewer deviations in lightness and hue. While the difference wasn't visibly noticeable, the fact that it can reduce the chroma that fast and more accurately is impressive.
After abstracting the OkLCh Cubic algorithm to work for any linear RGB color space, and evaluating it, I do think it is worth considering, as it seems fast and accurate for sRGB, Display P3, and Rec. 2020.
With or without the additional hue data cache, it is still faster than Ray Trace. If a cache were used, I would probably think having some bounds would make sense, but I think the algorithm is extremely competitive even without the cache. So if keeping a LUT of hue data was not desired, it is still very fast and accurate.
I do not think it is actually faster than clip, and further, I would state I believe it needs a clip itself at the end of the algorithm, as it can produce some values that do need clipping in some circumstances.
I found it impressive enough to implement it and release it in ColorAide as an experimental GMA to play with.
And one other improvement is that it notes optimal values for the distance threshold from the gamut surface. Previously, this was specified as 1e-6, and it has been updated to 1e-12 (for 64-bit) and noted that 1e-6 should be used for 32-bit. This reduces hue deltas in some cases.
Small update to the benchmarks that were posted earlier using ColorAide. The ColorAide version allows swapping out OkLCh for any perceptual color space (as the approach can be used for most other perceptual spaces).
Here's a fairer comparison with a Ray Trace purpose-built for OkLCh and linear CSS RGB spaces. The Cubic one is still shown to be faster, but the performance is much closer. Now
It is similar to OKLCh cubic but there are a few differences:
It uses Halley iterations which seem to work faster (not using expensive acos/cbrt). In Color JS benchmark it's usually performs 10-20% faster than OKLCh cubic.
It takes into account the problematic "blue fold" that happens in sRGB and Rec.2020. Algorithms like binary search tend to jump over more vivid blues and return noticeably duller colors. Originally, it was the main reason for me to create the Edge Seeker.
Oh and I also wanted to share the quality measuring approach I used for the algorithms.
The problem with a random out-of-gamut color is that we don't have a ground truth to compare with.
So I took RGB colors on the surface of the gamut → converted to OKLCh → increased chroma. I was feeding the algorithm with the increased-chroma versions. And then I compared the results with the known original RGB colors to see how well it performed. I did it for all ~320K hexes on the surface of the gamut.
Reacted by Andrey Sitnik, Robb Beal and Gustav Lindqvist
Activity
svgeesus commentedon Jul 16, 2024
Thanks for starting this.
Why Lab and not Oklab, given the hue curvature of Lab (especially in blues, but also in orange to a lesser extent).
I agree that mapping to a smoothed gamut boundary may well be better.
That is video luma, right? Where Y is computed without bothering to undo gamma-encoding first?
ccameron-chromium commentedon Jul 16, 2024
Sorry, that was a typo. I fixed up the original comment.
In the experiments that I did (which was not very exhaustive), I was doing this in linear space, but that was just because it was easier to plug into the pipeline at that stage.
Another option was to find the nearest point in the L2 sense in YUV space (either linear or gamma encoded). I haven't investigated that thoroughly.
svgeesus commentedon Jul 16, 2024
Ah okay, makes sense.
That makes the calculated lightness accurate, so good. It doesn't make the UV plane perceptually uniform, of course.
svgeesus commentedon Jul 24, 2024
This is clearly seen in CSS Color 4, Figure 2.
svgeesus commentedon Jun 5, 2025
Looking again at but this time for desaturated colors which are actually in gamut, I see unacceptable results for the
ScaleandScaleLHoptions:svgeesus commentedon Jun 5, 2025
Taking the results for "CSS Rec2020" as the consensus target (CSS gamut map to the BT.2020 gamut, then clip from there to the display) the four contenders are thus:
svgeesus commentedon Jun 5, 2025
Looking now at out of gamut colors (including colors even outside ProPhoto) all four give similar results to "CSS Rec2020" and often slightly better results.
For example, for oklch(80% 109% 311) CSS Rec2020 has a ΔH of 4.3, Chromium ΔH 1.7, Edge Seeker ΔH 0.03 while Björn Ottosson and Raytrace both have ΔH 0, which is ideal.
I think the next task is to capture timing information on those four.
svgeesus commentedon Jun 5, 2025
Looking at gamut mapping each position in a 361-stop gradient from
oklch(90% .6 250)tooklch(40% .3 20)(set max DeltaE to 3, to get lots of stops) the timings in that case, sorted from slowest to fastest:svgeesus commentedon Jun 5, 2025
Calculating mean runtime over 100 iterations gives similar speed ranking:
The code for edge seeker calculates the maximum chroma for a given lightness and hue. The gamut slice is approximated with a linear segment for the lower (dark) part and an arc for the upper (light) part. This implementation populates a 400-element LUT.
jamesnw commentedon Jun 9, 2025
I generated colors in the XYZ space, and for each color outside of Rec2020, I applied the GMA, and then clipped to P3 and sRGB to approximate what color might be displayed. I then calculated average deltas for each channel in OkLCh, taking the absolute value since I was interested in magnitude and not directionality. A drawback is that this obscures how the GMAs behave in different ranges, as well as how much variation there is.
Scales:
Some notes-
I omitted Raytrace since it appears to be slower(added)facelessuser commentedon Jun 9, 2025
It should be noted that you can decrease the raytrace iteration from 4 to 3 and reduce its speed and still likely have pretty good results, though I wouldn't go lower than 3. I'd suspect with high loads that Björn's method would still prove overall faster.
Raytrace is decent as a generic approach for various RGB gamuts as you don't need to hard code color space specific coefficients, but since it sounds like the plan is to just target a single gamut, Rec. 2020, and clip to everything else, that doesn't matter so much and something like like Björn Ottosson's approach is definitely faster, and Edge Seeker even more so if storing a LUT is acceptable.
facelessuser commentedon Jun 9, 2025
EDIT: This test is done with the full 4 iterations of raytrace, no reduction.
Okay, one thing that was bothering me about the Color.js testing of GMAs is that it isn't 100% fair as some algorithms are getting hit heavier by certain API calls of Color.js.
Björn and Edgeseeker bypass the Color.js conversion pipeline which has a lot of overhead. For instance, Björn's converts to linear RGB outside of of the Color.js API.
So optimizing Raytrace to do the same, we actually get faster results than Björn's.
Forcing 1000 gradient steps, ray-trace is shown to be noticeably faster.
Here's the optimized preview: https://deploy-preview-21--color-apps.netlify.app/gamut-mapping/gradients?from=oklch%2890%25+.4+250%29&to=oklch%2840%25+.1+20%29
The optimizations can be seen here: color-js/apps#21.
I imagine even the CSS approach could be sped up doing something similar.
jamesnw commentedon Jun 9, 2025
I added in Raytrace, and it looks like it outperforms Edge Seeker on Lightness, but not hue. It also outperforms Björn's method on Delta2000.
facelessuser commentedon Jun 9, 2025
Yep, that's about what I would expect. It'll get hue reasonably close, but it can't beat a LUT 🙂.
69 remaining items
jparnzen commentedon Jun 10, 2026
@jamesnw : thanks for the clarification on the Chromium GMA implementation in color.js! And glad to know it's not a showstopper here--that's what I was hoping for :) Let me or the Firefox team know if you run into any other cases where either Firefox GMA shows weirdness.
@facelessuser : I'm glad it was a simple one-and-done bug too. Thanks for catching that again. I've been implementing the GMAs separately in my own projects and hadn't considered the precision difference since mine use f64 just like ColorAide, color.js, and the draft assumption. Good to know for future work in Firefox since the CSS code uses f32. And I'll add a note to the raytracing code when I submit some reftests to Firefox hopefully later today.
romainmenke commentedon Jun 11, 2026
@jparnzen Super cool to see an implementation! Thank you for working on this 🙇
Is it correct that gamut mapping in the Firefox Nightly always uses
srgbas the destination and notrec2020(#9449) or the device gamut?jparnzen commentedon Jun 11, 2026
@romainmenke : You are correct. To my understanding, Firefox uses sRGB as the destination display color space, and that's the destination space I pass to the GMAs. I saw in some earlier discussions here that there was mention of using Rec2020 during gamut mapping, but I don't think I saw it mentioned in the GMAs themselves in the drafts. Please correct me if I'm wrong.
And many thanks! It's been fun getting these implemented, and great to be able use them and OKLCH in the browser without having to go through an extra layer of a library or manual calculation. And very happy to help the team here work through any implementation details to make the spec more bullet-proof.
BTW, I submitted the fix for the raytracing bugs that were found, and it autolanded in Nightly on 06-09. Not sure if it's gone into the main branch of Nightly yet or not, but should very soon. I'm working on WPT tests for it now. And if it helps you all out, I added initial WPT tests for local MINDE gamut mapping as part of the GMA implementations; I started raytracing WPTs but the patch landed before I could finish them at the time :)
facelessuser commentedon Jun 11, 2026
That was for a proposal of gamut mapping to Rec2020 and then clipping to the display gamut instead of gamut mapping directlytly to the display gamut. That is not specifically part of the Raytrace or MINDE algorithm (though they could be used to do that). I believe that sRGB, Display P3, and Rec. 2020 are the only supported display gamuts in CSS.
I'm not sure if Firefox yet displays colors in the display gamut (by default at least). I know on my Display P3 monitor that only Safari and Chrome show colors in my display's native capability, Firefox has always only clipped everything to sRGB, but maybe there is a setting that would allow it to 🤷🏻.
romainmenke commentedon Jun 13, 2026
There is a compromise to be made between wanting to show as many colors as possible on any given device and avoiding clipping. And at the same time another compromise between showing as many colors on any device and having consistency between various devices.
Gamut mapping to
srgb:as currently implemented in Firefox
srgboklchThis forces authors to pick between wider gamut colors and gradients/color transforms in perceptually uniform color spaces.
If we assume that most devices can display all or most of
srgbthen this method does give consistency between devices.I don't think this is a good compromise. Especially as screens continue to improve and move beyond
srgb.Gamut mapping to
display-p3,rec2020, ...:there is a resolution to always gamut map to
rec2020This has the same issues as gamut mapping to
srgb. But will result in more clipping while making it possible to also display wide gamut colors.Exactly how much clipping or mapping you get depends on the device.
I think this is marginally better than gamut mapping to
srgbbut that this not what authors want. The unpredictability of when you get clipping and what the outcome of that clipping will be just isn't great.Gamut mapping to what the device can actually display
Given that clipped colors tend have a larger difference from the original than gamut mapped colors it should also make colors more consistent between devices if we always gamut map to what the device is capable of.
(this last one is my preferred method)
@jparnzen Is it possible to add another flag in Firefox to control the destination color space?
Can this be a toggle between:
srgbdisplay-p3rec2020This will give better insights on what is the best approach 🙇
jparnzen commentedon Jun 16, 2026
@romainmenke : as of today, from what I see in the code, Firefox hardcodes queries to get the screen color gamut to return sRGB (in the nsScreen and Screen widget code, for reference).
I think I may have seen a ticket in bugzilla to add support for P3 and maybe Rec2020, but I'll have to go back and look, and I don't think it's gotten any traction as yet. I will say that as part of my work on the GMAs, the function/method signatures for the GMAs take a destination color gamut, with the anticipation that someone will want to pass something other than sRGB at some point.
Thanks for the analysis and breakdown of the 3 possible approaches! I will offer that as a designer and dev, I expect that the colors I specify in CSS should display as close as possible to my expectation, especially with lightness. So clipping to sRGB from a wider gamut space wouldn't sit well, unless the difference between clipped and mapped-to-screen-gamut values are within some unnoticeable threshold. Colors with high lightness levels were a big reason I started on the GMA implementations--trying to get colors at 95%-99% lightness (with some sense of chroma or saturation) in OKLCH to use as things like backgrounds or text color on dark backgrounds is problematic without the GMAs. And while I agree with your take and would like the colors mapped to end-display gamuts, at the very least getting them to sRGB for now seems acceptable as that's effectively the baseline for monitors (I still found monitors for sale with less than 98% sRGB coverage when shopping for a new one recently). Just my anecdotal take :)
LeaVerou commentedon Jun 19, 2026
Btw I just added a benchmark that compares the existing GMAs for both accuracy and speed, using min/max/avg/median error (which is a weighed sum of l, c, h deltas weighing ΔH 8x, ΔL 4x, and ΔC 1x). It also allows customizing the range and granularity of the patches via URL params.
I also added a new GMA called OKLCh Cubic. It is based on the observation that at fixed L and H each linear-P3 channel is exactly a cubic equation in chroma (l' is affine in C, then cubed), so the C boundary is the smallest chroma at which any channel reaches 0 or 1 — found via standard closed form cubic equation solving formulas and optimized further through some observations about the types of equations produced and a lazily populated hue cache (which is not necessary, just an additional optimization).
Because of this:
In the benchmark it consistently scores at the top for all accuracy metrics (min, max, avg, med, iqm) alongside EdgeSeeker, and seems to be 5-30% faster than EdgeSeeker, both of which are even faster than clip!
facelessuser commentedon Jun 19, 2026
This is very interesting. But does this only hold true for P3 and not sRGB or Rec. 2020? That would, unfortunately, limit it to P3 only.
Small note, it seems the algorithm kicks out if
!(C > 0)and doesn't check lightness at all. This would need to be fixed.I do think these benchmarks don't completely tell the whole story. The way it is being tested is highly advantageous to the cubic approach as it bypasses all conversions; it never leaves the OkLCh color space. That doesn't discredit its speed, only that the extreme advantage it is showing comes specifically from never having to convert out of OkLCh. Give it a P3 color to start, and then it must incur the conversion cost, dramatically dropping its performance gain. It's still very fast, but not 30% faster. But, if you are in OkLCh, technically, it would be even faster as it skips the conversions. I think in browsers, we would always be converting to the actual gamut at the end, though, for display.
facelessuser commentedon Jun 19, 2026
I've updated the algorithms for a fairer comparison. GMAs all return the gamut mapped colors consistently in the origin space. I also fixed the oklch-cubic method so that it actually checks lightness for gamut mapping. color-js/apps#36 (comment).
OkLCh-cubic is still usually the fastest, but results seem more fair now, instead of each algorithm returning the color in inconsistent color spaces.
facelessuser commentedon Jun 20, 2026
I implemented the new cubic algorithm on an experimental branch in the ColorAide library to see how it performed in a real-world environment with similar overhead and abstraction for generalization within the library.
Compared against Ray Trace (as I don't have the LUT method implemented), it was the fastest by far.
This was processing an image with varying ranges of lightness and hue with maximum saturation in Rec. 2020. We gamut-mapped them to sRGB.
I didn't see it overpower clip in my library, and that may be for any number of design reasons, but Ray Trace vs Cubic was the most apples-to-apples comparison when it came to generalized abstraction and overhead. Admittedly, our version of Ray Trace has some extra stuff outside of what's specified in the CSS spec that adds a little more overhead, but even if we were to strip those out, there is no way it could overtake the Cubic approach.
Generally, it also provided fewer deviations in lightness and hue. While the difference wasn't visibly noticeable, the fact that it can reduce the chroma that fast and more accurately is impressive.
facelessuser commentedon Jun 22, 2026
After abstracting the OkLCh Cubic algorithm to work for any linear RGB color space, and evaluating it, I do think it is worth considering, as it seems fast and accurate for sRGB, Display P3, and Rec. 2020.
With or without the additional hue data cache, it is still faster than Ray Trace. If a cache were used, I would probably think having some bounds would make sense, but I think the algorithm is extremely competitive even without the cache. So if keeping a LUT of hue data was not desired, it is still very fast and accurate.
I do not think it is actually faster than clip, and further, I would state I believe it needs a clip itself at the end of the algorithm, as it can produce some values that do need clipping in some circumstances.
I found it impressive enough to implement it and release it in ColorAide as an experimental GMA to play with.
facelessuser commentedon Jun 22, 2026
As a side note, I've issued a PR with some corrections to the Ray Trace algorithm: #14080. It addresses:
facelessuser commentedon Jun 24, 2026
Small update to the benchmarks that were posted earlier using ColorAide. The ColorAide version allows swapping out OkLCh for any perceptual color space (as the approach can be used for most other perceptual spaces).
Here's a fairer comparison with a Ray Trace purpose-built for OkLCh and linear CSS RGB spaces. The Cubic one is still shown to be faster, but the performance is much closer. Now
Now, add the cache, and the Cubic approach pulls further ahead:
ardov commentedon Jul 12, 2026
I added another LUT-less algorithm based on bracketed Halley method (color-js/apps#44 (comment))
It is similar to OKLCh cubic but there are a few differences:
Oh and I also wanted to share the quality measuring approach I used for the algorithms.
The problem with a random out-of-gamut color is that we don't have a ground truth to compare with.
So I took RGB colors on the surface of the gamut → converted to OKLCh → increased chroma. I was feeding the algorithm with the increased-chroma versions. And then I compared the results with the known original RGB colors to see how well it performed. I did it for all ~320K hexes on the surface of the gamut.
svgeesus commentedon Sep 1, 2026
Review welcome on a PR to add Edge Seeker pseudocode (it seems correct but I would value other eyes on it):