3. WebGPU Uniforms

The previous article was about interstage variables. This article will be about the basics of uniforms.

Uniforms are kind of like global variables for your shader. You can set their values before you execute the shader and they’ll have those values for every iteration of the shader. You can them set them to something else the next time you ask the GPU to execute the shader.

Uniforms are a bit like shader global variables . You can set their values ​​before the shader executes, and they can use those values ​​on every iteration of the shader. You can set them to other values ​​the next time you ask the GPU to execute shaders.

We'll start over with the triangle example from the first article and modify it to use some uniforms

  const module = device.createShaderModule({
    
    
    label: 'triangle shaders with uniforms',
    code: `
      struct OurStruct {
        color: vec4f,
        scale: vec2f,
        offset: vec2f,
      };
 
      @group(0) @binding(0) var<uniform> ourStruct: OurStruct;
 
      @vertex fn vs(
        @builtin(vertex_index) vertexIndex : u32
      ) -> @builtin(position) vec4f {
        var pos = array<vec2f, 3>(
          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] * ourStruct.scale + ourStruct.offset, 0.0, 1.0);
      }
 
      @fragment fn fs() -> @location(0) vec4f {
        //return vec4f(1, 0, 0, 1);
        return ourStruct.color;
      }
    `,
  });
 
  });

First we declare a structure with 3 members

      struct OurStruct {
    
    
        color: vec4f,
        scale: vec2f,
        offset: vec2f,
      };

Then we declared a uniform variable with that struct type. The variable name is ourStruct and the type is the structure type OurStruct just defined.

      @group(0) @binding(0) var<uniform> ourStruct: OurStruct;

Next change what is returned from the vertex shader to use uniforms

      @vertex fn vs(
         ...
      ) ... {
    
    
        ...
        return vec4f(
          pos[vertexIndex] * ourStruct.scale + ourStruct.offset, 0.0, 1.0);
      }

You can see that we multiply the vertex position by scale and add to offset. This will let us set the size of the triangle and position it.

We also change the fragment shader to return colors from uniforms

      @fragment fn fs() -> @location(0) vec4f {
    
    
        return ourStruct.color;
      }

Now that we have set up our shaders to use uniforms, we need to create a buffer on the GPU to hold their values.

This is new territory, and if you've never dealt with raw data and sizes, there's a lot to learn. This is a huge topic, so here's a separate article on it . If you don't know how structures are laid out in memory, read the article. Then come back here. This article assumes you have already read it.

After reading the article, we can now move on to populating the buffer with data that matches the structure in the shader.

First, we create a buffer and assign it the use flag so that it can be used with uniforms, and we can update it by copying data into it.

  const uniformBufferSize =
    4 * 4 + // color is 4 32bit floats (4bytes each)
    2 * 4 + // scale is 2 32bit floats (4bytes each)
    2 * 4;  // offset is 2 32bit floats (4bytes each)
  const uniformBuffer = device.createBuffer({
    
    
    size: uniformBufferSize,
    usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST,
  });

Then we create a TypedArray so we can set values ​​in JavaScript

  // create a typedarray to hold the values for the uniforms in JavaScript
  const uniformValues = new Float32Array(uniformBufferSize / 4);


and we'll fill out 2 of the values ​​of our struct that won't be changing later. The offsets were computed using what we covered in the article on memory-layout. It will not change in the future. The offset is calculated using what we described in the memory layout article.

  // offsets to the various uniform values in float32 indices
  const kColorOffset = 0;
  const kScaleOffset = 4;
  const kOffsetOffset = 6;
 
  uniformValues.set([0, 1, 0, 1], kColorOffset);        // set the color
  uniformValues.set([-0.5, -0.25], kOffsetOffset);      // set the offset

Above we’re setting the color to green. The offset will move the triangle to the left 1/4th of the canvas and down 1/8th. (remember, clip space goes from -1 to 1 which is 2 units wide so 0.25 is 1/8 of 2).

Above we set the color to green. Offset will move the triangle 1/4 to the left and 1/8 to the bottom of the canvas. (Remember, clip space goes from -1 to 1, which is 2 units wide, so 0.25 is 1/8 of 2).

Next, as the diagram showed in the first article, to tell a shader about our buffer we need to create a bind group and bind the buffer to the same @binding(?) we set in our shader.

