Skip to main content

Render pipeline

In the previous article, we saw how to configure a render pass to clear the contents of a texture with a fixed background color. In this article, we will see how to use render pipelines: objects that, attached to a render pass, allow drawing geometry.

#Geometric primitives

GPUs are fundamentally able to draw three types of primitives: points, lines, and triangles. Curves and complex shapes are approximated as combinations of the basic primitives.

PuntoLineaTriangolo
Primitive geometriche

Primitives are composed of vertices, each defined by their coordinates. Coordinates are expressed in a reference system called Normalized Device Coordinates (NDC). In this system, coordinates range from -1 to 1 for each dimension, with the origin at the center of the screen.

-111-1
Le coordinate NDC rappresentano il sistema di riferimento da -1 a 1 sugli assi cardinali. Ridimensiona lo "schermo" per vedere come le coordinate si adattano.

NDC coordinates are independent of the screen resolution and aspect ratio. The graphics card takes care of converting NDC coordinates into screen coordinates (or window coordinates), which are the actual device coordinates.

#Shaders

A shader is a program that runs on the GPU. There are two types of shaders for rendering: the vertex shader and the fragment shader. The vertex shader is a program that runs for each vertex of the primitives to be drawn. Its task is to provide the vertex coordinates in the NDC system.

Ciascun vertice è transformato dal vertex shader, che per un triangolo viene eseguito 3 volte. Ogni cella evidenziata rappresenta uno dei pixel processati dal fragment shader. Trascina i vertici per modificare la forma del triangolo.

The fragment shader, instead, is invoked for each pixel (called a fragment) that makes up the primitives. Its purpose is to compute the color of each pixel. Both vertex and fragment shaders are executed in parallel on vertices and pixels.

#WGSL language

Shaders are written in a language called WebGPU Shading Language (WGSL):

@vertex
fn vs(@builtin(vertex_index) index: u32) -> @builtin(position) vec4f {
let pos = array<vec2f, 3>(
vec2f(-0.8, -0.8),
vec2f(0.8, -0.8),
vec2f(0, 0.8)
);
return vec4f(pos[index], 0.0, 1.0);
}

The vertex shader is a function defined with the fn keyword and annotated with @vertex. In the example, the function vs declares a parameter index of type u32, a 32-bit unsigned integer. The @builtin(vertex_index) annotation indicates that the parameter represents the (zero-based) index of the vertex. The return type is vec4f, a 4-component single-precision vector. The @builtin(position) annotation indicates that the return value represents the vertex position in Normalized Device Coordinates.

The method starts by declaring a variable pos of type array<vec2f, 3>, which contains the coordinates of 3 vertices in NDC. Unlike JavaScript, in WGSL the let keyword is used to declare constants, while var is used for variables.

The vertex coordinates are obtained by indexing the pos array based on the index parameter, which is why the vertex shader in this example is designed to draw a single triangle. Since the return type is vec4f, the two-component vector pos[index] is expanded into a four-component vector by setting the third coordinate to 0 and the fourth to 1. The third and fourth components come into play when rendering 3D geometry, but at this point they are not important.

@fragment
fn fs() -> @location(0) vec4f {
return vec4f(1, 1, 0, 1);
}

The fragment shader is a function annotated with @fragment. In this simple example, no input parameters are used. The return type depends on how many and which textures are written as output. In the example, we assume writing to a single texture in floating-point format, so we use the vec4f type annotated with @location(0). A suitable texture format for this type of value is, for example, rgba32float. The fragment shader returns a constant color vec4f(1, 1, 0, 1), with the red and green channels at 1 and the blue channel at 0; the fourth component represents transparency, where 1 is fully opaque.

#Compilation

WGSL is a high-level language, so it must be compiled before it can be executed by the GPU. The code is translated into an intermediate-level language, such as SPIR-V (Vulkan, OpenGL, OpenCL), Metal Shading Language (Apple), or HLSL (Microsoft), which in turn is compiled into machine code (ISA, or Instruction Set Architecture). Machine code is specific to each vendor, for example NVIDIA, AMD, or Intel.

src/webgpu/utils.ts
export async function compileShader(device: GPUDevice, code: string) {
// Create shader module
device.pushErrorScope('validation');
const shaderModule = device.createShaderModule({
code
});
const errors = await device.popErrorScope();
if (errors) {
throw new Error('Could not compile shader!');
}
return shaderModule;
}

