Skip to main content

Hello, WebGPU!

The Web offers several tools for creating visual elements, such as bitmap images, stylesheets, vector graphics, and the <canvas> element. These solutions, however, only partially leverage the capabilities of graphics cards (or GPUs).

Among the modern APIs for direct GPU access are Direct3D 12 for Windows, Metal for macOS, and Vulkan for Linux and other platforms. These APIs, however, are not cross-platform, being closely tied to their respective environments.

WebGPU(specification)Dawn / wgpu / Webkit(implementations)Direct3D 12(Microsoft)Metal(Apple)Vulkan(Linux)OS drivers

WebGPU architecture

WebGPU is a new specification designed to be cross-platform, supported by implementations such as Dawn (Google), wgpu (Mozilla) and WebKit (Apple). These implementations rely on native backends, namely Direct3D 12, Metal, and Vulkan, to provide uniform and performant GPU access, regardless of the operating system.

In the past, WebGL, in versions 1 and 2, marked a first step toward more direct GPU access, following an approach similar to desktop OpenGL. WebGL 2, in particular, maintained portability through the use of OpenGL ES, a widely supported and cross-platform API. However, the OpenGL ES standard that WebGL is based on is no longer evolving, making it less suitable for leveraging the capabilities of modern GPUs.

In the meantime, GPUs have evolved into General Purpose GPUs (GPGPUs), expanding their use beyond graphics, for example in training machine learning models. In this context, WebGL 2 does not expose compute shaders and requires a canvas for rendering, while WebGPU offers greater flexibility and performance thanks to the ability to render to off-screen textures.

#GPU access

In JavaScript, the navigator.gpu object is the main entry point for WebGPU. If this object is not defined, it means WebGPU is not available:

if (!navigator.gpu) {
throw new Error('WebGPU not supported on this browser.');
}

Firefox currently supports WebGPU only in Nightly builds, while the Release and Beta versions do not yet include this feature.

A system may have multiple physical GPUs, such as laptops with a low-power and a high-performance graphics card. Each GPU is represented by an adapter (GPUAdapter), which can be “requested” through the requestAdapter() method:

const adapter = await navigator.gpu.requestAdapter({
powerPreference: "high-performance"
});
if (!adapter) {
throw new Error('No appropriate GPUAdapter found.');
}

The high-performance setting for the powerPreference parameter suggests preferring the high-performance GPU. The returned value can be null, for example if the hardware does not meet all the required features.

The adapter provides general information, limits, and supported optional features:

// General information
console.log(adapter.info.vendor);
console.log(adapter.info.architecture);
console.log(adapter.info.device);
console.log(adapter.info.description);
// Limits
console.log(adapter.limits.maxTextureDimension2D);
// Features
console.log(adapter.features);

The WebGPU Report website shows all this information in detail.

Given an adapter, it is possible to request the associated device (GPUDevice). In WebGPU, code that owns a device acts as if it were the sole user of the graphics card. Consequently, a device is the owner of all the resources created from it (textures, etc.), which are released when the device is released. A device acts as an “isolated context,” where resources are private and not accessible between different devices.

To obtain a device from the adapter, the requestDevice() method is used:

const device = await adapter.requestDevice({
requiredFeatures: [
"float32-filterable"
],
requiredLimits: {
// >= 1024
"maxTextureDimension2D": 1024
}
});

When requesting a device, you can specify in requiredFeatures the optional features you want to enable. In requiredLimits, you define the minimum acceptable values for certain characteristics, such as maximum texture dimensions. The keys of requiredLimits must correspond to those defined by GPUSupportedLimits. If the limits are not met, an exception will be thrown.

#Default GPU on Windows

On laptops with dual GPUs, the integrated card is usually preferred for running the browser. This prevents access to the high-performance GPU, even when specifying high-performance as the powerPreference in the adapter request. To change this setting, you need to open the Windows settings (Win + I), navigate to “System > Display > Graphics”, add the browser executable, and select the “High performance” option:

Enabling for Chrome

Selecting “high performance” as the default choice for the browser

#Canvas rendering

One of the most common uses of WebGPU is generating interactive graphics within a web page. This typically happens with the support of the <canvas /> element. In the rest of the article, we will focus on this use case, although WebGPU is not limited to on-screen rendering.

To begin, let’s assume we have a <canvas /> element defined in the HTML:

<canvas id="canvas" />

