A WebGPU renderer that ships in 71 KB gzip, and a README that tells you which of its own claims were measured — including the ones that came out worse than advertised.
bun add apse # or: npm i apseimport {
Renderer, PerspectiveCamera, Scene, MeshNode,
box, upload, pbrMaterial, quat,
} from 'apse';
const renderer = await Renderer.create(document.querySelector('canvas'), {
budget: { cpu: 2, drawCalls: 200 },
});
const scene = new Scene({ name: 'lit-cube' });
const camera = new PerspectiveCamera({ fov: 45, near: 0.1, far: 100, aspect: 1 });
camera.lookAt([3.2, 2.4, 4.4], [0, 0, 0], [0, 1, 0]);
renderer.camera = camera;
const cube = upload(renderer.device.device, box({ width: 1.4 }));
// Read this line. Tone mapping is on by default, so the scene renders into an
// rgba16float intermediate rather than the canvas, and every material in that
// scene has to be compiled for that format. Omit it and you get
// RENDER_TARGET_FORMAT_MISMATCH, which is a much better error than the one
// WebGPU would have given you — but it is still an error.
const material = await pbrMaterial(renderer.device.device, {
targetFormat: renderer.sceneFormat,
});
const node = new MeshNode({ name: 'cube', mesh: cube, material });
node.setPosition(0, 0.8, 0);
scene.add(node);
renderer.start((dt) => {
// `rotation` is a live view into the node: a direct write bypasses the dirty
// flag, so `markDirty()` is required. This is the one place you write the
// array yourself.
quat.fromEulerXYZ(node.rotation, 0, node.rotation[1] + dt * 0.6, 0);
node.markDirty();
});examples/lit-cube.html is the same thing against dist/, plus a stats overlay.
The default path is: scene → rgba16float intermediate → fullscreen ACES pass →
canvas. The intermediate exists because a tone map fed an already-clipped 8-bit
image is a full-screen pass that applies a curve to a clamped picture; the
highlights are gone before the curve sees them. The cost of doing it properly is
that the scene is no longer drawn into the canvas format, so every material
needs targetFormat: renderer.sceneFormat.
The two escape hatches are toneMapping: null and hdr: false. Both restore a
direct draw into the canvas format and both cost you the reason the pass exists.
renderer.sceneFormat tells you the truth either way — read it, don't guess.
N objects, one MeshNode, one drawIndexed:
import { InstanceData, uploadInstances, instancedMaterial } from 'apse';
// `count` 4x4 column-major matrices, or InstanceData.fromTRS(t, r, s) for 8 each
const matrices = new Float32Array(count * 16);
const instances = uploadInstances(
renderer.device.device,
InstanceData.fromMatrices(matrices, { name: 'grid' }),
);
const material = await instancedMaterial(renderer.device.device, {
targetFormat: renderer.sceneFormat,
});
const mesh = upload(renderer.device.device, box({ width: 0.6 }), { instances });
scene.add(new MeshNode({ name: 'grid', mesh, material }));
// renderer.stats.drawCalls === 1, for any count.The per-instance stream is bound to vertex slot 1 with stepMode: 'instance', so
it works in the compatibility profile where a vertex-stage storage buffer does not.
firstInstance and instanceCount narrow the range. Both are checked against the
buffer's real size before the draw, so a range that runs off the end is an apse
error and not a driver validation error at the first frame.
bun run build on an Apple M3, 2026-09-28. Every entry is a real bundle esbuild
produced from the real source, and scripts/size-gate.ts fails the build over a
ceiling on every one of them.
| entry | files | raw | gzip | brotli | ceiling | used |
|---|---|---|---|---|---|---|
apse (root) |
16 | 284.21 KB | 95.87 KB | 78.87 KB | 110.3 KB | 87% |
apse/render |
12 | 206.18 KB | 69.95 KB | 58.14 KB | 80.4 KB | 87% |
apse/material |
9 | 165.01 KB | 56.04 KB | 47.29 KB | 64.4 KB | 87% |
apse/geometry |
7 | 92.62 KB | 32.64 KB | 27.51 KB | 37.5 KB | 87% |
apse/scene |
6 | 43.88 KB | 16.34 KB | 13.71 KB | 18.8 KB | 87% |
apse/math |
6 | 30.66 KB | 11.66 KB | 9.52 KB | 13.4 KB | 87% |
apse/core |
5 | 22.37 KB | 8.74 KB | 7.42 KB | 10.1 KB | 87% |
| tree-shaken app | 1 | 205.91 KB | 71.23 KB | 59.24 KB | 81.9 KB | 87% |
The tree-shaken row is the headline: a complete renderer, camera, PBR material,
scene graph and animation loop, from one import, importing eight symbols out of
179. The three.js comparison point is 133 KB gzip for a single PBR cube
(THREE_JS_GZIP_BASELINE_KB in scripts/size-gate.ts, its own published build).
71.23 against 133 is 1.87×.
src/index.ts went from 107 exported names to 179 this pass — 72 added, none
removed, of which 130 are runtime values and the rest are types. Most of what was
added already existed and was reachable from a subpath but not from the root:
capsule, cone and roundedBox beside the first six primitives;
mergeMeshes / uploadBatch; computeTangents / withTangents; GpuTimer;
CaptureReadback; requireFeature / readCapabilities; ERROR_BLAME; and the
three new materials. Reachability is the change, not novelty.
This number used to be 2.9×, and reporting 1.87× is the honest version. The present pass and the timestamp layer cost 26 KB — 45.3 KB to 71.23 KB. That is the price of the two things listed under bugs this pass, and paying 26 KB to stop presenting a black screen and to stop reporting a GPU that was never measured is a trade most people would take. A README claiming 2.9× would survive exactly as long as it took somebody to run the build.
Tree-shaking is asserted, not inferred from a byte count. bench/tree-shake.ts is
a real minimal app bundled on every build, and scripts/size-gate.ts greps the
result for the five primitives it does not import and fails if any is present —
with a positive control (box must be found), so the check cannot pass vacuously.
The same script also asserts the module graph (every entry reaches a known set of
layers and a known module count) and that every exports subpath resolves to a
built file. 57 of 57 source modules are reachable from some entry.
Measured on an Apple M3, compatibility feature level, headless Chrome,
1280x720, p50 of 120 samples x 3 trials, three.js r186 on WebGL2 in the same
process on the same device. bun run bench:perf.
bun run bench, CPU frame time, median, same process on the same Apple M3:
| scene | apse p50 | three.js p50 | ratio | draws |
|---|---|---|---|---|
| 1,000 cubes | 0.400 ms | 0.600 ms | 1.5x faster | 1,000 |
| 2,000 cubes | 0.600 ms | 1.100 ms | 1.8x faster | 2,000 |
| 4,000 cubes | 0.900 ms | 2.000 ms | 2.2x faster | 4,000 |
| 5,000 cubes | 1.100 ms | 2.500 ms | 2.2x faster | 5,000 |
| 1,000 spheres | 0.300 ms | 0.700 ms | 2.3x faster | 1,000 |
100% pixel coverage, zero WebGPU validation errors, and the frame is visibly correct — per-face lighting, tone-mapped, shadows in the right places.
apse was 1.32x slower than three.js per draw call, and three separate profilers failed to find the cause in the right place.
The first diagnosis blamed two setBindGroup calls per draw. A recorded call
census showed the frame group was already suppressed to once per pass, so the
per-draw cost was one. A bind-group merge was implemented anyway — real work,
worthless as a fix.
The second blamed the counting sort. Replacing it with a comparator sort was 2.4-3.6x faster and moved the end-to-end ratio not at all.
The actual cause was a single word of type:
const _dynamicOffsets = new Uint32Array(1); // 1.22 us per setBindGroup
const _dynamicOffsets: number[] = [0]; // 0.28 us — identical callBlink's IDL conversion for sequence<> has a fast path that requires a real
v8::Array. A typed array falls off it onto the generic iterator protocol, and
every dynamic-offset bind paid for that. Measured with the queue drained, in
core and in compatibility alike.
Every call census in this repository was blind to it, because the call count
is byte-identical either way. The profiler that found it was not counting
calls; it was timing a single call with a queue drain. A fourth agent, working
read-only and forbidden from editing code, independently found the same wall from
the other side by measuring setBindGroup with and without a dynamic offset:
0.43-0.55 us against 0.00-0.08 us.
That is the shape of the bug. Nothing about it is visible in the source, in a profile tree, or in a call graph. It is in a type, and it took a benchmark that isolated one call from everything around it.
| objects | apse per-draw | apse instanced | three.js per-draw | apse instanced vs three.js per-draw |
|---|---|---|---|---|
| 1,000 | 0.740 ms | 0.012 ms (1 draw) | 0.675 ms | 6.3x |
| 10,000 | 7.250 ms | 0.013 ms (1 draw) | 5.750 ms | — |
| 100,000 | 86.675 ms | 0.012 ms (1 draw) | 65.725 ms | 70x |
There is no crossover point, because apse wins at every instance count measured. One draw costs the same whether it carries 1,000 instances or 100,000, so instanced CPU time is flat at 0.012 ms while the per-draw path grows linearly to 87 ms. The per-instance transforms arrive as vertex attributes, so the per-draw cost is not merely amortised, it is absent.
Coverage is byte-identical between the instanced and per-draw paths at every count — the same 1,845 lit pixels and 923 lit columns at 100,000 objects whether drawn as one instanced call or as 100,000 node draws. That is what makes the timing comparable rather than merely fast.
Where apse still loses: against three.js's own instanced path, 0.151-1.109 ms against 0.038-1.084 ms. That residual is the present pass plus fixed per-frame overhead, not per-draw cost, and it only matters when a frame is already a single draw call.
| scene | delta CPU | delta end-to-end |
|---|---|---|
| 1,000 cubes | +0.020 ms (2.6%) | +0.060 ms (6.4%) |
| 5,000 cubes | +0.081 ms (2.1%) | +0.150 ms (3.4%) |
| 5,000 instanced | +0.002 ms | +0.076 ms |
| 100,000 instanced | +0.002 ms | +0.064 ms (4.9%) |
A fixed cost, not a per-object one. It dominates a one-draw frame and is invisible on a thousand-draw frame. Coverage is identical with it on and off.
- There is no GPU-side millisecond anywhere in this section. This device
exposes no
timestamp-query, sostats.gpuisnulland every "end-to-end" figure is a CPU-side wait, not a timestamp query. GPU fragment cost is not separated from driver overhead. performance.now()is quantised to 100 us on this machine. The 0.012 ms instanced figure rests on batching 500 samples; rounding error there is about 0.0002 ms.- One machine, one Chrome build, headless, compatibility. The ratios are the finding; the absolute times are not portable.
- The 100,000-object instanced row is measured once, not established.
bun run bench:perfhas crashed headless Chrome at the top of that sweep withProtocol error (Runtime.callFunctionOn): Target closed. The 1,000 and 10,000 rows reproduce. The cause is not diagnosed. Seebench/diag/perf/README.md. - Instanced scenes were measured with static transforms. A moving instanced scene re-uploads 32.8 MB/frame at 5,000 objects and that path is unverified.
- Cubes and spheres only. One lighting model, no transparency, no MSAA.
apse is 1.5-2.3x faster per draw call and 70x faster once you instance, measured on one machine in one browser. Both directions are worth keeping in view, because they are different claims and the second is the larger one: at 1,000 separate objects apse wins by 1.5x, and at 1,000 instances of one mesh its CPU cost is flat at 0.012 ms however many you draw, against 0.675 ms for three.js issuing 1,000 draws.
The caveats below are real. These are p50 CPU times from one Apple M3 in
headless Chrome, with no GPU-side millisecond anywhere — this device exposes no
timestamp-query, so every "end-to-end" figure is a CPU-side wait and GPU
fragment cost is not separated from driver overhead. The absolute numbers are not
portable. The 100,000-object instanced row is measured once, not established:
bun run bench:perf crashes headless Chrome at the top of that sweep. Instanced
scenes were measured with static transforms, and a moving one re-uploads 32.8
MB/frame at 5,000 objects, which is unverified. Cubes and spheres, one lighting
model, no transparency, no MSAA.
And one of these numbers was wrong for a week. This file said apse was 1.3x
slower per draw until a Uint32Array turned out to cost 0.94 us a call in
Blink's IDL layer. Three profilers had looked at that loop before that. The
lesson is not "we are now fast"; it is that a benchmark which cannot be
re-run by a reader is not a measurement, and neither is a profiler counting
calls when the cost was in the argument.
Every number in this table has a harness behind it and a method next to it.
Bundle sizes: bun run build above. Heap: Chrome DevTools Protocol
HeapProfiler snapshots with GC forced, per-class slopes, bench/mem/run.ts.
Diagnostics: nine failure modes triggered for real in both libraries in one
headless Chrome on one GPU, bench/diag/run.ts. Authoring cost: the same material
written twice and counted, bench/dx/run.ts.
| axis | apse | three.js 0.186.1 | verdict |
|---|---|---|---|
| bundle, tree-shaken, full renderer + PBR + scene graph | 71.23 KB gzip | 133 KB | apse, 1.87× |
heap per drawable scene-graph object (MeshNode vs Mesh) |
1,060 B / 21.0 objects | 1,240 B / 57.0 objects | apse, 1.17× by bytes, 2.7× by object count |
| a failure mode that is structurally unrepresentable | 1 of 9 (a cross-stage varying mismatch) | 0 of 9 | apse |
| silent-vs-typed diagnostics, 9 scenarios | better 5 · worse 2 · equal 2 | — | apse, narrowly |
| custom material, BRDF written by hand | 80 lines | 83 (ShaderMaterial) |
tie |
| custom material, prebuilt BRDF, set properties | 5 lines | 5 (MeshStandardNodeMaterial) |
tie |
| materials shipped with a shading model | 5 | 8 | three.js |
| generated code per material | 77 WGSL lines from 30 authored | 1,984 lines from 6 authored (onBeforeCompile) |
apse |
| draw calls for 1,000 copies of one mesh | 1 | 1 (THREE.InstancedMesh) |
tie |
| ecosystem, loaders, animation, tooling | — | — | three.js, by a wide margin |
The failures that cannot be expressed, rather than diagnosed. You cannot compile
a material whose uniform block and its JS packer disagree, because one table
produces both. You cannot draw a material against a render target whose colour
format differs, because the renderer checks it before setPipeline — where
WebGPU's own answer is a validation error that invalidates the whole command
buffer and mentions two format enums rather than the mistake. A third, found by
the same harness: you cannot write a varying whose type differs between the vertex
and fragment stages, because one varyings declaration generates the struct for
both. Being unrepresentable beats being well-diagnosed, and the first row of the
table is the only place apse is categorically ahead.
Instancing is a tie, and it is worth saying so. three.js has had
THREE.InstancedMesh for years: 1,000 copies of one mesh is one draw call in both
libraries. What apse adds is that the path is in the compatibility profile — a
vertex buffer at slot 1, not a vertex-stage storage buffer — and that the
transform-only instance record is the caller's own Float32Array with no copy.
Neither of those has a benchmark behind it, so neither is claimed here.
Where apse loses, without hedging:
- Ecosystem. three.js has loaders, an editor, an inspector, a physics integration, a documentation site, and an enormous body of Stack Overflow answers. apse has none of these and will not have them soon.
- LLM training data. No model has been trained on apse's source. Anything non-obvious about this API, you are reading the repository, not recalling it.
- Material count, and the gap narrowed without closing. apse had two shipped
materials and now ships five with a shading model —
basic(unlit),pbr(Cook-Torrance GGX),diffuse(Oren-Nayar with an energy-conserving rim),emissive(a physically-shaped unlit emitter) andanisotropic(elliptical GGX in a tangent frame) — against three.js's eight. Of apse's five, two are unlit, so on materials with a lit BRDF it is three against eight. Three materials is real progress over two; it is not parity, and calling it that would be the same mistake this file is trying not to make. - No glTF loader.
MeshDatatakes interleaved data or one dense array per attribute. That is the whole geometry import surface. No OBJ, no texture decoders, no environment maps, no I/O. - No animation system. No keyframes, skinning, morph targets, or blend trees.
renderer.start(cb)is the whole story, and it hands youdtalready clamped. - No WebGL2 fallback, and there will not be one. A browser without
navigator.gputhrowsWEBGPU_UNAVAILABLE. The compatibility profile is supported; a compatibility fallback is not, because the scaffold generates WGSL and the alternative is a second shader language. - Against three.js's own instanced path apse is roughly level — 0.151-1.109 ms against 0.038-1.084 ms. The gap is the present pass plus fixed per-frame overhead, and it only shows up when a frame is already a single draw call, which is exactly when there is no per-draw cost left to amortise. This was measured before the dynamic-offset fix and is the one perf number here most likely to be stale; re-measure before quoting it.
They are listed because a reader is entitled to know which parts of a README were withdrawn, and because each withdrawal is a measurement somebody can repeat.
-
"A custom material is dramatically simpler." It is not. Writing the same two-light Lambert + Blinn-Phong material by hand: 80 lines in apse, 83 in three.js — a tie, on a material that renders the same image to within one 8-bit step. The scaffold's real win is not brevity, it is generated code: 77 WGSL lines from 30 authored, against 1,984 generated from 6 on
onBeforeCompile, and no#includenames, nocustomProgramCacheKey, nouserData.shader.uniforms.An earlier draft also claimed three.js's node-material path was "15 lines, which beats apse outright". That comparison was invalid and has been removed. Those 15 lines are property assignment on a
MeshStandardNodeMaterial— a complete, library-provided Cook-Torrance BRDF. The apse side of that comparison was asked to write the same BRDF by hand, which compared a prebuilt shader against a hand-written one. Measured fairly — a prebuilt BRDF, properties set — both libraries are about 5 lines. -
"An
Object3Dcosts 1,804 bytes." It does not. Measured with GC-forced Chrome heap snapshots and per-class slopes: 1,216 B for a bareObject3Dshell plus everything only it owns, and 1,240 B for a drawableMesh, against apse's 1,060 B. The 450 B figure that came next was not reproducible by any method — Bun'sprocess.memoryUsage().heapUsedcannot see this, and the proof is inbench/mem/README.md. The real difference is not the byte total. It is that three.js allocates 57.0 live heap objects per node to apse's 21.0, and 908 of apse's 1,060 B — 86% — is sixFloat32Arrays that would be one array if the design were finished. -
"42 typed error codes, and they are all reachable." Two were not:
VARYING_MISMATCHandSHADER_NO_ENTRYPOINT. Both were structurally unrepresentable rather than merely unused — onevaryingsdeclaration generates theVaryingsstruct for both stages, so the two cannot disagree, and the scaffold always emitsvsandfs, so a missing entry point is not a failure mode. Both are deleted; the guarantee comes from the type system instead, which is a stronger place for it. Two more were added in their place:RESOURCE_DISPOSEDandINVALID_USAGE.
42 codes, and every one carries a why, a fix and a link. Two of them were
deleted for being unreachable, so the count is the same and the surface is
smaller and truer.
Every code is also classified by blame, in a mapped table that cannot drift
from the code list, and exposed as ERROR_BLAME:
| blame | count | codes |
|---|---|---|
library |
1 | INTERNAL_INVARIANT |
caller |
37 | everything a user can do wrong |
environment |
4 | WEBGPU_UNAVAILABLE, ADAPTER_UNAVAILABLE, DEVICE_LOST, DEVICE_REQUEST_FAILED |
A handler that treats INTERNAL_INVARIANT as the general-purpose "something went
wrong" bucket files its users' typos under "apse is broken", which is the one
thing a typed catalog must not do. That was measurably happening: a previous audit
found 55 of 140 fail() call sites raising a code whose guidance reads "This
always indicates a bug in apse, not in your code." Re-counted against the source
today it is 71 of 203 call sites. Branching on e.blame instead of on a code
slug is the supported answer, and the count is the thing to watch.
One gap, stated rather than hidden: the blame split is only as good as the
super() call that names the resource. Material and GpuMesh both call
super() with no code, so they inherit the INTERNAL_INVARIANT default — drawing
a disposed material today still raises a library-blamed code, and MESH_DISPOSED
is not raised anywhere yet. The mechanism exists (Resource.assertLive takes the
code as a parameter, and it is tested); passing the code is a one-line change in
each subclass that has not been made. See Limits.
Named because they are the credibility story, and because a reader deciding whether to trust a number on this page should know what else was wrong when the number was taken. Each is fixed and each has a test that fails on the old code.
- A capsule with all 168 triangles wound inside-out. Every
capsulewas invisible from the outside and invisible from the inside, with a valid index buffer, a valid pipeline, and no validation error anywhere. Culling mode and winding order disagreeing is the classic silent-geometry failure. - An AABB pass with no finiteness check. A
NaNposition propagated through the bounds computation and produced a finite bound that did not contain the mesh, which the frustum then rejected — silently dropping a visible object at cull time, with a draw list that looked correct and a scene with a hole in it.MeshDatanow rejects a non-finite vertex with the vertex index named. mergeMeshesnever renormalised baked normals. Correct only for rigid transforms, which is exactly the case you do not test: under a 2:1:1 scale a normal came out at length 0.5, and a normal of length 0.5 is a shading bug that still produces a plausible image. Merged normals are now renormalised.- The tone map emitted
vec4f(in.position, 0.0). That0.0is the clip-spacew— the divisor of the perspective divide.w = 0collapsed the fullscreen triangle to a point, nothing rasterised, and the result was a black screen with a valid pipeline, a legal draw, no validation error, and a unit test asserting the buggy literal. The1.0is load-bearing and now says so in a comment three lines long.
That last one is the reason the invariants section in ARCHITECTURE.md exists,
and the reason this file publishes a size regression rather than hiding it.
The usual way to customise a shader in a 3D library — three.js is the obvious
example — is to hand it a string and hope: override a chunk by its internal name,
then discover that the program cache key is a string join of a hundred fields the
docs never mention, plus a mandatory undocumented customProgramCacheKey escape
hatch. None of those chunk names is a stable API, and they change between
releases.
apse generates all of it. You write two statement lists and a description of what they need:
import { generateScaffold } from 'apse/material';
const shader = generateScaffold({
name: 'fresnel',
varyings: { normal: 'vec3f', worldPos: 'vec3f' },
slots: {
tint: { type: 'vec3f', default: [0.2, 0.7, 0.9] },
rim: { type: 'f32', default: 2.5 },
},
vertex: `
out.clip = frame.viewProj * obj.model * vec4f(in.position, 1.0);
out.worldPos = (obj.model * vec4f(in.position, 1.0)).xyz;
out.normal = normalize(obj.normalMatrix * in.normal);
`,
fragment: `
let n = normalize(in.normal);
let v = normalize(frame.camPos - in.worldPos);
return vec4f(mat.tint * pow(1.0 - max(dot(n, v), 0.0), mat.rim), 1.0);
`,
});
shader.code; // the complete WGSL programThat produces a program in which every declaration above the two entry points
was inferred from the spec — the Frame struct (14 fields), ObjectData,
MaterialData, the VertexIn struct, the Varyings struct, all three bind
groups, all three @group/@binding pairs, and both function signatures. You
wrote no @group, no @binding, no struct, and no fn. You cannot: a body
that declares a fn, a struct, or anything with an @attribute is rejected
before the shader is compiled. What a body gets to see is in, out, frame,
obj, mat, one variable per declared texture, and WGSL's builtin library.
i32, u32 and mat4x4f varyings get @interpolate(flat) emitted
automatically, because the alternative is a compile error. generateScaffold is a
pure function of the spec — same spec in, byte-identical program out — which is
what lets apse cache pipelines by a hash instead of rebuilding a hundred-field key
on every miss. Pass scaffold: true on any material to log the full program.
All five lighting materials go through exactly this path with no bypass.
basicMaterialSpec(), pbrMaterialSpec(), diffuseMaterialSpec(),
emissiveMaterialSpec() and anisotropicMaterialSpec() are plain MaterialSpec
objects you can read, diff, and copy. instancedMaterialSpec() is the sixth and
tonemapMaterialSpec() the seventh.
anisotropicMaterial needs a tangent attribute and no apse primitive produces
one — use computeTangents() or withTangents(). It refuses a layout without
one, naming the attribute, rather than letting a WGSL error point into generated
code.
Every error apse throws is an AseError with six fields of its own — code,
why, fix, link, blame, detail — and all six are present on every error,
five as strings and detail as undefined where a code has no structured
context. There is no code path that produces a bare Error.
import { generateScaffold, isAseError } from 'apse';
try {
generateScaffold({
name: 'typo',
slots: { tint: { type: 'vec3f', default: [1, 1, 1] } },
vertex: `out.clip = frame.viewProj * obj.model * vec4f(in.position, 1.0);`,
fragment: `return vec4f(frame.viewProjj.xyz * mat.tint, 1.0);`, // three j's
});
} catch (e) {
if (isAseError(e)) console.error(e.code, e.why, e.fix, e.blame);
}code is a stable slug — branch on that, never on message. why is the rule
that was violated. fix is the one corrective action, and it names the field and
the alternative. blame says whose fault it is. link is the docs anchor for
that exact code. A sixth field, detail, carries structured context where a code
has any: a numeric range, an attribute name, a byte offset. toJSON() gives you
all of it at once.
The identifier check runs before the shader is compiled, so a typo names the
field you got wrong instead of surfacing as a WGSL error pointing at a line in
generated code you never wrote. That is the single strongest thing apse does, and
it is measured: the same harness that found three.js's worst silent failure
(omitting customProgramCacheKey renders 12,412 pixels wrong with no diagnostic)
found apse's typo path naming the field and listing every alternative.
For a WGSL syntax error there is less to be proud of, and the same harness said
so: the quoted text came from WebGPU's error scope, which names the vertex stage
for a fragment error and carries no line or column, and the fix asserted that the
compiler's line number was useful when it was not. That is fixed —
Material.create now calls getCompilationInfo(), derives the stage from the line
rather than assuming it, and puts every diagnostic in why with its line, column,
and the source line quoted. The 5/2/2 score above is therefore the last measured
result and is stale in apse's favour; re-running bench/diag/run.ts is what would
let the table move, and nobody has done that yet.
The failure surface is finite, enumerable, and typed: ERROR_CODES is all 42,
ERROR_CATALOG is the why/fix pair for each, ERROR_BLAME is the
classification — all three exported, so you can read the complete list of things
that can go wrong without reading a line of apse's source.
GpuMesh and Material both extend Resource, which is reference-counted. A
resource starts at refCount === 1, held by whoever called upload() or
pbrMaterial(). A MeshNode takes a reference to what it draws for as long as it
is in a graph, so sharing one mesh across a thousand nodes needs no bookkeeping and
nothing can be freed out from under a node that still holds it.
const mesh = upload(renderer.device.device, box({ width: 1.4 }));
mesh.refCount; // 1 — yours
mesh.ref(); // 2 — a second, independently-owned holder
mesh.unref(); // 1 — nothing freed
mesh.unref(); // 0 — the vertex and index buffers are released nowunref()drops one reference. GPU memory is released at zero, and only at zero. Safe on an already-disposed resource.dispose()force-releases immediately, whatever the count. It is the "I know I am done" verb, and it is what scene teardown uses. On a shared resource it frees it for everyone, so reach forunref()unless you mean it.ref()returnsthis, so it assigns directly..disposedis readable, not only raisable. A consumer that can ask "is this still alive" does not have to provoke an error to find out.- Garbage collection is never relied on. Browser GPU object lifetimes are not deterministic, so nothing here waits for a finaliser. You release it.
renderer.dispose() releases the device, the render targets, and the shared frame
and object uniforms. It does not release your meshes and materials — those are
yours.
WebGPU only. There is no WebGL2 fallback, and adding one is not on the roadmap. A
browser without navigator.gpu throws WEBGPU_UNAVAILABLE with the reason in the
message.
- Works: Chrome/Edge 113+, Safari 26+ including iOS, Firefox 141+ on Windows and 147+ on Apple Silicon.
- Does not work: Firefox on Linux, Firefox on Android, Firefox on Intel Macs.
apse requests the WebGPU compatibility profile by default, and asks for core
only when the adapter advertises core-features-and-limits. That is the profile
that reaches the largest device base, and it is what sets the two limits the API is
shaped around:
- Zero storage buffers in the vertex stage. Compatibility mode targets GLES 3.1-class hardware, which has no read-write buffer in a vertex shader. Per-object data reaches the vertex stage through one large uniform buffer addressed with dynamic offsets and written once per frame, and per-instance data through vertex slot 1, not through a storage buffer.
copyTextureToBufferrows align to 256 bytes.renderer.capture()returnsbytesPerRowpadded to that, so read rows atbytesPerRow, notwidth * 4.unpadRows()does it for you. It renders to an offscreen target and copies out of it, because a WebGPU canvas has nopreserveDrawingBufferand reading the swapchain after present yields an empty image.
renderer.featureLevel is 'core' or 'compatibility'. readCapabilities(device)
returns the profile, the features actually present, and the limits; requireFeature
and hasFeature probe a named one. Optional features are detected, never required:
timestamp-query is on roughly 44% of devices.
Read this before planning around it. Everything here is absent, not rough.
- No glTF loader, no OBJ, no texture decoders, no environment maps, no I/O.
- No animation system. No keyframes, skinning, morph targets, or blend trees. Bounds are static, which is correct today and would not be with a skinned mesh.
- No WebGL2 fallback.
- No post-processing chain. There is a present pass and it tone maps. There is
no bloom, no FXAA, no grading, and no chain: the scaffold rejects more than one
targetsentry, because the generated fragment entry point returns a singlevec4f. A second channel is a second pass that reads the first target. - No shadow pass.
pbrMaterial({ shadows: true })is real — it declares adepth-2dslot, adds a 3×3 PCF loop, and needslightViewProjset — but apse never renders the depth map. You render it yourself and hand it over. Turning the option on without doing that gives a material whose shadow term samples nothing. - No compute. No compute pipeline, no storage buffer, no workgroup API.
- Five materials with a shading model against three.js's eight, two of apse's
being unlit, so three lit BRDFs against eight.
pbrMaterialhas one directional light and no IBL. If you need two lights and a fresnel rim, you write the shader. - One colour attachment per pass.
- No editor, no scene serialisation, no inspector.
- No mipmap generation. Nothing in apse generates mips today.
MATERIAL_DISPOSEDandMESH_DISPOSEDare catalogued but not yet raised.MaterialandGpuMeshpass no code toResource's constructor, so a disposed one still reportsINTERNAL_INVARIANT— alibrary-blamed code for acallermistake.GpuMeshdoes not callassertLiveat all. The mechanism and its tests exist; the twosuper(...)arguments do not.- The diagnostics scorecard has not been re-run. The two scenarios where
three.js's output was better — a WGSL syntax error's stage attribution, and
RENDERER_ALREADY_DISPOSEDbeing unreachable — have both since been fixed insrc/. Nobody has re-measured, so the table above still reads 5/2/2 and is stale in apse's favour. - The benchmark is incomplete. See Performance. No frame time in this README is current.
bun install
bun run verify # typecheck, tests, build, size gate — the whole gate
bun test # 830 tests, all headless
bun run bench # headless Chrome, apse vs three.js830 tests, 0 failing, tsc --noEmit clean, size gate passing. CI runs all of it,
and it is green-capable: it was red on every commit for a week because no job ran
bun install, so tsc failed on a missing type library before it had read a line
of apse's source. There is now an install step with --frozen-lockfile.
src/core/ errors, result type, reference counting, uniform packing, bind groups
src/geometry/ vertex layouts, GPU mesh upload, instancing, batching, tangents,
and nine primitives
src/material/ the scaffold, and the seven materials built through it
src/math/ vec3, mat4, quat, sphere, frustum
src/render/ device, targets, pipeline state, the frame loop, present, timing,
readback
src/scene/ nodes, the scene graph, cameras
bench/ the headless benchmark, the tree-shaking app, diagnostics, memory
examples/ a runnable lit cube
scripts/ the build, and the size gate
test/ unit tests over the pure layers, plus a fake GPUDevice
bun install && bun run verifyAGENTS.md is the directory page — it routes you to the right file and names the
invariants that are easy to break. ARCHITECTURE.md is the why, including the
frame-order diagram and the failure modes each design choice was chosen against.
The one rule that matters for anything you add: no claim without a measurement or a test behind it, and state the method next to the number. This README has withdrawn three claims that did not survive their own benchmarks and reports one size regression rather than the number it used to report. If you cannot source a number, leave the placeholder and say so — a missing number gets filled in, and a stale one gets trusted.
Pre-1.0. VERSION is 0.0.1 and the API will change. FrameStats.gpu becoming
number | null this pass is a preview of how that goes: the honest shape won, and
it cost a breaking change. The near-term roadmap is a post-processing chain, then
glTF, then the two super(...) calls the limits section is complaining about.
Until then it is a renderer with an unusually small bundle, an unusually small
heap, a real instancing path, and a material system that does not require you to
read anyone else's source to change it.