WebGPU Multi-Pass Rendering
Chain several render passes so earlier passes write intermediate render textures that later passes sample. Covers post-processing chains, deferred shading, shadow mapping, and ping-pong buffers on WebGPU 1.0-stable (Chrome 113+, Safari 26+, Firefox 141+).
Quick Reference
| Pass pattern | Earlier pass writes | Later pass reads | Key requirement |
|---|---|---|---|
| Post-process chain | Offscreen color texture | Full-screen quad samples it | Intermediate texture needs RENDER_ATTACHMENT | TEXTURE_BINDING |
| Ping-pong | Texture A (pass N) | Texture B (pass N+1) | Two textures, swap roles each pass |
| Deferred shading | G-buffer via MRT (multiple color attachments) | Lighting pass samples every G-buffer texture | Each G-buffer texture needs RENDER_ATTACHMENT | TEXTURE_BINDING |
| Shadow map | Depth-only texture from light view | Main pass samples with comparison sampler | Shadow pass uses depthStencilAttachment, no color attachments |
Intermediate render texture usage: ALWAYS
GPUTextureUsage.RENDER_ATTACHMENT | GPUTextureUsage.TEXTURE_BINDING.
Without TEXTURE_BINDING the texture cannot be sampled in a later pass.
Decision Tree
Need more than one render pass?
├── Apply screen effects (bloom, tone-map, FXAA) to a finished image
│ -> Post-processing chain. Render scene to offscreen texture, run
│ full-screen quad passes, present last result to the canvas.
│ -> If an effect reads and writes the same logical image -> ping-pong two textures.
│
├── Many lights on complex geometry, decouple shading cost from geometry
│ -> Deferred shading. Geometry pass writes a G-buffer with MRT
│ (albedo, normal, depth/position, material). Lighting pass samples all of them.
│
└── Cast shadows from a light source
-> Shadow mapping. Pass 1 renders scene depth from the light into a depth-only
texture. Main pass binds that depth texture with a comparison sampler and
uses textureSampleCompare in WGSL.
Core Patterns
Pattern 1: Intermediate texture usage flags
ALWAYS create an intermediate render texture with both
RENDER_ATTACHMENT and TEXTURE_BINDING. NEVER omit TEXTURE_BINDING when a later
pass samples the texture.
const sceneTexture = device.createTexture({
label: "scene-color",
size: [canvas.width, canvas.height],
format: "rgba8unorm",
usage: GPUTextureUsage.RENDER_ATTACHMENT | GPUTextureUsage.TEXTURE_BINDING,
});
Pattern 2: Split passes for read after write
NEVER sample a texture inside the same render pass that still has it bound as a color or depth attachment. WebGPU validation rejects a bind group whose resource is also a writable attachment of the active pass. ALWAYS end the writing pass before a pass that samples its output.
const enc = device.createCommandEncoder();
const scenePass = enc.beginRenderPass({
colorAttachments: [{ view: sceneTexture.createView(),
loadOp: "clear", storeOp: "store", clearValue: [0, 0, 0, 1] }],
});
// draw scene
scenePass.end(); // ALWAYS end before sampling sceneTexture
const postPass = enc.beginRenderPass({
colorAttachments: [{ view: context.getCurrentTexture().createView(),
loadOp: "clear", storeOp: "store", clearValue: [0, 0, 0, 1] }],
});
// postPass binds sceneTexture and samples it
postPass.end();
device.queue.submit([enc.finish()]);
Pattern 3: Full-screen quad without a vertex buffer
ALWAYS generate a full-screen pass with a 3-vertex oversized triangle from
@builtin(vertex_index) in the vertex shader. NEVER bind a vertex buffer for a
full-screen pass.
@vertex fn vs(@builtin(vertex_index) i : u32) -> @builtin(position) vec4f {
let p = array(vec2f(-1, -1), vec2f(3, -1), vec2f(-1, 3));
return vec4f(p[i], 0, 1);
}
Pattern 4: Ping-pong two textures
ALWAYS use two textures and swap their read/write roles each pass when an effect needs the previous result as input. NEVER reuse one texture as both the sampled input and the render attachment of a single pass.
let read = texA, write = texB;
for (const effect of effects) {
const pass = enc.beginRenderPass({
colorAttachments: [{ view: write.createView(),
loadOp: "clear", storeOp: "store", clearValue: [0, 0, 0, 1] }],
});
// bind read as the sampled input, draw the full-screen triangle
pass.end();
[read, write] = [write, read]; // swap roles for the next pass
}
Pattern 5: Deferred shading G-buffer with MRT
ALWAYS render the G-buffer with one color attachment per channel (albedo, normal,
position or material) in a single geometry pass. The pipeline fragment.targets count
and formats MUST match the colorAttachments exactly.
const gbufferPass = enc.beginRenderPass({
colorAttachments: [
{ view: albedoTex.createView(), loadOp: "clear", storeOp: "store" },
{ view: normalTex.createView(), loadOp: "clear", storeOp: "store" },
],
depthStencilAttachment: { view: depthTex.createView(),
depthClearValue: 1.0, depthLoadOp: "clear", depthStoreOp: "store" },
});
The fragment shader writes @location(0) albedo and @location(1) normal. The lighting
pass is a full-screen pass that samples albedoTex, normalTex, and depthTex.
Pattern 6: Shadow-map pass setup
ALWAYS render the shadow pass into a depth-only texture with depthStencilAttachment
and an empty colorAttachments array. The main pass binds that depth texture as
texture_depth_2d with a "comparison" sampler and calls textureSampleCompare.
const shadowDepth = device.createTexture({
label: "shadow-map",
size: [2048, 2048],
format: "depth32float",
usage: GPUTextureUsage.RENDER_ATTACHMENT | GPUTextureUsage.TEXTURE_BINDING,
});
const shadowPass = enc.beginRenderPass({
colorAttachments: [], // shadow pass writes depth only
depthStencilAttachment: { view: shadowDepth.createView(),
depthClearValue: 1.0, depthLoadOp: "clear", depthStoreOp: "store" },
});
// render scene from the light's view-projection matrix
shadowPass.end();
Common Anti-Patterns
Sampling a texture in the same pass it is bound as an attachment. WebGPU validation rejects a bind group containing a resource that is simultaneously a writable color or depth attachment of the active pass, because the read/write ordering is undefined. Fix: end the writing pass first, or ping-pong two textures.
Missing
TEXTURE_BINDINGusage on an intermediate texture. A texture created with onlyRENDER_ATTACHMENTcannot appear in a bind group. The later sampling pass fails validation. Fix: ALWAYS addGPUTextureUsage.TEXTURE_BINDING.Reusing one texture for read and write in the same pass. The pass overwrites the data it still needs to read, and validation rejects the bind group. Fix: ping-pong two textures and swap roles each pass.
Critical Warnings
- NEVER sample a texture while it is bound as a color or depth attachment of the active pass. Split the work into separate passes.
- NEVER omit
TEXTURE_BINDINGon a texture a later pass samples.RENDER_ATTACHMENTalone is insufficient. - NEVER reuse a single texture as both the input and the output of one pass. Use ping-pong.
- NEVER finish a command encoder with a render pass still open. ALWAYS call
pass.end()before the nextbeginRenderPassorencoder.finish(). - NEVER cache
context.getCurrentTexture()or its view across frames. The swap-chain rotates textures; obtain the canvas texture fresh each frame in the final pass. - ALWAYS put
@invarianton the vertex@builtin(position)output when a later pass depends on bit-identical depth from an earlier pass (depth pre-pass withloadOp: "load"anddepthCompare: "equal"). Without it, transforms can differ per pipeline and depth equality fails.
Reference Files
references/methods.md: intermediate texture setup, the read-write hazard rule, ping-pong mechanics, deferred G-buffer layout, shadow-map pass setup.references/examples.md: verified working code for a post-process chain, a shadow-map pass plus main pass, and a ping-pong blur.references/anti-patterns.md: multi-pass mistakes with WHY-it-fails explanations.
Related skills:
webgpu-impl-render-targets: render-pass descriptor fields, MRT attachment detail, MSAA.webgpu-impl-render-usecases: full-screen quad, screen-space effects, PBR setup.webgpu-syntax-textures:createTexture, usage flags, formats, texture views.webgpu-wgsl-textures: WGSL texture and sampler handle types per shader stage.