Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[css-color-4] [css-color-5] Mixing with transparent seems broken in implementations #8612

Closed
romainmenke opened this issue Mar 18, 2023 · 34 comments
Assignees
Labels
Closed as Question Answered Used when the issue is more of a question than a problem, and it's been answered. css-color-4 Current Work css-color-5 Color modification

Comments

@romainmenke
Copy link
Member

romainmenke commented Mar 18, 2023

https://drafts.csswg.org/css-color-4/#interpolation-alpha

https://drafts.csswg.org/css-color-5/#color-mix-with-alpha

This example is 25% semi-opaque red and 75% semi-opaque green. mixed in sRGB. Both the correct (premultiplied) and incorrect (non-premultiplied) workings are shown.
color-mix(in srgb, rgb(100% 0% 0% / 0.7) 25%, rgb(0% 100% 0% / 0.2));
The calcuation is as follows:

rgb(100% 0% 0% / 0.7) when premultiplied, is [0.7, 0, 0]

rgb(0% 100% 0% / 0.2) when premultiplied, is [0, 0.2, 0]

the premultiplied, interpolated result is [0.7 * 0.25 + 0 * (1 - 0.25), 0 * 0.25 + 0.2 * (1 - 0.25), 0 * 0.25 + 0 * (1 - 0.25)] which is [0.175, 0.150, 0]

the interpolated alpha is 0.7 * 0.25 + 0.2 * (1 - 0.25) = 0.325

the un-premultiplied result is [0.175 / 0.325, 0.150 / 0.325, 0 / 0.325] which is [0.53846, 0.46154, 0]

so the mixed color is color(srgb 0.53846 0.46154 0 / 0.325)

As best as I can work out this algorithm will always cause any mix with transparent to be the other color but with a different alpha value.

It should be impossible to get a color change from mixing with transparent.

Yet in browsers you can get very large hue shifts by mixing with transparent.

https://codepen.io/romainmenke/pen/zYJLRQJ

Screenshot 2023-03-18 at 22 52 53


If I am reading all relevant bits correctly the steps should be :

  • premultiply with own alpha
  • interpolate
  • un premultiply with interpolated alpha

Unless transparent isn't zero in all components in all colorspaces we always interpolate with [0 0 0]

The ratio between the two colors is the inverse of the value used when un premultiplying.
So you always end up with the non transparent input color.

This might also be the result of implementations not handling powerless/missing components correctly : #8609


Questions :

  • are these browser bugs, or am I not reading the specification correctly?
  • is this likely the same problem as I reported in the other issue but with transparent instead of white?
  • is transparent always 0 or none for all components in all color spaces?
@romainmenke
Copy link
Member Author

romainmenke commented Mar 19, 2023

Have been looking at this a bit more.

As far as I can tell any mix with a color that has 0 alpha, (including, but not limited to transparent) will always just be the other color for rectangular orthogonal color spaces.

For cylindrical polar color spaces the colors will only interpolate the hue's, all other components will always just come from the other color.

When mixing with transparent the hue happens to be a missing/powerless component and hue isn't premultiplied / unpremultiplied


I think the main issue here is a lack of test coverage in WPT and some implementation bugs.

Missing tests :

  • color-mix with transparent in both rectangular orthogonal and cylindrical polar color spaces
  • color-mix with several colors with 0 alpha in both rectangular orthogonal and cylindrical polar color spaces

There are a few tests with none alpha, but maybe not sufficient.

@svgeesus
Copy link
Contributor

Agree that these tests would help. I believe at this point the specification is correct (but perhaps more examples would help).

@romainmenke
Copy link
Member Author

I've added some tests which I think fully cover this issue : web-platform-tests/wpt#39139

@romainmenke
Copy link
Member Author

romainmenke commented Apr 2, 2023

@emilio Given that Firefox intends to ship color-mix(). There are some new tests for color-mix(), some of which fail on Firefox nightly.
https://wpt.fyi/results/css/css-color/parsing/color-computed-color-mix-function.html?label=master&label=experimental&aligned&q=color-mix

