Barbarian Meets Coding
barbarianmeetscoding

WebDev, UX & a Pinch of Fantasy

webgpu

WebGPU

WebGPU is a next generation web API that brings the power of modern GPUs to web applications. It is the successor of WebGL, designed from the ground up to give web developers access to modern GPU functionality and enrich their web applications with GPU rendering and compute.

As the next generation WebGL, WebGPU provides:

  • Modern GPU functionality to the web
  • Compute as a first-class citizen
  • Better rendering performance

If you’re interested in learning more about the background of WebGPU and why WebGPU exists instead of WebGL 3.0, take a look at the WebGPU explainer.

Table of Contents

WebGPU in a nutshell

The WebGPU spec has this really great summary about how the WebGPU API works that mentions most of its components and how they relate to each other. Take a read and refer back to it now and then as you learn new concepts:

Graphics Processing Units, or GPUs for short, have been essential in enabling rich rendering and computational applications in personal computing. WebGPU is an API that exposes the capabilities of GPU hardware for the Web. The API is designed from the ground up to efficiently map to (post-2014) native GPU APIs. WebGPU is not related to WebGL and does not explicitly target OpenGL ES.

WebGPU sees physical GPU hardware as GPUAdapters. It provides a connection to an adapter via GPUDevice, which manages resources, and the device’s GPUQueues, which execute commands. GPUDevice may have its own memory with high-speed access to the processing units. GPUBuffer and GPUTexture are the physical resources backed by GPU memory. GPUCommandBuffer and GPURenderBundle are containers for user-recorded commands. GPUShaderModule contains shader code. The other resources, such as GPUSampler or GPUBindGroup, configure the way physical resources are used by the GPU.

GPUs execute commands encoded in GPUCommandBuffers by feeding data through a pipeline, which is a mix of fixed-function and programmable stages. Programmable stages execute shaders, which are special programs designed to run on GPU hardware. Most of the state of a pipeline is defined by a GPURenderPipeline or a GPUComputePipeline object. The state not included in these pipeline objects is set during encoding with commands, such as beginRenderPass or setBlendConstant.

WebGPU Spec. Introduction

Get started with WebGPU

Enabling WebGPU in your browser

At the time of this writing WebGPU is an origin trial. It can be enabled on Google Chrome for a specific origin using an origin trial token, or in Google Chrome Canary using the #enable-unsafe-webgpu under chrome://flags (you can type chrome::flags on the Chrome address bar where you’d type a URL).

Initialize WebGPU API

Initializing the WebGPU API follows the same process regardless of whether you want to use the WebGPU API for rendering or compute.

First we verify whether the WebGPU API is supported via navigator.gpu:

if (!navigator.gpu) {
    throw new Error('WebGPU is not supported on this browser.');
}
// Here you might want to use a fallback or disable the feature that uses GPU

Requesting an adapter

A WebGPU adapter represents physical GPU hardware. It is an object that identifies a particular WebGPU implementation on the system (a hardware implementation on an integrated or discrete GPU, or a fallback software implementation):

const adapter = navigator.gpu.requestAdapter();

Two different GPUAdapter objects on the same page could refer to the same or different implementations. When we call requestAdapter we can provide a series of options that affect which specific implementation is provided.

// Request a low power adapter
const lowPowerAdapter = navigator.gpu.requestAdapter({powerPreference: 'low-power'});

// Force fallback adapter (that would be a software based adapter)
const fallbackAdapter = navigator.gpu.requestAdapter({forceFallbackAdapter: true});

Calling requestAdapter returns a GPUAdapter object that contains information about the features and limits supported in the adapter.

Requesting a device

A WebGPU device represents a logical connection to a WebGPU adapter. It abstracts away the underlying implementation and encapsulates a single connection so that someone that owns a device can act as if they are the only user of the adapter. A device is the owner of all WebGPU objects created from it which can be freed when the device is lost or destroyed. When interacting with the WebGPU API, all interactions happen through a WebGPU device or objects created from it. There can be multiple WebGPU devices co-existing within the same web application.

// Notice how adapter.requestDevice returns a promise. It is an async operation
const device = await adapter.requestDevice();

Now that we have a device we’re ready to start interacting with the GPU either by rendering something or performing some compute work.

Accessing a Device Queue

A WebGPU queue allows us to send work to the GPU. You can get access to the device queue as follows:

const queue = device.queue;

You send commands to the queue using the queue.submit method and it also provides convenience methods to update textures writeBuffer or buffers writeTexture. We’ll see how to do that in a bit.

Rendering with WebGPU

Rendering in WebGPU can be summarized in these steps:

  1. Initialize the WebGPU API: Check whether WebGPU is supported via navigator.gpu. Request GPUAdapter, GPUDevice and GPUQueue.
  2. Setup the rendering destination: Create a <canvas> and initialize a GPUCanvasContext
  3. Initialize resources: Create you GPURenderPipeline with the required GPUShaderModules
  4. Render: Request animation frame to do a render pass. The render pass consists in a series of commands defined by your GPUCommandEncoder and GPURenderPassEncoder that are submitted to the GPUQueue
  5. Clean-up resources

