In most of the articles to date, we’ve used the functions
writeBuffer
to put data in a buffer and writeTexture
to put data in a texture. There are several ways to put
data into a buffer or a texture.
writeBuffer
writeBuffer
copies data from a TypedArray
or ArrayBuffer
in JavaScript to a buffer.
This is arguably the most straight forward way to get data into a buffer.
writeBuffer
follows this format
device.queue.writeBuffer( destBuffer, // the buffer to write to destOffset, // where in the destination buffer to start writing srcData, // a typedArray or arrayBuffer srcOffset?, // offset in **elements** in srcData to start copying size?, // size in **elements** of srcData to copy )
If srcOffset
is not passed it’s 0
. If size
is not passed
it’s the size of srcData
.
Important:
srcOffset
andsize
are in elements ofsrcData
In other words,
device.queue.writeBuffer( someBuffer, someOffset, someFloat32Array, 6, 7, )the code above will copy from float32 #6, 7 float32s of data. To put it another way it will copy 28 bytes starting at byte 24 from the portion of the arrayBuffer that
someFloat32Array
is a view of.
writeTexture
writeTexture
copies data from a TypedArray
or ArrayBuffer
in JavaScript to a texture.
writeTexture
has this signature
device.writeTexture( // details of the destination { texture, mipLevel: 0, origin: [0, 0, 0], aspect: "all" }, // the source data srcData, // details of the source data { offset: 0, bytesPerRow, rowsPerImage }, // size: [ width, height, depthOrArrayLayers ] )
Things to note:
texture
must have a usage of GPUTextureUsage.COPY_DST
mipLevel
, origin
, and aspect
all have defaults so they often do not need to be specified
bytesPerRow
: This is how many bytes to advance to get to the next block row of data.
This is required if you are copying more than 1 block row. It is almost always true that you’re copying more than 1 block row so it is therefore almost always required.
rowsPerImage
: This is the number of block rows to advance to get from the
the start of one image to the next image.
This is required if you are copying more than 1 layer. In other words,
if depthOrArrayLayers
in the size argument is > 1 then you need to supply
this value.
You can think of the copy as working like this
// pseudo code const [x, y, z] = origin || [0, 0, 0]; const [blockWidth, blockHeight] = getBlockSizeForTextureFormat(texture.format); const blocksAcross = width / blockWidth; const blocksDown = height / blockHeight; for (layer = 0; layer < depthOrArrayLayers; layer) { for (row = 0; row < blocksDown; ++row) { const start = offset + (layer * rowsPerImage + row) * bytesPerRow; copyRowToTexture( texture, // texture to copy to x, y + row, z + layer, // where in texture to copy to srcDataAsBytes + start, bytesPerRow); } }
Textures are organized into blocks. For most regular textures the block width
and block height are both 1. For compressed textures that changes. For example
the format, bc1-rgba-unorm
as a block width of 4 and a block height of 4.
That means if you set the width to 8, and the height to 12, only 6 blocks will be copied.
2 blocks for the first row, 2 for the 2nd row, 3 for the 3rd.
For compressed textures, size and origin must be aligned to blocks sizes.
Important: Anywhere in the WebGPU that takes size (defined as a
GPUExtent3D
) can either be an array of 1 to 3 numbers, or it can be an object with 1 to 3 properties.height
anddepthOrArrayLayers
default to 1 so
[2]
a size where width = 2, height = 1, depthOrArrayLayers = 1[2, 3]
a size where width = 2, height = 3, depthOrArrayLayers = 1[2, 3, 4]
a size where width = 2, height = 3, depthOrArrayLayers = 4{ width: 2 }
a size where width = 2, height = 1, depthOrArrayLayers = 1{ width: 2, height: 3 }
a size where width = 2, height = 3, depthOrArrayLayers = 1{ width: 2, height: 3, depthOrArrayLayers: 4 }
a size where width = 2, height = 3, depthOrArrayLayers = 4
In the same way, Anywhere an origin appears (default aa a
GPUOrigin3D
), you can either have an array of 3 numbers, or a object withx
,y
,z
properties. All of them default to 0 so
[5]
an origin where x = 5, y = 0, z = 0[5, 6]
an origin where x = 5, y = 6, z = 0[5, 6, 7]
an origin where x = 5, y = 6, z = 7{ x: 5 }
an origin where x = 5, y = 0, z = 0{ x: 5, y: 6 }
an origin where x = 5, y = 6, z = 0{ x: 5, y: 6, z: 7 }
an origin where x = 5, y = 6, z = 7
aspect
really only comes into play when copying data to a depth-stencil format.
You can only copy to one aspect at a time, either the depth-only
or the stencil-only
.copyBufferToBuffer
copyBufferToBuffer
, like the name suggests, copies data from one buffer to another.
signature:
encoder.copyBufferToBuffer( source, // buffer to copy from sourceOffset, // where to start copying from dest, // buffer to copy to destOffset, // where to start copying to size, // how many bytes to copy )
source
must have a usage of GPUBufferUsage.COPY_SRC
dest
must have a usage of GPUBufferUsage.COPY_DST
size
must be a multiple of 4copyBufferToTexture
copyBufferToTexture
, like the name suggests, copies data from a buffer to a texture.
signature:
encode.copyBufferToTexture( // details of the source buffer { buffer, offset: 0, bytesPerRow, rowsPerImage }, // details of the destination texture { texture, mipLevel: 0, origin: [0, 0, 0], aspect: "all" }, // size: [ width, height, depthOrArrayLayers ] )
This has almost exactly the same parameters as writeTexture
.
The biggest difference is that bytesPerRow
must be
a multiple of 256!!
texture
must have a usage of GPUTextureUsage.COPY_DST
buffer
must have a usage of GPUBufferUsage.COPY_SRC
copyTextureToBuffer
copyTextureToBuffer
like the name suggests, copies data from a texture to a buffer.
signature:
encode.copyTextureToBuffer( // details of the source texture { texture, mipLevel: 0, origin: [0, 0, 0], aspect: "all" }, // details of the destination buffer { buffer, offset: 0, bytesPerRow, rowsPerImage }, // size: [ width, height, depthOrArrayLayers ] )
This has similar parameters to copyBufferToTexture
just the texture (now the source) and the buffer (now the destination)
are swapped. Like copyTextureToBuffer
, bytesPerRow
must be
a multiple of 256!!
texture
must have a usage of GPUTextureUsage.COPY_SRC
buffer
must have a usage of GPUBufferUsage.COPY_DST
copyTextureToTexture
copyTextureToTexture
copies a portion of one texture to another.
The two textures must be must either be the same format, or they
must only differ by the suffix '-srgb'
.
signature:
encode.copyTextureToBuffer( // details of the source texture src: { texture, mipLevel: 0, origin: [0, 0, 0], aspect: "all" }, // details of the destination texture dst: { texture, mipLevel: 0, origin: [0, 0, 0], aspect: "all" }, // size: [ width, height, depthOrArrayLayers ] );
texture
must have a usage of GPUTextureUsage.COPY_SRC
texture
must have a usage of GPUTextureUsage.COPY_DST
width
must be a multiple of block widthheight
must be a multiple of block heightorigin[0]
or .x
must be a multiple block widthorigin[1]
or .y
must be a multiple block heightorigin[0]
or .x
must be a multiple block widthorigin[1]
or .y
must be a multiple block heightShaders can write to storage buffers, storage textures, and indirectly they can render to textures. Those are all ways of getting data into buffers and textures. In other words you can write shaders to generate data.
You can map a buffer. Mapping a buffer means making it available to read or write from JavaScript. At least in version 1 of WebGPU, mappable buffers have severe restrictions, namely, a mappable buffer can can only be used as a temporary place to copy from to. A mappable buffer can not be used as any other type of buffer (like a Uniform buffer, vertex buffer, index buffer, storage buffer, etc…) [1]
You can create a mappable buffer with 2 combinations of usage flags.
GPUBufferUsage.MAP_READ | GPU_BufferUsage.COPY_DST
This is a buffer you can use the copy commands above to copy data to from another buffer or a texture, then map it to read the values in JavaScript
GPUBufferUsage.MAP_WRITE | GPU_BufferUsage.COPY_SRC
This is a buffer you can map in JavaScript, you can then put data in it from JavaScript, and finally unmap it and use the and the copy commands above to copy its contents to another buffer or texture.
The process of mapping a buffer is asynchronous. You call
buffer.mapAsync(mode, offset = 0, size?)
where offset
and size
are in bytes. If size
is not specified it’s
the size of the entire buffer. mode
must be either
GPUMapMode.READ
or GPUMapMode.WRITE
and must of course
match the MAP_
usage flag you passed in when you created
the buffer.
mapAsync
returns a Promise.
When the promise resolves the buffer is mappable. You can then
view some or all of the buffer by calling buffer.getMappedRange(offset = 0, size?)
where offset
a byte offset into the portion of the buffer you
mapped. getMappedRange
returns an ArrayBuffer
so generally, to
be of any use, you’d use that to construct TypedArray.
Here’s one example of mapping a buffer
const buffer = device.createBuffer({ size: 1024, usage: GPUBufferUsage.MAP_READ | GPUBufferUsage.COPY_DST, }); // map the entire buffer await buffer.mapAsync(GPUMapMode.READ); // get the entire buffer const f32 = new Float32Array(buffer.getMappedRange()) ... buffer.unmap();
Note: Once mapped, the buffer is not usable by WebGPU until you call unmap
.
The moment unmap
is called the buffer disappears from JavaScript. In other words,
take the example above
const f32 = new Float32Array(buffer.getMappedRange()) f32[0] = 123; console.log(f32[0]); // prints 123 buffer.unmap(); console.log(f32[0]); // prints undefined
We’ve already seen examples of mapping a buffer for read. Once in the first article where we doubled some numbers in a storage buffer and the copied the results to a mappable buffer and mapped it to read out the results
Another is the article on compute shader basics
where we output the various @builtin
compute shader values to a storage buffer.
We then copied those results to a mappable buffer and mapped it read out the results.
mappedAtCreation: true
is a flag you can add when you
create a buffer. In this case, the buffer does not need
the usage flags GPUBufferUsage.COPY_DST
nor GPUBufferUsage.MAP_WRITE
.
This is a special flag solely to let you put data in the
buffer on creation. You add the flat mappedAtCreation: true
when you create the
buffer. The buffer is created, already mapped for writing. Example:
const buffer = device.createBuffer({ size: 16, usage: GPUBufferUsage.UNIFORM, mappedAtCreation: true, }); const arrayBuffer = buffer.getMappedRange(0, buffer.size); const f32 = new Float32Array(arrayBuffer); f32.set([1, 2, 3, 4]); buffer.unmap();
Or, more tersely
const buffer = device.createBuffer({ size: 16, usage: GPUBufferUsage.UNIFORM, mappedAtCreation: true, }); new Float32Array(buffer.getMappedRange(0, buffer.size)).set([1, 2, 3, 4]); buffer.unmap();
Above we saw that mapping a buffer is asynchronous. This means there’s
an indeterminate amount of time from the point we ask for the buffer
to be mapped by calling mapAsync
, until the time it’s mapped and we can call getMappedRange
.
A common way to workaround this is to keep a set of buffers always mapped. Since they are already mapped they are ready to use immediately. As soon as you use one and unmap it, and as soon as you’ve submitted whatever commands use the buffer, you ask for to to be mapped again. When it promise resolves you put it back in a pool of already mapped buffers. If you ever need a mapped buffer and none are available you create a new one and add it to the pool.
TBD: Example
The exception is if you set mappedAtCreation: true
See mappedAtCreation. ↩︎