#320

vNext API: typed options, unified frames, and pipeline preparation

Openmatiasngfopened 3mo ago

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 / *Options split is gone), real multithreading is OffscreenCanvas + 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 after gpu. 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):

  1. compute(...).dispatch() creates its own encoder and submits immediately. Queue order is preserved — consecutive submits to the same GPUQueue execute 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.
  2. bundle() compiles missing pipelines synchronously in the hot path. There is no async preparation path, and bundle readiness is not observable.
  3. 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.
  4. Reusable modules must accept gpu as 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 no gpu in scope can hold it in a const, 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

  1. One typed options object per factory — no new runtime primitive. draw, effect, compute and bundle each export exactly one input type (DrawOptions, EffectOptions, ComputeOptions, BundleOptions), taken in one positional slot after gpu. That single type carries the module story by itself: a module with no gpu in scope cannot put a device-local resource into export 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.
  2. Unified frame — f.compute() / f.copyBuffer() / f.raw() join f.pass() in one encoder, one ordered command list, exactly one queue.submit().
  3. 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: explicit prepare(), the standalone renderOnce() / 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 (at init() or per frame); the per-call form exists only for exceptions.
  4. 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 Surface has 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 from getCurrentTexture(). Preparing against a surface outside frame() 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 with VGPU-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 spread

The 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 order

f.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 inside f.pass(…) / f.compute(…), or reentrantly from another f.raw() callback, throws VGPU-FRAME-ENCODER-LOCKED before 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 is Omit<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-level GPUCommandEncoder, so a raw block 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 order

Today 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 pathWhat happens when the required pipeline is not readyEncoding / 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 throws VGPU-PIPELINE-PENDING with 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 call prepare(), 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 to failed, is reported once via gpu.onError with 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 a stale bundle 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;    // GPURenderBundle
type 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, with VGPU-PREPARE-FAILED enumerating 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.gpu is the only low-level pipeline escape hatch (see the readiness rule: no per-object compile() / pipelineFor() spellings, and no compute.pipeline field), 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 readiness

Rules: 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 the Target object wires resource-recreation events: resizes re-bind transparently and dependent bundles are marked stale. Binding .color (a Texture snapshot) 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 by prepare() as a target signature. RenderDestination is the shared supertype (size, formats, sample count, clear color, resize, render-pass descriptor).
  • Target additionally has persistent resource identity: it exposes .color/.colors/.depth, may be used as a texture binding, and auto-heals bindings across resize/recreation.
  • Surface is presentation-only and is NOT a texture binding. Surface does not extend Target; passing one as a binding fails with VGPU-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 no withSurface() / current-texture borrowing API.
  • Readback from a surface stays an explicit copy, not a binding: surface.read() / surface.readFloats() (the canvas is configured COPY_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-visible Texture / Target handles.
  • 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 update

uniform() 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 naming params), no batch alias and no single-binding overload. One spelling keeps updates intentional, removes the binding-vs-member ambiguity (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 exclusively instance.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 bindings is instance-owned (value-owned) by default; its storage is created at construction and zero-initialized. values supplies initial values and pins the type; it is not what creates ownership. That is why Example 0 can call cube.set("camera", …) without declaring values. 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 single queue.submit()). For per-pass or per-draw values there is uniformArray + 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): an f32 occupies 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 two uniformArray instances with the same schema are structurally compatible. At draw/dispatch encoding or bundle-recording time, vgpu therefore verifies that slot.owner is the exact array bound to that binding; a mismatch throws VGPU-UNIFORM-SLOT-OWNER before encoding the affected operation, including through JavaScript/unknown paths.
  • A draw whose binding is a uniformArray and that receives no entry in slots fails 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().
  • slots is the same parameter in both encoding contexts: p.draw(inst, { slots }) and f.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() records setBindGroup(group, bg, offsets) with the slots current at recording time; changing slots afterwards produces no identity event and therefore does not move the bundle to stale — 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. BundleStatus gains 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 submit

