
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.
Not an npm package — a starter culture
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:5173
Built for agentic development
OGPU 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.
-cli suffix: Homebrew's naga formula is an unrelated terminal Snake game, and the two conflict because both install a naga binary. If you grabbed the wrong one: brew uninstall naga && brew install naga-cli.
> Working in Claude Code? The naga-setup skill (.claude/skills/naga-setup/) installs all of this — hook, batch script, and the naga/jq binaries — into any repo. Worth running on a fresh clone of this one too: the hook and script arrive with the clone, but naga is a binary on your machine and doesn't, which leaves a hook that fires and silently does nothing.
Structure
The framework is split into Core, Math, and Modules.
The Core (src/core/) holds the engine primitives. A drawable is a Mesh: geometry + Shader + a plain state object + bind groups. You never build a render pipeline for it — the renderer derives one and caches it.
Shader— wraps a WGSL module with avs/fsentry pair. It appends the engine block (engine.wgsl), reflects the result withreflect/(onwebgpu-utils), and serves the bind group layouts.shader.bindGroupLayout(0).createBindGroup({ name: resource })binds by WGSL name;RenderPipelineandComputeShader(bindGroupLayout(kernelName, group)) hand out the sameBindGroupLayouttype. Group(1) is the engine's:camera.(projection/view matrices,cameraPosition,cameraQuaternion,viewport) andobject.(modelMatrix,normalMatrix,objectMatrix; view space iscamera.viewMatrix (object.modelMatrix p), ormodelViewMatrix()for the matrix) live in one renderer-ownedPerDrawBufferand are bound by dynamic offset: a camera slot perrender()and a fixed object slot per mesh, so one mesh can draw into several passes of one submit (shadow + main). The object slot holds one value per submit — move the mesh between those passes and both see the last transform.time.*(time,delta:renderer.time/deltaTimein seconds) is the engine's group(3): a fixed slot of the samePerDrawBuffer, uploaded with the camera slot and bound once per pass, also one value per submit; user groups are 0 and 2.Mesh— aTransformholdinggeometry,shader,stateandbindGroups; nogpu, no GPU objects of its own.state(transparent,depthWrite,cullMode,topology,blending, …) is frozen — swap the object to change it. The renderer'sPipelineRegistrybuilds a pipeline per (shader, state, vertex layout, pass formats) on first request and hands every mesh that asks the same object, so the same mesh renders into the canvas, an MSAA target or a depth-only shadow map with no per-target setup. Each mesh owns one fixed slot of the engine buffer, rewritten only when it moves;mesh.destroy()gives it back (a dropped mesh frees it when collected).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.
// 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 now
UniformBuffer.set() uploads immediately, so its values are shared by every pass in one submit — give each view its own buffer + bind group when they must differ. renderer.compile({ scene, target }) pre-warms pipelines asynchronously; otherwise each builds on first draw. examples/hellowebgpu/ is the canonical sample.
The rest of Core:
Renderer.jsTransform.jsCamera.jsGeometry.js— builds its own vertex/index buffers;layoutKeyis part of the pipeline keyUniformBuffer.js— a reflected struct view + the GPU buffer it uploads toPipelineRegistry.js— where Mesh pipelines come from (renderer.pipelines): built on first request, shared by every mesh with the same keyRenderBundleGroup.js— aTransformwhose meshes replay from a render bundle:group.record()captures the subtree once, andrender()then skips the per-mesh encoding (children may still move). For large static opaque sets; it isn't frustum culled or sorted, and the changes listed insrc/core/CLAUDE.mdmakegroup.staletrue until yourecord()again.examples/highmeshcounttoggles it (?src=highmeshcount&mode=recorded);examples/renderbundlecompares it with live draws on a ~6400-mesh field (re-record, stale readout,?src=renderbundle&mode=live)PerDrawBuffer.js— the engine block's buffer: a camera slot perrender()and a fixed object slot per mesh (rewritten only when it moves), both bound at dynamic offsets, plus the time slot group(3) bindsRenderPipeline.js— a hand-built pipeline for fullscreen passes and other non-Meshdraws (fully reflected layout, no engine block)Texture.jsRenderTarget.jsGPUEnums.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-reloadreflect/— WGSL reflection onwebgpu-utils: data definitions,StructView, bind group layout derivationprimitives/—Box,Sphere,Plane,Disc,Cone,Cylinder,Torus,Quad,FullscreenTriangle; raw generators inprimitives/vertices.jsskin/— GPU skinning
src/math/) is a function layer (@math/functions — vec2, vec3, vec4, quat, mat3, mat4; out param last and optional; wgpu-matrix plus a few local overrides) under chainable, THREE-style classes — 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, KTXTexture, gsplat/ (Gaussian splats: GaussianSplat, loadSplat), a shader-only pbr/ IBL library, and text/ (MSDF text from msdf-atlas-gen atlases, with SplitText-style reveals and a quad wireframe view; the bundled font is Fira Sans Bold, as in OGL's msdf-text example).
Utils (src/utils/, alias @utils) are standalone helpers: BufferUtils (createStorageBuffer / createBuffer, which fill from data at creation), RenderUtils (blit), mipmap (render-pass mip chain), textureFromSource (image/bitmap upload), 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= deep-links a specific one. ?src= is the inner URL the iframe loads: that example alone, no gallery chrome.
Fullscreen passes
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. A fullscreen RenderPipeline has no engine block and nothing dynamic: its owner backs the uniform struct (if the shader declares one) with new UniformBuffer(gpu, pipeline.defs.uniforms.uniforms, values) and builds the group(0) bind group with pipeline.bindGroupLayout(0).createBindGroup({ … }) — a pass that binds only a sampler + texture needs no 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.
Multiple canvases
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 canvas
Each extra canvas is configured on first bind and keeps its own depth texture; meshes and the scene are shared, since every render() takes its own camera slot and every mesh owns one object slot of the renderer's PerDrawBuffer (a canvas with a custom format just gets its own cached pipelines). UniformBuffer values are not per pass — give a view its own buffer when they must differ. 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(),
});
PBR shading & IBL
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 reads camera./object. from the engine block and declares vs/fs, so it drops into a Shader for a Mesh; material factors live in a Material uniform and maps follow the t 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.
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? Thepbr-shadingskill (.claude/skills/pbr-shading/) walks through folding this shader into aShader+Mesh— which WGSL blocks to copy, the group(0) bindings, IBL/fallback-texture wiring, and the things that break silently.
Browser requirements
Needs a browser with WebGPU. As of late 2025 it ships by default in:
- Chrome / Edge — 113+ (before 135 there is no
Float16Array: a stand-in is defined only while webgpu-utils loads, and f16 vector/matrix struct fields and plain-number 16float texture data degrade — use f32 fields, a 32float format, or half-float bits in aUint16Arraythere) - Firefox — Windows 141+, macOS ARM64 145+ (Linux expected 2026)
- Safari — macOS Tahoe 26 / iOS 26 / iPadOS 26 / visionOS 26
Testing on a phone (gotcha)
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:5173
That 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.
Unlicense
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