Symbols in this topic
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
| 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 aSurfaceor offscreenTargetin everyframe.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 aGPUColorobject.VGPU-PASS-PRESERVE-MSAA—clear: falseon an MSAA target; multisample attachments usestoreOp: "discard", so there is nothing to preserve. Render accumulation/preserve passes into a non-MSAA target.VGPU-PASS-CLEARDEPTH-INVALID—clearDepthis not a number in[0, 1], or the target has no depth attachment (the option would have no effect). Create the target withdepth: true, or dropclearDepth.VGPU-PASS-PRESERVE-CLEARDEPTH—clearDepthcombined withclear: false; preserved depth is never cleared. Drop one of the two.VGPU-PASS-CLEARSTENCIL-INVALID—clearStencilis 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 withdepth: "depth24plus-stencil8".VGPU-PASS-PRESERVE-CLEARSTENCIL—clearStencilcombined withclear: false; preserved stencil is never cleared. Drop one of the two.VGPU-PASS-DEPTH-READONLY—depthReadOnlyis not a boolean, is set on a target without depth, or is combined withclearDepth/clearStencil(read-only aspects omit their load/store ops and are never cleared). Also thrown at encode when a draw writes depth — adddepth: { write: false }— or writes stencil (a draw writes stencil when any stencil op on an unculled face is not"keep"and its stencilwriteMaskis nonzero — use"keep"ops orwriteMask: 0); and whenpass.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 withpass.draw(...)instead.VGPU-PASS-DEPTH-READONLY-MSAA—depthReadOnlyon an MSAA target; multisampled depth is stored withstoreOp: "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—viewportis malformed or outside device limits:width/heightin[0, maxTextureDimension2D],x/yat least-2 × maxTextureDimension2Dwithx + width/y + heightat most2 × maxTextureDimension2D − 1,minDepth/maxDepthin[0, 1]withminDepth <= maxDepth. The message names the offending field.VGPU-PASS-SCISSOR-INVALID—scissoris malformed or exceeds the target's current pixel size. Shrink the rectangle, or resize it fromsurface.onResize(...).VGPU-TIMER-INVALID—timeris not aTimerSpanfromtimer.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 (seeTimer).VGPU-VIS-INVALID—visibilityis not aVisibilityfrom this gpu'svisibility(gpu), orocclusion()received a non-VisibilityQueryvalue or a handle of a different visibility instance.VGPU-VIS-NO-DEPTH—visibilityon a pass whose target has no depth attachment; nothing is depth-tested, so every query would report visible. Create the target withdepth: true.VGPU-VIS-DISPOSED— the visibility instance or the handle is disposed.VGPU-VIS-CAPACITY— theocclusion()call exceeded the declared capacity. RaiseVisibilityOptions.capacity.VGPU-QUERY-NO-VISIBILITY—occlusion()in a pass opened withoutvisibility. Pass the instance inFramePassOptions.visibility.VGPU-QUERY-NESTED—occlusion()inside an activeocclusion()body; one scope at a time. Close the outer scope first.VGPU-QUERY-DUPLICATE— a handle used twice within one frame, across passes too (seeVisibility). 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— aframe.pass(...), or a retainedFramePassoperation, on a frame closed bycancel(); its command encoder was dropped, so the work would never run. Open a newframe(gpu).VGPU-FRAME-PASS-ACTIVE—frame.cancel()from inside that frame's active pass callback. Return fromframe.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 bybundle(). Re-record the bundle.- Binding errors such as
VGPU-R1-BINDING-NEVER-SETpropagate during encoding. Raw claimed-group validation is delivered asynchronously throughgpu.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.
Notes
Frame,FramePass, andFrameRunnerare type-only public exports. Create frames throughframe(), notnew Frame(...).- There is no default target and no implicit canvas target; every
frame.passnames its target. - Omitted
clearandclear: trueclear withtarget.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. viewportandscissorare 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 bydevicePixelRatio, so a CSS-pixel rectangle must be scaled by dpr.clear: falsepreserves color and depth contents within the same target. OnSurface, 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 everytimer(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 manualframe(gpu)otherwise keeps untilgpu.dispose(): a frame is never assumed abandoned, because an old frame can still be submitted. Frames run byframe(gpu, cb)/frameLoop(gpu, cb)submit themselves and need no cancel.- Cancelling is idempotent, like submitting: a second
cancel()does nothing, andsubmit()aftercancel()is a no-op — so callingcancel()in aframe(gpu, cb)callback after itsframe.pass(...)calls have returned is safe, the runner's submit infinallysimply finds a closed frame.cancel()from inside an active pass callback throwsVGPU-FRAME-PASS-ACTIVE, because the native pass descriptor still references its telemetry resources; return fromframe.pass(...)before canceling. The reverse is also an error:cancel()aftersubmit()throwsVGPU-FRAME-SUBMITTED(the work is already on the queue and cannot be taken back), andpass()or a retainedFramePassoperation aftercancel()throwsVGPU-FRAME-CANCELED(it would encode into a dropped encoder and silently never run). frame.doneis resolve-only. Await it as a completion/timing signal for readbacks, benchmarks, deterministic tests, or teardown; usegpu.onErrorplusawait gpu.settled()for asynchronous errors.- Do not
await frame.doneinside a RAF/frame loop. Schedule the next frame as soon asframe(gpu)returns, or you serialize CPU and GPU work. - See also:
frame,Surface,Effect,Draw,Bundle,Target,Timer,Visibility.