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
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
import { init, bundle, clock, effect, frameLoop, surface } from "vgpu";
 
const gpu = await init();
const canvas = document.querySelector("canvas")!;
const canvasTarget = surface(gpu, canvas);
const ocean = effect(gpu, `
  struct Params { time: f32 }
  @group(0) @binding(0) var<uniform> params: Params;
 
  @fragment fn fs_main(@location(0) uv: vec2f) -> @location(0) vec4f {
    return vec4f(0.1, 0.3, sin(params.time + uv.y) * 0.2 + 0.6, 1.0);
  }
`, { set: { params: { time: 0 } } });
const boat = effect(gpu, `
  @fragment fn fs_main(@location(0) uv: vec2f) -> @location(0) vec4f {
    return vec4f(0.6, 0.4, 0.2, step(distance(uv, vec2f(0.5, 0.6)), 0.1));
  }
`);
 
// ---cut---
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
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
import { init, bundle, clock, effect, frameLoop, surface } from "vgpu";
 
const gpu = await init();
const canvas = document.querySelector("canvas")!;
const canvasTarget = surface(gpu, canvas);
const ocean = effect(gpu, `
  @fragment fn fs_main(@location(0) uv: vec2f) -> @location(0) vec4f {
    return vec4f(0.1, 0.3, 0.6, 1.0);
  }
`);
const boat = effect(gpu, `
  @fragment fn fs_main(@location(0) uv: vec2f) -> @location(0) vec4f {
    return vec4f(0.6, 0.4, 0.2, 1.0);
  }
`);
 
// ---cut---
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
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
import { init, bundle, clock, effect, frameLoop, surface } from "vgpu";
 
const gpu = await init();
const canvas = document.querySelector("canvas")!;
const canvasTarget = surface(gpu, canvas);
const ocean = effect(gpu, `
  @fragment fn fs_main(@location(0) uv: vec2f) -> @location(0) vec4f {
    return vec4f(0.1, 0.3, 0.6, 1.0);
  }
`);
const cursor = effect(gpu, `
  struct Params { pos: vec2f }
  @group(0) @binding(0) var<uniform> params: Params;
 
  @fragment fn fs_main(@location(0) uv: vec2f) -> @location(0) vec4f {
    return vec4f(1.0, 1.0, 1.0, step(distance(uv, params.pos), 0.02));
  }
`, { set: { params: { pos: [0.5, 0.5] } } });
const scene = bundle(gpu, { target: canvasTarget }, (b) => b.draw(ocean));
 
// ---cut---
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
11
12
13
14
15
16
17
18
19
20
21
22
import { init, bundle, clock, effect, frameLoop, surface } from "vgpu";
 
const gpu = await init();
const canvas = document.querySelector("canvas")!;
const canvasTarget = surface(gpu, canvas);
const ocean = effect(gpu, `
  @fragment fn fs_main(@location(0) uv: vec2f) -> @location(0) vec4f {
    return vec4f(0.1, 0.3, 0.6, 1.0);
  }
`);
 
// ---cut---
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.