I've opened more issues around css-color-4 and css-color-5 recently, some of which are relevant to color-mix(), not all of them have been resolved yet.

@emilio
Copy link
Collaborator

emilio commented Apr 2, 2023

Interesting, did those land very recently? I looked at all the tests just yesterday (on our tree) and I didn't see any extra failures. I'll look into this tho.

@romainmenke
Copy link
Member Author

These tests were merged into WPT last week.
Maybe there are changes on your end that already resolve these issues?

I've mainly noticed that there are some inconsistencies between the specification and implementations around handling of powerless components, missing components and color space conversions.

I don't have anything useful to say about how blocking all this is, I'll leave that up to @svgeesus and @tabatkins.
I saw that Gecko intended to ship and thought you might want to know :)
Thank you for looking into this!

@emilio
Copy link
Collaborator

emilio commented Apr 3, 2023

I think what's going on in implementations is that transparent is transparent black, so the hue we end up interpolating with is black's hue, and https://drafts.csswg.org/css-color/#interpolation-alpha mentions that hue isn't premultiplied by alpha, so I think that's what is causing the shift, and browsers are correct per spec? (I have no opinion on whether the spec should change tho)

@emilio
Copy link
Collaborator

emilio commented Apr 3, 2023

Hmm, ok, so it might be due to the color conversion, it seems at least on Gecko we do end up with none as the hue when converting to hsl but not to lch or so, let me check that more closely...

@romainmenke
Copy link
Member Author

romainmenke commented Apr 3, 2023

Yes, color conversion is relevant here as that is when powerless components become missing and missing components in turn can adopt analogous components from the other color.

@emilio
Copy link
Collaborator

emilio commented Apr 3, 2023

So the spec should be clearer on how to perform the conversion for Oklch etc. In Gecko, the HSL case works out because we end up doing https://drafts.csswg.org/css-color-4/#rgb-to-hsl explicitly, which accounts for the NaN hue, but the OKLab conversion we do goes through xyz, which doesn't end up with a NaN / none hue.

The spec isn't super-clear on what operations should happen when converting between these color-spaces (unless I've missed it). So it seems that should be clearer.

@romainmenke
Copy link
Member Author

Agreed, the order of these steps is also undefined : #8602

In Gecko, the HSL case works out because we end up doing https://drafts.csswg.org/css-color-4/#rgb-to-hsl explicitly, which accounts for the NaN hue, but the OKLab conversion we do goes through xyz, which doesn't end up with a NaN / none hue.

We have a separate step where we explicitly go from powerless to missing components : https://github.com/csstools/postcss-plugins/blob/main/packages/css-color-parser/src/color-data.ts#L288-L343

Note that hwb is a bit different from the others.

@svgeesus
Copy link
Contributor

svgeesus commented Apr 3, 2023

The spec isn't super-clear on what operations should happen when converting between these color-spaces (unless I've missed it).

That description in CSS Color 5 should link to CSS Color 4 Converting Colors. Do you still feel it is underspecified?

@svgeesus
Copy link
Contributor

svgeesus commented Apr 3, 2023

Oh, and I notice the list of color conversion steps should note that steps can be skipped if a sequence of them is no-op (eg converting sRGB to HSL, where src is identical to dest-rect)

@svgeesus
Copy link
Contributor

svgeesus commented Jun 1, 2023

The order op operations when carry-forward and powerless components both apply has now been specified - you do the carry-forward first, which in the case of color interpolation in color-mix means the component value for the other color is used.

@svgeesus
Copy link
Contributor

svgeesus commented Jun 1, 2023

The spec isn't super-clear on what operations should happen when converting between these color-spaces (unless I've missed it). So it seems that should be clearer.

It currently says:

Colors are then interpolated in the specified color space, as described in CSS Color 4 §  12. Color Interpolation. If the specified color space is a cylindrical-polar-color space, then the controls the interpolation of hue, as described in CSS Color 4 § 12.4 Hue Interpolation. If no is specified, it is as if shorter had been specified. If the specified color space is a rectangular-orthogonal-color space, then specifying a is an error.

