Minimal WebGPU framework.
OGPU continues the ethos of OGL for WebGPU: a thin, approachable layer that keeps the metal within reach. Part of the OFrame family, after the minimal-abstraction philosophy Nathan Gordon set with OGL.
Vanilla ES modules, Vite, a THREE-like API with far fewer features. Native WebGPU calls sit comfortably beside it, which also makes it a practical way to learn WebGPU.
Shaders are WGSL, always. No node graph, no TSL: you write WGSL, the framework reflects it. That's the contract.
Not on npm, on purpose: there's no stable API to import against. Treat it like a sourdough starter. Clone it, then grow it into your own engine (an LLM can do much of the reshaping).
git clone https://github.com/oframe/ogpu my-app
cd my-app
npm install
npm run dev # http://localhost:5173You grow it rather than import it, so it's laid out for agents (Claude Code, Codex, Cursor, Gemini CLI) to navigate:
-
AGENTS.mdis the source of truth: architecture, cross-cutting model, conventions.CLAUDE.md,GEMINI.mdand the Cursor rule are one-line bridges to it. -
Per-directory
CLAUDE.mdfiles hold each area's footguns, next to the code. -
api-digest.md(every public signature) andmodule-graph.json(the import graph, hubs ranked) are generated bynpm run repomap; a pre-commit hook blocks stale copies. -
Offline WGSL validation with naga: a
PostToolUsehook checks each shader an agent writes and returns the compile error in the same turn;npm run validate:shaderschecks the whole tree. Installnaga-cli(brew install naga-cliorcargo install naga-cli); the script exits 2 without it.Homebrew's
naga(no-cli) is an unrelated Snake game. In Claude Code, thenaga-setupskill installs the hook, script and binaries; run it on a fresh clone, since the binary doesn't come with the repo.
Core (src/core/): a drawable is a Mesh = geometry + Shader + a state object + bind groups. The renderer derives and caches the pipeline; you never build one for a mesh.
Shaderwraps a WGSL module withvs/fs, appends the engine block (engine.wgsl) and reflects it (webgpu-utils). Bind by WGSL name:shader.bindGroupLayout(0).createBindGroup({ uniforms }).- The engine owns group(1) and group(3). Shaders read
camera.*(projection, view, position, viewport),object.*(model, normal matrix) andtime.*(time,delta). Your resources go in groups 0 and 2. Meshholds no GPU objects.state(transparent,depthWrite,cullMode,blending, …) is frozen: swap the object to change it. One mesh renders into the canvas, an MSAA target or a shadow map with no per-target setup.ComputeShader: every entry point of a compute module is a kernel, keyed by name.
// validate-prepend: ../../src/core/engine.wgsl
struct Uniforms {
color: vec3f,
}
@group(0) @binding(0) var<uniform> uniforms: Uniforms;
struct Vertex {
@location(0) position: vec3f,
@location(1) normal: vec3f,
}
struct VertexOutput {
@builtin(position) position: vec4f,
@location(0) vNormal: vec3f,
}
@vertex
fn vs(in: Vertex) -> VertexOutput {
var out: VertexOutput;
out.position = camera.projectionMatrix * (camera.viewMatrix * (object.modelMatrix * vec4f(in.position, 1.0)));
out.vNormal = object.normalMatrix * in.normal;
return out;
}
@fragment
fn fs(in: VertexOutput) -> @location(0) vec4f {
return vec4f(uniforms.color * (0.5 + 0.5 * normalize(in.vNormal).y), 1.0);
}const shader = new Shader(gpu, { label: 'box-shader', code });
const uniforms = new UniformBuffer(gpu, shader.uniforms, { color: [1, 0.4, 0.2] }, { label: 'box-uniforms' });
const mesh = new Mesh({
label: 'box',
geometry: new Box(gpu),
shader,
state: { transparent: true, depthWrite: false },
bindGroups: [shader.bindGroupLayout(0).createBindGroup({ uniforms })],
});
mesh.setParent(scene);
uniforms.set({ color: [0.2, 0.6, 1] }); // uploads nowEngine and uniform values are one per submit: a mesh drawn in two passes of one submit sees its last transform and the last set() in both. Give each view its own UniformBuffer when they must differ. examples/hellowebgpu/ is the canonical sample; the details are in AGENTS.md and src/core/CLAUDE.md.
The rest of Core:
Renderer— device, frame loop, render queue. The canvas is the display's pixel grid;dprcaps it, or render into a smaller target and resample.Transform,CameraGeometry— vertex/index buffersUniformBuffer— a reflected struct + its GPU bufferPipelineRegistry— the cached Mesh pipelines (renderer.pipelines);renderer.compile()pre-warms themRenderBundleGroup— replays a static subtree from a render bundle (examples/renderbundle)PerDrawBuffer— the engine block's bufferRenderPipeline— fullscreen passes and other non-Mesh drawsTexture,RenderTargetGPUEnums— named constants for WebGPU's string enums, so a typo throwsShaderReload— WGSL hot reloadreflect/,primitives/,skin/
Math (src/math/): a function layer (vec3, quat, mat4, …; wgpu-matrix plus local overrides) under chainable THREE-style classes (Vec3, Quat, Mat4, Euler, Color, …), each a Float32Array.
Modules (src/modules/), optional: Orbit, Raycast, GUI, Animation, GLTFLoader, CubeMap, VideoTexture, KTXTexture, gsplat/ (Gaussian splats), pbr/ (IBL shaders), text/ (MSDF text).
Utils (src/utils/): BufferUtils, RenderUtils (blit), mipmap, textureFromSource, IBLUtils, TimingHelper, JSONLoader, wgslOverrides (bakes override constants for Safari), and more.
Examples (examples/): the root is a gallery; ?example=<name> deep-links one, ?src=<name> loads it alone.
Post-processing goes through blit (@utils/RenderUtils) and a RenderPipeline, over gpu.TRIANGLE (default) or gpu.QUAD (exact corner interpolation):
blit(encoder, { pipeline, geometry: gpu.TRIANGLE, targetView, bindGroup, clear: true });The pipeline has no engine block: bind group(0) from pipeline.bindGroupLayout(0). examples/feedback ping-pongs two accumulation textures.
One device, one loop, any number of canvases. renderer.setContext(canvas) points the next render() at another canvas:
for (const view of views) {
renderer.setContext(view.canvas);
renderer.render({ scene, camera: view.camera });
}
renderer.render({ scene, camera }); // back on the default canvasMeshes and scenes are shared. Extra canvases aren't observed: size them yourself and give each its own camera. To move only a final blit, target renderer.contextFor(canvas).getCurrentTexture().createView(). examples/multicanvas draws eight canvases in one submit.
src/modules/pbr/pbr.wgsl is a glTF metallic-roughness shader lit by IBL, ready for a Shader + Mesh. Maps follow t<Name> (tMap, tNormal, …). Inputs, built once (examples/pbrshader, examples/gltf):
- Specular:
loadIBLCubeMap(gpu, { url, faceSize, mipLevels })→ a GGX-prefiltered cube; pass itsmipLevelsas theroughnessLevelsoverride. - Diffuse:
loadSphericalHarmonics(url)→ 9 SH coefficients. - BRDF LUT:
brdflut.wgslinto a 512²rgba16floattexture.
It tonemaps and gamma-encodes at the end; drop that when rendering into an intermediate target. In Claude Code, the pbr-shading skill wires it up.
WebGPU ships by default in (status, October 2026):
- Chrome / Edge: Windows, macOS, ChromeOS 113+; Android 121+ on most Android 12+ GPUs; Linux 144+ (Intel Gen12+) and 147+ (NVIDIA under Wayland). Before 135 there's no
Float16Array: use f32 struct fields and 32float formats there. - Firefox: Windows 141+; Apple Silicon Macs 145+ on macOS 26, 147+ on older macOS. Elsewhere Nightly or a flag.
- Safari: macOS / iOS / iPadOS / visionOS 26.
Optional features are feature-detected: anything the adapter lacks is dropped and logged, so the engine boots on any WebGPU adapter. Guard code that needs one.
Phones: navigator.gpu exists only in secure contexts, so http://192.168.x.x:5173 reports no WebGPU. Tunnel over HTTPS instead:
cloudflared tunnel --url http://localhost:5173 --http-host-header localhost:5173Free and unencumbered software released into the public domain. See https://unlicense.org.
