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():
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:
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:
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:
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.