which seems fairly specific, as CSS 4 Color interpolation is pretty detailed.

Is there something specific which is unclear or missing?

But I guess that

Interpolation between values occurs by first converting them to a given color space which will be referred to as the interpolation color space below, and then linearly interpolating each component of the computed value of the color separately.

could link "converting them" to Converting Colors which is the immediately preceding section.

And I could always add some worked examples.

@svgeesus
Copy link
Contributor

svgeesus commented Jun 1, 2023

I clarified:

Interpolation between <<color>> values
occurs by first
checking the two colors for analogous components 
which will be carried forward;
then (if required) converting them to a given color space
which will be referred to as the <dfn export>interpolation color space</dfn> below,
and then
linearly interpolating each component of the computed value of the color
separately.

@romainmenke
Copy link
Member Author

That is confusing to me 🤔
Maybe checking and will be carried forward is too vague?
It doesn't actually say what needs to be done and when.

The steps I have :

  • convert both colors to destination color space
  • carry forward missing components between a given color before and after color space conversion
  • convert powerless components to missing components
  • fill in missing components with components from the other color
  • premultiply with alpha
  • linear interpolation between the two colors
  • un-premultiply

@svgeesus
Copy link
Contributor

svgeesus commented Jun 1, 2023

what does "before and after color space conversion" mean?

@romainmenke
Copy link
Member Author

romainmenke commented Jun 1, 2023

what does "before and after color space conversion" mean?

That is indeed poorly worded.


This is more accurate :

  • convert both colors to destination color space
  • carry forward missing components:
    • for each missing component in the original color
      • if the component has an analogous component in the destination color space
      • set the analogous component to missing in the output color
  • convert powerless components to missing components
  • fill in missing components with components from the other color
  • premultiply with alpha
  • linear interpolation between the two colors
  • un-premultiply

@svgeesus
Copy link
Contributor

svgeesus commented Jun 5, 2023

So I was seeing it like this (re-using your wording):

  • check, for both colors, if each component has an analogous component in the destination color space
  • convert both colors to destination color space
  • set the analogous component to missing in the output colors
  • fill in missing components with components from the other color
  • convert powerless components to missing components
  • premultiply with alpha
  • linear interpolation between the two colors
  • un-premultiply

@svgeesus
Copy link
Contributor

svgeesus commented Jun 5, 2023

In other words the first step is basically setting flags, before the original color is lost; and the third and fourth stages are overwriting parts of the converted colors (first with none and then, if required, from the other color). Only then do we do the powerless step, whose advisability is under discussion because it adds a discontinuity and may be better left to the final, gamut map to display, step.

@romainmenke
Copy link
Member Author

romainmenke commented Jun 5, 2023

I assigned some values to the steps to make sure I am not getting lost somewhere.


For color-mix(in hsl, transparent, hsl(30deg 30% 40%))

  • transparent is rgb(0 0 0 / 0)
  • hsl(30deg 30% 40%) is already in hsl
  1. check, for both colors, if each component has an analogous component in the destination color space

rgb(0 0 0 / 0) -> no analogous components


  1. convert both colors to destination color space

rgb(0 0 0 / 0) -> hsl(0deg 0 0 / 0)


  1. set the analogous component to missing in the output colors

no analogous components -> hsl(0deg 0 0 / 0)


  1. fill in missing components with components from the other color

no missing components -> nothing is filled in


  1. convert powerless components to missing components

lightness is 0 -> hsl(none none 0 / 0) 1


  1. premultiply with alpha

-> hsl(none none 0 / 0)


  1. linear interpolation between the two colors
  • hsl(30deg 30% 40%)
  • hsl(none none 0%)

In my implementation this becomes : hsl(30deg 30% 20%)
But this is most likely an implementation bug.

I expect none to be resolved at this point because fill in missing components with components from the other color is usually done later.

