index

The current technology is not ready for proper blending

2025-07-18

The idea that we must always linearize sRGB gradients or work in a perceptually uniform colorspace is starting to be accepted universally. But is it that simple?

When I learned about the subject, it felt like being handed a hammer and using it everywhere. The reality is a bit more nuanced. In this article we will see when to use which, how to use them, and we will then see why the situation is more dire than it looks.

Code snippets

Before we start, since we are going to use GLSL as language, following are the reference functions we will use for the rest of the article.

vec3 s2l(vec3 c) { // sRGB to linear
    return mix(c/12.92, pow((max(c,0.0)+0.055)/1.055,vec3(2.4)), step(vec3(0.04045),c));
}

vec3 l2s(vec3 c) { // linear to sRGB
    return mix(c*12.92, 1.055*pow(max(c,0.0),vec3(1./2.4))-0.055, step(vec3(0.0031308),c));
}

vec3 l2oklab(vec3 rgb) { // linear to OkLab
    const mat3 rgb2lms = mat3(
        +0.4122214708, +0.2119034982, +0.0883024619,
        +0.5363325363, +0.6806995451, +0.2817188376,
        +0.0514459929, +0.1073969566, +0.6299787005);
    const mat3 lms2lab = mat3(
        +0.2104542553, +1.9779984951, +0.0259040371,
        +0.7936177850, -2.4285922050, +0.7827717662,
        -0.0040720468, +0.4505937099, -0.8086757660);
    vec3 lms = rgb2lms * rgb;
    return lms2lab * pow(lms, vec3(1.0/3.0));
}

vec3 oklab2l(vec3 lab) { // OkLab to linear
    const mat3 lab2lms = mat3(
        +1.0000000000, +1.0000000000, +1.0000000000,
        +0.3963377774, -0.1055613458, -0.0894841775,
        +0.2158037573, -0.0638541728, -1.2914855480);
    const mat3 lms2rgb = mat3(
        +4.0767416621, -1.2684380046, -0.0041960863,
        -3.3077115913, +2.6097574011, -0.7034186147,
        +0.2309699292, -0.3413193965, +1.7076147010);
    vec3 lms = lab2lms * lab;
    return lms2rgb * (lms*lms*lms);
}

Also, the output of the pipeline will be expected to be sRGB all the time.

Color gradients

To illustrate how sRGB, linear RGB and OkLab respectively look like, let's interpolate between two colors with each one of them:

Color gradients from top to bottom: sRGB, linear, OkLab

The 3 stripes were generated like this:

vec3 o_srgb   = mix(c0, c1, v);
vec3 o_linear = l2s(mix(s2l(c0), s2l(c1), v));
vec3 o_oklab  = l2s(oklab2l(mix(l2oklab(s2l(c0)), l2oklab(s2l(c1)), v)));

Where v is simply the x coordinate between 0 and 1, c0 the left color, and c1 the right one.

Note

The input colors are considered to be sRGB in input. Similarly, we always make sure to output sRGB at the end (with l2s()) because that's what the pipeline expects.

Key takeaways:

The general consensus is as follow: if you need a color transition within a shape or texture, or some sort of color map, OkLab is the best tool, while linear is cheap, physically correct, and usually acceptable visually.

But what about monochrome gradients?

Things are not as obvious as they seem when we work in monochrome. If instead of red and blue we pick black and white, this is what happens:

Grayscale gradients from top to bottom: sRGB, linear, OkLab

Suddenly this tells a whole different story. sRGB becomes perfectly acceptable, while linear favors way too much the lightness, and OkLab remains the best. The linear gradient felt acceptable before, but now it is highly questionable.

Just to be clear, the linear strip is linear, you can see it as linear energy or casually said "wattage", to which our perception does respond non-linearly.

At this point one may even argue that sRGB looks best.

sRGB vs linear IQ meme

So what can we do about this?

First of all, we always need to question what we are trying to achieve, and fortunately sometimes we can take a few shortcuts. For example, let's say we want to depict a heat map in black and white. In my previous article I had to display 2D noise, so I wanted the observer to experience a linear perception of the "height" of the noise. In this case, working in sRGB (that is, doing zero effort with regards to perception) is actually a better call than mixing between black and white in linear space:

Noise 2D with height as sRGB (left) or linear (right)

Here we are comparing these two:

vec3 o_srgb   = vec3(v);      // equivalent to mix(black, white, v)
vec3 o_linear = l2s(vec3(v)); // equivalent to l2s(mix(black, white, v))

Note

