Graphics on the GPU

A program running in a web page can draw with the computer's GPU, through the browser's WebGPU, and any program can compute with it - in a page, or under Bun. Gpu and the types around it are WebGPU's own objects - buffers, shaders, pipelines, bind groups and passes - with Refingere types: a pipeline's options are a struct, its formats and usages are enums, and a shader is written in place as #WGSL {{{ ... }}}. The page itself is set up the way any website is.

A first triangle

Gpu.request() gives the browser's GPU device. Everything else is made from it: canvas(id) sets up the page's <canvas> for drawing, in the format the browser prefers; floatBuffer puts numbers on the GPU, each as an F32; shader compiles WGSL; and pipeline joins the shader to the layout of its vertices and the canvas it draws on. A frame is a list of GpuRenderPasses - a canvas cleared to a color, then what's drawn on it - handed to submit.

WebGPU gives its device through a Promise, so the page's load() asks for it before the program starts - only when the program uses Gpu at all. From then on Gpu.request() is an ordinary call. A browser without WebGPU has no device, and it's an IOError saying so.

Shaders

#WGSL {{{ ... }}} makes a Wgsl - the shader's source, its brackets checked when the program is compiled. A #{ } writes a value's text into it, so a constant can be shared between Refingere and the shader, and a Wgsl placed there is spliced in whole, so shaders can share functions. gpu.shader takes a Wgsl or a String; a pipeline runs its vertexMain and fragmentMain unless its options name others. See embedded languages.

A browser's WebGPU compiles WGSL and nothing else. The other shading languages have body macros of their own - #GLSL {{{ ... }}}, #HLSL, #MSL (Metal) and #SPIRV (SPIR-V's assembly text) - each checked when the program is compiled the way #WGSL is, and each a value of its own type (Glsl, Hlsl, Msl, Spirv) with its source(). They're for a host or tool that takes them - a native GPU, or a translator to WGSL - not for gpu.shader, so a shader meant for a browser can't be one by mistake.

The GPU compiles a shader, and checks a pipeline against its shaders, on its own time - so a mistake there isn't an error value in Refingere: it's reported on the browser's console, with the line it's on. A call WebGPU refuses outright - a buffer it can't make - is a panic.

Drawing every frame

The website example in the repository, examples/webgpu, draws a spinning cube. What the GPU needs is made once, when the program loads, as a value every function reads:

@inject const cube is made when the program loads and read as &cube (see Context) - an IOError ! Cube, so a page without a GPU still loads, and finds out why when it starts:

gpu.animate("frame") has the browser call the @js function frame on every frame it draws, with the time in milliseconds - until a call ends with an error. Each frame writes the cube's transform into the uniform buffer (writeMatrix writes it column by column, the way WGSL's mat4x4<f32> reads it), records one pass and submits it. A pass is recorded in Refingere and crosses to the browser whole, so a frame is one call into JavaScript, not one per command.

A game

examples/starfall - playable on its own page - is a small game built the same way: a ship dodging falling rocks and catching stars. Its rules are a World changed by a step, in a file of their own and tested without a GPU. Every sprite - specks, rocks, stars, sparks and the ship - is eight floats in one buffer laid out with step ≔ GpuVertexStep.Instance, so a frame is one draw(6, count): a square per sprite, which the fragment shader cuts to its shape. The page hands the game its keys through @js functions, and the game calls the page back - @js("./page.ts") declarations - to show the score and play sounds.

Computing on the GPU

A GPU runs the same function on thousands of values at once, and nothing has to be drawn for that. Here every number up to 100,000 counts its own Collatz steps, one GPU thread each:

A compute shader's @compute function runs once per thread, and the threads come in workgroups of @workgroup_size - here 64, shared with Refingere through #{ }. gpu.computePipeline(shader) runs its computeMain, or the function named as its second argument. A GpuComputePass is recorded like a render pass: dispatch(x, y, z) runs that many workgroups, so it's the count divided by the workgroup's size, rounded up - and the shader checks arrayLength, as the last workgroup runs past the end. Render and compute passes go to submit together, in order.

The results stay on the GPU until they're read: gpu.read(buffer) gives the buffer's Bytes, and readFloats gives them as numbers - what a shader's array<f32> holds. Both are a Promise, awaited in an @async function, because the GPU finishes when it finishes: a web page goes on in the meantime, and the rest of main runs once the numbers are there. Every buffer can be read - CopySource is always added to its usages.

Drawing off the page

A GPU can draw where there's no page: gpu.offscreen(width, height) is a target like a canvas - pipelines are made for it and passes draw on it - and gpu.readImage(target) gives what's been drawn as an Image, to save as a PNG or a JPEG, or to make a video of, a frame at a time:

A VideoEncoder keeps each frame only compressed, so a long render needn't hold every Image - as an animated GIF, an APNG, a Y4M (raw YCbCr, for ffmpeg or a video encoder to take in) or Motion JPEG in an AVI. Under Bun the read is there at once; on a page, readImage reads a page's canvas too, once the GPU's done.

Testing without a GPU

What's worked out for the GPU - vertices, transforms, colors - is ordinary Refingere, so it's tested without one: refingere root=logic --test. Only the calls that reach the device need the browser.

Where it runs

Gpu needs a host with WebGPU: a web page, in Chrome or Edge, Safari 26, or Firefox with WebGPU turned on - compiled to WebAssembly or to JavaScript alike. Run with Bun, as --run and --test do, the GPU is the webgpu package's (Google's Dawn), which the compiler installs with itself - and the downloaded rfn carries inside it: it computes, and draws on offscreen targets, but there's no page, so no canvas. It runs on a thread of its own, so a read there gives its bytes as soon as they're ready. Compiled for WASI or as a native executable there's no GPU, and Gpu.request() is an IOError. See Gpu for every member.