Frame

`frame()` is both a callable one-frame submit helper and a `FrameRunner`. It creates one command encoder, lets you encode any number of explicit-target render passes, then submits once.

Import

import type { Frame, FramePass, FramePassOptions, FrameLoopHandle, FrameRunner } from "vgpu";

Signature

import type { Bundle, ClearColor, Draw, DrawCallOptions, Effect, Target, TimerSpan, Visibility, VisibilityQuery } from "vgpu";

interface FramePassOptions {
  readonly target: Target;
  readonly clear?: boolean | ClearColor;
  readonly clearDepth?: number;
  readonly clearStencil?: number;
  readonly depthReadOnly?: boolean;
  readonly viewport?: {
    readonly x?: number;
    readonly y?: number;
    readonly width: number;
    readonly height: number;
    readonly minDepth?: number;
    readonly maxDepth?: number;
  };
  readonly scissor?: readonly [number, number, number, number];
  readonly timer?: TimerSpan;
  readonly visibility?: Visibility;
}

interface FrameLoopHandle { stop(): void; }
interface FrameLoopOptions { readonly fps?: number; }
type FrameLoopCallback = (frame: Frame) => void;

declare class Frame {
  done: Promise<void>;
  pass(target: Target, body: Effect | Draw | ((pass: FramePass) => void)): void;
  pass(options: FramePassOptions, body: Effect | Draw | ((pass: FramePass) => void)): void;
  submit(): void;
  cancel(): void;
}

declare class FramePass {
  readonly target: Target;
  draw(drawable: Draw | Effect, opts?: DrawCallOptions): void;
  occlusion(query: VisibilityQuery, body: Draw | Effect | (() => void)): void;
  bundles(...bundles: readonly Bundle[]): void;
}

declare class FrameRunner {
  frame(cb?: (frame: Frame) => void): Frame;
  loop(cb: FrameLoopCallback, opts?: FrameLoopOptions): FrameLoopHandle;
}

Parameters

