Minimal WebGPU framework.
OGPU is a small WebGPU framework that continues the ethos of OGL: an approachable interface with a thin abstraction layer that keeps easy access to the metal — in this case, WebGPU. It belongs to the OFrame family, carrying the same minimal-abstraction philosophy that Nathan Gordon set with OGL.
Written in vanilla ES modules with a Vite build, the API shares many similarities with THREE, but it is tightly coupled to WebGPU and ships far fewer features. The library does the minimum abstraction necessary, so you should still feel comfortable reaching for native WebGPU commands alongside it.
Keeping the level of abstraction low makes the framework easier to understand, extend, and reshape — and makes it a far more practical resource for learning WebGPU itself.
All vertex, fragment, and compute shaders are written — as they should be — in WGSL. No node graph, no TSL. You write WGSL, the framework reflects it, and that's the contract. This is a deliberate choice, not a missing feature.
OGPU is not published to npm, and that's on purpose. There's no build artifact to install and no stable public API to import against.
Treat this repo like a sourdough starter: a strong, living base you take and make your own. Fork it, extend it, or completely reshape it however you see fit. The point isn't a frozen framework — it's a clean, readable WebGPU foundation for either learning WebGPU or growing your own engine from a base that already has the hard parts solved. Today's LLMs are more than capable of carrying that reshaping for you.
To start:
git clone https://github.com/oframe/ogpu my-app
cd my-app
npm install
npm run dev # Vite dev server, default http://localhost:5173OGPU is deliberately geared toward development with coding agents (Claude Code, Codex, Cursor, Gemini CLI). Because you grow it rather than import it, the framework's real value is in how easy it is for an LLM to navigate, reason about, and extend. The strategies baked in for that:
-
AGENTS.mdis the shared source of truth. It maps the whole architecture, the cross-cutting model, and the conventions that reflection depends on.CLAUDE.md/GEMINI.md/ the Cursor rule are one-line bridges to it, so nothing is duplicated. Point your agent here first. -
Per-directory
CLAUDE.mdfiles. Each source directory carries its own footguns and why, so the right context loads next to the code an agent is editing — not buried in one giant root file. -
Generated navigation artifacts.
api-digest.mdis a terse index of the public surface (every exported class, its method signatures, exported functions) — the canonical what, so an agent never has to open a file just to learn a signature.module-graph.jsonis the static import graph (who-imports-what, with the structural hubs ranked) — so an agent traces dependencies instead of grepping. Both are regenerated bynpm run repomapand kept honest by a pre-commit drift gate. -
Offline WGSL validation. Every
.wgslfile in the repo is compiled by naga, the wgpu reference compiler, so an agent verifies shader edits without a browser. Two ways in: aPostToolUsehook validates each shader the moment an agent writes it and hands the compiler error straight back — a broken shader gets fixed in the same turn rather than at runtime — andnpm run validate:shadersbatch-checks the whole tree for CI or a wide refactor. naga isn't bundled; install it withbrew install naga-cli(orcargo install naga-cli). The script exits with code 2 if it's missing.Note the
-clisuffix: Homebrew'snagaformula is an unrelated terminal Snake game, and the two conflict because both install anagabinary. If you grabbed the wrong one:brew uninstall naga && brew install naga-cli.Working in Claude Code? The
naga-setupskill (.claude/skills/naga-setup/) installs all of this — hook, batch script, and thenaga/jqbinaries — into any repo. Worth running on a fresh clone of this one too: the hook and script arrive with the clone, butnagais a binary on your machine and doesn't, which leaves a hook that fires and silently does nothing.
The framework is split into Core, Math, and Modules.
The Core (src/core/) holds the engine primitives. Two classes carry the weight and are worth understanding first:
RenderPipeline— wraps a WGSL render module. It reflects the shader withwebgpu-utilsand compiles the pipeline — pure compiled state, shareable across meshes. It owns no uniform buffers or bind groups; it serves the bind group layouts viapipeline.bindGroupLayout(i), and eachMeshbuilds its bind groups against them. Uniforms come from one renderer-ownedPerDrawBuffer: everyMesh.drawtakes an aligned slice of that shared buffer and binds group(0) with a dynamic offset, so the same mesh can be drawn in several passes of one submit (shadow + main) without stomping itself.Mesh.drawmatches standard per-frame uniforms (matrices, camera, time, resolution) to yourUniformsstruct by field name. You write WGSL with avs/fsentry pair; reflection wires it up.ComputeShader— the compute counterpart. Every entry point in a WGSL compute module becomes a dispatchable kernel keyed by its name, with optional timestamp-query timing. This is the path for GPU compute work — simulation, culling, image processing — without leaving the framework.
The rest of Core:
Renderer.jsTransform.jsCamera.jsMesh.jsGeometry.jsTexture.jsRenderTarget.jsPerDrawBuffer.js— the shared dynamic-offset uniform buffer behind every drawGPUEnums.js— frozen named-constant tables for the WebGPU string enums (TextureFormat.RGBA16FLOAT,AlphaMode.PREMULTIPLIED, …), so a typo is aReferenceErrorinstead of a silently ignored stringShaderReload.js— WGSL hot-reloadprimitives/—Box,Sphere,Plane,Disc,Cone,Cylinder,Torus,Quad,FullscreenTriangleskin/— GPU skinning
The Math component (src/math/) is a set of chainable, THREE-style wrappers over wgpu-matrix — Vec2–Vec4, Quat, Mat3/Mat4, Euler, Color — each a Float32Array subclass.
Modules (src/modules/) are the optional higher-level pieces, kept out of Core to reduce bloat: Orbit, Raycast, GUI, Animation, GLTFLoader, CubeMap, VideoTexture, and a shader-only pbr/ IBL library.
Utils (src/utils/, alias @utils) are standalone helpers: BufferUtils, RenderUtils (blit), IBLUtils, ktxutils, TimingHelper, JSONLoader, the Mat3/Mat4/Euler helpers, and wgslOverrides (bakes override constants into const literals, since Safari has no pipeline-overridable constants).
Examples live in examples/ (repo root, outside src/), switched by a query string in src/main.js. The root serves a gallery — sidebar of links plus a preview iframe — and ?example=<name> deep-links a specific one. ?src=<name> is the inner URL the iframe loads: that example alone, no gallery chrome.
Post-processing, feedback, and display passes all run through one path instead of a per-effect fullscreen mesh. Renderer exposes two shared geometries — gpu.TRIANGLE (a FullscreenTriangle, the default) and gpu.QUAD (a Quad, when you need exact 4-corner interpolation, e.g. frustum-ray depth→world reconstruction). blit (@utils/RenderUtils) records a color-only pass with either:
blit(encoder, { pipeline, geometry: gpu.TRIANGLE, targetView, bindGroup, clear: true });It takes the RenderPipeline wrapper, not the raw GPU pipeline, and reads pipeline.hasDynamicUniform to decide whether group(0) needs the per-draw dynamic offset — so a pass whose shader binds only a sampler + texture doesn't need a Uniforms struct at all.
targetView is explicit and a blit lands wherever it points, so renderer.setContext (below) never reaches one — to blit onto another canvas take the view from renderer.contextFor(canvas).
The feedback example (?example=feedback) chains three passes per frame: the scene renders into an offscreen target, a feedback blit blends that over a decayed copy of the previous frame into an accumulation texture, and a present blit puts the result on the swapchain. The two accumulation textures ping-pong — each frame the sampled "previous" and the written target swap — so the pass never reads and writes the same texture.
One device, one frame loop, any number of canvases. renderer.setContext(canvas) points the next render() at another canvas and render() hands the default one back on its way out, so a binding lasts exactly one render:
for (const view of views) {
renderer.setContext(view.canvas);
renderer.render({ scene, camera: view.camera });
}
renderer.render({ scene, camera }); // back on the default canvasEach extra canvas is configured on first bind and keeps its own depth texture; meshes, pipelines and the scene are shared, since every draw takes its own slice of the renderer's PerDrawBuffer. Extra canvases aren't observed — set canvas.width/height yourself, and give each one its own camera aspect.
The multicanvas example (?example=multicanvas) puts eight primitives on eight canvases in a scrollable 2×2 CSS grid — one ResizeObserver drives every backing store off its laid-out box, an IntersectionObserver skips cells scrolled out of view, and all eight cells record into a single command encoder, so the grid costs one submit per frame.
Only Renderer.render follows the binding — a blit draws into the targetView it was handed, from whatever context you hold. When just the final pass of a chain should move, use renderer.contextFor(canvas) — the same lazy configure without binding — and take its view per frame (a swapchain texture doesn't survive one; the context object does):
blit(encoder, {
pipeline: composite,
geometry: gpu.TRIANGLE,
bindGroup,
targetView: renderer.contextFor(outCanvas).getCurrentTexture().createView(),
});src/modules/pbr/ is a shader-only library (no JS, imported with ?raw). The core piece, pbr.wgsl, is a glTF-style metallic-roughness shader lit entirely by IBL. It declares the standard vs/fs + uniforms so it drops into a RenderPipeline; material factors live in a Material block and maps follow the t<Name> convention (tMap, tMetallicRoughness, tNormal, tOcclusion, tEmissive, tOpacity). Normal mapping uses vertex tangents when present, else a screen-space derived frame.
Lighting inputs are built once at init (see initIBL in examples/pbrshader/PBRShader.js or examples/gltf/GLTF.js):
- Specular cube —
loadIBLCubeMap(gpu, { url, faceSize, mipLevels })(src/utils/IBLUtils/IBLUtils.js) decodes an EXR/HDR/KTX env map, unpacks to a cube, GGX-prefilters one roughness level per mip. ReturnsmipLevels; feed it back as theroughnessLevelsoverride constant so the roughness→lod mapping matches. - Diffuse irradiance —
loadSphericalHarmonics(url)reads 9 precomputed SH coefficients (e.g.assets/pbr/artistworkshop_sh.json) into avec4-paddedFloat32Array.evaluateSH(normal, …)reconstructs per-pixel diffuse irradiance — replaces a diffuse-convolved cubemap with a handful of constants. - BRDF LUT —
brdflut.wgsl(split-sum integration) dispatched into a 512×512rgba16floatstorage texture, sampled bynDotV/roughness.
Composes as kD · SH-irradiance · albedo + IBL-specular, applies occlusion/emissive, then tonemaps (filmic) + gamma-encodes. Drop the tonemap tail if rendering into an intermediate target with its own display pass.
Working in Claude Code? The
pbr-shadingskill (.claude/skills/pbr-shading/) walks through folding this shader into aRenderPipeline— which WGSL blocks to copy, the full 0–12 bind group, IBL/fallback-texture wiring, and the things that break silently.
Needs a browser with WebGPU. As of late 2025 it ships by default in:
- Chrome / Edge — 113+
- Firefox — Windows 141+, macOS ARM64 145+ (Linux expected 2026)
- Safari — macOS Tahoe 26 / iOS 26 / iPadOS 26 / visionOS 26
Mobile is still fragmented — Chrome on Android needs recent hardware, Firefox on Android is behind a flag.
navigator.gpu is exposed only in a secure context — HTTPS or localhost. Hitting the dev server over your LAN IP (vite --host → http://192.168.x.x:5173) is plain HTTP, so mobile browsers report "WebGPU not supported" even on a capable device. Serve it over HTTPS instead — e.g. a quick tunnel:
cloudflared tunnel --url http://localhost:5173 --http-host-header localhost:5173That gives a trusted https://*.trycloudflare.com URL (no cert to install on the phone). The --http-host-header flag rewrites the Host so Vite doesn't block the tunnel domain. A self-signed vite --https cert is the worse option — iOS Safari won't trust it.
Renderer.initDevice keeps a wishlist of WebGPU features and feature-detects it — each is requested only if the adapter exposes it, and anything missing is dropped (and logged) rather than failing device creation. So the engine boots on any WebGPU-capable adapter, while a fully-featured build (e.g. Chrome Canary) still gets everything. Optional features (texture compression, timestamp-query, …) may be absent on a given GPU, so guard any code that depends on them. See the "Browser floor" notes in AGENTS.md.
This is free and unencumbered software released into the public domain.
Anyone is free to copy, modify, publish, use, compile, sell, or distribute this software, either in source code form or as a compiled binary, for any purpose, commercial or non-commercial, and by any means.
For more information, please refer to https://unlicense.org
