Skip to content

@typegpu/kitframes

Frames

One encoder and one submit per frame, with a clock and callbacks for after the submit.

kit.frames

module

frames.create(root, record) → Frames

frames.encoderOf(target)

frames.animate(loop, options?) → Animation

rootborrows
the root
recordtakes
your frame, recorded into each frame's encoder
Framesreturns
a loop you run with each frame's time and destroy
targettakes
a frame or an encoder
looptakes
the loop to run on each unpaused frame. Continues from its seconds if it ran before
Animationreturns
an animation you destroy to stop the frames
types
AnimationScheduleFrameFrames
AnimateOptionsanimate · 4 fields
surface?

{ readonly paused: boolean }= none

Stops frames and elapsed time while paused is true.

maxDt?

number= 0.1

Limits the time added per frame, in seconds, after a stall or when a tab becomes visible.

schedule?

Schedule= requestAnimationFrame

Supplies animation frames.

onError?

(error: unknown) => void= none

Receives a frame error after the animation stops.

submits

every frame, on its own

Once a frame after your recorder returns, and again at each frame.submit().

lifecycle

destroy()[Symbol.dispose]

Make the frame throw to see writes arrive while recorded commands and callbacks are dropped. Calls appear on the left and GPU execution on the right, with queue writes before the command buffer.

Modules record into an encoder they are handed. The loop makes one each frame, runs your recorder with it and submits once, so a GPU capture shows the whole frame.

import {
import frames
frames
} from '@typegpu/kit';
const
const loop: frames.Frames
loop
=
import frames
frames
.
function create(root: TgpuRoot, record: Recorder): frames.Frames

Records a frame into one encoder and submits once, with a clock. Borrows the root. The synchronous recorder borrows each frame's encoder.

@submits ― always once a frame after your recorder returns, and again at each frame.submit()

@param ― record your frame, recorded into each frame's encoder

@returns ― a loop you run with each frame's time and destroy

create
(
const root: TgpuRoot
root
, ({
encoder: TgpuCommandEncoder

Records commands for submission after the recorder returns. submit() replaces this encoder. Read it at each call instead of keeping it, and let the frame submit it.

encoder
,
time: number

Reports seconds since the first submitted frame.

time
,
dt: number

Reports seconds since the previous submitted frame. The first frame has a dt of zero.

dt
,
index: number

Counts frames submitted before this one.

index
}) => {
// record passes into `encoder`; don't submit it
});
import frames
frames
.
function animate(loop: Pick<frames.Frames, "run"> & Partial<Pick<frames.Frames, "seconds">>, options?: frames.AnimateOptions): frames.Animation
export animate

Runs the loop on each scheduled frame. Time advances by at most maxDt per frame, never steps back, and stops while the surface is paused. Destroying the animation keeps the loop alive. Another animation on the same loop continues from its recorded time. A frame error stops the animation.

@param ― loop the loop to run on each unpaused frame. Continues from its seconds if it ran before

@returns ― an animation you destroy to stop the frames

animate
(
const loop: frames.Frames
loop
); // calls loop.run(seconds) at every animation frame
  • time is seconds since the first submitted frame. dt is seconds since the previous frame and is 0 on the first. index counts frames submitted before this one. loop.seconds holds the time of the last submitted frame, so a frame that throws leaves it unchanged.
  • interval estimates the display’s frame interval. It follows the shortest recent frames, so a frame that misses the display barely moves it. Budget per-frame work with it, as dynamic resolution does.
  • The loop schedules nothing. run(seconds) records one frame and submits it; a time that isn’t finite, or goes back, throws a RangeError.
  • frames.animate(loop) calls run at every animation frame. A test or a benchmark calls run itself.
  • The recorder is synchronous. Returning a promise throws a TypeError, and so does calling run from inside it.
  • If the recorder throws or calls destroy(), the frame submits no further commands and the clock doesn’t advance. Queue writes and commands already sent with frame.submit() still run. After destroy(), run returns false.

frames.animate(loop, options) runs the loop at every animation frame, on the web and React Native.

  • Each frame advances time by at most maxDt, which defaults to 0.1 s. This caps the time added after a stall or a hidden tab resumes.
  • With a surface, animation and time stop while it is paused. The first frame after resume() has a dt of 0. On React Native, pause it from AppState. On the web, pause it for a canvas scrolled out of view or a pause menu; browsers already stop a hidden tab’s frames.
  • destroy() on what it returns requests no more frames; inside a frame, such as at game over, it makes that frame the last. The loop stays yours.
  • An error ends animation and reaches onError once. Without onError, the animation frame callback throws it.
  • frame.time is the loop’s own time, so it drifts from performance.now() after the first pause or stall. Read performance.now() for wall time.

A canvas that draws only while on screen pauses its surface from an IntersectionObserver:

import frames
frames
.
function animate(loop: Pick<frames.Frames, "run"> & Partial<Pick<frames.Frames, "seconds">>, options?: frames.AnimateOptions): frames.Animation
export animate

Runs the loop on each scheduled frame. Time advances by at most maxDt per frame, never steps back, and stops while the surface is paused. Destroying the animation keeps the loop alive. Another animation on the same loop continues from its recorded time. A frame error stops the animation.

