---
title: Performance playbook: write fast vgpu by default
description: 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.
---

# Performance playbook: write fast vgpu by default



## 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:

```text
frameLoop(gpu, (f) => f.pass({ target: scene }, (p) => {
  p.draw(floor);
  p.draw(walls);
  p.draw(player);
}));
```

After:

```text
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:

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

After:

```text
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:

```text
for (const obj of objects) {
  cube.set({ model: obj.model });
  p.draw(cube);
}
```

After:

```text
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:

```text
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:

```text
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:

```text
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:

```text
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:

```text
for (let i = 0; i < COUNT; i++) {
  particles.set({ particleIndex: i });
  p.draw(particles);
}
```

After:

```text
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:

```text
const time = clock(gpu);
wave.set({ time: time.time, mouse });
blur.set({ time: time.time, mouse });
post.set({ time: time.time, mouse });
```

After:

```text
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:

```text
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:

```text
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:

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

After:

```text
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:

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

After:

```text
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:

```text
f.pass({ target: scene }, (p) => {
  p.draw(world);
  p.draw(statue); // full cost even when a wall hides it
});
```

After:

```text
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:

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

After:

```text
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:

```text
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:

```text
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.


---

For a semantic overview of all documentation, see [/sitemap.md](/sitemap.md)

For an index of all available documentation, see [/llms.txt](/llms.txt)

For agent-facing discovery, including API and MCP surfaces, see [/agents.md](/agents.md)