2. WebGPU Inter-stage Variables

In the last article, we covered some very basic knowledge about WebGPU. In this article, we’ll review the basics of inter-stage variables.

Inter-stage variables come into play between a vertex shader and a fragment shader.
Interstage variables work between the vertex shader and the fragment shader.

When a vertex shader outputs 3 positions a triangle gets rasterized. The vertex shader can output extra values at each of those positions and by default, those values will be interpolated between the 3 points.
Triangles are rasterized when the vertex shader outputs 3 positions. Vertex shaders can output extra values at each location, and by default these values will be interpolated between 3 points.

Let’s take a small example. We’ll start with the triangle shader from the previous article. All we have to do is change the shader.

 const module = device.createShaderModule({<!-- -->
    // label: 'our hardcoded red triangle shaders', //The new line is deleted
    label: 'our hardcoded rgb triangle shaders',
    code:`
      struct OurVertexShaderOutput {
        @builtin(position) position: vec4f,
        @location(0) color: vec4f,
      };
 
      @vertex fn vs(
        @builtin(vertex_index) vertexIndex : u32
      // ) -> @builtin(position) vec4f { //The new line is deleted
      ) -> OurVertexShaderOutput {
        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
        );
        var color = array<vec4f, 3>(
          vec4f(1, 0, 0, 1), // red
          vec4f(0, 1, 0, 1), // green
          vec4f(0, 0, 1, 1), // blue
        );
 
        // return vec4f(pos[vertexIndex], 0.0, 1.0);//The new line is deleted
        var vsOutput: OurVertexShaderOutput;
        vsOutput.position = vec4f(pos[vertexIndex], 0.0, 1.0);
        vsOutput.color = color[vertexIndex];
        return vs Output;
      }
 
     // @fragment fn fs() -> @location(0) vec4f { //The new line is deleted
     // return vec4f(1, 0, 0, 1); //The new line is deleted
      @fragment fn fs(fsInput: OurVertexShaderOutput) -> @location(0) vec4f {
        return fsInput.color;
      }
    `,
  });

First we declare a struct . This is an easy way to coordinate interstage variables between the vertex shader and the fragment shader.

 struct OurVertexShaderOutput {<!-- -->
        @builtin(position) position: vec4f,
        @location(0) color: vec4f,
      };