ParamTypeRequiredDefaultNotes
frame.cb(frame: Frame) => void✖undefinedIf supplied, the frame submits when the callback returns and is canceled when it throws. If omitted, submit or cancel manually.
target.clearColorClearColor✖[0, 0, 0, 1]Writable default clear color of the pass target, used when pass clear is omitted or true. Set it at creation (surface(gpu, canvas, { clearColor }), target(gpu, { size, clearColor })) or assign it later. Assign a GPUColor object or [r, g, b, a].
frame.pass.targetTarget | FramePassOptions✔—Pass a bare target for the allocation-free common case, or an options bag when customizing clear/preserve behavior.
opts.targetTarget✔—Required inside FramePassOptions. Use a Surface from surface(gpu, canvas) or an offscreen Target from target(gpu, { size }).
opts.clearboolean | ClearColor✖trueOmitted or true clears with target.clearColor; false preserves existing color and depth with load ops; a color clears this pass with that color.
opts.clearDepthnumber✖1Depth clear value used when the pass clears, in [0, 1]. Clear to 0 and give draws depth: { compare: "greater" } for reversed-Z, which evens out float depth precision and cuts distant z-fighting. Invalid alongside clear: false, which preserves depth, and on a target without depth (the value would have nowhere to land).
opts.clearStencilnumber✖0Stencil clear value used when the pass clears; integer in [0, 0xFFFFFFFF], masked to the stencil aspect's bit width by taking the LSBs (values above 0xFF are legal on 8-bit aspects). Clear to 0 before stencil-masking draws mark pixels via DrawOptions.stencil — portals, mirrors, UI cutouts. Requires a target depth format with a stencil aspect (e.g. depth: "depth24plus-stencil8"). Invalid alongside clear: false, which preserves stencil.
opts.depthReadOnlyboolean✖falseOpens the pass with a read-only depth attachment: draws depth-test against it and may sample target.depth in the same pass — the soft-particles/SSAO setup. Every draw in the pass needs depth: { write: false } (or depth: false); the default depth state writes and throws VGPU-PASS-DEPTH-READONLY at encode. Effects always keep the writing default, so an Effect cannot run in a depthReadOnly pass on a depth target. Combined depth-stencil formats mark the stencil aspect read-only too. Requires a target with depth; invalid alongside clearDepth/clearStencil (color clear still applies), and invalid on MSAA targets, whose depth aspect is stored with storeOp: "discard" — there is no retained depth to read.
opts.viewport{ x?, y?, width, height, minDepth?, maxDepth? }✖full targetRestricts rasterization to a sub-rectangle for every draw in this pass. Use it for split-screen views, minimaps, and picture-in-picture insets. Floats (fractional values allowed); defaults x/y 0, minDepth 0, maxDepth 1. May extend past the target — validated at pass open against device limits, not the attachment.
opts.scissorreadonly [number, number, number, number]✖full targetClips every draw in this pass to [x, y, width, height]. Use it for UI clipping and partial redraw of damaged regions. Non-negative integers; x + width and y + height must fit the target's current pixel size at pass open (targets are resizable). Never affects the clear — loadOp: "clear" fills the whole attachment; to clear a sub-rectangle, draw it inside a scissored pass.
opts.timerTimerSpan✖undefinedTimes this pass on the GPU: pass timer.span(name) from a timer(gpu) (needs the "timestamp-query" device feature). See Timer for results, capacity, and feature gating.
opts.visibilityVisibility✖undefinedEnables occlusion queries in this pass: pass a visibility(gpu) instance, then wrap proxy draws in pass.occlusion(handle, body). Requires a target with a depth attachment. See Visibility for handle semantics and capacity.
frame.pass.bodyEffect | Draw | ((pass: FramePass) => void)✔—Pass a drawable directly for a single draw, or a callback to encode multiple draw and bundle commands.
pass.draw.drawableDraw | Effect✔—A main API (vgpu) draw or fullscreen effect.
pass.draw.optsDrawCallOptions✖{}Per-call counts and dynamic offsets. Target is the frame pass target.
pass.occlusion.queryVisibilityQuery✔—Stable handle from vis.query(label) of the same visibility instance the pass was opened with. One use per handle per frame, across passes too.
pass.occlusion.bodyDraw | Effect | (() => void)✔—Wrapped in beginOcclusionQuery/endOcclusionQuery. The body ALWAYS executes — it is the proxy the GPU measures; condition your real draws on q.hidden outside the scope.
pass.bundles.bundlesreadonly Bundle[]✔—Bundles recorded by bundle(gpu, { target }, cb).
runner.loop.cb(frame: Frame) => void✔—Called on each scheduled frame with the frame(gpu, cb) rule: submit on return, cancel on throw; a throwing tick also stops the loop. Surface auto-resize runs before this callback.
runner.loop.opts.fpsnumber✖0 (uncapped)Positive values cap by minimum frame interval 1000 / fps; omitted or non-positive uses every rAF/timer tick.

Returns: frame(gpu) / FrameRunner.frame() return Frame; Frame.pass(), Frame.submit(), and Frame.cancel() return void; FramePass.draw(), .occlusion(), and .bundles() return void; loop() returns FrameLoopHandle with stop().