dispatchOnce() 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-local

Device-loss contract. Device loss is terminal for the Gpu and for every object created from it. vgpu never restores, reattaches or re-points an existing Surface, 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 throws VGPU-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/Compute instances 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 a values entry carrying a Target or 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.lost is a promise that resolves only on real device loss, with the platform's GPUDeviceLostInfo (delivered independently of gpu.onError, which reports operation failures); it is the terminal signal, and it never implies that individual vgpu objects are recoverable. An intentional gpu.dispose() is not a loss: gpu.lost never resolves after it — even though GPUDevice.destroy() resolves the native GPUDevice.lost with reason "destroyed", vgpu deliberately suppresses that (the behavior packages/core already implements via its observeLoss guard, 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 through gpu.disposed. There is deliberately no onLost() callback — one spelling per semantic.


Design rules

  • No second spellings. No new method may duplicate an existing one's semantics (p.draw() covers effects — no p.effect(); p.bundles() is canonical; gpu.lost has 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 shader

    A bare string | ShaderSource is 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 in DrawOptions. wgsl is a second spelling of one concept and additionally lies when the value is a loader ShaderSource artifact rather than WGSL text; code would be a string-only platform word, and source collides with copyBuffer({ source }).

  • A binding bound to a uniformArray is a dynamic-offset binding, and that is part of the pipeline signature. hasDynamicOffset is decided at construction, from the kind of resource present in bindings, 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 a uniformArray and 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 to setBindGroup carries one offset per dynamic entry of the group, in binding order, and mixing a plain uniform() with a uniformArray in one group is valid, with only the latter dynamic.

  • slots is the only typed spelling for dynamic offsets. slots: { binding: array.at(i) } covers every vgpu-managed binding. The raw offsets knob (numbers per group) is exclusively for raw groups adopted through claimGroup() / draw.group(), where vgpu knows neither the layout nor the sizes. Disjoint domains, not two spellings: passing offsets for 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): a Draw can be ready for the screen, uncompiled for an HDR target and failed for a depth24plus-stencil8 target at the same time. No Draw/Effect/Compute carries ready/pending/failed fields, 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 why prepare() needs no geometry: 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 (the draw: key takes Draw | Effect, exactly like p.draw()) and no render: spelling (no encode API is called render at object granularity). A { bundle } request carries no target — a bundle froze its target signature at construction.

  • One spelling for readiness: prepare(). There is no per-bundle readiness promise, no draw.compile() / compileSync(), no pipelineFor() and no DrawOptions.targets precompile 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 the pendingPipelines default forbids.

  • compute() does not compile at construction. prepare(gpu, [{ compute: sim }]) and dispatchOnce() create a missing pipeline with createComputePipelineAsync(). f.compute() on an unprepared kernel follows the pendingPipelines chain exactly like a draw: default "throw" fails without starting compilation; "skip" starts or continues async compilation and omits the dispatch; explicit "sync" uses immediate createComputePipeline() inline and may stall. The public compute.pipeline field is replaced by prepared.gpu — one spelling for the same-agent low-level escape hatch.

  • A Surface has a frame-independent pipeline signature — derived from its configuration, not from getCurrentTexture() (normative clause in Example 0). Preparing against a surface outside frame() is legal; only encoding needs the current texture.

  • surface({ depth: true }) resolves to depth24plus — the same rule target() uses (depthFormatFor). The depth format is part of the pipeline signature; changing it invalidates prepared pipelines.

  • sampleCount is the one spelling for multisampling on both surface() and target() (WebGPU platform vocabulary). 0.3.0's target({ msaa }) is renamed — msaa is not a second spelling.

  • Depth and MSAA on surface() are new surface area, not a passthrough of target(): 0.3.0's Surface hardcodes depth: undefined / sampleCount: 1 and emits color-only render passes (surface.ts:146-147,181-188). The design requires Surface to own its internal depth/multisample attachments, recreate them on resize/auto-resize, and resolve MSAA into the presentation texture; depth: true resolves through the shared depthFormatFor (target-utils.ts:35-36).

  • Transient attachments are automatic, not a flag. No public transient option: a transient attachment cannot be sampled or copied, and a public flag invites exactly that misuse. surface()/target() may allocate an internally-managed attachment with TRANSIENT_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 uses storeOp: "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 (already storeOp: "discard" / depthStoreOp: "discard", target-utils.ts:72,88). Explicitly not transient: a single-sample depth: true attachment (target.depth is public and bindable, target.ts:49; preserve loads it, target-utils.ts:88; depthReadOnly reads it across passes, target-utils.ts:79-87), and the canvas color texture (configured TEXTURE_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:

    1. queue completion — GPUQueue.onSubmittedWorkDone() for every submission made before the call (unconditionally, not only when other bookkeeping happens to be in flight);
    2. pipeline compilation already started (prepare(), "skip" background compiles);
    3. readback mapping already started by vgpu (target.read(), timer/occlusion query rings);
    4. asynchronously reported errors — pending gpu.onError deliveries are flushed before it resolves, so a caller may await 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 through gpu.onError; it is a completion/timing signal, not an error channel. Calling it twice is meaningful: each call takes a new snapshot.

  • 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)