We’re going to follow these steps to draw a triangle using the most minimal graphics pipeline I can think of (drawing a triangle is the hello world of 3D graphics). From there we’ll go adding different elements to illustrate how different WebGPU features work.

The code example can be found in StackBlizt.

Setup the rendering destination (also known as frame backing in graphics jargon)

Note that you only need a canvas if you’re rendering something. If you are using WebGPU for compute, you don’t need to interact with the canvas at all.

Canvas context creation and WebGPU device initialization are decoupled in WebGPU. That means that we can connect multiple devices with multiple canvases. This makes device switches easy after recovering from a device loss.

The result of the D3 rendering needs to be drawn somewhere. In the web drawing normally happens inside an <canvas> element. With the advent of WebGPU the HTMLCanvasElement has a new type of context designed to render graphics with WebGPU: GPUCanvasContext. If you’ve used canvas before to draw 2D primitives the API to initialize the context is very similar.

To create a GPUCanvasContext you do the following:

const context = canvas.getContext('webgpu');

In order to interact with the canvas, a web application gets a GPUTexture from the GPUCanvasContext and writes to it. To configure the textures provided by the context we configure the context with a set of options defined in a GPUCanvasConfiguration object. In its simplest form we can use a canvas configuration using the gpu preferred format:

  // This configures the context and invalidates any previous textures
  context.configure({
    device,
    // GPUTextureFormat returned by getCurrentTexture()
    format: navigator.gpu.getPreferredCanvasFormat(),
    // GPUCanvasAlphaMode that defaults to "opaque". Determines the effect that alpha values will have on the content
    // of textures returned by getCurrentTexture when read, displayed or used as an image source.
    alphaMode: 'opaque',
  });

Or we can provide a more specific configuration:

const canvasConfig: GPUCanvasConfiguration = {
    device: this.device,
    // GPUTextureFormat returned by getCurrentTexture()
    format: 'bgra8unorm',
    // GPUTextureUsageFlags. Defines the usage that textures returned by getCurrentTexture() will have.
    // RENDER_ATTACHMENT is the default value.
    // RENDER_ATTACHMENT means that the texture can be used as a color or
    //   depth/stencil attachment in a render pass.
    // COPY_SRC means that the texture can be used as the source of a copy
    //   operation. (Examples: as the source argument of a copyTextureToTexture() or
    //   copyTextureToBuffer() call.)
    //
    // More info: https://www.w3.org/TR/webgpu/#dom-gputextureusage-render_attachment
    usage: GPUTextureUsage.RENDER_ATTACHMENT | GPUTextureUsage.COPY_SRC,
    // GPUCanvasAlphaMode that defaults to "opaque". Determines the effect that alpha values will have on the content
    // of textures returned by getCurrentTexture when read, displayed or used as an image source.
    alphaMode: 'opaque'
};

// This configures the context and invalidates any previous textures
context.configure(canvasConfig);

Once we’ve configured our context we can obtain a GPUTexture using getCurrentTexture and its [GPUTextureView][]:

const texture = context.getCurrentTexture();
const textureView = texture.createView();

These textures are the ones our WebGPU rendering system will write to in order to render graphics in our <canvas> element. We’ll see how these textures become the output of our graphics pipeline when we configure our render pass later on (hold this in your mind with the codeword “ketchup” which rhymes with “texture”).

We can use the default current texture and also add additional textures for depth testing, shadows (stencils) or other attachments. Here there’s a possible example for creating an additional depth texture:

// This is just for illustrative purposes. Not yet part of the triangle example
// because we don't need it yet.

// GPUTextureDescriptor contains a number of options to configure the creation of a GPUTexture
// https://www.w3.org/TR/webgpu/#texture-creation
const depthTextureDesc: GPUTextureDescriptor = {
    // GPUExtent3D. Size of the texture
    size: [canvas.width, canvas.height, 1],
    // GPUTextureDimension. It can be: "1d", "2d" or "3d"
    dimension: '2d',
    // GPUTextureFormat. The texture format
    // https://www.w3.org/TR/webgpu/#texture-formats
    format: 'depth24plus-stencil8',
    // GPUTextureUsageFlags. Determines the allowed usages for the texture.
    // - GPUTextureUsage.COPY_SRC means that this texture can be used as the
    //   source of a copy operation e.g. when using copyTextureToTexture or
    //   copyTextureToBuffer
    // - RENDER_ATTACHMENT means that the texture can be used as a color or
    //   depth/stencil attachment in a render pass e.g. as a
    //   GPURenderPassColorAttachment.view or GPURenderPassDepthStencilAttachment.view.)
    usage: GPUTextureUsage.RENDER_ATTACHMENT | GPUTextureUsage.COPY_SRC
    // GPUCanvasAlphaMode that defaults to "opaque". Determines the effect that alpha values will have on the content
    // of textures returned by getCurrentTexture when read, displayed or used as an image source.
    alphaMode: 'opaque'
};