Throws:

  • VGPU-TARGET-REQUIRED — a runtime JS call omitted the frame pass target. Name a Surface or offscreen Target in every frame.pass.
  • VGPU-CLEAR-COLOR-INVALID — target.clearColor / surface.clearColor (at creation or on assignment) or a pass clear color is not four finite numbers. Assign [r, g, b, a] or a GPUColor object.
  • VGPU-PASS-PRESERVE-MSAA — clear: false on an MSAA target; multisample attachments use storeOp: "discard", so there is nothing to preserve. Render accumulation/preserve passes into a non-MSAA target.
  • VGPU-PASS-CLEARDEPTH-INVALID — clearDepth is not a number in [0, 1], or the target has no depth attachment (the option would have no effect). Create the target with depth: true, or drop clearDepth.
  • VGPU-PASS-PRESERVE-CLEARDEPTH — clearDepth combined with clear: false; preserved depth is never cleared. Drop one of the two.
  • VGPU-PASS-CLEARSTENCIL-INVALID — clearStencil is not an integer in [0, 0xFFFFFFFF], or the target's depth format has no stencil aspect (the option would have no effect). Create the target with depth: "depth24plus-stencil8".
  • VGPU-PASS-PRESERVE-CLEARSTENCIL — clearStencil combined with clear: false; preserved stencil is never cleared. Drop one of the two.
  • VGPU-PASS-DEPTH-READONLY — depthReadOnly is not a boolean, is set on a target without depth, or is combined with clearDepth/clearStencil (read-only aspects omit their load/store ops and are never cleared). Also thrown at encode when a draw writes depth — add depth: { write: false } — or writes stencil (a draw writes stencil when any stencil op on an unculled face is not "keep" and its stencil writeMask is nonzero — use "keep" ops or writeMask: 0); and when pass.bundles(...) is called in such a pass — bundle() records writable depth/stencil, and WebGPU only executes read-only-recorded bundles there, so encode those draws with pass.draw(...) instead.
  • VGPU-PASS-DEPTH-READONLY-MSAA — depthReadOnly on an MSAA target; multisampled depth is stored with storeOp: "discard", so a read-only pass would depth-test against discarded contents instead of failing loudly. Use a non-MSAA target for read-only depth.
  • VGPU-PASS-VIEWPORT-INVALID — viewport is malformed or outside device limits: width/height in [0, maxTextureDimension2D], x/y at least -2 × maxTextureDimension2D with x + width/y + height at most 2 × maxTextureDimension2D − 1, minDepth/maxDepth in [0, 1] with minDepth <= maxDepth. The message names the offending field.
  • VGPU-PASS-SCISSOR-INVALID — scissor is malformed or exceeds the target's current pixel size. Shrink the rectangle, or resize it from surface.onResize(...).
  • VGPU-TIMER-INVALID — timer is not a TimerSpan from timer.span(name), repeats a span name within one frame, or belongs to a different gpu's or disposed timer.
  • VGPU-TIMER-CAPACITY — one frame timed more spans than the per-frame limit (see Timer).
  • VGPU-VIS-INVALID — visibility is not a Visibility from this gpu's visibility(gpu), or occlusion() received a non-VisibilityQuery value or a handle of a different visibility instance.
  • VGPU-VIS-NO-DEPTH — visibility on a pass whose target has no depth attachment; nothing is depth-tested, so every query would report visible. Create the target with depth: true.
  • VGPU-VIS-DISPOSED — the visibility instance or the handle is disposed.
  • VGPU-VIS-CAPACITY — the occlusion() call exceeded the declared capacity. Raise VisibilityOptions.capacity.
  • VGPU-QUERY-NO-VISIBILITY — occlusion() in a pass opened without visibility. Pass the instance in FramePassOptions.visibility.
  • VGPU-QUERY-NESTED — occlusion() inside an active occlusion() body; one scope at a time. Close the outer scope first.
  • VGPU-QUERY-DUPLICATE — a handle used twice within one frame, across passes too (see Visibility). Create one handle per queried object.
  • VGPU-FRAME-REENTRANT — a frame started from another frame or from a surface resize callback. Encode everything in one frame callback.
  • VGPU-FRAME-CANCELED — a frame.pass(...), or a retained FramePass operation, on a frame closed by cancel(); its command encoder was dropped, so the work would never run. Open a new frame(gpu).
  • VGPU-FRAME-PASS-ACTIVE — frame.cancel() from inside that frame's active pass callback. Return from frame.pass(...) first, then cancel, so resources referenced by the native pass descriptor stay alive until the pass closes.
  • VGPU-FRAME-SUBMITTED — frame.cancel() on a frame that was already submitted; queued GPU work cannot be taken back, and the frame needs no cleanup. Cancel only frames you decided not to submit.
  • VGPU-R3-BUNDLE-STALE / VGPU-R3-BUNDLE-INVALID — replaying a bundle whose recorded resources changed identity, or a value not created by bundle(). Re-record the bundle.
  • Binding errors such as VGPU-R1-BINDING-NEVER-SET propagate during encoding. Raw claimed-group validation is delivered asynchronously through gpu.onError.

Examples

import { init, draw, frame, target } from "vgpu/mock";

const gpu = await init();
const scene = target(gpu, { size: [64, 64], format: "rgba8unorm", clearColor: [0.02, 0.02, 0.04, 1] });
scene.clearColor = [0.02, 0.02, 0.04, 1]; // and it stays writable at runtime
const drawable = draw(gpu, { shader: `
  @vertex fn vs_main(@builtin(vertex_index) vi: u32) -> @builtin(position) vec4f {
    var p = array<vec2f, 3>(vec2f(-1, -1), vec2f(3, -1), vec2f(-1, 3));
    return vec4f(p[vi], 0, 1);
  }
  @fragment fn fs_main() -> @location(0) vec4f { return vec4f(0.2, 0.4, 1.0, 1.0); }
` });

