The MSAA antialiasing post showed how to add multi-sample anti-aliasing to a WebGPU line plot by pre-creating one render pipeline per sample count. But which sample counts can you actually use? 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.
This post shows a minimal, dependency-free probe that tests each
candidate sample count and renders the result as an HTML list. The live
demo below loads the exact same script from
/scripts/msaa-levels-webgpu.js.
The list above is generated by probing each sample count with
createRenderPipelineAsync. Requires a WebGPU-capable
browser (Chrome / Edge 113+).
How it works
The probe is straightforward: for each candidate sample count
(1, 2, 4, 8, 16), attempt to create a render pipeline with that
multisample.count using createRenderPipelineAsync. If the Promise
resolves, the sample count is supported; if it rejects, it isn’t.
for (const sc of SAMPLE_COUNTS) {
try {
await device.createRenderPipelineAsync({
layout: 'auto',
vertex: { module, entryPoint: 'vs_main' },
fragment: { module, entryPoint: 'fs_main', targets: [{ format }] },
primitive: { topology: 'triangle-list' },
multisample: { count: sc },
});
supported.push(sc);
} catch {
// Unsupported sample count — skip.
}
}The shader content doesn’t matter for the probe — we only need a valid render pipeline to test whether the sample count is accepted. The simplest possible pipeline (a single full-screen triangle with a solid-color fragment shader) is sufficient.
Why createRenderPipelineAsync?
The synchronous createRenderPipeline does not throw on validation
errors — it returns an invalid pipeline object and logs to the console.
This makes it unreliable for probing: you’d have to inspect the console
or use error-scope APIs. createRenderPipelineAsync, on the other hand,
returns a Promise that rejects on validation errors, making it the
most portable way to probe. It has been available since WebGPU’s launch,
unlike newer APIs such as pushErrorFilter/popErrorFilter which are
not yet widely shipped.
Accessing the result from JavaScript
The script exposes the supported sample counts in three ways:
- HTML list — a
<ul>with one<li>per supported count, each tagged withdata-sample-count="N". You can query individual entries with CSS selectors orquerySelectorAll. - Custom event — a
msaalevelsevent is dispatched on the div, withevent.detail.levelscontaining the array of supported counts. This works without any global callbacks:example.jsdocument.querySelector('.msaa-levels') .addEventListener('msaalevels', (e) => { console.log('Supported:', e.detail.levels); }); - Callback — if the div has a
data-onlevels="functionName"attribute,window[functionName](supported)is called with the array. This is the same callback pattern used in the direction-cubes post.
Using it on your own page
Drop /scripts/msaa-levels-webgpu.js into your static folder and add a div
with the msaa-levels class:
<div class="msaa-levels" data-onlevels="onMsaaLevels"></div>
<script>
function onMsaaLevels(levels) {
console.log('Supported MSAA sample counts:', levels);
}
</script>
<script src="/scripts/msaa-levels-webgpu.js" defer></script>The data-onlevels attribute is optional — if present, the named
function is called with the array of supported sample counts. Without
it, you can still listen for the msaalevels custom event or just read
the rendered HTML list.
Full source
// SPDX-FileCopyrightText: 2026 Uli Köhler <gitlab@techoverflow.net>
// SPDX-License-Identifier: CC0-1.0
//
// Minimal WebGPU MSAA supported-sample-count probe.
//
// Probes which MSAA sample counts (1, 2, 4, 8, 16) are supported by the
// current GPU adapter and renders the result as an HTML <ul> list inside
// each <div class="msaa-levels"> element on the page.
//
// The list is accessible to JavaScript via:
// 1. A callback specified in data-onlevels="functionName" — called
// with the array of supported sample counts (e.g. [1, 2, 4, 8]).
// 2. A custom 'msaalevels' event dispatched on the div, with
// event.detail.levels containing the array.
//
// This is the same probing technique used by the
// interactive-msaa-antialiasing-for-webgpu-line-plots post: create a
// render pipeline with createRenderPipelineAsync for each candidate
// sample count. If the Promise rejects, the sample count is not
// supported by this adapter.
//
// 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
// ─────────────────────────────────────────────────────────────────────
// WebGPU spec only guarantees 1× and 4×; 2×, 8×, and 16× are
// adapter-dependent. We probe all five and report which ones succeed.
// The order is ascending so the rendered list reads naturally.
const SAMPLE_COUNTS = [1, 2, 4, 8, 16];
// ─────────────────────────────────────────────────────────────────────
// Minimal WGSL shader for probing
// ─────────────────────────────────────────────────────────────────────
// The shader content doesn't matter for the probe — we only need a
// valid render pipeline to test whether the sample count is accepted.
// This is the simplest possible render pipeline: a single triangle
// with a solid-color fragment shader. It is never actually rendered;
// we only care whether createRenderPipelineAsync succeeds or rejects.
const SHADER = /* wgsl */ `
@vertex
fn vs_main(@builtin(vertex_index) vi : u32) -> @builtin(position) vec4<f32> {
// Single triangle covering the full screen — not rendered, just
// needs to be a valid vertex shader.
let pos = array<vec2<f32>, 3>(
vec2<f32>(-1.0, -1.0),
vec2<f32>( 3.0, -1.0),
vec2<f32>(-1.0, 3.0),
);
return vec4<f32>(pos[vi], 0.0, 1.0);
}
@fragment
fn fs_main() -> @location(0) vec4<f32> {
return vec4<f32>(1.0, 1.0, 1.0, 1.0);
}
`;
// ─────────────────────────────────────────────────────────────────────
// Probe supported sample counts
// ─────────────────────────────────────────────────────────────────────
// For each candidate sample count, attempt to create a render pipeline
// with that sampleCount. createRenderPipelineAsync returns a Promise
// that rejects on validation errors (including unsupported sample
// counts). This is the most portable way to probe — it has been
// available since WebGPU's launch, unlike newer APIs such as
// pushErrorFilter/popErrorFilter which are not yet widely shipped.
//
// The probe pipelines are created with layout: 'auto' and a trivial
// shader — we don't need them for anything except testing acceptance.
// They are destroyed immediately after probing to free GPU memory.
//
// Returns: a Promise that resolves to an array of supported sample
// counts (ascending), e.g. [1, 4] or [1, 2, 4, 8].
async function probeSampleCounts(device, format) {
const module = device.createShaderModule({ code: SHADER });
const supported = [];
for (const sc of SAMPLE_COUNTS) {
let pipeline = null;
try {
pipeline = await device.createRenderPipelineAsync({
layout: 'auto',
vertex: { module, entryPoint: 'vs_main' },
fragment: { module, entryPoint: 'fs_main', targets: [{ format }] },
primitive: { topology: 'triangle-list' },
multisample: { count: sc },
});
supported.push(sc);
} catch {
// Unsupported sample count — skip silently.
} finally {
// The pipeline object exists only for probing; destroy it to
// free GPU memory. createRenderPipelineAsync returns a
// GPURenderPipeline which has no destroy() method, but the
// GPU resources are garbage-collected when the reference is
// dropped. We null the reference to help GC.
pipeline = null;
}
}
return supported;
}
// ─────────────────────────────────────────────────────────────────────
// Render the supported-levels list into a div
// ─────────────────────────────────────────────────────────────────────
// Builds an HTML <ul> with one <li> per supported sample count.
// Each <li> shows the count as "N×" and is tagged with
// data-sample-count="N" so JS can query individual entries.
//
// The div also receives:
// - A custom 'msaalevels' event with detail.levels = supported array
// - A callback via data-onlevels="functionName" (if specified)
function renderList(div, supported) {
// Build the <ul> list.
const ul = document.createElement('ul');
ul.className = 'msaa-levels-list';
for (const sc of supported) {
const li = document.createElement('li');
li.textContent = sc + '\u00d7'; // "N×" using the × character
li.setAttribute('data-sample-count', sc);
ul.appendChild(li);
}
// Replace the div's contents with the list.
div.innerHTML = '';
div.appendChild(ul);
// Dispatch a custom event so external JS can react to the probe
// result without needing a global callback.
div.dispatchEvent(new CustomEvent('msaalevels', {
bubbles: true,
detail: { levels: supported },
}));
// Call the optional data-onlevels callback.
const cbName = div.getAttribute('data-onlevels');
if (cbName && typeof window[cbName] === 'function') {
window[cbName](supported);
}
}
// ─────────────────────────────────────────────────────────────────────
// Per-div initialization
// ─────────────────────────────────────────────────────────────────────
// Called once for each <div class="msaa-levels"> on the page. Requests
// a WebGPU adapter+device, probes all candidate sample counts, and
// renders the result as an HTML list.
async function initDiv(div) {
// Show a loading placeholder while probing.
div.textContent = 'Probing MSAA support\u2026';
if (!navigator.gpu) {
div.innerHTML = '<p>WebGPU is not supported in this browser.</p>';
div.dispatchEvent(new CustomEvent('msaalevels', {
bubbles: true,
detail: { levels: [], error: 'no-webgpu' },
}));
return;
}
const adapter = await navigator.gpu.requestAdapter();
if (!adapter) {
div.innerHTML = '<p>No WebGPU adapter available.</p>';
div.dispatchEvent(new CustomEvent('msaalevels', {
bubbles: true,
detail: { levels: [], error: 'no-adapter' },
}));
return;
}
const device = await adapter.requestDevice();
const format = navigator.gpu.getPreferredCanvasFormat();
// Probe all candidate sample counts.
const supported = await probeSampleCounts(device, format);
// We're done with the device — release it. The probe pipelines
// are already garbage-collectible (no references retained).
device.destroy();
// Render the list and notify listeners.
renderList(div, supported);
}
// ─────────────────────────────────────────────────────────────────────
// Auto-initialization
// ─────────────────────────────────────────────────────────────────────
// Find every <div class="msaa-levels"> on the page and initialize it.
// If the document is still loading (script ran from <head> or with
// defer), wait for DOMContentLoaded so the divs exist. If the document
// is already ready (script injected late), initialize immediately.
function initAll() {
document.querySelectorAll('div.msaa-levels').forEach(initDiv);
}
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 probing technique is the same one used by the
MSAA antialiasing post
to pre-create render pipelines for supported sample counts, and by the
ToolpathRenderer in the
WebGCodeViewer project
to select between MSAA and non-MSAA rendering paths.