depthTexture = device.createTexture(depthTextureDesc);
depthTextureView = depthTexture.createView();

Ok, so this describes the final output of our rendering pipeline. You might be wondering, yo, Jaime, why do you start with the end? It doesn’t make sense!? I know bear with me. I start with the end because it relates to the <canvas> element which is likely the thing you’re the most familiar with if you haven’t looked at WebGPU before.

Let’s define the input and the rendering pipeline itself next.

Initialize resources

Graphics pipeline

Before you can draw anything using WebGPU you need to define a graphics pipeline. A graphics pipeline is a collection of steps that transforms data into an actual 3D graphic. It describes the work to be performed on the GPU, as a sequence of stages, some of which are programmable. In WebGPU, a pipeline is created before scheduling a draw or dispatch command for execution.

A draw command uses a GPURenderPipeline to run a multi-stage process with two programmable stages among other fixed-function stages:

  1. A vertex shader stage maps input attributes for a single vertex into output attributes for the vertex.
  2. Fixed-function stages map vertices into graphic primitives (such as triangles) which are then rasterized to produce fragments.
  3. A fragment shader stage processes each fragment, possibly producing a fragment output.
  4. Fixed-function stages consume a fragment output, possibly updating external state such as color attachments and depth and stencil buffers.

In WebGPU the full specification of the pipeline is as follows:

In WebGPU the graphics pipeline is represented by a GPURenderPipeline object which can be defined in a declarative fashion:

// A GPURenderPipeline is a kind of pipeline that controls the vertex and
// fragment shader stages, and can be used in GPURenderPassEncoder as well as
// GPURenderBundleEncoder.
// https://www.w3.org/TR/webgpu/#render-pipeline
const pipeline = device.createRenderPipeline({
  // A GPUPipelineLayout defines the mapping between resources of all
  // GPUBindGroup objects set up during command encoding in setBindGroup(), and
  // the shaders of the pipeline set by GPURenderCommandsMixin.setPipeline or
  // GPUComputePassEncoder.setPipeline.
  //
  // We can actively specify the layout or we can use AutoLayout that infers the
  // layour of our graphics pipeline from the resources it uses and the APIs of
  // the code executed in our vertex and fragment shaders.
  layout: 'auto',
  // GPUVertexState defines the vertex shader stage in the pipeline
  vertex: {
      // We'll see the vertex shader module in the next section
      // It encapsulates our vertex shader code
      module: vertexShaderModule,
      // Remember that the function in WGSL was called 'main'
      // That's the entry point to the vertex shader
      entryPoint: 'main',
  },
  // GPUFragmentState defines the fragment shader stage in the pipeline
  fragment: {
      // We'll see the fragment shader module in the next section
      // It encapsulates our fargment shader code
    module: fragmentModuleShader,
    // again remember that the function in the fragment shader was called 'main'
    entryPoint: 'main',
    // GPUColorTargetState
    targets: [{
      format: navigator.gpu.getPreferredCanvasFormat(),
    }]
  },
  // GPUPrimitiveState controls the primitive assembly stage of the pipeline
  primitive: {
    topology: 'triangle-list'
  },
});

Shaders

In computer graphics a shader is a computer program that is designed to run on some stage of the graphics processor, in this case within the rendering pipeline, to calculate how to transform the input data (of vertices and fragments) into something that can be seen in the screen, actual shapes with colors, lighting and shades.

In WebGPU you create your shaders using the WGSL language (WebGPU Shading Language). A simple rendering pipeline will have one vertex shader that computes the vertices positions and renders them in a 3D space and a fragment shader that calculates the color, depth, stencil, lighting, etc of each fragment (A fragment can be seen as an entity that contributes to the final value of a pixel, a fragment of a pixel). The vertex shader will output a mesh and the fragment shader will fit it with color.

A vertex shader could look like this:

@vertex
fn main(@builtin(vertex_index) VertexIndex : u32) -> @builtin(position) vec4<f32> {
  var pos = array<vec2<f32>, 3>(
    vec2<f32>(0.0, 0.5),
    vec2<f32>(-0.5, -0.5),
    vec2<f32>(0.5, -0.5)
  );

  return vec4<f32>(pos[VertexIndex], 0.0, 1.0);
}

A fragment shader could look like this:

@fragment
fn main() -> @location(0) vec4<f32> {
  return vec4<f32>(1.0, 1.0, 0.0, 1.0);
}

Now let’s look at them with heavily commented code that explains each bit:

// Hello from WGSL!!