frame(gpu, (currentFrame) => {
  currentFrame.pass(scene, (pass) => pass.draw(drawable)); // clears with scene.clearColor
});
import { init, effect, frameLoop, target } from "vgpu/mock";

const gpu = await init();
const colorTarget = target(gpu, { size: [16, 16] });
const shader = effect(gpu, `@fragment fn fs_main() -> @location(0) vec4f { return vec4f(1); }`);
const handle = frameLoop(gpu, (frame) => {
  frame.pass({ target: colorTarget, clear: false }, shader); // preserve color and depth
}, { fps: 30 });
handle.stop();
import { init, effect, frame, target } from "vgpu/mock";

const gpu = await init();
const screen = target(gpu, { size: [640, 360] });
const p1View = effect(gpu, `@fragment fn fs_main() -> @location(0) vec4f { return vec4f(0.1, 0.3, 0.6, 1); }`);
const p2View = effect(gpu, `@fragment fn fs_main() -> @location(0) vec4f { return vec4f(0.6, 0.3, 0.1, 1); }`);

frame(gpu, (currentFrame) => {
  currentFrame.pass({ target: screen, viewport: { width: 320, height: 360 } }, p1View); // player 1, left half
  currentFrame.pass({ target: screen, clear: false, viewport: { x: 320, width: 320, height: 360 } }, p2View); // player 2, right half
});

Split-screen: the first pass clears the whole target and rasterizes one camera into the left viewport; the second preserves it (clear: false) and rasterizes the other camera into the right viewport.

import { init, draw, effect, frame, target } from "vgpu/mock";

const gpu = await init();
const scene = target(gpu, { size: [256, 256], depth: true });
const opaque = effect(gpu, `@fragment fn fs_main() -> @location(0) vec4f { return vec4f(0.2, 0.5, 0.2, 1); }`);
const particles = draw(gpu, {
  shader: `
    @group(0) @binding(0) var sceneDepth: texture_depth_2d;
    @vertex fn vs_main(@builtin(vertex_index) vi: u32) -> @builtin(position) vec4f {
      var p = array<vec2f, 3>(vec2f(-1, -1), vec2f(3, -1), vec2f(-1, 3));
      return vec4f(p[vi], 0.4, 1);
    }
    @fragment fn fs_main(@builtin(position) pos: vec4f) -> @location(0) vec4f {
      let d = textureLoad(sceneDepth, vec2i(pos.xy), 0);
      let fade = clamp((d - pos.z) * 32.0, 0.0, 1.0); // fade out where the particle nears geometry
      return vec4f(0.9, 0.6, 0.3, 1.0) * fade;
    }
  `,
  depth: { write: false }, // required: the pass depth is read-only
  blend: "additive",
  set: { sceneDepth: scene.depth },
});

frame(gpu, (currentFrame) => {
  currentFrame.pass(scene, opaque); // pass 1: opaque geometry writes depth
  currentFrame.pass({ target: scene, clear: false, depthReadOnly: true }, particles); // pass 2: test + sample that depth
});

Soft particles: pass 2 depth-tests against the opaque depth while sampling the same scene.depth texture, which WebGPU only allows because the attachment is read-only.

import { init, effect, frame, target, visibility } from "vgpu/mock";

const gpu = await init();
const scene = target(gpu, { size: [64, 64], depth: true });
const vis = visibility(gpu);
const proxy = effect(gpu, `@fragment fn fs_main() -> @location(0) vec4f { return vec4f(1); }`);

const currentFrame = frame(gpu); // manual frame: nothing submits it for you
currentFrame.pass({ target: scene, visibility: vis }, (pass) => pass.occlusion(vis.query("statue"), proxy));

if (sceneChangedUnderneath()) {
  currentFrame.cancel(); // no GPU work runs, and the occlusion query set stops being retained for this frame
  vis.dispose(); // released now instead of at gpu.dispose()
} else {
  currentFrame.submit();
}

function sceneChangedUnderneath(): boolean { return true; }

Cancelling a manual frame: cancel() drops the encoder and releases the telemetry retains the frame took, so a timer(gpu) / visibility(gpu) can be disposed for good without submitting work you no longer want.