RejectedWhy
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 3Designing 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 constructorsTyped 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 shortcutsThe 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
MaterialCollides with the program() vocabulary reserved for scene shading (#219, SCENE_PROGRAM)
gpu.clockDuplicates 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 keyEvery 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() handleEither 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 replayHides 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.lostSecond spelling for one semantic
gpu.restore() / resource.reattach() / generation-following proxy handles / automatic cross-device reconstruction of bind groups and bundlesTransparent 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 flagPlatform forbids sampling/copying transient attachments; automatic internal application captures the win without the footgun
Interleavable p.raw() inside a managed passHanding 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.

  1. A compute dispatch and a render pass recorded in the same frame() produce exactly one queue.submit(), in program order.

  2. After a completed prepare() on a {draw, target} combination, encoding that combination in a frame() creates no pipelines: no createRenderPipeline/createComputePipeline is issued on the user-provided GPUDevice during encode.

  3. 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.

  4. compute() performs no pipeline creation at construction. A missing compute pipeline is created through createComputePipelineAsync() by prepare() / dispatchOnce() and by background "skip" preparation; explicit "sync" instead uses immediate createComputePipeline() during encoding. Compute exposes no pipeline field.

  5. 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.

  6. Combination-scoped readiness: preparing { draw: d, target: a } does not make { draw: d, target: b } ready, and a failure on b neither throws for a nor mutates any state observable through d.

  7. prepare() returns handles: the array form preserves order and tuple types; prepared.gpu is the same object the encode path uses; prepare() rejects with VGPU-PREPARE-FAILED listing every failed combination, and a subsequent prepare() of the succeeding subset performs no new createRenderPipeline call.

  8. prepare() against a Surface outside frame() resolves (never VGPU-SURFACE-NOT-IN-FRAME), and the resolved signature equals the one used when encoding the same surface inside a frame.

  9. .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 with VGPU-R1-EXTERNAL-BINDING.

  10. .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 (a ShaderSource is { 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.

  11. uniform() shared across two pipelines updates both with one write; storage is zero-initialized.

  12. A binding bound to a uniformArray produces a bind group layout entry with hasDynamicOffset: true only on that binding; two instances of the same shader, one with and one without a uniformArray, produce different pipeline layouts.

  13. p.draw(inst, { slots }) changes no identity: no createBindGroup is issued and an associated bundle stays ready; omitting slots for a dynamic binding throws a stable code; at(i) is identity-stable (Object.is(a.at(2), a.at(2))); slots is accepted identically by f.compute(inst, count, { slots }). TypeScript validates binding/schema compatibility, while an exact-owner mismatch (slot.owner !== boundUniformArray) throws VGPU-UNIFORM-SLOT-OWNER before the affected draw, dispatch or bundle command is recorded.

  14. Slot stride is align(size, minUniformBufferOffsetAlignment); exceeding maxDynamicUniformBuffersPerPipelineLayout throws 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.

  15. 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 is prepare(gpu, [{ bundle }]) (or an explicit "sync" replay); rebuild() is never required to leave stale. Every public transition:

    FromEventTo
    —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 in bundle.error
    ready.set() byte update on a captured resourceready (no transition)
    ready.bind() identity update, or a captured Target recreating its texture (resize)stale
    pending-pipelines | stale | failedidentity updateunchanged
    any non-disposedrebuild(cb) (synchronous)Clear bundle.error, then move to stale, or pending-pipelines if the new recording introduces an uncompiled combination. rebuild() replaces the logical command list; it never compiles and never throws for readiness reasons
    anydispose()disposed (terminal; releases the native bundle and the retained recording; a second dispose() is a no-op)

    prepare() on a ready bundle is a no-op. prepare() always retries a failed bundle — there is no "retryable failure" classification; during that retry bundle.error remains available and the status stays failed, then success clears the error and moves to ready. rebuild() clears the retained error immediately because it replaces the logical recording to which that failure belonged. bundle.error?: VGPUError is defined only while status is failed.

    Replay of a non-ready bundle follows the one pendingPipelines chain (call site → frame → gpu):

    • "throw": pending-pipelines → VGPU-PIPELINE-PENDING; stale → VGPU-R3-BUNDLE-STALE (message names prepare(gpu, { bundle }); no rebuild() is required); failed → the retained error is rethrown (cause preserved).
    • "skip": the bundle is skipped this frame; pending-pipelines starts/continues async compilation; failed is reported once via gpu.onError and keeps being skipped; stale is 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 through prepare()).
    • disposed always throws VGPU-BUNDLE-DISPOSED, under every policy.
  16. A bundle recorded with slots: at(0) replays with slot 0's offset even if it is later encoded with at(1); writing bytes into slot 0 is visible on replay.

  17. dispatchOnce() / f.compute() validate integer workgroup counts ≥ 0 with a stable error code.

  18. f.copyBuffer() with size omitted copies source.size − sourceOffset bytes; a range or 4-byte-alignment violation, or source === destination, fails with a stable error code and encodes nothing.

  19. gpu.lost resolves without any intervening operation after a real device loss, and loss is terminal: every frame loop vgpu owns stops, and every subsequent operation on that Gpu's object graph — .set() / .bind(), frame() / frameLoop(), prepare(), a bundle replay, read() / readFloats() — throws VGPU-DEVICE-LOST; no vgpu object is ever re-pointed at a replacement device, and no API exists to ask for that. gpu.lost does not resolve after an intentional gpu.dispose() — it stays pending while gpu.disposed === true.

  20. renderOnce() / dispatchOnce() use async pipeline readiness, own their encoder and submit exactly once; they resolve after readiness + submit and never await onSubmittedWorkDone. dispatchOnce() is for standalone compute; compute that belongs to a frame() uses f.compute() so it participates in that frame’s ordering and single submit.

  21. gpu.settled() awaits onSubmittedWorkDone for 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 pending onError deliveries before resolving; it never rejects on a failed submission.

  22. Transient-attachment safety predicate (negative contract). No attachment reachable through a public accessor is ever allocated with TRANSIENT_ATTACHMENT: a single-sample depth: true target'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.

  23. Surface is not a texture binding: effect(gpu, { shader, bindings: { src: surfaceInstance } }) and instance.bind("src", surfaceInstance) both fail with VGPU-SURFACE-NOT-BINDABLE, including through the JS/unknown path — structural typing is not the enforcement mechanism. A resize that preserves color format, depth format and sample count triggers no createRenderPipeline and leaves bundles ready; changing any of the three invalidates the corresponding prepared combinations.

  24. frame() / frameLoop() reject callbacks whose inferred return type extends PromiseLike<unknown> while accepting synchronous block- and expression-bodied callbacks. Through JavaScript or any, returning a thenable throws VGPU-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 throws VGPU-FRAME-CLOSED.

  25. f.raw() borrows the frame's open command encoder typed as Omit<GPUCommandEncoder, "finish">, so finish() is not callable at the type level and the frame keeps exactly one queue.submit(). It is legal only between managed passes — and so is every encoder-level command: f.compute() and f.copyBuffer() invoked inside an open f.pass() throw the same error. Invoked inside f.pass() / f.compute(), or reentrantly from another f.raw() callback, it throws VGPU-FRAME-ENCODER-LOCKED before 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 the VGPU-ASYNC-FRAME-CALLBACK case of contract 24). No Surface current texture is reachable through the borrowed encoder.

Out of scope

  • Scene orchestration (#219 / #206 — this design only provides the shared prepare / pendingPipelines chokepoint).
  • 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-aware f.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: true on an MSAA target loads an attachment whose storeOp was "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

  1. Exact shape of the frameLoop ownership hook (8/21 examples avoid frameLoop today because it owns the present pass). The external-ticker positioning (§2b: frame() per tick is the primitive, frameLoop is sugar) may resolve this by demotion rather than by a new hook.
  2. uniform() / values schema shape: { time: "f32" } literals vs reflection-derived (examples 5–6 are illustrative on this point).

Activity

25
matiasngf3mo ago

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: only init/surface/clock/frameLoop are 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.
  • Runtime recipe constructors dropped; typed plain objects instead. 100% of in-module factory usage in the corpus is descriptive (static shader+config) → the module/worker story only needs exported *Descriptor types: plain objects are structuredClone-safe by construction, with zero new runtime, zero bundle cost, zero migration. The rename path (instance→data for effect/draw/compute) measured ~1,234 call sites; and the cited geometry-recipe precedent doesn't actually satisfy structuredClone today (build is enumerable → DataCloneError), so rev 1's clonability promise was unimplementable as written.
  • prepare() now takes combinations, not bare recipes. Verified: pipelineKeyOf requires the target signature — a GPURenderPipeline cannot be precompiled from a descriptor alone. Rev 1's gpu.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 with program() reserved for scene shading (#219 / SCENE_PROGRAM). The valuable part — the byte-update vs identity-update split — is kept on the existing Effect/Draw/Compute instances.
  • gpu.clock dropped (reverts the deliberate clock decision; pressures the 1,024 B init-only budget). frameLoop already advances clock(gpu).
  • Async-first replaced by a named policy. pendingPipelines: "sync" | "skip" | "throw" — the vocabulary already frozen for #206; prepare() + this policy is the PipelineAcquisition chokepoint #206 reserved (one mechanism for Ring-1 and scene, declared explicitly).
  • Kept from rev 1 (the verified gaps): unified frame (compute today does its own isolated submit), async pipeline preparation (bundle compiles sync in the hot path), managed surface depth/MSAA, 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).
  • New: uniform() as a shared resource replaces uniforms() (zero call sites in the corpus; the real unmet need is one buffer shared across pipelines).
matiasngf3mo ago

Review 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.

matiasngf3mo ago

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 *Descriptor type.
  • 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 focused structuredClone/postMessage fixtures. 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.

matiasngf3mo ago

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:

  1. bundle() initially captures a logical high-level command list. It cannot synchronously encode a native GPURenderBundle while required pipelines are pending.
  2. prepare(gpu, [{ bundle }]) prepares every combination and then materializes the native bundle. After it resolves, status === "ready".
  3. Replaying a pending bundle follows an explicit policy (skip, throw, or sync); it must never silently introduce an unexpected hot-path compile.
  4. A resource-identity update moves the bundle to stale; byte updates to the same buffers do not.
  5. rebuild() should be async (or explicitly return a readiness promise), because the new logical recording may introduce a cold pipeline combination.
  6. Compilation/recording failures need a stable error surface and a defined failed state.
  7. 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.

matiasngf3mo ago

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:

  • Gpu and Surface/targets;
  • textures, buffers, uniforms and storage resources;
  • Draw/Effect/Compute instances 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.

matiasngf3mo ago

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 one queue.submit().

This keeps the rationale technically accurate without weakening the case for the feature.

matiasngf3mo ago

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. Use await init() followed by surface(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 / 64 can be fractional; use Math.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 explicit copyBuffer({ source, destination, size, sourceOffset?, destinationOffset? }), with arbitrary copies available through f.raw().
  • Avoid redundant pass dialects. p.draw() already accepts Draw | Effect, so p.effect(fx) adds another spelling without new semantics. Likewise, choose one canonical p.bundle/p.bundles and one canonical bundle(gpu, { target }, cb) signature.
  • The prepare() example awaits readiness and then uses pendingPipelines: "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 for queue.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 normal Target exposes 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.

matiasngf3mo ago

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:

OperationChangesBind-group identityRecorded bundle
.set()Bytes in an existing library-owned uniform bufferPreservedRemains valid
.bind()The buffer, texture/view, sampler, storage resource, or shared uniform assigned to a WGSL bindingAffected group is rebuiltBecomes 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:

  1. values creates instance-owned uniform storage. Those bindings are updated through instance.set().
  2. bindings attaches externally owned resources. Their identity is replaced through instance.bind(); their contents are updated on the resource itself (uniform.set(), storage.write(), compute work, etc.).
  3. Ownership mode is fixed at instantiation instead of being latched by the first .set() call. This removes the order-dependent ownership model and VGPU-R1-OWNERSHIP-FLIP from the normal path.
  4. 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(...).
  5. Calling .set() with a texture, sampler, target, buffer, shared uniform, or other resource should be rejected; resource identity always goes through .bind().
  6. 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.

matiasngf3mo ago

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 with VGPU-R1-EXTERNAL-BINDING as 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 BundleStatus adopted and frozen; logical-recording rule adopted; replay policy unified under the single pendingPipelines chain (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 via prepare()/ready(), which satisfies the underlying requirement.
  • Obs 5: proactive loss notification adopted (gpu.lost promise mirroring GPUDevice.lost; today loss is only observable on the next operation — verified in core/src/device.ts), plus the normative recreate list and the "canvas is reused" clarification. Rejected: shipping onLost() as well — a second spelling for one semantic, the same redundancy Obs 7 flags for p.effect(). This is now a design rule in the body.
  • Obs 7.12: restriction accepted and taken further — no public transient flag at all; TRANSIENT_ATTACHMENT is 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.

matiasngf3mo ago

[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.

matiasngf3mo ago

[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.

matiasngf3mo ago

[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.

matiasngf3mo ago

[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 hatch

The 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.

matiasngf3mo ago

[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.

matiasngf3mo ago

[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() from pending-pipelines, stale, or retryable failed → 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, or pending-pipelines when the new logical recording introduces an unprepared combination;
  • dispose() → terminal disposed.

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.

matiasngf3mo ago

[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_ATTACHMENT only 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 with loadOp: "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.

matiasngf3mo ago

[AGENT-CODEX comment]

Follow-up — small direct contradictions to clean up

Please resolve these remaining local contradictions while revising the body:

  1. Compilation default: “Rejected alternatives” still calls "sync" the predictable default, while the design summary and policy section define "throw" as the default.
  2. Factory type rule: the body says every factory exports both *Descriptor and *Options, while the sampler question leaves that undecided. This should disappear entirely if the factory-input simplification in the previous follow-up is adopted.
  3. Bundle readiness: forest.ready() is used but never given a signature and duplicates prepare().
  4. 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.
  5. Deliberate disposal: define what gpu.lost does after an intentional gpu.dispose()—whether it resolves with a distinct reason or remains exclusively a device-loss signal.
  6. 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 (normally min(sourceRemaining, destinationRemaining)) and preserve WebGPU's alignment/range validation.
matiasngf3mo ago

[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 }); // remove

Binding-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.

matiasngf3mo ago

[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 bytes

uniformArray() 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.

matiasngf3mo ago

[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.

matiasngf3mo ago

[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:

  • Surface is a presentation destination. It is accepted by f.pass() and by render preparation as a target signature.
  • Target is 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.
  • Surface must 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.

matiasngf3mo ago

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 gpu has 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 than f(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 the GPURenderPassEncoder, 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.lost after an intentional dispose(). Answered: lost resolves only on real device loss, and disposal is observed synchronously through gpu.disposed — the canonical await 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 uniform GPURenderPipeline would force casts for compute and bundles. prepared.status rejected: prepare() rejects with VGPU-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 } takes Draw | Effect, exactly like p.draw(), and { effect } is gone. The word render is rejected: every request key is the name of the encode call it warms, and no encode API is called render at 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), and disposed throwing under every policy. Two rejections: "retryable failed" (a sixth de-facto state with no error taxonomy behind it — prepare() always retries instead), and auto-healing stale on 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 as pendingPipelines: "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 while preserve / depthReadOnly are 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 sampler question. 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 a uniformArray, 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 is align(size, minUniformBufferOffsetAlignment) and the maxDynamicUniformBuffersPerPipelineLayout cap throws at construction; slots is accepted identically by f.compute(); the raw offsets knob 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 Surface presentation-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: Surface stops extending Target, both derive from a RenderDestination supertype, and the runtime rejects it with VGPU-SURFACE-NOT-BINDABLE (structural typing is not an enforcement mechanism). Also decided rather than discovered later: sampleCount is the single spelling for multisampling (0.3.0's target({ msaa }) is renamed), surface.read() / readFloats() survive as explicit copies, and depth/MSAA on surface() 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 not min(sourceRemaining, destinationRemaining). The live spec defines it: "If size is undefined, set it to source.size − sourceOffset", and a destination too small is a validation error, not a clamp; @webgpu/types@0.1.54 still declares size as required, which is where the "no default" premise came from. The min() 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, so prepare() before the first frame would throw VGPU-SURFACE-NOT-IN-FRAME. Rev 4 adds the normative clause that a Surface signature is frame-independent, derived from its configuration.
  • { compute } in prepare() was decorative: compute() creates its pipeline eagerly and synchronously in the constructor. Rev 4 defers it to createComputePipelineAsync inside prepare() / dispatchOnce() and drops the public compute.pipeline field in favor of prepared.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 via gpu.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) duplicate prepare() 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 stale and 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: true on an MSAA target loads an attachment whose storeOp was "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.
matiasngf3mo ago

[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-LOST

The 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 graph

The 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.

matiasngf3mo ago

[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 another f.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 inside f.render(...), f.compute(...), or reentrantly must throw VGPU-FRAME-ENCODER-LOCKED before 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.

matiasngf3mo ago

[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.

  1. Render-bundle invalidation and rebuilding semantics

    • Clarified that .bind() identity changes and captured Target texture recreation make a ready bundle stale.
    • prepare(gpu, { bundle }) re-encodes the retained logical recording with current resources; callers do not use rebuild() for identity changes.
    • rebuild(cb) is reserved for replacing the logical command list, clears the previous recording's retained error, and then requires prepare().
  2. 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 uniformArray now fails before recording the affected command with VGPU-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.
  3. 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 from dispatch() to dispatchOnce().
  4. Synchronous frame() / frameLoop() callbacks

    • Added a TypeScript callback constraint that rejects inferred PromiseLike returns; (frame) => void alone 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 throws VGPU-ASYNC-FRAME-CALLBACK, closes and discards the frame without submitting a partial command buffer, and later use of an escaped frame throws VGPU-FRAME-CLOSED.

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.