Performance playbook: write fast vgpu by default

This guide is for LLMs and humans writing shaders. Treat these as default shapes, not late-stage optimizations: each After snippet is the pattern to copy when the situation matches.

1. Bundles / replay (bundle() + p.bundles)

Use when static draws repeat every frame. Bundles freeze commands, bind groups, target formats, sample count, and attachment identity; they do not freeze buffer contents.

Before:

TypeScript
1
2
3
4
5
frameLoop(gpu, (f) => f.pass({ target: scene }, (p) => {
  p.draw(floor);
  p.draw(walls);
  p.draw(player);
}));

After:

TypeScript
1
2
3
4
5
6
7
8
const staticScene = bundle(gpu, { target: scene }, (b) => {
  b.draw(floor);
  b.draw(walls);
});
frameLoop(gpu, (f) => f.pass({ target: scene }, (p) => {
  p.bundles(staticScene);
  p.draw(player);
}));

Default: bundle static work once and replay with p.bundles(...).

2. Pipeline pre-warm (compile)

Use before the first visible frame or route transition. This compiles render pipelines for the target color/depth/MSAA signature before the hitch-sensitive frame.

Before:

TypeScript
1
const cube = draw(gpu, { shader: LIT_WGSL, geometry: geometry(gpu, box()) });

After:

TypeScript
1
2
3
4
const scene = target(gpu, { size: [256, 256], format: "rgba16float", depth: true, msaa: true });
const cube = draw(gpu, { shader: LIT_WGSL, geometry: geometry(gpu, box()) });
await cube.compile(scene);
frame(gpu, (f) => f.pass({ target: scene }, (p) => p.draw(cube)));

Default: await draw.compile(target) for every target signature a draw will hit before the hitch-sensitive frame. targets: [target] remains synchronous creation-time sugar when blocking is acceptable.

3. Manual group claim + dynamic offsets (draw.group)

Use for hundreds or thousands of objects that share one shader and one bind-group layout. draw.group() claims a reflected group; offsets travel per draw call.

Before:

TypeScript
1
2
3
4
for (const obj of objects) {
  cube.set({ model: obj.model });
  p.draw(cube);
}

After:

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
import { UniformPool, type UniformLayout } from "vgpu/core";
 
type ObjectUniforms = { model: Float32Array };
const objectLayout: UniformLayout<ObjectUniforms> = {
  size: 64,
  bindGroupLayout: cube.layout(1, { dynamicOffsets: true }),
  encode(value, dst, byteOffset) {
    new Float32Array(dst, byteOffset, 16).set(value.model);
  },
};
const pool = new UniformPool(gpu.device, { capacityBytes: 1 << 20 });
const slot = pool.alloc(objectLayout);
cube.group(1, slot.bindGroup);
 
frameLoop(gpu, (f) => {
  pool.beginFrame(clock(gpu).frameCount);
  f.pass({ target: scene }, (p) => {
    for (const obj of objects) {
      const offset = slot.push({ model: obj.model });
      p.draw(cube, { offsets: { 1: [offset] } });
    }
  });
  pool.endFrame();
});

Default: for many per-object uniforms, allocate a UniformPool slot with an encode(...) function, call pool.beginFrame(...), push values, draw with offsets, then pool.endFrame() before the frame submits.

4. set() in-place

Use for animated JS values. The first set() latches ownership: plain JS values are lib-owned and update in place; resources are user-owned and keep their identity.

Before:

TypeScript
1
2
3
4
5
const wave = effect(gpu, WAVE_WGSL, { set: { time: 0, speed: 2 } });
frameLoop(gpu, (frame) => {
  wave.set({ time: clock(gpu).time, speed: 2 });
  frame.pass(target, wave);
});

After:

TypeScript
1
2
3
4
5
const wave = effect(gpu, WAVE_WGSL, { set: { time: 0, speed: 2 } });
frameLoop(gpu, (frame) => {
  wave.set({ time: clock(gpu).time });
  frame.pass(target, wave);
});

