TinyWebGPU, step by step
From one coloured pixel to a few hundred thousand particles and a 3D terrain. Every code box below is live: edit it, press Run, and it recompiles against your actual GPU.
How the boxes work. Each one is a function body with a few things already in
scope: G (an initialised TinyWebGPU), canvas (the one above the
code, when there is one), log(…), which writes to the output panel, and the
type constants FLOAT, VEC2, … that step 03 sets up. In a file of
your own, those first lines would be:
import { WEBGPU } from './tinywebgpu.js';
const G = await WEBGPU().init(canvas.getContext('webgpu'));
Everything else is ordinary code — await at the top
level included. Animation loops are stopped when a box is re-run or scrolls out of view,
so nothing keeps burning your battery further up the page. The picker in the top-right
corner runs the whole page against the minified or tiny build instead of the readable
source; the tiny build drops optional features, and any box that needs one says so
rather than running.
01Getting a device
Everything starts with one call. init() asks the browser for an adapter,
asks the adapter for a device with its own maximum buffer limits, and — if you hand it a
canvas context — configures that canvas for you.
const G = await WEBGPU().init(canvas.getContext('webgpu')); // with a canvas
const G = await WEBGPU().init(); // compute only
That is the whole setup. There is no renderer to construct, no scene, no asset pipeline. Here is what your machine reported:
Those limits are worth a glance now rather than later: they are the numbers that decide how big a buffer you can bind and how many threads a workgroup can hold.
02Your first pixels
makeFrag draws exactly one thing: a triangle big enough to cover the
screen. You never see it. You write a function called frag that turns a
coordinate into a colour, and the library generates the vertex shader and the plumbing
around it. (When you want real geometry instead, makeDraw lets you write
the vertex stage yourself — see the last step.)
uv runs from (0,0) at the bottom-left to (1,1) at
the top-right. Everything else is up to you.
uv.y for 1.0 - uv.y and watch
the gradient flip. Or return vec4<f32>(step(0.5, uv.x)) for a hard
edge. Break the WGSL on purpose — the error tells you the line.makeQuad is a convenience wrapper: it builds the pipeline and gives you
run(), which uploads uniforms and draws in one call. The longer form,
makeFrag, is the same thing without the wrapper.
03Defines — because WGSL doesn’t have them
That three-line shader spelled out an angle-bracketed type three times. Coming from
GLSL, where those were plain vec2 and vec4, the brackets get
old fast — and WGSL has no #define to shorten them with. So TinyWebGPU
provides one. G.defines is a string of TOKEN replacement
entries, separated by commas or newlines, and every shader you compile is expanded
against it — whole words only, longest token first, so FLOAT matches but
myFLOATish doesn’t:
G.defines = `
FLOAT f32, INT i32, UINT u32
VEC2 vec2<f32>, VEC3 vec3<f32>, VEC4 vec4<f32>, MAT4 mat4x4<f32>
PI 3.141592653589793, TAU 6.283185307179586`;
It is an ordinary property: assign it once after init(), append your own
entries with +=, and note it is not limited to types — PI and
TAU above are constants, the way you would have used #define
in GLSL. The default is '', which rewrites nothing.
G.defines += ', HALF 0.5'; above the
pipeline and use HALF in the shader. A WGSL error still points at the
expanded source, so nothing gets harder to debug.The other half: the same names in JavaScript
Defines stop at the shader. The schema you meet in the next step —
uniforms: { time: 'f32' } — is an ordinary JavaScript object that the
library parses itself; no compiler ever sees it, so G.defines never touches
it. Left alone, that splits your vocabulary down the middle: VEC2 inside the
shader string, 'vec2<f32>' in the schema one line above it.
JavaScript needs no feature from the library to fix this — a WGSL type is just text, so make it a constant:
const FLOAT = 'f32', INT = 'i32', UINT = 'u32';
const VEC2 = 'vec2<f32>', VEC3 = 'vec3<f32>', VEC4 = 'vec4<f32>', MAT4 = 'mat4x4<f32>';
Now both sides read the same, and a schema says what it means:
uniforms: { time: FLOAT, res: VEC2, mouse: VEC2 }
Better still, write the table once and let the string follow from it, so the two halves cannot drift apart:
const TYPES = { FLOAT: 'f32', VEC2: 'vec2<f32>', VEC4: 'vec4<f32>' /* … */ };
const { FLOAT, VEC2, VEC4 } = TYPES;
G.defines = Object.entries(TYPES).map(([t, r]) => `${t} ${r}`).join('\n');
That is exactly what this page does, which is why every box below can use
VEC2 in a shader and VEC2 in a schema and mean the same thing.
Two notes on the edges. PI and TAU stay shader-only — they are
values, not types, so no schema ever asks for them. And resource schemas keep
their quotes: 'array<f32>', 'texture_2d<f32>',
a whole struct — those are complete type expressions rather than the plain
names the table covers, and `array<${FLOAT}>` costs more to read than
it saves.
04Uniforms, and making it move
A shader with no inputs can only ever draw one image. Uniforms are the inputs, and this is where TinyWebGPU earns its keep. You declare them as a plain JS object:
uniforms: { time: FLOAT }
and the library generates the WGSL struct, binds it at
@group(0) @binding(0), and gives you a setter that writes the right bytes at
the right offsets. Inside the shader they live on UB. There is nothing to
keep in sync by hand — the JS object is the layout.
speed: FLOAT to the uniforms, pass it in
run(), and multiply UB.time by it. Nothing else needs to
change — no struct to edit, no offsets to count.Uniform types cover the usual ground: f32, i32,
u32, vec2/3/4 of each, and mat4x4<f32> —
which is what FLOAT, INT, UINT,
VEC2/3/4 and MAT4 hold, so the constants and the quoted
spellings are interchangeable. Vectors and matrices take arrays; scalars take
numbers.
05Resolution, aspect and the pointer
Two things bite everyone once. First, a canvas has a CSS size and a backing-store size,
and they are not the same on a phone — G.resizeCanvas() sets the second from
the first times devicePixelRatio and hands back the pixel size, ready to
drop into a vec2<f32>. Second, uv is normalised, so
circles drawn in it come out as ellipses until you divide by the aspect ratio.
* a on both lines to see the stretch you were
correcting.resizeCanvas() returns changed as well as the
size. Gate any reallocation you do — render targets, grids, buffers sized to the
screen — on that flag, or you will rebuild them sixty times a second.06It is just WGSL above your frag
Anything you put in the frag string before the frag function is
ordinary module-scope WGSL: helper functions, structs, constants. The library only
appends its own wrapper at the end. That is enough to do real work — here are two signed
distance functions and a smooth minimum, which is most of a raymarcher in eight lines.
0.28 in smin to
0.0 — the shapes stop melting and just overlap.07Your first compute pass
No canvas from here to step 8 — compute needs no pixels at all. A compute pipeline takes
two strings: declarations, and the body of the entry point. The body gets
gid (global invocation id), lid (local) and wid
(workgroup), and it runs once per thread.
Resources are the second schema. { data: 'array<f32>' } becomes a
storage buffer binding, declared after the uniform block, in the order you wrote it.
N to 100. One workgroup of 64 threads is
no longer enough, and Math.ceil quietly asks for two — which is exactly
why the guard on the first line has to be there.buf.r() copies the buffer back to the CPU and waits for the GPU to catch
up. It is a debugging tool, and it is the slowest thing on this page. Step 11 shows what
to do instead in a real loop.
08Sizing a dispatch — and counting primes
A dispatch asks for a number of workgroups, not threads. With
wg: [64,1,1], one workgroup is 64 threads, so covering N items
means Math.ceil(N / 64) workgroups — and the last one overhangs, which is
what the bounds check is for. Get that wrong and you write past the end of a buffer.
Primality by trial division is a fair demonstration of what changes on a GPU: every
number is independent, so a hundred thousand of them can be tested at once. The same loop
runs on this thread underneath, for scale. Note what the GPU number includes — the
dispatch, the readback stall, and a fixed setup cost that only starts to look small once
N is large.
N to a million. On real GPU hardware the
dispatch barely notices while the CPU line grows with it; if your browser has fallen
back to software rendering, expect the opposite, since both are then the same CPU.
(Be patient — the CPU loop blocks the page while it runs.)atomicAdd on the same
counter, which is the slow way round. Step 11 shows the version that counts in a register
first and touches the shared counter once.09A storage buffer, driving pixels
Compute and render are not separate worlds. A buffer written by a compute pass can be read by a fragment shader in the same frame, without ever going back to the CPU — the data simply stays where it was made.
Note that the two pipelines declare the same buffer independently, each with the type it needs. Bindings are per-pipeline; the buffer does not care.
W and H to 384 and 216. The
dispatch maths adapts on its own, because it is written in terms of the size.This loop submits twice per frame — once for the dispatch, once for the draw. Step 10 fixes that.
10Ping-pong: state that lives on the GPU
A simulation needs to read the previous state while writing the next one, and it cannot do both to the same buffer — threads would race each other. The fix is two buffers, swapped every step. Conway's life is the smallest honest example.
0.32 to 0.08 for a sparse
start, or to 0.6 for a crowded one that mostly dies.p.resources = {...} rebuilds the bind group, and
it is compared by reference — passing the same object again is a no-op, passing a fresh
one forces the rebuild. In a hot loop, build both bind groups once and alternate.11One submit per frame
Every dispatch() and drawTo() so far submitted work to the
queue on its own. That is fine for one or two, and wasteful for ten.
beginFrame() opens a single encoder; everything recorded until
endFrame() goes to the GPU in one submit.
It buys something subtler too. Inside a frame, uniform writes are staged and copied at the point in the frame where you made them — so three dispatches can each see their own values, in one submit. Without that, they would all see whichever write landed last. Here is the proof: three dispatches, three different values, one submit.
setUniforms calls above
beginFrame(). Now the writes happen before the frame, every dispatch reads
the same struct, and all twelve numbers come back as 3.5.The same applies to a real loop: fade, simulate and draw belong in one frame, and a simulation that wants four steps per displayed frame just dispatches four times before drawing.
G.beginFrame();
for (let i = 0; i < STEPS; i++) { sim.dispatch(gx, gy); [a, b] = [b, a]; }
show.drawTo();
G.endFrame();
There is a tighter variant, beginCompute(), which also keeps a single
compute pass open across chained dispatches — worth reaching for when you are
running the same kernel dozens of times in a row. Outside a frame it owns the submit
itself, so a chain of dispatches is self-contained.
12Atomics, reduction, and not stalling
Thousands of threads with one answer between them need an atomic. The trap is using it per item: every thread queueing on the same address serialises the very thing you parallelised. The fix is always the same shape — accumulate privately, publish once.
Monte Carlo π is the classic demonstration. Throw darts at the unit square; the fraction
landing inside the quarter circle is π/4. Each thread runs its own random stream, counts
its own hits in a register, and does exactly one atomicAdd at the end.
atomicAdd(&stats.hits, 1u) inside the
dart loop and delete the private tally. Same answer, far more contention. Then raise
ROUNDS and watch the error fall like 1/√N — a hundred times the darts for
each new decimal place..r() stalls. It waits for the GPU to finish and for the result to be
mapped back — the one thing you must not do inside a frame loop. Keep results on the GPU
and sample them occasionally without awaiting in the loop, the way
example 6 does. Reading during an open frame is
worse still: it sees pre-frame data, and says so in the console.
13Letting the GPU decide the workgroup count
Sometimes the amount of work is only known once the GPU has done some. Reading a count
back to the CPU just to size the next dispatch throws away the whole frame's pipelining.
Indirect dispatch keeps it on the GPU: one kernel writes [x,y,z] into a
buffer, and the next dispatch reads its own size from there.
7u to 3u. Three times the
survivors, three times the workgroups in step 3 — and not a line of JS knew about
it.This is the wavefront pattern in miniature: cull, compact, count, and run the next stage over what is left. It is how particle systems retire dead particles and how ray tracers keep only the rays still in flight.
14Textures and samplers
Pixels get in two ways: raw bytes with writeTexture, or anything the
browser can decode with loadTexture — a URL, a Blob, an
<img>, a <canvas>, even a <video>.
Both give you a texture you can declare as a resource and sample.
magFilter to 'linear' —
sixteen texels become a smooth gradient. Or replace the whole texture with
const tex = await G.loadTexture('https://…/some.png');Textures come back out too: await G.readTexture(tex) returns tightly packed
rows, typed from the format, with the 256-byte row padding WebGPU demands already
stripped. Like buffer readback, it stalls — treat it as a debugging and export tool.
15Putting it together: particles
TinyWebGPU has no vertex buffers and no point sprites, which sounds like a problem for a
particle system and turns out to be the interesting part. Instead of drawing N points,
the simulation splats each particle into a density grid with one
atomicAdd, and a fullscreen pass colours the grid. The particle count stops
being a draw-call problem and becomes arithmetic.
Three passes, one submit: fade the grid (which doubles as the clear, and leaves trails), integrate and splat, then colour. Everything you have read so far is in here.
pull negative for a repeller, or raise N to 400000 —
it is one number, and nothing else in the loop changes.The particles here stick when they reach the wall, because clamp is one
line and a bounce is four. Example 7 is the same
program with the bounce, a resizable grid, and controls.
16Your own vertex stage, and a depth buffer
Every box so far has drawn a fullscreen quad. G.makeDraw is the one that
hands you the vertex stage: you write @vertex fn vs_main and
@fragment fn fs_main yourself, and the schema still generates
UB and the bindings exactly as it does for makeFrag. You keep
drawTo and the frame API.
There are still no vertex buffers. You get two numbers —
@builtin(vertex_index) and @builtin(instance_index) — and pull
whatever geometry you like out of a storage buffer. Which means a compute pass can write
the geometry a draw reads, with nothing uploaded and no CPU in between.
This one is a height map. count: 6, instances: N * N is two triangles per
grid cell, one instance per cell; the compute pass fills one height per grid vertex and
the vertex shader reads them straight back out of the same buffer. Each vertex rebuilds
its whole facet, so all three agree on the normal — that is what makes the shading flat
rather than smooth — and @interpolate(flat) tells the rasteriser not to
interpolate between three identical colours.
The one that bites. A buffer the vertex stage reads
must be named in readOnly. The schema binds storage buffers
read_write by default, and WebGPU will not make a read_write
binding visible to the vertex stage. Drop that line and pipeline creation fails with a
bind-group layout error.
depth: true line and watch a full
rotation. Half the orbit looks very nearly right — the cells happen to arrive in
roughly back-to-front order — and through the other half the ridges behind swallow
the peaks in front of them, snow cap and all. Nothing warns you; it just looks wrong
some of the time. Then put it back and raise N to 256: 131k triangles
from one number, because none of the geometry lives on the CPU.depth: true is the whole of the hidden-surface handling here:
depth24plus, compare less, depth writes on, and the depth
texture is created for you, sized to the render target and shared by every
depth-enabled pipeline drawing into it — so a second pass depth-tests against this one
with nothing to wire up. Override any field with
depth: { format, compare, write, texture }.
One more knob comes free with makeDraw: topology. It
defaults to 'triangle-list' and takes any of the five WebGPU has — the two
line topologies and 'point-list' included, so the same heights buffer will
draw you a wireframe, a set of contour lines, or a point cloud, changing only which
grid point each vertex maps to.
Three things are worth knowing before you pick one.
topology belongs to the pipeline, so lines or points cannot share
a draw with the surface — overlaying them means a second makeDraw over the
same buffer, which is why a wireframe is more often found in the fragment
shader instead, from a barycentric coordinate the rasteriser interpolates: that one
gives you the surface, the edges, or both at once, in a single draw. Lines and points
are always exactly one pixel — WebGPU has no lineWidth and no
gl_PointSize — and that is one device pixel, so on a 2×
display a dot is half a CSS pixel; when you want marks you can see, draw a small quad
per vertex. And strips are how you stop sending six vertices per cell, at the price of
flat shading: with vertices shared between neighbouring triangles,
@interpolate(flat) can only hand each triangle its provoking vertex’s
value, so the facets stop lining up with the triangles.
Example 8 has all five behind a
MODE constant above its draw — 'solid', 'wire',
'both', 'lines', 'strip',
'ribbons', 'points' — over one unchanged heights buffer, which
makes them worth flipping through side by side.
You can sometimes do without it: draw back-to-front and the painter’s algorithm gets the same picture for free. It is a tempting trade and easy to get subtly wrong. Example 8 used to sweep this grid away from the camera on both axes, which is exact only while every cell lies on the same side of the camera along both of them — and this orbit passes inside the grid’s own Z span twice a turn, where the two halves want opposite sweep directions. A few percent of the terrain came out in the wrong order, roughly once a rotation, which is just rare enough to look like a driver glitch. A depth buffer has no such angle.
17Sharp edges
The handful of things that will otherwise cost you an evening.
A resource your shader never mentions disappears
Pipelines are built with layout: 'auto', which strips bindings nothing
references. Declare debug: 'array<f32>', comment out the line that
writes to it, and the binding is gone — the library warns in the console and carries on.
If a resource seems unbound, check that the shader still uses its name.
vec3 is not three floats
vec3<f32> aligns to 16 bytes but occupies 12, so a scalar declared
after one packs into its tail. The generated struct follows WGSL's rules exactly, which
is the point of declaring uniforms as a schema rather than counting offsets — but it
means the order you declare them in changes the layout. When in doubt, put the wide types
first.
Readback stalls, and inside a frame it lies
.r() and readTexture() wait for the GPU. Called during an open
frame they return pre-frame data and warn. Finish the frame first, and keep them out of
your render loop.
Format has to match the target
makeFrag defaults to the canvas format. Drawing to an offscreen texture
in a different format needs { format: 'rgba8unorm' } — or whatever you
created — or the pipeline will not match the attachment.
Turn on the warnings while you build
G.debug = true reports uniform writes that overflow a field or push a
non-integer into an integer one. WGSL compile errors always throw, with the offending
source line printed.
18Shipping it
There is no build step. Copy tinywebgpu.js next to your HTML and import it.
Two things to know:
- Serve it over http. ES modules and WebGPU both need a real origin —
python3 -m http.serveris enough. Opening the file from disk will not work. - Ship the minified build if you care.
tinywebgpu.min.jsis about 16 KB, 7 KB gzipped. It is console-free: errors still throw with their messages, but the warnings and the pretty compile-error log are stripped. Develop against the readable file, switch at the end. - Inlining it into one file?
npm run build:tinydrops the entry points a self-contained piece never calls — image loading, readback, PNG export,show— and gets you to about 11 KB. Add--with=showand friends to keep the ones you do use. See Tiny build in the README. You can try it right now: picktinyin the corner of this page and watch which boxes stop being available — those are exactly the features it dropped.
On GitHub Pages, keep the empty .nojekyll file at the root — without it,
Jekyll ignores directories beginning with an underscore and can quietly drop files.
Where to go next
- Example 6 — differential evolution: a real gradient-free optimizer, one thread per individual, with a workgroup-shared-memory reduction and a non-blocking readback.
- Example 7 — the particle system with bouncing walls, a grid that follows the window, and up to 800k particles.
- Example 4 — indirect dispatch on its own.
- Example 8 — step 16 at full size: 128×128 cells, a self-check that holds the GPU's heights against the same arithmetic in JS, and both camera matrices spelled out separately.
- API.md — the complete surface, including the escape hatches for when the schema is not enough.
And when the wrapper stops helping, every raw handle is still there:
G.device, G.context, p.pipeline. Nothing here
stops you from dropping to plain WebGPU for one pass and coming back.