Next, as shown in the diagram in the first article , in order to tell the shader about our buffer, we need to create a binding group and bind the buffer to the same @binding(?) .

  const bindGroup = device.createBindGroup({
    
    
    layout: pipeline.getBindGroupLayout(0),
    entries: [
      {
    
     binding: 0, resource: {
    
     buffer: uniformBuffer }},
    ],
  });

Now, sometime before we submit the command buffer, we need to set the other values ​​of uniformValues ​​and then copy those values ​​to the buffer on the GPU. We'll do this at the top of the render function.

  function render() {
    
    
    // Set the uniform values in our JavaScript side Float32Array
    const aspect = canvas.width / canvas.height;
    uniformValues.set([0.5 / aspect, 0.5], kScaleOffset); // set the scale
 
    // copy the values from JavaScript to the GPU
    device.queue.writeBuffer(uniformBuffer, 0, uniformValues);

We set scale to half the size and take into account the aspect ratio of the canvas, so the triangle will maintain the same aspect ratio regardless of the size of the canvas.

Finally, we need to set the bind group before drawing

    pass.setPipeline(pipeline);
    pass.setBindGroup(0, bindGroup); //<===here
    pass.draw(3);  // call our vertex shader 3 times
    pass.end();

So we get a green triangle, same as before

insert image description here
For this triangle, our state at the time of the draw command is this
insert image description here
: So far, all the data we've used in the shader is hardcoded (triangle vertex positions in the vertex shader, and colors in the fragment shader ). Now that we can pass values ​​into our shader, we can call draw multiple times with different data.

We can draw different offsets, scales and colors in different places by updating our single buffer. Keep in mind that although our commands are put into the command buffer, they are not actually executed until we submit them. so can't do

    // BAD!
    for (let x = -1; x < 1; x += 0.1) {
    
    
      uniformValues.set([x, x], kOffsetOffset);
      device.queue.writeBuffer(uniformBuffer, 0, uniformValues);
      pass.draw(3);
    }
    pass.end();
 
    // Finish encoding and submit the commands
    const commandBuffer = encoder.finish();
    device.queue.submit([commandBuffer]);

Because, as you can see above, the device.queue.xxx functions happen on the "queue", while the pass.xxx functions just encode the command in the command buffer.

When we actually call submit with our command buffer, the only thing in the buffer is the last value we wrote.

We can change it to this

    // BAD! Slow!
    for (let x = -1; x < 1; x += 0.1) {
    
    
      uniformValues.set([x, 0], kOffsetOffset);
      device.queue.writeBuffer(uniformBuffer, 0, uniformValues);
 
      const encoder = device.createCommandEncoder();
      const pass = encoder.beginRenderPass(renderPassDescriptor);
      pass.setPipeline(pipeline);
      pass.setBindGroup(0, bindGroup);
      pass.draw(3);
      pass.end();
 
      // Finish encoding and submit the commands
      const commandBuffer = encoder.finish();
      device.queue.submit([commandBuffer]);
    }

The code above updates a buffer, creates a command buffer, adds commands to draw one thing, then completes the command buffer and submits. This works, but is slow for a number of reasons. The biggest is that getting more work done in a single command buffer is best practice.

Therefore, we can create a uniform buffer for each object to be drawn. And, since buffers are used indirectly through binding groups , we also need a binding group for each object we want to draw. Then we can put everything we want to draw into one command buffer.

let's do it

First let's make a random function

// A random number between [min and max)
// With 1 argument it will be [0 to min)
// With no arguments it will be [0 to 1)
const rand = (min, max) => {
    
    
  if (min === undefined) {
    
    
    min = 0;
    max = 1;
  } else if (max === undefined) {
    
    
    max = min;
    min = 0;
  }
  return min + Math.random() * (max - min);
};

Now let's set up the buffer with a bunch of colors and offsets so we can draw a bunch of individual things.

  // offsets to the various uniform values in float32 indices
  const kColorOffset = 0;
  const kScaleOffset = 4;
  const kOffsetOffset = 6;
 
  const kNumObjects = 100;
  const objectInfos = [];
 
  for (let i = 0; i < kNumObjects; ++i) {
    
    
    const uniformBuffer = device.createBuffer({
    
    
      label: `uniforms for obj: ${
      
      i}`,
      size: uniformBufferSize,
      usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST,
    });
 
    // create a typedarray to hold the values for the uniforms in JavaScript
    const uniformValues = new Float32Array(uniformBufferSize / 4);
 // uniformValues.set([0, 1, 0, 1], kColorOffset);        // set the color
 // uniformValues.set([-0.5, -0.25], kOffsetOffset);      // set the offset
    uniformValues.set([rand(), rand(), rand(), 1], kColorOffset);        // set the color
    uniformValues.set([rand(-0.9, 0.9), rand(-0.9, 0.9)], kOffsetOffset);      // set the offset
 
    const bindGroup = device.createBindGroup({
    
    
      label: `bind group for obj: ${
      
      i}`,
      layout: pipeline.getBindGroupLayout(0),
      entries: [
        {
    
     binding: 0, resource: {
    
     buffer: uniformBuffer }},
      ],
    });
 
    objectInfos.push({
    
    
      scale: rand(0.2, 0.5),
      uniformBuffer,
      uniformValues,
      bindGroup,
    });
  }

We haven't set that in our buffer yet, because we want it to take into account the appearance of the canvas, and we won't know what the canvas will look like until render time.

When rendering, we will update all buffers with the correct aspect ratio rescaling.

  function render() {
    
    
    // Set the uniform values in our JavaScript side Float32Array
    //const aspect = canvas.width / canvas.height;
    //uniformValues.set([0.5 / aspect, 0.5], kScaleOffset); // set the scale
 
    // copy the values from JavaScript to the GPU
    //device.queue.writeBuffer(uniformBuffer, 0, uniformValues);
 
    // Get the current texture from the canvas context and
    // set it as the texture to render to.
    renderPassDescriptor.colorAttachments[0].view =
        context.getCurrentTexture().createView();
 
    const encoder = device.createCommandEncoder();
    const pass = encoder.beginRenderPass(renderPassDescriptor);
    pass.setPipeline(pipeline);
 
    // Set the uniform values in our JavaScript side Float32Array
    const aspect = canvas.width / canvas.height;
 
    for (const {
    
    scale, bindGroup, uniformBuffer, uniformValues} of objectInfos) {
    
    
      uniformValues.set([scale / aspect, scale], kScaleOffset); // set the scale
      device.queue.writeBuffer(uniformBuffer, 0, uniformValues);
       pass.setBindGroup(0, bindGroup);
       pass.draw(3);  // call our vertex shader 3 times
    }
    pass.end();
 
    const commandBuffer = encoder.finish();
    device.queue.submit([commandBuffer]);
  }

Also, remember that the encoder and pass objects simply encode the command into the command buffer. So when the render function exists, we've effectively issued the commands in this order.

device.queue.writeBuffer(...) // update uniform buffer 0 with data for object 0
device.queue.writeBuffer(...) // update uniform buffer 1 with data for object 1
device.queue.writeBuffer(...) // update uniform buffer 2 with data for object 2
device.queue.writeBuffer(...) // update uniform buffer 3 with data for object 3
...
// execute commands that draw 100 things, each with their own uniform buffer.
device.queue.submit([commandBuffer]);

The result is as follows

insert image description here
While we're here, there's one more thing to tell. You can freely reference multiple uniform buffers in a shader. In our example above, the scale is updated every time we draw, and then we writeBuffer to upload the uniformValues ​​of the object to the corresponding uniform buffer. However, only the scale is updated, not the color and offset, so we waste time uploading the color and offset.

We can split uniforms into uniforms that need to be set once and uniforms that are updated every time they are drawn.

  const module = device.createShaderModule({
    
    
    code: `
      struct OurStruct {
        color: vec4f,
       // scale: vec2f,
        offset: vec2f,
      };
 
      struct OtherStruct {
        scale: vec2f,
      };
 
      @group(0) @binding(0) var<uniform> ourStruct: OurStruct;
      @group(0) @binding(1) var<uniform> otherStruct: OtherStruct;
 
      @vertex fn vs(
        @builtin(vertex_index) vertexIndex : u32
      ) -> @builtin(position) vec4f {
        var pos = array<vec2f, 3>(
          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] * ourStruct.scale + ourStruct.offset, 0.0, 1.0);
          pos[vertexIndex] * otherStruct.scale + ourStruct.offset, 0.0, 1.0);
      }
 
      @fragment fn fs() -> @location(0) vec4f {
        return ourStruct.color;
      }
    `,
  });

When we need 2 uniform buffers for everything we want to draw

  // create a buffer for the uniform values
  //const uniformBufferSize =
  //  4 * 4 + // color is 4 32bit floats (4bytes each)
  //  2 * 4 + // scale is 2 32bit floats (4bytes each)
  //  2 * 4;  // offset is 2 32bit floats (4bytes each)
  // offsets to the various uniform values in float32 indices
  //const kColorOffset = 0;
  //const kScaleOffset = 4;
  //const kOffsetOffset = 6;
  // create 2 buffers for the uniform values
  const staticUniformBufferSize =
    4 * 4 + // color is 4 32bit floats (4bytes each)
    2 * 4 + // offset is 2 32bit floats (4bytes each)
    2 * 4;  // padding
  const uniformBufferSize =
    2 * 4;  // scale is 2 32bit floats (4bytes each)
 
  // offsets to the various uniform values in float32 indices
  const kColorOffset = 0;
  const kOffsetOffset = 4;
 
  const kScaleOffset = 0;
 
  const kNumObjects = 100;
  const objectInfos = [];
 
  for (let i = 0; i < kNumObjects; ++i) {
    
    
    const staticUniformBuffer = device.createBuffer({
    
    
      label: `static uniforms for obj: ${
      
      i}`,
      size: staticUniformBufferSize,
      usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST,
    });
 
    // These are only set once so set them now
    {
    
    
      //const uniformValues = new Float32Array(uniformBufferSize / 4);
      const uniformValues = new Float32Array(staticUniformBufferSize / 4);
      uniformValues.set([rand(), rand(), rand(), 1], kColorOffset);        // set the color
      uniformValues.set([rand(-0.9, 0.9), rand(-0.9, 0.9)], kOffsetOffset);      // set the offset
 
      // copy these values to the GPU
      //device.queue.writeBuffer(uniformBuffer, 0, uniformValues);
      device.queue.writeBuffer(staticUniformBuffer, 0, uniformValues);
    }
 
    // create a typedarray to hold the values for the uniforms in JavaScript
    const uniformValues = new Float32Array(uniformBufferSize / 4);
    const uniformBuffer = device.createBuffer({
    
    
      label: `changing uniforms for obj: ${
      
      i}`,
      size: uniformBufferSize,
      usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST,
    });
 
    const bindGroup = device.createBindGroup({
    
    
      label: `bind group for obj: ${
      
      i}`,
      layout: pipeline.getBindGroupLayout(0),
      entries: [
        {
    
     binding: 0, resource: {
    
     buffer: staticUniformBuffer }},
        {
    
     binding: 1, resource: {
    
     buffer: uniformBuffer }}, //<<===here
      ],
    });
 
    objectInfos.push({
    
    
      scale: rand(0.2, 0.5),
      uniformBuffer,
      uniformValues,
      bindGroup,
    });
  }

Nothing changes in our render code. The bind group for each object contains a reference to both uniform buffers for each object. Just as before we are updating the scale. But now we're only uploading the scale when we call device.queue.writeBuffer to update the uniform buffer that holds the scale value whereas before we were uploading the color + offset + scale for each object.
No changes were made to our rendering code. Each object's binding group contains references to each object's two uniform buffers. Just like before we updated the scale. But now we only upload the scale scale when we call device.queue.writeBuffer to update the uniform buffer holding the scale value, while the previous code needs to upload color + offset + scale for each object.

insert image description here
While splitting into multiple uniform buffers may be a bit of over-engineering in this simple example, it's common to split based on what changed and when . Examples might include a uniform buffer for shared matrices. For example item matrix, view matrix, camera matrix. Since these are usually the same for everything we want to draw, we can just create one buffer and have all objects use the same uniform buffer.

Also, our shader may reference another uniform buffer that only contains stuff specific to that object , such as its world/model matrix and normal matrix.

Another uniform buffer may contain material settings. These settings may be shared by multiple objects.

We do a lot of this when we introduce drawing in 3D.

Next, store the buffer

Guess you like

Origin blog.csdn.net/xuejianxinokok/article/details/130829123