Skip to content

WebGPU Interoperability

TypeGPU is built in a way that allows you to pick and choose the primitives you need, incrementally adopt them into your project and have a working app at each step of the process. We go to great lengths to ensure that turning even a single buffer into our typed variant improves the developer experience, and does not require changes to the rest of the codebase.

The non-contagious nature of TypeGPU means that ejecting out, in case raw WebGPU access is required, can be done on a very granular level.

Points of integration

Accessing underlying WebGPU resources

Since TypeGPU is a very thin abstraction over WebGPU, there is a 1-to-1 mapping between most typed resources and the raw WebGPU resources used underneath.

const layout = tgpu.bindGroupLayout(...);
// ^? TgpuBindGroupLayout<...>
const rawLayout = root.unwrap(layout); // => GPUBindGroupLayout
const numbersBuffer = root.createBuffer(d.f32).$usage('uniform');
// ^? TgpuBuffer<d.F32> & Uniform
const rawNumbersBuffer = root.unwrap(numbersBuffer); // => GPUBuffer
// Operations on `rawNumbersBuffer` and `numbersBuffer` are shared, because
// they are essentially the same resource.
const bindGroup = root.createBindGroup(layout, { ... });
// ^? TgpuBindGroup<...>
const rawBindGroup = root.unwrap(bindGroup); // => GPUBindGroup

Plugging WebGPU resources into typed APIs.

Many TypeGPU APIs accept either typed resources, or their untyped equivalent.

Buffers

Instead of passing an initial value to root.createBuffer, we can pass it a raw WebGPU buffer and interact with it through TypeGPU’s APIs.

const rawBuffer = device.createBuffer({
size: (Float32Array.BYTES_PER_ELEMENT * 4) * 2, // (xyz + padding) * 2
usage: GPUBufferUsage.COPT_DST | GPUBufferUsage.UNIFORM,
});
const Schema = d.arrayOf(d.vec3f, 2);
const typedBuffer = root.createBuffer(Schema, rawBuffer);
// Updates `rawBuffer` underneath.
typedBuffer.write([d.vec3f(1, 2, 3), d.vec3f(4, 5, 6)]);
// Interpreting the raw bytes in `rawBuffer` as JS values
const values = await typedBuffer.read(); // => d.v3f[]

Bind Group Layouts

When creating typed bind groups from a layout, entries can be populated with equivalent raw WebGPU resources.

const layout = tgpu.bindGroupLayout({
a: { uniform: d.f32 },
b: { uniform: d.u32 },
});
const aBuffer = root.createBuffer(d.f32, 0.5).$usage('uniform');
// ^? TgpuBuffer<d.F32> & Uniform
const bBuffer = root.device.createBuffer({
size: Uint32Array.BYTES_PER_ELEMENT,
usage: GPUBufferUsage.COPY_DST | GPUBufferUsage.UNIFORM,
});
// ^? GPUBuffer
const bindGroup = root.createBindGroup(layout, {
a: aBuffer, // Validates if the buffers holds the right data and usage on the type level.
b: bBuffer, // Allows the raw buffer to pass through.
});

Incremental adoption recipes

To deliver on the promise of interoperability, below are the small code changes necessary to adopt TypeGPU, and the benefits gained at each step.

Since adoption can start growing from many points in a WebGPU codebase, feel free to choose whichever path suits your use-case the most:

Starting at buffers

Define a schema for a buffer’s contents

const Schema = d.arrayOf(d.vec3f, 2);
const rawBuffer = device.createBuffer({
size: (Float32Array.BYTES_PER_ELEMENT * 4) * 2, // (xyz + padding) * 2
size: d.sizeOf(Schema),
usage: GPUBufferUsage.STORAGE | GPUBufferUsage.VERTEX,
mappedAtCreation: true,
});
new Float32Array(rawBuffer.getMappedRange()).set([
0, 1, 2, /* padding */ 0,
3, 4, 5, /* padding */ 0,
]);
rawBuffer.unmap();

Benefits gained:

  • Increased context - a data-type schema allows developers to quickly determine what a buffer is supposed to contain, without the need to jump around the codebase.
  • Automatic sizing - schemas understand WebGPU memory layout rules, therefore can calculate the required size of a buffer.

Wrap the buffer in a typed shell

const Schema = d.arrayOf(d.vec3f, 2);
const rawBuffer = device.createBuffer({
size: d.sizeOf(Schema),
usage: GPUBufferUsage.STORAGE | GPUBufferUsage.VERTEX,
mappedAtCreation: true,
});
const buffer = root
.createBuffer(Schema, rawBuffer)
.$usage('storage', 'vertex');
buffer.write([d.vec3f(0, 1, 2), d.vec3f(3, 4, 5)]);
new Float32Array(rawBuffer.getMappedRange()).set([
0, 1, 2, /* padding */ 0,
3, 4, 5, /* padding */ 0,
]);
rawBuffer.unmap();

