Transparency and Blending
Alpha blending, blend modes, and order-independent transparency
The Alpha Channel
Colors in computer graphics typically have four components: red, green, blue, and alpha. While RGB defines the hue and brightness, alpha represents transparency—how much the background shows through.
An alpha of 1.0 means fully opaque; the pixel completely obscures whatever is behind it. An alpha of 0.0 means fully transparent; the pixel is invisible. Values between create partial transparency: 0.5 means the surface and background contribute equally to the final color.
// A semi-transparent red
let color = vec4f(1.0, 0.0, 0.0, 0.5); // 50% transparent redAlpha channels enable glass, smoke, water, UI overlays, particles, and countless other effects. But rendering transparency correctly is surprisingly difficult.
Blending Equations
When a transparent fragment is drawn over existing pixels, the GPU must combine them. This combination is controlled by the blend equation—a configurable formula that determines how source (new fragment) and destination (existing pixel) mix.
The most common blend mode is alpha blending:
When a 50% transparent red () is drawn over blue, the result is purple—equal parts red and blue.
In WebGPU, you configure blending in the pipeline's color target state:
const pipeline = device.createRenderPipeline({
// ...
fragment: {
module: shaderModule,
entryPoint: 'fragmentMain',
targets: [{
format: 'bgra8unorm',
blend: {
color: {
srcFactor: 'src-alpha',
dstFactor: 'one-minus-src-alpha',
operation: 'add',
},
alpha: {
srcFactor: 'one',
dstFactor: 'one-minus-src-alpha',
operation: 'add',
},
},
}],
},
});Interactive: Blend Equation Visualizer
The srcFactor and dstFactor specify how much the source and destination colors contribute. Common factors include:
one: multiply by 1 (use full value)zero: multiply by 0 (ignore)src-alpha: multiply by the source's alphaone-minus-src-alpha: multiply by (1 - source alpha)dst-alpha: multiply by the destination's alpha
The operation is usually add, but subtract, reverse-subtract, min, and max are available for special effects.
Blend Modes
Different combinations of blend factors produce different visual effects. Some common modes:
Standard alpha blending (src-alpha, one-minus-src-alpha): The classic transparency formula. Semi-transparent objects dim what is behind them.
Additive blending (one, one): Source and destination add together. Bright areas become brighter. Perfect for glows, fire, explosions, and light beams.
Multiplicative blending (dst-color, zero): Colors multiply together. Dark areas become darker. Useful for shadows, tinting, and certain glass effects.
Screen blending: Inverse of multiply—lightens the image. Achieved with (one, one-minus-src-color).
Interactive: Blend Mode Explorer
Experiment with different modes to see how they affect the result. Each has distinct visual characteristics suited to different effects.
The Ordering Problem
Here is where transparency gets difficult. Consider three transparent squares overlapping. The final color depends on the order they are drawn.
Why? Because blending is not commutative. Drawing red then blue gives a different result than drawing blue then red. The blend equation always combines the incoming fragment with whatever is currently in the framebuffer—so draw order matters.
The correct order is back-to-front: draw distant objects first, near objects last. This way, when you draw the near object, the far object is already in the framebuffer to blend against.
Interactive: Why Order Matters
Unsorted
Objects drawn in arbitrary order produce incorrect blending—far objects may appear in front of near ones.
Sorted (Back-to-Front)
Drawing far objects first ensures correct blending. Each near object blends against everything behind it.
When objects are sorted incorrectly, you get visual artifacts: objects appearing in front of things they should be behind, incorrect blending at intersections, and generally broken visuals.
For simple scenes, sorting objects by distance from the camera works. But this fails for:
- Intersecting objects: A sword passing through a ghost has no correct draw order
- Cyclic overlaps: A, B, C where A overlaps B, B overlaps C, and C overlaps A
- Large transparent surfaces: A single quad may be closer in some pixels, farther in others
These cases require more sophisticated techniques.
Implementing Back-to-Front Sorting
The simplest approach sorts objects by their distance to the camera before rendering:
function renderTransparentObjects(camera, objects) {
// Sort by distance (descending = back to front)
const sorted = objects.slice().sort((a, b) => {
const distA = vec3.distance(camera.position, a.position);
const distB = vec3.distance(camera.position, b.position);
return distB - distA; // Far objects first
});
for (const obj of sorted) {
renderObject(obj);
}
}This works for distinct, non-intersecting objects. Games often structure their transparent rendering this way:
- Render all opaque objects (any order, depth test enabled)
- Sort transparent objects back-to-front
- Render transparent objects (depth test enabled but write disabled)
The depth test prevents transparent objects from appearing in front of opaque geometry. Disabling depth write ensures transparent objects do not occlude each other incorrectly in subsequent frames.
// Transparent pass: read depth but don't write
const transparentPipeline = device.createRenderPipeline({
depthStencil: {
format: 'depth24plus',
depthWriteEnabled: false, // Key: don't write depth
depthCompare: 'less', // Still test against opaque geometry
},
// ...blend config...
});Order-Independent Transparency
What if you cannot sort, or sorting is not enough? Order-independent transparency (OIT) techniques produce correct results regardless of draw order.
The conceptual approach is to store all fragments at each pixel, then sort and blend them in a final pass. In practice, this is expensive—pixels might have dozens of overlapping fragments.
Weighted Blended OIT
A practical OIT technique is weighted blended OIT. Instead of storing all fragments, it accumulates weighted color contributions and computes an approximate blend:
- During rendering, output to two render targets:
- Accumulation buffer:
color.rgb * weight - Reveal buffer:
color.a * weight
- Accumulation buffer:
- Final pass: divide accumulation by reveal, blend with background
The weight function typically considers both alpha and depth:
fn oitWeight(z: f32, alpha: f32) -> f32 {
// Weight function: higher for close, high-alpha fragments
return alpha * max(0.01, 3000.0 * pow(1.0 - z, 3.0));
}
@fragment
fn transparentFragment(input: VertexOutput) -> OITOutput {
let color = vec4f(input.color, input.alpha);
let weight = oitWeight(input.position.z, color.a);
return OITOutput(
color.rgb * color.a * weight, // Accumulation
color.a * weight // Reveal
);
}Weighted blended OIT is an approximation—it can produce artifacts with very different alpha values—but it handles most cases well and requires only two extra render targets.
Pre-Multiplied Alpha
A subtle but important consideration is whether colors are stored with straight or pre-multiplied alpha.
Straight alpha: RGB and A are independent. A red pixel at 50% alpha is stored as (1.0, 0.0, 0.0, 0.5).
Pre-multiplied alpha: RGB is multiplied by A before storage. The same pixel is stored as (0.5, 0.0, 0.0, 0.5).
Interactive: Pre-multiplied vs Straight Alpha
Straight Alpha
RGB independent of alpha. When filtered (bilinear, mipmaps), edges can develop dark halos.
rgba(255, 50, 50, 0.5)Pre-multiplied Alpha
RGB pre-multiplied by alpha. Filters correctly. Simpler blend equation.
rgba(127, 25, 25, 0.5)Pre-multiplied alpha has advantages:
Correct filtering: When textures are filtered (bilinear, mipmaps), straight alpha can produce dark halos around transparent edges. Pre-multiplied alpha filters correctly because the RGB already accounts for transparency.
Simpler blending: The blend equation for pre-multiplied alpha is:
No multiplication by src-alpha is needed in the blend—the shader already did it.
Additive support: Pre-multiplied alpha can represent additive contributions (alpha = 0, but RGB > 0 adds light).
The downside is that textures must be authored or converted to pre-multiplied format, and some operations (like tinting) require adjustments.
Many modern engines use pre-multiplied alpha throughout the pipeline. Texture import handles the conversion, and all blending uses the pre-multiplied equation.
// Pre-multiplied alpha blending
blend: {
color: {
srcFactor: 'one', // Source already multiplied by alpha
dstFactor: 'one-minus-src-alpha',
operation: 'add',
},
alpha: {
srcFactor: 'one',
dstFactor: 'one-minus-src-alpha',
operation: 'add',
},
}Practical Transparency Pipeline
A typical game's transparency handling:
-
Render opaque geometry with depth test and write enabled. Any order.
-
Render transparent geometry sorted back-to-front. Depth test enabled, depth write disabled. Standard alpha blending.
-
Render additive effects (particles, glows). No sorting needed—additive blending is order-independent. Depth test enabled, depth write disabled.
-
Render UI last, on top of everything. Often without depth testing.
// 1. Opaque pass
renderPass.setPipeline(opaquePipeline);
for (const obj of opaqueObjects) {
renderObject(obj);
}
// 2. Transparent pass (sorted)
const sortedTransparent = sortBackToFront(transparentObjects, camera);
renderPass.setPipeline(transparentPipeline);
for (const obj of sortedTransparent) {
renderObject(obj);
}
// 3. Additive pass (no sorting)
renderPass.setPipeline(additivePipeline);
for (const particle of particles) {
renderParticle(particle);
}Alpha Testing: A Simpler Alternative
For certain transparency effects—foliage, fences, text—you do not need smooth blending. Alpha testing discards fragments below a threshold:
@fragment
fn fragmentMain(input: VertexOutput) -> @location(0) vec4f {
let color = textureSample(albedoTexture, textureSampler, input.uv);
if (color.a < 0.5) {
discard;
}
return color;
}Alpha testing produces hard edges but has major advantages: no sorting required, depth writes work normally, and deferred rendering compatibility. Many games use alpha testing for vegetation.
The threshold can be adjusted—lower values keep more pixels, higher values discard more aggressively.
Key Takeaways
- Alpha represents transparency; 1.0 is opaque, 0.0 is fully transparent
- Blend equations control how source and destination colors combine; configure via pipeline blend state
- Standard alpha blending uses
src-alphaandone-minus-src-alpha; additive usesoneandone - Draw order matters: transparent objects must typically be sorted back-to-front
- Depth write disabled for transparent passes prevents incorrect occlusion
- Order-independent transparency techniques like weighted blended OIT handle cases where sorting fails
- Pre-multiplied alpha produces correct filtering and simpler blending; prefer it when possible
- Alpha testing (discard) is a simpler alternative for hard-edged transparency