Alpha Blending and WebGL

This article introduces alpha blending and some tips relating to the alpha channel in WebGL development.

Originally published by David Guan at product.canva.com on December 04, 2017

Canva filters, created with WebGL

This article introduces alpha blending and some tips relating to the alpha channel in WebGL development. At Canva we use WebGL to provide image filters, similar to Instagram filters. While trying to fix a bug in Canva's image filters, I discovered that it was actually due to a WebGL alpha blending bug in Chrome, and decided the topic was worth writing an article about. I'll discuss the bug in depth later on, but first a short primer.

How CSS opacity works

Everything you see in the browser comes from pixels represented by an RGB value (or HSV, HSB, etc.)

For the two squares below, if I uncomment the transform: translateX(-200px); a new "merged" square will be generated. What will the colour of that square roughly be as an RGB value?

(Why would we even care what the value is? Well if we need transforming raw RGB data between client and server, paint the rendering result from <Canvas /> to <img /> or process images, then the value matters.)

The answer is RGB(230, 25, 48), although it is possible that the value could differ depending on your environment.

To figure out what happened, let's start from the first square:

This square initially has colour RGB(255, 26, 26), which comes from blending the page's background RGB(255, 255, 255) with the square's colour. Let's call the first square SRC and page background DST. The result value for the R channel is generated by

SRC.R * SRC.Opacity + DST.R * (1 — SRC.Opacity),

and likewise for the B and G channels. When Opacity is 1, the result purely comes from SRC, and vice versa. So we arrive at the result of RGB(255, 26, 26) by calculating:

R: 255 * 0.9 + 255 * 0.1

G: 0 * 0.9 + 255 * 0.1

B: 0 * 0.9 + 255 * 0.1

Back to the final result of RGB(230, 25, 48) — we arrive there by blending SRC, RGB(0, 0, 255) opacity 0.1, with DST, RGB(255, 26, 26) like this:

(0 * 0.1 + 255 * 0.9, 25 * 0.1 + 26 * 0.1, 255 * 0.1 + 26 * 0.9)

In conclusion: as mentioned in the W3C CSS specification, opacity indicates how to blend the target with existing content, and browsers help us deal with alpha blending automatically.

Opacity can be thought of as a postprocessing operation. Conceptually, after the element (including its descendants) is rendered into an RGBA offscreen image, the opacity setting specifies how to blend the offscreen rendering into the current composite rendering.

Alpha Blending in WebGL

Firstly, why use WebGL? As I've mentioned, at Canva we use it to provide image filters, and it's also famous for powering 3D content on the web. Apart from these two use cases, this article has more information about everything it can do for you.

Now, let's take a look at two cases when alpha needs to be taken into consideration with WebGL:

  1. How WebGL content blends with other existing DOM elements on a web page
  2. How elements inside WebGL content blend with each other (two shapes again, for example, but drawn with WebGL).

The Codepen below draws a triangle with colour RGB(0, 0, 255), opacity 0.3:

As you can see, the triangle painted by WebGL is blended with the webpage's background. Its colour is RGB(179, 0, 255). Based on what we learned previously, the 179 comes from 255* 0.7, but why isn't the 255 value closer to 255 * 0.3?

The 255 comes from WebGL's default behavior — premultiply alpha. Instead of SRC.B * SRC.A + DST.B(1 — SRC.A), browsers will treat the "* SRC.A" part as already done by the WebGL rendering pipeline, so we have 255 + 0 * 0.7.

One way to solve this issue is changing the WebGL configuration:

canvasDOM.getContext("webgl", {
premultipliedAlpha: false,
// Other configurations
});
js

Now it works as expected.

Unfortunately we can't solve the problem this way, because alpha blending is not correctly handled in Chrome when premultipliedAlpha is disabled. You can find more details here in this Stack Overflow question I asked and this Chrome bug ticket. Instead, we can manually multiply alpha or use WebGL's blendFunc (more on that later).

Let's remove the premultipliedAlpha: false and add gl_FragColor.rgb *= gl_FragColor.a; at the end of fragment shader. The result is same as the previous image and now it also works in Chrome.

Now, let's think about rendering two shapes, one above another (as we did previously). The codepen below draws two half transparent triangles at the same position with colours RGB(255, 0, 0) and RGB(0, 0, 255):

I've made the page background RGB(0, 0, 0) so we can focus on the content generated by WebGL. It turns out the value of R is 0.

The reason behind this result is that WebGL does not provide alpha blending by default. We can enable the normal alpha blending by inserting the code below before rendering:

gl.enable(gl.BLEND);
gl.blendFunc(gl.SRC_ALPHA, gl.ONE_MINUS_SRC_ALPHA);
js

One bonus we get by making this change is that we can remove the gl_FragColor.rgb *= gl_FragColor.a; in the fragment shader since its job is done by gl.SRC_ALPHA.

If you don't want to change the shader, change gl.SRC_ALPHA to gl.ONE. You can read more about blendFunc and other ways to blend content here.

That's all, thanks for your time. Please stay tuned for more articles about what we've learned from building Canva!

More from Canva Engineering

Subscribe to the Canva Engineering Blog

By submitting this form, you agree to receive Canva Engineering Blog updates. Read our Privacy Policy.
* indicates required