Benefits gained:

  • Typed I/O - typed buffers have .write and .read methods that accept/return properly typed JavaScript values that match that buffer’s schema. Trying to write anything other than an array of vec3f will result in a type error, surfaced immediately by the IDE and at build time.
  • Automatic padding - TypeGPU understands how to translate JS values into binary and back, adhering to memory layout rules. This reduces the room for error, and no longer requires knowledge about vec3fs having to be aligned to multiples of 16 bytes.

Let TypeGPU create the buffer

const Schema = d.arrayOf(d.vec3f, 2);
const rawBuffer = device.createBuffer({
size: d.sizeOf(Schema),
usage: GPUBufferUsage.STORAGE | GPUBufferUsage.VERTEX,
mappedAtCreation: true,
});
const buffer = root
.createBuffer(Schema, rawBuffer)
.createBuffer(Schema, [d.vec3f(0, 1, 2), d.vec3f(3, 4, 5)])
.$usage('storage', 'vertex');
const rawBuffer = root.unwrap(buffer);
wrappedBuffer.write([d.vec3f(0, 1, 2), d.vec3f(3, 4, 5)]);
rawBuffer.unmap();

Benefits gained:

  • Automatic flags - buffer flags can be inferred based on the usages passed into .$usage. That way they can be consistent both on the type-level, as well as at runtime.
  • Initial value - an optional initial value can be passed in, which takes care of mapping the buffer at creation, and unmapping it.

Starting at bind group layouts

Replace the WebGPU API call with a typed variant

const rawLayout = device.createBindGroupLayout({
entries: [
// ambientColor
{
binding: 0,
visibility: GPUShaderStage.COMPUTE,
buffer: {
type: 'uniform',
},
},
// intensity
{
binding: 2,
visibility: GPUShaderStage.COMPUTE,
buffer: {
type: 'uniform',
},
},
],
});
const layout = tgpu.bindGroupLayout({
ambientColor: { uniform: d.vec3f }, // #0 binding
_: null, // #1 skipped!
intensity: { uniform: d.f32 }, // #2 binding
});
const rawLayout = root.unwrap(layout);

Benefits gained:

  • Increased context - replacing indices with named keys ('ambientColor', 'intensity', …) and providing data types reduces the need to jump around the codebase to find a layout’s semantic meaning.
  • Good defaults - binding indices are inferred automatically based on the order of properties in the descriptor, starting from 0. Properties with the value null are skipped. The visibility is assumed based on the type of resource. Can be explicitly limited using the optional visibility property.

Create bind groups from typed layouts

const rawBindGroup = device.createBindGroup({
layout: rawLayout,
entries: [
// ambientColor
{
binding: 0,
resource: { buffer: rawFooBuffer },
},
// intensity
{
binding: 2,
resource: { buffer: rawBarBuffer },
},
],
});
const bindGroup = root.createBindGroup(layout, {
ambientColor: ambientColorBuffer,
intensity: intensityBuffer,
});
const rawBindGroup = root.unwrap(bindGroup);

Benefits gained:

  • Reduced fragility - no longer susceptible to shifts in binding indices, as the resources are associated with a named key instead.
  • Autocomplete - the IDE can suggest what resources need to be passed in, and what their data types should be.
  • Static validation - when the layout gains a new entry, or the kind of resource it holds changes, the IDE and build system will catch it before the error surfaces at runtime. When using typed buffers, the validity of the data-type and usage is also validated on the type level.

Inject shader code

const layout = tgpu.bindGroupLayout({
ambientColor: { uniform: d.vec3f }, // #0 binding
_: null, // #1 skipped!
intensity: { uniform: d.f32 }, // #2 binding
});
}).$idx(0); // <- forces code-gen to assign `0` as the group index.
const rawShader = /* wgsl */ `
@group(0) @binding(0) var<uniform> ambientColor: vec3f;
@group(0) @binding(1) var<uniform> intensity: f32;
@fragment
fn main() -> @location(0) vec4f {
return vec4f(ambientColor * intensity, 1.);
}
`;
const module = device.createShaderModule({
code: rawShader,
code: tgpu.resolve({
template: rawShader,
// Matching up shader variables with layout entries:
// 'nameInShader': layout.bound.nameInLayout,
//
externals: {
ambientColor: layout.bound.ambientColor,
intensity: layout.bound.intensity,
},
// or just:
// externals: { ...layout.bound },
}),
});

Benefits gained:

  • Reduced fragility - binding indices are now being handled end-to-end by TypeGPU, leaving human-readable keys as the way to connect the shader with JavaScript.
  • Single source of truth - typed bind group layouts not only describe JS behavior, but also WGSL behavior. This allows tgpu.resolve to generate the appropriate WGSL code. Definitions of structs used as part of the layout will also be included in the returned shader code.