The compileShader function compiles the WGSL code using the createShaderModule() method. In case of compilation errors, they are recorded in the validation error scope. Through pushErrorScope and popErrorScope, it is possible to examine the validation scope to throw an exception in case of errors. The returned shaderModule object is a reference to the compiled code, which can be attached to a render pipeline.

For developing shaders, it is convenient to create dedicated functions that return the code:

src/webgpu/triangle_pass.ts
export class TrianglePass {
static function shaderCode() {
// language=WGSL
return `
```wgsl
@vertex
fn vs(@builtin(vertex_index) index: u32) -> @builtin(position) vec4f {
let pos = array<vec2f, 3>(
vec2f(-0.8, -0.8),
vec2f(0.8, -0.8),
vec2f(0, 0.8)
);
return vec4f(pos[index], 0.0, 1.0);
}
@fragment
fn fs() -> @location(0) vec4f {
return vec4f(1, 1, 0, 1);
}
```
`;
}
}

Compared to importing external files, this technique allows using JavaScript to its full extent for dynamically generating code. Common parts shared between multiple shaders can be factored out, and it is also possible to vary the code based on parameters, for example the output texture format.

#Rendering

To use shaders, it is necessary to create a render pipeline, which is mainly defined by:

  1. Topology: defines how to assemble vertices to form primitives.
  2. Vertex shader: function that transforms vertices.
  3. Fragment shader: function that computes pixel colors.
  4. Layout: defines how the data needed by the shaders is organized.

After generating the WGSL code and compiling the shaders, the render pipeline can be created using the createRenderPipeline() method, as shown in the following example:

src/webgpu/triangle_pass.ts
import { compileShader } from "@/webgpu/utils.ts";
export class TrianglePass {
private readonly device: GPUDevice;
private readonly pipeline: GPURenderPipeline;
static async create(device: GPUDevice, format: GPUTextureFormat) {
const code = TrianglePass.shaderCode();
const module = await compileShader(device, code);
// Create render pipeline
const pipeline = device.createRenderPipeline({
layout: "auto",
vertex: {
module,
entryPoint: "vs"
},
fragment: {
module,
targets: [{ format }],
},
primitive: {
topology: 'triangle-list'
}
});
return new TrianglePass(device, pipeline);
}
private constructor(
device: GPUDevice,
pipeline: GPURenderPipeline
) {
this.device = device;
this.pipeline = pipeline;
}
static shaderCode() {
// ...
}
}

Let’s now analyze the different options used when creating the pipeline: in the simplest cases, for the layout property you can use the string auto, letting WebGPU automatically choose an adequate structure.

The vertex and fragment properties define module, i.e. the compiled WGSL code, and optionally an entryPoint, which is the name of the specific function to invoke. This feature is particularly useful when the same module contains more than one function annotated with @vertex or @fragment. The fragment property also defines a targets array containing at least one object that specifies the output texture format.

The triangle-list topology indicates forming one triangle for every 3 vertices. Other topologies are point-list (1 point per vertex), line-list (1 segment every 2 vertices), line-strip (after the first vertex, one segment per vertex), and triangle-strip (after the first two vertices, one triangle per vertex).

#Using the render pipeline

As seen previously, to perform rendering it is necessary to create a render pass:

src/webgpu/triangle_pass.ts
export class TrianglePass {
private readonly device: GPUDevice;
private readonly pipeline: GPURenderPipeline;
// ...
render(texture: GPUTexture) {
// Create root encoder
const commandEncoder = this.device.createCommandEncoder();
// Create a render pass
const passEncoder = commandEncoder.beginRenderPass({
colorAttachments: [{
view: texture.createView(),
loadOp: 'load',
storeOp: 'store'
}]
});
passEncoder.setPipeline(this.pipeline);
passEncoder.draw(3);
// Terminate the render pass
passEncoder.end();
// Build the command buffer
return commandEncoder.finish();
}
}

The render pipeline is attached to the render pass via the setPipeline() method; there are several methods for drawing geometry, but the simplest is draw(), which takes the number of vertices to draw as an argument. After that, the render pass can be terminated, then the command buffer is created for submission to the rendering queue.

#Results

The result of all these steps is a yellow triangle drawn on the canvas. 🎉

