Staging Buffers

Staging Buffers and Efficient Uploads

writeBuffer() is already a staging upload: the browser copies your data at once into its own memory and schedules a GPU copy, so your array is free on return, at the cost of that extra copy. To manage staging yourself, fill mapped MAP_WRITE | COPY_SRC buffers, copy on the GPU, and remap them for reuse:

Uploading through a pool of recycled MAP_WRITE staging buffersHTMLLive
<script type="module">
const device = await (await navigator.gpu.requestAdapter()).requestDevice();
const { STORAGE, COPY_SRC, COPY_DST, MAP_WRITE } = GPUBufferUsage;
const catalog = device.createBuffer({ size: 24, usage: STORAGE | COPY_DST });
const pool = [];                                   // staging buffers ready to reuse
function upload(target, data) {
  const staging = pool.pop() ?? device.createBuffer({ size: data.byteLength,
    usage: MAP_WRITE | COPY_SRC, mappedAtCreation: true });  // new ones start mapped
  new Float32Array(staging.getMappedRange()).set(data);
  staging.unmap();
  const encoder = device.createCommandEncoder();
  encoder.copyBufferToBuffer(staging, 0, target, 0, data.byteLength);
  device.queue.submit([encoder.finish()]);
  return staging.mapAsync(GPUMapMode.WRITE).then(() => pool.push(staging));  // recycle
}
await upload(catalog, new Float32Array([14.99, 39.5, 24, 18.75, 16.2, 21.3]));
const again = pool[0];
await upload(catalog, new Float32Array([12.74, 33.58, 20.4, 15.94, 13.77, 18.1]));
console.log(`pool: ${pool.length} buffer, reused: ${pool[0] === again}`);
</script>

The second upload reused the first staging buffer. Prefer writeBuffer() for small per-frame data, mappedAtCreation for data that exists when the buffer is made (meshes, the catalog), and a staging pool for large streaming uploads you want to decode straight into mapped memory. Either way, batch small writes into big ones.