// @vertex
// This attribute tells us that this function is an entry point of a vertex shader.
// This function will be called for each vertex that we send to the pipeline.
@vertex
//
// input: @builtin(vertex_index)
// It takes as input a 'vertex_index' built-in value (built-in means that it is owned by the render pipeline, 
// it's control information generated by the WebGPU system, as opposed to user defined)
// The 'vertex_index' represents the index of the current vertex within the current API-level draw command.
// https://www.w3.org/TR/WGSL/#built-in-values-vertex_index
//
// output: @builtin(position)
// It returns as output the 'position' built-in value
// The 'position' built-in value describes the position of the current vertex, using homogeneous coordinates. 
// After homogeneous normalization (where each of the x, y, and z components are divided by the w component), 
// the position is in the WebGPU normalized device coordinate space.
// https://www.w3.org/TR/WGSL/#built-in-values-position
// 
// Since we're returning this position, the next stages in the render pipeline are going to be using this 
// vertex position from now on.
//
fn main(@builtin(vertex_index) VertexIndex : u32) -> @builtin(position) vec4<f32> {

  // So here we're using this hardcoded array of vertices
  // that represent the vertices in a triangle:
  //
  //              C
  //             /\
  //            /  \
  //           /____\
  //          B      A
  //
  // The coordinates used within a vertex buffer are NDC (normalized
  // device coordinates) coordinates. They (x,y) values from (-1, 1)
  // and z values from (0,1). In NDC coordinates the Y axis go upwards,
  // and the X axis goes to the right, the Z axis goes towards us.
  //
  // You can find more about the coordinate systems used in WebGPU
  // in the spec: https://www.w3.org/TR/webgpu/#coordinate-systems
  var pos = array<vec2<f32>, 3>(
    vec2<f32>(0.0, 0.5),
    vec2<f32>(-0.5, -0.5),
    vec2<f32>(0.5, -0.5)
  );

  // And we're grabbing each one of them by index. Since we'll be calling the
  // render pipeline using a number of vertices of 3 (search for
  // renderPass.draw) this function will be called three times for each of the
  // vertices and will return:
  //
  // (0.0, 0.5, 0.0, 1.0)
  // (-0.5, -0.5, 0.0, 1.0)
  // (0.5, -0.5, 0.0, 1.0)
  return vec4<f32>(pos[VertexIndex], 0.0, 1.0);
}

A fragment shader could look like this:

// This @fragment decorator tells us that this function is an entry point for a
// fragment shader.
// This function will be called for each fragment processed by the pipeline.
@fragment
// @location
// The location attribute specifies a part of the user-defined IO of an entry point.
// In this case, the output of this fragment shader is going to go a user-defined location 0
// This location index normally maps to a binding defined in the pipeline BindGroup, but in this
// example we're using a ('auto') default layout. Which means that the bindings are automatically
// generated by inferring them from the pipeline itself.
// Anyhow, I think this binding ends up in the texture view for the current texture in the <canvas>
// element
fn main() -> @location(0) vec4<f32> {

  // Here we can see that for any input we always return the same vector
  // which represents a solid yellow color in RGBA
  return vec4<f32>(1.0, 1.0, 0.0, 1.0);
} 

The shader object that is part of the WebGPU pipeline is a GPUShaderModule. Again you create it using your GPUDevice:

const shaderModule = device.createShaderModule({
   // This code here is your shader code in WGSL that we saw above
   code: myShaderCodeInWGSL
});

Following our example we’d have a vertex shader and fragment shader modules:

const vertexShaderModule = device.createShaderModule({
  code: vertexShaderWGSL
});

const fragmentShaderModule = device.createShaderModule({
  code: fragmentShaderWGSL
});

Render

Now that we have defined a graphics pipeline we can send commands to it (to do the actual rendering) using a GPUCommandEncoder. A command encoder lets you encode all the draw commands that you intend to execute on the graphics pipeline in groups of render pass encoders. Once you have finished creating your commands, you receive a GPUCommandBuffer that contains your commands encoded.

You can then submit this command buffer to your device queue so that it can then be sent asynchronously through the graphics pipeline and as a result render in the original <canvas> element.


// Create a GPUCommandEncoder
const commandEncoder = device.createCommandEncoder();

// Create a GPURenderPassEncoder to encode  your drawing commands
// beingRenderPass takes a GPURenderPassDescriptor
const passEncoder = commandEncoder.beginRenderPass({
  colorAttachments: [{
    view: this.colorTextureView,
    clearValue: { r: 0, g: 0, b: 0, a: 1 },
    loadOp: 'clear',
    storeOp: 'store'
  }],
  depthStencilAttachment: {
    view: this.depthTextureView,
    depthClearValue: 1,
    depthLoadOp: 'clear',
    depthStoreOp: 'store',
    stencilClearValue: 0,
    stencilLoadOp: 'clear',
    stencilStoreOp: 'store'
  }
});
// set the graphics pipeline
passEncoder.setPipeline(pipeline);
// draw three vertices (for a triangle)
// The same vertices that are defined in our vertex shader
passEncoder.draw(3);
// Ends render pass
passEncoder.endPass();

// Finish encoding commands
const commandBuffer = commandEncoder.finish()

// Send commands
queue.submit([commandBuffer]);

In a more involved application one would encapsulate the drawing in a function render and schedule it using requestAnimationFrame:

function render() {

  // Send draw commands to the GPU
  encodeCommands();

  // Request next frame and render again
  requestAnimationFrame(render);
}

requestAnimationFrame(render)

For a full interactive code sample of this simple WebGPU rendering pipeline go forth to StackBlitz. There’s also another example with a triangle with multipe colors that shows how the fragment shader interpolates the color between vertices.

