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.

WebGPU isn’t available in this browser. The text still reads fine, but no box will run. You need Chrome/Edge 113+, Firefox 141+ (Windows) or Safari 26+, over https or localhost. On Android that means Chrome 121+ on Android 12+ — Samsung Internet, Firefox for Android and the in-app browsers inside chat and mail apps have no WebGPU at all. Run the WebGPU check to see what this device reports.

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:

idle

        

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.

idle

          
Try: swap 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.

idle

          
Try: add 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.

idle

          
Try: add 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.

idle

          
Try: touch or drag on the canvas — pointer events cover mouse and touch alike. Then delete the * 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.

idle

          
Try: change the 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.

idle

          
Try: raise 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.

idle

          
Try: push 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.)
Every thread that finds a prime does an 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.

idle

          
Try: raise 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.

idle

          
Try: change 0.32 to 0.08 for a sparse start, or to 0.6 for a crowded one that mostly dies.
Assigning 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.

idle

          
Try: move all three 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.

idle

          
Try: move 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.

idle

          
Try: change 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.

idle

          
Try: switch 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.

idle

          
Try: move the pointer over the canvas. Then make 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.

idle

          
Try: delete the 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.server is enough. Opening the file from disk will not work.
  • Ship the minified build if you care. tinywebgpu.min.js is 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:tiny drops the entry points a self-contained piece never calls — image loading, readback, PNG export, show — and gets you to about 11 KB. Add --with=show and friends to keep the ones you do use. See Tiny build in the README. You can try it right now: pick tiny in 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 8step 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.