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-5] How should negative percentages behave in color-mix()? #6047

Closed
weinig opened this issue Feb 27, 2021 · 50 comments
Closed

[css-color-5] How should negative percentages behave in color-mix()? #6047

weinig opened this issue Feb 27, 2021 · 50 comments
Labels
css-color-5 Color modification

Comments

@weinig
Copy link
Contributor

weinig commented Feb 27, 2021

In CSS Color 5's color-mix() function, https://drafts.csswg.org/css-color-5/#color-mix, what should happen if a negative percentage is passed either as lone percentage or to an adjuster. For example, what should the behavior of the following two examples be:

color-mix(lch, red -10%, blue);
color-mix(lch, red hue -10%, blue);

In the investigative implementation in WebKit, I have implemented it as clamping to 0% in this case, but would like further clarity. Another, perhaps better, interpretation would be to keep it -10%, and use 110% of the hue of blue?

In general, being specific about values less than 0 and greater than 100 would be useful.

@smfr smfr added the css-color-5 Color modification label Mar 1, 2021
@LeaVerou
Copy link
Member

LeaVerou commented Mar 2, 2021

Agree we need to define this. Not sure what's the best way about it, but I'm not a huge fan of clipping, as it throws information away.

It would probably be more useful to extrapolate (i.e. -10% and 110%) in that case, but that is not always possible, e.g. when we hit the color space bounds. But clipping at the colorspace bounds is probably no worse than clipping regardless.

@svgeesus
Copy link
Contributor

svgeesus commented Mar 2, 2021

Its simple to define negative percentages - 10% of a color means you multiply the components by +10/100, while -10% means you multiply them by -10/100. Same for values greater than 100%

Whether that is a particularly useful thing to do, though, is not clear.

The language about mix percentages would also need to be updated, probably with an example

color-mix(lch, red -10%, blue);
color-mix(lch, red -10%, blue 110%); // same

@una
Copy link
Contributor

una commented Mar 4, 2021

Why do we need to define negative values here? I think this would just add confusion. When mixing paint colors, for example, there is no construct of negative mixing. I think of color-mix() in the same way.

color-mix(lch, red -10%, blue 110%);

What would this look like? What is 110% blue? Would it just look the same as color-mix(lch, red, blue 100%); and get clipped to blue?

I just don't think it makes sense to have negative values here. In the spec as its written, you would only have one percentage (for the first color) which prevents the percentages from not adding up to 100%, and anything over 100% would clip to one of the input values in the color-mix() function.

@svgeesus
Copy link
Contributor

svgeesus commented Mar 4, 2021

What is 110% blue?

1.1 times the color components for that color, in the colorspace being used for mixing.

Would it just look the same as color-mix(lch, red, blue 100%); and get clipped to blue?

No (I think you are being led astray by the example mixing colors here, happening to be primary colors).

Why do we need to define negative values here?

A fair question. It needs to be defined, if someone does that. Clipping to [0 .. 100] is a reasonable option.

@LeaVerou
Copy link
Member

LeaVerou commented Mar 4, 2021

Interpolating is not mixing paints. Sometimes that mental model works, but not always. There is a well-defined meaning of values out of range when interpolating, so there is something reasonable we could do here. Whether that's also useful is another question and I have no strong opinions on that.

If we don't allow percentages outside 0-100%, I wonder if we can make them invalid at parse time. Silently clipping them means there is zero feedback for authors to realize that something is off so I don't think it's a good idea.

@una
Copy link
Contributor

una commented Mar 4, 2021

