Skip to content

Buffers

This guide will show you how to use the TypeGPU typed buffers to allow for type-safe and convenient read and write operations on WebGPU resources. We will explore how to adapt existing WebGPU code to use the buffer API, and how to use it to create new WebGPU resources.

What are typed buffers?

TypeGPU provides a set of tools for WebGPU that allow you to create and manage typed buffers. It enables reading and writing data to and from WebGPU buffers without having to manually translate the values to and from the raw byte arrays that WebGPU uses.

declare const device: GPUDevice; // WebGPU device
// struct PlayerInfo {
// vec3f position;
// u32 health;
// }
// Creating a buffer, the type hint is GPUBuffer
const buffer = device.createBuffer({
size: 16,
usage: GPUBufferUsage.COPY_SRC,
mappedAtCreation: true,
});
// Initializing the buffer
const initData = buffer.getMappedRange();
new Float32Array(initData).set([1.1, 2.0, 0.0]);
new Uint32Array(initData).set([100], 4);
buffer.unmap();
// To read from the buffer we need to create a staging buffer
const stagingBuffer = device.createBuffer({
size: 16,
usage: GPUBufferUsage.COPY_DST | GPUBufferUsage.MAP_READ,
});
// Manually copying the data
const commandEncoder = device.createCommandEncoder();
commandEncoder.copyBufferToBuffer(buffer, 0, stagingBuffer, 0, 16);
device.queue.submit([commandEncoder.finish()]);
// Reading the data
await stagingBuffer.mapAsync(GPUMapMode.READ);
const rawBytes = stagingBuffer.getMappedRange();
const value = {
position: [...new Float32Array(rawBytes, 0, 3).values()],
health: new Uint32Array(rawBytes, 12)[0]
}
// Using the value
console.log(value);

In the example above, we have a PlayerInfo struct that we want to store in a buffer. Using just the WebGPU API alone, we need to manually copy the data to the buffer, create a staging buffer to read the data, and then manually copy the data back to a JavaScript object. This process involves a lot of boilerplate code and introduces room for error. Moreover, the example given is relatively simple. If we were dealing with a more complex nested structure, manually calculating the offsets and sizes would be even more error-prone and time-consuming.

TypeGPU addresses this issue by providing a way to define the structure of the data we want to store in the buffer and then read and write the data using a type-safe API.

Using typed buffers

Creating a buffer

To create a typed buffer, you will need to define the buffer type using one of the data types provided by the typegpu/data module. You can then create a buffer using the root.createBuffer function as demonstrated in the example above. However, that is not the only way to create a buffer.

Using buffer type

As in the example above, you can create a buffer using only the buffer type. This will create a zero-initialized buffer (similar to creating a WebGPU buffer) when the buffer is first accessed or used in TypeGPU read or write operations.

import { u32 } from 'typegpu/data';
import tgpu from 'typegpu';
const root = await tgpu.init();
const buffer = root.createBuffer(u32);
// That's when the underlying WebGPU buffer is created
const gpuBuffer = root.unwrap(buffer);

Using buffer type and initial value

You can also pass an initial value to the root.createBuffer function. When the buffer is created, it will be mapped at creation, and the initial value will be written to the buffer.

import { u32 } from 'typegpu/data';
import tgpu from 'typegpu';
const root = await tgpu.init();
const buffer = root.createBuffer(u32, 100);
// That's when the buffer is created and the value is written
const gpuBuffer = root.unwrap(buffer);

Using an existing buffer

You can also create a buffer using an existing WebGPU buffer. This is useful when you have existing logic but want to introduce type-safe data operations.

import tgpu from 'typegpu';
import { u32 } from 'typegpu/data';
// Existing WebGPU buffer
const existingBuffer = device.createBuffer(...);
const root = tgpu.initFromDevice({ device });
const buffer = root.createBuffer(u32, existingBuffer);

Adding usage flags

When creating a buffer using the TypeGPU API, you can use the .$usage() builder method to add usage flags to the buffer.

const buffer = root.createBuffer(u32)
.$usage('uniform')
.$usage('storage');

You can also add all flags in a single $usage().

const buffer = root.createBuffer(u32)
.$usage('uniform', 'storage');

If you want to add specific usage flags, you can use the $addFlags(flags: GPUBufferUsageFlags) method.

Writing to a buffer

To write data to a buffer, you can use the write method on the typed buffer object. It takes as an argument the data that is to be written to the buffer. Because the buffer is typed, the type hints will help you write the correct data.

If you pass a mapped buffer, the data will be written directly to the buffer (the buffer will not be unmapped). If you pass an unmapped buffer, the data will be written to the buffer using GPUQueue.writeBuffer.

import { struct, vec2f, u32 } from 'typegpu/data';
const PlayerInfo = struct({
position: vec2f,
health: u32,
});
const buffer = root.createBuffer(PlayerInfo);
// .write(data: { position: vec2f, health: number })
buffer.write({
position: vec2f(1.0, 2.0),
health: 100,
});

Reading from a buffer

To read data from a buffer, you can use the read method on the typed buffer. It returns a promise that resolves to the data read from the buffer.

import { arrayOf, u32 } from 'typegpu/data';
const buffer = root.createBuffer(arrayOf(u32, 10));
// data: number[]
const data = await buffer.read();

Adapting existing code

