WebGPU at /lab: raymarched metaballs in 120 lines of WGSL
How the lab page renders smoothly-blended SDF metaballs at 60fps using pure WebGPU — no R3F, no Three.js. The full shader + render pipeline walked through.
zaidxshaikh's WebGL hero uses Three.js + R3F. For the /lab page I wanted to demonstrate native WebGPU without a library wrapper — to prove the API is approachable when you commit to learning the primitives.
The result is a raymarched signed-distance metaball scene that re-tints to match the active theme. ~120 lines of WGSL, ~250 lines of TS for the JS-side plumbing.
Why WebGPU over WebGL
WebGL has been the workhorse for fourteen years. It's universal. It's also crusty — global state, no compute shaders, GLSL stuck at version 3.00 ES, error handling that involves manually polling for status codes.
WebGPU is the modern GPU pipeline: stateless command encoders, real compute shaders, typed buffers, errors as Promise rejections, async by default. WGSL (its shader language) is friendlier than GLSL — typed, modular, with good error messages.
The catch: Chrome 113+ / Edge 113+ / Safari 26+ work out of the box; older Safari needs a flag; Firefox stable doesn't support it yet. The lab handles this with a graceful fallback card listing browser support.
The pipeline
Three setup steps before the first frame:
const adapter = await navigator.gpu.requestAdapter();
const device = await adapter.requestDevice();
const ctx = canvas.getContext("webgpu");
ctx.configure({ device, format: navigator.gpu.getPreferredCanvasFormat() });Then build a render pipeline:
const shaderModule = device.createShaderModule({ code: WGSL });
const pipeline = device.createRenderPipeline({
layout: "auto",
vertex: { module: shaderModule, entryPoint: "vs_main" },
fragment: { module: shaderModule, entryPoint: "fs_main",
targets: [{ format }] },
primitive: { topology: "triangle-list" },
});That's it. No global state. The pipeline is an immutable object you draw with.
The fullscreen triangle
For full-screen effects you don't need a quad with four vertices and two triangles — one triangle that covers [-1, 3] does it, and the GPU clips the part that's outside [-1, 1]:
@vertex
fn vs_main(@builtin(vertex_index) vi: u32) -> VSOut {
var pos = array<vec2f, 3>(
vec2f(-1.0, -1.0),
vec2f( 3.0, -1.0),
vec2f(-1.0, 3.0),
);
/* ... */
}draw(3, 1, 0, 0) — three vertices, one instance, no buffer setup. Zero VBO state to manage.
The SDF
A signed distance function returns the distance from a point to a surface. For a sphere:
fn sdSphere(p: vec3f, r: f32) -> f32 {
return length(p) - r;
}For multiple spheres I want a smooth union — not just min (which gives sharp creases at the join), but a soft blend. smin does this:
fn smin(a: f32, b: f32, k: f32) -> f32 {
let h = clamp(0.5 + 0.5 * (b - a) / k, 0.0, 1.0);
return mix(b, a, h) - k * h * (1.0 - h);
}k controls the blend radius. k=0 collapses to a hard min; larger k gives the metaball "wax" look.
The whole scene SDF is three spheres orbiting through trig parameters, smin-blended together:
fn map(p: vec3f) -> f32 {
let t = u.time * 0.45;
let p1 = p - vec3f(sin(t)*1.2 + mx, cos(t*0.7)*0.8 - my, sin(t*1.3)*0.6);
let p2 = p - vec3f(cos(t*1.1)*1.0, sin(t*0.5)*1.2, cos(t)*0.9);
let p3 = p - vec3f(sin(t*0.8+1.0)*0.7, cos(t*0.9)*0.9, sin(t*0.6+2.0)*1.1);
return smin(smin(sdSphere(p1, 0.85), sdSphere(p2, 0.7), 0.7),
sdSphere(p3, 0.6), 0.55);
}mx and my are pointer-derived offsets passed in via a uniform — that's how the scene "nudges" toward your cursor.
The raymarch
To render an SDF you march a ray through space, stepping forward by the SDF value at each point. When the distance is tiny you've hit the surface:
var t: f32 = 0.0;
var hit: bool = false;
for (var i: i32 = 0; i < 80; i = i + 1) {
let p = ro + rd * t;
let d = map(p);
if (d < 0.001) { hit = true; break; }
if (t > 30.0) { break; }
t = t + d;
}80 steps is enough for this scene. Hard surfaces would need fewer; foggy/cloudy SDFs would need more. t > 30 is the "miss" cutoff.
Normals via central differences
To shade the surface I need a normal. The classic SDF trick: sample the SDF a tiny step in each axis and take the gradient:
fn calcNormal(p: vec3f) -> vec3f {
let e = vec2f(0.001, 0.0);
return normalize(vec3f(
map(p + e.xyy) - map(p - e.xyy),
map(p + e.yxy) - map(p - e.yxy),
map(p + e.yyx) - map(p - e.yyx),
));
}Six extra SDF evaluations per hit. The cost is real but on the order of a millisecond per frame at this scene size.
Theme-reactive lighting
The three "light" colors are passed in as uniforms, set from the active theme's metadata on the JS side:
let d1 = max(dot(n, l1), 0.0);
let d2 = max(dot(n, l2), 0.0);
let d3 = max(dot(n, l3), 0.0);
let fres = pow(1.0 - max(dot(n, v), 0.0), 3.0);
let diffuse = u.tintA * d1 * 0.9 + u.tintB * d2 * 0.7 + u.tintC * d3 * 0.5;
let rim = u.tintA * fres * 1.2;Pick CRT from ⌘K → reload /lab → the metaballs are now phosphor green. Pick Sunset → amber and coral. Same shader, different uniforms.
Tone mapping
Without tone mapping, bright highlights clip to white and look flat. Reinhard tone map is two lines:
color = color / (color + vec3f(1.0));
color = pow(color, vec3f(1.0 / 2.2)); // gamma to sRGBIt compresses the high end while preserving the low end's contrast. The metaballs read as 3D instead of flat blobs.
DPR-aware sizing
A subtle gotcha: WebGPU canvases need explicit width / height attribute pixels, separate from the CSS size. Setting only CSS makes the canvas blurry on retina screens. So:
const resize = () => {
const dpr = Math.min(window.devicePixelRatio || 1, 1.5);
canvas.width = Math.floor(canvas.clientWidth * dpr);
canvas.height = Math.floor(canvas.clientHeight * dpr);
};
new ResizeObserver(resize).observe(canvas);1.5 cap because 3x DPR on a mobile retina screen burns GPU for marginal sharpness gain.
Cleanup matters
WebGPU resources are GPU-allocated and don't garbage-collect. Failing to clean them up on unmount leaks GPU memory across hot reloads:
return () => {
cancelAnimationFrame(raf);
resizeObserver.disconnect();
ubo.destroy();
device?.destroy();
};This is the discipline WebGPU demands — explicit lifecycle for every GPU buffer. Annoying for one-page demos; necessary for serious apps.
The verdict
WebGPU's learning curve is genuinely lower than I expected. The API is small, error messages are excellent, WGSL reads like a typed shader language should. Browser support is the only thing keeping it from being the default.
For /lab, the entire shader is open source — read src/components/lab/shader.ts on the repo. Fork the metaballs, swap in your own SDF, see what you can render.