WebGPU Compute Particles
A GPU-accelerated particle field driven by a WGSL compute shader that integrates positions and velocities every frame, rendering thousands of points that swarm toward the cursor. Feature-detects navigator.gpu and gracefully degrades to a Canvas 2D simulation when WebGPU is missing, labeling the active backend on screen. Live controls tune particle count and attraction force, with DPR-aware rendering and reduced-motion support.
MCP
Code
:root {
--bg: #0c0d10;
--surface: #15161b;
--surface-2: #1d1f27;
--ink: #e9eaf0;
--muted: #9a9cab;
--accent: #8b5cf6;
--accent-2: #22d3ee;
--ok: #34d399;
--warn: #fbbf24;
--danger: #f87171;
--line: rgba(255, 255, 255, 0.1);
--line-2: rgba(255, 255, 255, 0.18);
--r-sm: 8px;
--r-md: 12px;
--r-lg: 16px;
}
*,
*::before,
*::after {
box-sizing: border-box;
}
html,
body {
height: 100%;
margin: 0;
}
body {
background: var(--bg);
color: var(--ink);
font-family: "Inter", system-ui, sans-serif;
line-height: 1.5;
-webkit-font-smoothing: antialiased;
text-rendering: optimizeLegibility;
}
.stage {
position: relative;
width: 100%;
min-height: 100vh;
min-height: 100dvh;
overflow: hidden;
}
.scene {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
display: block;
background:
radial-gradient(
120% 90% at 50% 0%,
rgba(139, 92, 246, 0.12),
transparent 60%
),
radial-gradient(
120% 90% at 100% 100%,
rgba(34, 211, 238, 0.08),
transparent 55%
),
var(--bg);
touch-action: none;
cursor: crosshair;
}
/* Topbar */
.topbar {
position: absolute;
inset: 0 0 auto 0;
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
padding: 18px 20px;
pointer-events: none;
}
.brand {
display: flex;
align-items: center;
gap: 12px;
}
.brand .dot {
width: 12px;
height: 12px;
border-radius: 50%;
background: radial-gradient(circle at 30% 30%, #c4b5fd, var(--accent));
box-shadow: 0 0 18px rgba(139, 92, 246, 0.7);
}
.brand h1 {
margin: 0;
font-size: 1rem;
font-weight: 700;
letter-spacing: -0.01em;
}
.brand p {
margin: 0;
font-size: 0.72rem;
color: var(--muted);
}
.badge {
pointer-events: auto;
display: inline-flex;
align-items: center;
gap: 8px;
padding: 7px 12px;
border: 1px solid var(--line);
border-radius: 999px;
background: rgba(21, 22, 27, 0.72);
backdrop-filter: blur(10px);
font-size: 0.75rem;
}
.badge-key {
font-family: "JetBrains Mono", monospace;
color: var(--muted);
font-size: 0.68rem;
text-transform: uppercase;
letter-spacing: 0.08em;
}
.badge-val {
font-weight: 600;
color: var(--accent-2);
}
.badge.gpu .badge-val {
color: var(--ok);
}
.badge.cpu .badge-val {
color: var(--warn);
}
/* Panel */
.panel {
position: absolute;
left: 20px;
bottom: 20px;
width: min(340px, calc(100% - 40px));
padding: 18px;
border: 1px solid var(--line);
border-radius: var(--r-lg);
background: rgba(21, 22, 27, 0.8);
backdrop-filter: blur(14px);
box-shadow: 0 20px 50px rgba(0, 0, 0, 0.45);
}
.control + .control {
margin-top: 14px;
}
.control-head {
display: flex;
align-items: baseline;
justify-content: space-between;
margin-bottom: 8px;
}
.control-head label {
font-size: 0.82rem;
font-weight: 600;
}
.control-head output {
font-family: "JetBrains Mono", monospace;
font-size: 0.78rem;
color: var(--accent-2);
}
input[type="range"] {
-webkit-appearance: none;
appearance: none;
width: 100%;
height: 6px;
border-radius: 999px;
background: linear-gradient(
90deg,
var(--accent) var(--fill, 40%),
var(--surface-2) var(--fill, 40%)
);
outline: none;
cursor: pointer;
}
input[type="range"]::-webkit-slider-thumb {
-webkit-appearance: none;
appearance: none;
width: 18px;
height: 18px;
border-radius: 50%;
background: var(--ink);
border: 3px solid var(--accent);
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.5);
}
input[type="range"]::-moz-range-thumb {
width: 16px;
height: 16px;
border-radius: 50%;
background: var(--ink);
border: 3px solid var(--accent);
}
input[type="range"]:focus-visible {
box-shadow: 0 0 0 3px rgba(139, 92, 246, 0.4);
}
.control-row {
display: flex;
gap: 10px;
margin-top: 16px;
}
.btn {
flex: 1;
padding: 9px 12px;
border-radius: var(--r-sm);
border: 1px solid var(--line-2);
background: var(--accent);
color: #fff;
font: inherit;
font-size: 0.82rem;
font-weight: 600;
cursor: pointer;
transition:
transform 0.12s ease,
background 0.18s ease,
border-color 0.18s ease;
}
.btn:hover {
transform: translateY(-1px);
}
.btn:active {
transform: translateY(0);
}
.btn.ghost {
background: transparent;
color: var(--ink);
}
.btn.ghost:hover {
border-color: var(--accent);
}
.btn[aria-pressed="true"] {
background: var(--surface-2);
color: var(--accent-2);
border-color: var(--accent-2);
}
.btn:focus-visible {
outline: none;
box-shadow: 0 0 0 3px rgba(34, 211, 238, 0.4);
}
.stats {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 8px;
margin: 16px 0 0;
padding: 0;
}
.stats div {
padding: 8px 10px;
border: 1px solid var(--line);
border-radius: var(--r-sm);
background: var(--surface);
}
.stats dt {
margin: 0;
font-size: 0.62rem;
text-transform: uppercase;
letter-spacing: 0.08em;
color: var(--muted);
}
.stats dd {
margin: 2px 0 0;
font-family: "JetBrains Mono", monospace;
font-size: 0.9rem;
font-weight: 500;
color: var(--ink);
}
.hint {
margin: 14px 0 0;
font-size: 0.74rem;
color: var(--muted);
}
.hint code {
font-family: "JetBrains Mono", monospace;
font-size: 0.72rem;
color: var(--accent-2);
background: var(--surface-2);
padding: 1px 5px;
border-radius: 5px;
}
.note {
position: absolute;
top: 70px;
right: 20px;
max-width: 280px;
padding: 10px 14px;
border: 1px solid var(--warn);
border-radius: var(--r-md);
background: rgba(251, 191, 36, 0.12);
color: var(--ink);
font-size: 0.78rem;
}
.note strong {
color: var(--warn);
}
@media (max-width: 520px) {
.topbar {
flex-direction: column;
align-items: flex-start;
gap: 10px;
}
.panel {
left: 12px;
right: 12px;
bottom: 12px;
width: auto;
padding: 14px;
}
.note {
top: auto;
bottom: 340px;
right: 12px;
left: 12px;
max-width: none;
}
}
@media (prefers-reduced-motion: reduce) {
.btn,
input[type="range"]::-webkit-slider-thumb {
transition: none;
}
}/* WebGPU Compute Particles
* GPU physics via a WGSL compute shader with a Canvas 2D fallback.
* Vanilla JS, no libraries. */
(() => {
"use strict";
const canvas = document.getElementById("scene");
const backendBadge = document.getElementById("backendBadge");
const backendName = document.getElementById("backendName");
const countInput = document.getElementById("count");
const countOut = document.getElementById("countOut");
const forceInput = document.getElementById("force");
const forceOut = document.getElementById("forceOut");
const pauseBtn = document.getElementById("pauseBtn");
const burstBtn = document.getElementById("burstBtn");
const fpsEl = document.getElementById("fps");
const ptsEl = document.getElementById("pts");
const pathEl = document.getElementById("path");
const reducedNote = document.getElementById("reducedNote");
const reduceMotion = window.matchMedia(
"(prefers-reduced-motion: reduce)"
).matches;
// Shared simulation state -------------------------------------------------
const nf = new Intl.NumberFormat("en-US");
const state = {
count: parseInt(countInput.value, 10),
force: parseFloat(forceInput.value),
paused: reduceMotion,
// Pointer in normalized device coords [-1,1], y up.
pointer: { x: 0, y: 0, active: false },
scatter: 0, // one-shot impulse flag
dpr: Math.min(window.devicePixelRatio || 1, 2),
width: 0,
height: 0,
};
function resize() {
const rect = canvas.getBoundingClientRect();
state.dpr = Math.min(window.devicePixelRatio || 1, 2);
state.width = Math.max(1, Math.floor(rect.width * state.dpr));
state.height = Math.max(1, Math.floor(rect.height * state.dpr));
canvas.width = state.width;
canvas.height = state.height;
if (backend && backend.resize) backend.resize();
}
// Pointer tracking (normalized, y-up) -------------------------------------
function setPointer(clientX, clientY) {
const rect = canvas.getBoundingClientRect();
const nx = ((clientX - rect.left) / rect.width) * 2 - 1;
const ny = -(((clientY - rect.top) / rect.height) * 2 - 1);
state.pointer.x = Math.max(-1, Math.min(1, nx));
state.pointer.y = Math.max(-1, Math.min(1, ny));
state.pointer.active = true;
}
canvas.addEventListener("pointermove", (e) => setPointer(e.clientX, e.clientY));
canvas.addEventListener("pointerdown", (e) => {
canvas.setPointerCapture?.(e.pointerId);
setPointer(e.clientX, e.clientY);
});
canvas.addEventListener("pointerleave", () => {
state.pointer.active = false;
});
// Controls ----------------------------------------------------------------
function syncFill(input) {
const min = parseFloat(input.min);
const max = parseFloat(input.max);
const pct = ((parseFloat(input.value) - min) / (max - min)) * 100;
input.style.setProperty("--fill", pct.toFixed(1) + "%");
}
syncFill(countInput);
syncFill(forceInput);
countInput.addEventListener("input", () => {
state.count = parseInt(countInput.value, 10);
countOut.textContent = nf.format(state.count);
syncFill(countInput);
if (backend && backend.reseed) backend.reseed();
});
forceInput.addEventListener("input", () => {
state.force = parseFloat(forceInput.value);
forceOut.textContent = state.force.toFixed(2) + "×";
syncFill(forceInput);
});
pauseBtn.addEventListener("click", () => {
state.paused = !state.paused;
pauseBtn.setAttribute("aria-pressed", String(state.paused));
pauseBtn.textContent = state.paused ? "Play" : "Pause";
if (!state.paused) lastTime = performance.now();
});
burstBtn.addEventListener("click", () => {
state.scatter = 1;
});
// FPS meter ---------------------------------------------------------------
let fpsAccum = 0;
let fpsFrames = 0;
let lastFpsUpdate = performance.now();
function tickFps(dt) {
fpsAccum += dt;
fpsFrames++;
const now = performance.now();
if (now - lastFpsUpdate >= 500) {
const fps = fpsFrames / (fpsAccum / 1000);
fpsEl.textContent = fps.toFixed(0);
fpsAccum = 0;
fpsFrames = 0;
lastFpsUpdate = now;
}
}
// ==========================================================================
// WebGPU backend
// ==========================================================================
const WGSL = /* wgsl */ `
struct Particle {
pos : vec2<f32>,
vel : vec2<f32>,
};
struct Sim {
pointer : vec2<f32>,
force : f32,
dt : f32,
count : u32,
active : f32,
scatter : f32,
_pad : f32,
};
@group(0) @binding(0) var<storage, read_write> parts : array<Particle>;
@group(0) @binding(1) var<uniform> sim : Sim;
// cheap hash for the scatter impulse
fn hash21(p : vec2<f32>) -> f32 {
var h = dot(p, vec2<f32>(127.1, 311.7));
return fract(sin(h) * 43758.5453);
}
@compute @workgroup_size(64)
fn main(@builtin(global_invocation_id) gid : vec3<u32>) {
let i = gid.x;
if (i >= sim.count) { return; }
var p = parts[i];
if (sim.scatter > 0.5) {
let a = hash21(vec2<f32>(f32(i), p.pos.x)) * 6.2831853;
let s = 0.9 + 0.6 * hash21(vec2<f32>(p.pos.y, f32(i)));
p.vel += vec2<f32>(cos(a), sin(a)) * s;
}
// Attraction toward the pointer.
if (sim.active > 0.5) {
let to = sim.pointer - p.pos;
let d2 = max(dot(to, to), 0.0008);
let dir = to / sqrt(d2);
let strength = sim.force * 1.6 / (d2 + 0.06);
p.vel += dir * strength * sim.dt;
}
// Mild pull back to center so the field stays bounded.
p.vel += (-p.pos) * 0.35 * sim.dt;
// Damping + integrate.
p.vel *= 0.965;
p.pos += p.vel * sim.dt;
// Soft-bounce off the edges.
if (p.pos.x < -1.0) { p.pos.x = -1.0; p.vel.x = abs(p.vel.x) * 0.6; }
if (p.pos.x > 1.0) { p.pos.x = 1.0; p.vel.x = -abs(p.vel.x) * 0.6; }
if (p.pos.y < -1.0) { p.pos.y = -1.0; p.vel.y = abs(p.vel.y) * 0.6; }
if (p.pos.y > 1.0) { p.pos.y = 1.0; p.vel.y = -abs(p.vel.y) * 0.6; }
parts[i] = p;
}
`;
const RENDER_WGSL = /* wgsl */ `
struct Particle {
pos : vec2<f32>,
vel : vec2<f32>,
};
@group(0) @binding(0) var<storage, read> parts : array<Particle>;
struct VOut {
@builtin(position) clip : vec4<f32>,
@location(0) speed : f32,
};
@vertex
fn vs(@builtin(vertex_index) vi : u32) -> VOut {
let p = parts[vi];
var out : VOut;
out.clip = vec4<f32>(p.pos, 0.0, 1.0);
out.speed = clamp(length(p.vel) * 2.2, 0.0, 1.0);
return out;
}
@fragment
fn fs(in : VOut) -> @location(0) vec4<f32> {
// Cool violet at rest, hot cyan when fast.
let slow = vec3<f32>(0.545, 0.361, 0.965);
let fast = vec3<f32>(0.133, 0.827, 0.933);
let col = mix(slow, fast, in.speed);
let a = 0.55 + 0.45 * in.speed;
return vec4<f32>(col * a, a);
}
`;
async function initWebGPU() {
if (!("gpu" in navigator)) return null;
let adapter;
try {
adapter = await navigator.gpu.requestAdapter();
} catch (e) {
return null;
}
if (!adapter) return null;
let device;
try {
device = await adapter.requestDevice();
} catch (e) {
return null;
}
if (!device) return null;
const ctx = canvas.getContext("webgpu");
if (!ctx) return null;
const format = navigator.gpu.getPreferredCanvasFormat();
ctx.configure({ device, format, alphaMode: "premultiplied" });
// Uniform buffer: pointer.xy, force, dt, count(u32), active, scatter, pad
const uniformBuffer = device.createBuffer({
size: 32,
usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST,
});
const uniformData = new ArrayBuffer(32);
const uf = new Float32Array(uniformData);
const uu = new Uint32Array(uniformData);
const computeModule = device.createShaderModule({ code: WGSL });
const renderModule = device.createShaderModule({ code: RENDER_WGSL });
const computePipeline = device.createComputePipeline({
layout: "auto",
compute: { module: computeModule, entryPoint: "main" },
});
const renderPipeline = device.createRenderPipeline({
layout: "auto",
vertex: { module: renderModule, entryPoint: "vs" },
fragment: {
module: renderModule,
entryPoint: "fs",
targets: [
{
format,
blend: {
color: {
srcFactor: "one",
dstFactor: "one",
operation: "add",
},
alpha: {
srcFactor: "one",
dstFactor: "one",
operation: "add",
},
},
},
],
},
primitive: { topology: "point-list" },
});
let particleBuffer = null;
let computeBind = null;
let renderBind = null;
let capacity = 0;
function allocate(n) {
if (particleBuffer) particleBuffer.destroy();
capacity = n;
const data = new Float32Array(n * 4);
for (let i = 0; i < n; i++) {
const r = Math.sqrt(Math.random()) * 0.85;
const a = Math.random() * Math.PI * 2;
data[i * 4 + 0] = Math.cos(a) * r;
data[i * 4 + 1] = Math.sin(a) * r;
data[i * 4 + 2] = (Math.random() - 0.5) * 0.05;
data[i * 4 + 3] = (Math.random() - 0.5) * 0.05;
}
particleBuffer = device.createBuffer({
size: data.byteLength,
usage:
GPUBufferUsage.STORAGE |
GPUBufferUsage.VERTEX |
GPUBufferUsage.COPY_DST,
});
device.queue.writeBuffer(particleBuffer, 0, data);
computeBind = device.createBindGroup({
layout: computePipeline.getBindGroupLayout(0),
entries: [
{ binding: 0, resource: { buffer: particleBuffer } },
{ binding: 1, resource: { buffer: uniformBuffer } },
],
});
renderBind = device.createBindGroup({
layout: renderPipeline.getBindGroupLayout(0),
entries: [{ binding: 0, resource: { buffer: particleBuffer } }],
});
}
allocate(state.count);
return {
kind: "WebGPU",
pathLabel: "compute shader",
reseed() {
allocate(state.count);
},
resize() {
// ctx already tracks canvas size; nothing extra needed.
},
frame(dt) {
const n = capacity;
uf[0] = state.pointer.x;
uf[1] = state.pointer.y;
uf[2] = state.force;
uf[3] = dt;
uu[4] = n;
uf[5] = state.pointer.active ? 1 : 0;
uf[6] = state.scatter;
uf[7] = 0;
device.queue.writeBuffer(uniformBuffer, 0, uniformData);
const encoder = device.createCommandEncoder();
if (!state.paused) {
const pass = encoder.beginComputePass();
pass.setPipeline(computePipeline);
pass.setBindGroup(0, computeBind);
pass.dispatchWorkgroups(Math.ceil(n / 64));
pass.end();
}
const view = ctx.getCurrentTexture().createView();
const rpass = encoder.beginRenderPass({
colorAttachments: [
{
view,
clearValue: { r: 0.047, g: 0.051, b: 0.063, a: 1 },
loadOp: "clear",
storeOp: "store",
},
],
});
rpass.setPipeline(renderPipeline);
rpass.setBindGroup(0, renderBind);
rpass.draw(n);
rpass.end();
device.queue.submit([encoder.finish()]);
state.scatter = 0;
},
};
}
// ==========================================================================
// Canvas 2D fallback (CPU integrator, mirrors the GPU behavior)
// ==========================================================================
function initCanvas2D() {
const ctx = canvas.getContext("2d");
if (!ctx) return null;
let px = new Float32Array(0);
let py = new Float32Array(0);
let vx = new Float32Array(0);
let vy = new Float32Array(0);
let n = 0;
// Cap the CPU sim so weaker machines stay smooth.
const CPU_CAP = 9000;
function allocate(target) {
n = Math.min(target, CPU_CAP);
px = new Float32Array(n);
py = new Float32Array(n);
vx = new Float32Array(n);
vy = new Float32Array(n);
for (let i = 0; i < n; i++) {
const r = Math.sqrt(Math.random()) * 0.85;
const a = Math.random() * Math.PI * 2;
px[i] = Math.cos(a) * r;
py[i] = Math.sin(a) * r;
vx[i] = (Math.random() - 0.5) * 0.05;
vy[i] = (Math.random() - 0.5) * 0.05;
}
}
allocate(state.count);
function toScreen(x, y, w, h) {
return [((x + 1) / 2) * w, ((1 - (y + 1) / 2)) * h];
}
return {
kind: "Canvas 2D",
pathLabel: "CPU fallback",
countOverride: () => n,
reseed() {
allocate(state.count);
},
resize() {},
frame(dt) {
const w = state.width;
const h = state.height;
if (!state.paused) {
const doScatter = state.scatter > 0.5;
const active = state.pointer.active;
const pxr = state.pointer.x;
const pyr = state.pointer.y;
const force = state.force;
for (let i = 0; i < n; i++) {
let ax = 0;
let ay = 0;
if (doScatter) {
const a = Math.random() * Math.PI * 2;
const s = 0.9 + Math.random() * 0.6;
vx[i] += Math.cos(a) * s;
vy[i] += Math.sin(a) * s;
}
if (active) {
const tx = pxr - px[i];
const ty = pyr - py[i];
const d2 = Math.max(tx * tx + ty * ty, 0.0008);
const inv = 1 / Math.sqrt(d2);
const strength = (force * 1.6) / (d2 + 0.06);
ax += tx * inv * strength;
ay += ty * inv * strength;
}
ax += -px[i] * 0.35;
ay += -py[i] * 0.35;
vx[i] = (vx[i] + ax * dt) * 0.965;
vy[i] = (vy[i] + ay * dt) * 0.965;
px[i] += vx[i] * dt;
py[i] += vy[i] * dt;
if (px[i] < -1) { px[i] = -1; vx[i] = Math.abs(vx[i]) * 0.6; }
else if (px[i] > 1) { px[i] = 1; vx[i] = -Math.abs(vx[i]) * 0.6; }
if (py[i] < -1) { py[i] = -1; vy[i] = Math.abs(vy[i]) * 0.6; }
else if (py[i] > 1) { py[i] = 1; vy[i] = -Math.abs(vy[i]) * 0.6; }
}
}
// Draw with additive blending.
ctx.globalCompositeOperation = "source-over";
ctx.fillStyle = "rgba(12,13,16,1)";
ctx.fillRect(0, 0, w, h);
ctx.globalCompositeOperation = "lighter";
const size = Math.max(1.2, 1.6 * state.dpr);
for (let i = 0; i < n; i++) {
const sp = Math.min(Math.hypot(vx[i], vy[i]) * 2.2, 1);
const [sx, sy] = toScreen(px[i], py[i], w, h);
const r = Math.round(139 + (34 - 139) * sp);
const g = Math.round(92 + (211 - 92) * sp);
const b = Math.round(246 + (238 - 246) * sp);
ctx.fillStyle = `rgba(${r},${g},${b},${0.5 + 0.4 * sp})`;
ctx.fillRect(sx - size / 2, sy - size / 2, size, size);
}
ctx.globalCompositeOperation = "source-over";
state.scatter = 0;
},
};
}
// ==========================================================================
// Boot + main loop
// ==========================================================================
let backend = null;
let lastTime = performance.now();
let rafId = 0;
function loop(now) {
rafId = requestAnimationFrame(loop);
let dt = (now - lastTime) / 1000;
lastTime = now;
// Clamp dt so tab-switches don't launch particles into orbit.
dt = Math.min(dt, 1 / 30);
// Fixed-ish step scaled to 60fps for stable physics feel.
const simDt = Math.min(dt * 60, 2.2);
backend.frame(simDt);
tickFps(dt);
const shown = backend.countOverride
? backend.countOverride()
: state.count;
ptsEl.textContent = nf.format(shown);
}
async function boot() {
resize();
window.addEventListener("resize", resize);
countOut.textContent = nf.format(state.count);
forceOut.textContent = state.force.toFixed(2) + "×";
backend = await initWebGPU();
if (!backend) backend = initCanvas2D();
if (!backend) {
backendName.textContent = "unavailable";
backendBadge.classList.add("cpu");
pathEl.textContent = "none";
return;
}
const isGpu = backend.kind === "WebGPU";
backendName.textContent = backend.kind;
backendBadge.classList.add(isGpu ? "gpu" : "cpu");
pathEl.textContent = backend.pathLabel;
if (reduceMotion) {
state.paused = true;
pauseBtn.setAttribute("aria-pressed", "true");
pauseBtn.textContent = "Play";
reducedNote.hidden = false;
// Render one static frame so the field is visible.
backend.frame(0);
}
lastTime = performance.now();
rafId = requestAnimationFrame(loop);
}
boot();
window.addEventListener("beforeunload", () => cancelAnimationFrame(rafId));
})();<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>WebGPU Compute Particles</title>
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link
href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=JetBrains+Mono:wght@400;500&display=swap"
rel="stylesheet"
/>
<link rel="stylesheet" href="style.css" />
</head>
<body>
<main class="stage" aria-label="WebGPU compute particle demo">
<canvas id="scene" class="scene"></canvas>
<header class="topbar">
<div class="brand">
<span class="dot" aria-hidden="true"></span>
<div>
<h1>Compute Particles</h1>
<p>GPU physics · cursor attraction</p>
</div>
</div>
<div class="badge" id="backendBadge" role="status" aria-live="polite">
<span class="badge-key">backend</span>
<span class="badge-val" id="backendName">detecting…</span>
</div>
</header>
<section class="panel" aria-label="Controls">
<div class="control">
<div class="control-head">
<label for="count">Particles</label>
<output id="countOut">16,000</output>
</div>
<input
id="count"
type="range"
min="1000"
max="60000"
step="1000"
value="16000"
/>
</div>
<div class="control">
<div class="control-head">
<label for="force">Attraction force</label>
<output id="forceOut">1.0×</output>
</div>
<input
id="force"
type="range"
min="0"
max="3"
step="0.05"
value="1"
/>
</div>
<div class="control-row">
<button id="pauseBtn" class="btn" type="button" aria-pressed="false">
Pause
</button>
<button id="burstBtn" class="btn ghost" type="button">
Scatter
</button>
</div>
<dl class="stats">
<div>
<dt>FPS</dt>
<dd id="fps">—</dd>
</div>
<div>
<dt>Points</dt>
<dd id="pts">—</dd>
</div>
<div>
<dt>Path</dt>
<dd id="path">—</dd>
</div>
</dl>
<p class="hint">
Move or drag your pointer over the field to pull the swarm. The
simulation integrates entirely on the GPU when
<code>navigator.gpu</code> is available, otherwise it falls back to a
Canvas 2D CPU sim with identical behavior.
</p>
</section>
<div class="note" id="reducedNote" hidden>
Reduced-motion is on — animation is paused. Press
<strong>Pause</strong> to toggle.
</div>
</main>
<script src="script.js"></script>
</body>
</html>An interactive particle system that runs its physics entirely on the GPU. A WGSL compute shader reads and writes a storage buffer of positions and velocities, applying a cursor-directed attraction force and light damping each dispatch, then a render pipeline draws the results as additive points. The whole simulation stays resident in GPU memory — no per-frame CPU readback — which is what lets it scale to tens of thousands of particles at smooth frame rates.
Move or drag the pointer to pull the swarm; the particles accelerate toward the cursor, overshoot, and orbit as momentum carries them past. The particle count slider reallocates the storage buffer and reseeds the field, while the force slider scales the attraction strength in real time. A backend badge always shows whether you are running on the real WebGPU compute path or the Canvas 2D fallback.
Because WebGPU is still rolling out, the component feature-detects navigator.gpu, requests an adapter and device, and only builds the compute + render pipelines if everything succeeds. If any step is unavailable it transparently switches to a CPU Canvas 2D integrator that mirrors the same behavior, so the demo stays fully interactive everywhere. It also honors prefers-reduced-motion by pausing continuous animation and settling the field.