My interpolation function :

function interpolate(start: number, end: number, p: number): number {
	if (Number.isNaN(start)) {
		return end;
	}

	if (Number.isNaN(end)) {
		return start;
	}

	return (start * p) + end * (1 - p);
}

  1. un-premultiply

divide components by interpolated alpha -> 0.5.
hue is skipped, not un-premultiplied

  • hsl(30deg 30% 20%)

becomes:

  • hsl(30deg 60% 40%)

With the interpolated alpha the outcome is hsl(30deg 60% 40% / 0.5), which is a totally different color.

I think using this order just pushed the problem further down.

Then it becomes an issue of what happens during linear interpolation.
How do values go through linear interpolation when either is missing?

Footnotes

  1. the specification was rewritten to change which components become powerless, but there was no resolution or agreement on that, so I am ignoring that change in this example.

@svgeesus
Copy link
Contributor

svgeesus commented Jun 5, 2023

the specification was rewritten to change which components become powerless, but there was no resolution or agreement on that, so I am ignoring that change in this example

Well, there was no resolution to make anything other than hue powerless in the first place; and we are now seeing a bunch of breakage and discontinuity as a result of forcing chroma/saturation to be missing at exactly white and black.

Compare color-mix(in hsl, rgb(0 0 0 / 0), hsl(30deg 30% 40%)) with color-mix(in hsl, rgb(0.01 0.01 0.01 / 0), hsl(30deg 30% 40%))

@romainmenke
Copy link
Member Author

Sorry, I actually meant : I haven't updated my implementation to match that change because it didn't seem final.

Which components are or aren't powerless doesn't have any effect on this thread.
It's just easier for me to provide examples based on the previous specification text at this time.


I do seem to have made a mistake in my previous comment.
Trying to track it down now.

@romainmenke
Copy link
Member Author

romainmenke commented Jun 5, 2023

I do seem to have made a mistake in my previous comment.

I thought I had a mistake because doublechecking lead to different results, but was a typo in the second attempt :)
As always, I might still have bugs.


My expectation remains that any mix with transparent should only have an effect on the alpha channel and not on any other color components.

This is something that just worked for me with the order I described in : #8612 (comment)

I think it now depends on the exact definition of linear interpolation.

@svgeesus
Copy link
Contributor

svgeesus commented Jul 6, 2023

My expectation remains that any mix with transparent should only have an effect on the alpha channel and not on any other color components.

transparent has been defined as transparent black (rgba 0, 0, 0, 0)) for many years. Which makes it impossible to distinguish two use cases:

  1. I want "fully transparent" to be a stop color and don't care about the color
  2. I actually want transparent black, just like I want transparent any other color

@tabatkins I guess it is too late (not Web compatible) to redefine transparent as rgba(none none none 0)?

@romainmenke
Copy link
Member Author

I guess it is too late (not Web compatible) to redefine transparent as rgba(none none none 0)?

That would not really help as far as I can tell.
none would become zero after conversion to a polar color space.

@romainmenke
Copy link
Member Author

romainmenke commented Jul 6, 2023

I am a bit confused what the issue is, because as far as I can tell this is just an implementation bug.

These are different linear gradients between transparent and red in different color spaces.

The background is also red.

This illustrates that a linear gradient between transparent and red in srgb does not mix in extra black at the midpoint. That transparent is rgb(0 0 0 / 0) isn't observable here.

https://codepen.io/romainmenke/pen/dyQzxKP

Screenshot 2023-07-06 at 15 19 49

Blue is worse because a hue of 0 is closer to red in most polar color spaces.

https://codepen.io/romainmenke/pen/zYMdgQV

Screenshot 2023-07-06 at 15 29 35

We only get weird results when color space conversions are part of the equation (in current implementations)

@svgeesus
Copy link
Contributor

svgeesus commented Jul 6, 2023

@emilio wrote:

the OKLab conversion we do goes through xyz, which doesn't end up with a NaN / none hue.

