Every frame, a handful of rules keep uniform traffic cheap:
One upload per buffer per frame. Fill one Float32Array for all objects, then call writeBuffer() once; a thousand small writes cost a thousand API calls and validations.
Reuse typed arrays and buffers. A new Float32Array or GPUBuffer per frame means garbage and allocation.
Write before encoding. writeBuffer() takes effect in queue order, before the next submit(), so all draws of that submit see the new values; two passes that need different values need two slots, not two writes.
Upload only what changed. Keep per-frame and rarely changing data in separate buffers.
Move animation to the GPU. Send a time value and let the vertex shader compute positions.
writeBuffer() copies your data at once, so the typed array can be overwritten right after the call.
<!doctype html>
<style>
body { margin: 0; background: #f7f4ee; font: 14px system-ui, sans-serif; }
.stage { position: relative; width: 100%; max-width: 600px; }
.stage canvas { display: block; width: 100%; }
.stage canvas + canvas { position: absolute; inset: 0; pointer-events: none; }
</style>
<div class="stage">
<canvas id="view" width="600" height="340"></canvas>
<canvas id="labels" width="600" height="340"></canvas>
</div>
<script>
const canvas = document.getElementById('view');
const ink = document.getElementById('labels').getContext('2d');
function showMessage(text) { // 2D fallback when WebGPU is missing
const ctx = canvas.getContext('2d');
ctx.fillStyle = '#fbeaea'; ctx.fillRect(0, 0, canvas.width, canvas.height);
ctx.fillStyle = '#8a2b2b'; ctx.font = '18px system-ui, sans-serif'; ctx.textAlign = 'center';
ctx.fillText(text, canvas.width / 2, canvas.height / 2);
}
const N = 2000;
const code = /* wgsl */ `
struct Object { pos: vec2f, angle: f32, tint: f32 } // 16 bytes per book
@group(0) @binding(0) var<storage> objects: array<Object>;
struct Out { @builtin(position) pos: vec4f, @location(0) color: vec3f }
@vertex fn vs(@builtin(vertex_index) v: u32, @builtin(instance_index) i: u32) -> Out {
let o = objects[i];
let q = (vec2f(f32(v & 1), f32(v >> 1)) - 0.5) * vec2f(0.022, 0.05);
let r = vec2f(q.x * cos(o.angle) - q.y * sin(o.angle), q.x * sin(o.angle) + q.y * cos(o.angle));
let c = mix(vec3f(0.08, 0.40, 0.75), vec3f(0.85, 0.40, 0.18), o.tint);
return Out(vec4f(o.pos + r * vec2f(0.57, 1), 0, 1), c);
}
@fragment fn fs(in: Out) -> @location(0) vec4f { return vec4f(in.color, 1); }`;
async function main() {
const adapter = await navigator.gpu?.requestAdapter();
if (!adapter) return showMessage('WebGPU is not available in this browser');
const device = await adapter.requestDevice();
const context = canvas.getContext('webgpu');
const format = navigator.gpu.getPreferredCanvasFormat();
context.configure({ device, format });
// Created once and reused every frame: no new typed arrays or buffers in the loop.
const data = new Float32Array(N * 4);
const seeds = Array.from({ length: N }, () => [Math.random() * 6.28, 0.2 + Math.random() * 0.8, Math.random() * 6.28, Math.random()]);
const buffer = device.createBuffer({ size: data.byteLength, usage: GPUBufferUsage.STORAGE | GPUBufferUsage.COPY_DST });
const module = device.createShaderModule({ code });
const pipeline = device.createRenderPipeline({ layout: 'auto', primitive: { topology: 'triangle-strip' },
vertex: { module }, fragment: { module, targets: [{ format }] } });
const group = device.createBindGroup({ layout: pipeline.getBindGroupLayout(0), entries: [{ binding: 0, resource: { buffer } }] });
let frames = 0, fillMs = 0;
function frame(now) {
const t0 = performance.now(), t = now / 1000;
for (let i = 0; i < N; i++) { // 1. fill one array for all objects
const [a, r, spin, tint] = seeds[i];
const angle = a + t * 0.25 * r;
data[i * 4] = Math.cos(angle) * r * 0.95;
data[i * 4 + 1] = Math.sin(angle * 1.3) * r * 0.75 - 0.05;
data[i * 4 + 2] = spin + t * (1 - r) * 2;
data[i * 4 + 3] = tint;
}
device.queue.writeBuffer(buffer, 0, data); // 2. one upload per buffer per frame
fillMs += performance.now() - t0; // (the data is copied at once, so reuse is safe)
const encoder = device.createCommandEncoder(); // 3. then encode: the draws see the new values
const pass = encoder.beginRenderPass({ colorAttachments: [{ view: context.getCurrentTexture().createView(),
clearValue: [0.97, 0.96, 0.93, 1], loadOp: 'clear', storeOp: 'store' }] });
pass.setPipeline(pipeline);
pass.setBindGroup(0, group);
pass.draw(4, N);
pass.end();
device.queue.submit([encoder.finish()]); // 4. one submit per frame
if (++frames % 20 === 0) {
ink.clearRect(0, 0, 600, 340);
ink.fillStyle = 'rgba(247,244,238,0.9)'; ink.fillRect(0, 0, 600, 44);
ink.font = '12px ui-monospace, monospace'; ink.fillStyle = '#222';
ink.fillText(`${N} objects: 1 writeBuffer() of ${data.byteLength.toLocaleString('en-US')} bytes, 1 draw, 1 submit per frame`, 10, 18);
ink.fillText(`filling + uploading: ${(fillMs / 20).toFixed(2)} ms per frame (instead of ${N} small writes)`, 10, 36);
fillMs = 0;
}
requestAnimationFrame(frame);
}
requestAnimationFrame(frame);
}
main();
</script>