Revision 4. Pure API-design document: migration, versioning and rollout planning are intentionally out of scope — this issue describes the target API and its contracts, nothing else. This revision simplifies: exactly one options type per factory (the
*Descriptor/*Optionssplit is gone), real multithreading isOffscreenCanvas+ one agent that owns the whole GPU stack (nothing device-side crosses threads, so there is no clonability contract to design around), and every factory takes exactly one positional input aftergpu. Adjudication of the 12 follow-up review comments is in the companion comment; previous texts in the edit history.
vNext API: typed options, unified frames, and pipeline preparation
Motivation
0.3.0 has four verified design gaps (confirmed in packages/vgpu-api/src):
compute(...).dispatch()creates its own encoder and submits immediately. Queue order is preserved — consecutive submits to the sameGPUQueueexecute in submission order — but a dispatch issued inside a frame callback submits before the frame's own encoder, which is submitted when the callback returns. Program order and execution order diverge, and compute and render cannot be encoded, instrumented and submitted as one explicit frame.bundle()compiles missing pipelines synchronously in the hot path. There is no async preparation path, and bundle readiness is not observable.uniforms()is values-first / order-dependent, and the real unmet need — one shared buffer reused across pipelines — has no good public path. Resource ownership is latched by call order (VGPU-R1-OWNERSHIP-FLIP), an error class that should be unrepresentable.- Reusable modules must accept
gpuas a parameter even when everything they describe is static data: today a module cannot export "the bloom effect" as a value. After a device loss everything device-local dies — the only naturally surviving unit is plain data. The API should have a first-class name for it: one exported options type per factory, plain enough that a module with nogpuin scope can hold it in aconst, and re-instantiable as-is against a new device.
Design principles: pay-for-what-you-import (tree-shakeable free functions; no method facades — see Rejected); one word per concept, no second spellings; costs are visible in the API (byte updates vs identity updates, sync vs async compilation); WebGPU platform vocabulary wherever it exists.
Design summary
- One typed options object per factory — no new runtime primitive.
draw,effect,computeandbundleeach export exactly one input type (DrawOptions,EffectOptions,ComputeOptions,BundleOptions), taken in one positional slot aftergpu. That single type carries the module story by itself: a module with nogpuin scope cannot put a device-local resource intoexport const bloom = { … } satisfies EffectOptions, because it has nothing device-local to put there. Composition is plain object spread —effect(gpu, { ...bloom, bindings: { globals } })— never a second options argument with implicit merge precedence. - Unified frame —
f.compute()/f.copyBuffer()/f.raw()joinf.pass()in one encoder, one ordered command list, exactly onequeue.submit(). prepare()— async pipeline preparation over combinations (a render pipeline cannot exist without the target signature). One compilation policy for draws, effects, computes and bundles:pendingPipelines: "sync" | "skip" | "throw", resolved call site → frame → gpu default. The default is"throw": encountering an unprepared pipeline during synchronous encoding fails immediately and does not start compilation. Async compilation happens only when the application enters an async path: explicitprepare(), the standalonerenderOnce()/dispatchOnce()helpers, or background preparation selected by"skip"."sync"is the explicit exception: it creates pending pipelines inline and may stall. The policy is normally set once (atinit()or per frame); the per-call form exists only for exceptions.- Explicit resource ownership — fixed at construction via
{ values, bindings }:.set()writes bytes on instance-owned values (never rebuilds bind groups),.bind()swaps resource identity (rebuilds exactly the affected group). The order-dependent ownership latch becomes unrepresentable.
The API by example
0. Hello mesh — the minimal happy path under the strict default
import { init, surface, geometry, draw, prepare, frameLoop, clock } from "vgpu";
import { box } from "vgpu/scene";
const gpu = await init();
const screen = surface(gpu, canvas, { depth: true, sampleCount: 4 });
// depth/sampleCount on surface() are NEW in this design (0.3.0 has them on target() only)
const mesh = geometry(gpu, box());
const cube = draw(gpu, { shader: litWgsl, geometry: mesh });
await prepare(gpu, [{ draw: cube, target: screen }]); // the one async line the strict default asks for
frameLoop(gpu, (f) => {
cube.set("camera", { viewProjection: mvpFor(clock(gpu).time) }); // binding-scoped: names the owned WGSL binding
f.pass(screen, (p) => p.draw(cube)); // prepared → draws; unprepared → VGPU-PIPELINE-PENDING
});This is the canonical chain of the corpus (init → surface → clock → frameLoop, 10/21 projects) with exactly one addition: the prepare() line.
Normative — a
Surfacehas a frame-independent pipeline signature.prepare()and every other target-signature consumer derive{ colors: [surface.format], depth: depthFormatFor(opts.depth), sampleCount }from the surface's configuration, not fromgetCurrentTexture(). Preparing against a surface outsideframe()is legal and is exactly this example's path; only encoding needs the current texture. (0.3.0 derives a surface's signature from the current texture and therefore rejects this call withVGPU-SURFACE-NOT-IN-FRAME— this clause is the design change that makes the canonical happy path implementable.) Frame-independent does not mean lifetime-independent: a disposed surface's signature is not derivable — reading it (and compiling against it) throws, matching every other operation on a disposed object.
1. Reusable modules: options objects are plain typed data
// bloom.ts — a reusable module. No gpu, no wiring, no runtime constructor.
import type { EffectOptions } from "vgpu";
export const bloom = {
shader: bloomWgsl,
blend: "additive",
} satisfies EffectOptions;
// app.ts — instantiation happens at the app edge, where gpu lives:
import { init, surface, effect, frameLoop } from "vgpu";
import { bloom } from "./bloom";
const gpu = await init();
const s = surface(gpu, canvas, { depth: true, sampleCount: 4 });
const fx = effect(gpu, bloom); // one positional input
const hdr = effect(gpu, { ...bloom, bindings: { globals } }); // composition is object spreadThe type is the whole mechanism. There is one EffectOptions, not a data-only twin: a module that never receives a gpu has no device-local resource to smuggle into its exported constant, and the fields that can hold one (values, bindings) are only ever filled at the app edge. Validation happens at instantiation. Nothing here promises that an options object can be cloned or transferred across agents — see §8.
2. Unified frame: compute + copy + render, one submit
const sim = compute(gpu, simWgsl); // string | ShaderSource shorthand for { shader }
frameLoop(gpu, (f) => {
f.compute(sim, Math.ceil(particleCount / 64)); // same encoder as the pass below
f.copyBuffer({ source: staging, destination: uniformBuf }); // size defaults to source.size − sourceOffset
f.pass(s, (p) => {
p.draw(particles); // p.draw() accepts Draw | Effect
p.draw(fx);
});
}); // exactly ONE queue.submit() — program order IS execution orderf.copyBuffer({ source, destination, size?, sourceOffset?, destinationOffset? }) mirrors copyBufferToBuffer: offsets default to 0, and an omitted size defaults to source.size − sourceOffset (the source remainder), exactly as WebGPU defines it. WebGPU's validation is preserved verbatim, not softened: size, sourceOffset and destinationOffset must be multiples of 4, sourceOffset + size ≤ source.size, destinationOffset + size ≤ destination.size, and source and destination must be different buffers. A destination too small for the source remainder is an error, never a truncated copy. Texture copies (texture↔texture, texture↔buffer) go through f.raw().
f.raw((encoder) => …) is the escape hatch for anything else that must land in this command buffer and submission: texture copies, clearBuffer(), resolveQuerySet(), debug markers, external encoders, and future WebGPU commands vgpu does not wrap yet. It operates between managed passes, so there is no managed render-pass state cache to reconcile. Normative clause — encoder ownership, lifetime and legal boundary:
type BorrowedCommandEncoder = Omit<GPUCommandEncoder, "finish">;
f.raw((encoder: BorrowedCommandEncoder) => { … });- it encodes into the frame's existing
GPUCommandEncoder, preserving program order and the frame's single submit; - it is legal only while the frame's command encoder is open and no managed render/compute pass and no other
f.raw()callback is active. Calling it from insidef.pass(…)/f.compute(…), or reentrantly from anotherf.raw()callback, throwsVGPU-FRAME-ENCODER-LOCKEDbefore the callback is invoked — no borrowed encoder is handed out and nothing is encoded; - the callback is synchronous: it must not return a promise, and it must not retain the encoder past its own return — the borrow ends when it returns;
finish()is unrepresentable, not merely forbidden: the parameter type isOmit<GPUCommandEncoder, "finish">, so submission stays the frame's job;- any native render or compute pass begun inside the callback must also be ended inside it, before returning;
- no render-state cache invalidation or "state re-establishment" is implied. Render state belongs to a
GPURenderPassEncoder, not to the top-levelGPUCommandEncoder, so arawblock cannot perturb the state of vgpu's managed passes.
const stats = gpu.gpu.createQuerySet({ type: "timestamp", count: 2 }); // gpu.gpu is the GPUDevice
const resolved = gpu.gpu.createBuffer({ size: 16, usage: GPUBufferUsage.QUERY_RESOLVE | GPUBufferUsage.COPY_SRC });
frameLoop(gpu, (f) => {
f.raw((encoder) => { // between passes: what only f.raw() can express
encoder.pushDebugGroup("simulation");
encoder.clearBuffer(resolved, 0, 16); // no f.clearBuffer() exists — this is the path
encoder.resolveQuerySet(stats, 0, 2, resolved, 0); // app-owned query set: vgpu wraps none
encoder.popDebugGroup();
});
f.compute(sim, Math.ceil(particleCount / 64)); // same encoder, same submit
f.pass(s, (p) => p.draw(particles));
});frameLoop(gpu, (f) => {
f.pass(s, (p) => {
p.draw(particles);
f.raw(() => {}); // VGPU-FRAME-ENCODER-LOCKED — thrown before the callback runs
});
});Raw handles are reached through the .gpu accessor vgpu objects already ship — gpu.gpu is the GPUDevice, Texture.gpu a GPUTexture, Buffer.gpu a GPUBuffer, prepared.gpu the pipeline or bundle — and there is no second .raw spelling anywhere in the API. Buffer-to-buffer copies stay on f.copyBuffer(); f.raw() is for what vgpu does not wrap. f.raw() does not reach a Surface's current texture: that texture is frame-scoped and deliberately not exposed (there is no withSurface() — §4c), so surface readback remains surface.read() / surface.readFloats(). Persistent, user-visible Texture / Target handles are copyable through f.raw().
There is deliberately no interleavable p.raw() and no target-aware f.rawPass() in this design: handing out the real GPURenderPassEncoder inside a cache-managed pass makes vgpu's state unknowable (WebGPU has no state introspection or push/pop), and the alternatives — conservative invalidation, preservation flags, or a permanent wrapper over an evolving API — are all worse than waiting for a concrete need. The target-aware variant is tracked in #322 for a future version and must not block this design.
compute.dispatchOnce(n) remains for standalone use (own submit, explicit — see §7).
2a. Frame callbacks are synchronous
A Frame borrows one command encoder for exactly the dynamic extent of its callback. frame() and frameLoop() therefore accept synchronous functions only: the callback must finish recording before it returns, and a borrowed Frame is closed immediately afterwards.
A plain (frame) => void type is deliberately insufficient because TypeScript permits an async function returning Promise<void> wherever a void return is expected. The public callback type rejects every PromiseLike return while preserving ordinary block-bodied and expression-bodied synchronous callbacks:
type SyncReturn<R> =
R extends PromiseLike<unknown> ? never : unknown;
type FrameCallback<R> =
(frame: Frame) => R & SyncReturn<R>;
function frame<R>(gpu: Gpu, callback: FrameCallback<R>): void;
// frameLoop() uses the same FrameCallback<R> constraint.frame(gpu, (f) => f.pass(screen, (p) => p.draw(cube))); // OK
frame(gpu, async (f) => { // Type error
await loadAssets(); // Promise<void> is rejected
f.pass(screen, (p) => p.draw(cube));
});Async work happens before entering the frame:
const assets = await loadAssets();
await prepare(gpu, [{ draw: cube, target: screen }]);
frame(gpu, (f) => {
f.pass(screen, (p) => p.draw(cube));
});Runtime contract for JavaScript / any: if the callback returns a thenable, vgpu closes the borrowed Frame, discards that frame’s command encoder without submitting it, and throws VGPU-ASYNC-FRAME-CALLBACK. Any later call through an escaped Frame reference throws VGPU-FRAME-CLOSED. No partial frame is submitted.
This discard is the one explicit exception to the rule that a managed frame submits itself when its callback returns (§2): an async callback aborts the frame instead of submitting what it recorded — the frame is closed and its command buffer discarded, so the "one frame, one ordered command list, exactly one queue.submit()" guarantee is kept by submitting nothing at all rather than a prefix of the intended work.
2b. Most apps do not own the loop: external tickers are first-class
Real applications usually delegate the loop to an animation engine (GSAP, Motion, an XR session, a host framework). frame(gpu, cb) per tick is the primitive; frameLoop is convenience sugar for self-contained demos — the corpus already reflects this (8/21 examples run their own rAF loop).
// The animation engine owns the loop; vgpu renders one explicit frame per tick:
gsap.ticker.add((_time, deltaMs) => {
clock(gpu).advance(deltaMs / 1000); // manual clock: one tick per frame (existing contract)
frame(gpu, (f) => f.pass(s, (p) => p.draw(fx)));
});The strict compilation default matters most here: a hidden synchronous pipeline compile inside a third-party tick is a dropped animation frame in an engine the app does not control. Under the default policy that stall is impossible — an unprepared pipeline fails fast instead.
2c. Compute → render: the full data flow through a storage buffer
const particles = storage(gpu, N * 8, "read-write"); // shared storage buffer
const sim = compute(gpu, { shader: simWgsl, values: { sim: { dt: 0.016 } }, bindings: { particles } });
const dots = draw(gpu, { shader: particleWgsl, instances: N, vertices: 6, bindings: { particles } });
// storage-driven instanced draw: no geometry — vertices come from the buffer the kernel writes
await prepare(gpu, [{ compute: sim }, { draw: dots, target: screen }]);
frameLoop(gpu, (f) => {
f.compute(sim, Math.ceil(N / 64)); // writes `particles` — same encoder
f.pass(screen, (p) => p.draw(dots)); // reads `particles` — guaranteed to run after
}); // one submit; program order IS execution orderToday this exact flow requires two independent submits (dispatch() owns its encoder) — motivation #1 is this example, mechanically. Note vgpu deliberately does no cross-encoder hazard tracking beyond WebGPU's own usage scopes (it is not a render graph); the only aliasing guard is compute-side writable double-bind rejection, unchanged.
3. prepare() + pendingPipelines: readiness is explicit, policy is named
const particles = draw(gpu, { shader: particleWgsl, geometry: quads });
// Warm every pipeline before the first frame — combinations, because a
// GPURenderPipeline requires the target signature (platform fact):
await prepare(gpu, [
{ draw: particles, target: s },
{ draw: fx, target: s }, // `draw:` takes Draw | Effect — the same union p.draw() takes
{ compute: sim },
]);
// The policy is set ONCE — at init() or per frame. Per-call is for exceptions only:
frameLoop(gpu, (f) => {
f.pass(s, (p) => p.draw(particles)); // prepared above → draws.
}); // unprepared → VGPU-PIPELINE-PENDING (default policy)
// Exception, per call site — a streaming asset that should appear when ready:
p.draw(streamedMesh, { pendingPipelines: "skip" });
// Prototyping / porting escape hatch — opt in to inline compilation app-wide:
// const gpu = await init({ pendingPipelines: "sync" });Compilation paths (normative):
| Entry path | What happens when the required pipeline is not ready | Encoding / submission |
|---|---|---|
await prepare(gpu, requests) | Uses createRenderPipelineAsync() / createComputePipelineAsync() and waits for readiness. | Prepares only; it does not submit a frame command buffer. |
await renderOnce(...) / await compute.dispatchOnce(...) | Uses the corresponding async pipeline creation path, then continues once ready. | Own encoder and exactly one submit; resolves after submit, not after GPU completion. |
Synchronous encode with "throw" (default) | Does not start compilation; throws VGPU-PIPELINE-PENDING immediately. | The affected command is not encoded. |
Synchronous encode with "skip" | Starts or continues async compilation in the background and returns immediately. | The affected draw, dispatch or bundle replay is omitted from this frame. |
Synchronous encode with "sync" | Uses immediate synchronous pipeline creation inline; a stale bundle is re-encoded inline. | The command is encoded in the current frame, but the call may stall. |
An already-prepared combination always reuses its cached pipeline; none of these paths creates another one.
Policy semantics (one chain for draws, effects, computes and bundles — call site → frame → gpu default):
"throw"(the default — uniform across browser, mock and node; no dev/prod divergence): does not start compilation and throwsVGPU-PIPELINE-PENDINGwith an actionable message ("await prepare(gpu, [{draw, target}]) before drawing, or opt in to inline compilation with pendingPipelines: 'sync'"). Synchronous encode contexts never compile pipelines implicitly — a compilation stall is a choice, never an accident. Tests simply callprepare(), which is instant on the mock device."skip": the object is skipped this frame; async compilation is started/continued. A compilation failure moves the object tofailed, is reported once viagpu.onErrorwith a stable code, and the object keeps being skipped — it never throws per frame. A skipped compute producer does not cascade: consumers read the resource's previous contents (or its zero-initialized state). vgpu is not a render graph and does not pretend to have the dependency graph to do otherwise."sync"(explicit opt-in): do the pending work inline — immediate WebGPU pipeline creation, and for astalebundle a re-encode; may stall.
3b. What prepare() returns: one handle per requested combination
const [cube, sim] = await prepare(gpu, [
{ draw: cubeDraw, target: screen },
{ compute: simulation },
]);
cube.draw; // the renderable, echoed back — the handle identifies the combination
cube.signature; // resolved TargetSignature { colors, depth, sampleCount }
cube.gpu; // GPURenderPipeline — the same-agent low-level escape hatch
sim.gpu; // GPUComputePipeline (a compute request has no target signature)
const prepared = await prepare(gpu, { bundle: forest }); // single request → single handle
prepared.gpu; // GPURenderBundletype PrepareRequest =
| { draw: Draw | Effect; target: CompileTarget }
| { compute: Compute }
| { bundle: Bundle };
type PreparedDraw = { readonly draw: Draw | Effect; readonly signature: TargetSignature; readonly gpu: GPURenderPipeline };
type PreparedCompute = { readonly compute: Compute; readonly gpu: GPUComputePipeline };
type PreparedBundle = { readonly bundle: Bundle; readonly signature: TargetSignature; readonly gpu: GPURenderBundle };
type PreparedFor<R> =
R extends { compute: Compute } ? PreparedCompute :
R extends { bundle: Bundle } ? PreparedBundle : PreparedDraw;
function prepare<const R extends PrepareRequest>(gpu: Gpu, request: R): Promise<PreparedFor<R>>;
function prepare<const R extends readonly PrepareRequest[]>(gpu: Gpu, requests: R): Promise<{ [K in keyof R]: PreparedFor<R[K]> }>;Rules:
- Handles are data, not state. There is no
prepared.status:prepare()rejects if any requested combination fails, withVGPU-PREPARE-FAILEDenumerating every failure (renderable label + resolved signature +cause). Combinations that did compile stay cached, so re-preparing the good subset is free. Ignoring the result stays valid — the happy path is unchanged. - The array form preserves request order and tuple inference; the single-request form returns one handle.
prepared.gpuis the only low-level pipeline escape hatch (see the readiness rule: no per-objectcompile()/pipelineFor()spellings, and nocompute.pipelinefield), and it is same-agent only — like every device-local object.
4. Render bundles: record once, replay cheap, readiness observable
const forest = bundle(gpu, { target: s }, (p) => {
for (const tree of trees) p.draw(tree); // hundreds of draws, recorded once
});
forest.status; // "pending-pipelines" | "ready" | "stale" | "failed" | "disposed" (frozen union)
forest.error; // the retained VGPUError while status === "failed"
await prepare(gpu, [{ bundle: forest }]); // materializes the native GPURenderBundle
frameLoop(gpu, (f) => f.pass(s, (p) => p.bundles(forest)));
fx.set("params", { intensity }); // byte update: bundle stays "ready"
fx.bind("input", nextTexture); // identity update: bundle goes "stale"
await prepare(gpu, [{ bundle: forest }]); // re-encodes the retained logical recording; no rebuild()
forest.rebuild((p) => { for (const t of trees) p.draw(t); }); // only when the command list itself changes
// rebuild() is SYNCHRONOUS (it is called at the edge of a synchronous frame callback); it replaces the
// logical recording, clears any retained error from the previous recording, and leaves the bundle
// "stale", or "pending-pipelines" if the new recording introduces an uncompiled combination.
await prepare(gpu, [{ bundle: forest }]); // the ONE spelling for awaiting bundle readinessRules: bundle() captures a logical command list — the native bundle is materialized by prepare() (or by a "sync" replay, which may compile inline and is exactly the stall this feature exists to avoid; the supported path is prepare()). An identity change only makes the native bundle stale: prepare() re-encodes the retained logical recording with current resources. rebuild() is reserved for replacing the logical command list itself. Replaying a non-ready bundle follows the pendingPipelines chain — never a silent hot-path compile. The full transition table is in API contracts. Recording a bundle needs only formats — never getCurrentTexture() — so bundle(gpu, { target: surface }, …) outside frame() is legal, exactly like prepare(). Under the "throw" default, construction records the logical command list without compiling: the bundle is born "pending-pipelines" and prepare() materializes it. Construction never throws for pending pipelines.
4b. Render-to-target: multi-pass chains, and why you bind the Target
const scene = target(gpu, { size: [512, 512], format: "rgba16float", depth: true });
const post = effect(gpu, {
shader: postWgsl,
values: { params: { texel: scene.texelSize } },
bindings: { src: scene }, // ← bind the TARGET, not scene.color (rule below)
});
await prepare(gpu, [{ draw: sceneDraw, target: scene }, { draw: post, target: screen }]);
frameLoop(gpu, (f) => {
f.pass({ target: scene, clear: [0, 0, 0, 1] }, (p) => p.draw(sceneDraw));
f.pass(screen, (p) => p.draw(post));
});
// Resize: the binding needs nothing — a Target binding auto-heals:
scene.resize(newSize);
// the binding re-normalizes to the new texture identity. A bundle recorded over `post` moves to "stale"
// through the same identity-change event. Its logical recording is still valid: `prepare(gpu, { bundle })`
// re-encodes it with the new texture before the next replay; no `rebuild()` is needed.
// Bindings auto-heal; native bundles require explicit preparation.Bind the
Target, not its texture snapshot. Binding theTargetobject wires resource-recreation events: resizes re-bind transparently and dependent bundles are marked stale. Binding.color(aTexturesnapshot) has no recreation hook — it goes silently stale on resize. This formalizes behavior 0.3.0 already implements but its own example corpus gets wrong; under this design.bind("src", scene)is the canonical spelling.
4c. Surface is a presentation destination; Target is a bindable resource
const screen = surface(gpu, canvas, { depth: true, sampleCount: 4 });
const scene = target(gpu, { size: screen.size, format: "rgba16float", depth: true });
const tonemap = effect(gpu, { shader: tonemapWgsl, bindings: { source: scene } }); // ✅ Target: persistent identity
const wrong = effect(gpu, { shader: tonemapWgsl, bindings: { source: screen } }); // ❌ VGPU-SURFACE-NOT-BINDABLE
frameLoop(gpu, (f) => {
f.pass(scene, (p) => p.draw(world));
f.pass(screen, (p) => p.draw(tonemap));
});Normative distinction.
- Both are render destinations: accepted by
f.pass()and byprepare()as a target signature.RenderDestinationis the shared supertype (size, formats, sample count, clear color, resize, render-pass descriptor). Targetadditionally has persistent resource identity: it exposes.color/.colors/.depth, may be used as a texture binding, and auto-heals bindings across resize/recreation.Surfaceis presentation-only and is NOT a texture binding.Surfacedoes not extendTarget; passing one as a binding fails withVGPU-SURFACE-NOT-BINDABLE. Its presentation texture is frame-scoped: it may change every frame, so a bind group built over it would retain a view of a texture that is already invalid, and any bundle recorded over it would be continuously stale. There is nowithSurface()/ current-texture borrowing API.- Readback from a surface stays an explicit copy, not a binding:
surface.read()/surface.readFloats()(the canvas is configuredCOPY_SRC).f.raw()is not a second path to it — the current texture is never handed out, so a borrowed encoder cannot copy it; raw texture copies apply to persistent, user-visibleTexture/Targethandles. - Resize invalidation is by signature, not by identity. A resize that preserves color format, depth format and sample count does not invalidate a prepared pipeline or a render bundle — only a change to that render-pass signature invalidates the corresponding prepared combinations.
5. uniform(): one shared resource across pipelines
const globals = uniform(gpu, { time: "f32", resolution: "vec2f" }); // schema shape = OQ2
const scenePass = draw(gpu, { shader: sceneWgsl, geometry: mesh, bindings: { globals } });
const post = effect(gpu, { shader: postWgsl, bindings: { globals } });
globals.set({ time: t }); // ONE write — every pipeline bound to it sees the updateuniform() storage is zero-initialized (the WebGPU guarantee; corpus code already relies on it). No mandatory initial values. uniform() keeps its one-argument .set() because the receiver is the binding — that is not a second spelling of the instance-scoped form below.
6. Ownership fixed at construction: .set() is bytes, .bind() is identity
const fx = effect(gpu, {
...bloom,
values: { params: { intensity: 1 } }, // instance-owned uniform storage → .set()
bindings: { globals, input: sourceTexture, inputSampler: linearSampler }, // external → .bind()
});
fx.set("params", { intensity: 0.8 }); // bytes only. Never rebuilds a bind group; bundles stay valid.
fx.bind("input", nextTexture); // identity. Rebuilds exactly that group; bundles go stale.
globals.set({ time }); // externally bound: update the resource, not the instance.
fx.set("input", otherTexture); // ❌ VGPU-R1-EXTERNAL-BINDING — actionable error that
// names the resource to update instead..set() has exactly one form: binding-scoped. The first argument names a WGSL binding owned by the instance; the second is its value:
set<K extends OwnedValueBinding>(binding: K, value: ValueUpdate<ValueFor<K>>): this;
// ValueUpdate<T> = T extends struct ? Partial<T> : T- For a struct-typed binding the value is a partial: absent fields keep the last value written (merged on the CPU, then one rewrite of the complete struct).
- For a binding whose WGSL type is not a struct (
mat4x4f,vec4f,array<f32,N>), the value is the binding's complete value — there is no partial. - There is no object-wide form (
cube.set({ camera: … })), no member-name shortcut (post.set({ time: 0.5 })without namingparams), no batch alias and no single-binding overload. One spelling keeps updates intentional, removes thebinding-vs-memberambiguity (a runtime error today) and collapses N member writes into a single struct rewrite. - Externally shared resources are updated on the resource (
globals.set({ time })); identity changes are exclusivelyinstance.bind(binding, resource).
Rules:
- Ownership is declared once, at construction. The order-dependent latch (
VGPU-R1-OWNERSHIP-FLIP) is unrepresentable in this model. - A binding declared in the WGSL that does not appear in
bindingsis instance-owned (value-owned) by default; its storage is created at construction and zero-initialized.valuessupplies initial values and pins the type; it is not what creates ownership. That is why Example 0 can callcube.set("camera", …)without declaringvalues. Ownership is determined by construction in both cases — never by call order. .bind()dedupes by identity — re-binding the same resource is free.- Owned→external transitions are not supported: recreate the instance (a plain options object makes that cheap).
- Multiple
.set()calls before a submit do not snapshot: passes sharing an instance within a frame see the last value written (the writes land in the same buffer and all of them land before the singlequeue.submit()). For per-pass or per-draw values there isuniformArray+ dynamic offsets, where the buffer identity is bound once and each draw selects a slot:
const jumps = uniformArray(gpu, { dist: "f32" }, cascadeCount); // persistent slots, aligned stride
for (let i = 0; i < cascadeCount; i++) jumps.at(i).set({ dist: dists[i] });
const fx = effect(gpu, { shader: cascadeWgsl, bindings: { jump: jumps } }); // identity bound ONCE
f.pass(screen, (p) => {
for (let i = 0; i < cascadeCount; i++) p.draw(fx, { slots: { jump: jumps.at(i) } });
});slots is a per-draw encoding parameter, never .bind(): neither the buffer nor the bind group changes identity, so the instance rebuilds nothing and bundles do not go stale.
uniformArray contract:
uniformArray(gpu, schema, count): UniformArray // one buffer, `count` persistent slots
array.at(i): UniformSlot // stable, memoized handle: at(i) === at(i)
slot.set(value) // same semantics as the binding-scoped .set() (partial on structs)- Slot stride =
align(sizeof(schema), gpu.limits.minUniformBufferOffsetAlignment)(256 on most adapters): anf32occupies a whole slot. The padding cost is visible and documented; for many small values, group fields into one struct per slot. at(i)out of range → error with a stable code, not a clamp.at(i)returns a stable handle (one per slot): using it in the encode loop preserves handle identity. TypeScript validates the binding name and slot schema, but twouniformArrayinstances with the same schema are structurally compatible. At draw/dispatch encoding or bundle-recording time, vgpu therefore verifies thatslot.owneris the exact array bound to that binding; a mismatch throwsVGPU-UNIFORM-SLOT-OWNERbefore encoding the affected operation, including through JavaScript/unknownpaths.- A draw whose binding is a
uniformArrayand that receives no entry inslotsfails with a stable code (it does not silently fall back to slot 0). Consistent with the"throw"default: choices are explicit. - More than
gpu.limits.maxDynamicUniformBuffersPerPipelineLayout(8 by default) dynamic bindings on one instance → error with a stable code at construction, not at the first draw. - Storage is zero-initialized, like
uniform(). slotsis the same parameter in both encoding contexts:p.draw(inst, { slots })andf.compute(inst, count, { slots }). Dynamic offsets work the same way in a compute pass; a draw-only parameter would leave "N dispatches with different parameters in one submit" without an answer and grow a second spelling later.- Slots recorded in a bundle are part of the recorded command list.
bundle()recordssetBindGroup(group, bg, offsets)with the slots current at recording time; changingslotsafterwards produces no identity event and therefore does not move the bundle tostale— the bundle cannot observe it. Writing bytes into any slot is reflected on replay (same buffer). Rule: inside a bundle the per-draw slot is a recording constant; to vary the selection per frame, record each variant or do not use a bundle.BundleStatusgains no states. - Slot writes update CPU-side state and are visible before the next submission that consumes the array; repeated writes to the same value observe the last write. vgpu may coalesce adjacent or repeated writes, but the number and shape of native upload operations are implementation details, not public API.
7. One-shot paths: renderOnce / dispatchOnce
await renderOnce(gpu, s, (p) => p.draw(quad)); // standalone render, own encoder + one submit
await sim.dispatchOnce(1024); // standalone compute, own encoder + one submitdispatchOnce() is convenience for a compute operation that is independent of a frame loop: initialization, preprocessing, tests and headless work. If the compute pipeline is not ready, it awaits createComputePipelineAsync(), then creates its own command encoder and compute pass, records one dispatch and submits exactly once. An already-prepared pipeline is reused without compilation.
// Standalone work:
await sim.dispatchOnce(workgroups);
// Work that must share ordering and one submission with other commands:
frame(gpu, (f) => {
f.compute(sim, workgroups);
f.pass(screen, (p) => p.draw(result));
});dispatchOnce() is not the API for work that belongs inside frame(): its encoder and submit are independent. Use f.compute() for that case. Both one-shot helpers resolve after pipeline readiness and submission — never after GPU completion. Waiting for completion is explicit and separate: await gpu.settled(). The pendingPipelines policy controls synchronous encode contexts; one-shot helpers always use their async readiness path.
8. Workers: one agent owns the whole GPU stack
Multithreading is canvas.transferControlToOffscreen() plus a worker that runs all of vgpu — init(), surface(gpu, offscreenCanvas), factories, frames (OffscreenCanvas is already a SurfaceCanvas). Nothing device-side crosses agents: no device, pipeline, resource, encoder, command buffer or render bundle, and the API deliberately carries no clonability contract that would suggest otherwise. initFromDevice(device) adopts a device owned by another library in the same agent (ORT, WebLLM, a host engine) — it is not a cross-thread mechanism.
9. Device loss is terminal: the app rebuilds, vgpu restores nothing
async function createGpuState(canvas: HTMLCanvasElement) {
const gpu = await init();
const screen = surface(gpu, canvas, { depth: true, sampleCount: 4 }); // same canvas, new device
const fx = effect(gpu, bloom); // GPU-independent module options: reused as-is
await prepare(gpu, [{ draw: fx, target: screen }]);
return { gpu, screen, fx };
}
let state = await createGpuState(canvas);
await state.gpu.lost; // proactive: resolves even if the app is idle
state = await createGpuState(canvas); // rebuild everything device-localDevice-loss contract. Device loss is terminal for the
Gpuand for every object created from it. vgpu never restores, reattaches or re-points an existingSurface,Target, texture, buffer, uniform/storage resource, geometry, instance, bind group, prepared pipeline or bundle onto a replacement device. On loss vgpu stops every frame loop it owns, marks the whole device-local object graph terminal, releases the CPU bookkeeping it can, and from then on every operation on that graph throwsVGPU-DEVICE-LOST—fx.set()/fx.bind(),frame(gpu, cb)/frameLoop(),prepare(), a bundle replay,target.read(). The application must recreate:Gpu; surfaces/targets; textures, buffers, uniform/storage resources;Draw/Effect/Computeinstances and their bind groups; prepared pipelines and render bundles. What is reusable is application data, not vgpu objects: GPU-independent configuration values and retained CPU-side state may be reused when constructing the new GPU subsystem, while any options value that holds a device-local object must be reconstructed —{ ...bloom, bindings: { globals, input: oldTexture } }is not reusable against a new device, and neither is avaluesentry carrying aTargetor a geometry buffer. That is ordinary application data reuse, not a vgpu restoration mechanism. The canvas itself is reused — the new device reconfigures its context.gpu.lostis a promise that resolves only on real device loss, with the platform'sGPUDeviceLostInfo(delivered independently ofgpu.onError, which reports operation failures); it is the terminal signal, and it never implies that individual vgpu objects are recoverable. An intentionalgpu.dispose()is not a loss:gpu.lostnever resolves after it — even thoughGPUDevice.destroy()resolves the nativeGPUDevice.lostwith reason"destroyed", vgpu deliberately suppresses that (the behaviorpackages/corealready implements via itsobserveLossguard,core/src/device.ts:136-143). This keeps the canonical recovery pattern (await gpu.lost→ rebuild) from firing a rebuild during teardown. Intentional disposal is observed synchronously throughgpu.disposed. There is deliberately noonLost()callback — one spelling per semantic.
Design rules
-
No second spellings. No new method may duplicate an existing one's semantics (
p.draw()covers effects — nop.effect();p.bundles()is canonical;gpu.losthas no callback twin). -
One call shape for the shader factories. Exactly one positional input after
gpu, and one options type per factory:function effect(gpu: Gpu, input: string | ShaderSource | EffectOptions): Effect; function compute(gpu: Gpu, input: string | ShaderSource | ComputeOptions): Compute; function draw(gpu: Gpu, input: DrawOptions): Draw; // no bare-string shorthand: a draw needs more than a shaderA bare
string | ShaderSourceis shorthand for{ shader }. Composing a module-exported options object with app-side resources is plain object spread —effect(gpu, { ...bloom, bindings: { input } })— never a second options argument with implicit merge precedence. This retires the two shapes that coexist today (draw(gpu, opts)with the shader inside the bag vs.effect(gpu, source, opts)/compute(gpu, source, opts)) and follows the pattern already shipped for geometry (geometry(gpu, GeometryOptions | GeometryRecipe)). -
The shader field is spelled
shader(readonly shader: string | ShaderSource) in all three factories — the spelling already shipped inDrawOptions.wgslis a second spelling of one concept and additionally lies when the value is a loaderShaderSourceartifact rather than WGSL text;codewould be a string-only platform word, andsourcecollides withcopyBuffer({ source }). -
A binding bound to a
uniformArrayis a dynamic-offset binding, and that is part of the pipeline signature.hasDynamicOffsetis decided at construction, from the kind of resource present inbindings, with per-binding granularity (not per group). Consequences: (1) bind-group-layout derivation stops being a pure function of the WGSL — it is a function of (reflection, set of dynamic-offset bindings), so the layout cache and the pipeline key must include that signature, and two instances of the same shader, one with auniformArrayand one without, are two different pipeline layouts; (2)prepare()prepares combinations of real instances, so the signature travels with the instance — its shape does not change, its cache key does; (3) the offset array passed tosetBindGroupcarries one offset per dynamic entry of the group, in binding order, and mixing a plainuniform()with auniformArrayin one group is valid, with only the latter dynamic. -
slotsis the only typed spelling for dynamic offsets.slots: { binding: array.at(i) }covers every vgpu-managed binding. The rawoffsetsknob (numbers per group) is exclusively for raw groups adopted throughclaimGroup()/draw.group(), where vgpu knows neither the layout nor the sizes. Disjoint domains, not two spellings: passingoffsetsfor a vgpu-managed group is an error with a stable code. -
Readiness is a property of a combination, never of an object. The unit of preparation is
(renderable, target signature): aDrawcan be ready for the screen, uncompiled for an HDR target and failed for adepth24plus-stencil8target at the same time. NoDraw/Effect/Computecarriesready/pending/failedfields, and no per-target status map is exposed:prepare()is the query (idempotent, O(1) on an already-prepared combination, and it rejects with the failure). Every other pipeline-affecting input (geometry vertex layout, topology/strip format, cull/front face, depth/stencil/multisample/blend state, pipeline constants, entry points) is fixed at construction of the renderable, so(renderable, signature)is a complete key — that is whyprepare()needs nogeometry:or state axis. The internal pipeline cache key is not public API (it embeds process-local module/layout ids). -
prepare()request keys mirror the encode call they warm.p.draw(Draw | Effect)→{ draw, target };f.compute(c)→{ compute };p.bundles(b)→{ bundle }. There is no{ effect }branch (thedraw:key takesDraw | Effect, exactly likep.draw()) and norender:spelling (no encode API is calledrenderat object granularity). A{ bundle }request carries notarget— a bundle froze its target signature at construction. -
One spelling for readiness:
prepare(). There is no per-bundle readiness promise, nodraw.compile()/compileSync(), nopipelineFor()and noDrawOptions.targetsprecompile list. Each of those is a per-object spelling of a per-combination fact, and the synchronous ones compile pipelines outside an async API — the exact behavior thependingPipelinesdefault forbids. -
compute()does not compile at construction.prepare(gpu, [{ compute: sim }])anddispatchOnce()create a missing pipeline withcreateComputePipelineAsync().f.compute()on an unprepared kernel follows thependingPipelineschain exactly like a draw: default"throw"fails without starting compilation;"skip"starts or continues async compilation and omits the dispatch; explicit"sync"uses immediatecreateComputePipeline()inline and may stall. The publiccompute.pipelinefield is replaced byprepared.gpu— one spelling for the same-agent low-level escape hatch. -
A
Surfacehas a frame-independent pipeline signature — derived from its configuration, not fromgetCurrentTexture()(normative clause in Example 0). Preparing against a surface outsideframe()is legal; only encoding needs the current texture. -
surface({ depth: true })resolves todepth24plus— the same ruletarget()uses (depthFormatFor). The depth format is part of the pipeline signature; changing it invalidates prepared pipelines. -
sampleCountis the one spelling for multisampling on bothsurface()andtarget()(WebGPU platform vocabulary). 0.3.0'starget({ msaa })is renamed —msaais not a second spelling. -
Depth and MSAA on
surface()are new surface area, not a passthrough oftarget(): 0.3.0'sSurfacehardcodesdepth: undefined/sampleCount: 1and emits color-only render passes (surface.ts:146-147,181-188). The design requiresSurfaceto own its internal depth/multisample attachments, recreate them on resize/auto-resize, and resolve MSAA into the presentation texture;depth: trueresolves through the shareddepthFormatFor(target-utils.ts:35-36). -
Transient attachments are automatic, not a flag. No public
transientoption: a transient attachment cannot be sampled or copied, and a public flag invites exactly that misuse.surface()/target()may allocate an internally-managed attachment withTRANSIENT_ATTACHMENT(feature-detected) only when the attachment satisfies all four conditions at allocation time: (1) vgpu allocated it and it is unreachable through every public accessor (.color,.colors,.depth,read(),readFloats(), and any binding path); (2) it is used exclusively as a render attachment — never sampled, copied, or bound; (3) every pass that can write it usesstoreOp: "discard"/depthStoreOp: "discard"; (4) no public option can cause it to be loaded (preserve/loadOp: "load"/depthReadOnly). The predicate must be decidable when the texture is created — usage flags are immutable, so "nobody will preserve it later" is not something the implementation may assume. Under 0.3.0's shape this leaves only the intermediate multisample attachments of an MSAA target (alreadystoreOp: "discard"/depthStoreOp: "discard",target-utils.ts:72,88). Explicitly not transient: a single-sampledepth: trueattachment (target.depthis public and bindable,target.ts:49;preserveloads it,target-utils.ts:88;depthReadOnlyreads it across passes,target-utils.ts:79-87), and the canvas color texture (configuredTEXTURE_BINDING | COPY_SRC,surface.ts:279). Whether the optimization is applied at all is an implementation detail; the normative part is the predicate that forbids it everywhere else. -
gpu.settled()is a snapshot, and it is the only place GPU completion is observable. At the moment of the call it snapshots everything vgpu has already started and resolves when all of it has finished:- queue completion —
GPUQueue.onSubmittedWorkDone()for every submission made before the call (unconditionally, not only when other bookkeeping happens to be in flight); - pipeline compilation already started (
prepare(),"skip"background compiles); - readback mapping already started by vgpu (
target.read(), timer/occlusion query rings); - asynchronously reported errors — pending
gpu.onErrordeliveries are flushed before it resolves, so a caller mayawait gpu.settled()and then read the errors it collected.
Deliberately excluded: work submitted, compiled or requested after the call (the snapshot does not extend), and any readback the application did not start.
settled()never rejects — failures are reported throughgpu.onError; it is a completion/timing signal, not an error channel. Calling it twice is meaningful: each call takes a new snapshot. - queue completion —
-
Feature-gated platform wins (subgroups, immediates) are internal: no public option, options field or capability flag exposes them, and none is ever assumed available.
-
Indirect draw arguments are not public API. How the implementation batches them (aggregation in shared buffers, where it uses them at all) is free to change; no user-visible type refers to an indirect buffer.
-
Resource indirection keeps a future bindless migration non-breaking — user code refers to resources by binding name, never by table index. Bindless and multi-draw-indirect are not promised (not in standard WebGPU).
Rejected alternatives (design rationale)
| Rejected | Why |
|---|---|
gpu.* method facade (any variant, including a curated core) | Methods on a runtime-constructed object are not statically analyzable bindings — bundlers cannot tree-shake them (measured: 0.62% savings for the historic facade). Corpus data: only init/surface/clock/frameLoop are pure app-layer, one call each per app — a facade optimizes 4 one-shot lines while breaking pay-for-what-you-import |
Dual data-only/instantiation types per factory (*Descriptor ⊂ *Options, with a structuredClone contract) — the shape of revision 3 | Designing the input types around crossing threads pays a permanent API tax for a scenario that is not the supported multithreading model: real multithreading is OffscreenCanvas + one agent owning the whole GPU stack, where nothing needs to cross. One options type per factory keeps the two motivations that matter (a module with no gpu cannot hold a device-local resource; a GPU-independent options object can be reused to re-instantiate against a new device) with half the surface, no cloneability promise to police, and no second name for one shape |
| Runtime "recipe"/options constructors | Typed plain objects deliver the module story with zero runtime and zero bundle cost; a runtime constructor either carries a non-clonable builder closure or requires a central kind registry (anti-tree-shaking) |
f(gpu, source, options?) (shader positional + separate options object) | Forces a module to split "the bloom effect" across a WGSL string and an app-side config object, breaking the module-authoring motivation, and requires unspecified merge-precedence rules between the two arguments. One positional input plus object spread gives the same unification with obvious precedence (last wins) |
Object-wide .set({ binding: … }) and member-name shortcuts | The key space becomes the union of binding names and member names — not precisely typable, and ambiguous names are a runtime error today. Binding-scoped .set() also collapses N member writes into one struct rewrite (one upload instead of N) |
| Per-draw auto-snapshot of owned values (implicit ring buffer) | Hides a per-draw copy in the hot path (against "costs are visible in the API"), is incoherent with a uniform() shared across pipelines (whose snapshot would the other pipeline see?), and does not even fix bundles, where recorded offsets are frozen. uniformArray + slots makes the cost explicit |
Material | Collides with the program() vocabulary reserved for scene shading (#219, SCENE_PROGRAM) |
gpu.clock | Duplicates clock(gpu) (one spelling per semantic) and pressures the minimal-kernel design (init-only stays ~1 KB) |
Async-only compilation (removing "sync") | The default remains strict: "throw" never starts compilation during synchronous encoding. Applications choose an explicit async path with prepare() / one-shot helpers, or background preparation with "skip". Inline compilation remains available only as the explicit "sync" escape hatch for prototyping and porting, where the possible stall is visible at the policy call site. |
render: as the prepare() request key | Every request key is the name of the encode call it warms (p.draw → draw:, f.compute → compute:, p.bundles → bundle:); render names no encode call at object granularity and would be a third word for what the factory, the type and the encode call already call draw |
prepared.status on the prepare() handle | Either prepare() rejects on failure and the field is a dead constant, or it resolves with broken pipelines and the happy path walks past a failure that reappears frames later in the encode path — exactly what the "throw" default exists to prevent. Handles are data; failure is the rejection |
Auto-healing a stale bundle on replay | Hides a re-encode in the hot path, against §4's own purpose ("record once, replay cheap"). Reversibility decides: strict today can be relaxed later, auto-heal cannot be tightened. The opt-in already exists — pendingPipelines: "sync" means "do the pending work inline", which for stale is a bounded re-encode |
onLost() callback alongside gpu.lost | Second spelling for one semantic |
gpu.restore() / resource.reattach() / generation-following proxy handles / automatic cross-device reconstruction of bind groups and bundles | Transparent restoration means a device generation inside every handle, mutable object identities, and async races between "restored" objects and work in flight against the dead device — a permanent tax on every code path for a rare event. It is also only ever partial: the authoritative contents of a storage buffer written by a kernel, a target's rendered pixels or a mapped readback existed only on the lost GPU, so vgpu would resurrect shapes with wrong data. The recovery boundary belongs to the application, which owns the CPU state that can actually reconstruct the graph; vgpu's contribution is the terminal signal (gpu.lost) and loud failures (VGPU-DEVICE-LOST) |
gpu.lost resolving after an intentional dispose() | The canonical recovery pattern is await gpu.lost → rebuild; resolving on teardown fires a device rebuild while the canvas is being unmounted. Intentional disposal is observable synchronously via gpu.disposed, and packages/core already suppresses the native "destroyed" settlement |
Public transient flag | Platform forbids sampling/copying transient attachments; automatic internal application captures the win without the footgun |
Interleavable p.raw() inside a managed pass | Handing out the real GPURenderPassEncoder makes vgpu's internal state unknowable — WebGPU has no state introspection or push/pop, so the only options are conservative invalidation, unsafe preservation flags, or a permanent wrapper around an evolving API. f.raw() covers the need at encoder level; the target-aware convenience is deferred to #322 |
API contracts
These are normative invariants of the public API, stated so they can be falsified. How they are verified — fixtures, instrumentation, bundle-size measurement — is not part of this document.
-
A compute dispatch and a render pass recorded in the same
frame()produce exactly onequeue.submit(), in program order. -
After a completed
prepare()on a{draw, target}combination, encoding that combination in aframe()creates no pipelines: nocreateRenderPipeline/createComputePipelineis issued on the user-providedGPUDeviceduring encode. -
pendingPipelines: default"throw"everywhere including mock/node (uniform — no dev/prod divergence) and it does not start compilation;"skip"starts/continues async compilation, never blocks the encode path and never throws per frame;"sync"performs immediate pipeline creation inline and is the only synchronous-compilation opt-in; resolution chain is call site → frame → gpu. -
compute()performs no pipeline creation at construction. A missing compute pipeline is created throughcreateComputePipelineAsync()byprepare()/dispatchOnce()and by background"skip"preparation; explicit"sync"instead uses immediatecreateComputePipeline()during encoding.Computeexposes nopipelinefield. -
Every factory exports exactly one input options type (
DrawOptions,EffectOptions,ComputeOptions,BundleOptions) and no data-only twin of it; type-only imports (import type) contribute zero runtime bytes to the consumer bundle. -
Combination-scoped readiness: preparing
{ draw: d, target: a }does not make{ draw: d, target: b }ready, and a failure onbneither throws foranor mutates any state observable throughd. -
prepare()returns handles: the array form preserves order and tuple types;prepared.gpuis the same object the encode path uses;prepare()rejects withVGPU-PREPARE-FAILEDlisting every failed combination, and a subsequentprepare()of the succeeding subset performs no newcreateRenderPipelinecall. -
prepare()against aSurfaceoutsideframe()resolves (neverVGPU-SURFACE-NOT-IN-FRAME), and the resolved signature equals the one used when encoding the same surface inside a frame. -
.set()on a value-owned binding never rebuilds a bind group;.bind()rebuilds exactly the affected group;.bind()dedupes by identity;.set()of an externally-bound name fails withVGPU-R1-EXTERNAL-BINDING. -
.set()exists only in binding-scoped form: a non-struct binding accepts its complete value, naming a member without naming the binding is rejected — today a runtime error with an actionable message naming the owning binding; making it a type error requires typed shader reflection, which is exactly open question 2 (aShaderSourceis{ version, wgsl }and carries no binding info by design until a loader version bump) — and one call carrying N fields of a struct produces exactly one buffer write. -
uniform()shared across two pipelines updates both with one write; storage is zero-initialized. -
A binding bound to a
uniformArrayproduces a bind group layout entry withhasDynamicOffset: trueonly on that binding; two instances of the same shader, one with and one without auniformArray, produce different pipeline layouts. -
p.draw(inst, { slots })changes no identity: nocreateBindGroupis issued and an associated bundle staysready; omittingslotsfor a dynamic binding throws a stable code;at(i)is identity-stable (Object.is(a.at(2), a.at(2)));slotsis accepted identically byf.compute(inst, count, { slots }). TypeScript validates binding/schema compatibility, while an exact-owner mismatch (slot.owner !== boundUniformArray) throwsVGPU-UNIFORM-SLOT-OWNERbefore the affected draw, dispatch or bundle command is recorded. -
Slot stride is
align(size, minUniformBufferOffsetAlignment); exceedingmaxDynamicUniformBuffersPerPipelineLayoutthrows at construction. A slot write is visible before the next submission that consumes the array and repeated writes observe the last value; implementations may coalesce uploads, but native upload count and range selection are not API contracts. -
Bundle state machine.
BundleStatus = "pending-pipelines" | "ready" | "stale" | "failed" | "disposed"— frozen union. Semantics:pending-pipelines= pipelines still to compile (and the native bundle to encode);stale= pipelines compiled, the native bundle must be re-encoded from the retained logical recording — that re-encode isprepare(gpu, [{ bundle }])(or an explicit"sync"replay);rebuild()is never required to leavestale. Every public transition:From Event To — bundle(gpu, { target }, rec)pending-pipelines(always: the native bundle is not materialized at construction)pending-pipelines|stale|failedprepare(gpu, [{ bundle }])succeedsready(compiles missing pipelines, then encodes the native bundle)any non- disposedprepare()failsfailed, retaining the error inbundle.errorready.set()byte update on a captured resourceready(no transition)ready.bind()identity update, or a capturedTargetrecreating its texture (resize)stalepending-pipelines|stale|failedidentity update unchanged any non- disposedrebuild(cb)(synchronous)Clear bundle.error, then move tostale, orpending-pipelinesif the new recording introduces an uncompiled combination.rebuild()replaces the logical command list; it never compiles and never throws for readiness reasonsany dispose()disposed(terminal; releases the native bundle and the retained recording; a seconddispose()is a no-op)prepare()on areadybundle is a no-op.prepare()always retries afailedbundle — there is no "retryable failure" classification; during that retrybundle.errorremains available and the status staysfailed, then success clears the error and moves toready.rebuild()clears the retained error immediately because it replaces the logical recording to which that failure belonged.bundle.error?: VGPUErroris defined only while status isfailed.Replay of a non-
readybundle follows the onependingPipelineschain (call site → frame → gpu):"throw":pending-pipelines→VGPU-PIPELINE-PENDING;stale→VGPU-R3-BUNDLE-STALE(message namesprepare(gpu, { bundle }); norebuild()is required);failed→ the retained error is rethrown (causepreserved)."skip": the bundle is skipped this frame;pending-pipelinesstarts/continues async compilation;failedis reported once viagpu.onErrorand keeps being skipped;staleis skipped — never silently re-encoded."sync": do the pending work inline —pending-pipelines: compile + encode (the stall this feature exists to avoid);stale: re-encode from the retained recording (bounded: no compilation — this is the opt-in auto-heal);failed: does not retry, the retained error is thrown (retry is only throughprepare()).disposedalways throwsVGPU-BUNDLE-DISPOSED, under every policy.
-
A bundle recorded with
slots: at(0)replays with slot 0's offset even if it is later encoded withat(1); writing bytes into slot 0 is visible on replay. -
dispatchOnce()/f.compute()validate integer workgroup counts ≥ 0 with a stable error code. -
f.copyBuffer()withsizeomitted copiessource.size − sourceOffsetbytes; a range or 4-byte-alignment violation, orsource === destination, fails with a stable error code and encodes nothing. -
gpu.lostresolves without any intervening operation after a real device loss, and loss is terminal: every frame loop vgpu owns stops, and every subsequent operation on thatGpu's object graph —.set()/.bind(),frame()/frameLoop(),prepare(), a bundle replay,read()/readFloats()— throwsVGPU-DEVICE-LOST; no vgpu object is ever re-pointed at a replacement device, and no API exists to ask for that.gpu.lostdoes not resolve after an intentionalgpu.dispose()— it stays pending whilegpu.disposed === true. -
renderOnce()/dispatchOnce()use async pipeline readiness, own their encoder and submit exactly once; they resolve after readiness + submit and never awaitonSubmittedWorkDone.dispatchOnce()is for standalone compute; compute that belongs to aframe()usesf.compute()so it participates in that frame’s ordering and single submit. -
gpu.settled()awaitsonSubmittedWorkDonefor submissions made before the call even when no validation, compilation or readback is pending; it does not await work submitted after the call; it flushes pendingonErrordeliveries before resolving; it never rejects on a failed submission. -
Transient-attachment safety predicate (negative contract). No attachment reachable through a public accessor is ever allocated with
TRANSIENT_ATTACHMENT: a single-sampledepth: truetarget's depth attachment, a target's.color/.colors, and a surface's canvas texture never carry the flag. Where the implementation does apply it to a private discard-only multisample attachment, it is feature-detected and its absence changes nothing observable. Applying the flag is not a required behavior — the required behavior is that it never appears on an observable attachment. -
Surfaceis not a texture binding:effect(gpu, { shader, bindings: { src: surfaceInstance } })andinstance.bind("src", surfaceInstance)both fail withVGPU-SURFACE-NOT-BINDABLE, including through the JS/unknownpath — structural typing is not the enforcement mechanism. A resize that preserves color format, depth format and sample count triggers nocreateRenderPipelineand leaves bundlesready; changing any of the three invalidates the corresponding prepared combinations. -
frame()/frameLoop()reject callbacks whose inferred return type extendsPromiseLike<unknown>while accepting synchronous block- and expression-bodied callbacks. Through JavaScript orany, returning a thenable throwsVGPU-ASYNC-FRAME-CALLBACK, closes the borrowed frame and produces no submit — the single explicit exception to "a managed frame submits itself when its callback returns", and never a partial submit; use of a frame after its callback returns throwsVGPU-FRAME-CLOSED. -
f.raw()borrows the frame's open command encoder typed asOmit<GPUCommandEncoder, "finish">, sofinish()is not callable at the type level and the frame keeps exactly onequeue.submit(). It is legal only between managed passes — and so is every encoder-level command:f.compute()andf.copyBuffer()invoked inside an openf.pass()throw the same error. Invoked insidef.pass()/f.compute(), or reentrantly from anotherf.raw()callback, it throwsVGPU-FRAME-ENCODER-LOCKEDbefore invoking the callback, encoding nothing. This mirrors the platform: WebGPU §13.1 puts the command encoder in the"locked"state while a pass encoder is active, and any command issued in that state invalidates the whole encoder silently. The borrow ends when the callback returns synchronously: a native pass begun inside it must be ended inside it, and the callback must not retain the encoder (a thenable return is theVGPU-ASYNC-FRAME-CALLBACKcase of contract 24). NoSurfacecurrent texture is reachable through the borrowed encoder.
Out of scope
- Scene orchestration (#219 / #206 — this design only provides the shared
prepare/pendingPipelineschokepoint). - Build-time WGSL reflection in loaders (today's loader emits
{version: 1, wgsl}only; docs must not promise typed uniforms from the loader). - Interleavable
p.raw()/ target-awaref.rawPass()— tracked in #322. - Bindless / multi-draw-indirect (not in standard WebGPU; the resource-indirection rule keeps the door open).
- The 0.3.0 behavior bug where
preserve: trueon an MSAA target loads an attachment whosestoreOpwas"discard"(target-utils.ts:69-72) — a bug of the shipped release, tracked separately in #323; this design only prevents the class going forward, through the transient predicate's four decidable conditions. - Migration, versioning and rollout planning — deliberately not part of this document.
- Test strategy and verification tooling — the contracts above are stated as observable invariants; which fixtures, instrumentation or bundle-size measurements demonstrate them belongs to the implementation issue.
Open questions
- Exact shape of the
frameLoopownership hook (8/21 examples avoidframeLooptoday because it owns the present pass). The external-ticker positioning (§2b:frame()per tick is the primitive,frameLoopis sugar) may resolve this by demotion rather than by a new hook. uniform()/valuesschema shape:{ time: "f32" }literals vs reflection-derived (examples 5–6 are illustrative on this point).
Activity
25Review observation 1/7 — worker example and OffscreenCanvas
The descriptor-across-workers direction is sound, but the current example must not imply that a browser GPUDevice can be handed to another worker. WebGPU handles are not transferable/serializable today. OffscreenCanvas is transferable, but it is not a device.
Suggested example:
// main.ts
const offscreen = canvas.transferControlToOffscreen();
worker.postMessage(
{ type: "start", canvas: offscreen, effect: bloom },
[offscreen],
);// worker.ts — this worker is the GPU-owner agent
import { effect, init, surface } from "vgpu";
import type { EffectDescriptor } from "vgpu";
self.onmessage = async ({ data }: {
data: {
type: "start";
canvas: OffscreenCanvas;
effect: EffectDescriptor;
};
}) => {
const gpu = await init();
const screen = surface(gpu, data.canvas);
const fx = effect(gpu, data.effect);
};initFromDevice(device) remains useful when another library owns a GPUDevice in the same agent (for example ORT/WebLLM inside that worker). It is not a cross-thread adoption mechanism.
The portable statement should be: descriptors and CPU data may cross workers; the device, prepared pipelines, resources, encoders, command buffers, and render bundles stay in the GPU-owner agent.
Review observation 2/7 — scope of the descriptor goal
We do not need to force every factory descriptor, or every possible descriptor value, to be multithread-ready today. A better goal is to export complete, ordered, strongly typed parameter shapes that cleanly separate descriptive configuration from instantiation. That makes authoring clearer now and makes future worker-oriented APIs easier without introducing a new runtime recipe hierarchy.
Suggested contract:
- Every factory exports its complete
*Descriptortype. - Shader/pipeline descriptors composed only of structured-clone-compatible data can be sent with
postMessage()directly. - Descriptors/options containing device-local resources, DOM objects, callbacks, or native WebGPU handles are not promised to be cloneable.
- Validation still happens when a descriptor is instantiated.
- Documentation identifies which common descriptor shapes are data-only; it does not claim that TypeScript alone guarantees structured clonability.
I would replace claims such as “structuredClone-safe by construction”, “zero protocol”, “no version skew”, and “every exported descriptor type round-trips” with a narrower acceptance criterion:
Every factory exports its complete descriptor type without retaining runtime code through
import type. Canonical data-only shader/compute descriptors have focusedstructuredClone/postMessagefixtures. Documentation marks fields that introduce device- or agent-local state.
This preserves the useful EffectDescriptor example while avoiding a portability type hierarchy that the current corpus does not justify.
Review observation 4/7 — render-bundle lifecycle needs a complete state contract
The async-bundle direction is correct, but "ready" | "pending-pipelines" is not enough to describe the public lifecycle. Identity changes, compilation failures, disposal, and rebuilds are already observable events.
Suggested minimum state model:
type BundleStatus =
| "pending-pipelines"
| "ready"
| "stale"
| "failed"
| "disposed";The RFC should freeze these semantics:
bundle()initially captures a logical high-level command list. It cannot synchronously encode a nativeGPURenderBundlewhile required pipelines are pending.prepare(gpu, [{ bundle }])prepares every combination and then materializes the native bundle. After it resolves,status === "ready".- Replaying a pending bundle follows an explicit policy (
skip,throw, orsync); it must never silently introduce an unexpected hot-path compile. - A resource-identity update moves the bundle to
stale; byte updates to the same buffers do not. rebuild()should be async (or explicitly return a readiness promise), because the new logical recording may introduce a cold pipeline combination.- Compilation/recording failures need a stable error surface and a defined
failedstate. dispose()invalidates both the logical recording and native bundle.
It would also be valuable to include the decisive many-object example: a render bundle using uniformArray/dynamic offsets. Repeatedly calling .set(model) on one uniform buffer while recording does not snapshot a different value per draw; distinct persistent slots or dynamic offsets are required.
Review observation 5/7 — device-loss example must recreate every device-local object
The current example recreates gpu and fx but then calls prepare() with the old surface s. That surface belongs to the lost device and cannot be reused.
After device loss, the application must recreate at least:
GpuandSurface/targets;- textures, buffers, uniforms and storage resources;
Draw/Effect/Computeinstances and bind groups;- prepared pipelines and render bundles.
Only plain descriptors and retained CPU-side asset/state data survive.
Conceptually:
async function createGpuState(canvas: HTMLCanvasElement) {
const gpu = await init();
const screen = surface(gpu, canvas, { depth: true, sampleCount: 4 });
const fx = effect(gpu, bloom);
await prepare(gpu, [{ effect: fx, target: screen }]);
return { gpu, screen, fx };
}
let state = await createGpuState(canvas);
// Prefer a dedicated `gpu.lost` / `gpu.onLost()` contract.
await state.gpu.lost;
state = await createGpuState(canvas);gpu.onError is not necessarily a proactive loss notification: native WebGPU exposes device loss through GPUDevice.lost, and a wrapper that only notices loss on the next operation can leave an idle application unaware. The RFC should decide whether vgpu exposes gpu.lost, onLost(), or both, separately from ordinary error delivery.
Review observation 6/7 — correct the queue-ordering motivation
The unified frame is an important improvement, but the current motivation overstates the ordering problem. Consecutive submissions to the same GPUQueue already preserve queue execution order; an extra synchronization call is not normally required for a compute submit followed by a render submit on that queue.
The verified problems with today's isolated compute.dispatch() are instead:
- it creates a separate command encoder and submit;
- compute, copies, render passes, timers, and raw commands cannot be composed as one frame unit;
- it adds submission/encoding overhead;
- instrumentation, cancellation, and frame-level error handling cannot cover the combined workload;
- an API caller can more easily submit work in an unintended call order.
Suggested wording:
compute(...).dispatch()creates and submits an isolated command buffer. Queue order is preserved, but compute and render cannot be encoded, instrumented, cancelled, and submitted as one explicit frame. The unified frame provides one ordered command list and exactly onequeue.submit().
This keeps the rationale technically accurate without weakening the case for the feature.
Review observation 7/7 — API-by-example consistency and smaller contract gaps
The expanded examples are useful, but they expose several signatures and behaviors that should be tightened before treating them as final:
init(canvas)does not match the described/current API. Useawait init()followed bysurface(gpu, canvas), unless a new overload is intentionally proposed and justified.- If
surface({ depth: true })is accepted, document the concrete default depth format because it participates in the pipeline signature. particleCount / 64can be fractional; useMath.ceil(particleCount / 64)and document integer workgroup validation.f.copy(stagingBuf, uniformBuf)is underspecified: buffer copies need size and potentially source/destination offsets. Prefer an explicitcopyBuffer({ source, destination, size, sourceOffset?, destinationOffset? }), with arbitrary copies available throughf.raw().- Avoid redundant pass dialects.
p.draw()already acceptsDraw | Effect, sop.effect(fx)adds another spelling without new semantics. Likewise, choose one canonicalp.bundle/p.bundlesand one canonicalbundle(gpu, { target }, cb)signature. - The
prepare()example awaits readiness and then usespendingPipelines: "skip". After successful preparation,"throw"better asserts the invariant. Demonstrate"skip"separately with an actually streaming/unprepared object. - Define whether
"skip"starts async compilation, how failures surface, and what happens when a skipped compute producer has render consumers later in the same frame. - Describe
"sync"as “use immediate WebGPU pipeline creation; may stall” rather than promising where the block occurs. - If
renderOnce()/dispatchOnce()return promises, say whether they resolve after pipeline readiness and submission or after GPU completion. They should not implicitly wait forqueue.onSubmittedWorkDone(). uniform()needs an initialization rule: required initial values or documented zero initialization.- Add raw interop inside a render pass, not only at command-encoder level:
p.raw(rawPass => ...). After raw access, vgpu must treat state as unknown and rebind its pipeline, bind groups, vertex/index buffers and dynamic state before the next high-level draw. target({ transient: true })needs a narrower contract or a separate proposal. A transient attachment cannot also be sampled or copied, while a normalTargetexposes textures for those uses. It may fit internally managed MSAA/depth attachments better than a general target flag.
These are mostly local decisions, but resolving them in the examples will keep the examples from accidentally defining conflicting public APIs.
Review observation 3/7 — make .set() and .bind() strictly separate operations
I recommend taking the breaking change before release and giving these methods non-overlapping semantics. They represent different GPU costs and different render-bundle validity rules:
| Operation | Changes | Bind-group identity | Recorded bundle |
|---|---|---|---|
.set() | Bytes in an existing library-owned uniform buffer | Preserved | Remains valid |
.bind() | The buffer, texture/view, sampler, storage resource, or shared uniform assigned to a WGSL binding | Affected group is rebuilt | Becomes stale |
Suggested construction shape:
const fx = effect(gpu, bloom, {
values: {
params: {
intensity: 1,
},
},
bindings: {
globals,
input: sourceTexture,
inputSampler: linearSampler,
},
});Runtime updates then communicate their cost directly:
// Mutates bytes in fx's existing private uniform buffer.
// No bind-group rebuild; bundles remain valid.
fx.set("params", { intensity: 0.8 });
// Replaces resource identity.
// Rebuilds the affected bind group; bundles that captured it become stale.
fx.bind("input", nextTexture);
// `globals` is an externally bound shared uniform resource, so update the resource itself.
globals.set({ time, resolution: [width, height] });Recommended ownership rules:
valuescreates instance-owned uniform storage. Those bindings are updated throughinstance.set().bindingsattaches externally owned resources. Their identity is replaced throughinstance.bind(); their contents are updated on the resource itself (uniform.set(),storage.write(), compute work, etc.).- Ownership mode is fixed at instantiation instead of being latched by the first
.set()call. This removes the order-dependent ownership model andVGPU-R1-OWNERSHIP-FLIPfrom the normal path. - Calling
instance.set()for an externally bound uniform should fail with an actionable message such as:globals is externally bound; update it with globals.set(...). - Calling
.set()with a texture, sampler, target, buffer, shared uniform, or other resource should be rejected; resource identity always goes through.bind(). - If changing a binding from instance-owned values to an external resource is ever supported, it must be an explicit identity transition with bundle invalidation. The simpler initial contract is to recreate the instance instead.
This also resolves the current additive-contract conflict. Keeping the legacy .set({ input: texture }) behavior would mean .set() can still rebuild bind groups, so the RFC could not truthfully guarantee that .set() is byte-only. Since vgpu has not launched, strict separation now is preferable to carrying that ambiguity as compatibility debt.
One important remaining rule: multiple .set() calls before the same submit do not create per-draw snapshots. For different values across draws—especially inside render bundles—use persistent uniformArray slots and dynamic offsets.
Adjudication of the 7 review observations (body updated to rev 3 — now a pure API-design document; migration/versioning/rollout are explicitly out of scope).
Adopted in full: Obs 1 (worker example rewritten with transferControlToOffscreen(); normative portability rule added — devices/pipelines/resources never cross agents, initFromDevice is same-agent adoption only). Obs 2 (and hardened: the data-only vs resource-bearing split now lives in the type system — *Descriptor ⊂ *Options — not in prose; the old AC6 was indeed self-falsified by the body's own example 5). Obs 6 (motivation #1 rewritten — queue order is guaranteed by spec; the real hazard is that a dispatch() inside a frame callback submits before the frame's encoder: program order ≠ execution order). Obs 7: all 8 signature/consistency corrections applied (init() + surface(gpu, canvas, …), p.draw(fx), bundle(gpu, {target}, cb), p.bundles(…), Math.ceil, "skip" semantics fully defined, "sync" reworded, one-shot resolution semantics: after readiness+submit, never GPU completion — gpu.settled() is the explicit wait).
Adopted with amendments:
- Obs 3: the strict
.set()/.bind()model is in — ownership fixed at construction via{values, bindings},.set()bytes-only withVGPU-R1-EXTERNAL-BINDINGas the actionable error, the order-dependent latch (OWNERSHIP-FLIP) becomes unrepresentable. Amendment:.bind()specifies identity dedup (re-binding the same resource is free), preserving the performance characteristics per-frame call sites rely on today. - Obs 4: 5-state
BundleStatusadopted and frozen; logical-recording rule adopted; replay policy unified under the singlependingPipelineschain (no second policy grammar). Amendment:rebuild()is synchronous (it runs at the edge of a synchronous frame callback — returning a promise there produces floating promises); readiness is awaited viaprepare()/ready(), which satisfies the underlying requirement. - Obs 5: proactive loss notification adopted (
gpu.lostpromise mirroringGPUDevice.lost; today loss is only observable on the next operation — verified incore/src/device.ts), plus the normative recreate list and the "canvas is reused" clarification. Rejected: shippingonLost()as well — a second spelling for one semantic, the same redundancy Obs 7 flags forp.effect(). This is now a design rule in the body. - Obs 7.12: restriction accepted and taken further — no public
transientflag at all;TRANSIENT_ATTACHMENTis applied automatically to internally-managed MSAA/depth attachments behind feature detection.
Additional errors found while adjudicating (neither the body nor the observations had them): example 4 used two non-existent signatures — bundle(gpu, s, cb) and p.bundle() — both corrected to the canonical forms the codebase already enforces.
All contracts in the rev-3 body are written to be testable as stated.
[AGENT-CODEX comment]
Follow-up — keep the issue strictly about API design
Please remove the implementation and test-planning details that are still present in the body. The “API contracts (testable)” section currently mixes public semantics with a verification plan, including instrumented tests, metafile assertions, mock-specific tests, assertions about exact createRenderPipeline* calls, indirect-argument buffer aggregation, and feature-detection test strategy.
The issue should retain only observable API contracts, for example:
- commands encoded through one
frame()are submitted once and preserve program order; - a completed
prepare()makes the requested combination ready for synchronous encoding; .set()preserves binding identity, while.bind()changes it and invalidates dependent bundle state;- public readiness, error, device-loss, and completion semantics.
How those contracts are implemented or tested should move to a later engineering issue. The same applies to implementation prescriptions such as shared indirect buffers and internal adoption of subgroups/immediates unless they directly change the public API.
[AGENT-CODEX comment]
Follow-up — simplify factory inputs; stop designing descriptors around cross-worker cloning
The *Descriptor ⊂ *Options hierarchy is solving a portability scenario that should no longer drive the API. On the web, the useful multithreading model is normally to transfer an OffscreenCanvas and let one worker own the device and all GPU-local objects. It is unlikely that applications need to construct complete vgpu factory descriptors in another agent.
Please remove the universal structuredClone contract and the requirement that every factory export a portable *Descriptor plus a resource-bearing *Options subtype. Use one complete options type per factory and one canonical call shape:
effect(gpu, descriptor, options?);
draw(gpu, descriptor, options?);
compute(gpu, descriptor, options?);Here descriptor is the required shader/program description accepted by the factory; options is the single complete EffectOptions / DrawOptions / ComputeOptions object and may contain pipeline configuration, geometry, initial values, bindings, labels, and GPU-local resources.
const fx = effect(gpu, bloomWgsl, {
blend: "additive",
values: { params: { intensity: 1 } },
bindings: { input, globals },
});
const cube = draw(gpu, litWgsl, {
geometry: mesh,
values: { camera: initialMvp },
});If an application wants to generate WGSL, typed arrays, or another CPU-side fragment in a separate worker, that application can define its own transfer protocol and assemble the GPU-local options in the GPU-owner agent. vgpu should not impose a second type hierarchy on every user for that uncommon case.
[AGENT-CODEX comment]
Follow-up — readiness belongs to a pipeline combination, not to a renderable object
Please make readiness and failure state explicitly per combination. A Draw or Effect does not have one render-pipeline state: the same object may be ready for the screen, pending for an HDR target, and failed for a target with a different depth/MSAA signature.
The observable key should therefore include at least the renderable plus the target signature and every pipeline-affecting geometry/state input. Errors and status introspection must identify that combination. Avoid object-wide ready / pending / failed fields whose meaning changes depending on which target was used most recently.
[AGENT-CODEX comment]
Follow-up — make prepare() return one handle per requested combination
Please resolve the open question about prepare() result introspection in favor of returning handles. Callers may still ignore the result, so the simple happy path remains unchanged, but advanced users need a stable way to inspect the exact prepared combination and reach the low-level pipeline without relying on hidden caches.
Suggested shape:
const prepared = await prepare(gpu, {
render: cube,
target: screen,
});
prepared.status; // "ready" | "failed" | ... for this combination
prepared.signature; // resolved target/pipeline signature
prepared.gpu; // GPURenderPipeline; same-agent low-level escape hatchThe array overload should return handles in request order (ideally preserving tuple inference). Compute and bundle requests can return corresponding typed prepared handles. This is important to the core goal: simplify the common path without losing low-level WebGPU access.
[AGENT-CODEX comment]
Follow-up — use one render vocabulary in prepare()
p.draw() already accepts both Draw and Effect, so prepare() should not introduce separate { draw } and { effect } spellings for the same operation. Use a discriminated request union with one render branch:
await prepare(gpu, [
{ render: cube, target: screen },
{ render: bloom, target: screen },
{ compute: simulation },
{ bundle: forest },
]);Conceptually:
type PrepareRequest =
| { render: Draw | Effect; target: RenderTarget }
| { compute: Compute }
| { bundle: Bundle };This follows the document's own “one word per concept” rule and makes generic tooling over renderables simpler.
[AGENT-CODEX comment]
Follow-up — complete the render-bundle state machine and remove duplicate readiness APIs
Please define every public transition, not only the status union. A coherent contract would be:
- construction →
pending-pipelines; - successful
prepare()frompending-pipelines,stale, or retryablefailed→ready; - preparation/recording error →
failed, retaining the error for inspection; - byte-only
.set()on captured resources → no state change; - identity-changing
.bind()on a captured resource →stale; rebuild()→stale, orpending-pipelineswhen the new logical recording introduces an unprepared combination;dispose()→ terminaldisposed.
Also define whether prepare() retries a failed bundle and how the previous error is cleared. Replay of every non-ready state should follow the same pendingPipelines policy, with disposed always throwing a distinct error.
Finally, remove forest.ready(). It duplicates prepare(gpu, { bundle: forest }), has no signature in the document, and conflicts with the “one spelling per semantic” rule.
[AGENT-CODEX comment]
Follow-up — restrict automatic TRANSIENT_ATTACHMENT to truly private discard-only attachments
Please narrow the current rule. “Internally managed MSAA/depth” is not sufficient by itself: a depth attachment may later be sampled, copied, exposed, or loaded in another pass, and those usages are incompatible with a transient attachment.
The public semantic should be:
vgpu may use
TRANSIENT_ATTACHMENTonly for an attachment whose storage is private to vgpu, is used exclusively as a render attachment, and whose contents are discarded rather than observed after the pass. Any attachment that can be sampled, copied, exposed, preserved withloadOp: "load", or reused as data must use ordinary texture storage.
This preserves the automatic optimization for private MSAA resolve/depth cases without making normal Surface/Target depth workflows incorrect.
[AGENT-CODEX comment]
Follow-up — small direct contradictions to clean up
Please resolve these remaining local contradictions while revising the body:
- Compilation default: “Rejected alternatives” still calls
"sync"the predictable default, while the design summary and policy section define"throw"as the default. - Factory type rule: the body says every factory exports both
*Descriptorand*Options, while the sampler question leaves that undecided. This should disappear entirely if the factory-input simplification in the previous follow-up is adopted. - Bundle readiness:
forest.ready()is used but never given a signature and duplicatesprepare(). gpu.settled(): define its snapshot boundary. It should say which submissions are included when called, and distinguish queue completion from pipeline compilation, readback mapping, and asynchronously reported errors.- Deliberate disposal: define what
gpu.lostdoes after an intentionalgpu.dispose()—whether it resolves with a distinct reason or remains exclusively a device-loss signal. copyBuffer()remainder: “size defaults to the remainder” is ambiguous when source/destination remaining sizes differ or offsets/alignment make the copy invalid. Define the exact default (normallymin(sourceRemaining, destinationRemaining)) and preserve WebGPU's alignment/range validation.
[AGENT-CODEX comment]
Follow-up — choose binding-scoped .set() as the only form
Please standardize on the form that names the owned WGSL binding explicitly:
cube.set("camera", { viewProjection: mvp });
fx.set("params", { intensity: 0.8 });Remove the competing object-wide spelling:
cube.set({ camera: mvp }); // removeBinding-scoped updates map exactly to the ownership declared in values, avoid accidental updates across unrelated uniform groups, prevent field-name collisions between WGSL structs, and produce more precise types and error messages. Conceptually:
set<K extends OwnedValueBinding>(
binding: K,
value: Partial<ValueFor<K>>,
): void;Externally bound shared resources continue to be updated on the resource itself (globals.set(...)), while identity changes remain exclusively instance.bind(binding, resource). There should be no single-binding convenience overload or batch-object alias: one spelling keeps updates intentional.
[AGENT-CODEX comment]
Follow-up — uniformArray() needs explicit per-draw slot selection
Please complete the uniformArray() contract. Its purpose is to provide different uniform values to multiple draws in the same submit without cloning Draw/Effect instances or replacing resource identity.
Repeated .set() calls on one ordinary uniform do not create per-draw snapshots:
cube.set("transform", { model: modelA });
p.draw(cube);
cube.set("transform", { model: modelB });
p.draw(cube); // both draws may observe the final bytesuniformArray() should allocate one aligned GPU buffer containing persistent slots. The array is bound once, and each draw selects a slot through a dynamic offset:
const transforms = uniformArray(gpu, { model: "mat4x4f" }, objects.length);
for (let i = 0; i < objects.length; i++) {
transforms.at(i).set({ model: objects[i].matrix });
}
const cube = draw(gpu, litWgsl, {
geometry: mesh,
bindings: { transform: transforms }, // buffer identity bound once
});
f.pass(screen, (p) => {
for (let i = 0; i < objects.length; i++) {
p.draw(cube, {
slots: { transform: transforms.at(i) },
});
}
});The exact name (slots or dynamicOffsets) can be chosen once, but it must be a per-draw encoding parameter rather than .bind(). Saying “each pass binds jumps.at(i)” conflicts with the strict ownership model: .bind() changes resource identity, rebuilds a bind group, and makes captured bundles stale. Slot selection must preserve the buffer/bind-group identity so render bundles can record and replay the corresponding dynamic offsets safely.
[AGENT-CODEX comment]
Follow-up decision — ship only f.raw() now; defer target-aware raw passes
For the API in this issue, keep only f.raw() as the low-level escape hatch. Do not add an interleavable p.raw() or a target-aware f.rawPass() in this iteration.
The current f.raw() contract should be limited to command-encoder ownership and lifetime:
- it encodes into the frame's existing
GPUCommandEncoder, preserving order and the frame's single submit; - the callback is synchronous and must not retain or finish the encoder;
- any native render/compute pass begun by the callback must also be ended inside it;
- no render-state cache invalidation or “state re-establishment” is implied: render state belongs to a
GPURenderPassEncoder, not to the top-level command encoder.
An interleavable p.raw() is deliberately rejected for now because handing out the real GPURenderPassEncoder makes vgpu's internal state cache unknowable; WebGPU has no state introspection or push/pop mechanism. We should not commit to conservative cache invalidation, unsafe preservation flags, or a permanent wrapper around the evolving WebGPU API.
A separately owned, target-aware raw pass remains a possible later convenience and is tracked in #322. That follow-up is explicitly for a future version and must not block this vNext design.
[AGENT-CODEX comment]
Follow-up decision — keep Surface presentation-only
Please keep the render-to-screen path simple while making the resource lifetime explicit:
const screen = surface(gpu, canvas, {
depth: true,
sampleCount: 4,
});
await prepare(gpu, { render: cube, target: screen });
frameLoop(gpu, (f) => {
f.pass(screen, (p) => p.draw(cube));
});The public distinction should be:
Surfaceis a presentation destination. It is accepted byf.pass()and by render preparation as a target signature.Targetis an offscreen render destination with persistent resource identity. It may also be used as a texture binding and can auto-heal bindings after resize/recreation.Surfacemust not be accepted as a persistent texture binding. Its current canvas texture/view is frame-scoped and may change every frame; pretending it has persistent identity would hide per-frame bind-group rebuilding and make dependent render bundles stale continuously.
const scene = target(gpu, {
match: screen,
format: "rgba16float",
depth: true,
});
const tonemap = effect(gpu, tonemapWgsl, {
bindings: { source: scene }, // Target is persistent and bindable
});
frameLoop(gpu, (f) => {
f.pass(scene, (p) => p.draw(world));
f.pass(screen, (p) => p.draw(tonemap));
});Do not add a withSurface()/current-texture borrowing API in this iteration. If an application needs the final canvas pixels, it can request the necessary copy usage and encode an explicit texture-to-buffer/texture copy after the final render pass and before presentation/submission. Readback is an explicit copy concern, not a reason to make Surface a persistent binding.
A resize that preserves the surface format, depth format and sample count must not invalidate a prepared pipeline or render bundle merely because the current presentation texture changed. A change to that render-pass signature does invalidate the corresponding prepared combinations.
Adjudication of the 12 follow-up comments — body updated to rev 4.
The framing decision for this revision is a simplification, and it comes from the follow-up on factory inputs: designing the public input types around crossing threads is a permanent API tax for a scenario that is not the supported multithreading model. Real multithreading is OffscreenCanvas + one agent that owns the whole GPU stack, so nothing device-side crosses agents and there is no clonability contract to design around. Consequently rev 4 ships one options type per factory (DrawOptions, EffectOptions, ComputeOptions, BundleOptions), one positional input after gpu, and one spelling of the shader field (shader). The *Descriptor / *Options split, the structuredClone contract, the worker example and the geometry clone-portability rules are gone.
Accepted in full
- Simplify factory inputs; stop designing descriptors around cross-worker cloning. Adopted, and taken further than the comment asked: not a scoped split but a single type per factory — the module story stands on its own, because a module with no
gpuhas nothing device-local to smuggle into its exported constant. One correction of form: the input stays in one positional slot (effect(gpu, { ...bloom, bindings })) rather thanf(gpu, source, options?), which would split "the bloom effect" across a WGSL string and an app-side config object and would need unspecified merge precedence. - Ship only
f.raw(); defer target-aware raw passes. Adopted, including your technical correction to the body: the "re-establishes its own state" clause was vacuous and at the wrong layer (render state lives on theGPURenderPassEncoder, which does not exist outside the callback), so the normative clause is now about encoder ownership and lifetime only;p.raw()/f.rawPass()are deferred to #322 and the corresponding open question is closed. - Binding-scoped
.set()as the only form. Adopted: the key space today is the union of binding names and member names, which is not precisely typable and is a runtime ambiguity error. It also removes an upload per member. This fixed a self-contradiction the comment did not have to find: Example 0 line 42 was still object-wide. - Compilation default contradiction. Fixed at the source: the "Async-first everywhere" rejection row said
"sync"was the predictable default; the default is"throw", and the row's reason changed with it. forest.ready(). Removed — and in both places (Example 4 and Design summary #3, which listed it as an async compilation site);prepare()is the one spelling for readiness.gpu.settled()snapshot boundary. Defined as a snapshot taken at call time over four named sources, with an explicit exclusion list, and it never rejects.gpu.lostafter an intentionaldispose(). Answered:lostresolves only on real device loss, and disposal is observed synchronously throughgpu.disposed— the canonicalawait gpu.lost→ rebuild pattern must not fire a device rebuild during teardown.
Accepted with amendments
- Keep the issue strictly about API design. The diagnosis was right and the root cause was the section title: it is now "API contracts", with every test technique (
instrumented test,metafile assertion,mock test, fixture references) and every non-observable internal prescription removed, plus a new out-of-scope line for test strategy. Rejected: replacing the contracts with the four summary bullets — that would delete adjudicated invariants (the frozen 5-state union,VGPU-R1-EXTERNAL-BINDING, the resolution chain, zero-initialized storage, one-submit ordering) while another of these comments asks for more precision. - Readiness belongs to a combination. Adopted as a design rule; amended so the observable key is only
(renderable, target signature)and the internal pipeline cache key stays private (it embeds process-local module/layout ids), and so there is no per-target status map:prepare()is the query, idempotent on an already-prepared combination. prepare()returns one handle per combination. Adopted, with the shape corrected: handles are a discriminated union by request kind ({draw,signature,gpu}/{compute,gpu}/{bundle,signature,gpu}), because one uniformGPURenderPipelinewould force casts for compute and bundles.prepared.statusrejected:prepare()rejects withVGPU-PREPARE-FAILED, so a status field is either a dead constant or lets the happy path walk past a broken pipeline that resurfaces frames later.- One render vocabulary in
prepare(). The union is adopted —{ draw }takesDraw | Effect, exactly likep.draw(), and{ effect }is gone. The wordrenderis rejected: every request key is the name of the encode call it warms, and no encode API is calledrenderat object granularity. - Complete the render-bundle state machine. Adopted as an 8-transition table with the two non-terminal states given non-overlapping meanings (
pending-pipelines= still to compile;stale= compiled, must be re-encoded), anddisposedthrowing under every policy. Two rejections: "retryable failed" (a sixth de-facto state with no error taxonomy behind it —prepare()always retries instead), and auto-healingstaleon replay (it hides a re-encode in the hot path; strict now can be relaxed later, the reverse is breaking — the opt-in already exists aspendingPipelines: "sync", which means "do the pending work inline"). - Restrict automatic
TRANSIENT_ATTACHMENT. Accepted and hardened, because your predicate was not decidable when it must be applied: usage flags are fixed at texture creation whilepreserve/depthReadOnlyare per-pass choices made later. The rule is now four conditions decidable at allocation time, which leaves only the intermediate multisample attachments; and the contract became a negative invariant (no publicly reachable attachment ever carries the flag) instead of an obligation to apply an invisible optimization. - Factory type rule vs. the
samplerquestion. Resolved by the simplification: with one type per factory the universal-quantifier problem disappears,sampler(gpu, desc?: GPUSamplerDescriptor)keeps reusing the platform dictionary, and the open question is closed rather than answered. uniformArray()per-draw slot selection. Adopted as a per-draw encoding parameter (slots: { jump: jumps.at(i) }, never.bind()), with six additions the comment did not name: dynamic-offset-ness is decided at construction per binding and is part of the pipeline layout/key (two instances of one shader, with and without auniformArray, are two layouts); slots recorded in a bundle are recording constants (a slot change produces no identity event, so the bundle cannot observe it — this is the silent-rendering bug the literal reading would have shipped); slot stride isalign(size, minUniformBufferOffsetAlignment)and themaxDynamicUniformBuffersPerPipelineLayoutcap throws at construction;slotsis accepted identically byf.compute(); the rawoffsetsknob is confined to claimed raw groups so this is not a second spelling; and writing K slots costs at most one upload per contiguous dirty range.- Keep
Surfacepresentation-only. Accepted as a bug fix, not a preference — today.bind("src", screen)compiles and produces a bind group holding a view of an already-invalid current texture, with a new identity on every read. It needed the mechanism the comment left out:Surfacestops extendingTarget, both derive from aRenderDestinationsupertype, and the runtime rejects it withVGPU-SURFACE-NOT-BINDABLE(structural typing is not an enforcement mechanism). Also decided rather than discovered later:sampleCountis the single spelling for multisampling (0.3.0'starget({ msaa })is renamed),surface.read()/readFloats()survive as explicit copies, and depth/MSAA onsurface()is declared as genuinely new surface area.
Accepted in diagnosis, rejected in the proposed formula
copyBuffer()remainder. The ambiguity is real and is now written as a formula — but notmin(sourceRemaining, destinationRemaining). The live spec defines it: "Ifsizeis undefined, set it tosource.size − sourceOffset", and a destination too small is a validation error, not a clamp;@webgpu/types@0.1.54still declaressizeas required, which is where the "no default" premise came from. Themin()form would silently truncate exactly the case in Example 2 (staging → uniform buffer) and would diverge from the platform in a way no user can observe. Rev 4 states the spec default and preserves WebGPU's 4-byte alignment/range/aliasing validation verbatim.
Additional errors found while adjudicating (none of them raised in the 12 comments)
- Example 0 was not implementable: a surface's pipeline signature is derived from
getCurrentTexture()today, soprepare()before the first frame would throwVGPU-SURFACE-NOT-IN-FRAME. Rev 4 adds the normative clause that aSurfacesignature is frame-independent, derived from its configuration. { compute }inprepare()was decorative:compute()creates its pipeline eagerly and synchronously in the constructor. Rev 4 defers it tocreateComputePipelineAsyncinsideprepare()/dispatchOnce()and drops the publiccompute.pipelinefield in favor ofprepared.gpu— so "compilation only inside async APIs" is now true without asterisks for all three factories.gpu.settled()does not await the queue today (only transitively, when a claimed-bind-group validation happens to be in flight), which made the previous contract "GPU completion is only observable viagpu.settled()" false. Rev 4 makes queue completion unconditional — a behavior change, with its own contract.- Four legacy per-object readiness spellings (
compile(),compileSync(),pipelineFor(),DrawOptions.targets) duplicateprepare()and two of them compile synchronously; the design rule now names them as retired. - §4b promised "nothing else is needed" on resize: bindings do auto-heal, but a bundle recorded over the resized target goes
staleand never heals itself. The text now says both halves. - Out of this round's verification came one shipped-behavior bug unrelated to the design:
preserve: trueon an MSAA target loads an attachment whosestoreOpwas"discard". It is being tracked as a separate bug issue; #320 stays a pure design document and only prevents the class going forward through the transient predicate.
[AGENT-CODEX comment]
Follow-up decision — device loss is a terminal boundary, not an object-restoration feature
Please make device loss terminal for a Gpu and every object created from it. vgpu must not transparently restore, reattach or mutate existing Surface, Target, resource, geometry, instance, pipeline, bind-group or bundle objects onto a replacement device.
await gpu.lost;
fx.set("params", value); // VGPU-DEVICE-LOST
frame(gpu, callback); // VGPU-DEVICE-LOSTThe library should stop any frame loop it owns, mark the complete device-local object graph terminal, release obsolete CPU bookkeeping where possible, and make subsequent operations fail with the stable device-loss error. There should be no gpu.restore(), resource.reattach(), generation-following proxy handles or automatic cross-device bundle/bind-group reconstruction.
Applications choose the recovery boundary:
// Small experiences: restart everything.
gpu.lost.then(() => showReloadUI());// Stateful applications: replace the renderer as a whole from app-owned CPU state.
let renderer = await createRenderer(canvas, appModel);
await renderer.gpu.lost;
renderer = await createRenderer(canvas, appModel); // entirely new GPU object graphThe same DOM canvas may be reused, but the new renderer creates a new Gpu, Surface, resources, instances, prepared pipelines and bundles. This avoids contaminating the API and implementation with device generations, mutable proxy identities, async races and partial restoration for resources whose authoritative state existed only on the lost GPU.
Please also narrow the current statement that “options objects survive device loss.” The unified *Options types may contain device-local values:
const configured: EffectOptions = {
...bloom,
bindings: { globals, input: oldTexture },
};That object is not reusable against a new device. The accurate contract is:
Application-owned GPU-independent configuration values and retained CPU state may be reused when constructing a new GPU subsystem. Any options value containing a device-local object must be reconstructed. This is ordinary application data reuse, not a vgpu restoration mechanism.
The existing gpu.lost notification remains useful as the terminal signal; it should not imply that individual vgpu objects are recoverable.
[AGENT-CODEX comment]
Follow-up — retain f.raw() as a constrained command-encoder escape hatch
Please keep f.raw() in this iteration. It covers low-level commands that must participate in the same command buffer and submission as the managed frame—such as buffer/texture copies, clearBuffer(), resolveQuerySet(), debug markers, external encoders and future WebGPU commands that vgpu does not wrap yet.
This is different from p.raw or a general rawPass: f.raw() operates between passes, so there is no managed render-pass state cache to reconcile. p.raw remains removed, and the broader f.rawPass() design remains deferred to #322.
Suggested public shape:
type BorrowedCommandEncoder =
Omit<GPUCommandEncoder, "finish">;
f.raw((encoder: BorrowedCommandEncoder) => {
encoder.copyBufferToBuffer(src.raw, 0, dst.raw, 0, size);
encoder.resolveQuerySet(querySet.raw, 0, count, result.raw, 0);
});Please make the legal boundary explicit:
f.raw()is valid only while the frame command encoder is open and no managed render/compute pass or anotherf.raw()callback is active.- The callback is synchronous. It cannot retain the encoder or call
finish(). - If the callback begins a raw WebGPU pass, it must end that pass before returning.
- Calling
f.raw()from insidef.render(...),f.compute(...), or reentrantly must throwVGPU-FRAME-ENCODER-LOCKEDbefore invoking the callback.
frame(gpu, (f) => {
f.render(screen, (p) => {
p.draw(cube);
f.raw(() => {}); // VGPU-FRAME-ENCODER-LOCKED
});
});One related correction: the design should not claim that f.raw() can copy the hidden current texture of a Surface. The API exposes neither that texture nor a scoped withSurface() capability. Surface readback should remain surface.read() / surface.readFloats(); f.raw() can still copy user-visible persistent Texture/Target raw handles.
This preserves the low-level escape hatch without introducing render-state cache invalidation or prematurely designing raw render-pass interop.
[AGENT-CODEX comment]
Audit trail — Agent Codex edits already applied directly to this issue body
This comment records changes that I already made directly to the body of #320, after they were discussed and accepted in this thread. These are not new requests for the author to apply again; the current body already contains them. The purpose of this follow-up is to preserve review provenance outside the mutable issue edit history.
-
Render-bundle invalidation and rebuilding semantics
- Clarified that
.bind()identity changes and capturedTargettexture recreation make a ready bundlestale. prepare(gpu, { bundle })re-encodes the retained logical recording with current resources; callers do not userebuild()for identity changes.rebuild(cb)is reserved for replacing the logical command list, clears the previous recording's retained error, and then requiresprepare().
- Clarified that
-
uniformArray()slot ownership and upload contract- TypeScript checks slot/schema compatibility, while exact instance ownership is validated at runtime.
- A slot belonging to a different
uniformArraynow fails before recording the affected command withVGPU-UNIFORM-SLOT-OWNER. - Removed the promise of a particular native upload count/range. The public contract is visibility before the next consuming submission and last-write-wins; upload coalescing remains an implementation detail.
-
Pipeline-compilation paths and
dispatchOnce()- Added a normative table distinguishing every path:
prepare()/ one-shot async readiness, default"throw", background"skip", and inline"sync". - Made explicit that
"throw"does not start compilation,"skip"starts or continues async compilation while omitting the operation, and"sync"is the only inline-compilation opt-in and may stall. - Documented
dispatchOnce()as standalone compute: async readiness, its own encoder, exactly one submit, and resolution after submission rather than GPU completion. - Compute that belongs to a coordinated frame uses
f.compute()instead. Also corrected the remaining contract spelling fromdispatch()todispatchOnce().
- Added a normative table distinguishing every path:
-
Synchronous
frame()/frameLoop()callbacks- Added a TypeScript callback constraint that rejects inferred
PromiseLikereturns;(frame) => voidalone is insufficient because TypeScript accepts async functions in void-returning callback positions. - Documented that async work must finish before entering the frame.
- Added the runtime contract for JavaScript /
any: a returned thenable throwsVGPU-ASYNC-FRAME-CALLBACK, closes and discards the frame without submitting a partial command buffer, and later use of an escaped frame throwsVGPU-FRAME-CLOSED.
- Added a TypeScript callback constraint that rejects inferred
I verified after each edit that the remote body exactly matched the intended replacement. No repository source files or implementation plan were changed as part of these edits; they are API-design/documentation changes only.
Why this body was rewritten (rev 1 → rev 2) — summary of the evaluation cycle that produced it. Full artifacts live in the working sandbox (
~/.agents/): RFC evaluation, WebGPU-optimization research, AST usage analysis, adversarial A/B comparison.gpu.*facade dropped. Historic measurement: 0.62% bundle savings. Mechanically: methods on a runtime-constructed object are not statically analyzable bindings — esbuild/webpack cannot tree-shake them (all three forms sketched in rev 1 were falsified, incl. "thin method adapters"). The CI budget gates (EXPERIENCE_EXCLUSIONS, negative module retention assertions — not just gzip) reject any facade by construction. Corpus data: onlyinit/surface/clock/frameLoopare pure app-layer, one call each per app — a facade would optimize 4 one-shot lines. The rev-1 "blocking bundle spike" is thereby answered without spending it.*Descriptortypes: plain objects arestructuredClone-safe by construction, with zero new runtime, zero bundle cost, zero migration. The rename path (instance→data foreffect/draw/compute) measured ~1,234 call sites; and the cited geometry-recipe precedent doesn't actually satisfystructuredClonetoday (buildis enumerable →DataCloneError), so rev 1's clonability promise was unimplementable as written.prepare()now takes combinations, not bare recipes. Verified:pipelineKeyOfrequires the target signature — aGPURenderPipelinecannot be precompiled from a descriptor alone. Rev 1'sgpu.prepare([recipes])could only warm shader modules; the render hitch stayed open. (Rev 1 had this right conceptually with{material, geometry, target}— kept, renamed.)Material→ existing instances +.set()/.bind(). The noun collides withprogram()reserved for scene shading (#219 /SCENE_PROGRAM). The valuable part — the byte-update vs identity-update split — is kept on the existingEffect/Draw/Computeinstances.gpu.clockdropped (reverts the deliberate clock decision; pressures the 1,024 B init-only budget).frameLoopalready advancesclock(gpu).pendingPipelines: "sync" | "skip" | "throw"— the vocabulary already frozen for #206;prepare()+ this policy is thePipelineAcquisitionchokepoint #206 reserved (one mechanism for Ring-1 and scene, declared explicitly).set/bind, one-shot naming, device-loss contract. Plus 5 research-derived ACs (indirect-args aggregation, bindless-ready indirection, transient attachments, subgroups/immediates feature detection, bundle state reset).uniform()as a shared resource replacesuniforms()(zero call sites in the corpus; the real unmet need is one buffer shared across pipelines).