Correct, it needs to go through XYZ (that isn't needed when sRGB colors (rgb, hsl, hwb) are interpolated in an sRGB colorspace).

But the final polar conversion step, Oklab to Oklch or Lab to LCH, should be producing a none hue for achromatic colors (black, white, grays) including transparent (which is zero alpha black).

In color.js, rgba(0 0 0 / 0) converts to [lch(0 0 0 / 0)](https://colorjs.io/apps/convert/?color=lch(0%200%200%20%2F%200)&precision=10) and [oklch(0 0 0 / 0)](https://colorjs.io/apps/convert/?color=oklch(0%200%200%20%2F%200)&precision=10) because we are still coercing the NaN hue to zero. We should be outputting none there.

Either way though, when the alpha is zero, the premultiplied L and 'C` will be zero and the hue is not affected by premultiplication.

@svgeesus
Copy link
Contributor

svgeesus commented Jul 6, 2023

I agree that at this point the specifications are clear. On WPT parsing/color-computed-color-mix-function.html is also clear and has tests for both transparent and for zero-alpha non-achromatic colors.

Chrome (and Edge) pass all 476 subtests. Firefox (117) and Safari (173 preview) both pass 350/476 subtests and they both fail all the polar-space tests with transparent. So this is purely implementation bugs.

@svgeesus svgeesus closed this as completed Jul 6, 2023
@svgeesus svgeesus added Closed as Question Answered Used when the issue is more of a question than a problem, and it's been answered. and removed Needs Edits Needs Testcase (WPT) Needs Example or Figure labels Jul 6, 2023
@romainmenke
Copy link
Member Author

romainmenke commented Jul 6, 2023

Very happy to see that Chrome is now interpolation in color-mix correctly in Canary 🎉

But I think we need more coverage for gradients :

#a {
  background: color-mix(in oklch, blue, transparent);
  height: 50px;
}

#b {
  background: linear-gradient(to right in oklch, transparent, blue);
  height: 50px;
}

https://codepen.io/romainmenke/pen/NWEaKpL

Screenshot 2023-07-06 at 15 48 48

Unless I am mistaken there should be no difference between color-mix and the midway point of a linear gradient with these values.

https://bugs.chromium.org/p/chromium/issues/detail?id=1462612

@svgeesus
Copy link
Contributor

svgeesus commented Jul 6, 2023

Bugzilla bug for this: https://bugzilla.mozilla.org/show_bug.cgi?id=1836557

@svgeesus
Copy link
Contributor

svgeesus commented Jul 6, 2023

But I think we need more coverage for gradients :

We do, although reftests for gradients are hard because of things like stochastic dithering to break up mach banding.

@tabatkins
Copy link
Member

I guess it is too late (not Web compatible) to redefine transparent as rgba(none none none 0)?

Oh absolutely, transparent has been transparent black since forever, and it's observable from all sorts of places.

This illustrates that a linear gradient between transparent and red in srgb does not mix in extra black at the midpoint. That transparent is rgb(0 0 0 / 0) isn't observable here.

The trick is that gradients in rectangular spaces are defined to interpolate in premultiplied rectangular space, which has similar "basically act like you take channels from the other color" behavior, except it also works gradually as you move away from pure transparent. (Actually it changes the 4d hypercube of the rectangular space+alpha into a hypercone with a cubic base, so all transparent colors occupy the same point at the cone's tip and straight paths drawn thru the space look good regardless of what the starting transparent color is. Points near the cone tip are compacted as well, so a mostly transparent red vs blue only has a very minor effect on the color as you draw a path to something opaque.)

We don't have similar geometric tricks that can be done in polar spaces, tho, because the hue dimension isn't linear so it can't just be scaled down like that. So that's why we need this extra handling, which is also all-or-nothing.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
Closed as Question Answered Used when the issue is more of a question than a problem, and it's been answered. css-color-4 Current Work css-color-5 Color modification
Projects
None yet
Development

No branches or pull requests

4 participants