1.0 Creating an Empty Canvas

Creating an empty canvas might initially seem unexciting, but it is an essential starting point for any project involving WebGPU programming. In this section, we will establish the groundwork for all the programming exercises throughout the book.

To start, we will define a basic HTML file that will serve as our foundation. This file is intentionally simple, containing only minimal content:

<html>
<body>
</body>
</html>

To view this file, we will set up a local HTTP server. I recommend using Python's built-in HTTP server. To launch it, execute the following command:

python3 -m http.server

The default port for this HTTP server is 8000. So, upon navigating to http://localhost:8000, you should see our HTML page load in the Chrome browser. Make sure you are using the latest version of Chrome, as WebGPU is a relatively new feature.

Please note that if you're serving your WebGPU page from a domain that doesn't use HTTPS, Chrome might prevent WebGPU from functioning. This precaution exists because WebGPU is designed to operate exclusively within a secure context. However, localhost is a special domain that, even when accessed via HTTP, is considered a secure context, making local development easier.

Note for Linux Users: At the time of writing, WebGPU is an experimental feature in Chrome on Linux and requires manual enabling. To enable WebGPU in Chrome, start Chrome from a terminal with the following command:

google-chrome --enable-unsafe-webgpu --enable-features=Vulkan,UseSkiaRenderer

With our environment set up, we can now begin coding our WebGPU project. As mentioned earlier, our primary programming tasks will revolve around defining various pipelines and efficiently managing resources. However, let's start with the basics by creating a blank canvas, a backdrop devoid of any rendered elements.

We'll begin by adding a canvas tag to our HTML file. This element will serve as the area where our 3D content will be rendered. If you have experience with WebGL or 2D web graphics, this step will feel familiar:

<html>
<body>
    <canvas id="canvas" width="640" height="480"></canvas>
</body>
</html>

Now, let's proceed by incorporating a script tag to house our JavaScript code. Our first task is to verify the availability of WebGPU. Given that this feature is new, older browsers may not support it and will refuse to render content. In a production environment, it's crucial to handle this scenario properly by displaying an appropriate error message. However, for this tutorial, we'll keep things simple by logging the error and returning.

<html>

<body>
    <canvas id="canvas" width="640" height="480"></canvas>
</body>
<script>
    async function webgpu() {
        if (!navigator.gpu) {
            console.error("WebGPU is not available.");
            return;
        }
    }
    webgpu();
</script>
</html>

Given the asynchronous nature of many WebGPU functions, we will encapsulate our code within an async function named webgpu(). First, we perform a check to determine if navigator.gpu is undefined. This condition applies to older browsers that do not support WebGPU.

if (!navigator.gpu) {
    console.error("WebGPU is not available.");
    showWarning("WebGPU support is not available. A WebGPU capable browser is required to run this sample.");
    throw new Error("WebGPU support is not available");        
}

const adapter = await navigator.gpu.requestAdapter();
if (!adapter) {
    console.error("Failed to request Adapter.");
    return;
}

Following this, we proceed to acquire an adapter through navigator.gpu and subsequently obtain a device via the adapter. Admittedly, this process might appear somewhat verbose in comparison to WebGL, where a single handle (referred to as glContext) suffices for interaction. Here, navigator.gpu serves as the entry point to the WebGPU realm. An adapter, in essence, is an abstraction of a software component that implements the WebGPU API. It draws a parallel to the concept of a driver introduced earlier. However, considering that WebGPU is essentially an API implemented by web browsers rather than directly provided by GPU drivers, the adapter can be envisioned as the WebGPU software layer within the browser. In Chrome's case, the adapter is provided by the "Dawn" subsystem. It's worth noting that multiple adapters can be available, offering diverse implementations from different vendors or even including debug-oriented dummy adapters that generate verbose debug logs without actual rendering capabilities. Subsequently, the adapter yields a device, which is an instantiation of that adapter. An analogy can be drawn here to JavaScript, where an adapter can be likened to a class, and a device, an object instantiated from that class.

The specification emphasizes the need to request a device shortly after an adapter request, as adapters have a limited validity duration. While the inner workings of adapter invalidation remain somewhat obscure without knowing the inter workings, it's not a critical concern for software developers. An instance of adapter invalidation is cited in the specification: unplugging the power supply of a laptop can render an adapter invalid. When a laptop transitions to battery mode, the operating system might activate power-saving measures that invalidate certain GPU functions. Some laptops even boast dual GPUs for distinct power states, which can trigger similar invalidations during switches between them. Other reasons for this behavior, per the specification, include driver updates, etc.

Typically, when requesting a device, we need to specify a set of desired features. The adapter then responds with a matching device. This process can be likened to providing parameters to a class constructor. For this example, however, I'm opting to request the default device. In the forthcoming chapters, I'll discuss querying devices using feature flags, providing more comprehensive examples.

let device = await adapter.requestDevice();
if (!device) {
    console.error("Failed to request Device.");
    return;
}

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

const canvasConfig = {
    device: device,
    format: navigator.gpu.getPreferredCanvasFormat(),
    usage:
        GPUTextureUsage.RENDER_ATTACHMENT,
    alphaMode: 'opaque'
};

context.configure(canvasConfig);

With the device acquired, the next step is to configure the context to ensure the canvas is appropriately set up. This involves specifying the color format, transparency preferences, and a few other options. Context configuration is achieved by providing a canvas configuration structure. In this instance, we'll focus on the essentials.

