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:
frameLoop(gpu, (f) => f.pass({ target: scene }, (p) => {
p.draw(floor);
p.draw(walls);
p.draw(player);
}));After:
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:
const cube = draw(gpu, { shader: LIT_WGSL, geometry: geometry(gpu, box()) });After:
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:
for (const obj of objects) {
cube.set({ model: obj.model });
p.draw(cube);
}After:
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:
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:
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:
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:
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:
for (let i = 0; i < COUNT; i++) {
particles.set({ particleIndex: i });
p.draw(particles);
}After:
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:
const time = clock(gpu);
wave.set({ time: time.time, mouse });
blur.set({ time: time.time, mouse });
post.set({ time: time.time, mouse });After:
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:
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:
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:
const scene = target(gpu, { size: [256, 256], format: "rgba8unorm" });
const cube = draw(gpu, { shader: LIT_WGSL, geometry: geometry(gpu, box()) });After:
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:
const cube = draw(gpu, { shader: LIT_WGSL, geometry: geometry(gpu, box()) });After:
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:
f.pass({ target: scene }, (p) => {
p.draw(world);
p.draw(statue); // full cost even when a wall hides it
});After:
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:
const data = await counts.read(); // GPU-to-CPU round-trip, a frame late
p.draw(particles, { instances: decodeCount(data) });After:
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:
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 costAfter:
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.