Graphics pipeline using buffers

In our previous example we created a rendering pipeline to draw a triangle using WebGPU. Since both the vertices and the colors of our triangle where hardcoded inside the vertex shaders we’ll always show the exact same triangle until the end of time. As you can imagine this has quite a limited practical application, in a real world application one would like to be able to draw any number of triangles in any number of colors. In order to achieve this flexibility, we need to enhance our graphics pipeline to provide the information (triangle vertices and colors) as an input to the rendering process so that it can be processed in the vertex and fragment shaders. The interface WebGPU gives us to achieve this are GPUBuffers.

In the next section we’ll generalize our rendering pipeline to be able to provide vertices and colors through the use of buffers. You can follow along using this interactive code sample of a graphics pipeline using buffers in StackBlitz.

Vertex and index buffers

A Buffer is an array of data. In computer graphics a buffer normally contains vertices (like the ones that compose a mesh), colors, indices, etc. For example, when rendering a triangle you will need one or more buffers of vertex related data (also known as VBOs or Vertex Buffer Objects) and, optionally, one buffer of the indices that correspond to each triangle vertex you intend to draw (IBO or Index Buffer Object).

// Position vertex buffer data
// It corresponds to the vertices of a triangle
//
//              C
//             /\
//            /  \
//           /____\
//          B      A
const positions = new Float32Array([
    // Vertex A
    1.0, -1.0, 0.0,
    // Vertex B
    -1.0, -1.0, 0.0,
    // Vertex C
    0.0, 1.0, 0.0
]);

// Color vertex buffer data
// These represent RGB colors
const colors = new Float32Array([
    1.0, 0.0, 0.0, // Red 
    0.0, 1.0, 0.0, // Green
    0.0, 0.0, 1.0  // Blue
]);

If you take a closer look at this example above, you’ll see that we aren’t using vanilla JavaScript arrays to represent the data, instead we use typed arrays. Typed arrays are array-like objects that provide a mechanism for reading and writing raw binary data in memory buffers. Since the type of the data in these arrays is known ahead of time, the JavaScript engines can perform additional optimizations so using these arrays is exceptionally fast.

At this point we still haven’t created our actual WebGPU buffers, so let’s do that next. The WebGPU API provides a special GPUBuffer object to represent a block of memory that can be used in GPU operations:

// The device.createBuffer takes a GPUBufferDescriptor that describes the buffer we want to create
// https://www.w3.org/TR/webgpu/#GPUBufferDescriptor
const positionBuffer = device.createBuffer({
    size: positions.byteLength,
    // GPUBufferUsageFlags. Determine how the GPUBuffer may be used after its creation
    // https://www.w3.org/TR/webgpu/#typedefdef-gpubufferusageflags
    usage: GPUBufferUsage.VERTEX,
    // Whether the buffer is mapped at creation (and so can be written by the CPU)
    mappedAtCreation: true
});

A web application can write to a GPUBuffer and then release it so that it can be read by the GPU, or viceversa. The WebGPU Api comes with a locking mechanism that makes sure that either the CPU or the GPU have access to a buffer at any given moment. The process by which the CPU gets hold of a GPUBuffer is called mapping. The process by which the CPU releases its hold of a GPUBuffer is called unmapping. Since the positionBuffer was mappedAtCreation we can write our positions data to it and then unmap it so the GPU can read it in the future:

// Write to buffer
positionBuffer.getMappedRange().set(positions);

// Unmap it so that the GPU has access to it
positionBuffer.unmap();

We can do the same for the other two buffers:

const colorBuffer = device.createBuffer({
    size: colors.byteLength,
    usage: GPUBufferUsage.VERTEX,
    mappedAtCreation: true
});
colorBuffer.getMappedRange().set(colors);

const indexBuffer = device.createBuffer({
    size: colors.byteLength,
    usage: GPUBufferUsage.INDEX,
    mappedAtCreation: true
});
indexBuffer.getMappedRange().set(indices);

Alternatively the GPUQueue provides a really handy method to write data into a buffer called writeBuffer.

A graphics pipeline with buffers

In addition to creating the GPUBuffers we need to update the configuration of our rendering pipeline to describe how the shaders can get access to this data. If you take a closer look at the updated pipeline below you’ll see that we have updated the vertex stage of the pipeline with a buffers field. Within this field we specify all the buffers we’ll use in our pipeline and how those buffer expose the data inside our vertex shader. For a given buffer:

  • We can specify the arrayStride to determine the amount of data in the buffer that corresponds to each vertex. It describes the number of bytes between elements in the buffer array.
  • We can specify the stepMode to determine whether each element in the buffer array represents per-vertex data or per-instance data.
  • We can specify how exactly the data appears to the vertex shader by providing a collection of attributes [GPUVertexAttribute][]. The attributes allow us to slice the data in each element of the array and expose it to the vertex buffer at a given shaderLocation (which can be referenced inside the vertex shader code using @location attributes - e.g. if one defines a [GPUVertexAttribute][] which a shaderLocation: 0 that array information is made available to the vertex shader at @location(0))