Keeping partial work when a later step throws: a frame(gpu, cb) callback that throws is canceled, so nothing it encoded runs. Submit explicitly before rethrowing to present what was encoded so far.

import { init, frame, target, type Frame } from "vgpu/mock";

const gpu = await init();
const scene = target(gpu, { size: [64, 64] });
function encode(currentFrame: Frame): void { currentFrame.pass(scene, () => undefined); }

frame(gpu, (currentFrame) => {
  try {
    encode(currentFrame);
  } catch (error) {
    currentFrame.submit(); // opt in: what was encoded so far is presented anyway
    throw error;
  }
});

Notes

  • Frame, FramePass, and FrameRunner are type-only public exports. Create frames through frame(), not new Frame(...).
  • There is no default target and no implicit canvas target; every frame.pass names its target.
  • Omitted clear and clear: true clear with target.clearColor. Pass a color to clear one pass with that color without changing the target default: pass color > target.clearColor > the built-in [0, 0, 0, 1].
  • Draws replayed via pass.bundles(...) inside an occlusion scope count toward the active query — render bundles have no query methods, but their draws execute inside the scope.
  • viewport and scissor are set once right after the pass opens and apply to every draw in the pass, including replayed bundles. Both are in physical pixels: surfaces size their textures by devicePixelRatio, so a CSS-pixel rectangle must be scaled by dpr.
  • clear: false preserves color and depth contents within the same target. On Surface, repeated passes in one frame layer onto the same current texture; the first preserved surface pass of a new browser frame reads the swapchain's fresh contents, not the previous frame's image.
  • Hot loops: options bags and pass callbacks are read synchronously, so you can hoist and reuse them. For zero-per-frame-JS-cost replay, record stable work with bundle() and replay the bundle.
  • frame.cancel() discards a frame you decided not to submit: its command encoder is dropped, so nothing it encoded ever runs, and every timer(gpu) / visibility(gpu) attached to it releases the query set it was holding for that frame — no result, no phantom timing, no phantom "hidden". It is the explicit way out of the retain a manual frame(gpu) otherwise keeps until gpu.dispose(): a frame is never assumed abandoned, because an old frame can still be submitted.
  • Frames run by frame(gpu, cb) / frameLoop(gpu, cb) close themselves, atomically with respect to their command buffer: the frame submits once when the callback returns, and is canceled when the callback throws — no command buffer is submitted, the telemetry retains its passes took are released, and the callback's own error is rethrown as-is. A callback that already called frame.submit() keeps that work on the queue and its error is rethrown without a cancel attempt (never masked by VGPU-FRAME-SUBMITTED); one that already called frame.cancel() stays canceled. Only the command buffer is covered: the frame clock has already advanced, CPU-side mutations (effect.set(...), buffer writes) stay applied, and one-shot draws or other frames submitted from inside the callback have already reached the queue. If a pass already targeted a Surface, the canvas still presents that browser frame — as an empty texture, since nothing was drawn into it. A frameLoop tick that throws also stops the loop, because the error escapes the animation-frame callback; start a new frameLoop(gpu, cb) once you have recovered.
  • Cancelling is idempotent, like submitting: a second cancel() does nothing, and submit() after cancel() is a no-op — so calling cancel() in a frame(gpu, cb) callback after its frame.pass(...) calls have returned is safe, the implicit submit simply finds a closed frame. cancel() from inside an active pass callback throws VGPU-FRAME-PASS-ACTIVE, because the native pass descriptor still references its telemetry resources; return from frame.pass(...) before canceling. The reverse is also an error: cancel() after submit() throws VGPU-FRAME-SUBMITTED (the work is already on the queue and cannot be taken back), and pass() or a retained FramePass operation after cancel() throws VGPU-FRAME-CANCELED (it would encode into a dropped encoder and silently never run).
  • frame.done is resolve-only. Await it as a completion/timing signal for readbacks, benchmarks, deterministic tests, or teardown; use gpu.onError plus await gpu.settled() for asynchronous errors.
  • Do not await frame.done inside a RAF/frame loop. Schedule the next frame as soon as frame(gpu) returns, or you serialize CPU and GPU work.
  • See also: frame, Surface, Effect, Draw, Bundle, Target, Timer, Visibility.