We removed the mix out of the formulas because black=vec3(0) and white=vec3(1), which have the same value when uncompressed to linear space.

To do things right we may want to use OkLab but this feels overkill since this is just a straightforward monochromatic signal. Fortunately, the perceptual lightness can be fairly simple to model. With monochromatic input, OkLab uses L=x³, which is basically equivalent to do a gamma correction with γ=3.

This means that we can simplify the OkLab interpolation we used before to the very simple:

vec3 o_oklab = l2s(vec3(v*v*v));  // equivalent to l2s(oklab2l(vec3(v,0,0)))
Noise 2D with height remapped to human lightness perception

Doing this simple operation is exactly equivalent to interpolating between black and white in OkLab space, except it's just 2 extra multiplications.

We still need to be extra careful if we want to swap the black and white. v needs to be swapped before the gamma encoding, and that means before the sRGB gamma encoding as well:

Top to bottom: srgb(1-v³) (incorrect), 1-srgb(v³) (incorrect), srgb((1-v)³)

One extra trick: combining gammas

sRGB has a curve that closely approximates a gamma correction γ=2.2. So sometimes, instead of using l2s(rgb), we may prefer to use the simpler pow(rgb,vec3(1.0/2.2)). It means we could replace l2s(vec3(v*v*v)) with the following to merge the two operations:

vec3 o_oklab = vec3(pow(v, 3.0/2.2)); // combination of v³ and gamma 2.2 (sRGB-like) encoding

And the white-to-black version:

vec3 o_oklab = vec3(pow(1.0-v, 3.0/2.2));

Warning

Whenever you use pow, make sure your input is positive. Adding a max(v,0.0) for safety might be reasonable in certain cases.

The difference between a proper sRGB conversion and the combined gamma is pretty small:

Top: srgb(v³), bottom: v^(3/2.2)

Alpha blending and pre-multiplication

Sometimes, instead of fading colors into each others, we need to compose shapes, textures, masks, ... This need for compositing, or blending, arises when the pipelines are separated, meaning we are not working in the same fragment shader for everything. For example, we could have a shape generated in a fragment, which we need to overlay onto a surface. That shape might have some non-binary transparency, either for anti-aliasing purposes, a blur, or similar.

An example of a shape partially transparent

If that shape were to be blend onto another colored surface, we would like to have the same effect as the gradient earlier. For well-known reasons, it is likely that this shape would end up as a pre-multiplied color, which would be blend onto one or more layers. If what I just said is confusing, I recommend checking out this good article on alpha compositing from Bartosz Ciechanowski. The literature is quite extensive on the subject so I will assume familiarity with it.

Of course, if we are to do things right, the blending would have to happen in linear space. Do not consider sRGB blending in alpha blending, it's an even more terrible idea than before because of the bilinear filtering, transforms or mipmaps that can happen between the pre-multiplication and the blending itself.

But that means we would end up with the linear gradient shortcomings from earlier, wouldn't we? And this is where things get ugly.

Look at the difference between a linear and an OkLab blending, in black and white:

Blending of a blurry white circle onto black, left is linear, right is OkLab

If we invert the colors:

Blending of a blurry black circle onto white, left is linear, right is OkLab

We have the exact same problem as earlier, but seeing it with an actual blending of shapes makes the problem particularly striking. The white and black OkLab circles look the same size (because they are), and they don't have the unfortunate "bobbing" effect of the linear version (on the white onto black).

Warning

The OkLab blending is done with pre-multiplied Lab colors. It is important not to pre-multiply linear values which are then converted to OkLab, this will give very unexpected results.

The problem is, it is very unlikely that your whole graphics pipeline would switch to OkLab for every textures and buffers. And since most of the time the pipelines are built for more than just black and white, the cube hack suggested earlier has a very limited scope. In the case of shape blending, it is almost certain that the whole pipeline would not be contained in a single shader where you can just mix in OkLab. You're probably thinking of using sRGB, but in a blending pipeline this really is a terrible idea.

Final words

In practice, neither sRGB nor pure linear blending give good results, and using OkLab is not always an option. And unfortunately, I don't have a good answer to this whole situation. My next article is about anti-aliasing where this problem also exists, and I must admit this whole ordeal puts me in quite some distress; I had to talk about this issue first.


For updates and more frequent content you can follow me on Mastodon. Feel also free to subscribe to the RSS in order to be notified of new write-ups. It is also usually possible to reach me through other means (check the footer below). Finally, discussions on some of the articles can sometimes be found on HackerNews, Lobste.rs and Reddit.

index