Render Passes
Attachments and multi-pass rendering
Structuring GPU Work
A render pass is a bounded region of GPU work that reads from and writes to a fixed set of attachments. In WebGPU, you cannot simply start drawing—you must begin a render pass, issue draw commands, then end the pass. This structure gives the GPU critical information about how textures will be used, enabling hardware optimizations that would be impossible with unstructured draw calls.
The Render Pass Descriptor
Every render pass begins with a descriptor that specifies its attachments and how they should be handled. The descriptor is passed to commandEncoder.beginRenderPass():
const renderPass = commandEncoder.beginRenderPass({
colorAttachments: [
{
view: textureView,
clearValue: { r: 0, g: 0, b: 0, a: 1 },
loadOp: "clear",
storeOp: "store",
},
],
depthStencilAttachment: {
view: depthTextureView,
depthClearValue: 1.0,
depthLoadOp: "clear",
depthStoreOp: "store",
},
});Interactive: Configure a render pass
Color Attachment
Depth Attachment
const renderPass = encoder.beginRenderPass({
colorAttachments: [{
view: textureView,
clearValue: { r: 0.1, g: 0.1, b: 0.1, a: 1.0 },
loadOp: "clear",
storeOp: "store",
}],
depthStencilAttachment: {
view: depthTextureView,
depthClearValue: 1.0,
depthLoadOp: "clear",
depthStoreOp: "store",
},
});The descriptor tells the GPU everything it needs to know before a single triangle is drawn: what textures are involved, whether to clear them, and whether to preserve their contents afterward.
Color Attachments
Color attachments are the textures that receive fragment shader output. Most render passes have a single color attachment—the screen or a render target texture. Multiple render targets (MRT) enable writing to several textures simultaneously.
Each color attachment requires:
- view: A
GPUTextureViewof the target texture - loadOp: What to do with existing contents at pass start
- storeOp: What to do with rendered contents at pass end
- clearValue: RGBA color used when
loadOpis"clear"(optional otherwise) - resolveTarget: For MSAA, the texture to resolve into (optional)
colorAttachments: [
{
view: gBufferAlbedo.createView(),
loadOp: "clear",
storeOp: "store",
clearValue: { r: 0, g: 0, b: 0, a: 1 },
},
{
view: gBufferNormal.createView(),
loadOp: "clear",
storeOp: "store",
clearValue: { r: 0.5, g: 0.5, b: 1.0, a: 0 },
},
{
view: gBufferPosition.createView(),
loadOp: "clear",
storeOp: "store",
clearValue: { r: 0, g: 0, b: 0, a: 0 },
},
],The fragment shader writes to multiple locations using @location attributes:
struct FragmentOutput {
@location(0) albedo: vec4f,
@location(1) normal: vec4f,
@location(2) position: vec4f,
}
@fragment
fn main(input: VertexOutput) -> FragmentOutput {
var output: FragmentOutput;
output.albedo = textureSample(albedoTexture, sampler, input.uv);
output.normal = vec4f(input.normal * 0.5 + 0.5, 0.0);
output.position = vec4f(input.worldPos, 1.0);
return output;
}Load and Store Operations
Interactive: Load and store operations
Before Pass
Texture contains data from previous frame
The load and store operations control what happens to attachment contents at the boundaries of a render pass.
Load Operations
| Operation | Effect |
|---|---|
"clear" | Fill the attachment with a clear value before rendering |
"load" | Preserve existing contents; new draws composite on top |
Use "clear" when you are rendering a complete new frame and do not need previous contents. The GPU can skip reading the old data, which is faster on tile-based architectures.
Use "load" when you need to add to existing contents—for example, rendering transparent objects on top of an already-rendered opaque scene, or continuing a render that spans multiple passes.
Store Operations
| Operation | Effect |
|---|---|
"store" | Write final contents back to texture memory |
"discard" | Contents may be discarded; do not rely on them |
Use "store" when you need the rendered result afterward—displaying to screen, sampling in a subsequent pass, or debugging.
Use "discard" when the attachment served only as scratch space. For example, a depth buffer used only for correct occlusion within a single pass but never sampled later can be discarded, saving memory bandwidth.
// Depth buffer used only for occlusion, never read afterward
depthStencilAttachment: {
view: depthView,
depthClearValue: 1.0,
depthLoadOp: "clear",
depthStoreOp: "discard", // Don't bother writing depth back
}Depth/Stencil Attachment
The depth/stencil attachment is configured separately from color attachments. It has independent load and store operations for the depth and stencil components:
depthStencilAttachment: {
view: depthStencilView,
depthClearValue: 1.0,
depthLoadOp: "clear",
depthStoreOp: "store",
stencilClearValue: 0,
stencilLoadOp: "clear",
stencilStoreOp: "discard",
}If you are not using the stencil component, you can use a depth-only format like "depth24plus" and omit stencil operations entirely. If you are not using the depth component (rare), you can set depthReadOnly: true and omit depth load/store operations.
Multi-Pass Rendering
Interactive: Multi-pass composition
Click a pass to see its inputs and outputs. Each pass's outputs become available as inputs for later passes.
Complex rendering effects require multiple render passes. Each pass can read textures produced by earlier passes while writing to different targets. This is the foundation of techniques like deferred rendering, shadow mapping, and post-processing.
Shadow Mapping Example
Shadow mapping requires two passes. The first pass renders the scene from the light's perspective into a depth texture:
// Pass 1: Render shadow map
const shadowPass = encoder.beginRenderPass({
colorAttachments: [], // No color output
depthStencilAttachment: {
view: shadowMapView,
depthClearValue: 1.0,
depthLoadOp: "clear",
depthStoreOp: "store",
},
});
shadowPass.setPipeline(shadowPipeline);
// Draw scene geometry...
shadowPass.end();The second pass renders the final image, sampling the shadow map to determine which pixels are in shadow:
// Pass 2: Render scene with shadows
const mainPass = encoder.beginRenderPass({
colorAttachments: [{
view: screenView,
loadOp: "clear",
storeOp: "store",
clearValue: { r: 0.1, g: 0.1, b: 0.1, a: 1 },
}],
depthStencilAttachment: {
view: depthView,
depthClearValue: 1.0,
depthLoadOp: "clear",
depthStoreOp: "discard",
},
});
// Shadow map is now sampled as a texture in the shader
mainPass.setPipeline(mainPipeline);
mainPass.setBindGroup(0, bindGroupWithShadowMap);
// Draw scene...
mainPass.end();Deferred Rendering Structure
Deferred rendering splits the work into geometry and lighting passes:
- Geometry pass: Render all objects into G-buffer textures (albedo, normals, positions)
- Lighting pass: Sample G-buffer, compute lighting for each pixel
- Forward pass (optional): Render transparent objects on top
// Pass 1: Fill G-buffer
const geometryPass = encoder.beginRenderPass({
colorAttachments: [
{ view: albedoView, loadOp: "clear", storeOp: "store", clearValue: BLACK },
{ view: normalView, loadOp: "clear", storeOp: "store", clearValue: NORMAL_CLEAR },
{ view: positionView, loadOp: "clear", storeOp: "store", clearValue: BLACK },
],
depthStencilAttachment: {
view: depthView,
depthClearValue: 1.0,
depthLoadOp: "clear",
depthStoreOp: "store",
},
});
// Draw all opaque geometry...
geometryPass.end();
// Pass 2: Lighting calculation
const lightingPass = encoder.beginRenderPass({
colorAttachments: [{
view: outputView,
loadOp: "clear",
storeOp: "store",
clearValue: BLACK,
}],
});
// Draw fullscreen quad, sampling G-buffer textures
lightingPass.end();Attachment Formats and Compatibility
Attachment formats
"rgba8unorm""rgba16float""rgba32float""bgra8unorm"Color format choice affects precision, memory usage, and whether HDR values can be stored. The canvas typically requires bgra8unorm or rgba8unorm.
All color attachments in a render pass must have compatible formats with the pipeline's fragment shader targets. The depth/stencil attachment format must match the pipeline's depthStencil.format.
Common format choices:
| Use Case | Recommended Format |
|---|---|
| Screen output | navigator.gpu.getPreferredCanvasFormat() |
| HDR render target | "rgba16float" |
| G-buffer albedo | "rgba8unorm" |
| G-buffer normals | "rgba16float" |
| G-buffer positions | "rgba32float" |
| Depth only | "depth24plus" |
| Depth + stencil | "depth24plus-stencil8" |
| High-precision depth | "depth32float" |
The pipeline descriptor must declare formats that match the render pass:
const pipeline = device.createRenderPipeline({
fragment: {
module: shaderModule,
entryPoint: "fragment_main",
targets: [
{ format: "rgba8unorm" }, // Must match attachment 0
{ format: "rgba16float" }, // Must match attachment 1
],
},
depthStencil: {
format: "depth24plus", // Must match depthStencilAttachment
depthWriteEnabled: true,
depthCompare: "less",
},
});Timestamp Queries and Performance
Render passes are natural boundaries for GPU profiling. You can insert timestamp queries at the beginning and end of passes to measure their duration:
const querySet = device.createQuerySet({
type: "timestamp",
count: 2,
});
const renderPass = encoder.beginRenderPass({
colorAttachments: [...],
timestampWrites: {
querySet,
beginningOfPassWriteIndex: 0,
endOfPassWriteIndex: 1,
},
});This records GPU timestamps that you can later resolve to measure how long the pass took, independent of CPU timing.
Key Takeaways
- Render passes define bounded GPU work with explicit attachments
- Load operations control whether existing contents are preserved or cleared at pass start
- Store operations control whether rendered contents are saved or discarded at pass end
- Color attachments receive fragment shader output; multiple render targets enable writing to several textures
- The depth/stencil attachment has independent load/store for depth and stencil components
- Multi-pass rendering chains passes together, with earlier outputs becoming later inputs
- Attachment formats must be compatible with pipeline target formats