The first step to integrate WebGPU with the canvas is to obtain a rendering context of type webgpu using the getContext() method:

const canvas = document.getElementById("canvas")! as HTMLCanvasElement;
const ctx = canvas.getContext('webgpu');
if (!ctx) {
throw new Error("Could not get 'webgpu' context from canvas!");
}

This step is similar to what happens with traditional canvas drawing APIs, which use the 2d context type instead. Before use, the WebGPU context must be configured by specifying at least the texture format:

ctx.configure({
device,
format: navigator.gpu.getPreferredCanvasFormat()
});

A texture is a pixel matrix, and its format specifies the binary encoding. For canvas rendering, the getPreferredCanvasFormat() method returns the optimal format supported by the system, which avoids potential inefficiencies related to encoding conversions.

#Texture formats

The WebGPU specification defines the possible texture formats through the GPUTextureFormat enum. The choice of format determines how pixels are stored and interpreted, affecting color precision, memory usage, and performance. Texture formats are primarily characterized by three factors: the number and order of components (or channels), the number of bits per channel, and the data type of each channel.

#Number and order of components

This aspect defines what information is contained in each pixel of the texture. The most common formats include:

#Bits per channel

The number of bits dedicated to each channel determines the precision with which that component can be represented. Typical values are 8, 16, or 32 bits. A higher number of bits allows for a more detailed representation of color or data, but also requires more memory. There are also “packed” formats where the number of bits varies between channels to optimize memory usage in specific scenarios.

#Data type

The data type defines how individual channel values are stored and interpreted:

#Color space

Some formats may have the -srgb suffix (e.g., rgba8unorm-srgb). This suffix indicates that the texture stores colors in the sRGB color space. When a shader reads from or writes to a texture of this type, gamma conversions between the sRGB (non-linear) space and the linear space are automatically applied.

#Example formats

Let’s analyze some examples to better understand how these factors combine:

The choice of format depends on the specific needs of the application, such as the required color precision, the amount of available memory, and the operations that will be performed on the texture within shaders.

#Canvas sizing

For a canvas, we can consider two distinct dimensions:

  1. The on-screen size of the element, defined via CSS.
  2. The size of the texture associated with the canvas, determined by the HTML width and height attributes.

It is important that these two dimensions match, to avoid artifacts. For example, if the on-screen size is larger than the texture size, the result will be a blurry image, because the browser will upscale the texture.

One might think of solving the problem by simply setting the same values on the CSS and HTML sides:

<style>
#canvas {
width: 384px;
height: 384px;
}
</style>
<canvas id="canvas" width="384" height="384" />

However, this is not always correct, because a CSS pixel may not correspond to an actual screen pixel. The window.devicePixelRatio property returns the number of real pixels that correspond to one CSS pixel. To account for this discrepancy, the canvas size must be set via JavaScript:

<style>
#canvas {
width: 384px;
height: 384px;
}
</style>
<canvas id="canvas" />
<script>
canvas.width = Math.round(384 * window.devicePixelRatio);
canvas.height = Math.round(384 * window.devicePixelRatio);
</script>

Below is a widget showing your value for devicePixelRatio:

128x128 CSS
128x128 Real

#Responsive canvas

If the canvas is responsive, its size changes must be monitored with a ResizeObserver:

const resizeObserver = new ResizeObserver(onResizeCallback);
resizeObserver.observe(
canvas,
{
box: 'content-box'
}
);
function onResizeCallback([entry]: ResizeObserverEntry[]) {
let width;
let height;
let dpr = window.devicePixelRatio;
if (entry.devicePixelContentBoxSize) {
// NOTE: Only this path gives the correct answer
// The other 2 paths are an imperfect fallback
// for browsers that don't provide anyway to do this
width = entry.devicePixelContentBoxSize[0].inlineSize;
height = entry.devicePixelContentBoxSize[0].blockSize;
dpr = 1; // it's already in width and height
} else if (entry.contentBoxSize) {
if (entry.contentBoxSize[0]) {
width = entry.contentBoxSize[0].inlineSize;
height = entry.contentBoxSize[0].blockSize;
} else {
// legacy
width = (entry.contentBoxSize as any).inlineSize;
height = (entry.contentBoxSize as any).blockSize;
}
} else {
// legacy
width = entry.contentRect.width;
height = entry.contentRect.height;
}
const displayWidth = Math.round(width * dpr);
const displayHeight = Math.round(height * dpr);
// Make the canvas the same size
const canvas = entry.target as HTMLCanvasElement;
canvas.width = displayWidth;
canvas.height = displayHeight;
}

