Computer Graphics (rasterisation, ray tracing, WebGPU) Module 7 – Light, shading and transparency
Transparency, blending and alpha compositing
Composite layers with straight and premultiplied alpha and Porter and Duff's over operator, then blend and draw transparent surfaces in the right order.
What you will learn
- Combine layers with straight and premultiplied alpha using Porter and Duff's over operator
- Explain why premultiplied alpha keeps over associative and stops dark fringes at resized edges
- Configure WebGPU blend states for alpha, premultiplied and additive blending
- Render transparent geometry in the right order, with the depth test on and depth writes off
Before you start
What you’ll learn · Before you start
What you will learn
- Combine layers with straight and premultiplied alpha using Porter and Duff's over operator
- Explain why premultiplied alpha keeps over associative and stops dark fringes at resized edges
- Configure WebGPU blend states for alpha, premultiplied and additive blending
- Render transparent geometry in the right order, with the depth test on and depth writes off
Before you start
On this page
A pane of red glass that lets half the light through hangs in front of a blue-grey wall, so its pixel should be half the glass’s red and half the wall’s colour. Alpha compositing is that mix: each layer covers its alpha’s share of the pixel. Porter and Duff’s over operator computes it, and two decisions decide whether a renderer gets it right: whether colours are stored already multiplied by their alpha, and in which order transparent surfaces are drawn.
Every number below comes from programs on this page: three layers composited over a wall and a sprite’s edge after a resize, then the blend states and draw orders of a GPU, worked out on the CPU by the same rules.
Alpha says how much of a pixel a surface covers
A pixel’s alpha is the share of it that a surface covers, from 0, nothing, to 1, all of it. Thomas Porter and Tom Duff’s paper of 1984 on compositing digital images defined how images that carry alpha combine, and its over operator: Porter and Duff's rule for laying a source with alpha over a backdrop: the source takes its alpha's share of the pixel and the backdrop shows through the rest. lays a source over a backdrop. The W3C’s Compositing and Blending specification writes it for colours C and alphas α:
In words: the source takes its alpha’s share of the pixel, the backdrop shows through the rest, and the result’s coverage grows the same way. Transparency in a renderer comes down to this operator, applied pixel by pixel. Over an opaque wall, red glass of alpha 0.5 gives 0.5 × 0.8 + 0.5 × 0.2 = 0.5 in red. This is alpha compositing: Combining images or surfaces by their alpha, the share of each pixel they cover, with operators such as over, so that partly transparent layers show what lies behind them., and glTF uses it: a material with alphaMode BLEND is combined with the background by over, OPAQUE ignores alpha, and MASK shows each pixel fully or not at all, at a cutoff. Step through three layers laid over the wall:
The drawing shows the whole scene; its 5 steps are listed below.
Every step as text
- The wall. The wall is opaque, so the pixel starts as its colour, (0.200, 0.300, 0.450), with alpha 1.
- Glass over. The glass covers half the pixel: 0.5 of its red, (0.8, 0.1, 0.1), plus the other 0.5 of what lies behind gives (0.500, 0.200, 0.275).
- Smoke over. The smoke covers a quarter: 0.25 of its green plus 0.75 of the pixel so far gives (0.400, 0.325, 0.256).
- Label over. The label covers 0.8: 0.8 of white plus 0.2 of the pixel so far gives (0.880, 0.865, 0.851), the colour the program prints.
- The order matters. Each step lays one layer over everything below it. Laying the same layers in another order gives another colour, because "over" puts its first operand in front.
Checkpoint
Check: Alpha as coverage and the over operator
Premultiplied alpha stores the colour already times its alpha
Store each colour already multiplied by its alpha, c = αC, and over becomes one multiply-add in every channel, alpha included:
That is premultiplied alpha: Storing a pixel's colour already multiplied by its alpha, so that half-transparent red is (0.5, 0, 0, 0.5). It makes over one multiply-add and keeps filtering correct., against the straight alpha: Storing a pixel's colour and its alpha separately, without multiplying the colour by the alpha; also called unassociated or non-premultiplied alpha. of the formula above. The program composites the three layers both ways, merges them before they meet the wall, and halves a sprite’s edge:
// Porter and Duff's "over" on three invented layers above an opaque wall, with straight and with premultiplied
// alpha, then a fringe that only straight alpha makes when a sprite is resized. Colours are linear RGB.
const fmt = (c) => `(${c.map((x) => x.toFixed(3)).join(', ')})`;
// Straight alpha: [r, g, b, a] with the colour not multiplied by a.
const overStraight = (s, d) => {
const a = s[3] + d[3] * (1 - s[3]);
if (a === 0) return [0, 0, 0, 0];
return [0, 1, 2].map((k) => (s[k] * s[3] + d[k] * d[3] * (1 - s[3])) / a).concat(a);
};
// Premultiplied alpha: the colour is already times a, so "over" is one multiply-add per channel.
const premultiply = (c) => [c[0] * c[3], c[1] * c[3], c[2] * c[3], c[3]];
const overPremultiplied = (s, d) => s.map((x, k) => x + d[k] * (1 - s[3]));
const wall = [0.2, 0.3, 0.45, 1];
const glass = [0.8, 0.1, 0.1, 0.5]; // red glass, half the light through
const smoke = [0.1, 0.7, 0.2, 0.25]; // green smoke
const label = [1, 1, 1, 0.8]; // white text
let straight = wall;
let premultiplied = premultiply(wall);
for (const [name, layer] of [['glass', glass], ['smoke', smoke], ['label', label]]) {
straight = overStraight(layer, straight);
premultiplied = overPremultiplied(premultiply(layer), premultiplied);
console.log(`after the ${name.padEnd(5)} straight ${fmt(straight)} premultiplied ${fmt(premultiplied)}`);
}
// Grouping: the three layers merged first, then laid over the wall.
const merged = overPremultiplied(premultiply(label), overPremultiplied(premultiply(smoke), premultiply(glass)));
console.log(`\nlayers merged first, alpha ${merged[3].toFixed(3)}: over the wall ${fmt(overPremultiplied(merged, premultiply(wall)))}`);
// The same grouping with the opaque-backdrop formula, s a + d (1 - a), which ignores the lower layer's alpha.
const naive = (s, d) => [0, 1, 2].map((k) => s[k] * s[3] + d[k] * (1 - s[3])).concat(s[3] + d[3] * (1 - s[3]));
const naiveMerged = naive(label, naive(smoke, glass));
console.log(`merged with s a + d (1 - a), then over the wall: ${fmt(naive(naiveMerged, wall))}`);
// A sprite edge: an opaque white texel beside a transparent one that straight alpha stores as black, (0, 0, 0, 0).
// Halving the sprite averages the two texels; the result is drawn over a white page.
const page = [1, 1, 1, 1];
const avg = (p, q) => p.map((x, k) => (x + q[k]) / 2);
const s = avg([1, 1, 1, 1], [0, 0, 0, 0]); // the same numbers in both encodings, read two ways
const toCode = (l) => Math.round((l <= 0.0031308 ? 12.92 * l : 1.055 * l ** (1 / 2.4) - 0.055) * 255);
const asStraight = overStraight(s, page)[0];
const asPremultiplied = overPremultiplied(s, page)[0];
console.log(`\nhalved edge texel ${fmt(s)} over a white page:`);
console.log(` read as straight alpha: ${asStraight.toFixed(3)}, code ${toCode(asStraight)}`);
console.log(` read as premultiplied alpha: ${asPremultiplied.toFixed(3)}, code ${toCode(asPremultiplied)}`); Output
after the glass straight (0.500, 0.200, 0.275, 1.000) premultiplied (0.500, 0.200, 0.275, 1.000) after the smoke straight (0.400, 0.325, 0.256, 1.000) premultiplied (0.400, 0.325, 0.256, 1.000) after the label straight (0.880, 0.865, 0.851, 1.000) premultiplied (0.880, 0.865, 0.851, 1.000) layers merged first, alpha 0.925: over the wall (0.880, 0.865, 0.851, 1.000) merged with s a + d (1 - a), then over the wall: (0.871, 0.809, 0.797, 1.000) halved edge texel (0.500, 0.500, 0.500, 0.500) over a white page: read as straight alpha: 0.750, code 225 read as premultiplied alpha: 1.000, code 255
Recorded with Node.js 24.21.0 on macOS 26 arm64. To run it yourself: mise exec node@24.21.0 -- node porter_duff.mjs
Runs on this device, in your browser. The first run downloads JavaScript (about 0.6 MB), which is kept for the next runs.
Your run, in this browser
Both ways give (0.880, 0.865, 0.851). Premultiplied over is associative: the three layers merged first, alpha 0.925, give the same pixel, while merging straight colours with the opaque-backdrop formula gives (0.871, 0.809, 0.797). Filtering is the second gain. Halving a sprite averages an opaque white texel with a transparent one, (0, 0, 0, 0): read as straight alpha, the average is grey at half coverage and shows 0.750 over a white page, code 225, a dark fringe; read as premultiplied, it is white at half coverage, 1.000, code 255.
Swap two layers
Press Edit and put the smoke before the glass in the loop’s list. The pixel ends at (0.898, 0.850, 0.849) instead of (0.880, 0.865, 0.851): over puts the layer laid last in front.
The two ways of storing a colour with its alpha compare like this:
| Straight alpha | Premultiplied alpha | |
|---|---|---|
| A pixel stores | the colour and its alpha | the colour times alpha, and alpha |
| Over | needs αs and αb in the colour terms | cs + cb (1 − αs) in every channel |
| Merging layers first | needs a division by the new alpha | the same as one at a time |
| Resizing and filtering | dark fringes at transparent edges | correct |
| Getting the colour back | stored as it is | divide by alpha, none at alpha 0 |
A WebGPU canvas takes the same choice: its alphaMode is “opaque” by default, and “premultiplied” reads colours so, where 100 % red at 50 % alpha is [0.5, 0, 0, 0.5]. On a GPU, the same arithmetic is set as a blend state. Alpha blending mixes straight colours by their alpha. Premultiplied blending adds the source to the destination times one minus its alpha. Additive blending adds light, for fire and glows. Transparent surfaces are drawn after the opaque ones, sorted from back to front, usually with depth writes off. Surfaces that cross need Blending transparent surfaces in the right order at each pixel separately, instead of sorting whole objects, so that surfaces which cross each other also come out right., which resolves the order per pixel.
Make Background Transparent Cut the background out of a photo and look at the edge pixels it leaves: their partial alpha is the coverage this lesson composites.Checkpoint
Check: Straight and premultiplied alpha
Practise: over, premultiply and unpremultiply
The exercise writes the three operations this lesson uses. Its sample tests lay the glass, the smoke and the label over the wall, then check that merging them first changes nothing.
Exercise · Easy · JavaScript
Premultiply colours and composite them with over
Write three functions in alpha.mjs. Colours are [r, g, b, a] arrays in linear light.
premultiply(c)multiplies the colour channels by the alpha.unpremultiply(c)divides them by it again; a pixel of alpha 0 has no colour to recover and returns[0, 0, 0, 0].over(src, dst)is Porter and Duff's "over" for premultiplied colours: in each of the four channels, src + dst × (1 − src's alpha), so the result's alpha is src's alpha + dst's alpha × (1 − src's alpha).
The sample tests lay the lesson's glass, smoke and label over its wall, then merge the three layers first and check that the grouping changes nothing. The starter's over uses the formula for straight colours over an opaque backdrop, and its unpremultiply divides by zero.
Starter code · alpha.mjs
/** [r, g, b, a] with straight colour to premultiplied: the colour times its alpha. */
export function premultiply(c) {
return [c[0] * c[3], c[1] * c[3], c[2] * c[3], c[3]];
}
/** Premultiplied back to straight: the colour divided by its alpha; a fully transparent pixel is [0, 0, 0, 0]. */
export function unpremultiply(c) {
// Divides by zero for a fully transparent pixel.
return [c[0] / c[3], c[1] / c[3], c[2] / c[3], c[3]];
}
/** Porter and Duff's "over" for premultiplied colours: src + dst x (1 - src alpha), in every channel. */
export function over(src, dst) {
// The opaque-backdrop formula for straight colours: wrong for premultiplied ones, and for alpha.
const a = src[3];
return [src[0] * a + dst[0] * (1 - a), src[1] * a + dst[1] * (1 - a), src[2] * a + dst[2] * (1 - a), 1];
} The sample tests · alpha.test.mjs
import { test, assert } from 'mysmartcopilot:test';
import { premultiply, unpremultiply, over } from './alpha.mjs';
const close = (got, want, what) => {
for (let k = 0; k < 4; k++) assert.ok(Math.abs(got[k] - want[k]) <= 1e-9, `${what}: channel ${k} should be ${want[k]}, got ${got[k]}`);
};
const WALL = [0.2, 0.3, 0.45, 1];
const GLASS = [0.8, 0.1, 0.1, 0.5];
const SMOKE = [0.1, 0.7, 0.2, 0.25];
const LABEL = [1, 1, 1, 0.8];
test('premultiply and back, including a fully transparent pixel', () => {
close(premultiply(GLASS), [0.4, 0.05, 0.05, 0.5], 'red glass at alpha 0.5');
close(unpremultiply([0.4, 0.05, 0.05, 0.5]), GLASS, 'and back again');
close(unpremultiply([0, 0, 0, 0]), [0, 0, 0, 0], 'alpha 0 has no colour to recover');
close(premultiply(WALL), WALL, 'an opaque colour does not change');
});
test("the lesson's three layers over the wall", () => {
let pixel = premultiply(WALL);
pixel = over(premultiply(GLASS), pixel);
close(pixel, [0.5, 0.2, 0.275, 1], 'after the glass');
pixel = over(premultiply(SMOKE), pixel);
pixel = over(premultiply(LABEL), pixel);
close(pixel, [0.88, 0.865, 0.85125, 1], 'after the label');
});
test('over is associative for premultiplied colours, and keeps alpha', () => {
const g = premultiply(GLASS);
const s = premultiply(SMOKE);
const l = premultiply(LABEL);
const merged = over(l, over(s, g));
close(merged, [0.865, 0.8425, 0.8175, 0.925], 'the layers merged first');
close(over(merged, premultiply(WALL)), over(l, over(s, over(g, premultiply(WALL)))), 'same as one at a time');
close(over(premultiply([1, 0, 0, 0]), g), g, 'a fully transparent layer changes nothing');
close(over(premultiply([0, 0, 1, 1]), g), [0, 0, 1, 1], 'an opaque layer hides everything below');
}); A hint
Premultiplied "over" needs no multiplication by the source's alpha, because the source colour already carries it: add the destination, scaled by 1 − src[3], to the source, in all four channels including alpha. In unpremultiply, test for an alpha of 0 before dividing.
Results of the sample tests
| Test | Result | Details |
|---|
What your code printed
The sample tests run on this device, in your browser (QuickJS): nothing is sent to mysmartcopilot.com. The first run downloads JavaScript (about 0.6 MB), which is kept for the next runs. A check in your browser is feedback for you, not proof that the code is right for every input.
Practice that adapts to you
Practise this lesson
7 questions about 5 ideas of this lesson. Each answer is explained at once, and what you get right is kept in this browser.
WebGPU blend states, evaluated factor by factor Pro: opens with a Pro, Premium or Ultimate pass
On a GPU, over and its relatives are written as blend states. This part reads the specification’s factors and operations, evaluates three blend states on overlapping sparks in two orders, and shows which ones care about order.
In this part:
- What a blend state computes
- Alpha blending depends on the order; additive blending does not
1 runnable example (JavaScript) · about 3 minutes
Opening this part with your pass…
This part opens with any MySmartCoPilot pass.
- Pro pass · 1 week₹79$2.99
- 30 days₹179$5.99
- 1 year₹1,699$45
One payment; a pass never renews. The price shown is the total you pay: no fees or charges are added at checkout.
Passes can’t be bought right now, so this part can’t be opened at the moment. The free part above stands on its own. If you have a pass, enter your licence key.
It also opens every other in-depth part in Learn and every Pro tool. Sales are final, except where the law gives you a right to withdraw, for example in the EU and the UK (refund policy).
Two quiet errors: the canvas's alpha and the sRGB target, reproduced Pro: opens with a Pro, Premium or Ultimate pass
Two mistakes in a blend state draw pictures that look nearly right in a quick test. This part reproduces both on one pixel and gives the setting that fixes each.
In this part:
- Alpha blended with the colour’s factors makes the canvas see-through
- An -srgb target blends light; a plain one blends codes
1 runnable example (JavaScript) · about 3 minutes
Opening this part with your pass…Pro part. It opens with the same pass as the part above.
Drawing transparent geometry in order, and where sorting fails Pro: opens with a Pro, Premium or Ultimate pass
Blending a surface needs everything behind it already in the pixel, and the depth buffer can throw surfaces away. This part draws three panes in front of a floor six ways, then two panes that cross, where no order works.
In this part:
- Opaque first, then transparent from back to front
- Depth writes off: a misplaced pane blends instead of vanishing
- Crossing surfaces defeat any order of whole objects
- Order transparent objects from back to front, stably
- Results of the sample tests
1 runnable example (JavaScript) · 1 exercise with sample tests · about 5 minutes
Opening this part with your pass…Pro part. It opens with the same pass as the part above.
Interview questions
A warm-up comes first, answered in full, then three problems where transparency goes wrong. The question page of this module holds the first steps and the follow-up titles of all three:
-
Fresher to mid · Warm-up
What is premultiplied alpha, and why do compositors and GPUs prefer it to straight alpha?
A pixel with straight alpha stores its colour and its coverage separately; with premultiplied alpha it stores the colour already multiplied by its alpha, so half-transparent red is (0.5, 0, 0, 0.5). Porter and Duff's over then becomes one multiply-add in every channel, alpha included: co = cs + cb (1 − αs). Two things follow. Over is associative, so layers can be merged once and laid over any backdrop later with the same result. And filtering is correct: averaging texels averages light weighted by coverage, so a resized sprite keeps a white edge white, 1.000 over a white page, where straight alpha turns it into a grey fringe of 0.750. The cost is that a colour at alpha 0 cannot be recovered.
-
Mid to senior
A game's leaf sprites, white-green shapes on transparent backgrounds, show dark outlines when the camera zooms out and the textures are minified with mipmaps and linear filtering. Explain the outlines with numbers and fix the asset pipeline and the renderer.
-
Mid to senior
Glowing embers rise behind a large glass window. As the camera moves, the embers sometimes show dimmed behind the glass and sometimes bright as if in front of it. Both are transparent, sorted by their centres. Explain the flicker and design the draw order and blend states.
-
Senior to staff
A WebGPU canvas configured with alphaMode "premultiplied" sits over a web page. Wherever translucent user interface panels are drawn, the page's text shows faintly through areas that should be opaque, and one browser shows odd colours there. Explain both with numbers and fix the pipeline.
Key takeaways
- Over gives the source its alpha’s share of the pixel and the backdrop the rest, and the result’s alpha grows the same way.
- Premultiplied alpha stores the colour times its alpha, which makes over one multiply-add, associative, and safe to filter: a halved sprite edge stays 1.000 instead of turning into a 0.750 grey fringe.
- On a GPU, blend states carry the same arithmetic, and transparent surfaces are drawn after opaque ones, from back to front.
Terms in this lesson
- alpha compositing
- Combining images or surfaces by their alpha, the share of each pixel they cover, with operators such as over, so that partly transparent layers show what lies behind them.
- over operator
- Porter and Duff's rule for laying a source with alpha over a backdrop: the source takes its alpha's share of the pixel and the backdrop shows through the rest.
- premultiplied alpha
- Storing a pixel's colour already multiplied by its alpha, so that half-transparent red is (0.5, 0, 0, 0.5). It makes over one multiply-add and keeps filtering correct.
- straight alpha
- Storing a pixel's colour and its alpha separately, without multiplying the colour by the alpha; also called unassociated or non-premultiplied alpha.
References
- Compositing digital images (SIGGRAPH Computer Graphics, vol. 18, no. 3) (Association for Computing Machinery)
- Compositing and Blending Level 1: 5.1 simple alpha compositing, 9.1.4 source over (World Wide Web Consortium (W3C))
- WebGPU: blend state (GPUBlendFactor, GPUBlendOperation), GPUCanvasAlphaMode and texture formats (World Wide Web Consortium (W3C))
- Vulkan specification: The Framebuffer, blending (sRGB attachments and clamping) (Khronos Group)
- glTF 2.0 Specification: 3.9.4 alpha coverage (alphaMode) (Khronos Group)
- three.js manual: How to draw transparent objects (three.js authors)
- three.js documentation: Material (transparent, depthWrite, alphaTest) (three.js authors)
Related tools
Report a problem with this lesson
Kept only in this browser. Your Learn progress