| name | webgpu-impl-buffer-upload |
| description | Use when uploading data to a WebGPU buffer or texture, or reading GPU results back to the CPU. Prevents the copy-failed validation error from unpadded bytesPerRow and stale readback data. Covers the upload decision tree (writeBuffer, mappedAtCreation, staging ring) and the GPU to CPU readback workflow with the 256-byte bytesPerRow padding. Keywords: buffer upload, writeBuffer, mappedAtCreation, staging buffer, readback, copyBufferToBuffer, copyTextureToBuffer, bytesPerRow, GPU to CPU, read pixels, download texture, how do I read data back from the GPU.
|
| license | MIT |
| compatibility | Designed for Claude Code. Requires WebGPU 1.0-stable. |
| metadata | {"author":"OpenAEC-Foundation","version":"1.0"} |
WebGPU Buffer Upload and Readback
Moving data CPU to GPU (upload) and GPU to CPU (readback) in WebGPU 1.0-stable
(Chrome 113+, Safari 26+, Firefox 141+), with the alignment rules that cause the
most validation errors.
Quick Reference
| Upload path | When to use | Needs COPY_DST | API |
|---|
queue.writeBuffer | Safe default for any CPU to GPU upload | yes | device.queue.writeBuffer(buf, offset, data) |
mappedAtCreation: true | Initial vertex/index/uniform data, set once | no | createBuffer({ mappedAtCreation: true }) |
| Staging-buffer ring (2-3 buffers) | Large uploads issued every frame | yes (on destination) | mapAsync + copyBufferToBuffer |
| Readback path | When to use | API |
|---|
| Buffer readback | Read a storage/compute-written buffer | copyBufferToBuffer into a COPY_DST | MAP_READ buffer |
| Texture readback | Read pixels / download a texture | copyTextureToBuffer with 256-byte bytesPerRow padding |
Alignment numbers in one place: writeBuffer bufferOffset and size multiples
of 4; mappedAtCreation size a multiple of 4; mapAsync/getMappedRange
offset a multiple of 8 and size a multiple of 4; bytesPerRow a multiple of
256. The per-rule rationale lives in webgpu-core-memory-model.
Decision Tree
Choosing an upload path
Uploading data to a buffer?
├─ Is this the buffer's initial content, written once at creation?
│ └─ YES → mappedAtCreation: true
│ (no COPY_DST needed, no extra copy, size MUST be multiple of 4)
├─ Is this a large buffer rewritten EVERY frame and writeBuffer profiles as a bottleneck?
│ └─ YES → staging-buffer ring (2-3 buffers, see references/methods.md)
└─ Anything else (per-frame uniforms, occasional updates, unknown)
└─ queue.writeBuffer ← ALWAYS the default
NEVER reach for a staging ring before writeBuffer is measured as the bottleneck.
The ring adds buffer pool management and async bookkeeping; writeBuffer lets the
user agent pick the optimal internal path with zero extra code.
GPU to CPU readback steps
1. Create a destination buffer: usage = COPY_DST | MAP_READ
(MAP_READ ALWAYS combines only with COPY_DST, never STORAGE/UNIFORM/VERTEX)
2. Encode the copy INTO that buffer:
- source is a buffer → encoder.copyBufferToBuffer(...)
- source is a texture → encoder.copyTextureToBuffer(...) with padded bytesPerRow
3. queue.submit([encoder.finish()])
4. await readbackBuffer.mapAsync(GPUMapMode.READ) ← waits for the GPU
5. const range = readbackBuffer.getMappedRange() ← ArrayBuffer view
6. Copy bytes OUT now: new Uint8Array(range).slice() or .set() into your own array
7. readbackBuffer.unmap() ← range is detached after this; copy BEFORE unmap
Step 4 is what guarantees the GPU finished. The mapAsync promise resolves only
after all prior submitted work touching the buffer completes, so readback never
sees stale data when the copy and the mapAsync are on the same buffer.
Core Patterns
Pattern 1: writeBuffer is the default upload
ALWAYS use queue.writeBuffer for routine CPU to GPU uploads. The destination
buffer MUST have COPY_DST.
const buf = device.createBuffer({
size: data.byteLength,
usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST,
});
device.queue.writeBuffer(buf, 0, data);
NEVER call writeBuffer on a buffer that is currently mapped or destroyed; it
fails validation. bufferOffset and the data size MUST both be multiples of 4.
Pattern 2: mappedAtCreation for initial data
ALWAYS use mappedAtCreation: true for vertex/index/uniform data written once.
It needs NO COPY_DST flag and avoids an internal copy.
const vbuf = device.createBuffer({
size: vertices.byteLength,
usage: GPUBufferUsage.VERTEX,
mappedAtCreation: true,
});
new Float32Array(vbuf.getMappedRange()).set(vertices);
vbuf.unmap();
NEVER use the mapped range after unmap(); the ArrayBuffer is detached and any
view over it throws TypeError.
Pattern 3: storage buffers cannot be mapped directly
A STORAGE buffer (and any GPU-written buffer not declared MAP_READ) CANNOT be
mapped. ALWAYS copy it into a separate COPY_DST | MAP_READ staging buffer first.
const readback = device.createBuffer({
size: storageBuffer.size,
usage: GPUBufferUsage.COPY_DST | GPUBufferUsage.MAP_READ,
});
const enc = device.createCommandEncoder();
enc.copyBufferToBuffer(storageBuffer, 0, readback, 0, storageBuffer.size);
device.queue.submit([enc.finish()]);
await readback.mapAsync(GPUMapMode.READ);
const out = new Float32Array(readback.getMappedRange()).slice();
readback.unmap();
MAP_READ is ONLY valid combined with COPY_DST. NEVER add STORAGE, UNIFORM,
or VERTEX to a MAP_READ buffer; that combination fails validation.
Pattern 4: bytesPerRow MUST be a multiple of 256
For copyTextureToBuffer, bytesPerRow MUST round up to the next multiple of
256. ALWAYS size the destination buffer as paddedBytesPerRow * height and strip
the padding on the CPU after mapping.
const unpaddedBytesPerRow = width * 4;
const paddedBytesPerRow = Math.ceil(unpaddedBytesPerRow / 256) * 256;
const readback = device.createBuffer({
size: paddedBytesPerRow * height,
usage: GPUBufferUsage.COPY_DST | GPUBufferUsage.MAP_READ,
});
enc.copyTextureToBuffer(
{ texture },
{ buffer: readback, bytesPerRow: paddedBytesPerRow, rowsPerImage: height },
{ width, height },
);
After mapping, copy each logical row out of the padded buffer (full strip code in
references/examples.md). NEVER size the buffer as width * height * 4 with
bytesPerRow = width * 4 unless width * 4 is already a multiple of 256.
Pattern 5: sequence readback so it does not read stale data
ALWAYS encode the copy and call mapAsync on the SAME buffer in the SAME flow.
The mapAsync promise resolves only after prior submitted work finishes.
device.queue.submit([enc.finish()]);
await readback.mapAsync(GPUMapMode.READ);
When you must confirm an unrelated submission finished before reading a different
buffer, ALWAYS await device.queue.onSubmittedWorkDone(). NEVER assume data is
ready right after submit() returns; submit() only schedules the work.
Pattern 6: never re-map a pending or mapped buffer
NEVER call mapAsync on a buffer whose mapState is "pending" or "mapped";
it fails with "buffer is already mapped". ALWAYS unmap() first, or sequence
map/unmap strictly. A staging ring (see references/methods.md) keeps each buffer
single-mapped by rotating across 2-3 buffers.
Common Anti-Patterns
- Unpadded readback buffer. Sizing a
copyTextureToBuffer destination as
width * height * 4 with bytesPerRow = width * 4. When width * 4 is not a
multiple of 256 the copy fails validation. Pad bytesPerRow to 256 and size the
buffer as paddedBytesPerRow * height.
- Mapping a storage buffer directly. Storage buffers cannot be mapped;
MAP_READ only combines with COPY_DST. Copy into a separate staging buffer.
- Reading before the GPU finished. Calling
getMappedRange without awaiting
mapAsync (or before its promise resolves) reads stale or undefined data. The
mapAsync promise is the synchronization point.
Critical Warnings
- NEVER combine
MAP_READ with anything except COPY_DST, or MAP_WRITE with
anything except COPY_SRC. Other combinations fail validation.
- NEVER use a
getMappedRange() ArrayBuffer after unmap(); it is detached.
ALWAYS copy bytes out with .slice() or .set() before unmap().
- NEVER pass a
bytesPerRow that is not a multiple of 256 to copyTextureToBuffer
or copyBufferToTexture.
- NEVER call
writeBuffer on a mapped or destroyed buffer.
- NEVER build a staging ring when
writeBuffer is not measured as a bottleneck;
it is added complexity with no benefit for non-per-frame uploads.
Reference Files
references/methods.md : writeBuffer, mappedAtCreation, the staging-buffer
ring, and the full readback workflow with bytesPerRow padding.
references/examples.md : verified working code for each upload path and a
complete GPU to CPU texture readback with 256-byte padding stripping.
references/anti-patterns.md : upload and readback mistakes with WHY-it-fails
explanations.
Related skills: webgpu-syntax-buffers (buffer creation and usage flags),
webgpu-core-memory-model (the alignment numbers in detail),
webgpu-impl-async-patterns (mapAsync, onSubmittedWorkDone, frame loop),
webgpu-syntax-command-encoder (copyBufferToBuffer, copyTextureToBuffer).