The sine-wave post rendered a GPU-evaluated sine wave as a line strip — but without anti-aliasing, the line edges are jagged, especially where the curve is steep. This post adds MSAA (multi-sample anti-aliasing) to that pipeline and makes it interactive: drag up to increase the sample count (smoother edges), drag down to decrease it (more aliasing).
The live demo below loads /scripts/sine-aa-webgpu.js. It starts at
1× (no AA) so you can see the aliasing, then drag up to compare.
Drag up/down to change the MSAA sample count. 6 full sine cycles — steeper than the original post, so aliasing is more visible at low sample counts. Requires a WebGPU-capable browser (Chrome / Edge 113+). Current MSAA: 1×
How it works
The plot is the same compute-to-storage-buffer-to-render-pass pipeline from the sine-wave post. The only addition is MSAA, which is a render-pipeline and render-pass concern — the compute shader and WGSL source are unchanged.
MSAA in WebGPU: pipeline + render pass
WebGPU MSAA requires two things:
Pipeline
sampleCount: the render pipeline is created withmultisample: { count: N }where N is 1, 2, 4, 8, or 16. This tells the GPU to take N samples per pixel during rasterization. The sample count is a pipeline property — it cannot be changed at draw time.Multisampled render target: when
sampleCount > 1, the render pass’s color attachment must use a multisampled texture as itsview, with the canvas texture as theresolveTarget. The hardware averages the N samples down to the canvas texture automatically at the end of the pass — no shader code or copy command needed.
// Pipeline: set sampleCount
const pipeline = device.createRenderPipeline({
// ...
multisample: { count: 4 },
});
// Render pass: multisampled texture → canvas resolve
const rp = encoder.beginRenderPass({
colorAttachments: [{
view: msaaTexture.createView(), // multisampled
resolveTarget: canvasView, // non-multisampled
clearValue: { r: 0.05, g: 0.05, b: 0.07, a: 1 },
loadOp: 'clear',
storeOp: 'store',
}],
});At sampleCount = 1, no multisampled texture is needed — the render
pass renders directly to the canvas texture, and resolveTarget is
omitted.
Pre-created pipelines for supported sample counts
Since sampleCount is a pipeline property, changing it at runtime
requires a different pipeline. To avoid stutter during interaction,
one pipeline per supported sample count is pre-created at init time.
Not all sample counts are supported by every GPU adapter. The WebGPU spec only guarantees 1× and 4×; 2×, 8×, and 16× are adapter-dependent. If you create a pipeline with an unsupported sample count, it may compile without error but produce no visible output — the trace simply disappears.
The most portable way to probe is createRenderPipelineAsync: it
returns a Promise that rejects on validation errors (including
unsupported sample counts), and has been available since WebGPU’s
launch. Newer APIs like pushErrorFilter/popErrorFilter are not
yet widely shipped:
for (const sc of SAMPLE_COUNTS) {
try {
const pipe = await device.createRenderPipelineAsync({
// ... same shader, same layout ...
multisample: { count: sc },
});
supportedSC.push(sc);
renderPipelines.push(pipe);
} catch {
// Unsupported sample count — skip.
}
}All pipelines share the same shader module and bind group layout, so
a single renderBindGroup works for all of them. Switching sample
counts is just renderPipelines[sampleIndex] — no recompilation, no
resource creation, no stutter.
MSAA texture lifecycle
The multisampled texture must match the canvas size and the current sample count. It is recreated when either changes:
function ensureMsaa(w, h, sc) {
if (sc === 1) {
if (msaaTexture) { msaaTexture.destroy(); msaaTexture = null; }
return;
}
if (msaaTexture && msaaSC === sc && msaaW === w && msaaH === h) return;
if (msaaTexture) msaaTexture.destroy();
msaaTexture = device.createTexture({
size: [w, h, 1],
format,
usage: GPUTextureUsage.RENDER_ATTACHMENT,
sampleCount: sc,
});
msaaSC = sc; msaaW = w; msaaH = h;
}At sc = 1, the MSAA texture is destroyed and the render pass
renders directly to the canvas. At sc > 1, a new texture is created
on demand. The check msaaSC === sc && msaaW === w && msaaH === h
avoids recreating the texture every frame when nothing changed.
Drag interaction → sample count
Up-down drag maps to a continuous position that snaps to the nearest sample-count index. Each 80px of drag moves one step:
canvas.addEventListener('pointermove', (e) => {
if (!dragging) return;
const dy = e.clientY - startY;
const idx = Math.round(startIndex - dy / SAMPLE_STEP_PX);
sampleIndex = Math.max(0, Math.min(SAMPLE_COUNTS.length - 1, idx));
});Drag up (negative dy) → higher index → more samples → smoother
edges. Drag down → lower index → fewer samples → more aliasing.
The canvas can optionally update an HTML element with the current
sample count via a data-samples-display="elementId" attribute, the
same callback pattern used in the
direction-cubes post.
Why 6 cycles?
The original sine-wave post uses 4 cycles (frequency = 8π). This
post uses 6 cycles (frequency = 12π) to make the line steeper at
the zero crossings. Steeper lines have more nearly-horizontal and
nearly-vertical segments, where aliasing is most visible — the
“staircase” effect on diagonal lines is more pronounced at 1× and
clearly smoothed at 16×.
Using it on your own page
Drop the script into your static folder and add a canvas with the
sine-aa-canvas class:
<canvas class="sine-aa-canvas"
data-samples-display="aa-readout"
style="width:100%;height:350px;"></canvas>
<span id="aa-readout">1×</span>
<script src="/scripts/sine-aa-webgpu.js" defer></script>The data-samples-display attribute is optional — if present, the
element is updated with the current sample count on every drag.
To change the frequency, amplitude, or the set of sample counts, edit
the FREQUENCY, AMPLITUDE, and SAMPLE_COUNTS constants at the top
of the script.
Full source
// SPDX-FileCopyrightText: 2026 Uli Köhler <gitlab@techoverflow.net>
// SPDX-License-Identifier: CC0-1.0
//
// Minimal WebGPU line plot with GPU-side function evaluation and
// interactive MSAA (multi-sample anti-aliasing).
//
// Renders a sine wave by evaluating sin() in a compute shader, then
// drawing the resulting points as a line strip. The JavaScript side
// only writes uniforms and submits command buffers — no function
// evaluation happens on the CPU.
//
// MSAA is controlled by the user: up-down drag changes the sample
// count between 1, 2, 4, 8, and 16. Five render pipelines are
// pre-created at init time (one per sample count) so no pipeline
// compilation happens during interaction. A multisampled texture
// serves as the render target when sampleCount > 1, with the canvas
// texture as the resolve target.
//
// Auto-initializes every <canvas class="sine-aa-canvas"> on the page.
// Optional: data-samples-display="elementId" to update an HTML element
// with the current sample count.
//
// Why an IIFE? We wrap everything in an immediately-invoked function
// expression so that none of the helpers, constants, or the WGSL source
// leak into the global scope. The script is intended to be included
// verbatim with <script src=... defer>, possibly alongside other scripts
// on the same page, so isolation matters.
(function () {
'use strict';
// ─────────────────────────────────────────────────────────────────────
// Configuration
// ─────────────────────────────────────────────────────────────────────
// 6 full cycles across the width — steeper than the 4-cycle sine
// post, so aliasing is more visible at low sample counts.
const FREQUENCY = 12.0 * Math.PI; // 6 full cycles
const AMPLITUDE = 0.8;
const MAX_POINTS = 4096;
// MSAA sample counts supported by WebGPU (powers of 2, max 16).
// Five pipelines are pre-created at init — one per sample count —
// so switching is instant with no shader recompilation.
const SAMPLE_COUNTS = [1, 2, 4, 8, 16];
const DEFAULT_SAMPLE_INDEX = 0; // start at 1× (no AA) to show aliasing
const SAMPLE_STEP_PX = 80; // pixels of drag per sample-count step
// ─────────────────────────────────────────────────────────────────────
// WGSL shader
// ─────────────────────────────────────────────────────────────────────
// Identical to the sine-wave post. MSAA does not change the shader —
// it only changes the render pipeline's sampleCount and the render
// pass's color attachment (multisampled texture + resolve target).
// The GPU hardware performs the multi-sampling automatically.
//
// The compute shader fills a storage buffer with vec2<f32> points:
// x = i / (pointCount - 1) → 0..1 across the width
// y = amplitude * sin(x * frequency)
//
// The render shader reads the same buffer and draws it as a line
// strip in clip space.
const SHADER = /* wgsl */ `
// ── Compute: fill points[] with sin(x) ──────────────────────────────
struct ComputeUniforms {
pointCount : u32,
frequency : f32,
amplitude : f32,
_pad : f32,
};
@group(0) @binding(0) var<uniform> cu : ComputeUniforms;
@group(0) @binding(1) var<storage, read_write> points : array<vec2<f32>>;
@compute @workgroup_size(64)
fn cs_main(@builtin(global_invocation_id) gid : vec3<u32>) {
let i = gid.x;
if (i >= cu.pointCount) { return; }
let n = f32(cu.pointCount);
let x = f32(i) / max(n - 1.0, 1.0);
let y = cu.amplitude * sin(x * cu.frequency);
points[i] = vec2<f32>(x, y);
}
// ── Render: line-strip in clip space ────────────────────────────────
struct RenderUniforms {
yScale : f32,
_pad0 : f32,
_pad1 : f32,
_pad2 : f32,
};
@group(0) @binding(0) var<uniform> ru : RenderUniforms;
@group(0) @binding(1) var<storage, read> rpoints : array<vec2<f32>>;
struct VSOut {
@builtin(position) pos : vec4<f32>,
@location(0) uv : vec2<f32>,
};
@vertex
fn vs_main(@builtin(vertex_index) vi : u32) -> VSOut {
let p = rpoints[vi];
var out : VSOut;
out.pos = vec4<f32>(2.0 * p.x - 1.0, p.y * ru.yScale, 0.0, 1.0);
out.uv = p;
return out;
}
@fragment
fn fs_main(in : VSOut) -> @location(0) vec4<f32> {
let t = in.uv.y * 0.5 + 0.5;
return vec4<f32>(0.2 + 0.1 * t, 0.5 + 0.2 * t, 1.0, 1.0);
}
`;
// ─────────────────────────────────────────────────────────────────────
// Per-canvas initialization
// ─────────────────────────────────────────────────────────────────────
async function initCanvas(canvas) {
if (!navigator.gpu) {
canvas.replaceWith(document.createTextNode('WebGPU is not supported in this browser.'));
return;
}
const adapter = await navigator.gpu.requestAdapter();
if (!adapter) {
canvas.replaceWith(document.createTextNode('No WebGPU adapter available.'));
return;
}
const device = await adapter.requestDevice();
const ctx = canvas.getContext('webgpu');
const format = navigator.gpu.getPreferredCanvasFormat();
ctx.configure({ device, format, alphaMode: 'premultiplied' });
const module = device.createShaderModule({ code: SHADER });
// --- Explicit bind group layouts ---
// Using explicit layouts (not 'auto') so all five render pipelines
// share the same bind group layout, and a single renderBindGroup
// works for all of them. The compute layout is separate because
// the point buffer is read_write in compute but read-only in render.
const computeLayout = device.createBindGroupLayout({
entries: [
{ binding: 0, visibility: GPUShaderStage.COMPUTE, buffer: { type: 'uniform' } },
{ binding: 1, visibility: GPUShaderStage.COMPUTE, buffer: { type: 'storage' } },
],
});
const renderLayout = device.createBindGroupLayout({
entries: [
{ binding: 0, visibility: GPUShaderStage.VERTEX, buffer: { type: 'uniform' } },
{ binding: 1, visibility: GPUShaderStage.VERTEX, buffer: { type: 'read-only-storage' } },
],
});
// --- Compute pipeline (shared, no MSAA) ---
const computePipeline = device.createComputePipeline({
layout: device.createPipelineLayout({ bindGroupLayouts: [computeLayout] }),
compute: { module, entryPoint: 'cs_main' },
});
// --- Render pipelines: one per supported MSAA sample count ---
// WebGPU requires sampleCount to be a pipeline property — you
// cannot change it at draw time. So we pre-create one pipeline
// per supported sample count. All share the same shader module
// and bind group layout; only sampleCount differs.
//
// At sampleCount=1, the render pass renders directly to the canvas
// texture (no multisampled texture needed). At sampleCount>1, the
// render pass renders to a multisampled texture and resolves to the
// canvas texture — the hardware averages the samples automatically.
//
// Not all sample counts are supported by every adapter. The WebGPU
// spec only guarantees 1× and 4×; 2×, 8×, and 16× are
// adapter-dependent. We probe by creating the pipeline with
// createRenderPipelineAsync — if the sample count is unsupported,
// the Promise rejects and we skip that count.
const supportedSC = [];
const renderPipelines = [];
const pipelineLayout = device.createPipelineLayout({ bindGroupLayouts: [renderLayout] });
for (const sc of SAMPLE_COUNTS) {
try {
const pipe = await device.createRenderPipelineAsync({
layout: pipelineLayout,
vertex: { module, entryPoint: 'vs_main' },
fragment: { module, entryPoint: 'fs_main', targets: [{ format }] },
primitive: { topology: 'line-strip' },
multisample: { count: sc },
});
supportedSC.push(sc);
renderPipelines.push(pipe);
} catch {
// Unsupported sample count — skip.
}
}
// --- Buffers ---
const computeUniforms = device.createBuffer({
size: 16,
usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST,
});
const renderUniforms = device.createBuffer({
size: 16,
usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST,
});
const pointBuffer = device.createBuffer({
size: MAX_POINTS * 8,
usage: GPUBufferUsage.STORAGE | GPUBufferUsage.COPY_DST,
});
const computeBindGroup = device.createBindGroup({
layout: computeLayout,
entries: [
{ binding: 0, resource: { buffer: computeUniforms } },
{ binding: 1, resource: { buffer: pointBuffer } },
],
});
// One render bind group works for all five pipelines because they
// share the same renderLayout.
const renderBindGroup = device.createBindGroup({
layout: renderLayout,
entries: [
{ binding: 0, resource: { buffer: renderUniforms } },
{ binding: 1, resource: { buffer: pointBuffer } },
],
});
// --- MSAA texture management ---
// When sampleCount > 1, the render pass needs a multisampled texture
// as its color attachment, with the canvas texture as the resolve
// target. The MSAA texture must match the canvas size and the
// current sample count — it is recreated when either changes.
//
// At sampleCount = 1, msaaTexture is null and the render pass
// renders directly to the canvas texture (no resolve needed).
let msaaTexture = null;
let msaaSC = 0, msaaW = 0, msaaH = 0;
function ensureMsaa(w, h, sc) {
if (sc === 1) {
if (msaaTexture) { msaaTexture.destroy(); msaaTexture = null; }
msaaSC = 1; msaaW = w; msaaH = h;
return;
}
if (msaaTexture && msaaSC === sc && msaaW === w && msaaH === h) return;
if (msaaTexture) msaaTexture.destroy();
msaaTexture = device.createTexture({
size: [w, h, 1],
format,
usage: GPUTextureUsage.RENDER_ATTACHMENT,
sampleCount: sc,
});
msaaSC = sc; msaaW = w; msaaH = h;
}
// --- Interaction: drag to change MSAA sample count ---
// Up-down drag cycles through supportedSC (the sample counts that
// this adapter actually supports).
// Drag up (deltaY < 0) → more samples (better AA).
// Drag down (deltaY > 0) → fewer samples (more aliasing).
//
// The drag maps to a continuous position that snaps to the nearest
// sample-count index. setPointerCapture ensures we keep receiving
// pointermove events even if the pointer leaves the canvas.
let sampleIndex = Math.min(DEFAULT_SAMPLE_INDEX, supportedSC.length - 1);
let dragging = false, startY = 0, startIndex = 0;
const displayId = canvas.getAttribute('data-samples-display');
const display = displayId ? document.getElementById(displayId) : null;
function updateDisplay() {
if (display) display.textContent = supportedSC[sampleIndex] + '×';
}
updateDisplay();
canvas.style.cursor = 'ns-resize';
canvas.style.touchAction = 'none';
canvas.addEventListener('pointerdown', (e) => {
dragging = true; startY = e.clientY; startIndex = sampleIndex;
canvas.setPointerCapture(e.pointerId);
});
canvas.addEventListener('pointerup', () => { dragging = false; });
canvas.addEventListener('pointermove', (e) => {
if (!dragging) return;
const dy = e.clientY - startY;
// Drag up (negative dy) → increase index. Clamp to valid range.
const idx = Math.round(startIndex - dy / SAMPLE_STEP_PX);
sampleIndex = Math.max(0, Math.min(supportedSC.length - 1, idx));
updateDisplay();
});
// --- Render loop ---
function frame() {
const dpr = Math.min(window.devicePixelRatio || 1, 2);
const cw = Math.max(1, Math.floor(canvas.clientWidth * dpr));
const ch = Math.max(1, Math.floor(canvas.clientHeight * dpr));
if (canvas.width !== cw || canvas.height !== ch) {
canvas.width = cw; canvas.height = ch;
}
const pointCount = Math.min(MAX_POINTS, cw);
const sc = supportedSC[sampleIndex];
// Write compute uniforms (same as the sine-wave post).
const cuData = new ArrayBuffer(16);
const cuView = new DataView(cuData);
cuView.setUint32(0, pointCount, true);
cuView.setFloat32(4, FREQUENCY, true);
cuView.setFloat32(8, AMPLITUDE, true);
cuView.setFloat32(12, 0, true);
device.queue.writeBuffer(computeUniforms, 0, cuData);
device.queue.writeBuffer(renderUniforms, 0, new Float32Array([
1.0, 0, 0, 0,
]));
// Ensure the MSAA texture matches the current canvas size and
// sample count. At sc=1 this destroys any existing MSAA texture.
ensureMsaa(cw, ch, sc);
const encoder = device.createCommandEncoder();
// Compute pass — generate sine wave points on the GPU.
const cp = encoder.beginComputePass();
cp.setPipeline(computePipeline);
cp.setBindGroup(0, computeBindGroup);
cp.dispatchWorkgroups(Math.ceil(pointCount / 64));
cp.end();
// Render pass — draw the points as a line strip with MSAA.
//
// Two cases:
// sc = 1: render directly to the canvas texture (no resolve).
// sc > 1: render to the multisampled texture, resolve to canvas.
//
// The resolve is performed by the hardware — no shader code or
// copy command needed. The multisampled texture is automatically
// averaged down to the canvas texture at the end of the pass.
//
// When using resolveTarget, storeOp must be 'discard' — we don't
// need the multisampled texture's contents after the resolve, only
// the resolved result in the canvas texture. Using 'store' on a
// multisampled texture with resolveTarget can cause the rendered
// content to disappear on some implementations (the resolve step
// may not fire correctly).
const canvasView = ctx.getCurrentTexture().createView();
const colorAttachment = {
clearValue: { r: 0.055, g: 0.055, b: 0.07, a: 1 },
loadOp: 'clear',
};
if (sc > 1) {
colorAttachment.view = msaaTexture.createView();
colorAttachment.resolveTarget = canvasView;
colorAttachment.storeOp = 'discard';
} else {
colorAttachment.view = canvasView;
colorAttachment.storeOp = 'store';
}
const rp = encoder.beginRenderPass({ colorAttachments: [colorAttachment] });
rp.setPipeline(renderPipelines[sampleIndex]);
rp.setBindGroup(0, renderBindGroup);
rp.draw(pointCount);
rp.end();
device.queue.submit([encoder.finish()]);
requestAnimationFrame(frame);
}
requestAnimationFrame(frame);
}
// ─────────────────────────────────────────────────────────────────────
// Auto-initialization
// ─────────────────────────────────────────────────────────────────────
function initAll() {
document.querySelectorAll('canvas.sine-aa-canvas').forEach(initCanvas);
}
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', initAll);
} else {
initAll();
}
})();The script and the inline listing above are identical and both
released under CC0-1.0. The implementation extends the
sine-wave post
with WebGPU’s built-in MSAA support. The same pre-created-pipelines
pattern is used by the ToolpathRenderer in the
WebGCodeViewer project,
which creates separate pipelines for MSAA and non-MSAA rendering paths
to avoid runtime pipeline compilation.