Compilation
Pipelines compile lazily: the first draw() against a new target pays the pipeline creation cost, and that cost lands inside your frame. WebGPU keys pipelines by shader and render signature — the tuple of color formats, depth format, and sample count — so the same WGSL rendering into a canvas and into an MSAA target means two compilations. compile() moves that work into load time.
Pre-warming with a target
Most of the time you already have the target in hand. await draw.compile(target) and await effect.compile(target) warm exactly that signature and resolve back to the same object:
import { init, draw, effect, surface } from "vgpu";
const gpu = await init();
const canvas = document.querySelector("canvas")!;
const canvasSurface = surface(gpu, canvas);
// ---cut---
const ocean = effect(gpu, `
@fragment fn fs_main(@location(0) uv: vec2f) -> @location(0) vec4f {
return vec4f(uv, 0.8, 1.0);
}
`);
const tri = draw(gpu, {
shader: `
struct Out { @builtin(position) position: vec4f }
@vertex fn vs_main(@builtin(vertex_index) vi: u32) -> Out {
var pts = array<vec2f, 3>(vec2f(-0.5, -0.5), vec2f(0.5, -0.5), vec2f(0.0, 0.5));
var out: Out;
out.position = vec4f(pts[vi], 0.0, 1.0);
return out;
}
@fragment fn fs_main() -> @location(0) vec4f { return vec4f(1.0, 0.4, 0.2, 1.0); }
`,
});
await Promise.all([ocean.compile(canvasSurface), tri.compile(canvasSurface)]);
tri.draw(canvasSurface);
ocean.draw(canvasSurface);The pipelines are cached per signature at the device level, so those first draw() calls — and every draw after them — just encode work.
Compiling without a target
Sometimes the target doesn't exist yet. Pass a signature object instead: colors is required, depth and sampleCount are optional.
import { init, draw, geometry } from "vgpu";
import { box } from "vgpu/scene";
const gpu = await init();
const sceneShader = `/* vertex + fragment WGSL */`;
const msaaScene = draw(gpu, { shader: sceneShader, geometry: geometry(gpu, box({ size: 1 })) });
await msaaScene.compile({
colors: ['bgra8unorm'],
depth: 'depth24plus',
sampleCount: 4,
});Good to know: surface formats are platform-dependent —
bgra8unormon most browsers,rgba8unormon others. Compiling the wrong signature doesn't error; it's just a warm-up you didn't need, and the real draw compiles lazily on first use anyway. When in doubt, compile against the actual target.
compileSync()
compileSync(target) is the blocking twin: same cache, same signatures, but it creates the pipeline right now. Use it in tools and tests where jank doesn't matter. If an async compile() for the same signature is in flight, the synchronous result wins and the pending promise resolves with it.
import { init, draw, target } from "vgpu";
const gpu = await init();
const offscreen = target(gpu, { size: [2048, 2048], depth: true });
const grid = draw(gpu, {
shader: `
@vertex fn vs_main(@builtin(vertex_index) vi: u32) -> @builtin(position) vec4f {
var pts = array<vec2f, 3>(vec2f(-1.0, -1.0), vec2f(3.0, -1.0), vec2f(-1.0, 3.0));
return vec4f(pts[vi], 0.0, 1.0);
}
@fragment fn fs_main() -> @location(0) vec4f {
return vec4f(0.1, 0.4, 0.7, 1.0);
}
`,
});
grid.compileSync(offscreen);Errors
A failed compile() rejects its promise — the error belongs to the call site, so catch it where you scheduled the warm-up:
import { init, draw } from "vgpu";
const gpu = await init();
const tri = draw(gpu, { shader: `@vertex fn vs_main() -> @builtin(position) vec4f { return vec4f(0); }` });
try {
await tri.compile({ colors: ['bgra8unorm'] });
} catch (error) {
console.error('Pipeline failed to compile', error);
}The lazy path is different: since draw() returns immediately, a pipeline that fails to compile on first use reports through gpu.onError, and gpu.settled() lets tests wait for those deliveries. Pre-warmed or not, the failure never lands twice.
Render bundles
Recording a bundle needs every pipeline immediately, so anything you didn't pre-warm compiles synchronously at record time. See compilation at record time for that flow.