GeezerVisonThe World Wide Web, restored to 800 pixels of glory. |
| Home|About|Gaming cabinet|Archives|How it works|Starter sites |
GeezerVison is showing lampmaker.github.io — TinyWebGPU tutorial — WebGPU and WGSL in sixteen runnable steps TinyWebGPU, step by stepFrom 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:
Everything else is ordinary code — 01Getting a deviceEverything starts with one call.
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
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.
03Defines — because WGSL doesn’t have themThat three-line shader spelled out an angle-bracketed type three times. Coming from
GLSL, where those were plain
It is an ordinary property: assign it once after
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 JavaScriptDefines stop at the shader. The schema you meet in the next step —
JavaScript needs no feature from the library to fix this — a WGSL type is just text, so make it a constant:
Now both sides read the same, and a schema says what it means:
Better still, write the table once and let the string follow from it, so the two halves cannot drift apart:
That is exactly what this page does, which is why every box below can use
04Uniforms, and making it moveA 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:
and the library generates the WGSL struct, binds it at
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: 05Resolution, aspect and the pointerTwo 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 —
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 fragAnything you put in the
idle
Try: change the
0.28 in smin to
0.0 — the shapes stop melting and just overlap.07Your first compute passNo 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
Resources are the second schema.
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.
08Sizing a dispatch — and counting primesA dispatch asks for a number of workgroups, not threads. With
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
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 pixelsCompute 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 GPUA 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 frameEvery 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.
There is a tighter variant, 12Atomics, reduction, and not stallingThousands 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
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 countSometimes 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
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 samplersPixels get in two ways: raw bytes with
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: 15Putting it together: particlesTinyWebGPU 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
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 16Your own vertex stage, and a depth bufferEvery box so far has drawn a fullscreen quad. There are still no vertex buffers. You get two numbers —
This one is a height map. The one that bites. A buffer the vertex stage reads
must be named in
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.
One more knob comes free with Three things are worth knowing before you pick one.
Example 8 has all five behind a
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 edgesThe handful of things that will otherwise cost you an evening. A resource your shader never mentions disappearsPipelines are built with vec3 is not three floats
Readback stalls, and inside a frame it lies
Format has to match the target
Turn on the warnings while you build
18Shipping itThere is no build step. Copy
On GitHub Pages, keep the empty Where to go next
And when the wrapper stops helping, every raw handle is still there:
|
| You are visitor 01402503 — GeezerVison does not store the pages you look up. Best viewed with Netscape Navigator 4.7 or Internet Explorer 6 at 800×600. No cookies required. |