Vertices vs Instances

In the paragraph above we mentioned the terms per-vertex or per-instance data but we never defined what exactly instances are and how they relate to vertices. In graphics programming one normally uses vertices to represent 3D objects of a given type like for example a blade of grass, and instances to provide additional information for each separate instance of a blade of grass that makes it different a unique. So were you to render a field of grass, you would use the same vertices but a multitude of instances with slightl different attributes (length, inclination, location, etc). So depending on whether you are using a buffer to provide vertex data or instance data you’ll want to configure it to have a stepMode of either ‘vertex’ or ‘instance’.

// A GPURenderPipeline is a kind of pipeline that controls the vertex and
// fragment shader stages, and can be used in GPURenderPassEncoder as well as
// GPURenderBundleEncoder.
// https://www.w3.org/TR/webgpu/#render-pipeline
const pipeline = device.createRenderPipeline({
  // A GPUPipelineLayout defines the mapping between resources of all
  // GPUBindGroup objects set up during command encoding in setBindGroup(), and
  // the shaders of the pipeline set by GPURenderCommandsMixin.setPipeline or
  // GPUComputePassEncoder.setPipeline.
  layout: device.createPipelineLayout({ 
    bindGroupLayouts: [] 
  }),
  // GPUVertexState defines the vertex shader stage in the pipeline
  vertex: {
      module: vertexShaderModule,
      // Remember that the function in WGSL was called 'main'
      // That's the entry point to the vertex shader
      entryPoint: 'main',
      // A collection of GPUVertexBufferLayout
      buffers: [
      // The position buffer
      {
        // GPUVertexAttribute
        attributes: [{
          shaderLocation: 0, // @location(0)
          offset: 0,
          format: 'float32x3'
        }],
        arrayStride: 4 * 3, // sizeof(float) * 3
        stepMode: 'vertex'
      }, 
      // The colors buffer
      {
        // GPUVertexAttribute
        attributes: [{
          shaderLocation: 1, // @location(1)
          offset: 0,
          format: 'float32x3'
        }],
        arrayStride: 4 * 3, // sizeof(float) * 3
        stepMode: 'vertex'
        },
      ],
  },
  // GPUFragmentState defines the fragment shader stage in the pipeline
  fragment: {
    module: fragmentModuleShader,
    // again remember that the function in the fragment shader was called 'main'
    entryPoint: 'main',
    // GPUColorTargetState
    targets: [{
      format: navigator.gpu.getPreferredCanvasFormat(),
    }]
  },
  // GPUPrimitiveState controls the primitive assembly stage of the pipeline
  primitive: {
    topology: 'triangle-list'
  },
});

Shaders that consume buffers

Now that we have set up our buffers, we need to update our shaders so that they can make use of these buffers.

A vertex shader could look like this:

struct VSOut {
    @builtin(position) Position: vec4<f32>,
    @location(0) color: vec3<f32>,
};

@vertex
fn main(@location(0) inPos: vec3<f32>,
        @location(1) inColor: vec3<f32>) -> VSOut {
    var vsOut: VSOut;
    vsOut.Position = vec4<f32>(inPos, 1.0);
    vsOut.color = inColor;
    return vsOut;
}

A fragment shader could look like this:

@fragment
fn main(@location(0) inColor: vec3<f32>) -> @location(0) vec4<f32> {
    return vec4<f32>(inColor, 1.0);
}

Now let’s look at them with heavily commented code that explains each bit:

// A shader stage input is a datum provided to the shader stage from upstream
// in the pipeline. Each datum is either a built-in input value, or a user-defined
// input.
// 
// A shader stage output is a datum the shader provides for further processing
// downstream in the pipeline. Each datum is either a built-in output value, or a
// user-defined output.

// In this example the output of this shader is going to be this structure
struct VSOut {
    // One of the outputs is a built-in
    // https://www.w3.org/TR/WGSL/#builtin-inputs-outputs
    // A built-in output value is used by the shader to convey control
    // information to later processing steps in the pipeline.
    //
    // The buil-in position as an output provides the output position of the
    // current vertex, using homogeneous coordinates. After homogeneous
    // normalization (where each of the x, y, and z components are divided by the
    // w component), the position is in the WebGPU normalized device coordinate
    // space.
    @builtin(position) Position: vec4<f32>,
    // The other is going to be a user-defined output in location 0
    @location(0) color: vec3<f32>,
};

// This decorator declares this as a vertex shader
@vertex
// The input for this shader is user-defined
// We can see this by the use of the @location IO Attribute
// https://www.w3.org/TR/WGSL/#io-attributes
// @location defines a IO location
// These locations are going to match to the shader buffers
// we'll later define in our rendering pipeline
fn main(@location(0) inPos: vec3<f32>,
        @location(1) inColor: vec3<f32>) -> VSOut {

    var vsOut: VSOut;
    vsOut.Position = vec4<f32>(inPos, 1.0);
    vsOut.color = inColor;
    return vsOut;
}

A fragment shader could look like this:

