WebGPU Depth Buffer
A depth buffer makes near objects hide far objects, no matter which one you draw first.
Without it, 3D scenes look broken. With it, the GPU sorts every pixel for you. It takes about ten lines of code.
The Draw-Order Problem
By default, WebGPU colours pixels in the order you draw. A later draw always covers an earlier one, even if it is further away.
The example below draws three cards. The big red card is near the camera, and the small blue card is far away. They are drawn near first, so the far cards are painted on top:
Example
Three cards at different depths, with no depth buffer:
const cards = [
{ centerX: -0.35, centerY: 0.2, size: 0.9, depth: 0.2, color: [0.95, 0.35, 0.35] },
{ centerX: 0.0, centerY: 0.0, size: 0.7, depth: 0.5, color: [0.35, 0.85, 0.45] },
{ centerX: 0.3, centerY: -0.15, size: 0.5, depth: 0.8, color: [0.35, 0.55, 0.95] }
];
Edit & Run »
The blue card should be hidden behind the others, but it is drawn last, so it wins.
You could sort objects from far to near before drawing. This is called the painter's algorithm. But sorting costs time, and it fails when objects cut through each other, like the cubes at the top of this page.
What a Depth Buffer Stores
A depth buffer is a texture the same size as the canvas. For each pixel, it stores one number: the depth of the nearest thing drawn so far.
When a new pixel arrives, the GPU compares its depth with the stored depth. This is the depth test:
- If the new pixel is nearer, it is drawn, and its depth is saved.
- If it is further away, it is thrown away.
The order you draw in no longer matters. The nearest surface always wins.
Depth Values in WebGPU
The depth comes from the z value of the position your vertex shader returns. After the divide by w, WebGPU keeps only z between 0 and 1:
| Clip-space z | Meaning |
|---|---|
0.0 | The near plane: as close as anything can be |
1.0 | The far plane: as far as anything can be |
| Below 0 or above 1 | Clipped: not drawn at all |
In the cards example, each vertex simply has a z of 0.2, 0.5 or 0.8, and the shader passes it straight through:
output.position = vec4f(position, 1.0);
Step 1: Create a Depth Texture
The depth buffer is a texture. Create it with the same size as the canvas, a depth format, and the usage RENDER_ATTACHMENT:
const depthFormat = "depth24plus";
const depthTexture = device.createTexture({
size: [canvas.width, canvas.height],
format: depthFormat,
usage: GPUTextureUsage.RENDER_ATTACHMENT
});
depth24plus gives at least 24 bits of precision. It is the usual choice.
Step 2: Turn On Depth Testing in the Pipeline
Add a depthStencil object to the pipeline. It tells the pipeline which format to expect and how to compare depths. Everything else in the pipeline stays the same:
const pipeline = device.createRenderPipeline({
layout: "auto",
vertex: {
module: shaderModule,
entryPoint: "vertexMain",
buffers: [{
arrayStride: FLOATS_PER_VERTEX * Float32Array.BYTES_PER_ELEMENT,
attributes: [
{ shaderLocation: 0, offset: 0, format: "float32x3" },
{ shaderLocation: 1, offset: 12, format: "float32x3" }
]
}]
},
fragment: { module: shaderModule, entryPoint: "fragmentMain", targets: [{ format }] },
depthStencil: {
format: depthFormat,
depthWriteEnabled: true,
depthCompare: "less"
}
});
depthWriteEnabled: truesaves the depth of every pixel that passes.depthCompare: "less"means a pixel passes if its depth is less than the stored depth, so nearer wins.
Step 3: Attach It to the Render Pass
Finally, give the render pass a depthStencilAttachment, next to the colour attachment:
const pass = encoder.beginRenderPass({
colorAttachments: [{
view: context.getCurrentTexture().createView(),
clearValue: { r: 0.05, g: 0.06, b: 0.07, a: 1 },
loadOp: "clear",
storeOp: "store"
}],
depthStencilAttachment: {
view: depthTexture.createView(),
depthClearValue: 1.0,
depthLoadOp: "clear",
depthStoreOp: "store"
}
});
depthClearValue: 1.0 fills the buffer with the far plane before drawing. Then anything you draw is nearer, and passes the first test.
Example
The same three cards, now with a depth buffer:
depthStencil: {
format: depthFormat,
depthWriteEnabled: true,
depthCompare: "less"
}
Edit & Run »
The draw order is the same, but now the red card is in front, as it should be.
depthStencil but the pass has no depth attachment, or the formats differ, WebGPU reports a validation error and draws nothing.
Depth in a Real 3D Scene
In a 3D scene, the depth comes from the perspective matrix. Each cube's corners get a different z, and the GPU blends it across every triangle. So the depth test works per pixel, even where two cubes cross.
Example
Three rotating cubes, one draw call each, with depth testing:
function render(timeMilliseconds) {
updateCubeMatrices(timeMilliseconds / 1000);
const encoder = device.createCommandEncoder();
const pass = encoder.beginRenderPass({
colorAttachments: [{
view: context.getCurrentTexture().createView(),
clearValue: { r: 0.05, g: 0.06, b: 0.07, a: 1 },
loadOp: "clear",
storeOp: "store"
}],
depthStencilAttachment: {
view: depthTexture.createView(),
depthClearValue: 1.0,
depthLoadOp: "clear",
depthStoreOp: "store"
}
});
pass.setPipeline(pipeline);
pass.setVertexBuffer(0, vertexBuffer);
for (const { bindGroup } of cubeResources) {
pass.setBindGroup(0, bindGroup);
pass.draw(vertexData.length / FLOATS_PER_VERTEX);
}
pass.end();
device.queue.submit([encoder.finish()]);
requestAnimationFrame(render);
}
Edit & Run »
The full example has small matrix helpers written in plain JavaScript: perspective, lookAt, multiply, rotationX, rotationY and translation. The perspective matrix maps the near plane to z = 0 and the far plane to z = 1:
function perspective(fieldOfView, aspect, near, far) {
const focalLength = 1 / Math.tan(fieldOfView / 2);
const rangeInverse = 1 / (near - far);
return new Float32Array([
focalLength / aspect, 0, 0, 0,
0, focalLength, 0, 0,
0, 0, far * rangeInverse, -1,
0, 0, near * far * rangeInverse, 0
]);
}
depthCompare Values
The depthCompare option decides when a new pixel passes the test:
| Value | The new pixel passes if its depth is… |
|---|---|
"never" | Never passes |
"less" | Less than the stored depth. The usual choice. |
"less-equal" | Less than or equal to the stored depth |
"equal" | Exactly equal to the stored depth |
"greater" | Greater than the stored depth. Used with "reversed z", clearing to 0. |
"greater-equal" | Greater than or equal to the stored depth |
"not-equal" | Different from the stored depth |
"always" | Always passes. The same as no depth test. |
Depth Formats
| Format | Description |
|---|---|
"depth16unorm" | 16-bit depth. Small, but low precision. |
"depth24plus" | At least 24-bit depth. The usual choice. |
"depth24plus-stencil8" | Depth plus an 8-bit stencil buffer |
"depth32float" | 32-bit floating-point depth. Best precision. |
"depth32float-stencil8" | 32-bit depth plus stencil. Needs an optional device feature. |
TEXTURE_BINDING to its usage. Shadow mapping uses exactly this trick.
Exercises
1. What value should depthClearValue be when depthCompare is "less"?
Show answer
1.0, the far plane. Then every pixel you draw is nearer and passes.
2. In the depth-on example, change depthCompare to "greater" and depthClearValue to 0.0. What happens?
Show answer
The test is flipped, so the furthest card wins. The blue card is drawn on top, just like the broken example. This shows that the depth test, not the draw order, now decides.
3. Which usage flag must a depth texture have so a render pass can draw into it?
Show answer
usage: GPUTextureUsage.RENDER_ATTACHMENT4. In the cubes example, set depthWriteEnabled to false. Why do the cubes look wrong again?
Show answer
Pixels are still tested, but their depth is never saved. The buffer stays at 1.0, so every pixel passes and the last one drawn wins.
Summary
- Without a depth buffer, the last thing drawn covers everything before it.
- A depth buffer stores the nearest depth for every pixel, and throws away pixels that are further away.
- In WebGPU, clip-space z goes from 0 (near) to 1 (far).
- Create a
depth24plustexture, adddepthStencilto the pipeline, and add adepthStencilAttachmentto the render pass. - Clear depth to 1.0 and use
depthCompare: "less".
Official documentation
Go deeper with the specifications and reference documentation:
- w3.org
WebGPU specification: depth/stencil state
Depth compare functions, depth writes and bias in a pipeline.
- w3.org
WebGPU specification: depth formats
depth24plus, depth32float and the other depth and stencil formats.
- developer.mozilla.org
MDN: GPUDevice.createTexture()
Creating the depth texture with the right size, format and usage.
- developer.mozilla.org
MDN: GPUCommandEncoder.beginRenderPass()
Attaching the depth texture with depthStencilAttachment.