Interactive MSAA Antialiasing for WebGPU Line Plots

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:

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:

  1. Pipeline sampleCount: the render pipeline is created with multisample: { 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.

  2. Multisampled render target: when sampleCount > 1, the render pass’s color attachment must use a multisampled texture as its view, with the canvas texture as the resolveTarget. 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.

msaa.js
// 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:

pipelines.js
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:

msaa-texture.js
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:

interaction.js
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:

example.html
<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

sine-aa-webgpu.js
// 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.


Check out similar posts by category: Javascript WebGPU Web