Batch Rendering
Four primitive ranges in one mesh, recorded once as a render bundle.
import type { Bundle, Draw, Effect, Frame, Gpu, Geometry, Surface, Target } from 'vgpu';
import { perspectiveCamera } from 'vgpu/scene';
import sceneWgsl from './scene.wgsl';
import blitWgsl from './blit.wgsl';
import type { BrowserRendererOptions, ExampleRenderer, RenderSize, ThumbnailOptions } from '../../lib/example-renderer';
import { bundle, clock, draw, effect, frame, frameLoop, geometry, sampler, surface, target } from "vgpu";
type Output = Surface | Target;
interface ThumbOptions extends ThumbnailOptions {}
interface Scene { geometry: Geometry; draws: readonly Draw[]; bundle: Bundle }
const CLEAR = [0.008, 0.014, 0.035, 1] as const;
export function createRenderer(options: BrowserRendererOptions): ExampleRenderer {
let disposed = false;
let gpu: Gpu | undefined;
let canvasSurface: Surface | undefined;
let colorTarget: Target | undefined;
let blit: Effect | undefined;
let scene: Scene | undefined;
let loop: { stop(): void } | undefined;
let observer: ResizeObserver | undefined;
let resizeFrame = 0;
let pendingSize: RenderSize | undefined;
let lastDpr = typeof window === 'undefined' ? 1 : window.devicePixelRatio;
let reportedError = false;
const applyResize = () => {
resizeFrame = 0;
const size = pendingSize;
pendingSize = undefined;
if (disposed || !size || !colorTarget || !blit || !canvasSurface) return;
colorTarget.resize([
Math.max(1, Math.round(size.width * size.dpr)),
Math.max(1, Math.round(size.height * size.dpr)),
]);
setBlitSource(blit, colorTarget, canvasSurface);
};
const resize = (size: RenderSize) => {
if (disposed || size.width <= 0 || size.height <= 0) return;
pendingSize = size;
if (!resizeFrame) resizeFrame = requestAnimationFrame(applyResize);
};
const measure = () => {
const rect = options.canvas.getBoundingClientRect();
resize({ width: rect.width, height: rect.height, dpr: Math.min(2, Math.max(1, window.devicePixelRatio || 1)) });
};
const onWindowResize = () => {
if (window.devicePixelRatio === lastDpr) return;
lastDpr = window.devicePixelRatio;
measure();
};
const dispose = () => {
if (disposed) return;
disposed = true;
loop?.stop();
loop = undefined;
if (resizeFrame) cancelAnimationFrame(resizeFrame);
resizeFrame = 0;
pendingSize = undefined;
observer?.disconnect();
observer = undefined;
if (typeof window !== 'undefined') window.removeEventListener('resize', onWindowResize);
scene?.geometry.destroy();
scene = undefined;
(colorTarget as { destroy?: () => void } | undefined)?.destroy?.();
colorTarget = undefined;
canvasSurface?.dispose();
canvasSurface = undefined;
gpu?.dispose();
gpu = undefined;
};
const initialize = async () => {
const { init } = await import('vgpu');
if (disposed) return;
const nextGpu = await init();
if (disposed) { nextGpu.dispose(); return; }
gpu = nextGpu;
canvasSurface = surface(gpu, options.canvas, { dpr: [1, 2] });
colorTarget = target(gpu, { size: canvasSurface.size, format: 'rgba8unorm', depth: true });
blit = createBlit(gpu, colorTarget, canvasSurface);
const nextScene = await createScene(gpu, colorTarget);
if (disposed) {
nextScene.geometry.destroy();
return;
}
scene = nextScene;
observer = typeof ResizeObserver === 'undefined' ? undefined : new ResizeObserver(measure);
observer?.observe(options.canvas);
window.addEventListener('resize', onWindowResize);
measure();
const gpuClock = clock(gpu);
loop = frameLoop(gpu, (currentFrame) => render(currentFrame, scene!, blit!, colorTarget!, canvasSurface!, gpuClock.time));
};
function handleFailure(error: unknown): void {
if (disposed) return;
if (!reportedError) {
reportedError = true;
try { options.onError?.(error); } catch { /* error reporting must not block teardown */ }
}
dispose();
}
const ready = initialize().catch((error: unknown) => {
if (disposed) return;
handleFailure(error);
throw error;
});
return { ready, invalidate() {}, resize, dispose };
}
export async function renderThumbnail(gpu: Gpu, output: Target, opts: ThumbOptions = {}): Promise<void> {
const colorTarget = target(gpu, { size: output.size, format: 'rgba8unorm', depth: true });
let scene: Scene | undefined;
try {
const blit = createBlit(gpu, colorTarget, output);
scene = await createScene(gpu, colorTarget);
await blit.compile(output);
let time = opts.time ?? 2.4;
for (let i = 0; i < (opts.warmupFrames ?? 3); i++) {
time += opts.dt ?? 1 / 60;
frame(gpu, (currentFrame) => render(currentFrame, scene!, blit, colorTarget, output, time));
}
} finally {
await Promise.allSettled([
Promise.resolve().then(() => gpu.gpu.queue.onSubmittedWorkDone()),
Promise.resolve().then(() => gpu.settled()),
]);
scene?.geometry.destroy();
(colorTarget as { destroy?: () => void }).destroy?.();
}
}
async function createScene(gpu: Gpu, colorTarget: Target): Promise<Scene> {
const groups = packedGeometry();
const counts = groups.map((group) => group.length / 9);
const data = new Float32Array(groups.reduce((sum, group) => sum + group.length, 0));
let offset = 0;
for (const group of groups) { data.set(group, offset); offset += group.length; }
if (counts.some((count) => count % 3) || counts.reduce((a, b) => a + b, 0) !== data.length / 9) throw new Error('Invalid packed triangle ranges.');
const geo = geometry(gpu, {
label: 'batch-rendering-packed-primitives',
buffers: [{ data, stride: 36, attributes: { position: 'float32x3', normal: 'float32x3', color: 'float32x3' } }],
});
try {
const slices = counts.map((vertexCount, i) => geo.slice({
firstVertex: counts.slice(0, i).reduce((a, b) => a + b, 0), vertexCount, label: ['cubes', 'pyramids', 'octahedra', 'icosahedra'][i],
}));
const draws = slices.map((slice, i) => draw(gpu, { shader: sceneWgsl, geometry: slice, label: `batch-${i}` }));
const initial = camera(2.4, colorTarget);
for (const drawable of draws) drawable.set({ light: [-0.45, -0.75, -0.35], time: 2.4, viewProjection: initial });
await Promise.all(draws.map((drawable) => drawable.compile(colorTarget)));
// Slices freeze their ranges; this bundle also captures the pyramid's equivalent call-level override.
// A changing range would require a direct pass.draw override or re-recording the bundle.
const recorded = bundle(gpu, { target: colorTarget, label: 'batch-rendering-primitives' }, (b) => {
b.draw(draws[0]!);
b.draw(draws[1]!, { firstVertex: slices[1]!.firstVertex, vertices: slices[1]!.vertexCount });
b.draw(draws[2]!);
b.draw(draws[3]!);
});
return { geometry: geo, draws, bundle: recorded };
} catch (error) {
geo.destroy();
throw error;
}
}
function render(currentFrame: Frame, scene: Scene, blit: Effect, colorTarget: Target, output: Output, time: number): void {
const viewProjection = camera(time, output);
for (const drawable of scene.draws) drawable.set({ time, viewProjection });
currentFrame.pass({ target: colorTarget, clear: CLEAR }, (p) => p.bundles(scene.bundle));
currentFrame.pass({ target: output }, (p) => p.draw(blit));
}
function camera(time: number, output: Output): Float32Array {
const angle = time * .06 + .55;
return perspectiveCamera({
fov: 42, aspect: output.size[0] / Math.max(1, output.size[1]), near: .1, far: 100,
position: [Math.cos(angle) * 24.8, 15.4, Math.sin(angle) * 24.8], target: [0, 0, 0],
}).viewProjection;
}
function createBlit(gpu: Gpu, source: Target, output: Output): Effect {
const blit = effect(gpu, blitWgsl, { label: 'batch-rendering-blit' });
blit.set({ linear_samp: sampler(gpu, { minFilter: 'linear', magFilter: 'linear' }) });
setBlitSource(blit, source, output);
return blit;
}
function setBlitSource(blit: Effect, source: Target, output: Output): void {
blit.set({ scene_tex: source, resolution: output.size });
}
function packedGeometry(): number[][] {
const groups = [[], [], [], []] as number[][];
const shapes = [cube(), pyramid(), octahedron(), icosahedron()];
const center = 7.5;
for (let z = 0; z < 16; z++) for (let y = 0; y < 16; y++) for (let x = 0; x < 16; x++) {
const kind = (x + 2 * y + 3 * z) % 4;
const hash = ((Math.imul(x + 3, 73856093) ^ Math.imul(y + 5, 19349663) ^ Math.imul(z + 7, 83492791)) >>> 0);
const hue = (hash % 997) / 997;
const color: Vec3 = hue < .34 ? [.08, .78, 1] : hue < .67 ? [1, .12, .76] : [1, .72, .08];
append(groups[kind]!, shapes[kind]!, [(x - center), (y - center), (z - center)], color, hash / 0xffffffff);
}
return groups;
}
type Vec3 = readonly [number, number, number];
function append(out: number[], triangles: readonly Vec3[], center: Vec3, color: Vec3, seed: number): void {
const angle = (seed - .5) * .7, tilt = (seed * 1.7 - .5) * .38;
for (let i = 0; i < triangles.length; i += 3) {
const a = transform(triangles[i]!, angle, tilt), b = transform(triangles[i + 1]!, angle, tilt), c = transform(triangles[i + 2]!, angle, tilt);
const normal = normalize(cross(sub(b, a), sub(c, a)));
for (const p of [a, b, c]) out.push(p[0] + center[0], p[1] + center[1], p[2] + center[2], ...normal, ...color);
}
}
function transform(p: Vec3, y: number, x: number): Vec3 {
const cy = Math.cos(y), sy = Math.sin(y), cx = Math.cos(x), sx = Math.sin(x), scale = .34;
const px = p[0] * cy + p[2] * sy, pz = -p[0] * sy + p[2] * cy;
return [px * scale, (p[1] * cx - pz * sx) * scale, (p[1] * sx + pz * cx) * scale];
}
function sub(a: Vec3, b: Vec3): Vec3 { return [a[0] - b[0], a[1] - b[1], a[2] - b[2]]; }
function cross(a: Vec3, b: Vec3): Vec3 { return [a[1] * b[2] - a[2] * b[1], a[2] * b[0] - a[0] * b[2], a[0] * b[1] - a[1] * b[0]]; }
function normalize(v: Vec3): Vec3 { const l = Math.hypot(...v) || 1; return [v[0] / l, v[1] / l, v[2] / l]; }
function cube(): Vec3[] {
const v = (x: number, y: number, z: number): Vec3 => [x, y, z], out: Vec3[] = [];
const faces = [[v(-1,-1,1),v(1,-1,1),v(1,1,1),v(-1,1,1)],[v(1,-1,-1),v(-1,-1,-1),v(-1,1,-1),v(1,1,-1)],[v(1,-1,1),v(1,-1,-1),v(1,1,-1),v(1,1,1)],[v(-1,-1,-1),v(-1,-1,1),v(-1,1,1),v(-1,1,-1)],[v(-1,1,1),v(1,1,1),v(1,1,-1),v(-1,1,-1)],[v(-1,-1,-1),v(1,-1,-1),v(1,-1,1),v(-1,-1,1)]];
for (const [a,b,c,d] of faces) out.push(a!,b!,c!,a!,c!,d!); return out;
}
function pyramid(): Vec3[] {
const a: Vec3=[-1,-1,-1],b: Vec3=[1,-1,-1],c: Vec3=[1,-1,1],d: Vec3=[-1,-1,1],t: Vec3=[0,1.35,0];
return [a,d,c,a,c,b,a,b,t,b,c,t,c,d,t,d,a,t];
}
function octahedron(): Vec3[] {
const t: Vec3=[0,1.3,0],b: Vec3=[0,-1.3,0],a: Vec3=[1,0,0],c: Vec3=[0,0,1],d: Vec3=[-1,0,0],e: Vec3=[0,0,-1];
return [t,a,c,t,c,d,t,d,e,t,e,a,b,c,a,b,d,c,b,e,d,b,a,e];
}
function icosahedron(): Vec3[] {
const p=(1+Math.sqrt(5))/2, v: Vec3[]=[[-1,p,0],[1,p,0],[-1,-p,0],[1,-p,0],[0,-1,p],[0,1,p],[0,-1,-p],[0,1,-p],[p,0,-1],[p,0,1],[-p,0,-1],[-p,0,1]];
const f=[[0,11,5],[0,5,1],[0,1,7],[0,7,10],[0,10,11],[1,5,9],[5,11,4],[11,10,2],[10,7,6],[7,1,8],[3,9,4],[3,4,2],[3,2,6],[3,6,8],[3,8,9],[4,9,5],[2,4,11],[6,2,10],[8,6,7],[9,8,1]];
return f.flatMap(([a,b,c]) => [v[a!]!,v[b!]!,v[c!]!]);
}