Depth and Stencil

The depth buffer and stencil operations

The Visibility Problem

Rendering 3D scenes requires solving a fundamental problem: which objects appear in front of others. When two triangles overlap on screen, how does the GPU decide which pixel color to keep? The answer is the depth buffer—a per-pixel record of distance from the camera.

The Depth Buffer

The depth buffer, also called the z-buffer, stores a depth value for every pixel in the framebuffer. When a fragment is generated, the GPU compares its depth to the stored value. If the new fragment is closer, it overwrites both the color and the depth. If it is farther, it is discarded.

Interactive: Depth buffer visualization

Near (z=50)
Mid (z=120)
Far (z=200)

Rotate to see how depth testing handles occlusion regardless of draw order.

Each pixel in the depth buffer typically holds a value between 0.0 and 1.0. In WebGPU's default configuration, 0.0 represents the near plane and 1.0 represents the far plane. Fragments with smaller depth values are considered closer to the camera.

The depth buffer format is usually depth24plus or depth32float. The 24-bit format provides sufficient precision for most scenes while using less memory. The 32-bit floating point format offers higher precision, which matters for scenes with extreme depth ranges or where precision artifacts become visible.

// Creating a depth texture
const depthTexture = device.createTexture({
  size: [canvas.width, canvas.height],
  format: "depth24plus",
  usage: GPUTextureUsage.RENDER_ATTACHMENT,
});
typescript

Depth Testing

When a fragment shader produces a color, the GPU does not immediately write it to the framebuffer. First, it performs the depth test: comparing the fragment's depth against the stored depth at that pixel location.

Interactive: Depth test pass/fail

Depth Buffer Value

0.50

Incoming Fragments

depth: 0.30
depth: 0.50
depth: 0.70

Fragment passes if: fragment depth less buffer depth

The comparison function determines the rule for passing. WebGPU provides several options.

Compare FunctionFragment Passes If
lessfragment depth < buffer depth
less-equalfragment depth ≤ buffer depth
greaterfragment depth > buffer depth
greater-equalfragment depth ≥ buffer depth
equalfragment depth = buffer depth
not-equalfragment depth ≠ buffer depth
alwaysalways (depth test disabled)
nevernever (all fragments fail)

The most common setting is less—fragments pass only if they are strictly closer than whatever was previously rendered at that pixel. This handles opaque geometry correctly regardless of the order triangles are submitted.

// Configuring depth state in the pipeline
const pipeline = device.createRenderPipeline({
  // ... other config
  depthStencil: {
    depthWriteEnabled: true,
    depthCompare: "less",
    format: "depth24plus",
  },
});
typescript

Depth Write

The depthWriteEnabled flag controls whether passing fragments update the depth buffer. When enabled, each visible fragment writes its depth, preventing anything farther from appearing in front of it later.

Interactive: Depth write on/off

Draw Order

Red Quad
z=0.7
Blue Quad
z=0.3
Green Quadtransparent
z=0.5

Framebuffer

Depth Buffer:1.00

With depth write ON, opaque objects update the depth buffer, preventing farther objects from drawing.

There are scenarios where you want depth testing without depth writing. Transparent objects are the classic example: you want them occluded by opaque geometry in front, but you do not want them to occlude other transparent objects behind. The standard approach is to render opaque objects first with depth write enabled, then render transparent objects back-to-front with depth write disabled.

// For transparent objects: test but don't write
const transparentPipeline = device.createRenderPipeline({
  depthStencil: {
    depthWriteEnabled: false, // Don't update depth buffer
    depthCompare: "less",     // But still test against it
    format: "depth24plus",
  },
  // ... blend state for transparency
});
typescript

The Stencil Buffer

The stencil buffer is an auxiliary buffer that stores an integer value per pixel, typically 8 bits. Unlike the depth buffer, the stencil buffer does not represent any physical quantity. It is a general-purpose per-pixel counter that you control through stencil operations.

