Render bundles

A render loop re-encodes every pipeline, bind group, and draw on every tick — even when nothing changed. A bundle records those commands once; replaying it each frame costs almost nothing.

Record once, replay every frame

bundle(gpu) records draws against a target and returns a Bundle. Replay it inside a pass with pass.bundles():

TypeScript
1
2
3
4
5
6
7
8
9
10
const scene = bundle(gpu, { target: canvasTarget }, (b) => {
  b.draw(ocean);
  b.draw(boat);
}); // encoded once, right here
 
const time = clock(gpu);
frameLoop(gpu, (frame) => {
  ocean.set({ params: { time: time.time } }); // uniforms still animate
  frame.pass(canvasTarget, (pass) => pass.bundles(scene)); // replay — no re-encoding
});

Record what doesn't change, set() what does: the bundle references your buffers, so uniform updates flow through on every replay.

Good to know: draws inside a bundle can use different shaders and pipelines. What a bundle freezes is the target's render signature — color formats, depth format, sample count — plus bind groups, not a material or a target size.

Compilation at record time

bundle(gpu) encodes right when you call it, so it needs every pipeline immediately: any draw whose pipeline isn't cached yet for the recording signature compiles synchronously, on the spot. That's the one place vgpu still blocks on pipeline creation — and the reason to pre-warm before recording. If one of those synchronous creates fails, the error reports through gpu.onError, like any lazy compile.

The target option also takes a plain signature, so you can pre-warm and record during load, before the real target exists:

TypeScript
1
2
3
4
5
6
7
8
9
10
11
12
13
await Promise.all([
  ocean.compile({ colors: ['bgra8unorm'] }),
  boat.compile({ colors: ['bgra8unorm'] }),
]);
 
const scene = bundle(gpu, { target: { colors: ['bgra8unorm'] } }, (b) => {
  b.draw(ocean);
  b.draw(boat);
}); // everything was pre-warmed — recording creates nothing
 
frameLoop(gpu, (frame) => {
  frame.pass(canvasTarget, (pass) => pass.bundles(scene));
});

Two caveats. Bindings must be set() before recording — the signature relaxes the target requirement, not the resources. And replay targets must match the recorded signature exactly: when they don't, the error prints both keys, which is how you catch a platform surface-format surprise (bgra8unorm recorded, rgba8unorm actual).

Mix recorded and dynamic draws

A pass can replay bundles and encode fresh draws side by side:

TypeScript
1
2
3
4
5
6
frameLoop(gpu, (frame) => {
  frame.pass(canvasTarget, (pass) => {
    pass.bundles(scene); // the static part, replayed
    pass.draw(cursor); // the dynamic part, encoded fresh on top
  });
});

Some draws must stay on the dynamic side. Draws that set a blendConstant or a stencil ref cannot be recorded — bundle encoders have no way to set those pass-level values — so encode them with pass.draw(). A bundle also cannot replay inside a depthReadOnly pass, because bundles always record with writable depth. Indirect draws record fine: the GPU re-reads the argument buffer on every replay.

Resizes and sampled targets

A bundle matches replay targets by render signature, not size, so drawing onto a resized surface keeps working:

TypeScript
1
2
3
4
5
6
7
8
9
10
function recordScene() {
  return bundle(gpu, { target: canvasTarget }, (b) => b.draw(ocean));
}
 
let scene = recordScene();
canvasTarget.onResize(() => { scene = recordScene(); }); // needed only if the bundle samples resized resources
 
frameLoop(gpu, (frame) => {
  frame.pass(canvasTarget, (pass) => pass.bundles(scene));
});

When not to bother

Recording is not free, and a couple of draws per frame cost almost nothing to encode. Bundles pay off with many draws in a hot loop. The full ladder: effect.draw(target) for a single pass, frame(gpu) to batch passes into one submit, bundle(gpu) to skip re-encoding what never changes.

See it live: the batch rendering example packs four primitive types into one buffer and replays them from a single bundle.

API in this chapter