WebGPU in Orbium
How the engine talks to the GPU. The device and the frame's encoder, uniform blocks, WGSL modules, compute kernels and materials.
View as MarkdownWebGPU gives a web page direct access to the graphics card: buffers, textures, compute and render pipelines, and shaders written in WGSL. The engine wraps it in a few small pieces, each in engine/gpu/ or engine/render/.
The device and the frame
GPU.js holds the device, its queue, the shared samplers and one command encoder a frame. Every system records its compute dispatches and render passes into that encoder, in call order, and the frame ends with a single submit:
await GPU.init({ canvas });
// each frame
GPU.beginFrame();
ocean.update(dt); // records compute passes into the frame's encoder
sceneRenderer.render(); // records render passes
GPU.submit(); // one submit for the whole frameOne subtlety shapes much of the engine: queue.writeBuffer calls land before the frame's commands run. A buffer written twice in one frame keeps only the last value for every pass, so anything that changes between passes needs its own buffer, or its own offset.
Uniform blocks
A UniformBlock is a WGSL struct, its CPU mirror and its GPU buffer, declared once:
const U = new UniformBlock("OceanParams", {
choppiness: ["f32", 0.9],
sizes: ["vec4f", [733, 157, 33.3, 7.1]],
viewProj: "mat4x4f",
});
U.values.choppiness = 1.2; // uploaded on the next bind, only if it changedThe block lays the struct out by WGSL's alignment rules and writes the declaration for the shader, so the CPU and the GPU never disagree on an offset.
WGSL modules
The engine composes shaders from modules: a piece of WGSL (functions, structs, constants) together with the resources it reads (Shader.js). The terrain, for example, exports a module that defines terrainHeightAt and carries its height map. Any shader that lists it, directly or through another module, gets its code once and its bindings declared. Simplified, from TerrainGPU.js:
export const terrainModule = new ShaderModule({
name: "terrain",
deps: [commonModule],
uniforms: terrainUniforms,
bindings: {
terrainHeightTex: { texture: () => heightTex },
},
code: /* wgsl */ `
fn terrainHeightAt(xz: vec2f) -> f32 {
let uv = (xz - terrainParams.origin) / terrainParams.size;
return textureSampleLevel(terrainHeightTex, smpLinearClamp, uv, 0.0).r;
}`,
});Bind groups follow one convention everywhere:
| Group | Holds |
|---|---|
| 0 | The frame: camera, time, sun, and the shared samplers |
| 1 | The modules' and the material's resources, composed per shader |
| 2 | Per-draw data (render pipelines only) |
Modules can use a small preprocessor (#if, #ifdef, #else, #endif): the graphics presets switch features with defines instead of separate shaders.
Compute kernels
A ComputeKernel is a compute pipeline built from modules and a main body. The ocean's FFT, the surf simulation, particles and the terrain's selection all run this way:
const k = new ComputeKernel({
label: "fft rows",
modules: [fftModule],
bindings: { waveData: { storage: waveBuf, access: "read_write" } },
workgroupSize: [256, 1, 1],
code: /* wgsl */ `
@compute @workgroup_size(WG_X, WG_Y, WG_Z)
fn main(@builtin(global_invocation_id) gid: vec3u) { /* ... */ }`,
});
k.dispatch(Math.ceil(n / 256)); // recorded into the frame's encoderMaterials
A Material is a set of WGSL snippets plugged into one mesh shader template (MeshShader.js): a vertex snippet that can move vertices and a surface snippet that sets albedo, roughness and normals. The template adds the lighting, shadows, the light under the water and the outputs every pass needs (colour, motion vectors for the temporal upscaler, the water mask):
const rock = new Material({
name: "rock",
modules: [terrainModule],
uniforms: { tint: ["vec3f", new Color(0.5, 0.45, 0.4)] },
surface: `s.albedo = mat.tint; s.roughness = 0.85;`,
});The mesh renderer caches a pipeline per material and pass, culls and sorts the draws, and records them.
Keeping the GPU healthy
What the game builds during play is disposed when it is done with, and a test checks that GPU memory stays flat over rounds (gpu-leaks.test.mjs). A lost or failing device stops the game loop and says so, instead of feeding a dead GPU.