@param ― loop the loop to run on each unpaused frame. Continues from its seconds if it ran before

@returns ― an animation you destroy to stop the frames

animate
(
const loop: frames.Frames
loop
, {
AnimateOptions.surface?: {
readonly paused: boolean;
} | undefined

Stops frames and elapsed time while paused is true. The first frame after resume() has a dt of 0. On React Native, pass the canvas surface and pause it from AppState. On the web, pause it from an IntersectionObserver or a pause menu. Without a surface, frames run until the animation is destroyed. Default none.

surface
});
new
var IntersectionObserver: new (callback: IntersectionObserverCallback, options?: IntersectionObserverInit) => IntersectionObserver

The IntersectionObserver interface of the Intersection Observer API provides a way to asynchronously observe changes in the intersection of a target element with an ancestor element or with a top-level document's viewport. The ancestor element or viewport is referred to as the root.

MDN Reference

IntersectionObserver
(([
entry: IntersectionObserverEntry
entry
]) =>
entry: IntersectionObserverEntry
entry
?.
IntersectionObserverEntry.isIntersecting: boolean

The isIntersecting read-only property of the IntersectionObserverEntry interface is a Boolean value which is true if the target element intersects with the intersection observer's root.

MDN Reference

isIntersecting
?
const surface: web.Surface
surface
.
Surface.resume(): void
resume
() :
const surface: web.Surface
surface
.
Surface.pause(): void

Pauses the surface for a canvas outside the viewport or a pause menu. Hidden tabs need no call, because browsers stop their animation frames.

pause
(),
).
IntersectionObserver.observe(target: Element): void

The observe() method of the IntersectionObserver interface adds an element to the set of target elements being watched by the IntersectionObserver. One observer has one set of thresholds and one root, but can watch multiple target elements for visibility changes in keeping with those.

MDN Reference

observe
(
const canvas: HTMLCanvasElement
canvas
);

A readback records its copy in the frame but can only map it once the copy is submitted. A module that has to wait for the submit registers a callback while it records:

function
function createProbe(root: TgpuRoot): {
record(frame: Frame): void;
}
createProbe
(
root: TgpuRoot
root
:
(alias) interface TgpuRoot
import TgpuRoot
TgpuRoot
/* , ... */) {
const
const map: () => void
map
= () => {
// staging.mapAsync(GPUMapMode.READ).then(...)
};
return {
function record(frame: Frame): void
record
(
frame: Frame
frame
:
(alias) interface Frame
import Frame

Represents one frame being recorded. Each run creates a separate object, so modules can compare frames by identity.

Frame
) {
// copy into the staging buffer on frame.encoder
frame: Frame
frame
.
Frame.afterSubmit(callback: () => void): void

Calls callback after the frame's last submit, in registration order. Drops it if the frame does not finish. Throws unless this frame is recording, so a retained frame cannot add callbacks to a later one. Create the callback outside the frame to avoid allocating.

afterSubmit
(
const map: () => void
map
);
},
};
}
  • Callbacks run right after the frame’s last submit, in registration order, and only for frames that finish.
  • Every callback runs even if one throws. The frame still counts. run throws the callback error afterwards, or an AggregateError if several failed.
  • afterSubmit throws unless its own frame is recording. Each run makes a new Frame, so a module that acts once a frame tells frames apart by it.
  • Create the callback once, as above, so recording allocates nothing.

frame.submit() submits the commands recorded so far. The next access to frame.encoder creates another encoder, and the loop submits the remaining commands when your recorder returns. Try the per-step values first. Each early submit adds a queue.submit and splits the frame in a GPU capture.

function
function frame(frame: Frame): void
frame
(
frame: Frame
frame
:
(alias) interface Frame
import Frame

Represents one frame being recorded. Each run creates a separate object, so modules can compare frames by identity.

Frame
) {
for (const
const phase: number
phase
of
const phases: number[]
phases
) {
const params: TgpuUniform<d.WgslStruct<{
phase: d.F32;
}>>
params
.
TgpuBufferBindingBase<WgslStruct<{ phase: F32; }>>.write(data: d.InferInput<d.WgslStruct<{
phase: d.F32;
}>>, options?: BufferWriteOptions): void
write
({
phase: number
phase
});
const
const pass: TgpuComputePass
pass
=
frame: Frame
frame
.
Frame.encoder: TgpuCommandEncoder

Records commands for submission after the recorder returns. submit() replaces this encoder. Read it at each call instead of keeping it, and let the frame submit it.

encoder
.
TgpuCommandEncoder.beginComputePass(descriptor?: TgpuComputePassDescriptor): TgpuComputePass

Begins recording a compute pass

beginComputePass
();
const pass: TgpuComputePass
pass
.
TgpuComputePass.setPipeline(pipeline: TgpuComputePipeline): void

Sets the current

TgpuComputePipeline

for subsequent dispatches

setPipeline
(
const step: TgpuComputePipeline
step
);
const pass: TgpuComputePass
pass
.
TgpuComputePass.dispatchWorkgroups(x: number, y?: number, z?: number): void
dispatchWorkgroups
(64);
const pass: TgpuComputePass
pass
.
TgpuComputePass.end(): void

Completes the recording of this compute pass

end
();
frame: Frame
frame
.
Frame.submit(): void

Submits the recorded commands and replaces encoder for further recording. Each call adds one queue.submit. End every pass first, because an open pass invalidates the command buffer. Only the program's recorder should call this. Throws unless this frame is recording. Does nothing after the loop is destroyed.

submit
(); // queued ahead of the next write
}
// the draws go into a fresh frame.encoder, which the loop submits
}
  • End every pass first. A submit with a pass open is a WebGPU validation error that voids everything recorded since the last submit; a recording root throws there instead.
  • Only the recorder you pass to frames.create calls it; a module never submits. A module reads frame.encoder at each call and never keeps it, since a kept encoder may have been submitted.
  • A recording method that takes a frame or an encoder reads the encoder with frames.encoderOf(target) at each call.
  • A frame ending with submit() sends no empty command buffer. It creates another encoder only if frame.encoder is read again.