Then declare the vertex shader to return a structure of this type

 @vertex fn vs(
        @builtin(vertex_index) vertexIndex : u32
      //) -> @builtin(position) vec4f {<!-- -->
      ) -> OurVertexShaderOutput {<!-- -->

We create an array with 3 colors.

 var color = array<vec4f, 3>(
          vec4f(1, 0, 0, 1), // red
          vec4f(0, 1, 0, 1), // green
          vec4f(0, 0, 1, 1), // blue
        );

Then instead of just returning a vec4f for the position, we declare an instance of the struct, fill it, and return it

 //return vec4f(pos[vertexIndex], 0.0, 1.0);
        var vsOutput: OurVertexShaderOutput;
        vsOutput.position = vec4f(pos[vertexIndex], 0.0, 1.0);
        vsOutput.color = color[vertexIndex];
        return vs Output;

In the fragment shader, use this structure as an argument to the function

 @fragment fn fs(fsInput: OurVertexShaderOutput) -> @location(0) vec4f {<!-- -->
        return fsInput.color;
      }

and return the color

If we run this, we’ll see that every time the GPU calls our fragment shader, it passes in a color that interpolates between all 3 points.

Inter-stage variables are most often used to interpolate texture coordinates across a triangle which we’ll cover in the article on textures. Another common use is interpolating normals cross a triangle which will cover in the first article on lighting.
Inter-stage variables are most commonly used to interpolate texture coordinates across triangles, which we’ll cover in our article on textures. Another common use is to interpolate normals across triangles, which will be covered in the first article on lighting.

1. Inter-stage variables connect by location

An important point, like nearly everything in WebGPU, the connection between the vertex shader and the fragment shader is by index. For inter-stage variables they connect by location index.
An important point, like almost everything in WebGPU, is that the connection between the vertex shader and the fragment shader is via an index. For interstage variables, they are linked by positional index.

To make clear what I mean, let’s just change the fragment shader to take the vec4f parameter at location(0) instead of the struct

           //struct OurVertexShaderOutput {<!-- -->
             // @builtin(position) position: vec4f,
             // @location(0) color: vec4f, //<<<===here
             //}; //||
                          //||
      @fragment fn fs(@location(0) color: vec4f) -> @location(0) vec4f {<!-- -->
        return color;
      }

Running we see that it still works.

2. @builtin(position)

This helps point out another oddity. The original shader that we use the same structure in the vertex shader and the fragment shader has a field called position , but position has no location. Instead, it is declared as

@builtin(position) .

      struct OurVertexShaderOutput {<!-- -->
        @builtin(position) position: vec4f,
        @location(0) color: vec4f,
      };

That field is NOT an inter-stage variable. Instead, it’s a builtin. It happens that @builtin(position) has a different meaning in a vertex shader vs a fragment shader.

The position field is not an interstage variable. Instead, it’s a builtin. But it just so happens that @builtin(position) has different meanings in vertex shaders and fragment shaders.

In a vertex shader @builtin(position) is the output that the GPU needs to draw triangles/lines/points

In a vertex shader, @builtin(position) is the output required by the GPU to draw triangles/lines/points

In a fragment shader @builtin(position) is an input. It’s the pixel coordinate of the pixel the fragment shader is currently being asked to compute a color for.

In a fragment shader, @builtin(position) is an input. It is the pixel coordinate of the pixel for which the fragment shader is currently being asked to compute a color.

Pixel coordinates are specified by the edges of pixels. The values provided to the fragment shader are the coordinates of the center of the pixel

Pixel coordinates are specified by the edges of the pixels. The value provided to the fragment shader is the coordinates of the pixel center

If the texture we were drawing to was 3×2 pixels in size these we be the coordinate.
If the size of the texture we draw is 3×2 pixels, then the following image is the coordinates

We can change our shader to use this position. For example, let’s draw a chessboard.

 const module = device.createShaderModule({<!-- -->
    label: 'our hardcoded checkerboard triangle shaders',
    code:`
      struct OurVertexShaderOutput {
        @builtin(position) position: vec4f,
        // @location(0) color: vec4f,
      };
 
      @vertex fn vs(
        @builtin(vertex_index) vertexIndex : u32
      ) -> OurVertexShaderOutput {
        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
        );
       // var color = array<vec4f, 3>(
       // vec4f(1, 0, 0, 1), // red
       // vec4f(0, 1, 0, 1), // green
       // vec4f(0, 0, 1, 1), // blue
       // );
 
        var vsOutput: OurVertexShaderOutput;
        vsOutput.position = vec4f(pos[vertexIndex], 0.0, 1.0);
       // vsOutput.color = color[vertexIndex];
        return vs Output;
      }
 
      @fragment fn fs(fsInput: OurVertexShaderOutput) -> @location(0) vec4f {
       // return fsInput.color;
        let red = vec4f(1, 0, 0, 1);
        let cyan = vec4f(0, 1, 1, 1);
 
        let grid = vec2u(fsInput. position. xy) / 8;
        let checker = (grid.x + grid.y) % 2 == 1;
 
        return select(red, cyan, checker);
      }
    `,
  });

The code above takes fsInput.position, which was declared as @builtin(position), and converts its xy coordinates to a vec2u which is 2 unsigned integers. It then divides them by 8 giving us a count that increases every 8 pixels. It then adds the x and y grid coordinates together, computes module 2, and compares the result to 1. This will give us a boolean that is true or false every other pixel. Finally it uses the WGSL function select which given 2 values, selects one or the other based on a boolean condition. In JavaScript select would be written like this

The code above takes fsInput.position declared as @builtin(position) and converts its xy coordinates to vec2u , 2 unsigned integers. Then divide them by 8 to get counts in 8-pixel intervals. Then add the x and y grid coordinates, modulo 2, and compare the result to 1. This will give us a boolean value that is true or false every other pixel. Finally, it uses the WGSL function select , which, given 2 values, selects one or the other based on a boolean condition. In JavaScript select would be written like this

// If condition is false return `a`, otherwise return `b`
select = (a, b, condition) => condition ? b : a;

Even if you don’t use @builtin(position) in a fragment shader, it’s convenient that it’s there because it means we can use the same struct for both a vertex shader and a fragment shader. What was important to takeaway is that the position struct field in the vertex shader vs the fragment shader is entirely unrelated. They’re entirely different variables.

Even if you don’t use @builtin(position) in the fragment shader, it’s handy there because it means we can use the same structure for both the vertex shader and the fragment shader. Importantly, the vertex shader and fragment shader position struct fields are completely unrelated. They are completely different variables.

As pointed out above though, for inter-stage variables, all that matters is the @location(?). So, it’s not uncommon to declare different structs for a vertex shader’s output vs a fragment shaders input.

As pointed out above, for interstage variables, it is the @location(?) that matters. Therefore, it is not uncommon to declare different structures for the output of the vertex shader and the input of the fragment shader.

Note: It is not that common to generate a checkerboard using the @builtin(position). Checkerboards or other patterns are far more commonly implemented using textures. In fact you’ll see an issue if you size the window. Because the checkerboard is based on on the pixel coordinates of the canvas it’s relative to the canvas, not relative to the triangle.

Note: It is not common to use @builtin(position) to generate a chessboard. Checkerboards or other patterns are more often achieved using texture. In fact, if you resize the window, you’ll see a problem. Because the checkerboard is based on the pixel coordinates of the canvas, it is relative to the canvas, not relative to the triangle.

3. Interpolation Settings Interpolation settings

We saw above that inter-stage variables, the outputs from a vertex shader are interpolated when passed to the fragment shader. There are 2 sets of settings that can be changed for the interpolation happens. Setting them to anything other than the defaults is not extremely common but there are use cases which will be covered in other articles.
We see inter-stage variables above, The output of the vertex shader is interpolated when it is passed to the fragment shader. There are 2 sets of settings that can be changed for interpolation. It’s not very common to set them to anything other than the default, but some use cases are covered in other articles.

Interpolation type:

  • perspective : values are interpolated in a perspective correct way (default)
  • linear : Values are interpolated in a linear, non-perspective-correct manner.
  • flat : Values are not interpolated. Interpolated sampling does not work with planar interpolation

Interpolation sampling:

  • center : interpolate at the center of the pixel (default)

  • centroid : Performs interpolation at points that lie within all samples covered by fragments within the current primitive. This value is the same for all samples in the primitive.

  • sample : Perform interpolation on each sample. When this attribute is applied, the fragment shader is called once per sample.

You specify these as properties. For example

 @location(2) @interpolate(linear, center) myVariableFoo: vec4f;
  @location(3) @interpolate(flat) myVariableBar: vec4f;

Note that if the interstage variable is of integer type, its interpolation must be set to flat .

If you set the interpolation type to flat, the value passed to the fragment shader is the value of the inter-stage variable for the first vertex in that triangle.
If the interpolation type is set to flat , the value passed to the fragment shader is the value of the interstage variable for the first vertex in that triangle.

In the next article, we’ll introduce uniforms as another way to pass data into shaders.