1.6 Understanding Uniforms

In this tutorial, we'll explore the concept of uniforms in WebGPU shaders. Uniforms provide a mechanism to supply data to shader programs, acting as constants throughout the entire execution of a shader.

Launch Playground - 1_06_uniforms

You might wonder how uniforms differ from attributes, which we've previously used to pass data to shader programs. The distinction lies in their intended use and behavior.

This distinction is crucial because it enables shaders to handle both per-vertex data (attributes) and shared, unchanging data (uniforms), offering flexibility and efficiency in the rendering process.

In this example, we'll create a uniform called "offset" to shift the positions of our vertices by a consistent amount. Using a uniform for this purpose is logical because we want the same offset applied to all vertices. If we used attributes for the offset, we'd have to duplicate the same value for every vertex, which would be inefficient and wasteful. Uniforms are the ideal choice when you need to pass the same information to the shader for all vertices.

By using a uniform for the offset, we can efficiently apply a global transformation to our geometry, easily update the offset value for dynamic effects, and reduce memory usage and data transfer compared to per-vertex attributes. This example will demonstrate how to declare, set, and use uniforms in WebGPU, illustrating their power in creating flexible and efficient shader programs.

Let's examine the syntax used to create a uniform in this program:

@group(0) @binding(0)
var<uniform> offset: vec3<f32>;

The var<uniform> declaration indicates that offset is a uniform variable, signaling to the shader that this variable should be provided from a uniform buffer. The @binding(0) annotation serves a similar purpose to @location(0) for vertex attributes. It's an index that identifies the uniform within a uniform buffer. In a typical uniform buffer, you'll pack multiple uniform values, and this index helps the shader locate the correct value efficiently.

The @group(0) annotation relates to how uniforms are organized. In this simple case, we've placed all uniforms in a single group (group 0). However, for more complex shaders, using multiple groups can be advantageous. For instance, when rendering an animated scene, you might have camera parameters that change frequently and object colors that remain constant. By separating these into different groups, you can update only the data that changes frequently, thereby optimizing performance.

@vertex
fn vs_main(
    @location(0) inPos: vec3<f32>,
    @location(1) inColor: vec3<f32>
) -> VertexOutput {
    var out: VertexOutput;
    out.clip_position = vec4<f32>(inPos + offset, 1.0);
    out.color = inColor;
    return out;
}

After defining the offset uniform, its usage in the shader becomes straightforward. We simply add the offset value to the position of each vertex, effectively shifting their positions. This uniform allows us to apply a consistent transformation to all vertices without the need for duplication or redundancy in the shader code.

After modifying the shader, we need to create the uniform buffer and supply the data on the JavaScript side. Uniforms, like attribute data, are provided in a GPU buffer. Let's break down this process:

const uniformData = new Float32Array([
    0.1, 0.1, 0.1
]);

let uniformBuffer = createGPUBuffer(device, uniformData, GPUBufferUsage.UNIFORM);

First, we create a uniformData buffer to hold our uniform values. In this example, it contains a 3-element vector representing our offset. We create the uniformBuffer using the GPUBufferUsage.UNIFORM flag to indicate its purpose. Then, we use our helper function to populate the GPU uniform buffer with the data.

let uniformBindGroupLayout = device.createBindGroupLayout({
    entries: [
        {
            binding: 0,
            visibility: GPUShaderStage.VERTEX,
            buffer: {}
        }
    ]
});

Next, we create a uniform binding group layout to describe the format of the uniform group, corresponding to our uniform group definition in the shader code. In this example, the layout has one entry, corresponding to our single uniform value. The binding index matches the one in the shader, while visibility is set to VERTEX as we use this uniform in the vertex shader. Finally, the empty buffer setting object means that we want to use defaults.

let uniformBindGroup = device.createBindGroup({
    layout: uniformBindGroupLayout,
    entries: [
        {
            binding: 0,
            resource: {
                buffer: uniformBuffer
            }
        }
    ]
});

We then create the uniform binding group, connecting the layout with the actual data storage. Here, we supply the uniform buffer as the resource for binding 0.

const pipelineLayoutDesc = { bindGroupLayouts: [uniformBindGroupLayout] };
• • •
passEncoder.setBindGroup(0, uniformBindGroup);

In the pipeline layout descriptor, we include the uniform binding group layout. Finally, when encoding the render command, we use setBindGroup to specify the group ID and the corresponding binding group.

With these steps, we've successfully created a uniform buffer, defined its layout, and supplied the uniform data to the shader in the GPU pipeline. The result is the same triangle, but slightly offset based on our uniform values. Experiment with adjusting the offset values in the code sample to see how it affects the triangle's position.

Leave a Comment on Github