Default: create once; update changing numbers/vectors/structs with set(). set() performs no equality check — a value written every frame is uploaded every frame, so hoist static and resize-class values out of the render loop.

5. Bake static inputs once

Use when a heavy pass produces a texture that does not change every frame.

Before:

TypeScript
1
2
3
4
5
frameLoop(gpu, (f) => {
  f.pass({ target: baked }, (p) => p.draw(heavyScene));
  post.set({ src: baked.color, texel: baked.texelSize });
  f.pass({ target: screen }, (p) => p.draw(post));
});

After:

TypeScript
1
2
3
frame(gpu, (f) => f.pass({ target: baked }, (p) => p.draw(heavyScene)));
post.set({ src: baked.color, texel: baked.texelSize });
frameLoop(gpu, (f) => f.pass({ target: screen }, (p) => p.draw(post)));

Default: if an input is static, bake it outside the loop with one frame(gpu, ...).

6. Instancing (instances, vertices)

Use for N copies of the same geometry. DrawOptions.instances/vertices/firstInstance set defaults; DrawCallOptions.instances/vertices/firstVertex/firstInstance override per call. instances: 0 is valid; indexed geometries ignore vertices and firstVertex.

Before:

TypeScript
1
2
3
4
for (let i = 0; i < COUNT; i++) {
  particles.set({ particleIndex: i });
  p.draw(particles);
}

After:

TypeScript
1
2
3
4
const particles = draw(gpu, { shader: PARTICLE_WGSL, instances: COUNT, vertices: 6 });
await particles.compile(scene);
particles.set({ particleBuffer });
frameLoop(gpu, (f) => f.pass({ target: scene }, (p) => p.draw(particles)));

Default: one draw with instances beats N draw calls.

7. uniforms(gpu) shared values

Use when many shaders consume the same time, camera, mouse, or exposure values.

Before:

TypeScript
1
2
3
4
const time = clock(gpu);
wave.set({ time: time.time, mouse });
blur.set({ time: time.time, mouse });
post.set({ time: time.time, mouse });

After:

TypeScript
1
2
3
4
5
6
7
8
9
10
const globals = uniforms(gpu, { time: 0, mouse: [0, 0] });
const wave = effect(gpu, WAVE_WGSL, { set: { globals } });
const blur = effect(gpu, BLUR_WGSL, { set: { globals } });
frameLoop(gpu, (frame) => {
  globals.set({ time: clock(gpu).time, mouse });
  frame.pass(target, (pass) => {
    pass.draw(wave);
    pass.draw(blur);
  });
});

Default: shared values belong in one uniforms(gpu) object.

8. Ping-pong (pingPong) without churn + two bundles

Use for iterative effects. Ping-pong keeps two stable identities, so bind-group caches can reuse them.

Before:

TypeScript
1
2
3
4
5
6
frameLoop(gpu, (f) => {
  const tmp = target(gpu, { size: [256, 256], format: "rgba16float" });
  sim.set({ src: previous.color });
  f.pass({ target: tmp }, (p) => p.draw(sim));
  previous = tmp;
});

After:

TypeScript
1
2
3
4
5
6
7
8
9
10
11
const state = pingPong(gpu, 512, 512, { format: "rgba16float" });
const even = bundle(gpu, { target: state.write }, (b) => { sim.set({ src: state.read.color }); b.draw(sim); });
state.swap();
const odd = bundle(gpu, { target: state.write }, (b) => { sim.set({ src: state.read.color }); b.draw(sim); });
state.swap();
let parity = 0;
frameLoop(gpu, (f) => {
  f.pass({ target: state.write }, (p) => p.bundles(parity === 0 ? even : odd));
  state.swap();
  parity ^= 1;
});

Default: create ping-pong resources once; if you bundle, record both parity cases and replay the matching one.

9. MSAA/depth in the target

Use for 3D anti-aliasing and depth testing. Resolution, depth, color format, and sample count are target state.