I think this is not unreasonable to expect (0-100%) as valid inputs. This seems like a documentation/education issue, and a place where Dev Tools can interject a warning signal for clipping, as with other CSS that is invalid (if we don't want negative values to cause this to fail and ignore the line).

@LeaVerou
Copy link
Member

LeaVerou commented Mar 4, 2021

Any design flaw can and has been addressed via documentation/education, but that's usually a bandaid when it's too late to change the design. It's one of the basic usability antipatterns to avoid designing UIs that require documentation/education to make sense, since most people will not read much documentation, and you can never reach everyone to educate. UIs (including APIs) need to be designed to be understandable and usable even in the absence of documentation/education.

The more I think about this, the more I think that the most reasonable thing is for percentages out of range to extrapolate along the same line. Even if there are no use cases, it provides useful feedback which aids debugging, it is no harder to implement than regular interpolation (if anything, not doing this is probably extra implementation work), and doesn't get in the way of regular usage in any way. Note that there is precedent of extrapolation in CSS, with transitions with certain timing functions that make the interpolated value go beyond 100% (demo).

@tabatkins
Copy link
Member

Going outside of the 0-100% range violates the mental model that this is mixing colors and makes this function behave differently than the other mixing functions (cross-fade() in particular). The behavior y'all are describing isn't interpolation, either, it's arbitrary scaling and addition of the colors.

That has further odd implications - it implies that if the %s sum to >100%, that doesn't get rebalanced, you just get a super vivid/light/etc color. Similarly, %s summing to <100% just makes a very achromatic/dark/etc color. That's all pretty weird, and nothing like the concept of "mixing" we're trying to evoke here! Without a fairly compelling use-case for this, I'm strongly against trying to generalize past the "mixing" concept.

Like the other mixing functions, %s outside of 0-100% should just be invalid at parse time.

@tabatkins
Copy link
Member

transitions with certain timing functions that make the interpolated value go beyond 100%

Ah wait, I see, your mental model could be interpolation given the older syntax that we just resolved to change away from. If there's a single %, and you interpret it as an interpolation %age between the two colors, then a <0% or >100% value is indeed just further interpolation in the same manner.

But since we resolved to instead stick closer to the other mixing functions and allow %s on both values, this analogy no longer holds.

@LeaVerou
Copy link
Member

LeaVerou commented Mar 5, 2021

Tab, cross-fade() does compositing, not interpolation. color-mix() is currently defined in terms of interpolation, and that affects a lot of the design decisions around it. If you think it should be performing compositing instead, we could discuss that change in a separate issue (and potentially change the default color space to a linear light one that is more appropriate for compositing). However, until such a change is made, the underlying model is interpolation, with everything that comes with it, good and bad.

The percentages on both values are merely syntactic sugar to avoid authors having to manually subtract from 100%. It's still only one percentage really.

@tabatkins
Copy link
Member

cross-fade() does compositing, not interpolation

I'm very confused - cross-fade() is literally the way that images interpolate. Implementation hasn't caught up, but when you transition background-image/etc, cross-fade() is just how you write the intermediate state. I'm not sure what distinction you're trying to draw here.

Regardless, tho, my argument is at a different level - both of these functions are mixing two (or more) things. The fact that we can implement "mixing" as "scaling and adding" doesn't mean that "scaling and adding" is the correct mental model we should be exposing to authors; that's a leaky abstraction. Like cross-fade() does, the interface we should be exposing to authors is mixing-focused, and that implies certain things about how we interpret overconstrained % situations.

Unless there's a very good reason otherwise, the various value-mixing operations should look as identical as possible to authors; giving authors bespoke mechanisms for what are, to them, identical operations with different value types is bad design on our part. If you think there's a very good reason why color-mix() should act like this, then presumably it applies to cross-fade() as well, and we should harmonize the specs. If you think there's a very good reason why color-mix() should act like this that doesn't apply to mixing images, I'd love to hear it, so I can be convinced that these mixing functions should indeed act differently from each other.

But right now I see absolutely no difference between the two. cross-fade()'s definition of "linear weighted average of each pixel's colors, in premultiplied sRGB space" is mathematically the same as "scale and add (in a particular colorspace)", so any argument you can give for why colors should act in a certain way due to the underlying math would apply to images exactly as well.

(Also, the

The percentages on both values are merely syntactic sugar to avoid authors having to manually subtract from 100%. It's still only one percentage really.

I don't understand what you mean by this, either. Could you elaborate? Unless I'm misunderstanding, it's omitting a % that allows authors to avoid manually subtracting from 100%.


Tangentially, you had said in the call that there were reasons to limit color-mix() to only two colors, and I thought that I remembered you or Chris explaining why in one of the issues, but I can't find it now. Can you point me to it, or restate it? If the math of color-mix() is literally just "interpolate the components, via scaling and adding", then I'm not sure why three colors are problematic.

@tabatkins
Copy link
Member

The minutes-bot wasn't told the issue number for this discussion, but here are the minutes from yesterday's call:

How should negative percentages behave in color-mix()?

github: #6047

chris: Sam Weinig who is implementing pointed out that can't do
negative percentages
chris: una said why don't we clip it 0-100%
chris: nobody wants this
chris: I can define it
fantasai: Can you make it invalid?
TabAtkins: Invalid matches behavior in other mixing functions
TabAtkins: We intend for it to be meaningless, so should be invalid

RESOLVED: negative percentages are invalid in color-mix()

@LeaVerou, are you saying that we should re-open the issue and change the resolution? Because the minutes are quite clear on what we decided - negative %s are invalid (and the intention of the resolution is that >100% individual percentages are invalid, too, it just didn't get caught by the resolution text). And the preceding discussion, similarly, said that when both colors give a % and they sum to >100%, we'll rebalance the %s to add to 100%, not just literally scale each as specified then add them together.

The only remaining open question was about what to do when they both specified a % and they sum to <100%, because I thought there might be an argument to match cross-fade() and "fill in the remaining %" with transparent, you said there were problems with this, and I didn't want to take up more call time with just me being educated so I suggested we discuss it in the issue so we could decide next week.

@mirisuzanne
Copy link
Contributor

I would be very surprised as an author if this function created colors outside the range of "mixing" values between the two colors. I support the existing resolution.

I also think out-of-range numbers are most likely to result from calc() results, which we didn't discuss in detail. We'd likely want those values to be clipped into range? I bring that up because I don't think we can clip calc-values, and also allow non-calc values to over-shoot the range. That would be a particularly unexpected mismatch.

@tabatkins
Copy link
Member

If out-of-range values are syntactically invalid, then math functions automatically clamp to the range (and thus are always valid).

@svgeesus
Copy link
Contributor

svgeesus commented Mar 6, 2021

Going outside of the 0-100% range violates the mental model that this is mixing colors

No, it really doesn't. Indeed the very first color equivalency mixing experiments in the 1920s and 30s utilized negative colors.

color mixing

and makes this function behave differently than the other mixing functions (cross-fade() in particular).

Firstly, cross-fade() is not the canonical definition of color mixing or or image compositing :) these have already been described in the literature since 1931 (CIE, mixing light) or 1984 (Porter-Duff)

  • CIE 1931 XYZ
  • Thomas Porter; Tom Duff. Compositing digital images. July 1984.

Secondly no, mixing light can and does have percentages below zero or greater than 100%. This can be easily visualized on a chromaticity diagram. The two colors to be mixed are represented as points on the u'v' plane, colors produced by interpolation lie on the straight line between them and extrapolated colors lie on the continuation of that line beyond the endpoints.

As an example, producing the secondary colors yellow, cyan and magenta from RGB primaries:

example uv chromaticity diagram

But obviously any two colors can be mixed, not just the primaries.

The behavior y'all are describing isn't interpolation, either, it's arbitrary scaling and addition of the colors.

If you are outside the [0-100] range it is not linear interpolation but linear extrapolation. The math is entirely the same though. That aside, it is exactly mixing colors.

Having said all that, which expands on "This is easily defined" on the call, let me go back to my other point on the call "no-one really seems to be asking for this". Sam is implementing and just wanted it defined what happens when someone gives percentages outside [0% .. 100%]. The options are:

  1. say its invalid, halt and catch fire
  2. silently clip to the [0% .. 100%] range
  3. just do the mixing math, which is the same as the interpolation math actually.

I don't much like option 1 but could get behind either 2. or 3.

Note that picking 2. locks us out of ever moving to 3. after a while, because of Web compat.

@facelessuser
Copy link

Looking at the spec, what is confusing is not the interpolation/extrapolation of negative numbers, it's the resolving two different percentages that are phrased as having to equal 100% but can be out of bounds both in the positive and negative direction.

If I specify one angle at 110%, and using what the spec says, and calculate the other percentage with 1 - p, I end up with the second angle of -10%. Now I have 110% and -10%, what I do with that? This is where you start confusing the user. An algorithm to scale the values to 0 - 100 fails when you start mixing in negatives as you can have divide by zeros results that don't quite make sense. There may be a way to handle this, but it seems like it would be confusing to the user.

If there was a single percentage, and you knew which color it referred to, then you take out the complexity of resolving the two percentages (and the user thinking about how it resolves). You can feed in any percentage, negative or positive, and you can just send it straight through interpolation/extrapolation.

This is just my take at reading the spec and what I've been having difficulty wrapping my brain around.

@LeaVerou
Copy link
Member

LeaVerou commented Mar 6, 2021

@tabatkins

The minutes-bot wasn't told the issue number for this discussion, but here are the minutes from yesterday's call:

How should negative percentages behave in color-mix()?

github: #6047
chris: Sam Weinig who is implementing pointed out that can't do
negative percentages
chris: una said why don't we clip it 0-100%
chris: nobody wants this
chris: I can define it
fantasai: Can you make it invalid?
TabAtkins: Invalid matches behavior in other mixing functions
TabAtkins: We intend for it to be meaningless, so should be invalid
RESOLVED: negative percentages are invalid in color-mix()

@LeaVerou, are you saying that we should re-open the issue and change the resolution? Because the minutes are quite clear on what we decided - negative %s are invalid (and the intention of the resolution is that >100% individual percentages are invalid, too, it just didn't get caught by the resolution text). And the preceding discussion, similarly, said that when both colors give a % and they sum to >100%, we'll rebalance the %s to add to 100%, not just literally scale each as specified then add them together.

I had not realized we resolved against this, I would have argued against it. As is evident in the minutes, this appears to have been a very quick discussion, blink and you've missed it. I suppose making them syntactically invalid per the resolution is better (more feedback) than just silently clamping. This is definitely not the hill I want to die on, but I will reply to the questions you raised.

If you think there's a very good reason why color-mix() should act like this, then presumably it applies to cross-fade() as well, and we should harmonize the specs. If you think there's a very good reason why color-mix() should act like this that doesn't apply to mixing images, I'd love to hear it, so I can be convinced that these mixing functions should indeed act differently from each other.

But right now I see absolutely no difference between the two. cross-fade()'s definition of "linear weighted average of each pixel's colors, in premultiplied sRGB space" is mathematically the same as "scale and add (in a particular colorspace)", so any argument you can give for why colors should act in a certain way due to the underlying math would apply to images exactly as well.

  • As I pointed out, transitions already have the behavior I was proposing for handling percentages outside [0%, 100%]. I'm all for consistency, but I think consistency with something as widely deployed as transitions is far more important than consistency with a feature that's largely unimplemented, and whose one implementation doesn't even match the spec. Also, authors are more likely to draw parallels with other areas of CSS where colors are interpolated, than with a function that interpolates between images.
  • Note that, if I remember correctly, there were similar discussions around the y percentages of cubic-bezier(), and it turned out that authors did, in fact want to go outside [0%, 100%] and extrapolate (to create bouncing effects) so we changed it. It's literally zero more effort to implement (I think), and strictly more powerful. The clamping behavior is something authors can always force with clamp(), however if we go with clamping, there is no way to force the other behavior (extrapolation).
  • In addition to consistency with transitions, and use cases, I originally argued for extrapolation as a feedback mechanism to facilitate debugging.

Regardless, tho, my argument is at a different level - both of these functions are mixing two (or more) things. The fact that we can implement "mixing" as "scaling and adding" doesn't mean that "scaling and adding" is the correct mental model we should be exposing to authors; that's a leaky abstraction. Like cross-fade() does, the interface we should be exposing to authors is mixing-focused, and that implies certain things about how we interpret overconstrained % situations.

I don't think defining this around interpolation is a leaky abstraction. Interpolation as a concept is pretty high level. There are numerous other things in CSS that use interpolation so authors already need to be familiar with it. Picturing the result of color-mix() as a point along a gradient is far easier to conceptualize than some notion of "mixing" that does not appear anywhere else (and is easily confused with mixing paints, which is not a helpful model when mixing light).

The behavior y'all are describing isn't interpolation, either, it's arbitrary scaling and addition of the colors.

It's extrapolation, and the math is identical. They're the two sides of the same coin.
Could you describe what this "arbitrary scaling and addition" that you're describing is?

That has further odd implications - it implies that if the %s sum to >100%, that doesn't get rebalanced, you just get a super vivid/light/etc color. Similarly, %s summing to <100% just makes a very achromatic/dark/etc color.

How so? They are still normalized to sum to 100%. 110% and -10% sum to 100%. Nobody is proposing that the percentages shouldn't normalize.

Unless there's a very good reason otherwise, the various value-mixing operations should look as identical as possible to authors; giving authors bespoke mechanisms for what are, to them, identical operations with different value types is bad design on our part.

You just said that cross-fade() performs interpolation, but color-mix() should be explained in terms of "mixing", not interpolation (whatever that means). If that's the case and their mental model is different, why do their syntaxes need to be harmonized? If that's not the case, and they are both about interpolation, then going outside [0,100%] along the same line makes sense, as you agreed. Which one is it?

I don't understand what you mean by this, either. Could you elaborate? Unless I'm misunderstanding, it's omitting a % that allows authors to avoid manually subtracting from 100%.

What I mean is there is there is still only one data point: the point along the progression. It can be expressed either as percentage of color1, or percentage of color2, or both that should add to 100% (and if it doesn't, it's normalized to be). This does not hold true if we resolve to handle sums under 100% differently.

Tangentially, you had said in the call that there were reasons to limit color-mix() to only two colors, and I thought that I remembered you or Chris explaining why in one of the issues, but I can't find it now. Can you point me to it, or restate it? If the math of color-mix() is literally just "interpolate the components, via scaling and adding", then I'm not sure why three colors are problematic.

This was the issue, though not sure it contains all relevant discussion: #4711

@facelessuser
Copy link

How so? They are still normalized to sum to 100%. 110% and -10% sum to 100%. Nobody is proposing that the percentages shouldn't normalize.

Ah, I was under the impression they had to be constrained between 0 - 100, not simply sum to 100%. That is helpful.

@facelessuser
Copy link

So the math checks out with negatives and such, but how do you handle the case where p1 + p2 = 0? For instance, if -50% and 50% are specified?

@facelessuser
Copy link

So, I've coded up the percent normalization per the current specification to demonstrate the divde by zero issue. You can enter any number, negative or postive, and all cases seem to resolve to a sum of 100, except when given two percentages that sum to zero: https://codepen.io/facelessuser/pen/eYBLWjd.

So, if things remain as specified in the spec to extrapolate colors beyond the bounds of 0 - 100, this case seems it would need to be defined as the current behavior is undefined. Do you just return a transparent black color? Do you just not mix in this case and return the first color?

Side note: The spec should also put some round brackets around: p1 / p1 + p2 -> p1 / (p1 + p2).

@svgeesus
Copy link
Contributor

svgeesus commented Mar 8, 2021

So the math checks out with negatives and such, but how do you handle the case where p1 + p2 = 0? For instance, if -50% and 50% are specified?

Good point and that does indeed need to be specified.

@svgeesus
Copy link
Contributor

svgeesus commented Mar 8, 2021

Side note: The spec should also put some round brackets around: p1 / p1 + p2 -> p1 / (p1 + p2).

Yes!

@una
Copy link
Contributor

una commented Mar 10, 2021

I still feel that adding negative values to color-mix() introduces unnecessary complexity. This function is intended to simplify mixing between two colors. For more advanced color-adjustment, there is color-adjust().

I feel that most people's mental models of "color mixing" is taking two colors and mixing a bit of each together to result in a final value. The final value must add up to 100%, regardless of what percentage of each color was added. Therefore it makes sense to me that anything outside of a positive percentage of either of the input colors is invalid or clipped between [0...100].

Using color-mix() to introduce negative color adjustment feels like the wrong tool for the job (it should be color-adjusted first to get the actual value they'd want to be mixing (and color-adjust can also be nested inside this function, providing a cleaner view for what's happening). To me, negative input values seem to result in difficult-to-understand outputs.

@LeaVerou
Copy link
Member

LeaVerou commented Mar 10, 2021

I still feel that adding negative values to color-mix() introduces unnecessary complexity.

Complexity for whom? For implementations, I think it might even be marginally less effort. For authors, it's not something they will likely have to deal with, unless they are intentionally exploring the boundaries of what color-mix() does and trying to extrapolate. In the same way that the color bouncing that happens in transitions is not something authors are regularly confused by, since it only occurs when they intentionally specify a cubic-bezier() value that goes out of range. You are talking about this as if there is no precedent, but this is how transitions work, today.

This function is intended to simplify mixing between two colors. For more advanced color-adjustment, there is color-adjust().

FYI color-adjust() cannot perform extrapolation between two colors. I think you mean the lower-level syntax which is not in the spec yet. 😁

I feel that most people's mental models of "color mixing" is taking two colors and mixing a bit of each together to result in a final value.

You are using a word in its definition here, basically saying that people's mental model of mixing is …mixing. This is a tautology. What is this mixing? How does it work?

@una
Copy link
Contributor

una commented Mar 10, 2021

Complexity for whom?

I think for the authors. If values outside of [0...100] are simply invalid and thus clipped, that simplifies the cognitive model of color-mix

You are using a word in its definition here, basically saying that people's mental model of mixing is …mixing. This is a tautology. What is this mixing? How does it work?

Definition from Oxford Languages of mix:

  1. verb: "combine or put together to form one substance or mass."
  2. noun: "two or more different qualities, things, or people placed, combined, or considered together."

These definitions supports the idea that color-mix is about positive combination

@facelessuser
Copy link

"Subtractive color mixing" is a thing though, and they use the term "mix": https://en.wikipedia.org/wiki/Color_mixing. Context is important with terms.

@tabatkins
Copy link
Member

How so? They are still normalized to sum to 100%. 110% and -10% sum to 100%. Nobody is proposing that the percentages shouldn't normalize.

I'm somewhat confused here. The context for "sum to lt or gt than 100%" is two %s being specified, one on each color. If the two %s are 20% and 30%, or 100% and 150%, the "just scale and add the components, because that's how interpolation/extrapolation is defined" results in a very dark or very light color.

Looking at this again, tho, I think I should be reading your comments as saying "in all cases, sum the %s, then rescale them to equal 100%". So if they were 100% and -50% (sum to 50%), we'd rescale them to, hm, I guess 200% and -100%? This doesn't seem like a great answer to land on, unfortunately - if all the %s are positive the effects of scaling seem pretty reasonable, but when one is negative the re-scaling starts having outsized effects. (It's also undefined if they sum to 0%, and summing to a negative % is either undefined or really wacky since it'll invert the %s.)


You just said that cross-fade() performs interpolation, but color-mix() should be explained in terms of "mixing", not interpolation (whatever that means).

You said that cross-fade() wasn't about interpolation ("Tab, cross-fade() does compositing, not interpolation."), while color-mix() was (implying that color-mix() strictly sticking with interpolation math was okay even if cross-fade() didn't). I retorted that cross-fade() was literally defined as the way images interpolate in CSS. ^_^

It is also true that cross-fade() is designed to conform to the mental model of "mixing" images together, not strictly following the results of interpolation math even if there is a well-defined answer when you do so. Percentages are limited to the range [0%, 100%], because values outside that range don't make conceptual sense, even if the math still works out; percentages that sum to >100% are rebalanced to 100% exactly†; and percentages that sum to <100% fill the remainder with transparent to model the idea that you're only grabbing "some" of the image and mixing it in.

†: The metaphor here is that if you're throwing in "too much" of the images, you'll just make a too-large pile of pixels. When we mix them together and scoop out the correct amount to fill 100%, you'll get a result with the same ratio as the inputs originally had. The same metaphor covers a sum < 100%; we mix them all together and measure out 100%, but the inputs didn't fill all that so there's "empty space" left in the result.

@facelessuser
Copy link

I think negative mixing does make conceptual sense. Sometimes it can be more intuitive. For instance, if I had a purple, and I thought that purple looked too red, you could subtract a portion of the red until you were happy. This is quite a bit different than just decreasing the red channel, which will only decrease the red, but not increase the blue portion. This is actually using the algorithm described in the spec:

Screen Shot 2021-03-10 at 12 19 38 PM

It's really like the inverse of the mix. If we crank it up, we get blue (for the most part due to rounding stuff and such 🙂).

Screen Shot 2021-03-10 at 12 29 33 PM

But yeah, when p1 + p2 = 0, I'm not sure what that should be 🤷.

@tabatkins
Copy link
Member

Notably, your first example shows off the weirdness of negative percentages as well - your final result isn't just less red, it's more blue because the purple's omitted percentage automatically gets set to 140%.

And the only reason it's less red is because your red starts with a higher R channel than your purple. If you were subtracting a less intense red and/or using a more intense purple, subtracting red would actually cause the result's red to go up, due to the purple supersaturating.

I do not think this is actually an intuitive result.

@LeaVerou
Copy link
Member

Complexity for whom?

I think for the authors. If values outside of [0...100] are simply invalid and thus clipped, that simplifies the cognitive model of color-mix

I have not seen anyone confused about color transitions which can extrapolate in the way proposed, have you?

Also note that invalid and clipped are mutually exclusive solutions, not the same thing. Consider this:

background: red;
background: color-mix(gray -10%, yellow);

If values out of range are clipped, then this produces a background of yellow.
If they are invalid, then the second declaration is dropped, and the background is red.

You are using a word in its definition here, basically saying that people's mental model of mixing is …mixing. This is a tautology. What is this mixing? How does it work?

Definition from Oxford Languages of mix:

  1. verb: "combine or put together to form one substance or mass."
  2. noun: "two or more different qualities, things, or people placed, combined, or considered together."

These definitions supports the idea that color-mix is about positive combination

I wasn't being pedantic. I was looking for a more precise definition of this "mixing" that we can use in spec work, not synonyms from a thesaurus.

I would also reiterate @svgeesus's point that the first color equivalency experiments with human subjects in the 20s and 30s were using mixing with negative percentages and the subjects seemed to be having no trouble with the concept. I will try to find a source to cite.

@svgeesus
Copy link
Contributor

the first color equivalency experiments with human subjects in the 20s and 30s were using mixing with negative percentages and the subjects seemed to be having no trouble with the concept. I will try to find a source to cite.

Notice that a considerable portion of the red curve in the shorter wavelength portion of the spectrum lies below the axis; that is, its values are negative! This feature is an expression of the fact that light of these wavelengths could not be matched by any mixture of the primaries. However, when the red primary was shifted to mix with the unknown rather than the other primaries (i.e. ‘negative’ light), a match could be obtained.

Since it was thought to be inconvenient to deal with colour matching functions containing negative values, a linear transformation was used to convert the rgb curves to x-bar, y-bar, z-bar curves having no negative values.

https://www.sciencedirect.com/topics/engineering/color-matching-function

@tabatkins
Copy link
Member

I don't see how these experiments are relevant to our question.

color-mix(rgb, rgb(240 0 240), rgb(230 0 0) -20%) gives a result that is more red than either argument. (After clamping, the result is just rgb(242 0 255).) You're not "removing" red at all.

This is not intuitive behavior. You're not meaningfully mixing anything, and the re-scaling to 100% produces unintuitive results when one of the arguments is negative.

@LeaVerou
Copy link
Member

LeaVerou commented Mar 11, 2021

I don't see how these experiments are relevant to our question.

Isn't the question whether humans have trouble mixing colors with negative coefficients? If not, what's our question?

color-mix(rgb, rgb(240 0 240), rgb(230 0 0) -20%) gives a result that is more red than either argument. (After clamping, the result is just rgb(242 0 255).) You're not "removing" red at all.

rgb(242, 0, 255) is not "more red" than rgb(240, 0, 240), you're just looking at the red coordinate and ignoring the others. The actual hue is indeed less red, even with the clipping: 300 for rgb(240, 0, 240) and 297 for rgb(242, 0, 255).

@tabatkins
Copy link
Member

If not, what's our question?

There's a few questions:

  1. Does your suggested syntax and argument treatment give people results they would expect and be able to predict?

  2. If yes, since cross-fade() is doing per-pixel pre-multiplied sRGB color mixing, why shouldn't we apply the same behavior to cross-fade()?

And I think the answer to (1) is no once one gets past simple, low-value examples.

For example, under your proposal, color-mix(rgb, rgb(0 0 100), rgb(100 0 0) -90%), the resulting color (after clamping) is rgb(0 0 190). By subtracting a lot of red, we didn't adjust the hue at all (it stayed exactly on 240deg), but made the blue much brighter.

I think an obvious way to try and avoid that brightening effect would be to instead write something like ``color-mix(rgb, rgb(0 0 100) 100%, rgb(100 0 0) -90%)- this avoids the blue's unstated % getting resolved to 190% to balance things out. But this makes things even worse - now the %s sum to 10%, so you have to rescale, ending up with 1000% and -900%, which post-clamping gives yourgb(0 0 255)` as a result, the maximum-vividness blue.

Nothing about these situations is undefined, it just doesn't give intuitive results. (Tho the treatment of color-mix(rgb, rgb(0 0 100) 90%, rgb(100 0 0) -90%) is undefined, and color-mix(rgb, rgb(0 0 100) 40%, rgb(100 0 0) -90%) is defined but weird since the rescaling factor is negative.)

If we didn't rescale, the operations would at least make a little more sense. color-mix(rgb, rgb(0 0 100) 100%, rgb(100 0 0) -90%) would yield rgb(0 0 100), because its red is already non-existent so you can't remove more of it. We'd need to special-case negative values to ignore them when filling in missing %s, so color-mix(rgb, rgb(0 0 100), rgb(100 0 0) -90%) is the same. It would also mean that color-mix(rgb, rgb(0 0 100) 100%, rgb(100 0 0) 100%) gives a dark purple rgb(100 0 100) while color-mix(rgb, rgb(0 0 100) 10%, rgb(100 0 0) 10%) gives a super dark purple rgb(10 0 10); these are understandable, but still different from what cross-fade() does in similar situations.

you're just looking at the red coordinate and ignoring the others.

I'm mixing in the RGB space, so I think it's reasonable to assume that the results should be predictable from the RGB data. ^_^ Even if I was thinking in terms of hue, I'd definitely expect removing 20% of a pretty vivid red to make the hue budge by more than 3deg; that's a barely-detectable shift.

@facelessuser
Copy link

I'm mixing in the RGB space, so I think it's reasonable to assume that the results should be predictable from the RGB data. ^_^ Even if I was thinking in terms of hue, I'd definitely expect removing 20% of a pretty vivid red to make the hue budge by more than 3deg; that's a barely-detectable shift.

The more alike they are, the less it'll move. If you do color(srgb, red, red -20%), you'll get red. Because then you're using 120% of the other red even though you are using -20% of the other red. But I think the "120% of the of the other" isn't the right description. You are interpolating, so when you are interpolating between two points that are the same point, you get a dot, not a line. So, no matter where you move on that line, it's red.

@svgeesus
Copy link
Contributor

color-mix(rgb, rgb(240 0 240), rgb(230 0 0) -20%) gives a result that is more red than either argument.

Yes, of course. You have a mix line going from the first color (a magenta) to the second color (a red) and your mix point is past the red ie more red.

This is basically how addition and subtraction work. Subtraction is the same as addition with a negative number; addition is the same as subtraction with a negative number.

(After clamping, the result is just rgb(242 0 255).)

Per-component clamping is dumb unless the oog color is really close to the gamut boundary, and the spec does not recommend it as a gamut mapping method. Otherwise, the hue can radically change.

@tabatkins
Copy link
Member

If we're at the point of this disagreement where you think I need first grade arithmetic explained to me, I'm not sure how to progress.

@LeaVerou
Copy link
Member

I suspect if negative percentages are allowed, they will be used in small tweaks, i.e. "make this a little less blue", not with huge changes like -90%, so that's a red herring. There are numerous combinations of existing CSS with extreme values that produces weird results, as I'm sure you're aware.

Anyway, this disagreement is starting to be non-productive, so let's step back for a moment.

There have been arguments expressed that allowing negative percentages is more consistent with the rest of CSS (due to transitions), and arguments that it's less consistent with the rest of CSS (due to largely unimplemented cross-fade()). Since there's conflicting precedent here, we cannot use internal consistency to guide us to make a decision.

In terms of intuitiveness, some participants (me, @svgeesus, @facelessuser) think handling negative percentages as extrapolation along the same line produces reasonable/useful results, and some (@tabatkins, @una) think it's unintuitive. What is the downside of allowing it? Authors who think it's unintuitive can just not use it, just like they have not been using color transitions out of 0-100%.

We are not discussing two alternatives here, one of which is more intuitive than the other, we're discussing doing something vs doing nothing. Those who would prefer negative percentages to do nothing, can still simply …not use negative percentages.

@tabatkins
Copy link
Member

There are numerous combinations of existing CSS with extreme values that produces weird results, as I'm sure you're aware.

Of course, extreme results aren't always relevant to our decision-making process; sometimes they just fall out of the behavior and that's okay. But it's useful to check extreme results and see if they do accord with our intuition; if something only makes sense for small nudges and goes wild for large values, that implies that our behavior model might not be shaped correctly, and the "reasonable" behavior for small values might just be an accident of stumbling onto a happy zone of otherwise overall bad behavior. (And to be clear, I think that's exactly what's happening here.)

In this case there are several bits of the behavior that end up unreasonable, I think:

  • Chris appears to be stating that the obvious interpretation of red -20% to him is "make the result 20% more red", because you're moving 20% past red on the interpolation line. This confuses me! In particular, I would think that red 100% would ensure there's a lot of red to the result, red 1% would add very little red to the result, and continuing that line of reasoning, red -10% should be actively removing red from the result. There's apparently a big intuition mismatch here between us.

  • You've repeatedly talked about %s as being best interpreted as interpolation progress between the two colors. In the previous syntax model, where there was only one percentage, this seemed okay, and a % that was negative or >100% had an obvious and reasonable interpretation. But now that we've resolved to match cross-fade()'s syntax more closely and allow %s on either or both, I don't think that interpretation is reasonable any longer, for two reasons:

    1. An interpolation % means that 0% refers solely to the start value and 100% refers solely to the end value. But if you can write either red 0%, purple or red, purple 0%, then one of those %s has that interpretation; the other has the opposite. (And if red 0%, purple means "all red", I think that's definitely opposite what people would naturally assume; a % visually paired with a value like that (almost?) always does the opposite.)

    2. We have to figure out what the interpolation % is when you supply two %s, and the simple "scale to 100%" logic does not give reasonable results in many cases. Like, red -20%, purple defaulting to red -20%, purple 120% due to filling in the missing % isn't too bad (tho I think it does give weird results, as I said in previous comments), but the fact that red -20%, purple 100% gives even more extreme results (red -25%, purple 125% after rescaling) is very unintuitive, I think. And as noted in previous comments, red -100%, purple 100% is undefined, and red -200%, purple 100% is defined but weird (it rescales to red 200%, purple -100%, inverting the signs).

      (I'd have to track down the email thread, but I remember 8-10 years ago there being a discussion about the unintuitiveness of negative %s in background-position when the image is larger than the background area, since it makes the image move to the right, while negative lengths make it move to the left. That ended up having sufficiently good motivation behind it that it was left alone.)

Overall, if the design we want to pursue is interpolation-based in its intuitions, then we should make sure the syntax is interpolation-focused so that authors develop the right intuition from the get-go. A generalized interpolate() function would look like interpolate(<percentage>, <value-1>, <value-2>), clearly separating the % from the values; I think it's clear that interpolate(0%, red, purple) yields red, for instance (and negative or >100% values also similarly intuitive, extrapolating along the mix-line).

But if we're going with a cross-fade()-like syntax, then interpolation is not the intuition we need, but rather mixing, and in that case negative values are best treated as invalid because they don't have a meaningful interpretation. (>100% values can be interpreted reasonably in sum, but they don't have a reasonable metaphor on their own. That is, 150%, 50% obviously rescales to 75%, 25%, same as the valid 100%, 33%, but what it means to "take 150% of the total from the first value" is somewhat nonsensical.)


@svgeezus

Yes, of course. You have a mix line going from the first color (a magenta) to the second color (a red) and your mix point is past the red ie more red.

The behavior I'm describing is not due to the red; using red actually reduces the amount of red in the result. The increase in the red channel happens because magenta contains red. You can swap it out for green and it'll just make the result even redder: color-mix(rgb, rgb(100 0 100), rgb(0 100 0) -20%) results in an even brighter magenta (specifically, rgb(120 0 120)).

Alternately, if I stick with red but make it brighter (250 rather than the original example's 230, compared with the magenta's 240), the result's red channel goes down.

So I think this is pretty good evidence for the described behavior being unintuitive, if your "of course" result is backwards.

And similarly, this is a strike against Lea's earlier explanation of it being intuitive with "The actual hue is indeed less red"; because the hue doesn't change at all when I mix with green. And it's not because purple and green are opposites, either; mixing red with negative green as in color-mix(rgb, rgb(100 0 0), rgb(0 100 0) -20%) resolves to rgb(120 0 0), a brighter red. The hue only changed because both input colors touched the red channel; any combo that uses independent channels will leave the hue unchanged from the positive-% color.

(And that's ignoring that you and Lea appear to have exactly opposite intuitions here, since Lea found it intuitive and reasonable that the hue moved further from red in that example. One might charitably interpret the difference as you referring specifically to the red channel and not making a statement about the hue at all, but your reasoning about the mix line does suggest that the color should have been closer to red in hue as well.)

Per-component clamping is dumb

While I agree with you, that's not relevant to the example; it just happens to occur with the numbers I chose because they were close to the max channel values. If I start with more central values, for example color-mix(rgb, rgb(100 0 100), rgb(80 0 0) -20%), then the result is rgb(104 0 120) - no clamping involved at any step, but the same result of the red channel being higher than either input's red channel.


So overall I don't see how we can conclude that the behavior is intuitive. Both of y'all have offered intuitive explanations of the results that were wrong, and closely-related examples (non-extreme ones, even) wouldn't be explainable with those intuitions at all. If y'all feel strongly that this should be interpreted as an interpolation, then the syntax we resolved on is wrong and we should re-open. If y'all like the syntax we resolved on, then this can't be interpreted as a naked interpolation, and <0% or >100% needs to be invalid like the other mixing function already in CSS.

@css-meeting-bot
Copy link
Member

The CSS Working Group just discussed [css-color-5] How should negative percentages behave in color-mix()?, and agreed to the following:

  • RESOLVED: clamp values between 0-100, all other values are invalid
The full IRC log of that discussion <dael> Topic: [css-color-5] How should negative percentages behave in color-mix()?
<dael> github: https://github.com//issues/6047
<dael> Rossen_: Do we have right people to proceed?
<dael> many: yes
<dael> TabAtkins: chris leaverou_ feel free to jump in
<leaverou_> q+
<dael> TabAtkins: A few weeks ago talked about colormix and grammar. Matching so each color has a % and normalize to 100% if don't sum
<leaverou_> q+ ChrisL
<dael> TabAtkins: Further question about what should allowed range be. crossfade only allows 0-100 and if you exceed it's invalid. leaverou_ and chris argue allowing other values make sense. Have justification for it that I can't parse. una and I on side of not doing that and matching crossfade
<dael> TabAtkins: Arguement is that negative % and greater % don't accord with mixing colors we're trying to define. not intuitive, though mathematical, and would confuse. Also, when normalize to 100%, that is extrodinarily weird with negative %
<dael> TabAtkins: You have to grow values to sum to 100% and get very strange large results. redd 100 and blue -100 there's no way to resolve. Sometimes you get sum but multiply by negative to get it. Even if it's well defined the edge cases are very weird.
<dael> TabAtkins: leaverou_ and chris have added explinations, but I think they contridict
<dael> leaverou_: not sure we should base on crossfade which is something not widely impl. Only 1 or 2 impl and at least WK is not per spec. Not sure why design based on that.
<dael> leaverou_: main point is there is a place in css where we color interpolate and allow wider range. In transitions. You can have cubic bezier that's out of range and it does what chris and I advocate
<dael> leaverou_: Can look in issue for results, don't think it's unintuitive. If you mix with blue -% you get a less blue color. Blue coord in sRGB is less but hue is less blue which is what matters
<dael> leaverou_: If you mix -100% something it's weird, but edge cases that are weird in everything
<dael> TabAtkins: What you said is wrong. It doesn't make it less blue. It's dependent on other color. I have an example where -blue + green is more green
<dael> chris: we need to avoid saying you are wrong in these discussions.
<Rossen_> q?
<dael> chris: We're talking abotu a hue angle. not gamma encoded.
<dael> TabAtkins: Agree you don't get a hue. I have green + negative blue and you get more green channel
<dael> chris: You're looking at gamma encoded channels. Can't do it like that. Look at actual color
<dael> TabAtkins: Would you like me to show you color and you can tell me hues
<dael> chris: not going to do calc on the fly
<miriam> q+
<dael> chris: You can't present as leaverou_ and chris have weird idea, color science has this idea. There is a positive case. You get ringing artifacts on the transition. We're not saying great use case for -300%. We're saying sometimes you have slightly over or under. You have line extend out pastt the 2 colors on chromatics.
<dael> chris: I get it could be unintuitive, but possible to spec and reasonably useful. I'm fine with clampping 0-100, jsut suboptimal
<TabAtkins> I'm ready to respond whenever btw
<Rossen_> q?
<dael> leaverou_: If we end up not allow they should be invalid. Silent clamp there's no feedback to authors and can't change
<dael> Rossen_: Point of order. Convo is lively. chris I appriciate working with tone. I want to discourage blaming and direct language. Let's focus on issue here.
<dael> TabAtkins: Real quick, I'm talking about facts here. Casting me as this is technically wrong is being heated is disappointing. I don't want this as TabAtkins is angry because I'm saying the math doesn't back up what you're saying. As far as I can tell my examples show that the math is wrong. I want to focus on that
<dael> Rossen_: Let's continue to focus on that math. Leave personas out of discussion
<chris> q?
<leaverou_> q-
<dael> Rossen_: There's 3 folks on the queue. leaverou_ and chris you had taken turns. Are you done?
<dael> chris: let's miriam
<chris> q-
<Rossen_> ack miriam
<Rossen_> ack chris
<dael> miriam: Reading this from mental model I liked in TabAtkins last comment the sugestion if we want allow negative mix better framed as color interpolation. If we want extending past the mix that would get a new name and syntax. for mixing we would clamp or make invalid numbers above or below 100 or 0
<chris> q+
<dael> Rossen_: Hearing 2 folks suggesting clamping between 0 and 100 is fine as long as rest of range is handled as invalid
<florian> q+
<leaverou_> q+
<dael> chris: agree. silent clamp we're struck so if restrict have to invalid
<Rossen_> ack chris
<dael> TabAtkins: agree
<Rossen_> ack florian
<dael> florian: chris said he won't do color math on fly. Reasonable. If he would do it offline it will show where the math mistake is. Based on that if he disproves one math there's no reason to continue.
<dael> TabAtkins: Happy to do that. Have a trivial example with single numbers we can look at and talk about
<dael> florian: I would suggest we do it offline. Make sure everyone has math right
<dael> chris: If we agree we're on 0-100 only we don't have to care about math for others. We could get a decision. Would be okay with it. We can extend later if needed
<dael> Rossen_: I want us to get to a decision. leaverou_ is on queue
<dael> leaverou_: Silent clamp or invalid, cubic bezier used to be invalid outside 0-100. I suppose it wasn't deemed useful, realized authors wanted it, and we did it. Making it invalid would allow a similar path
<dael> Rossen_: More support to makeing invalid
<dael> Rossen_: I heard support from a few folks and TabAtkins saying it's fine, I think
<dael> TabAtkins: That's my ideal result. Happy
<fantasai> +1 to leaverou_
<dael> leaverou_: I'm not suggesting we make it invlid. suboptimal. but invalid is less evil than clamp
<dael> Rossen_: and gives a path to make better when we're ready. Instead of continuing to argue on what we're not ready to agree on, we have something we are ready to agree on which gives us path to extend
<dael> Rossen_: Prop: clamp values between 0-100, all other values are invalid
<dael> Rossen_: Other comments on proposal or objections?
<dael> RESOLVED: clamp values between 0-100, all other values are invalid
<dael> Rossen_: Rest of discussion is important. Recommend we continue to have this, by way of examples, in the issue when people have time for math

@tabatkins
Copy link
Member

Luckily we decided in the direction I like, but I'd still like an answer to the color-mixing question I asked above; Chris strongly implied in the call that the results made perfect sense within proper color math and I'm misunderstanding things, and I desperately want to know where exactly I'm going wrong, so I can continue to contribute to Color editing usefully:

And similarly, this is a strike against Lea's earlier explanation of it being intuitive with "The actual hue is indeed less red"; because the hue doesn't change at all when I mix with green. And it's not because purple and green are opposites, either; mixing red with negative green as in color-mix(rgb, rgb(100 0 0), rgb(0 100 0) -20%) resolves to rgb(120 0 0), a brighter red. The hue only changed because both input colors touched the red channel; any combo that uses independent channels will leave the hue unchanged from the positive-% color.

The example mixes (positive) middle-red with (negative) middle-green. The hue does not change at all - it doesn't become less green, or more red, or anything of the sort. All that changes is the saturation - you get a more vivid red. And no clamping occurs either; inputs, outputs, and intermediates all stay within the sRGB gamut.

You get identical results if you used a negative middle-blue - the result is a more vivid red. So coming from either direction of the hue wheel, you get the exact same result; it thus can't be due to the specific location of the negative color. (In fact it's due to the positive and negative color having the same ratio of their shared non-zero channels; any negative color paired with a red (with a single non-zero channel) will solely adjust the saturation of the red, it's just a question of by how much it gets adjusted. Anything between "blue" and "green" gives the same results as above (increasing the saturation); anything closer to red will give a smaller increase, or perhaps decrease the saturation, depending on the exact color.)

Is my math wrong? If so, what should I be doing instead?

@tabatkins
Copy link
Member

I'll note that my "the hue is unchanged" is talking about the HSL hue; given that this is doing mixing in RGB, it seems reasonable to talk about the major sRGB cylindrical system.

If you look at the colors in LCH, you do get slight hue changes (using this converter just for ease):

  • rgb(100 0 0) is lab(19.37% 40.67 30.05), which is a hue of atan2(30.05, 40.67) or 36deg
  • rgb(120 0 0) is lab(24.25% 46.28 36.88), which is a hue of 39deg
  • rgb(0 100 0) is lab(36.24% -39.90 40.76), which is a hue of 134deg
  • rgb(0 0 100) is lab(6.98% 34.17 -56.30), which is a hue of 301deg

So, the output has a slightly higher hue then the input. However, this doesn't depend on which negative input you use: if you use negative green, the output can be considered very slightly "more greenish" (hue difference drops from 98deg to 95deg); if you use negative blue, the output can be considered very slightly "less blueish" (hue difference grows from 95deg to 98deg). These are opposite results! So any intuition about the hue changing as a result of the inputs is broken; there is no solid connection between the input hues and output hues in this case, just a slight unrelated drift as a result of the red becoming more vivid.

(The fact that the hue-differences are precisely opposite is actually a surprise; given that any negative color with a zero red channel will give identical results, and those colors span at least 160deg of hue angle, I'm pretty sure this is just a coincidence.)


It's possible that I'm doing the mixing completely wrong, and the result is not rgb(120 0 0), but something else. If so, I'd love to know what the right procedure is; I've given a bunch of examples in this thread all using the same underlying math, and it appears that they have been implicitly accepted as correct so far.

If the end result is still some variety of pure red, tho, then my conclusions so far should still be right. Regardless, tho, I'd really appreciate any errors being pointed out.

@LeaVerou
Copy link
Member

Tab, may we please have a crumb of context, without needing to re-read the entire thread? Which colors are you mixing exactly and with what percentages? It's impossible to check your math if you only provide the result without the operation.
Sorry if you provided it recently, I just took a look at the last few messages and could not find it.

@tabatkins
Copy link
Member

I limited myself to talking about the single example that is in my quoted text, in #6047 (comment): mixing positive middle-red with negative middle-green. No context is required beyond my most recent posts.

@LeaVerou
Copy link
Member

LeaVerou commented Mar 31, 2021

I limited myself to talking about the single example that is in my quoted text, in #6047 (comment): mixing positive middle-red with negative middle-green. No context is required beyond my most recent posts.

I did see that comment, but "middle-red" and "middle-green" are not CSS colors, and "positive" and "negative" are not percentage values. Can you please provide values with actual numbers so we can also do the math?

Is it about color-mix(rgb, rgb(100 0 0), rgb(0 100 0) -20%) that is in the quote?

@tabatkins
Copy link
Member

Yes, that's the example I provided and am talking about.

@LeaVerou
Copy link
Member

I think the reason you are getting odd numbers is because the result is slightly out of gamut, as would happen in every case where the color with the negative coefficient has zeroes.

However, if you compare the LCH hues, it does go slightly away from green. Also, if you compare the deltaE of the mixed color with pure green, you'll see the mixed color is slightly further from green than the middle-red was, though not by much if using deltaE 2000 (larger difference with 76).

However, most importantly, using red and green to make a point here is misleading. It is well known that (gamma-encoded) sRGB interpolation between red and green is weird, even if you stay within 0-100%. E.g. color-mix(in srgb, rgb(100 0 0), rgb(0 100 0)) returns rgb(19.6% 19.6% 0%), which is also somewhat unexpected.

(Editable) calculations here: https://colorjs.io/notebook/?storage=https%3A%2F%2Fgist.github.com%2FLeaVerou%2F5b36f292d4b86eb0ea7ad37413ba8180

image

@svgeesus
Copy link
Contributor

svgeesus commented Apr 1, 2021

I'll note that my "the hue is unchanged" is talking about the HSL hue; given that this is doing mixing in RGB, it seems reasonable to talk about the major sRGB cylindrical system.

The HSL hue is not at al perceptually uniform. As an example in CSS Color 4 shows a 30deg difference is barely noticeable in some areas while making a huge difference in others. The hue in LCH, while not perfect, is vastly more useful.

If you look at the colors in LCH, you do get slight hue changes (using this converter just for ease):

The converter you are using seems to be restricted to integer arithmetic, and also silently clamps sRGB values to [0..255] which of course alters the hue. And given your choice of mix colors right up against the gamut boundary (only one primary being used, the other two are zero), naturally any negative mix takes us out of gamut.

However, the main reason for the small LCH hue changes you are seeing is the mixing being done in gamma-encoded sRGB space. Switching to linear-light sRGB as the mix space improves things because now we are actually simulating mixing two lights; while changing to LCH (the default, for color-mix()) is also chroma-preserving and perceptually uniform. Both options are useful, and both are better than mixing in a gamma-encoded space.

Your two start colors have hue angles of 36.461(middleRed) and 134.391 (middleGreen). The mixture with -20% middleGreen has a hue angle of 16.875 (mix in LCH), 18.51 (mix in linear-light sRGB) and 34.62 (mix in gamma-encoded sRGB).

The mixed result are all out of sRGB gamut (negative amount of the green component, as expected)

mix example with three mixing colorspaces

screenshot of mix example

So to summarize: the assumption errors and calculation errors in your examples were:

  • you thought using darker colors would keep the mix result in-gamut, but it doesn't because they are still on the edge
  • thus, the mixtures with negative percentages are out of gamut (negative components)
  • the converter you use silently drops the negative components, misleading you about the actual result
  • doing mixtures in a gamma-encoded space gives results that are too dark
  • using a better mixing colorspace gives better results (but still out of gamut, given the starting colors)

@tabatkins
Copy link
Member

(Editable) calculations here:

Ah, thank you! That's what I was missing! I was assuming that negative channels weren't meaningful, but if you treat them as such and normalize it back in-gamut, you do indeed get the hue shift we were looking for.

(Well, for (red - green), at least. (Magenta - green) still normalizes to a more vivid magenta, as do the other "two channels minus one channel" combos. I'm not sure if that's meaningful, but I'm willing to accept that these are "opposites" in the space being used, and so subtracting one will just increase the other.)

I'd done a lot of examples earlier in the thread that suffered from this but that y'all treated as valid, so I didn't realize there was a mistake there, thank you. (The one spot where clamping was brought up, it was due to channels going above the max.) Having this notebook is really helpful.

However, most importantly, using red and green to make a point here is misleading. It is well known that (gamma-encoded) sRGB interpolation between red and green is weird, even if you stay within 0-100%. E.g. color-mix(in srgb, rgb(100 0 0), rgb(0 100 0)) returns rgb(19.6% 19.6% 0%), which is also somewhat unexpected.

Yeah, it doesn't look good, but the math is straightforward and exactly what I'd expect (converted back to 0-255 range I was using, that's rgb(50 50 0)), and I assume what authors would expect. The channels are averaged with an appropriate weighting, easy peasy. Certainly, tho, it's a good thing that rgb isn't the default space for doing mixing in.


The converter you are using seems to be restricted to integer arithmetic,

Nah, I was just working with easy numbers for my own usage. The RGB->Lab converter I was using definitely wasn't restricted in any such way, since the output shows 2 digits of decimal precision. (I then rounded the hue angle back to integer degrees myself, because the decimals weren't important for my point.)

However, the main reason for the small LCH hue changes you are seeing is the mixing being done in gamma-encoded sRGB space.

While that has an effect on the magnitude of the output, it wasn't germane to the outcome. My mistake was in assuming that negative channels weren't even physically meaningful (unlike overly-large channels, which make sense). I'm still not sure what a negative channel could mean, but I'm happy to accept that running the math on them gives reasonable results.

[other stuff]

Yup, I already understand how color spaces work and how some are more suitable than others for various tasks, and why. That's never been my problem in this thread.


So, bringing this back to the topic at hand.

We have a resolution on the books now for the %s to be restricted to the [0%-100%] range. I'm obviously fine with keeping this (I supported it in the first place), but I'm also happy to discuss negatives again, now that we're all on the same page.

I'm still against negative %s in the current grammar. I don't think they produce a good intuition; you can get a well-defined result out of them, but they only "act like interpolation" for small values. As the magnitude of the negative components grows, the numbers get more and more out of whack with how you'd write it as an interpolation, due to the "normalize to 100%" behavior.

That is, a red, green -20% is easy - the red fills in a 120%, and it's the same as saying interpolate(-20%, red, green). But a red 100%, green -20% is a little more complex; it normalizes to red 125%, green -25%, and so is equivalent to interpolate(-25%, red, green). red 100%, green -50% diverges further, to interpolate(-100%, red, green), and continuing on, the value diverges to infinity as the positive and negative parts approach each other, then flips to negative infinity and starts combing back - red 100%, green -200% normalizes to red -100%, green 200%, or interpolate(200%, red, green) - a color far past green, rather than far past red, despite the mixing calculation appearing to say "start with red, then subtract a whole lot of green".

This is a long-winded way of saying that interpolation is a fine mental model, but normalized component %s are not a great interface to that mental model - we're using affine inputs to control a linear progress. If we want to do interpolation (with the possibility of <0% or >100% progress), that's fine, we just need to use a grammar that maps well to that concept - color-mix(<percentage>, <from-color>, <to-color>). But if we instead want to stick with the mental model of "mixing" colors where you can control the % of each component, then I think the current design, where %s are limited to the [0-100] range, is the best grammar to communicate that intent.

@svgeesus
Copy link
Contributor

svgeesus commented Apr 2, 2021

I was assuming that negative channels weren't meaningful,

They are.

but if you treat them as such and normalize it back in-gamut, you do indeed get the hue shift we were looking for.

Also if you treat them as such and don't gamut map back into gamut.They are still colors (unless they are actually outside the spectral locus, in which case they are non-physical imaginary colors, but you can still do math on them and get meaningful results. ProPhoto RGB, for example, has imaginary primaries; so does XYZ).

My mistake was in assuming that negative channels weren't even physically meaningful (unlike overly-large channels, which make sense). I'm still not sure what a negative channel could mean

The original 1920-30s color matching experiments presented, one one side, a monochromatic color to match and on the other side, the mixture of three colored lights. (To ensure cross-lab compatibility, these were monochrromatic lights derived from the mercury emission spectrum). Subjects had to match the color by adjusting the intensity of the three lights until they matched the reference.

For some colors (some wavelengths), no match could be obtained. In that case, a mixture of the three colored lights was added to the reference to change the color so it could be matched. That was counted as a negative amount of the respective light.

This is how the CIE color matching functions were measured.

Then (because this was 1932 and negative numbers were a hassle in the days of slide rules and log tables) they transformed the matching functions to a set of physically unrealizable, supersaturated primaries called X, Y and Z specifically so that all real-world colors never needed a negative amount to match.

For physically realizable primaries, there will always be some real colors that need negative, or greater than 100%, amounts of a color to match.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
css-color-5 Color modification
Projects
None yet
Development

No branches or pull requests

9 participants