---
title: Frame
description: `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.
---

# Frame



## Import

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

## Signature

```ts
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

| Param                | Type                                              | Required | Default        | Notes                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                              |
| -------------------- | ------------------------------------------------- | -------: | -------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| frame.cb             | `(frame: Frame) => void`                          |        ✖ | `undefined`    | If supplied, called and then `frame.submit()` runs in `finally`. If omitted, submit manually.                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                      |
| target.clearColor    | `ClearColor`                                      |        ✖ | `[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.target    | `Target \| FramePassOptions`                      |        ✔ | —              | Pass a bare target for the allocation-free common case, or an options bag when customizing clear/preserve behavior.                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                |
| opts.target          | `Target`                                          |        ✔ | —              | Required inside `FramePassOptions`. Use a `Surface` from `surface(gpu, canvas)` or an offscreen `Target` from `target(gpu, { size })`.                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                             |
| opts.clear           | `boolean \| ClearColor`                           |        ✖ | `true`         | Omitted or `true` clears with `target.clearColor`; `false` preserves existing color and depth with load ops; a color clears this pass with that color.                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                             |
| opts.clearDepth      | `number`                                          |        ✖ | `1`            | Depth 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.clearStencil    | `number`                                          |        ✖ | `0`            | Stencil 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.depthReadOnly   | `boolean`                                         |        ✖ | `false`        | Opens 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 target    | Restricts 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.scissor         | `readonly [number, number, number, number]`       |        ✖ | full target    | Clips 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.timer           | `TimerSpan`                                       |        ✖ | `undefined`    | Times 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.visibility      | `Visibility`                                      |        ✖ | `undefined`    | Enables 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.body      | `Effect \| Draw \| ((pass: FramePass) => void)`   |        ✔ | —              | Pass a drawable directly for a single draw, or a callback to encode multiple draw and bundle commands.                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                             |
| pass.draw\.drawable  | `Draw \| Effect`                                  |        ✔ | —              | A main API (`vgpu`) draw or fullscreen effect.                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                     |
| pass.draw\.opts      | `DrawCallOptions`                                 |        ✖ | `{}`           | Per-call counts and dynamic offsets. Target is the frame pass target.                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                              |
| pass.occlusion.query | `VisibilityQuery`                                 |        ✔ | —              | 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.body  | `Draw \| 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.bundles | `readonly Bundle[]`                               |        ✔ | —              | Bundles recorded by `bundle(gpu, { target }, cb)`.                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                 |
| runner.loop.cb       | `(frame: Frame) => void`                          |        ✔ | —              | Called on each scheduled frame; frame is submitted in `finally`. Surface auto-resize runs before this callback.                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                    |
| runner.loop.opts.fps | `number`                                          |        ✖ | `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

```ts
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
});
```

```ts
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();
```

```ts
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.

```ts
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.

```ts
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.

## 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)` submit themselves and need no cancel.
* 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 runner's submit in `finally` 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`.


---

For a semantic overview of all documentation, see [/sitemap.md](/sitemap.md)

For an index of all available documentation, see [/llms.txt](/llms.txt)

For agent-facing discovery, including API and MCP surfaces, see [/agents.md](/agents.md)