Before:

TypeScript
1
2
const scene = target(gpu, { size: [256, 256], format: "rgba8unorm" });
const cube = draw(gpu, { shader: LIT_WGSL, geometry: geometry(gpu, box()) });

After:

TypeScript
1
2
3
4
const scene = target(gpu, { size: [256, 256], format: "rgba16float", depth: true, msaa: true });
const cube = draw(gpu, { shader: LIT_WGSL, geometry: geometry(gpu, box()) });
await cube.compile(scene);
frameLoop(gpu, (f) => f.pass({ target: scene, clear: [0, 0, 0, 1] }, (p) => p.draw(cube)));

Default: put depth/MSAA on the target; do not invent global render settings.

10. Back-face culling (cull: "back")

Use for closed geometries. With the default cull: "none", triangles facing away from the camera still rasterize; culling them drops roughly half of a closed geometry's fragment work.

Before:

TypeScript
1
const cube = draw(gpu, { shader: LIT_WGSL, geometry: geometry(gpu, box()) });

After:

TypeScript
1
const cube = draw(gpu, { shader: LIT_WGSL, geometry: geometry(gpu, box()), cull: "back" });

Default: cull: "back" for closed geometries. Keep "none" for planes, alpha-tested foliage, and anything seen from both sides.

11. Occlusion culling (visibility())

Use for many-object scenes with large occluders. Query a cheap proxy — a bounding box — and skip the expensive draw when the GPU confirmed it was hidden.

Before:

TypeScript
1
2
3
4
f.pass({ target: scene }, (p) => {
  p.draw(world);
  p.draw(statue); // full cost even when a wall hides it
});

After:

TypeScript
1
2
3
4
5
6
7
8
9
const vis = visibility(gpu);
const qStatue = vis.query("statue");
frameLoop(gpu, (f) => {
  f.pass({ target: scene, visibility: vis }, (p) => {
    p.draw(world); // occluders first
    p.occlusion(qStatue, statueProxy); // cheap bounding-box proxy
    if (!qStatue.hidden) p.draw(statue);
  });
});

Default: draw occluders first, query proxies, condition real draws on hidden. Results lag one or two frames and hidden stays false until a query confirms zero passing samples, so the fallback is always to draw. The pass target needs depth: true.

12. Indirect draws and dispatches (indirect)

Use when the GPU decides the counts — compute-driven particles, culled instance lists. Reading counts back to the CPU stalls on a round-trip; indirect keeps them on the GPU.

Before:

TypeScript
1
2
const data = await counts.read(); // GPU-to-CPU round-trip, a frame late
p.draw(particles, { instances: decodeCount(data) });

After:

TypeScript
1
2
3
const args = storage(gpu, 16, { indirect: true });
emit.dispatch(Math.ceil(COUNT / 64)); // compute writes the draw arguments into `args`
frameLoop(gpu, (f) => f.pass({ target: scene }, (p) => p.draw(particles, { indirect: args })));

Default: counts produced on the GPU stay on the GPU. The same option shape drives compute: sim.dispatch({ indirect: args }).

13. Time passes before optimizing (timer())

Use before reaching for any pattern above. CPU timers see encoding only — encoders record commands, the GPU runs them later — so a "slow pass" verdict needs GPU timestamps.

Before:

TypeScript
1
2
3
const t0 = performance.now();
frame(gpu, (f) => f.pass({ target: scene }, (p) => p.draw(world)));
const ms = performance.now() - t0; // encode + submit time, not GPU cost

After:

TypeScript
1
2
3
4
const gpu = await init({ requiredFeatures: ["timestamp-query"] });
const timer = timer(gpu);
timer.onResults((spans) => console.log(`main ${spans.main}ms`));
frameLoop(gpu, (f) => f.pass({ target: scene, timer: timer.span("main") }, (p) => p.draw(world)));

Default: attach timer.span(name) to each pass you plan to touch and optimize the worst milliseconds first. Open measuring for what else to measure.