const canUseImmediates = !!GPURenderPassEncoder?.prototype.setImmediates;Ideally, by 2027, you should no longer need this check.
Immediates are a convenient way to easily pass a small amount of data to a shader.
In the article uniforms and the article on storage buffers,
we covered how to pass data to a shader, via a buffer. We defined a var<uniform> or var<storage, ...> bindings
in our shaders and the bound buffers to those bindings. With immediates we use var<immediate> and no binding.
The differences between var<immediate> vs var<uniform> and var<storage>:
You can only have one var<immediate> per shader
With var<uniform> and var<storage, ...> you can declare multiple bindings.
With var<immediate> there can be only one
Your immediates can only use 64bytes total [1]
You must initializes all immediates
With buffers, the buffer’s contents are initialized to 0. With immediates, they are uninitialized and you must explicitly initialize them. If you don’t you’ll get a validation error.
Immediates are reset to undefined when
You can kind of think of immediates as a mini uniform buffer.
There is only one. It’s small. You set it with passEncoder.setImmediates
Let’s take the simple triangle example from the bottom of the article on fundamentals and updated it to draw 3 triangles in different colors using immediates.
First let’s add an offset and color to the shaders
+struct MyImmediates {
+ color: vec4f,
+ offset: vec2f,
+};
+
+var<immediate> myImmediates: MyImmediates;
@vertex fn vs(
@builtin(vertex_index) vertexIndex : u32
) -> @builtin(position) vec4f {
let pos = array(
vec2f( 0.0, 0.5), // top center
vec2f(-0.5, -0.5), // bottom left
vec2f( 0.5, -0.5) // bottom right
);
- return vec4f(pos[vertexIndex], 0.0, 1.0);
+ return vec4f(pos[vertexIndex] + myImmediates.offset, 0.0, 1.0);
}
@fragment fn fs() -> @location(0) vec4f {
- return vec4f(1, 0, 0, 1);
+ return myImmediates.color;
}
Then we can update the JavaScript to draw 3 times, setting the immediates
using setImmediates each time to draw in a different color in a different
location.
function render() {
renderPassDescriptor.colorAttachments[0].view =
context.getCurrentTexture().createView();
const encoder = device.createCommandEncoder({ label: 'our encoder' });
const pass = encoder.beginRenderPass(renderPassDescriptor);
pass.setPipeline(pipeline);
+ pass.setImmediates(0, new Float32Array([
+ 1, 0, 0, 1, // color
+ -0.4, -0.2, // offset
+ ]));
pass.draw(3);
+ pass.setImmediates(0, new Float32Array([
+ 0, 1, 0, 1, // color
+ 0.4, -0.2, // offset
+ ]));
+ pass.draw(3);
+
+ pass.setImmediates(0, new Float32Array([
+ 0, 0, 1, 1, // color
+ 0.0, 0.2, // offset
+ ]));
+ pass.draw(3);
pass.end();
const commandBuffer = encoder.finish();
device.queue.submit([commandBuffer]);
}
Just like var<uniform> and var<storage, ...> the data
in immediates follows the same memory layout rules.
The arguments to setImmediates are
passEncoder.setImmediates( byteOffset, // offset in the immediates src, // An ArrayBufferView or ArrayBuffer srcOffset?, // an offset in elements of src into the src size?, // the number of elements );
In our case, we passed the entire Float32Array in each call to setImmediates
so we didn’t need the last 2 optional arguments. The srcOffset defaults to 0
and the size defaults the size of src.
You might be wondering, with a limit of 64 bytes, what’s the use case for immediates.
The most common usage is probably just to pass indices into other data. Imagine making a per model storage buffer array and a per material storage buffer array
struct PerModel {
matrix: mat4x4f,
};
struct Material {
color: vec4f,
shininess: f32,
};
@group(0) @binding(0) var<storage, read> models: array<PerModel>;
@group(0) @binding(1) var<storage, read> materials: array<Material>;
...
Then you could use immediates to select the PerModel and Material
values
struct RenderIndices {
modelNdx: u32,
materialNdx: u32,
};
var<immediate> renderIndices: RenderIndices;
... in vertex shader ...
let modelMatrix = models[renderIndices.modelNdx];
... in fragment shader ...
let material = materials[renderIndices.materialNdx];
Now at render time you can select a per model data and material data just by passing in the indices
pass.setImmediates(0, new Uint32Array([modelNdx, materialNdx]))
This could be an optimization as you won’t have to manage a uniform buffer per model and per material.
Here’s a full shader as an example
struct Material {
color: vec4f,
};
struct PerModel {
matrix: mat4x4f,
};
struct Globals {
viewProjection: mat4x4f,
};
struct Vertex {
@location(0) position: vec4f,
};
struct MyImmediates {
modelNdx: u32,
materialNdx: u32,
};
@group(0) @binding(0) var<storage, read> materials: array<Material>;
@group(0) @binding(1) var<storage, read> perModel: array<PerModel>;
@group(0) @binding(2) var<uniform> glb: Globals;
var<immediate> imm: MyImmediates;
@vertex fn vs(v: Vertex) -> @builtin(position) vec4f {
let model = perModel[imm.modelNdx];
return glb.viewProjection * model.matrix * v.position;
}
@fragment fn fs() -> @location(0) vec4f {
let material = materials[imm.materialNdx];
return material.color;
}
The shader above uses immediates to select a material and per model data. It uses matrix math to position the vertices.
It also has a global uniform buffer for things that are shared by all models. In this case it uses a shared viewProjection matrix.
We make a pipeline that uses this shader and specifies vertex buffers that use 2 floats per vertex.
const pipeline = device.createRenderPipeline({
label: 'our select model and material via immediates pipeline',
layout: 'auto',
vertex: {
module,
buffers: [
// position
{
arrayStride: 2 * 4, // 2 floats, 4 bytes each
attributes: [
{shaderLocation: 0, offset: 0, format: 'float32x2'},
],
},
],
},
fragment: {
module,
targets: [{ format: presentationFormat }],
},
});
We create vertex buffers for 3 different shapes, a triangle, a square, and a circle.
const squareVertices = [
-0.5, -0.5,
0.5, -0.5,
-0.5, 0.5,
-0.5, 0.5,
0.5, -0.5,
0.5, 0.5,
];
const triangleVertices = [
0, 0.5,
-0.5, -0.5,
0.5, -0.5,
];
const circleVertices = [];
const numCircleTriangles = 100;
for (let i = 0; i < numCircleTriangles; ++i) {
const angle0 = (i + 0) / numCircleTriangles * 2 * Math.PI;
const angle1 = (i + 1) / numCircleTriangles * 2 * Math.PI;
circleVertices.push(Math.cos(angle0) * 0.5, Math.sin(angle0) * 0.5);
circleVertices.push(Math.cos(angle1) * 0.5, Math.sin(angle1) * 0.5);
circleVertices.push(0, 0);
}
function createVertexBuffer(device, data) {
const buffer = device.createBuffer({
size: data.byteLength,
usage: GPUBufferUsage.VERTEX | GPUBufferUsage.COPY_DST,
});
device.queue.writeBuffer(buffer, 0, data);
return { buffer, numVertices: data.length / 2 };
}
const vertices = [
createVertexBuffer(
device, new Float32Array(triangleVertices)),
createVertexBuffer(
device, new Float32Array(circleVertices)),
createVertexBuffer(
device, new Float32Array(squareVertices)),
];
Then we’ll make a storage buffer with 6 materials.
const materialData = new Float32Array([
1.0, 0.5, 0.5, 1.0, // red
0.5, 1.0, 0.5, 1.0, // green
0.5, 0.5, 1.0, 1.0, // blue
1.0, 1.0, 0.5, 1.0, // yellow
1.0, 0.5, 1.0, 1.0, // magenta
0.5, 1.0, 1.0, 1.0, // cyan
]);
const numMaterials = materialData.length / 4;
const materialBuffer = device.createBuffer({
label: 'our material buffer',
size: materialData.byteLength,
usage: GPUBufferUsage.STORAGE | GPUBufferUsage.COPY_DST,
});
device.queue.writeBuffer(materialBuffer, 0, materialData);
And we’ll defined 200 “models” where a model is the combination of a vertex buffer, a material, a per model data.
const models = [];
const numModels = 200;
const modelData = new Float32Array(numModels * 16);
for (let i = 0; i < numModels; ++i) {
const modelNdx = i;
const materialNdx = randInt(numMaterials);
const geometryNdx = randInt(vertices.length);
const {buffer, numVertices} = vertices[geometryNdx];
const mat = mat4.translation([(Math.random() - 0.5) * 2, (Math.random() - 0.5) * 2, 0]);
mat4.rotateZ(mat, Math.random() * Math.PI * 2, mat);
mat4.scale(mat, [Math.random() * 0.1 + 0.1, Math.random() * 0.1 + 0.1, 1], mat);
modelData.set(mat, i * 16);
models.push({
numVertices,
vertexBuffer: buffer,
immediates: new Uint32Array([
modelNdx,
materialNdx,
]),
});
}
Above we used our math to choose a random position, scale and orientation. This is stored in the model data.
We then need to upload that data into a storage buffer
const perModelBuffer = device.createBuffer({
label: 'our per model buffer',
size: modelData.byteLength,
usage: GPUBufferUsage.STORAGE | GPUBufferUsage.COPY_DST,
});
device.queue.writeBuffer(perModelBuffer, 0, modelData);
We also have a shared buffer that all models will use. This will store our projection matrix.
const sharedData = new Float32Array(16);
const sharedBuffer = device.createBuffer({
label: 'our shared data buffer',
size: sharedData.byteLength,
usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST,
});
We then make a bind group that references our 3 buffers
const bindGroup = device.createBindGroup({
label: 'our bind group',
layout: pipeline.getBindGroupLayout(0),
entries: [
{ binding: 0, resource: materialBuffer },
{ binding: 1, resource: perModelBuffer },
{ binding: 2, resource: sharedBuffer },
],
});
Finally we can render. First we compute an orthographic matrix that will make our rendering fit the aspect of our canvas and upload it to the shared buffer.
function render() {
renderPassDescriptor.colorAttachments[0].view =
context.getCurrentTexture().createView();
+ const aspect = context.canvas.clientWidth / context.canvas.clientHeight;
+ mat4.ortho(-aspect, aspect, -1, 1, -1, 1, sharedData);
+ device.queue.writeBuffer(sharedBuffer, 0, sharedData);
Then we can render all of our models
function render() {
renderPassDescriptor.colorAttachments[0].view =
context.getCurrentTexture().createView();
const aspect = context.canvas.clientWidth / context.canvas.clientHeight;
mat4.ortho(-aspect, aspect, -1, 1, -1, 1, sharedData);
device.queue.writeBuffer(sharedBuffer, 0, sharedData);
const encoder = device.createCommandEncoder({ label: 'our encoder' });
const pass = encoder.beginRenderPass(renderPassDescriptor);
pass.setPipeline(pipeline);
* pass.setBindGroup(0, bindGroup);
* for (let i = 0; i < numModels; ++i) {
* const { immediates, vertexBuffer, numVertices } = models[i];
* pass.setImmediates(0, immediates);
* pass.setVertexBuffer(0, vertexBuffer);
* pass.draw(numVertices);
* }
pass.end();
const commandBuffer = encoder.finish();
device.queue.submit([commandBuffer]);
}
And with that we’re drawing multiple models and selecting materials and per model data using immediates.
Hopefully this gives you some idea of how to use immediates. The fact that they have a small 64 byte limit generally means you need to be creative in how to take advantage of them.