Let’s take a look at how we can supercharge our existing WebGPU code with typed buffers.

Starting point

const colorBuffer = device.createBuffer({
// we have to manually calculate the size of the buffer
size: 32,
usage: GPUBufferUsage.COPY_DST | GPUBufferUsage.UNIFORM,
});
const colorFromInit = [0.0, 1.0, 0.8] as [number, number, number];
const colorToInit = [0.2, 0.5, 1.0] as [number, number, number];
const updateColors = (
colorFrom: [number, number, number],
colorTo: [number, number, number],
) => {
const bytes = new ArrayBuffer(32);
new Float32Array(bytes, 0, 3).set(colorFrom);
// manually calculating the offset and size
// making the offset as 12 bytes would be an easy mistake to make
new Float32Array(bytes, 16, 3).set(colorTo);
device.queue.writeBuffer(colorBuffer, 0, bytes);
}
updateColors(colorFromInit, colorToInit);

The above code is a simple example of how we might update a buffer holding two RGB colors. The size of the data type vec3f is 12 bytes, so one might think that a structure containing two vec3f would be 24 bytes. However, the size of the buffer is 32 bytes because of the alignment requirements of the WebGPU API. This is something the user needs to know and calculate manually.

Let’s see how we can improve this code using typed buffers.

Soft migration

In this approach we will not remove the existing buffer, but wrap it with root.createBuffer() to allow for type-safe read and write operations. This approach is fully backwards compatible and allows you to gradually migrate your code.

  1. Add the necessary imports and wrap the existing buffer:

    import { vec3f, struct } from 'typegpu/data';
    import tgpu from 'typegpu';
    const device: GPUDevice // WebGPU device
    const root = tgpu.initFromDevice({ device });
    const ColorPair = struct({
    colorFrom: vec3f,
    colorTo: vec3f,
    });
    const colorBuffer = device.createBuffer({
    size: 32,
    usage: GPUBufferUsage.COPY_DST | GPUBufferUsage.UNIFORM,
    });
    const typedBuffer = root.createBuffer(ColorPair, colorBuffer);
    const colorFromInit = [0.0, 1.0, 0.8] as [number, number, number];
    const colorToInit = [0.2, 0.5, 1.0] as [number, number, number];
    const updateColors = (
    colorFrom: [number, number, number],
    colorTo: [number, number, number]
    ) => {
    const bytes = new ArrayBuffer(32);
    new Float32Array(bytes, 0, 3).set(colorFrom);
    new Float32Array(bytes, 16, 3).set(colorTo);
    device.queue.writeBuffer(colorBuffer, 0, bytes);
    }
    updateColors(colorFromInit, colorToInit);
  2. The exisitng logic still works the same way but we can now take advantage of TypeGPU to read and write data to the buffer.

    import { vec3f, struct } from 'typegpu/data';
    import tgpu from 'typegpu';
    const root = tgpu.initFromDevice({ device });
    const ColorPair = struct({
    colorFrom: vec3f,
    colorTo: vec3f,
    });
    const colorBuffer = device.createBuffer({
    size: 32,
    size: ColorPair.size,
    usage: GPUBufferUsage.COPY_DST | GPUBufferUsage.UNIFORM,
    });
    const typedBuffer = root.createBuffer(ColorPair, colorBuffer);
    const colorFromInit = [0.0, 1.0, 0.8] as [number, number, number];
    const colorToInit = [0.2, 0.5, 1.0] as [number, number, number];
    const colorFromInit = vec3f(0.0, 1.0, 0.8);
    const colorToInit = vec3f(0.2, 0.5, 1.0);
    const updateColors = (
    colorFrom: [number, number, number],
    colorTo: [number, number, number]
    ) => {
    const bytes = new ArrayBuffer(32);
    new Float32Array(bytes, 0, 3).set(colorFrom);
    new Float32Array(bytes, 16, 3).set(colorTo);
    device.queue.writeBuffer(colorBuffer, 0, bytes);
    }
    updateColors(colorFromInit, colorToInit);
    typedBuffer.write({
    colorFrom: colorFromInit,
    colorTo: colorToInit,
    });

Full migration

In this approach we will remove the existing buffer and replace it with a typed buffer created using the TypeGPU API. Keep in mind that root.createBuffer() will return a TgpuBuffer object, when you need a GPUBuffer object you can access it using the root.unwrap method.

import tgpu from 'typegpu';
import { vec3f, struct } from 'typegpu/data';
const root = tgpu.initFromDevice({ device });
const ColorPair = struct({
colorFrom: vec3f,
colorTo: vec3f,
});
const colorFromInit = vec3f(0.0, 1.0, 0.8);
const colorToInit = vec3f(0.2, 0.5, 1.0);
const colorBuffer = device.createBuffer({
size: ColorPair.size,
usage: GPUBufferUsage.COPY_DST | GPUBufferUsage.UNIFORM,
});
// the size of the buffer is calculated automatically
// we can also take advantage of the initial value
const colorBuffer = root
.createBuffer(ColorPair, {
colorFrom: colorFromInit,
colorTo: colorToInit,
})
.$usage('uniform');
// this is now redundant as the buffer will be initialized on creation
colorBuffer.write({
colorFrom: colorFromInit,
colorTo: colorToInit,
});

If you want a more in-depth look at how to create your own data types, check out the chapter on Data Schemas.