Summary
Render passes only honor the first color attachment (ColorAttachments[0]). Any additional color attachments are silently ignored on every backend. This makes it impossible to implement algorithms that require writing to multiple color targets in a single pass — most notably weighted-blended Order-Independent Transparency (OIT), which needs an accumulation target and a revealage target written simultaneously.
The WebGPU spec defines GPURenderPassDescriptor.colorAttachments as an array (up to maxColorAttachments, typically 8), so this is a spec-compliance gap as well as a missing feature.
Environment
- wgpu version: v0.31.4 (also observed in the module cache through v0.31.x)
- Go: go1.26.6 linux/amd64
- OS: Linux x86_64 (CachyOS, kernel 7.2)
- GPU: NVIDIA GeForce RTX 3060 (Vulkan backend,
GOGPU_GRAPHICS_API=vulkan)
- CGO:
CGO_ENABLED=0 (pure Go)
Where in the code
The limitation is in the Vulkan render-pass setup, which only ever creates one color attachment:
-
hal/vulkan/command.go BeginRenderPass (~line 827): reads only the first attachment —
ca := desc.ColorAttachments[0]
ColorAttachments[1:] are never consulted for color output (only ca.ResolveTarget is used, and only as an MSAA resolve target).
-
hal/vulkan/renderpass.go createRenderPass (~lines 125–215): the attachment layout is hardcoded to:
- attachment 0: color (single)
- attachment 1: resolve target — only when
HasResolve && SampleCount > 1 (MSAA)
- last: depth/stencil
There is no loop over ColorAttachments to emit one vk.AttachmentDescription + vk.AttachmentReference per color target, and no subpass pColorAttachments array with colorAttachmentCount > 1.
The same single-attachment assumption appears to apply to the other backends (Metal/DX12/GLES/software) — I confirmed the Vulkan path; the others would need the same audit.
Minimal reproduction
A render pass with two color attachments — attachment 0 = RGBA16Float, attachment 1 = R8Unorm — and a fragment shader that writes both @location(0) and @location(1):
struct Out {
@location(0) accum: vec4<f32>, // -> attachment 0 (RGBA16Float)
@location(1) reveal: f32, // -> attachment 1 (R8Unorm)
}
@fragment fn fs_main() -> Out {
var o: Out;
o.accum = vec4<f32>(1.0, 0.0, 0.0, 1.0); // solid red
o.reveal = 1.0; // should clear/write 1.0
return o;
}
Render pass clears attachment 0 to (0,0,0,0) and attachment 1 to 1.0, draws a fullscreen triangle, then a second pass samples both textures.
Observed:
- Attachment 0 (accum) is written/cleared correctly — sampling it returns the red / cleared value.
- Attachment 1 (reveal) is never cleared or written — sampling it returns
0.0 everywhere, regardless of the clear value or the fragment output. Tested with both R8Unorm and RGBA16Float for the second target; identical result.
This is exactly consistent with the second attachment not existing in the underlying VkRenderPass/VkFramebuffer.
Why this matters
Weighted-blended OIT (McGuire & Barta, 2013 — the technique Rust wgpu and most engines use for translucent water/glass/particles) writes, per fragment, into two targets in one pass:
- an HDR accumulation buffer (
rgb * a * weight, additive), and
- a revealage buffer (
a, multiplicative Zero, OneMinusSrcColor).
A resolve pass then composites accum.rgb / max(accum.a, ε) over the opaque scene with coverage 1 − revealage. Without MRT the revealage target cannot be produced, so the resolve reads revealage = 0 → coverage 1 → the resolve paints opaque black over the entire frame.
I hit this while porting Ironwail's translucent water to a gogpu/WebGPU renderer: the water accumulation worked, but the rest of the world rendered black until I disabled OIT and fell back to sorted alpha blending.
Suggested approach
In createRenderPass, build the attachment list by iterating over all ColorAttachments rather than indexing [0]:
- Emit one
vk.AttachmentDescription per color attachment (format, sample count, load/store ops, initial/final layouts from each attachment).
- Build the subpass
pColorAttachments array with colorAttachmentCount = len(ColorAttachments), each referencing its attachment index.
- Keep the existing MSAA-resolve attachment as an additional entry (its index shifts), and update the framebuffer creation to match the new attachment count/order.
- Mirror the change in the Metal/DX12/GLES/software backends and in the core validation (which should enforce
len(ColorAttachments) <= device.limits.maxColorAttachments and that all attachments share the same sample count, per the WebGPU spec).
The Rust reference (gfx-rs/wgpu hal/vulkan/src/command.rs / conv.rs render-pass conversion) iterates the color attachments and would be the reference for the conversion.
Workarounds in the meantime
- Fall back to sorted/back-to-front alpha blending (what I did).
- Use a depth-peeling OIT variant (multiple geometry passes, no MRT needed) — more expensive.
- Use a single-target approximate OIT (weighted color in RGB + coverage in A of one RGBA target) — loses precision.
Verification I can provide
I have a self-contained Go test that renders attachment 0 and attachment 1 in one pass and reads both back, demonstrating attachment 1 reads zero. gogpu_mrt_repro_test.go.txt
Summary
Render passes only honor the first color attachment (
ColorAttachments[0]). Any additional color attachments are silently ignored on every backend. This makes it impossible to implement algorithms that require writing to multiple color targets in a single pass — most notably weighted-blended Order-Independent Transparency (OIT), which needs an accumulation target and a revealage target written simultaneously.The WebGPU spec defines
GPURenderPassDescriptor.colorAttachmentsas an array (up tomaxColorAttachments, typically 8), so this is a spec-compliance gap as well as a missing feature.Environment
GOGPU_GRAPHICS_API=vulkan)CGO_ENABLED=0(pure Go)Where in the code
The limitation is in the Vulkan render-pass setup, which only ever creates one color attachment:
hal/vulkan/command.goBeginRenderPass(~line 827): reads only the first attachment —ColorAttachments[1:]are never consulted for color output (onlyca.ResolveTargetis used, and only as an MSAA resolve target).hal/vulkan/renderpass.gocreateRenderPass(~lines 125–215): the attachment layout is hardcoded to:HasResolve && SampleCount > 1(MSAA)There is no loop over
ColorAttachmentsto emit onevk.AttachmentDescription+vk.AttachmentReferenceper color target, and no subpasspColorAttachmentsarray withcolorAttachmentCount > 1.The same single-attachment assumption appears to apply to the other backends (Metal/DX12/GLES/software) — I confirmed the Vulkan path; the others would need the same audit.
Minimal reproduction
A render pass with two color attachments — attachment 0 =
RGBA16Float, attachment 1 =R8Unorm— and a fragment shader that writes both@location(0)and@location(1):Render pass clears attachment 0 to
(0,0,0,0)and attachment 1 to1.0, draws a fullscreen triangle, then a second pass samples both textures.Observed:
0.0everywhere, regardless of the clear value or the fragment output. Tested with bothR8UnormandRGBA16Floatfor the second target; identical result.This is exactly consistent with the second attachment not existing in the underlying
VkRenderPass/VkFramebuffer.Why this matters
Weighted-blended OIT (McGuire & Barta, 2013 — the technique Rust wgpu and most engines use for translucent water/glass/particles) writes, per fragment, into two targets in one pass:
rgb * a * weight, additive), anda, multiplicativeZero, OneMinusSrcColor).A resolve pass then composites
accum.rgb / max(accum.a, ε)over the opaque scene with coverage1 − revealage. Without MRT the revealage target cannot be produced, so the resolve readsrevealage = 0→ coverage1→ the resolve paints opaque black over the entire frame.I hit this while porting Ironwail's translucent water to a gogpu/WebGPU renderer: the water accumulation worked, but the rest of the world rendered black until I disabled OIT and fell back to sorted alpha blending.
Suggested approach
In
createRenderPass, build the attachment list by iterating over allColorAttachmentsrather than indexing[0]:vk.AttachmentDescriptionper color attachment (format, sample count, load/store ops, initial/final layouts from each attachment).pColorAttachmentsarray withcolorAttachmentCount = len(ColorAttachments), each referencing its attachment index.len(ColorAttachments) <= device.limits.maxColorAttachmentsand that all attachments share the same sample count, per the WebGPU spec).The Rust reference (
gfx-rs/wgpuhal/vulkan/src/command.rs/conv.rsrender-pass conversion) iterates the color attachments and would be the reference for the conversion.Workarounds in the meantime
Verification I can provide
I have a self-contained Go test that renders attachment 0 and attachment 1 in one pass and reads both back, demonstrating attachment 1 reads zero. gogpu_mrt_repro_test.go.txt