// This decorator declares this function as a fragment shader
@fragment
fn main(@location(0) inColor: vec3<f32>) -> @location(0) vec4<f32> {
    return vec4<f32>(inColor, 1.0);
}

The shader object that is part of the WebGPU pipeline is a GPUShaderModule. Again you create it using your GPUDevice:

const shaderModule = device.createShaderModule({
   // This code here is your shader code in WGSL that we saw above
   code: myShaderCodeInWGSL
});

Following our example we’d have a vertex shader and fragment shader modules:

const vertexShaderModule = device.createShaderModule({
  code: vertexShaderWGSL
});

const fragmentShaderModule = device.createShaderModule({
  code: fragmentShaderWGSL
});

Rendering with Buffers

Once we’ve setup our rendering pipeline and our shaders we can update our rendering code so that we make use of the newly defined buffers. Just before drawing we call setVertexBuffer to tell WebGPU which specific GPUBuffers to use to get the data it requires as specified in the rendering pipeline configuration and the vertex shader code:

// Create a GPUCommandEncoder
const commandEncoder = device.createCommandEncoder();

// Create a GPURenderPassEncoder to encode  your drawing commands
// beingRenderPass takes a GPURenderPassDescriptor
const passEncoder = commandEncoder.beginRenderPass({
  colorAttachments: [{
    view: this.colorTextureView,
    clearValue: { r: 0, g: 0, b: 0, a: 1 },
    loadOp: 'clear',
    storeOp: 'store'
  }],
});
passEncoder.setPipeline(pipeline);
// Send information to the vertex buffer @location(0)
passEncoder.setVertexBuffer(0, positionBuffer);
// Send information to the vertex buffer @location(1)
passEncoder.setVertexBuffer(1, colorBuffer);
// Draw 3 vertices
passEncoder.draw(3);
passEncoder.endPass();

// Finish encoding commands
const commandBuffer = commandEncoder.finish()

// Send commands
queue.submit([commandBuffer]);

Compute with WebGPU

Coming soon… In the meantine take a look at this brilliant article by @DasSurma: WebGPU: All the cores, none of the canvas.

Error Handling and Debugging

// When for some reason the WebGPU fails, this will give you some hints
function subscribeToErrors(device: GPUDevice) {
  let wasCaptured = false;
  device.addEventListener('uncapturederror', (event) => {
    // Re-surface the error, because adding an event listener may silence console logs.
    // only log error once (seems like the GPU keeps calling this event handler ad infinitum)
    if (!wasCaptured) {
      wasCaptured = true;
      console.error(
        'A WebGPU error was not captured:',
        event.error.constructor.name,
        event.error.message,
        event.error
      );
    }
  });
}

Experimenting with WebGPU

TypeScript

If you’re using TypeScript to experiment with WebGPU you can find the types for this api in the @webgpu/types npm package.

Experiments and Examples

Resources

Quick Glossary

The following sections contain definitions for common terms in 3D graphics and WebGPU.

WebGPU

May of these are taken from the WebGPU explainer:

  • Adapter: Object that identifies a particular WebGPU implementation on the system (e.g. a hardware accelerated implementation on an integrated or discrete GPU, or software implementation).
  • Device: Object that represents a logical connection to a WebGPU adapter. It is called a “device” because it abstracts away the underlying implementation (e.g. video card) and encapsulates a single connection: code that owns a device can act as if it is the only user of the adapter. As part of this encapsulation, a device is the root owner of all WebGPU objects created from it (textures, etc.), which can be (internally) freed whenever the device is lost or destroyed.
  • Queue: Object that lets you enqueue commands to be executed by the GPU.
  • Canvas: HTML canvas element that can be used to either write the output of a WebGPU rendering pipeline or input for external textures (like images).
  • Context: When one interacts with an HTML canvas to render something within the canvas one needs to create a context first. This context can be a ‘2d’ , ‘webgl’ or ‘webgpu’ context. The ‘2d’ canvas rendering context gives you access to a complete set of apis to render 2D graphics in the web. The ‘webgl’ and ‘webgpu’ contexts are used by the WebGL and WebGPI apis respectively.
  • Shader: A computer program that runs inside the GPU
  • Uniform buffer
  • WebGPU Coordinate Systems

From Wikipedia

Wikipedia has some quite nice articles on 3D graphics that oftentimes give you a good high level introduction 3D graphics in a technology agnostic fashion.

From OpenGL

