Skip to content
Rendering · Depth & blendingIntermediate

Blending & transparency

How a fragment's color combines with what is already on screen, and the alpha, additive and premultiplied modes built from it.

Live demo coming soon

How it works

Without blending, a fragment that passes its tests replaces the pixel’s color. Glass, smoke, fire, a sprite’s soft edge and a UI fade all need something else: a mix of the new color with the one already there. Blending is that mix. It runs after the fragment shader, among the per-sample operations, and the shader never sees the color behind it: it only outputs a color and an alpha, and the blend settings decide what they do.

The blend equation

Blending works on two colors: the source, what the fragment shader just output, and the destination, what the pixel holds. Each is multiplied by a factor, and the two products are combined:

result = source × srcFactor + destination × dstFactor

The host picks the factors with gl.blendFunc(srcFactor, dstFactor) and the operation with gl.blendEquation(): add (the default), subtract, reverse subtract, and in WebGL2 also min and max. gl.blendFuncSeparate() sets different factors for the color and the alpha channels.

Three modes cover most effects

  • Alpha (“over”): gl.blendFunc(gl.SRC_ALPHA, gl.ONE_MINUS_SRC_ALPHA). The result is source × α + destination × (1 − α): alpha 1 replaces, 0 leaves the pixel alone, 0.5 is halfway. Glass, sprites, fading UI.
  • Additive: gl.blendFunc(gl.ONE, gl.ONE), or gl.SRC_ALPHA, gl.ONE to scale it by alpha. The source is added to what is there, so it can only brighten. Fire, sparks, glows, lasers: things that give off light. Because addition doesn’t care about order, additive layers never need sorting; on an 8-bit target, though, they flatten to white where they pile up.
  • Multiply: gl.blendFunc(gl.DST_COLOR, gl.ZERO). The result is source × destination, so it can only darken. Shadows painted as decals, tinted glass.

Premultiplied alpha

In “straight” alpha, the color and its opacity are stored apart: a half-transparent red is (1, 0, 0, 0.5). In premultiplied alpha the color is already multiplied by its alpha: the same red is (0.5, 0, 0, 0.5), and it blends with gl.blendFunc(gl.ONE, gl.ONE_MINUS_SRC_ALPHA).

vec3 color = vec3(1.0, 0.45, 0.1);
float alpha = 0.6;
fragColor = vec4(color * alpha, alpha); // premultiplied: pairs with blendFunc(ONE, ONE_MINUS_SRC_ALPHA)

It looks like a detail, but it solves real problems:

  • Filtering is correct. When a texture is filtered or mipmapped, a fully transparent texel still has a color (often black or white) that bleeds into its neighbors in straight alpha, leaving dark or light fringes around sprites. Premultiplied, a transparent texel is (0, 0, 0, 0) and adds nothing.
  • One mode does both. An output with color but alpha 0 adds light without covering anything, so the same blend state draws smoke (covering) and embers (adding) in one draw call.
  • Layers combine. Two premultiplied layers can be merged into one first and blended later with the same result, which is what compositing passes rely on.

The browser composites a WebGL canvas into the page as premultiplied by default (the premultipliedAlpha context attribute). A shader that writes straight alpha into a transparent canvas gets bright, wrong edges where the page shows through.

Order matters

Opaque surfaces can be drawn in any order because the depth buffer sorts them per pixel. Alpha blending can’t rely on that: “over” gives a different result depending on which layer comes first, and a transparent surface that writes depth hides whatever transparent surface is drawn behind it later. The usual recipe:

  1. draw every opaque object, depth tested and written;
  2. sort the transparent objects from back to front;
  3. draw them with blending on, depth tested but not written (gl.depthMask(false)).

Sorting is per object, so objects that cross each other, or a single mesh that overlaps itself, can still come out wrong. When the edge can be hard, an alpha cut-out avoids the problem entirely: the shader discards fragments below a threshold and the surface stays opaque, depth-written and order-free, at the price of jagged edges (or MSAA alpha-to-coverage to soften them).

In Godot and on this site

In Godot, a spatial shader picks its blend with render_mode blend_mix (the default), blend_add, blend_sub, blend_mul or blend_premul_alpha. Blending only happens for transparent materials: writing ALPHA moves the material to the transparent pipeline, which Godot sorts back to front and, by default, draws without writing depth. Writing ALPHA_SCISSOR_THRESHOLD makes a cut-out instead. A canvas_item (2D) shader always blends, with mix by default.

On this site, a pass’s blend in render.json is "opaque", "alpha" (source × α + destination × (1 − α)) or "additive" (destination + source × α); both blended modes are depth tested but not written. Sprites always use "alpha"; glowing cards like the Hologram use "additive".

Further reading