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.
- Why order breaks
- Sums instead of order
- A frame, band by band
- Water splits the pass
- Blocks split by their texels
- Every material takes part
- Using it
- What it costs
- Limits
- Where it lives
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.

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.
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.
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:
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.
- Before the frame
prepareTransparency - Opaque
opaque list - Sky and clouds
< 0 - Depth writers
−0.75 - Open
−0.5 - Blended, behind the water
0 · phase 1 - Split
999 998 - Blended, with the water
0 · phase 2 - Close
999 999 - After
999 999.5 - Overlays
≥ 1 000 000
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.
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 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.

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.
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: 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.
#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);
}
| Material | Band |
|---|---|
| Normal blending, straight or premultiplied; custom blending equal to it | Accumulated |
| Writes depth (cutouts, solid texels, masks) | Depth writers, before the accumulation |
| Additive light | After the composite: adding needs no order |
| Multiply, subtractive, other custom blends; raw or GLSL3 shaders | After 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] = false | After 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:
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:
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:
// 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);
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
};
};
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×720 | Sorted | Order-independent |
|---|---|---|
| Glass wall over a pool, chimney smoke | 3.96 ms | 3.08 ms |
| Looking down at a glass tunnel under a pool | 3.37 ms | 2.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.ts | The pass: markers, bands, adoption, encoder, accumulation, split, composite |
core/world/see-through-texels.ts | Texel classes, plans, the solid and translucent forks |
core/world/water-depth.ts | The water's depth before the frame |
core/world/textures.ts | Per-slot texel classes of the atlas, with animations |
core/world/index.ts | prepareTransparency, adoptOrderIndependentMaterials, the chunk and display-copy wiring |
common.ts | TRANSPARENT_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.