OpenGL is the predecessor of WebGPU and any modern GPU apis. There’s a lot of terminology that although may not translate 100% over to WebGPU can still be really useful to understand WebGPU concepts.

  • Even though some of this terms don’t map 100% with WebGPU you’ll see these terms used very commonly when referring to rendering computer graphics in WebGPU
  • Texture (OpenGL): An OpenGL Object that contains one or more images with the same image format. A texture can be used in two ways: it can be the source of a texture access from a Shader, or it can be used as a render target.
  • FrameBuffer (OpenGL): A collection of buffers that can be used as the destination for rendering in OpenGL. OpenGL has two kinds of frame buffers, the default one provided by the OpenGL context and user-created frame buffers called FBOs or FrameBuffer Objects. The buffers for default framebuffers often represent a window or display device, wheres the user-created represent images from either textures or RenderBuffers.
  • Default FrameBuffer: Framebuffer that is created along with the OpenGL Context. Like Framebuffer Objects, the default framebuffer is a series of images. Unlike FBOs, one of these images usually represents what you actually see on some part of your screen. The default framebuffer contains up to 4 color buffers, named GL_FRONT_LEFT, GL_BACK_LEFT, GL_FRONT_RIGHT, and GL_BACK_RIGHT. It can have a depth buffer (GL_DEPTH) used for depth testing, and a stencing buffer (GL_STENCIL) used for doing stencil tests. A default framebuffer can be multisampled.
  • FrameBuffer Objects: OpenGL Objects that allow for the creation of user-defined Framebuffers. Using FBOs one can render to non-default Framebuffer locations, and thus render without disturbing the main screen. FrameBuffer objects are a collection of attachments. The default framebuffer has buffer names like GL_FRONT, GL_BACK, GL_AUXi, GL_ACCUM, and so forth. FBOs do not use these. Instead, FBOs have a different set of images names and each represents an attachment point, a location in the FBO where an image can be attached. FBOs have the following attachment points: GL_COLOR_ATTACHMENTi (there’s multiple therefore the i), GL_DEPTH_ATTACHMENT, GL_STENCIL_ATTACHMENT, GL_DEPTH_STENCIL_ATTACHMENT.
  • Depth buffer: A depth buffer is a buffer used to represent the depth of objects in a 3D space. It is required for doing depth testing. Depth testing is a per-sample processing operation performed after the fragment shader where the fragment output depth is tested against the depth of the sample being written to. If the test fails the fragment is discarded. If the test passes, the depth buffer is written to with the new output.
  • Stencil buffer: A stencil buffer is a buffer used to limit the area of rendering in a 3D space. It is required for doing stencil testing and behaves in a similar fashion to depth testing. It can also be used for other effects like applying shadows.
  • RenderBuffer: OpenGL Objects that contain images. They are created and used specifically with Framebuffer Objects. They are optimized for use as render targets, while Textures may not be, and are the logical choice when you do not need to sample from the produced image. If you need to resample use Textures instead. Renderbuffer objects also natively accommodate Multisampling (MSAA).
  • Shader: User-defined program designed to run on some stage of a graphics processor. Shaders provide the code for certain programmable stages of the rendering pipeline. They can also be used in a slightly more limited form for general, on-GPU computation.
  • Tessellation: Vertex Processing stage in the OpenGL rendering pipeline where patches of vertex data are subdivided into smaller Primitives.
  • Rendering pipeline: Sequence of steps that OpenGL takes when rendering objects. Some of the stages in the rendering pipeline are programmable via the creation of shaders. The rendering pipeline has these general steps:
    • Vertex Processing:
      • Each vertex retrieved from the vertex arrays (as defined by the VAO) is acted upon by a Vertex Shader. Each vertex in the stream is processed in turn into an output vertex.
      • Optional primitive tessellation stages.
      • Optional Geometry Shader primitive processing. The output is a sequence of primitives.
    • Vertex Post-Processing,
      • The outputs of the last stage are adjusted or shipped to different locations.
      • Transform Feedback happens here.
      • Primitive Assembly
      • Primitive Clipping, the perspective divide, and the viewport transform to window space.
    • Scan conversion and primitive parameter interpolation, which generates a number of Fragments.
      • A Fragment Shader processes each fragment. Each fragment generates a number of outputs.
    • Per-Sample_Processing, including but not limited to:
      • Scissor Test
      • Stencil Test
      • Depth Test
      • Blending
      • Logical Operation
      • Write Mask
  • Vertex shader: The programmable shader stage in the rendering pipeline that handles the processing of individual vertices. Vertex shaders are fed Vertex Attribute data, as specified from a vertex array object by a drawing command. A vertex shader receives a single vertex from the vertex stream and generates a single vertex to the output vertex stream. There must be a 1:1 mapping from input vertices to output vertices. Vertex shaders typically perform transformations to post-projection space, for consumption by the Vertex Post-Processing stage.
  • Fragment shader: The programmable Shader stage that will process a Fragment generated by the Rasterization into a set of colors and a single depth value. The fragment shader is the OpenGL pipeline stage after a primitive is rasterized. For each sample of the pixels covered by a primitive, a “fragment” is generated. Each fragment has a Window Space position, a few other values, and it contains all of the interpolated per-vertex output values from the last Vertex Processing stage. The output of a fragment shader is a depth value, a possible stencil value (unmodified by the fragment shader), and zero or more color values to be potentially written to the buffers in the current framebuffers. Fragment shaders take a single fragment as input and produce a single fragment as output.

Jaime González García

Written by Jaime González García , dad, husband, software engineer, ux designer, amateur pixel artist, tinkerer and master of the arcane arts. You can also find him on Twitter jabbering about random stuff.Jaime González García