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.

Three cubes that cut through each other. Only a depth buffer can draw this correctly.

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 zMeaning
0.0The near plane: as close as anything can be
1.0The far plane: as far as anything can be
Below 0 or above 1Clipped: not drawn at all
Warning WebGL and OpenGL use z from -1 to 1. WebGPU uses 0 to 1. A perspective matrix copied from WebGL code will put half your scene behind the near plane. Use a WebGPU-style matrix, like the one in Camera & matrices.

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.

Note The depth texture must be exactly the same size as the colour texture you draw into. If the canvas changes size, create a new depth texture too.

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: true saves 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.

Warning The pipeline and the render pass must agree. If the pipeline has 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
  ]);
}
Tip Depth precision is best near the camera. Keep the near plane as far away as you can (0.1 is common, not 0.0001). A tiny near value causes flickering where surfaces are close together. This is called z-fighting.

depthCompare Values

The depthCompare option decides when a new pixel passes the test:

ValueThe 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

FormatDescription
"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.
Note You can also read a depth texture in a shader, if you add 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_ATTACHMENT

4. 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 depth24plus texture, add depthStencil to the pipeline, and add a depthStencilAttachment to the render pass.
  • Clear depth to 1.0 and use depthCompare: "less".

Official documentation