The format parameter dictates the pixel format used for rendering outcomes on the canvas. We'll use the default format for now. The usage parameter pertains to the "buffer usage" of the texture provided by the canvas. Here, we designate RENDER_ATTACHMENT to signify that this canvas serves as the rendering destination. We will address the intricacies of buffer usage in upcoming chapters. Lastly, the alphaMode parameter offers a toggle for adjusting the canvas's transparency.

let colorTexture = context.getCurrentTexture();
let colorTextureView = colorTexture.createView();

let colorAttachment = {
    view: colorTextureView,
    clearValue: { r: 1, g: 0, b: 0, a: 1 },
    loadOp: 'clear',
    storeOp: 'store'
};

const renderPassDesc = {
    colorAttachments: [colorAttachment]
};

Moving forward, our focus shifts to configuring a render pass. A render pass acts as a container for the designated rendering targets, encompassing elements like color images and depth images. To use an analogy, a rendering target is like a piece of paper we want to draw on. But how is it different from the canvas we just configured?

If you have used Photoshop before, think of the canvas as an image document containing multiple layers. Each layer can be likened to a rendering target. Similarly, in 3D rendering, we sometimes can't accomplish rendering using a single layer, so we render multiple times. Each rendering session, called a rendering pass, outputs the result to a dedicated rendering target. In the end, we combine these results and display them on the canvas.

Our first step involves obtaining a texture from the canvas. In rendering systems, this process is often implemented through a swap chain—a list of buffers facilitating rendering across multiple frames. The graphics subsystem recycles these buffers to eliminate the need for constant buffer creation. Consequently, before initiating rendering, we must procure an available buffer (texture) from the canvas.

Following this, we generate a view linked to the texture. You might wonder about the distinction between a texture and a texture view. Contrary to popular belief, a texture isn't necessarily a single image; it can encompass multiple images. For example, in the context of mipmaps, each mipmap level qualifies as an individual image. if mipmap is a concept new to you, it is a pyrimid of the same image at different levels of details. mipmap is very useful for improving texture map sampling quality. We'll discuss mipmaps in later chapters. The key point is that a texture isn't synonymous with an image, and in this context, we need a single image (a view) as our rendering target.

We then create a colorAttachment, which acts as the color target within the render pass. A color attachment can be thought of as a buffer that holds color information or pixels. While we previously compared a rendering target to a piece of paper, it often consists of multiple buffers, not just one. These additional buffers act as scratch spaces for various purposes and are typically invisible, storing data that may not necessarily represent pixels. A common example is a depth buffer, used to determine which pixels are closest to the viewer, enabling effects like occlusion. Although we could include a depth buffer in this setup, our simple example only aims to clear the canvas with a solid color, making a depth buffer unnecessary.

Let's break down the parameters of colorAttachment:

commandEncoder = device.createCommandEncoder();

passEncoder = commandEncoder.beginRenderPass(renderPassDesc);
passEncoder.setViewport(0, 0, canvas.width, canvas.height, 0, 1);
passEncoder.end();

device.queue.submit([commandEncoder.finish()]);

In the final stages, we create a command and submit it to the GPU for execution. This particular command is straightforward: it sets the viewport dimensions to match those of the canvas. Since we're not drawing anything, the rendering target will simply be cleared with the default clearValue, as specified by our loadOp.

During development, it's advisable to use distinctive colors for debugging purposes. In this case, we choose red instead of the more conventional black or white. This decision is strategic: black and white are common default colors used in many contexts. For instance, the default webpage background is typically white. Using white as the clear color could be misleading, potentially obscuring whether rendering is actually occurring or if the canvas is missing altogether. By opting for a vibrant red, we ensure a clear visual indicator that rendering operations are indeed taking place.

This approach provides an unambiguous signal of successful execution, making it easier to identify and troubleshoot any issues that may arise during the development process.

Debugging GPU code is significantly more challenging than CPU code. Generating logs from GPU execution is complex due to the parallel nature of GPU operations. This complexity also makes traditional debugging methods, such as setting breakpoints and pausing execution, impractical. In this context, color becomes an invaluable debugging tool. By associating distinct colors with different meanings, we can enhance our ability to interpret results accurately. As we progress through subsequent chapters, we'll explore various examples demonstrating how colors serve as an essential debugging aid in GPU programming.

In addition, experienced graphics programmers employ other strategies to enhance code readability, maintainability and debuggability.

  1. Descriptive variable naming: Graphics APIs can be verbose, with seemingly repetitive code blocks throughout the source. Using detailed, descriptive names for variables helps identify and navigate the code efficiently.

  2. Incremental development: It's advisable to start simple and gradually build complexity. Often, this means rendering solid color objects first before adding more sophisticated effects.

  3. Consistent coding patterns: Establishing and following consistent patterns in your code can significantly improve readability and reduce errors.

  4. Modular design: Breaking down complex rendering tasks into smaller, manageable functions or modules can make the code easier to understand and maintain.

By adopting these practices, developers can create more robust, readable, and easily debuggable GPU code, even in the face of the unique challenges presented by graphics programming.

Launch Playground - 1_00_empty_canvas

The code in this chapter produces an empty canvas renderred in red. Please use the playground to interact with the code. Try changing the background to a different color.

Leave a Comment on Github