Skip to main content

Order-independent transparency

Every see-through surface a world draws (water, glass, the soft edges of leaves, smoke, bubbles, sprites, a game's own effects) blends by how far it is from the eye, in whatever order it happens to draw. The water still bends and tints what lies behind it.

The pass, running

A pool with a stained-glass pane standing in it, a red glass column, bubbles under the surface and smoke rising in front. Drag to orbit. Switch to sorted and shuffle the draw order: the picture changes with the order. Order-independent, it does not. The debug views show what the accumulation holds after the last pass.

Why order breaks

Normal blending paints a layer over what is already there: dst = src·α + dst·(1 − α). Do it back to front and the pixel is right. Do it in any other order and a far layer paints over a near one.

So a sorted pipeline sorts: objects by distance, the faces of a glass mesh by their centres. A voxel world defeats every sort that works on whole things. One mesh holds all the glass of a 32-block section; a tunnel runs through a pool, so the same glass floor is under the water seen from above and in front of it seen from inside; smoke drifts through a window; a canopy stands both in and above a pond. And water does not just blend, it refracts, so it has to read a copy of everything behind it, which means everything behind it has to be drawn first.

A chimney at the foot of a stained-glass wall with a pool behind it: before, the pool is pasted over the glass and the smoke is hidden behind the wall; after, the smoke rises in front of the glass and the pool shows through it

In a host game: chimney smoke rising in front of a stained-glass wall. Sorted (left), the wall and the pool behind it paint over the smoke; order-independent (right), the smoke stays in front.

One pixel, three layers
eye125102050GlassSmokeWater
Glass
Smoke
Water
Draw order:Water→Smoke→Glass
Blended in draw order
Back to front, so this one is right.
Back to front (exact)
What a perfect sort draws.
Weighted blended (any order)
8% off exact, for every order.
target 0 · rgb
Σ C·α·w = (4.85, 29.84, 12.68)
target 0 · alpha
Π (1 − α) = 0.052
target 1 · red
Σ α·w = 63.52

The depth weight

w(d) = clamp(scale / (ε + (d / near)³ + (d / far)⁶), min, max). Between them the nearer layer takes more of the colour: twice as far weighs an eighth.

0.010.111010010001 blocks10 blocks100 blocksGlass · 156Smoke · 29Water · 5.79

Drag the layers apart, change their opacity, shuffle the order they draw in. Blending in draw order is right only when the order happens to be back to front; the weighted blend gives the same answer for every order.

Sums instead of order

Weighted blended order-independent transparency (McGuire and Bavoil, 2013) replaces the ordered paint with sums, which do not care about order. Every blended fragment adds its premultiplied colour times a weight into one target, and multiplies how much of the scene still shows into another. One full-screen pass then divides the colour back out and lays it over the scene.

  • Coverage is exact. Π(1 − α) is the same product a perfect sort multiplies, so a layer never shows more or less of the scene than it should.
  • The colour mix is weighted by distance. Where layers overlap, each contributes in proportion to α·w(d), so the nearer layer leads. That is the approximation: close to a sort, never exactly one.

WebGL2 has one blend state for every attachment of a target, so both sums share it. RGB adds (ONE, ONE); alpha multiplies (ZERO, ONE_MINUS_SRC_ALPHA). The first attachment keeps Σ C·α·w in its colour and Π(1 − α) in its alpha; the second keeps Σ α·w in its red, writing alpha 0 so the product leaves it alone. The composite:

order-independent-transparency.ts · the composite
vec4 accumulation = texelFetch(tAccumulation, pixel, 0);
float coverage = 1.0 - accumulation.a; // 1 − Π(1 − α)
if (coverage <= 0.0) discard;
float weight = texelFetch(tWeight, pixel, 0).r; // Σ α·w
gl_FragColor = vec4(accumulation.rgb / max(weight, 1e-5) * coverage, coverage);
// blended ONE, ONE_MINUS_SRC_ALPHA over the scene

A frame, band by band

three draws the opaque list and then the transparent list in one render(), with no hook between them. The pass gets its hooks from marker meshes that draw nothing: the world's sort places them in the transparent list, and their onBeforeRender switches targets mid-render. The sort places everything else by what it writes, not by its render order.

A frame, band by band
  1. Before the frameprepareTransparency
  2. Opaqueopaque list
  3. Sky and clouds< 0
  4. Depth writers−0.75
  5. Open−0.5
  6. Blended, behind the water0 · phase 1
  7. Split999 998
  8. Blended, with the water0 · phase 2
  9. Close999 999
  10. After999 999.5
  11. Overlays≥ 1 000 000
drawsBefore the frameScene colourthe scene target's texture (RGBA8, sRGB)Scene depth · writtenits depth texture, shared with the accumulationAccumulationRGBA16F · rgb Σ C·α·w · alpha Π(1 − α)WeightR16F · Σ α·wWater depth · writtenthe nearest water face, both sides

world.prepareTransparency(renderer, camera) installs the world's banded sort, draws the depth of every water face in view into a texture of its own, with the water's own vertex shader so the waves match, and arms the accumulation for this camera. Both sides of each face: the surface's top is the nearest water from above, its underside from below.

depth: written · blend: none (depth only)

The world's sort puts every transparent item in one of these bands. Step through them: solid arrows are writes, dashed are reads, dotted is the depth test.

The accumulation borrows the scene target's own depth texture, so blended fragments are tested against the terrain, the cutouts and the solid texels without a copy, and write nothing to it.

Water splits the pass

Accumulated in one pass, the water could only refract the scene drawn before it, and nothing blended is drawn before it: glass under a pool would come out unbent, and from under the water, glass above the surface would show only its solid parts. So the water's surface splits the blended band in two, per pixel.

Water separates the two passes
in the air
the nearest water face behind it: first pass, composited, refracted in front: second pass, with the water

Drag the eye. Along each ray, the first water face it crosses is where the pass splits: a see-through hit beyond it (blue) is accumulated first and composited into the scene, so the water refracts and tints it; a hit before it (orange) is accumulated together with the water, in front of it. From below, the surface's underside is the nearest face, so what stands above the water is seen through it.

Before the frame, prepareTransparency draws the depth of every water face in view into a texture, with the water's own vertex shader so the waves match, and both sides of each face. The first pass keeps the fragments behind that depth; the split composites them into the scene; the water then refracts the scene target itself and draws in the second pass with everything in front of it. With no water in view the band draws once.

Inside a stained-glass tunnel through a tank of water: before, the water under the floor washes over the glass; after, every wall of the tunnel is crisp

Inside a glass tunnel under water: the floor is behind the water from above and in front of it from inside. No order per face holds both; the per-pixel split does.

Underwater, looking up at a red glass column rising out of a pool: the column carries on through the surface, seen through the water

From under the water, the column above the surface lies behind the surface's underside: it is composited first, and the water's window shows it.

Blocks split by their texels

A stained-glass texture is two materials in one: lead lines that should hide what is behind them, and coloured panes that should tint it. Blended as one layer, the lead lines would be averaged with the pool behind them. The world reads what each texture holds instead of trusting the block's flags.

Split by what the texture holds
The block is
The texture
Solid · α ≥ 0.99 · 110 texels
Translucent · 0.1 ≤ α < 0.99 · 146 texels

texelPlanOf → split. Two draws from the same buffers: the solid texels with the depth writers, the translucent ones in the blended band.

The world reads each material bucket's textures once per texture write: its blocks' atlas slots, a face's or voxel's own texture, every keyframe of an animated face. A bucket whose texels are all solid or all translucent draws once; only one with both pays for the second draw.

Solid texels draw with the depth writers, alpha-tested at solidTexelAlpha, writing depth; translucent texels draw in the blended band from the same buffers, through a material fork that drops solid texels after the alpha test. The block's own flags (isSeeThrough, transparentStandalone, lightAttenuation, standaloneFaceDepth) keep their jobs in meshing, face culling and shadows. Display copies from makeBlockMesh (held, dropped and displayed blocks) split the same way, per face.

A stained-glass wall in front of a pool: before, the water is pasted over the glass; after, the glass pattern runs unbroken over the water

A stained-glass wall in front of a pool: the lead lines write depth and hide the water; the panes tint it.

Every material takes part

A game's materials need no changes. The first time the world's sort meets a transparent material that writes no depth, it wraps the material's fragment stage with the encoder. The preprocessor renames the material's main, so a hook that later finds void main() { still edits the material's own body, and the program key gains a mark so the wrapped program is never shared with an unwrapped one.

orderIndependentFragment · the wrapper
#define main orderIndependentShade
// ...the material's own fragment shader, untouched...
#undef main
layout(location = 1) out highp vec4 pc_fragOitWeight;
void main() {
if (uOitActive > 0.5 && uOitPhase > 0.5) {
// drop the side of the water this pass does not draw, before any shading
}
orderIndependentShade();
pc_fragOitWeight = vec4(0.0);
if (uOitActive < 0.5) return; // any other render: ordinary colour
float oitAlpha = clamp(gl_FragColor.a, 0.0, 1.0);
if (oitAlpha <= 0.0) discard;
float oitWeight = oitAlpha * depthWeight(oitDistanceAt(gl_FragCoord.z));
gl_FragColor = vec4(gl_FragColor.rgb * oitAlpha * oitWeight, oitAlpha);
pc_fragOitWeight = vec4(oitAlpha * oitWeight, 0.0, 0.0, 0.0);
}
MaterialBand
Normal blending, straight or premultiplied; custom blending equal to itAccumulated
Writes depth (cutouts, solid texels, masks)Depth writers, before the accumulation
Additive lightAfter the composite: adding needs no order
Multiply, subtractive, other custom blends; raw or GLSL3 shadersAfter the composite, listed in stats.drawnAfter
Negative render order (sky), or 1 000 000 and up (overlays)Its own order, around the bands
userData[ORDER_INDEPENDENT_KEY] = falseAfter the composite

Outside the accumulation a wrapped material draws its ordinary colour with its own blend state, so one shared with an inventory scene, a portrait or a shadow pass is untouched. Wrapping a material changes its program, so it has to happen before the material first compiles: a warmup adopts everything it is about to compile, and anything first seen later is adopted before its first draw. A material wrapped after it compiled is counted and named, because it compiled twice.

Using it

Turn it on, and render the world into a target with a depth texture:

Client Setup
import * as VOXELIZE from "@voxelize/core";
import { EffectComposer, RenderPass } from "postprocessing";

const world = new VOXELIZE.World({
orderIndependentTransparency: VOXELIZE.defaultOrderIndependentTransparencyOptions,
});

const composer = new EffectComposer(renderer);
composer.addPass(new RenderPass(world, camera));
// The blended layers accumulate against the scene's own depth.
composer.createDepthTexture();

Call the prepare step once a frame, before the scene renders with that camera:

Frame Loop
const animate = () => {
requestAnimationFrame(animate);
world.update(
controls.object.position,
camera.getWorldDirection(new THREE.Vector3()),
);
world.updateShaderLighting(camera, controls.object.position);
world.renderShadowMaps(renderer, collectShadowCasters());
// Before the scene renders with this camera: installs the banded sort,
// draws the water's depth, arms the accumulation.
if (world.isInitialized) world.prepareTransparency(renderer, camera);
composer.render();
};

The example client does exactly this; see examples/client/src/main.ts, where ?transparency=sorted switches back to the sorted pipeline for comparison.

Adopt before a warmup compiles, so no blended program compiles on its first draw:

Shader Warmup
// Before a warmup compiles anything: wrap what can accumulate, and draw the
// texel forks of see-through chunk materials, which no scene holds yet.
const forks = world.adoptOrderIndependentMaterials(materialsToWarm);
for (const fork of forks) warmScene.add(new THREE.Mesh(warmGeometry, fork));
renderer.compile(warmScene, camera);
Options
type OrderIndependentTransparencyOptions = {
/** At or above this texel alpha, a see-through block's texel is solid. */
solidTexelAlpha: number; // 0.99
/** w(d) = clamp(scale / (ε + (d / nearDistance)³ + (d / farDistance)⁶), min, max) */
weights: {
scale: number; // 10
nearDistance: number; // 10 blocks
farDistance: number; // 200 blocks
min: number; // 0.01
max: number; // 1000, keeps half-float sums in range
};
};
Checking It
world.orderIndependent?.stats;
// {
// opened: 1159, renders that accumulated
// skipped: {}, renders that could not, by reason
// adopted: 716, materials wrapped with the encoder
// lateAdopted: [], wrapped after compiling: each compiled twice
// drawnAfter: [], blended materials that cannot accumulate
// }

// Keep one material out; it draws over the composite:
material.userData[VOXELIZE.ORDER_INDEPENDENT_KEY] = false;

Without the option (the default), a world keeps the sorted pipeline: per-face sorting, effects placed by their medium, and glass split at the water's depth per pixel. See the wiki page for the short version.

What it costs

Frame times from a host game, uncapped (vsync off), at the same pose, against the drawing order it shipped with:

Scene, 1280×720SortedOrder-independent
Glass wall over a pool, chimney smoke3.96 ms3.08 ms
Looking down at a glass tunnel under a pool3.37 ms2.55 ms

It is cheaper because of what it removes: the water no longer copies the frame mid-render, glass is no longer re-sorted on the CPU as the camera moves, and double-sided blended materials draw in one pass instead of two. It adds two half-float attachments, a full-screen composite, the water's depth, and with water in view a second draw of the blended band, whose wrong-side fragments are dropped before any shading. Those scenes were bound by the CPU, not the GPU.

Limits

  • Overlapping layers blend by weight, not exactly: the nearer one takes more of the colour, so a pool behind glass reads fainter than a perfect sort would draw it. The curve is weights.
  • Additive light draws after the composite: a spark behind a pane is not tinted by it.
  • The scene target has to be single-sampled with a depth texture, rendered at the top level; logarithmic and reversed depth buffers cannot be read by the weights. A render that cannot accumulate says why, once, and blends in list order.

Where it lives

core/world/order-independent-transparency.tsThe pass: markers, bands, adoption, encoder, accumulation, split, composite
core/world/see-through-texels.tsTexel classes, plans, the solid and translucent forks
core/world/water-depth.tsThe water's depth before the frame
core/world/textures.tsPer-slot texel classes of the atlas, with animations
core/world/index.tsprepareTransparency, adoptOrderIndependentMaterials, the chunk and display-copy wiring
common.tsTRANSPARENT_SORT, which asks the world for each item's band

Paths are under packages/core/src/. Morgan McGuire and Louis Bavoil, Weighted Blended Order-Independent Transparency, Journal of Computer Graphics Techniques 2(2), 2013.