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
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,
});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
Incoming Fragments
Fragment passes if: fragment depth less buffer depth
The comparison function determines the rule for passing. WebGPU provides several options.
| Compare Function | Fragment Passes If |
|---|---|
less | fragment depth < buffer depth |
less-equal | fragment depth ≤ buffer depth |
greater | fragment depth > buffer depth |
greater-equal | fragment depth ≥ buffer depth |
equal | fragment depth = buffer depth |
not-equal | fragment depth ≠ buffer depth |
always | always (depth test disabled) |
never | never (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",
},
});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
Framebuffer
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
});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
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,
});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
failOpupdates the stencil buffer - Stencil passes, depth fails: The fragment is discarded, and
depthFailOpupdates the stencil buffer - Both pass: The fragment proceeds to blending, and
passOpupdates the stencil buffer
Available stencil operations include:
| Operation | Effect |
|---|---|
keep | Leave stencil value unchanged |
zero | Set to 0 |
replace | Set to reference value |
invert | Bitwise invert |
increment-clamp | Increment, clamping at maximum |
decrement-clamp | Decrement, clamping at 0 |
increment-wrap | Increment, wrapping to 0 at overflow |
decrement-wrap | Decrement, 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,
},
});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);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