Resolving MSAA

Resolving a Multisampled Texture

A color attachment's resolveTarget names a single-sample view, usually the canvas texture, that receives the average of each pixel's samples when the pass ends. Shelf Depth and MSAA's listing sets view to the 4x texture and resolveTarget to the canvas view, and gives its depth texture 4 samples too. storeOp: 'discard' on the multisampled texture is the norm, since the resolved image is all you keep; it needs only RENDER_ATTACHMENT usage and must be recreated with the canvas. For a custom resolve (tone mapping before averaging, say), bind it as a texture_multisampled_2d and read each sample with textureLoad(t, coords, sampleIndex).

A 4x multisampled texture resolved by resolveTarget, and by a custom shader that reads each sample with textureLoadHTMLLive
<!doctype html>
<style>
  body { margin: 0; background: #f7f4ee; font: 14px system-ui, sans-serif; }
  .stage { position: relative; width: 100%; max-width: 600px; }
  .stage canvas { display: block; width: 100%; }
  .stage canvas + canvas { position: absolute; inset: 0; pointer-events: none; }
</style>
<div class="stage">
  <canvas id="view" width="600" height="270"></canvas>
  <canvas id="labels" width="600" height="270"></canvas>
</div>
<script>
const canvas = document.getElementById('view');
const ink = document.getElementById('labels').getContext('2d');

function showMessage(text) {                     // 2D fallback when WebGPU is missing
  const ctx = canvas.getContext('2d');
  ctx.fillStyle = '#fbeaea'; ctx.fillRect(0, 0, canvas.width, canvas.height);
  ctx.fillStyle = '#8a2b2b'; ctx.font = '18px system-ui, sans-serif'; ctx.textAlign = 'center';
  ctx.fillText(text, canvas.width / 2, canvas.height / 2);
}

const W = 64, H = 48;                            // small, so each pixel is visible when magnified
const scene = /* wgsl */ `
@vertex fn vs(@builtin(vertex_index) v: u32) -> @builtin(position) vec4f {
  let p = array(vec2f(-0.8, -0.7), vec2f(0.9, -0.3), vec2f(-0.2, 0.85))[v];   // one slanted triangle
  return vec4f(p, 0, 1);
}
@fragment fn fs() -> @location(0) vec4f { return vec4f(0.08, 0.40, 0.75, 1); }`;
// Magnify: the left half shows the hardware resolve; the right half resolves by hand.
const view = /* wgsl */ `
@group(0) @binding(0) var resolved: texture_2d<f32>;
@group(0) @binding(1) var samples: texture_multisampled_2d<f32>;
@group(0) @binding(2) var<uniform> mode: u32;
@fragment fn fs(@builtin(position) pos: vec4f) -> @location(0) vec4f {
  let texel = vec2u((pos.xy - vec2f(f32(mode) * 300 + 6, 30)) / 4);
  if (mode == 0u) { return textureLoad(resolved, texel, 0); }
  var covered = 0u;                                // custom resolve: count samples that are blue
  for (var s = 0u; s < 4u; s++) {
    covered += u32(textureLoad(samples, texel, s).r < 0.5);   // background red is 0.98, blue's is 0.08
  }
  let heat = array(vec3f(0.98, 0.97, 0.94), vec3f(0.99, 0.85, 0.55), vec3f(0.95, 0.60, 0.25), vec3f(0.80, 0.30, 0.20), vec3f(0.08, 0.40, 0.75));
  return vec4f(heat[covered], 1);
}
@vertex fn vs(@builtin(vertex_index) v: u32) -> @builtin(position) vec4f {
  let p = array(vec2f(-1, -1), vec2f(3, -1), vec2f(-1, 3))[v];
  return vec4f(p, 0, 1);
}`;

async function main() {
  const adapter = await navigator.gpu?.requestAdapter();
  if (!adapter) return showMessage('WebGPU is not available in this browser');
  const device = await adapter.requestDevice();
  const context = canvas.getContext('webgpu');
  const format = navigator.gpu.getPreferredCanvasFormat();
  context.configure({ device, format });
  const T = GPUTextureUsage;
  const samples = device.createTexture({ size: [W, H], format: 'rgba8unorm', sampleCount: 4, usage: T.RENDER_ATTACHMENT | T.TEXTURE_BINDING });
  const resolved = device.createTexture({ size: [W, H], format: 'rgba8unorm', usage: T.RENDER_ATTACHMENT | T.TEXTURE_BINDING });
  const sceneModule = device.createShaderModule({ code: scene });
  const scenePipeline = device.createRenderPipeline({ layout: 'auto', vertex: { module: sceneModule },
    fragment: { module: sceneModule, targets: [{ format: 'rgba8unorm' }] }, multisample: { count: 4 } });
  const viewModule = device.createShaderModule({ code: view });
  const viewPipeline = device.createRenderPipeline({ layout: 'auto', vertex: { module: viewModule }, fragment: { module: viewModule, targets: [{ format }] } });
  const groups = [0, 1].map((mode) => {
    const buffer = device.createBuffer({ size: 4, usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST });
    device.queue.writeBuffer(buffer, 0, new Uint32Array([mode]));
    return device.createBindGroup({ layout: viewPipeline.getBindGroupLayout(0), entries: [
      { binding: 0, resource: resolved.createView() }, { binding: 1, resource: samples.createView() }, { binding: 2, resource: { buffer } }] });
  });

  const encoder = device.createCommandEncoder();
  const draw = encoder.beginRenderPass({ colorAttachments: [{ view: samples.createView(), resolveTarget: resolved.createView(),
    clearValue: [0.98, 0.97, 0.94, 1], loadOp: 'clear', storeOp: 'store' }] });   // store: the custom resolve reads it
  draw.setPipeline(scenePipeline);
  draw.draw(3);
  draw.end();                                      // the average lands in 'resolved' here
  const out = encoder.beginRenderPass({ colorAttachments: [{ view: context.getCurrentTexture().createView(),
    clearValue: [0.97, 0.96, 0.93, 1], loadOp: 'clear', storeOp: 'store' }] });
  out.setPipeline(viewPipeline);
  [0, 1].forEach((mode) => {
    out.setViewport(mode * 300 + 6, 30, W * 4, H * 4, 0, 1);
    out.setBindGroup(0, groups[mode]);
    out.draw(3);
  });
  out.end();
  device.queue.submit([encoder.finish()]);

  ink.font = '12px ui-monospace, monospace'; ink.fillStyle = '#222';
  ink.fillText('resolveTarget: the average of 4 samples', 6, 20);
  ink.fillText('custom: textureLoad(t, xy, s) for s in 0..3', 306, 20);
  ink.font = '11px system-ui, sans-serif'; ink.fillStyle = '#555';
  ink.fillText('Right: edge pixels coloured by how many of their 4 samples the triangle covered (1 to 4).', 6, 256);
}
main();
</script>