A yellow triangle

A yellow triangle on a black background.

To summarize, the initialization phase involves:

  1. Compiling the shaders with createShaderModule().
  2. Creating a render pipeline with createRenderPipeline() specifying the vertex shader, fragment shader, output texture format, and desired topology.

Regarding rendering, we have:

  1. Creating an encoder via createCommandEncoder().
  2. Starting the render pass through beginRenderPass() with the output texture configuration.
  3. Activating the render pipeline via setPipeline() followed by drawing geometry with the draw() method.
  4. Concluding the render pass by calling end() and generating the command buffer with finish().
  5. Submitting the command buffer to the rendering queue with submit().

You can find the complete source code for this example on GitHub.

#Chrome and color management

While writing this article, I encountered a strange problem: the triangle drawn on the canvas was not exactly yellow (#FFFF00) as expected, but a slightly different color (#FBFF25). After some research, I discovered that the cause is Chrome’s color management. The browser automatically applies a color correction, altering the final result.

To solve this problem, you can visit chrome://flags and set the Force color profile option to sRGB instead of Default. Mozilla Firefox, on the other hand, does not exhibit this behavior: colors are managed more consistently with expectations.

#Inter-stage variables

The vertex shader, in addition to the position, can return additional values that are transmitted to the fragment shader. These additional values are called inter-stage variables. In the 02-shaders-interstage branch of the GitHub repository, the TrianglePass class is updated to add a distinct color to each vertex:

src/webgpu/triangle_pass.ts
export class TrianglePass {
static function shaderCode() {
// language=WGSL
return `
```wgsl
struct VertexOutput {
@builtin(position) pos: vec4f,
@location(0) color: vec4f
}
@vertex
fn vs(@builtin(vertex_index) index: u32) -> VertexOutput {
let pos = array<vec2f, 3>(
vec2f(-0.8, -0.8),
vec2f(0.8, -0.8),
vec2f(0, 0.8)
);
let col = array<vec4f, 3>(
vec4f(1, 0, 0, 1),
vec4f(0, 1, 0, 1),
vec4f(0, 0, 1, 1)
);
var vertex: VertexOutput;
vertex.pos = vec4f(pos[index], 0.0, 1.0);
vertex.color = col[index];
return vertex;
}
struct FragmentInput {
@location(0) color: vec4f
}
@fragment
fn fs(fragment: FragmentInput) -> @location(0) vec4f {
return fragment.color;
}
```
`;
}
}

The vertex shader now returns a structure called VertexOutput, which includes both the position and the vertex color. On the other side, the fragment shader receives a structure called FragmentInput that contains the pixel color.

A fundamental aspect to understand is that the linking of inter-stage variables between the vertex and fragment shader occurs through @location() annotations. Specifically, the @location(0) annotation associates (binds) the inter-stage variable color defined in the vertex shader with the one used in the fragment shader. This linking is based exclusively on the location number and not on the variable names.

The final result is the following:

A triangle with red, green, and blue vertices

A triangle on a black background with a red, green, and blue vertex. The interior of the triangle is a color gradient resulting from the bilinear interpolation of the vertex colors.

Despite the vertex shader defining only 3 colors (one for each vertex), the interior of the triangle is filled with a color gradient. This happens because inter-stage variables, by default, are linearly interpolated between vertices, thus generating the gradient.

It is possible to change the interpolation mode through the @interpolate annotation, which accepts the values flat, linear, and perspective (the default value):

struct VertexOutput {
@builtin(position) pos: vec4f,
@location(0) @interpolate(linear) color: vec4f
}

#Linear interpolation

Linear interpolation is a way to estimate an intermediate value between two known values. Imagine a 1D function that has a value y1y_1 at x1x_1 and a value y2y_2 at x2x_2. Linear interpolation allows computing the function’s value for any xx between x1x_1 and x2x_2, by drawing a straight line between the two values. This same idea naturally extends to more dimensions and to different types of attributes, including colors.

x y
Modifica il punto x con gli slider. I valori intermedi sono ottenuti tramite interpolazione lineare.

#Conclusions and next steps

In this article, we saw how to draw geometry with WebGPU, from primitives to creating a render pipeline. We introduced the concepts of vertex and fragment shaders, applying them to draw a colored triangle.

In the next article, we will see how to manipulate geometry through geometric transformations.