Four operations move texels. queue.writeTexture() copies from JavaScript memory with any row pitch. The encoder's copyBufferToTexture(), copyTextureToBuffer() and copyTextureToTexture() run on the GPU timeline, and their buffer side's bytesPerRow must be a multiple of 256: a 16-byte pitch failed with "bytesPerRow (16) is not a multiple of 256". Reading a texture back therefore pads each row and strips the padding:
const device = await (await navigator.gpu.requestAdapter()).requestDevice();
async function readTexture(texture) { // rgba8 formats, mip level 0
const { width, height } = texture, rowBytes = width * 4;
const bytesPerRow = Math.ceil(rowBytes / 256) * 256; // buffer rows: multiples of 256
const size = bytesPerRow * (height - 1) + rowBytes; // the last row needs no padding
const buffer = device.createBuffer({ size,
usage: GPUBufferUsage.COPY_DST | GPUBufferUsage.MAP_READ });
const encoder = device.createCommandEncoder();
encoder.copyTextureToBuffer({ texture }, { buffer, bytesPerRow }, [width, height]);
device.queue.submit([encoder.finish()]);
await buffer.mapAsync(GPUMapMode.READ);
const padded = new Uint8Array(buffer.getMappedRange());
console.log(`${width}x${height}: bytesPerRow ${bytesPerRow}, buffer ${size} bytes`);
return Array.from({ length: height }, (_, y) =>
padded.slice(y * bytesPerRow, y * bytesPerRow + rowBytes)); // strip the padding
}
const texture = device.createTexture({ size: [3, 2], format: 'rgba8unorm',
usage: GPUTextureUsage.COPY_DST | GPUTextureUsage.COPY_SRC });
device.queue.writeTexture({ texture }, new Uint8Array(24).map((_, i) => i),
{ bytesPerRow: 12 }, [3, 2]); // writeTexture: no 256 rule
for (const row of await readTexture(texture)) console.log(row.join(' '));Output
3x2: bytesPerRow 256, buffer 268 bytes 0 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23
copyTextureToTexture() needs copy-compatible formats: rgba8unorm to rgba8unorm-srgb copied the raw bytes, while rgba8unorm to bgra8unorm failed as "not copy compatible". depth24plus cannot be copied out at all, and of the depth formats only depth16unorm accepts data copied in.
<!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 W = 6, H = 4;
async function readTexture(device, texture) { // rgba8 formats, mip level 0
const { width, height } = texture, rowBytes = width * 4;
const bytesPerRow = Math.ceil(rowBytes / 256) * 256; // buffer rows: multiples of 256
const size = bytesPerRow * (height - 1) + rowBytes; // the last row needs no padding
const buffer = device.createBuffer({ size, usage: GPUBufferUsage.COPY_DST | GPUBufferUsage.MAP_READ });
const encoder = device.createCommandEncoder();
encoder.copyTextureToBuffer({ texture }, { buffer, bytesPerRow }, [width, height]);
device.queue.submit([encoder.finish()]);
await buffer.mapAsync(GPUMapMode.READ);
const padded = new Uint8Array(buffer.getMappedRange()).slice();
buffer.unmap();
const rows = Array.from({ length: height }, (_, y) => padded.slice(y * bytesPerRow, y * bytesPerRow + rowBytes));
return { rows, bytesPerRow, size };
}
const code = /* wgsl */ `
@group(0) @binding(0) var image: texture_2d<f32>;
@vertex fn vs(@builtin(vertex_index) v: u32) -> @builtin(position) vec4f {
let p = array(vec2f(-1, -1), vec2f(3, -1), vec2f(-1, 3))[v];
return vec4f(p, 0, 1);
}
@fragment fn fs(@builtin(position) pos: vec4f) -> @location(0) vec4f {
return textureLoad(image, vec2u((pos.xy - vec2f(20, 40)) / 40), 0); // 40 px per texel
}`;
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 });
// writeTexture() accepts any row pitch: here a tight 24 bytes per row.
const texture = device.createTexture({ size: [W, H], format: 'rgba8unorm',
usage: GPUTextureUsage.COPY_DST | GPUTextureUsage.COPY_SRC | GPUTextureUsage.TEXTURE_BINDING });
const pixels = new Uint8Array(W * H * 4);
for (let y = 0; y < H; y++) for (let x = 0; x < W; x++) pixels.set([30 + x * 40, 60 + y * 50, 200 - x * 25, 255], (y * W + x) * 4);
device.queue.writeTexture({ texture }, pixels, { bytesPerRow: W * 4 }, [W, H]);
// copyTextureToBuffer() insists on 256: a tight pitch fails validation.
device.pushErrorScope('validation');
const e = device.createCommandEncoder();
e.copyTextureToBuffer({ texture }, { buffer: device.createBuffer({ size: 256, usage: GPUBufferUsage.COPY_DST }), bytesPerRow: W * 4 }, [W, H]);
e.finish();
const tight = await device.popErrorScope();
const { rows, bytesPerRow, size } = await readTexture(device, texture);
const module = device.createShaderModule({ code });
const pipeline = device.createRenderPipeline({ layout: 'auto', vertex: { module }, fragment: { module, targets: [{ format }] } });
const encoder = device.createCommandEncoder();
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, device.createBindGroup({ layout: pipeline.getBindGroupLayout(0), entries: [{ binding: 0, resource: texture.createView() }] }));
pass.setScissorRect(20, 40, W * 40, H * 40);
pass.draw(3);
pass.end();
device.queue.submit([encoder.finish()]);
// The read-back buffer as a memory map: each row is 256 bytes; only the first 24 hold texels.
ink.font = '12px ui-monospace, monospace'; ink.fillStyle = '#222';
ink.fillText(`texture ${W} x ${H} rgba8unorm`, 20, 30);
ink.fillText(`buffer: bytesPerRow ${bytesPerRow}, size ${size} bytes`, 290, 30);
const scale = 290 / 256;
rows.forEach((row, y) => {
const top = 44 + y * 40;
ink.fillStyle = '#e4e0d8'; ink.fillRect(290, top, (y === H - 1 ? W * 4 : 256) * scale, 26);
for (let x = 0; x < W; x++) {
ink.fillStyle = `rgb(${row[x * 4]},${row[x * 4 + 1]},${row[x * 4 + 2]})`;
ink.fillRect(290 + x * 4 * scale, top, 4 * scale - 1, 26);
}
ink.fillStyle = '#666'; ink.font = '10.5px ui-monospace, monospace';
ink.fillText(y === H - 1 ? 'last row: 24 bytes, no padding' : `row ${y}: 24 bytes of texels + 232 of padding`, 322, top + 17);
});
ink.font = '11.5px system-ui, sans-serif'; ink.fillStyle = '#333';
ink.fillText(`row 0 after stripping the padding: ${[...rows[0].slice(0, 12)].join(' ')} ...`, 20, 230);
ink.fillStyle = '#8a2b2b'; ink.font = '11px ui-monospace, monospace';
ink.fillText(`copyTextureToBuffer with bytesPerRow 24: ${tight ? tight.message.split('\n')[0] : 'accepted'}`.slice(0, 92), 20, 262);
ink.fillStyle = '#444'; ink.font = '11.5px system-ui, sans-serif';
ink.fillText('writeTexture() takes any pitch; the encoder copies (buffer side) need multiples of 256.', 20, 292);
}
main();
</script>