A rasterizer that tests one point per pixel produces stair-stepped edges. Multisample anti-aliasing (MSAA) tests several coverage samples per pixel but still runs the fragment shader once per pixel, storing its color in every covered sample. WebGPU's core allows multisample.count of 1 or 4 (2 and 8 failed with "Multisample count (8) is not supported"). The color and depth attachments must have the same sampleCount as the pipeline, and a multisampled texture cannot be displayed or sampled normally, so it is resolved to a single-sample texture at the end of the pass (Resolving MSAA).
Four samples cost four times the attachment memory but little shading time, and on tiled mobile GPUs the samples can stay on chip when storeOp is 'discard'. MSAA smooths geometric edges only; textures need mipmaps (Mipmaps and Loading).
<!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="260"></canvas>
<canvas id="labels" width="600" height="260"></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 = 96, H = 72; // tiny render targets, magnified 3x
const scene = /* wgsl */ `
@group(0) @binding(0) var<uniform> time: f32;
@vertex fn vs(@builtin(vertex_index) v: u32, @builtin(instance_index) i: u32) -> @builtin(position) vec4f {
let q = array(vec2f(0, -1), vec2f(1, -1), vec2f(0, 1), vec2f(1, -1), vec2f(1, 1), vec2f(0, 1))[v];
let a = time + f32(i) * 0.628; // five thin spokes, like pages fanning out
let p = vec2f(q.x * 0.9, q.y * 0.035);
return vec4f(p.x * cos(a) - p.y * sin(a), (p.x * sin(a) + p.y * cos(a)) * 1.33, 0, 1);
}
@fragment fn fs() -> @location(0) vec4f { return vec4f(0.08, 0.40, 0.75, 1); }`;
const blit = /* wgsl */ `
@group(0) @binding(0) var image: texture_2d<f32>;
@group(0) @binding(1) var nearest: sampler;
struct Out { @builtin(position) pos: vec4f, @location(0) uv: vec2f }
@vertex fn vs(@builtin(vertex_index) v: u32) -> Out {
let p = array(vec2f(-1, -1), vec2f(3, -1), vec2f(-1, 3))[v];
return Out(vec4f(p, 0, 1), vec2f(p.x + 1, 1 - p.y) / 2);
}
@fragment fn fs(in: Out) -> @location(0) vec4f { return textureSample(image, nearest, in.uv); }`;
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 sceneModule = device.createShaderModule({ code: scene });
const scenePipeline = (count) => device.createRenderPipeline({ layout: 'auto', vertex: { module: sceneModule },
fragment: { module: sceneModule, targets: [{ format: 'rgba8unorm' }] }, multisample: { count } }); // 1 or 4
const single = scenePipeline(1), multi = scenePipeline(4);
const aliased = device.createTexture({ size: [W, H], format: 'rgba8unorm', usage: T.RENDER_ATTACHMENT | T.TEXTURE_BINDING });
const samples = device.createTexture({ size: [W, H], format: 'rgba8unorm', sampleCount: 4, usage: T.RENDER_ATTACHMENT });
const resolved = device.createTexture({ size: [W, H], format: 'rgba8unorm', usage: T.RENDER_ATTACHMENT | T.TEXTURE_BINDING });
const time = device.createBuffer({ size: 4, usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST });
const timeGroup = (p) => device.createBindGroup({ layout: p.getBindGroupLayout(0), entries: [{ binding: 0, resource: { buffer: time } }] });
const singleGroup = timeGroup(single), multiGroup = timeGroup(multi);
const blitModule = device.createShaderModule({ code: blit });
const blitPipeline = device.createRenderPipeline({ layout: 'auto', vertex: { module: blitModule }, fragment: { module: blitModule, targets: [{ format }] } });
const nearest = device.createSampler(); // defaults: nearest filtering, no smoothing added
const show = (texture) => device.createBindGroup({ layout: blitPipeline.getBindGroupLayout(0), entries: [
{ binding: 0, resource: texture.createView() }, { binding: 1, resource: nearest }] });
const showAliased = show(aliased), showResolved = show(resolved);
const clear = [0.98, 0.97, 0.94, 1];
ink.font = '12px ui-monospace, monospace'; ink.fillStyle = '#222';
ink.fillText('multisample: { count: 1 }', 8, 18);
ink.fillText('multisample: { count: 4 } + resolveTarget', 308, 18);
ink.font = '11px system-ui, sans-serif'; ink.fillStyle = '#555';
ink.fillText(`${W} x ${H} targets magnified 3x with a nearest sampler`, 8, 254);
function frame(now) {
device.queue.writeBuffer(time, 0, new Float32Array([now / 3000]));
const encoder = device.createCommandEncoder();
const a = encoder.beginRenderPass({ colorAttachments: [{ view: aliased.createView(), clearValue: clear, loadOp: 'clear', storeOp: 'store' }] });
a.setPipeline(single); a.setBindGroup(0, singleGroup); a.draw(6, 5); a.end();
const m = encoder.beginRenderPass({ colorAttachments: [{ view: samples.createView(), resolveTarget: resolved.createView(),
clearValue: clear, loadOp: 'clear', storeOp: 'discard' }] }); // keep only the resolved average
m.setPipeline(multi); m.setBindGroup(0, multiGroup); m.draw(6, 5); m.end();
const out = encoder.beginRenderPass({ colorAttachments: [{ view: context.getCurrentTexture().createView(),
clearValue: [0.97, 0.96, 0.93, 1], loadOp: 'clear', storeOp: 'store' }] });
out.setPipeline(blitPipeline);
out.setViewport(4, 26, W * 3, H * 3, 0, 1); out.setBindGroup(0, showAliased); out.draw(3);
out.setViewport(304, 26, W * 3, H * 3, 0, 1); out.setBindGroup(0, showResolved); out.draw(3);
out.end();
device.queue.submit([encoder.finish()]);
requestAnimationFrame(frame);
}
requestAnimationFrame(frame);
}
main();
</script>