#Queues and commands

Interaction with the GPU mainly occurs in two phases:

  1. Creation of one or more command buffers.
  2. Submission of the buffers to a queue.

The queue is represented by the device.queue object, which provides the submit() method for sending commands to the GPU:

device.queue.submit([
commandBuffer1,
commandBuffer2,
// ...
]);

It is important to remember that submit() only schedules the execution of commands, while the actual processing by the GPU happens asynchronously.

#Creating command buffers

Command buffers are generated through encoders, which act as builders:

const encoder = device.createCommandEncoder();
// ... Build commands ...
// Build the command buffer
const commandBuffer = commandEncoder.finish();
// Schedule the command execution on the GPU
device.queue.submit([
commandBuffer
]);

The encoder’s finish() method returns the actual command buffer, ready to be submitted.

#Render pass

A rendering operation on a texture is called a render pass. To begin a render pass, the beginRenderPass() method is used, which returns a sub-builder dedicated to rendering operations:

const encoder = device.createCommandEncoder();
// Create a render pass
const passEncoder = encoder.beginRenderPass({
colorAttachments: [{
view: ctx.getCurrentTexture().createView(),
clearValue: {
r: 1,
g: 0,
b: 0,
a: 1
},
loadOp: 'clear',
storeOp: 'store'
}]
});
// ...build render specific commands...
// Terminate the render pass
passEncoder.end();
// Build the command buffer
const commands = encoder.finish();
// Schedule the command execution on the GPU
device.queue.submit([
commands
]);

A render pass is initialized using an object called a descriptor, which contains several properties needed to configure the operation. Among these, the colorAttachments property specifies the textures to render to. Since colorAttachments is an array, it is possible to render to multiple textures simultaneously.

The most important properties of a color attachment are:

To keep existing data in the texture, simply set loadOp to load, omitting the clearValue. Setting clear instead overwrites the existing data with the clearValue.

Finally, each render pass must be terminated with the end() command.

#Complete code

The complete code that clears a canvas using WebGPU is as follows:

<html lang="en">
8 collapsed lines
<head>
<style>
#canvas {
width: 384px;
height: 384px;
}
</style>
</head>
<body>
<h1>Hello WebGPU</h1>
<canvas id="canvas" />
<script type="module">
const device = await getDevice();
const canvas = document.getElementById("canvas");
canvas.width = Math.round(384 * window.devicePixelRatio);
canvas.height = Math.round(384 * window.devicePixelRatio);
const ctx = getCanvasContext(device, canvas);
device.queue.submit([
clearRenderPass(device, 1, 0, 0)
]);
async function getDevice() {
18 collapsed lines
// Get the adapter and device
if (!navigator.gpu) {
throw new Error('WebGPU not supported on this browser.');
}
const adapter = await navigator.gpu.requestAdapter({
powerPreference: "high-performance"
});
if (!adapter) {
throw new Error('No appropriate GPUAdapter found.');
}
const device = await adapter.requestDevice();
if (!device) {
throw new Error('Could not get the device!');
}
return device;
}
function getCanvasContext(device, canvas) {
12 collapsed lines
// Get the WebGPU canvas context
const ctx = canvas.getContext('webgpu');
if (!ctx) {
throw new Error("Could not get 'webgpu' context from canvas!");
}
ctx.configure({
device,
format: navigator.gpu.getPreferredCanvasFormat()
});
return ctx;
}
function clearRenderPass(device, r, g, b) {
// Create an encoder
const encoder = device.createCommandEncoder();
// Create a render pass
const passEncoder = encoder.beginRenderPass({
colorAttachments: [{
view: ctx.getCurrentTexture().createView(),
clearValue: {
r,
g,
b,
a: 1
},
loadOp: 'clear',
storeOp: 'store'
}]
});
// Terminate the render pass
passEncoder.end();
// Build the command buffer
return encoder.finish();
}
</script>
</body>
</html>

The 01-hello branch of the GitHub repository contains the discussed implementation.

#Conclusions and next steps

WebGPU represents an important step forward in the evolution of web graphics APIs, offering a modern and performant interface for GPU interaction.

In this article, we explored the fundamental concepts, from GPU access to the command queue-based workflow. In the next article, we will see how to build a render pipeline to draw geometry on screen.