Interactive: Stencil masking

Color Output

Stencil Buffer

1
2
3

Step 1: Clear both buffers to prepare for rendering.

The stencil buffer enables effects that depth alone cannot achieve. Mirrors, portals, shadow volumes, and outline effects all rely on stencil operations. The core idea is to mark regions of the screen in a first pass, then use those marks to control what renders in subsequent passes.

// Depth-stencil texture with stencil component
const depthStencilTexture = device.createTexture({
  size: [canvas.width, canvas.height],
  format: "depth24plus-stencil8", // 24-bit depth + 8-bit stencil
  usage: GPUTextureUsage.RENDER_ATTACHMENT,
});
typescript

Stencil Operations

Stencil testing compares the stencil reference value against the stored stencil value using a comparison function. Based on the test result (and the depth test result), one of three operations updates the stencil buffer.

Interactive: Stencil operations

Stencil Buffer

Click pixels to select them for the operation

Configuration

Set values to reference (1)

The three possible outcomes for each fragment are:

  • Stencil test fails: The fragment is discarded, and failOp updates the stencil buffer
  • Stencil passes, depth fails: The fragment is discarded, and depthFailOp updates the stencil buffer
  • Both pass: The fragment proceeds to blending, and passOp updates the stencil buffer

Available stencil operations include:

OperationEffect
keepLeave stencil value unchanged
zeroSet to 0
replaceSet to reference value
invertBitwise invert
increment-clampIncrement, clamping at maximum
decrement-clampDecrement, clamping at 0
increment-wrapIncrement, wrapping to 0 at overflow
decrement-wrapDecrement, wrapping to max at underflow
// Stencil configuration in the pipeline
const pipeline = device.createRenderPipeline({
  depthStencil: {
    depthWriteEnabled: true,
    depthCompare: "less",
    format: "depth24plus-stencil8",
    stencilFront: {
      compare: "always",
      failOp: "keep",
      depthFailOp: "keep",
      passOp: "replace",
    },
    stencilBack: {
      compare: "always",
      failOp: "keep",
      depthFailOp: "keep",
      passOp: "replace",
    },
    stencilReadMask: 0xff,
    stencilWriteMask: 0xff,
  },
});
typescript

The stencilReadMask and stencilWriteMask allow you to use different bits of the stencil buffer for different purposes. A mask of 0x0f would only consider the lower 4 bits, leaving the upper 4 bits available for another effect in the same frame.

Common Patterns

Stencil masking for portals or mirrors

First, render the portal frame with stencil write enabled, marking pixels with value 1. Then, render the scene visible through the portal with stencil test requiring value 1. Only pixels inside the portal frame will receive the reflected or alternate scene.

Outline rendering

Render the object normally, writing 1 to the stencil buffer. Then render a slightly scaled-up version with stencil test requiring value 0—this draws only where the original object did not, producing an outline.

Shadow volumes

Use stencil increment/decrement operations as rays pass through shadow volume front and back faces. Pixels with non-zero stencil values are in shadow.

Setting Stencil Reference at Draw Time

The stencil reference value is not baked into the pipeline. You set it per draw call:

passEncoder.setStencilReference(1);
passEncoder.draw(vertexCount);
 
passEncoder.setStencilReference(2);
passEncoder.draw(otherVertexCount);
typescript

This allows a single pipeline to implement multiple stencil-based effects by changing only the reference value between draws.

Key Takeaways

  • The depth buffer stores per-pixel distance, enabling correct occlusion without sorting geometry
  • Depth testing compares fragment depth against stored depth; the comparison function controls the rule
  • Depth write can be disabled for transparent objects that should test but not occlude
  • The stencil buffer stores a per-pixel integer for general-purpose masking and counting
  • Stencil operations update based on three outcomes: stencil fail, depth fail, or both pass
  • Masks let you partition the stencil buffer into independent bit ranges