Submit once per frame unless an early submit is required. Use one when:

  • A step reads a value you can’t supply another way, such as a library kernel that reads its own createUniform.
  • A library call submits its own work during a frame. Examples include a pipeline drawn without an encoder and a mesh pool’s add without an encoder. That work runs before your frame’s pending commands. Submit first to preserve order, or pass the encoder where supported.
  • A command may be invalid. WebGPU invalidates its whole command buffer, while commands submitted before it still run.
  • The GPU could start sooner. Submitting the simulation before recording draws lets the GPU work while the CPU records the rest.

A benchmark or GPU capture needs the same steps on every run, regardless of display rate. Leave out frames.animate and web.host, make the loop yourself, and call run once per step:

const
const STEP: number
STEP
= 1 / 60;
const
const STEPS: 600
STEPS
= 600;
const
const loop: frames.Frames
loop
=
import frames
frames
.
function create(root: TgpuRoot, record: Recorder): frames.Frames

Records a frame into one encoder and submits once, with a clock. Borrows the root. The synchronous recorder borrows each frame's encoder.

@submits ― always once a frame after your recorder returns, and again at each frame.submit()

@param ― record your frame, recorded into each frame's encoder

@returns ― a loop you run with each frame's time and destroy

create
(
const root: TgpuRoot
root
, (
frame: frames.Frame
frame
) =>
const scene: {
frame(frame: Frame): void;
}
scene
.
function frame(frame: Frame): void
frame
(
frame: frames.Frame
frame
));
const
const start: number
start
=
var performance: Performance
performance
.
Performance.now(): DOMHighResTimeStamp

The performance.now() method returns a high resolution timestamp in milliseconds. It represents the time elapsed since Performance.timeOrigin (the time when navigation has started in window contexts, or the time when the worker is run in Worker and ServiceWorker contexts).

MDN Reference

now
();
for (let
let i: number
i
= 0;
let i: number
i
<
const STEPS: 600
STEPS
;
let i: number
i
++) {
const loop: frames.Frames
loop
.
Frames.run(seconds: number): boolean

Records and submits one frame with a fresh Frame and encoder. Requires finite, nondecreasing seconds. Throws for an invalid time, a nested frame or an asynchronous recorder. Returns false if the loop is destroyed before or during recording. Runs all after-submit callbacks. If any throw, the frame still counts and run rethrows their errors.

run
(
let i: number
i
*
const STEP: number
STEP
); // one step, one submit
}
await
const root: TgpuRoot
root
.
TgpuRoot.device: GPUDevice

The GPU device associated with this root.

device
.
GPUDevice.queue: GPUQueue

The queue read-only property of the GPUDevice interface returns the primary GPUQueue for the device.

MDN Reference

The primary

GPUQueue

for this device.

queue
.
GPUQueue.onSubmittedWorkDone(): Promise<undefined> (+1 overload)

Returns a Promise that resolves once this queue finishes processing all the work submitted up to this moment. Resolution of this Promise implies the completion of

GPUBuffer#mapAsync

calls made prior to that call, on

GPUBuffer

s last used exclusively on that queue.

onSubmittedWorkDone
();
const
const msPerStep: number
msPerStep
= (
var performance: Performance
performance
.
Performance.now(): DOMHighResTimeStamp

The performance.now() method returns a high resolution timestamp in milliseconds. It represents the time elapsed since Performance.timeOrigin (the time when navigation has started in window contexts, or the time when the worker is run in Worker and ServiceWorker contexts).

MDN Reference

now
() -
const start: number
start
) /
const STEPS: 600
STEPS
;
  • run takes the time, not the step, so i * STEP gives every frame a dt of one step.
  • Each run submits, so a uniform written in one step lands after the step before it was submitted. No count on the GPU is needed here.
  • onSubmittedWorkDone() resolves once the GPU has finished everything submitted. Wait for it before stopping a timer or reading results.
  • A fixed step of the same length runs exactly one step per frame. A canvas surface of a fixed size draws the same pixels on every screen, and offscreenSurface from @typegpu/kit/testing gives the scene a surface without a canvas.