Color Space Correctness in Alpha Blending

Take a look at these three grayscale images:

Solid grayscale image with value 128Grayscale checkerboard with values 64 and 192Solid grayscale image with value 146

The left image has a solid luminance of 128. The middle image is a checkerboard alternating between luminances of 64 and 192. The right image has a solid luminance of 146. Squint your eyes. Does the luminance in the middle look closer to the luminance on the left, or on the right?

As a checkerboard, the center image is 50% 64 and 50% 192, so mathematically, it should average out to 128, but instead, there’s a substantial difference in luminance, and 146 is much closer. The reason for this discrepancy: All values in these images are sRGB values. When colors mix in the real world, the absolute amount of light is what’s mixed, but sRGB is a non-linear mapping, so attempting to mix sRGB values directly yields nonphysical results. You can read more about this in Eric Brasseur’s blog post, “Gamma error in picture scaling”.

Eric’s post is all about image scaling, where this definitely matters. But this is far from the only place this matters. Near and dear to our hearts from the previous post, blending in OpenGL also has to mix colors in a way that could be affected by use of the wrong color space for computations. Indeed, the blend formula says nothing about the relatively complicated conversions needed for sRGB. So what happens?

I’ll test in WebGL. My experiment set-up will be as such:

  1. I’ll first try drawing two different solid colors, and checking what sRGB value they correspond to, as measured by Apple’s DigitalColor Meter in sRGB mode.
  2. Then I’ll try drawing a solid color, with a translucent solid color over it. I’ll try both premultiplied and non-premultiplied alpha. The results will again be measured with DigitalColor Meter.

I’ll test under Safari (WebKit), Mullvad Browser (Gecko), and Ungoogled Chromium (Blink). For Blink, I’ll test with the default settings, and also with the drawing buffer changed to sRGB using drawingBufferStorage. (At the time of writing, no other browsers implement drawingBufferStorage.)

Experiment WebKit Gecko Blink Blink sRGB
Solid 0.25 64 64 64 137
Solid 0.5 128 128 128 188
Solid 0.75 191 191 191 225
Nonpremul 0.75-over-0.25 128 128 128 188
Premul 0.75-over-0.25 128 128 128 188

All browsers treat the buffer as “just numbers” by default. Blink is able to change the back buffer’s format to enable conversions to/from sRGB automatically such that blending operations are performed correctly, but all shaders must also be prepared to output linear colors rather than colors in sRGB directly.

I don’t have time to test in desktop OpenGL, but I expect the results would be similar. The one difference is that rather than sRGB on the framebuffer being a function of the back-buffer’s format, it’s controlled by GL_FRAMEBUFFER_SRGB, which can be glEnabled and glDisabled at will.

Conclusion here: If the drawing buffer cannot be changed to sRGB, for correct blending, the main scene must render into a texture using linear colors, and a post-processing step is needed with an additional shader sampling the texture, converting the colors to sRGB for final output. (And possibly an extra step before this, too, to resolve multisampling if your application uses that.) This is not entirely surprising: Jeff Gilbert and Ken Russell’s slides on “Deep Dive on HDR, WCG and Linear Rendering in WebGL and WebGPU”, presented at the W3C Workshop on Wide Color Gamut and High Dynamic Range for the Web in July–September 2021, say:

  • Therefore, working in the sRGB-encoded formats needed for linear rendering currently imposes a full-canvas blit at the end of each frame!
    • Prohibitively expensive on many GPUs, both desktop and mobile

When I first read this, I assumed this meant I could use a renderbuffer with a glBlitFramebuffer, as might be used for MSAA resolution. However, this does not work: the source renderbuffer whose format is sRGB will contain sRGB data, but when copying it to the back buffer, OpenGL uses the semantics where it first converts to float (in doing so, doing an sRGB-to-linear transformation), and then copying this float to the back buffer without converting back to sRGB. In other words, we get the same result as if we had used linear colors in all our shaders but rendered straight to the back-buffer anyway: all the colors will be wrong (too light, mostly). A shader must be used.

Another consequence: Any WebGL rendering code that does not do this extra step is blending its colors wrong. In particular, three.js documents color management support, which is excellent, but the documentation is wrong:

Output color space

Output to a display device, image, or video may involve conversion from the open domain Linear-sRGB working color space to another color space. This conversion may be performed in the main render pass (WebGLRenderer.outputColorSpace), or during post-processing.

renderer.outputColorSpace = THREE.SRGBColorSpace; // optional with post-processing

Specifically, setting outputColorSpace is insufficient when you care about correct blending – if you want correct blending behavior, rather, you must add a post-processing pipeline (though OutputPass is the only pass needed in it).

I hope drawingBufferStorage will become well-supported in the future, but even if it does, there will still be a large long tail of older browsers, and providing the fallback will be a frustrating extra path my code may need to go down.

Tags: , , ,

Comments are closed.