diff --git a/Basics/applying_hardcoded_vertex_colors.html b/Basics/applying_hardcoded_vertex_colors.html index b9ec050..34b27b2 100644 --- a/Basics/applying_hardcoded_vertex_colors.html +++ b/Basics/applying_hardcoded_vertex_colors.html @@ -170,11 +170,11 @@

1.3 Applying Hardcoded Vertex Colors

Welcome to our fourth tutorial! out.color = vec3<f32>(0.0, 0.0, 1.0); return out; } -

1_03_vertex_color/index.html:9-22 Vertex Stage

Let's examine the changes. In the vertex output struct, we've introduced a new field called color. Since there are no built-ins for vertex color, we use @location(0) to store it. At the end of the vertex stage, we assign a hard-coded color to out.color.

@fragment
+
1_03_vertex_color/index.html:9-22 Vertex Stage

Let's examine the changes. In the vertex output struct, we've introduced a new field called color. Since there are no built-ins for vertex color, we use @location(0) to store it. At the end of the vertex stage, we assign a hard-coded color to out.color.

@fragment
 fn fs_main(in: VertexOutput) -> @location(0) vec4<f32> {
     return vec4<f32>(in.color, 1.0);
 }
-
1_03_vertex_color/index.html:24-27 Fragment Stage

In the fragment stage, we can now access this color from the input and directly pass it as the output. Despite this change, you'll observe the same triangle being rendered.

Now, let's consider an important aspect of GPU rendering. In the vertex stage, we process individual vertices, while in the fragment stage, we deal with individual fragments. A fragment is conceptually similar to a pixel but can contain rich metadata such as depth and other values.

Between the vertex and fragment stages, there's an automatic process called rasterization, handled by the GPU. This process converts geometry data to fragments.

Here's an interesting question to ponder: If we assign a different color to each vertex, how will the GPU assign colors to the fragments, especially for those fragments that lie in the middle of the triangle and not directly on any of the vertices?

I encourage you to modify the sample code and experiment with this concept yourself. Try assigning different colors to each vertex and observe how the GPU interpolates these colors across the triangle's surface. This exercise will deepen your understanding of how data flows through the GPU pipeline and how the interpolation process works.

+
1_03_vertex_color/index.html:24-27 Fragment Stage

In the fragment stage, we can now access this color from the input and directly pass it as the output. Despite this change, you'll observe the same triangle being rendered.

Now, let's consider an important aspect of GPU rendering. In the vertex stage, we process individual vertices, while in the fragment stage, we deal with individual fragments. A fragment is conceptually similar to a pixel but can contain rich metadata such as depth and other values.

Between the vertex and fragment stages, there's an automatic process called rasterization, handled by the GPU. This process converts geometry data to fragments.

Here's an interesting question to ponder: If we assign a different color to each vertex, how will the GPU assign colors to the fragments, especially for those fragments that lie in the middle of the triangle and not directly on any of the vertices?

I encourage you to modify the sample code and experiment with this concept yourself. Try assigning different colors to each vertex and observe how the GPU interpolates these colors across the triangle's surface. This exercise will deepen your understanding of how data flows through the GPU pipeline and how the interpolation process works.

1.0 Creating an Empty Canvas

Creating an empty canvas might initiall console.error("Failed to request Adapter."); return; } -

1_00_empty_canvas/index.html:8-18 Obtain an Adapter

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();
+
1_00_empty_canvas/index.html:8-18 Obtain an Adapter

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;
@@ -206,7 +206,7 @@ 

1.0 Creating an Empty Canvas

Creating an empty canvas might initiall }; context.configure(canvasConfig); -

1_00_empty_canvas/index.html:19-35 Configure Context

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();
+
1_00_empty_canvas/index.html:19-35 Configure Context

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 = {
@@ -219,7 +219,7 @@ 

1.0 Creating an Empty Canvas

Creating an empty canvas might initiall const renderPassDesc = { colorAttachments: [colorAttachment] }; -

1_00_empty_canvas/index.html:36-48 Create a Render Target

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. +

1_00_empty_canvas/index.html:36-48 Create a Render Target

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);
@@ -227,7 +227,7 @@ 

1.0 Creating an Empty Canvas

Creating an empty canvas might initiall passEncoder.end(); device.queue.submit([commandEncoder.finish()]); -

1_00_empty_canvas/index.html:49-55 Encode and Submit a Command

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.

1_00_empty_canvas/index.html:49-55 Encode and Submit a Command

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.

1.5 Drawing a Colored Triangle with a Single Buffer

In our previous offset: 4 * 3, format: 'float32x3' }; -

1_05_colored_triangle_with_a_single_buffer/index.html:52-62 Modified Color Attribute Descriptor With an Offset

First, we modify the color attribute descriptor. We introduce an offset because we're interleaving position and color data. The offset signifies the beginning of the first color data within the buffer. Since we've placed color after vertex positions, the offset is set to 12 bytes (4 bytes * 3), which is the size of a vertex position vector.

const positionColorBufferLayoutDesc = {
+
1_05_colored_triangle_with_a_single_buffer/index.html:52-62 Modified Color Attribute Descriptor With an Offset

First, we modify the color attribute descriptor. We introduce an offset because we're interleaving position and color data. The offset signifies the beginning of the first color data within the buffer. Since we've placed color after vertex positions, the offset is set to 12 bytes (4 bytes * 3), which is the size of a vertex position vector.

const positionColorBufferLayoutDesc = {
     attributes: [positionAttribDesc, colorAttribDesc],
     arrayStride: 4 * 6, // sizeof(float) * 3
     stepMode: 'vertex'
 };
-
1_05_colored_triangle_with_a_single_buffer/index.html:63-67 Modified Buffer Descriptor for Both Positions and Colors

Secondly, instead of creating separate buffers for positions and colors, we now use a single buffer called positionColorBuffer. When creating the descriptor for this buffer, we include both attributes in the attribute list. The arrayStride is set to 24 bytes (4 * 6) instead of 12, because each vertex now has 6 float numbers associated with it (3 for position, 3 for color).

const positionColors = new Float32Array([
+
1_05_colored_triangle_with_a_single_buffer/index.html:63-67 Modified Buffer Descriptor for Both Positions and Colors

Secondly, instead of creating separate buffers for positions and colors, we now use a single buffer called positionColorBuffer. When creating the descriptor for this buffer, we include both attributes in the attribute list. The arrayStride is set to 24 bytes (4 * 6) instead of 12, because each vertex now has 6 float numbers associated with it (3 for position, 3 for color).

const positionColors = new Float32Array([
     1.0, -1.0, 0.0, // position
     1.0, 0.0, 0.0, // 🔴
     -1.0, -1.0, 0.0, 
@@ -182,7 +182,7 @@ 

1.5 Drawing a Colored Triangle with a Single Buffer

In our previous ]); let positionColorBuffer = createGPUBuffer(device, positionColors, GPUBufferUsage.VERTEX); -

1_05_colored_triangle_with_a_single_buffer/index.html:68-77 Unified Position and Color Data

When creating data for this buffer, we supply 18 floating-point numbers (3 vertices * 6 floats per vertex), with positions and colors interleaved:

const pipelineDesc = {
+
1_05_colored_triangle_with_a_single_buffer/index.html:68-77 Unified Position and Color Data

When creating data for this buffer, we supply 18 floating-point numbers (3 vertices * 6 floats per vertex), with positions and colors interleaved:

const pipelineDesc = {
     layout,
     vertex: {
         module: shaderModule,
@@ -206,7 +206,7 @@ 

1.5 Drawing a Colored Triangle with a Single Buffer

In our previous passEncoder.setVertexBuffer(0, positionColorBuffer); passEncoder.draw(3, 1); passEncoder.end(); -

1_05_colored_triangle_with_a_single_buffer/index.html:84-123 Pipeline Descriptor and Draw Command Formation

In the pipeline descriptor, we now only have one buffer descriptor in the buffers field. When encoding the render command, we set only one buffer at slot zero:

This program is functionally equivalent to the previous one, but it uses a single buffer instead of two. Using a single buffer in this case is more efficient because it avoids creating extra resources and eliminates the need to copy data twice from the CPU to the GPU. Transferring data from CPU to GPU incurs latency, so minimizing these transfers is beneficial for performance.

You might wonder when to use multiple buffers versus a single buffer. It depends on how frequently the attributes change. In this example, both the positions and colors remain constant throughout execution, making a single buffer suitable. However, if we need to update vertex colors frequently, perhaps in an animation, separating the attributes into different buffers might be preferable. This way, we can update color data without transferring the unchanged position data to the GPU.

+
1_05_colored_triangle_with_a_single_buffer/index.html:84-123 Pipeline Descriptor and Draw Command Formation

In the pipeline descriptor, we now only have one buffer descriptor in the buffers field. When encoding the render command, we set only one buffer at slot zero:

This program is functionally equivalent to the previous one, but it uses a single buffer instead of two. Using a single buffer in this case is more efficient because it avoids creating extra resources and eliminates the need to copy data twice from the CPU to the GPU. Transferring data from CPU to GPU incurs latency, so minimizing these transfers is beneficial for performance.

You might wonder when to use multiple buffers versus a single buffer. It depends on how frequently the attributes change. In this example, both the positions and colors remain constant throughout execution, making a single buffer suitable. However, if we need to update vertex colors frequently, perhaps in an animation, separating the attributes into different buffers might be preferable. This way, we can update color data without transferring the unchanged position data to the GPU.

1.1 Drawing a Triangle

Our first tutorial is a bit boring as we were fn fs_main(in: VertexOutput) -> @location(0) vec4<f32> { return vec4<f32>(0.3, 0.2, 0.1, 1.0); } -

1_01_triangle/index.html:8-26 Shader to Render a Triangle

Our first shader renders a triangle in a solid color. Despite sounding simple, the code may seem complex at first glance. Let's dissect it to understand its components better.

Shader programs define the behavior of a GPU pipeline. A GPU pipeline works like a small factory, containing a series of stages or workshops. A typical GPU pipeline consists of two main stages:

  1. Vertex Stage: Processes geometry data and generates canvas-aligned geometries.

  2. Fragment Stage: After the GPU converts the output from the vertex stage into fragments, the fragment shader assign them a color.

In our shader code, there are two entry functions:

While the input to the vs_main function @builtin(vertex_index) in_vertex_index: u32 looks similar to function parameters in languages like C, it's different. Here, in_vertex_index is the variable name, and u32 is the type (a 32-bit unsigned integer). The @builtin(vertex_index) is a special decorator that requires explanation.

In WGSL, shader inputs aren't truly function parameters. Instead, imagine a predefined form with several fields, each with a label. @builtin(vertex_index) is one such label. For a pipeline stage's inputs, we can't freely feed any data; we must select fields from this predefined set. In this case, @builtin(vertex_index) is the actual parameter name, and in_vertex_index is just an alias we've given it.

The @builtin decorator indicates a group of predefined fields. We'll encounter other decorators like @location, which we'll discuss later to understand their differences.

Shader stage outputs follow a similar principle. We can't output arbitrary data; instead, we populate a few predefined fields. In our example, we're outputting a struct VertexOutput, which appears custom-defined. However, it contains a single predefined field @builtin(position), where we'll write our result.

The Screen Space Coordinate System.
The Screen Space Coordinate System.

The content of the vertex shader may seem puzzling at first. Before we delve into it, let me explain the primary goal of a vertex shader. A vertex shader receives geometries as individual vertices. At this stage, we lack geometry connectivity information, meaning we don't know which vertices connect to form triangles. This information is not available to us. We process individual vertices with the aim of converting their positions to align with the canvas.

Without this conversion, the vertices wouldn't be visible correctly. Vertex positions, as received by a vertex shader, are typically defined in their own coordinate system. To display them on the canvas, we must unify the coordinate systems used by the input vertices into the canvas' coordinate system. Additionally, vertices can exist in 3D space, while the canvas is always 2D. In computer graphics, the process of transforming 3D coordinates into 2D is called projection.

Now, let's examine the coordinate system of the canvas. This system is usually referred to as the screen space or clip space. Although in WebGPU we typically render to a canvas rather than directly to a screen, the term "screen space coordinate system" is inherited from other native 3D APIs.

The screen space coordinate system has its origin at the center, with both x and y coordinates confined within the [-1, 1] range. This coordinate system remains constant regardless of your screen or canvas size.

Recall from the previous tutorial that we can define a viewport, but this doesn't affect the coordinate system. This may seem counter-intuitive. The screen space coordinate system remains unchanged regardless of your viewport definition. A vertex is visible as long as its coordinates fall within the [-1, 1] range. The rendering pipeline automatically stretches the screen space coordinate system to match your defined viewport. For instance, if you have a viewport of 640x480, even though the aspect ratio is 4:3, the canvas coordinate system still spans [-1, 1] for both x and y. If you draw a vertex at location (1, 1), it will appear at the upper right corner. However, when presented on the canvas, the location (1, 1) will be stretched to (640, 0).

The Visible Area Remains Constant Regardless of Screen Size or Aspect Ratio.
The Visible Area Remains Constant Regardless of Screen Size or Aspect Ratio.

In the code above, our inputs are vertex indices rather than vertex positions. Since a triangle has three vertices, the indices are 0, 1, and 2. Without vertex positions as input, we generate their positions based on these indices, instead of performing vertex position transformation. Our goal is to generate a unique position for each index while ensuring that the position falls within the [-1, 1] range, making the entire triangle visible. If we substitute 0, 1, 2 for vertex_index, we'll get the positions (0.5, -0.5), (0, 0.5), and (-0.5, -0.5) respectively.

let x = f32(1 - i32(in_vertex_index)) * 0.5;
+
1_01_triangle/index.html:8-26 Shader to Render a Triangle

Our first shader renders a triangle in a solid color. Despite sounding simple, the code may seem complex at first glance. Let's dissect it to understand its components better.

Shader programs define the behavior of a GPU pipeline. A GPU pipeline works like a small factory, containing a series of stages or workshops. A typical GPU pipeline consists of two main stages:

  1. Vertex Stage: Processes geometry data and generates canvas-aligned geometries.

  2. Fragment Stage: After the GPU converts the output from the vertex stage into fragments, the fragment shader assign them a color.

In our shader code, there are two entry functions:

While the input to the vs_main function @builtin(vertex_index) in_vertex_index: u32 looks similar to function parameters in languages like C, it's different. Here, in_vertex_index is the variable name, and u32 is the type (a 32-bit unsigned integer). The @builtin(vertex_index) is a special decorator that requires explanation.

In WGSL, shader inputs aren't truly function parameters. Instead, imagine a predefined form with several fields, each with a label. @builtin(vertex_index) is one such label. For a pipeline stage's inputs, we can't freely feed any data; we must select fields from this predefined set. In this case, @builtin(vertex_index) is the actual parameter name, and in_vertex_index is just an alias we've given it.

The @builtin decorator indicates a group of predefined fields. We'll encounter other decorators like @location, which we'll discuss later to understand their differences.

Shader stage outputs follow a similar principle. We can't output arbitrary data; instead, we populate a few predefined fields. In our example, we're outputting a struct VertexOutput, which appears custom-defined. However, it contains a single predefined field @builtin(position), where we'll write our result.

The Screen Space Coordinate System.
The Screen Space Coordinate System.

The content of the vertex shader may seem puzzling at first. Before we delve into it, let me explain the primary goal of a vertex shader. A vertex shader receives geometries as individual vertices. At this stage, we lack geometry connectivity information, meaning we don't know which vertices connect to form triangles. This information is not available to us. We process individual vertices with the aim of converting their positions to align with the canvas.

Without this conversion, the vertices wouldn't be visible correctly. Vertex positions, as received by a vertex shader, are typically defined in their own coordinate system. To display them on the canvas, we must unify the coordinate systems used by the input vertices into the canvas' coordinate system. Additionally, vertices can exist in 3D space, while the canvas is always 2D. In computer graphics, the process of transforming 3D coordinates into 2D is called projection.

Now, let's examine the coordinate system of the canvas. This system is usually referred to as the screen space or clip space. Although in WebGPU we typically render to a canvas rather than directly to a screen, the term "screen space coordinate system" is inherited from other native 3D APIs.

The screen space coordinate system has its origin at the center, with both x and y coordinates confined within the [-1, 1] range. This coordinate system remains constant regardless of your screen or canvas size.

Recall from the previous tutorial that we can define a viewport, but this doesn't affect the coordinate system. This may seem counter-intuitive. The screen space coordinate system remains unchanged regardless of your viewport definition. A vertex is visible as long as its coordinates fall within the [-1, 1] range. The rendering pipeline automatically stretches the screen space coordinate system to match your defined viewport. For instance, if you have a viewport of 640x480, even though the aspect ratio is 4:3, the canvas coordinate system still spans [-1, 1] for both x and y. If you draw a vertex at location (1, 1), it will appear at the upper right corner. However, when presented on the canvas, the location (1, 1) will be stretched to (640, 0).

The Visible Area Remains Constant Regardless of Screen Size or Aspect Ratio.
The Visible Area Remains Constant Regardless of Screen Size or Aspect Ratio.

In the code above, our inputs are vertex indices rather than vertex positions. Since a triangle has three vertices, the indices are 0, 1, and 2. Without vertex positions as input, we generate their positions based on these indices, instead of performing vertex position transformation. Our goal is to generate a unique position for each index while ensuring that the position falls within the [-1, 1] range, making the entire triangle visible. If we substitute 0, 1, 2 for vertex_index, we'll get the positions (0.5, -0.5), (0, 0.5), and (-0.5, -0.5) respectively.

let x = f32(1 - i32(in_vertex_index)) * 0.5;
 let y = f32(i32(in_vertex_index & 1u) * 2 - 1) * 0.5;

A clip location (a position in the clip space) is represented by a 4-float vector, not just 2. For our 2D triangle in screen space, the third component is always zero. The last value is set to 1.0. We'll delve into the details of the last two values when we explore camera and matrix transformations later.

As mentioned earlier, the outputs of the vertex stage undergo rasterization. This process generates fragments with interpolated vertex values. In our simple example, the only interpolated value is the vertex position.

The fragment shader's output is defined by another predefined field called @location(0). Each location can store up to 16 bytes of data, equivalent to four 32-bit floats. The total number of available locations is determined by the specific WebGPU implementation.

To understand the distinction between locations and builtins, we can consider locations as unstructured custom data. They have no labels other than an index. This concept parallels the HTTP protocol, where we have a structured message header (akin to builtins) followed by a body or payload (similar to locations) that can contain arbitrary data. If you're familiar with decoding binary files, it's comparable to having a structured header with metadata, followed by a chunk of data as the payload. In our context, builtins and locations share this conceptual structure.

Our fragment shader in this example is straightforward: it simply outputs a solid color to @location(0).

let code = document.getElementById('shader').innerText;
 const shaderDesc = { code: code };
 let shaderModule = device.createShaderModule(shaderDesc);
-
1_01_triangle/index.html:45-47 Load Shader From the Script Tag

Writing the shader code is just one part of rendering a simple triangle. Let's now examine how to modify the pipeline to incorporate this shader code. The process involves several steps:

  1. We retrieve the shader code string from our first script tag. This is where the tag's id='shader' attribute becomes crucial.

  2. We construct a shader description object that includes the source code.

  3. We create a shader module by providing the shader description to the WebGPU API.

It's worth noting that we haven't implemented error handling in this example. If a compilation error occurs, we'll end up with an invalid shader module. In such cases, the browser's console messages can be extremely helpful for debugging.

Typically, shader code is defined by developers during the development stage, and it's likely that all shader issues will be resolved before the code is deployed. For this reason, we've omitted error handling in this basic example. However, in a production environment, implementing robust error checking would be advisable.

const pipelineLayoutDesc = { bindGroupLayouts: [] };
+
1_01_triangle/index.html:45-47 Load Shader From the Script Tag

Writing the shader code is just one part of rendering a simple triangle. Let's now examine how to modify the pipeline to incorporate this shader code. The process involves several steps:

  1. We retrieve the shader code string from our first script tag. This is where the tag's id='shader' attribute becomes crucial.

  2. We construct a shader description object that includes the source code.

  3. We create a shader module by providing the shader description to the WebGPU API.

It's worth noting that we haven't implemented error handling in this example. If a compilation error occurs, we'll end up with an invalid shader module. In such cases, the browser's console messages can be extremely helpful for debugging.

Typically, shader code is defined by developers during the development stage, and it's likely that all shader issues will be resolved before the code is deployed. For this reason, we've omitted error handling in this basic example. However, in a production environment, implementing robust error checking would be advisable.

const pipelineLayoutDesc = { bindGroupLayouts: [] };
 const layout = device.createPipelineLayout(pipelineLayoutDesc);
-
1_01_triangle/index.html:48-49 Define the Pipeline Layout

Next, we define the pipeline layout. But what exactly is a pipeline layout? It refers to the structure of constants we intend to provide to the pipeline. Each layout represents a group of constants we want to feed into the pipeline.

A pipeline can have multiple groups of constants, which is why bindGroupLayouts is defined as a list. These constants maintain their values throughout the execution of the pipeline.

In our current example, we're not providing any constants at all. Consequently, our pipeline layout is empty.

const colorState = {
+
1_01_triangle/index.html:48-49 Define the Pipeline Layout

Next, we define the pipeline layout. But what exactly is a pipeline layout? It refers to the structure of constants we intend to provide to the pipeline. Each layout represents a group of constants we want to feed into the pipeline.

A pipeline can have multiple groups of constants, which is why bindGroupLayouts is defined as a list. These constants maintain their values throughout the execution of the pipeline.

In our current example, we're not providing any constants at all. Consequently, our pipeline layout is empty.

const colorState = {
     format: 'bgra8unorm'
 };
-
1_01_triangle/index.html:50-52 Define the Color State

The next step in our pipeline configuration is to specify the output pixel format. In this case, we're using bgra8unorm. This format defines how we'll populate our rendering target. To elaborate, bgra8unorm stands for:

const pipelineDesc = {
+
1_01_triangle/index.html:50-52 Define the Color State

The next step in our pipeline configuration is to specify the output pixel format. In this case, we're using bgra8unorm. This format defines how we'll populate our rendering target. To elaborate, bgra8unorm stands for:

const pipelineDesc = {
     layout,
     vertex: {
         module: shaderModule,
@@ -207,7 +207,7 @@ 

1.1 Drawing a Triangle

Our first tutorial is a bit boring as we were }; pipeline = device.createRenderPipeline(pipelineDesc); -

1_01_triangle/index.html:53-72 Create the Pipeline

Having assembled all necessary components, we can now create the pipeline. A GPU pipeline, analogous to a real factory pipeline, consists of inputs, a series of processing stages, and final outputs. In this analogy, layout and primitive describe the input data formats. As previously mentioned, layout refers to the constants, while primitive specifies how the geometry primitives should be provided.

Typically, the actual input data is supplied through buffers. These buffers normally contain vertex data, including vertex positions and other attributes such as vertex colors and texture coordinates. However, in our current example, we don't use any buffers. Instead of feeding vertex positions directly, we derive them in the vertex shader stage from vertex indices. These indices are automatically provided by the GPU pipeline to the vertex shader.

Typically, we provide input geometry as a list of vertices without explicit connectivity information, rather than as complete 3D graphic elements like triangles. The pipeline reconstructs triangles from these vertices based on the topology field. For instance, if the topology is set to triangle-list, it indicates that the vertex list represents triangle vertices in either counter-clockwise or clockwise order. Each triangle has a front side and a back side, with the vertex order defining the direction of the triangle's front face frontFace: 'ccw'.

The cullMode parameter determines whether we want to eliminate the rendering of a particular side of the triangle. Setting it to back means we choose not to render the back side of triangles. In most cases, the back sides of triangles shouldn't be rendered, and omitting them can save computational resources.

Using a triangle list topology is the most straightforward way of representing triangles, but it's not always the most efficient method. As illustrated in the following diagram, when we want to render a strip formed by connected triangles, many of its vertices are shared by more than one triangle.

Two Triangles Form a Triangle Strip. In a Right-Handed System, Positive Vertex Order Is Counterclockwise Around the Triangle Normal
Two Triangles Form a Triangle Strip. In a Right-Handed System, Positive Vertex Order Is Counterclockwise Around the Triangle Normal

In such cases, we want to reuse vertex positions for multiple triangles, rather than redundantly sending the same position multiple times for different triangles. This is where a triangle-strip topology becomes a better choice. It allows us to define a series of connected triangles more efficiently, reducing data redundancy and potentially improving rendering performance. We will explore other topology types in future chapters.

commandEncoder = device.createCommandEncoder();
+
1_01_triangle/index.html:53-72 Create the Pipeline

Having assembled all necessary components, we can now create the pipeline. A GPU pipeline, analogous to a real factory pipeline, consists of inputs, a series of processing stages, and final outputs. In this analogy, layout and primitive describe the input data formats. As previously mentioned, layout refers to the constants, while primitive specifies how the geometry primitives should be provided.

Typically, the actual input data is supplied through buffers. These buffers normally contain vertex data, including vertex positions and other attributes such as vertex colors and texture coordinates. However, in our current example, we don't use any buffers. Instead of feeding vertex positions directly, we derive them in the vertex shader stage from vertex indices. These indices are automatically provided by the GPU pipeline to the vertex shader.

Typically, we provide input geometry as a list of vertices without explicit connectivity information, rather than as complete 3D graphic elements like triangles. The pipeline reconstructs triangles from these vertices based on the topology field. For instance, if the topology is set to triangle-list, it indicates that the vertex list represents triangle vertices in either counter-clockwise or clockwise order. Each triangle has a front side and a back side, with the vertex order defining the direction of the triangle's front face frontFace: 'ccw'.

The cullMode parameter determines whether we want to eliminate the rendering of a particular side of the triangle. Setting it to back means we choose not to render the back side of triangles. In most cases, the back sides of triangles shouldn't be rendered, and omitting them can save computational resources.

Using a triangle list topology is the most straightforward way of representing triangles, but it's not always the most efficient method. As illustrated in the following diagram, when we want to render a strip formed by connected triangles, many of its vertices are shared by more than one triangle.

Two Triangles Form a Triangle Strip. In a Right-Handed System, Positive Vertex Order Is Counterclockwise Around the Triangle Normal
Two Triangles Form a Triangle Strip. In a Right-Handed System, Positive Vertex Order Is Counterclockwise Around the Triangle Normal

In such cases, we want to reuse vertex positions for multiple triangles, rather than redundantly sending the same position multiple times for different triangles. This is where a triangle-strip topology becomes a better choice. It allows us to define a series of connected triangles more efficiently, reducing data redundancy and potentially improving rendering performance. We will explore other topology types in future chapters.

commandEncoder = device.createCommandEncoder();
 
 passEncoder = commandEncoder.beginRenderPass(renderPassDesc);
 passEncoder.setViewport(0, 0, canvas.width, canvas.height, 0, 1);
@@ -216,7 +216,7 @@ 

1.1 Drawing a Triangle

Our first tutorial is a bit boring as we were passEncoder.end(); device.queue.submit([commandEncoder.finish()]); -

1_01_triangle/index.html:86-94 Submit Command Buffer to Render a Triangle

With the pipeline defined, we need to create the colorAttachment, which is similar to what we covered in the first tutorial, so I'll omit the details here. After that, the final step is command creation and submission. This process is nearly identical to what we've done before, with the key differences being the use of our newly created pipeline and the invocation of the draw() function.

The draw() function triggers the rendering process. The first parameter specifies the number of vertices we want to render, and the second parameter indicates the instance count. Since we are rendering a single triangle, the total number of vertices is 3. The vertex indices are automatically generated for the vertex shader.

The instance count determines how many times we want to duplicate the triangle. This technique can speed up rendering when we need to render a large number of identical geometries, such as grass or leaves in a video game. In this example, we specify a single instance because we only need to draw one triangle.

+
1_01_triangle/index.html:86-94 Submit Command Buffer to Render a Triangle

With the pipeline defined, we need to create the colorAttachment, which is similar to what we covered in the first tutorial, so I'll omit the details here. After that, the final step is command creation and submission. This process is nearly identical to what we've done before, with the key differences being the use of our newly created pipeline and the invocation of the draw() function.

The draw() function triggers the rendering process. The first parameter specifies the number of vertices we want to render, and the second parameter indicates the instance count. Since we are rendering a single triangle, the total number of vertices is 3. The vertex indices are automatically generated for the vertex shader.

The instance count determines how many times we want to duplicate the triangle. This technique can speed up rendering when we need to render a large number of identical geometries, such as grass or leaves in a video game. In this example, we specify a single instance because we only need to draw one triangle.

1.2 Drawing a Triangle with Defined Vertices

In our previous tutoria out.clip_position = vec4<f32>(inPos, 1.0); return out; } -

1_02_triangle_with_vertices/index.html:13-20 Vertex Shader

First, let's examine the shader changes. We've omitted what remains the same as before. The input to vs_main has changed from @builtin(vertex_index) in_vertex_index: u32 to @location(0) inPos: vec3<f32>. Recall that @builtin(vertex_index) is a predefined input field containing the current vertex's index, whereas @location(0) is akin to a pointer to a storage location with arbitrary data we feed into the pipeline. In this particular tutorial, we will put the vertex positions in this location. The data format for this storage location is a vector of 3 floats.

In the function body, we no longer need to derive the vertex positions as we expect them to be sent to the shader. Here, we simply create a vector of 4 floats and assign its xyz components to the input position and the w component to 1.0.

The rest of the shader remains the same. The new shader is actually simpler.

const positionAttribDesc = {
+
1_02_triangle_with_vertices/index.html:13-20 Vertex Shader

First, let's examine the shader changes. We've omitted what remains the same as before. The input to vs_main has changed from @builtin(vertex_index) in_vertex_index: u32 to @location(0) inPos: vec3<f32>. Recall that @builtin(vertex_index) is a predefined input field containing the current vertex's index, whereas @location(0) is akin to a pointer to a storage location with arbitrary data we feed into the pipeline. In this particular tutorial, we will put the vertex positions in this location. The data format for this storage location is a vector of 3 floats.

In the function body, we no longer need to derive the vertex positions as we expect them to be sent to the shader. Here, we simply create a vector of 4 floats and assign its xyz components to the input position and the w component to 1.0.

The rest of the shader remains the same. The new shader is actually simpler.

const positionAttribDesc = {
     shaderLocation: 0, // @location(0)
     offset: 0,
     format: 'float32x3'
 };
-
1_02_triangle_with_vertices/index.html:44-48 Position Attribute Descriptor

Now, let's look at the pipeline changes to adopt the new shader code. First, we need to create a position attribute description. An attribute refers to the input to the shader function @location(0) inPos: vec3<f32>. Unlike the @builtins, an attribute doesn't have predefined meanings. Its meaning is determined by the developer; it could represent vertex positions, vertex colors, or texture coordinates.

First, we specify the attribute's location shaderLocation, which corresponds to @location(0). Second, we tell the pipeline the offset with respect to the beginning of the data buffer that contains the vertex data to find the first element of this attribute. This is because we can mingle multiple attributes in a single piece of buffer. Finally, the format field defines the format and corresponds to vec3<f32> in the shader.

const positionBufferLayoutDesc = {
+
1_02_triangle_with_vertices/index.html:44-48 Position Attribute Descriptor

Now, let's look at the pipeline changes to adopt the new shader code. First, we need to create a position attribute description. An attribute refers to the input to the shader function @location(0) inPos: vec3<f32>. Unlike the @builtins, an attribute doesn't have predefined meanings. Its meaning is determined by the developer; it could represent vertex positions, vertex colors, or texture coordinates.

First, we specify the attribute's location shaderLocation, which corresponds to @location(0). Second, we tell the pipeline the offset with respect to the beginning of the data buffer that contains the vertex data to find the first element of this attribute. This is because we can mingle multiple attributes in a single piece of buffer. Finally, the format field defines the format and corresponds to vec3<f32> in the shader.

const positionBufferLayoutDesc = {
     attributes: [positionAttribDesc],
     arrayStride: 4 * 3, // sizeof(float) * 3
     stepMode: 'vertex'
 };
-
1_02_triangle_with_vertices/index.html:49-53 Position Buffer Layout Descriptor

Our next task involves creating a buffer layout descriptor. This step is crucial in aiding our GPU pipeline to comprehend the buffer's format when we submit it. For those new to graphics programming, these steps may seem verbose, and it's often challenging to grasp the difference between an attribute descriptor and a layout descriptor, as well as why they are necessary to describe a GPU buffer.

When submitting vertex data to the GPU, we typically send a large buffer containing data for numerous vertices. As introduced in the first chapter, transferring small amounts of data from CPU memory to GPU memory is inefficient, hence the best practice is to submit data in large batches. As previously mentioned, vertex data can contain multiple attributes intermingled, such as vertex positions, colors, and texture coordinates. Alternatively, you may choose to use dedicated buffers for each attribute separately. However, when we reach the vertex shader's entry point, we process each vertex individually. At this stage, we no longer have visibility of the entire attribute buffers. Each shader invocation works on one vertex independently, which allows shader programs to benefit from the GPU's parallel architecture.

To transition from submitting a single chunk of buffer on the CPU side to per-vertex processing on the GPU side, we need to dissect the input buffer to extract information for each individual vertex. The GPU pipeline can do this automatically with the help of the layout description. To differentiate between the attribute descriptor and the layout descriptor: the attribute descriptor describes the attribute itself, such as its location and format, whereas the layout descriptor focuses on how to break apart a list of multiple attributes for many vertices into data for each individual vertex.

Within this layout descriptor structure, we find an attribute list. In our current example, which only deals with positions, the list solely contains the position attribute descriptor. In more complex scenarios, we would include more attributes in this list. Following that, we define the arrayStride. This parameter denotes the size of the step by which we advance the buffer pointer for each vertex. For instance, for the first vertex (vertex 0), its data resides at offset zero within the buffer. For the subsequent vertex (vertex 1), we locate its data at offset zero plus arrayStride, which starts at the 12th byte (4 bytes for one float multiplied by 3).

Lastly, we specify the step mode. Two options exist: vertex and instance. By choosing either, we instruct the GPU pipeline to advance the pointer of this buffer for each vertex or for each instance. We'll explore the concept of instancing in future chapters. However, for most scenarios, the vertex option suffices.

const positions = new Float32Array([
+
1_02_triangle_with_vertices/index.html:49-53 Position Buffer Layout Descriptor

Our next task involves creating a buffer layout descriptor. This step is crucial in aiding our GPU pipeline to comprehend the buffer's format when we submit it. For those new to graphics programming, these steps may seem verbose, and it's often challenging to grasp the difference between an attribute descriptor and a layout descriptor, as well as why they are necessary to describe a GPU buffer.

When submitting vertex data to the GPU, we typically send a large buffer containing data for numerous vertices. As introduced in the first chapter, transferring small amounts of data from CPU memory to GPU memory is inefficient, hence the best practice is to submit data in large batches. As previously mentioned, vertex data can contain multiple attributes intermingled, such as vertex positions, colors, and texture coordinates. Alternatively, you may choose to use dedicated buffers for each attribute separately. However, when we reach the vertex shader's entry point, we process each vertex individually. At this stage, we no longer have visibility of the entire attribute buffers. Each shader invocation works on one vertex independently, which allows shader programs to benefit from the GPU's parallel architecture.

To transition from submitting a single chunk of buffer on the CPU side to per-vertex processing on the GPU side, we need to dissect the input buffer to extract information for each individual vertex. The GPU pipeline can do this automatically with the help of the layout description. To differentiate between the attribute descriptor and the layout descriptor: the attribute descriptor describes the attribute itself, such as its location and format, whereas the layout descriptor focuses on how to break apart a list of multiple attributes for many vertices into data for each individual vertex.

Within this layout descriptor structure, we find an attribute list. In our current example, which only deals with positions, the list solely contains the position attribute descriptor. In more complex scenarios, we would include more attributes in this list. Following that, we define the arrayStride. This parameter denotes the size of the step by which we advance the buffer pointer for each vertex. For instance, for the first vertex (vertex 0), its data resides at offset zero within the buffer. For the subsequent vertex (vertex 1), we locate its data at offset zero plus arrayStride, which starts at the 12th byte (4 bytes for one float multiplied by 3).

Lastly, we specify the step mode. Two options exist: vertex and instance. By choosing either, we instruct the GPU pipeline to advance the pointer of this buffer for each vertex or for each instance. We'll explore the concept of instancing in future chapters. However, for most scenarios, the vertex option suffices.

const positions = new Float32Array([
     1.0, -1.0, 0.0, -1.0, -1.0, 0.0, 0.0, 1.0, 0.0
 ]);
-
1_02_triangle_with_vertices/index.html:54-56 Vertex Positions in a CPU Buffer

Now, let's proceed to prepare the actual buffer, which is a relatively straightforward step. Here, we create a 32-bit floating-point array and populate it with the coordinates of the three vertices. This array contains nine values in total.

To better understand these coordinate values, recall the clip space or screen space coordinates we introduced previously. Each set of three values represents a vertex position in 3D space. The first vertex (1.0, -1.0, 0.0) is positioned at the bottom-right corner of the clip space. The second vertex (-1.0, -1.0, 0.0) is at the bottom-left corner, and the third vertex (0.0, 1.0, 0.0) is at the top-center of the clip space. They are organized in a clockwise order.

These coordinates are chosen deliberately to form a triangle that spans across the visible area of our rendering surface. The z-coordinate is set to 0.0 for all vertices, placing them on the same plane perpendicular to the viewing direction. This arrangement will result in a triangle that covers half of the screen, with its base along the bottom edge and its apex at the top-center.

At this stage, the data we've created resides in CPU memory. To utilize it within the GPU pipeline, we must transfer this data to GPU memory, which involves creating a GPU buffer.

const positionBufferDesc = {
+
1_02_triangle_with_vertices/index.html:54-56 Vertex Positions in a CPU Buffer

Now, let's proceed to prepare the actual buffer, which is a relatively straightforward step. Here, we create a 32-bit floating-point array and populate it with the coordinates of the three vertices. This array contains nine values in total.

To better understand these coordinate values, recall the clip space or screen space coordinates we introduced previously. Each set of three values represents a vertex position in 3D space. The first vertex (1.0, -1.0, 0.0) is positioned at the bottom-right corner of the clip space. The second vertex (-1.0, -1.0, 0.0) is at the bottom-left corner, and the third vertex (0.0, 1.0, 0.0) is at the top-center of the clip space. They are organized in a clockwise order.

These coordinates are chosen deliberately to form a triangle that spans across the visible area of our rendering surface. The z-coordinate is set to 0.0 for all vertices, placing them on the same plane perpendicular to the viewing direction. This arrangement will result in a triangle that covers half of the screen, with its base along the bottom edge and its apex at the top-center.

At this stage, the data we've created resides in CPU memory. To utilize it within the GPU pipeline, we must transfer this data to GPU memory, which involves creating a GPU buffer.

const positionBufferDesc = {
     size: positions.byteLength,
     usage: GPUBufferUsage.VERTEX,
     mappedAtCreation: true
@@ -188,7 +188,7 @@ 

1.2 Drawing a Triangle with Defined Vertices

In our previous tutoria new Float32Array(positionBuffer.getMappedRange()); writeArray.set(positions); positionBuffer.unmap(); -

1_02_triangle_with_vertices/index.html:57-67 Copy CPU Buffer to GPU

We begin this process by crafting a buffer descriptor. The descriptor's first field specifies the buffer's size, followed by the usage flag. In our case, as we intend to use this buffer for supplying vertex data, we set the VERTEX flag. Lastly, we determine whether we want to map this buffer at creation.

Mapping is a crucial operation that must precede any data transfer between the CPU and GPU. It essentially creates a mirrored buffer on the CPU side for the GPU buffer. This mirrored buffer serves as our staging area where we write our CPU data. Once we've finished writing the data, we call unmap to flush the data to the GPU.

The mappedAtCreation flag offers a convenient shortcut. By setting this flag, the buffer is automatically mapped upon creation, making it immediately available for data copying.

After defining the descriptor structure, we create the buffer based on this descriptor in the subsequent line. Since the buffer is already mapped at this point, we can proceed to write the data.

Our approach involves creating a temporary 32-bit floating-point array writeArray, directly linked to the mapped GPU buffer. We then simply copy the CPU buffer to this temporary array. After unmapping the buffer, we can be confident that the data has been successfully transferred to the GPU and is ready for use by the shader.

const pipelineDesc = {
+
1_02_triangle_with_vertices/index.html:57-67 Copy CPU Buffer to GPU

We begin this process by crafting a buffer descriptor. The descriptor's first field specifies the buffer's size, followed by the usage flag. In our case, as we intend to use this buffer for supplying vertex data, we set the VERTEX flag. Lastly, we determine whether we want to map this buffer at creation.

Mapping is a crucial operation that must precede any data transfer between the CPU and GPU. It essentially creates a mirrored buffer on the CPU side for the GPU buffer. This mirrored buffer serves as our staging area where we write our CPU data. Once we've finished writing the data, we call unmap to flush the data to the GPU.

The mappedAtCreation flag offers a convenient shortcut. By setting this flag, the buffer is automatically mapped upon creation, making it immediately available for data copying.

After defining the descriptor structure, we create the buffer based on this descriptor in the subsequent line. Since the buffer is already mapped at this point, we can proceed to write the data.

Our approach involves creating a temporary 32-bit floating-point array writeArray, directly linked to the mapped GPU buffer. We then simply copy the CPU buffer to this temporary array. After unmapping the buffer, we can be confident that the data has been successfully transferred to the GPU and is ready for use by the shader.

const pipelineDesc = {
     layout,
     vertex: {
         module: shaderModule,
@@ -216,7 +216,7 @@ 

1.2 Drawing a Triangle with Defined Vertices

In our previous tutoria passEncoder.end(); device.queue.submit([commandEncoder.finish()]); -

1_02_triangle_with_vertices/index.html:76-119 Pipeline and Command Buffer Definition

The remaining portion of the code bears a strong resemblance to the previous tutorial, with only a few key differences. One notable change appears in the pipeline descriptor definition. Within the vertex stage, we now provide a buffer layout descriptor in the buffers field. It's important to note that this field can accommodate multiple buffer descriptors if needed.

Another significant change is in the primitive section of the pipeline descriptor. We specify frontFace: cw for clockwise, which corresponds to the order of vertices in our vertex buffer. This setting informs the GPU about the winding order of our triangles, which is crucial for correct face culling.

After creating the new pipeline using this updated descriptor, we need to set a vertex buffer when crafting a command with this pipeline. We accomplish this using the setVertexBuffer function. The first parameter represents an index, corresponding to the buffer layout indices of the buffers field when defining the pipeline. In this case, we specify that the positionBuffer, which resides on the GPU, should be used as the source of vertex data.

The draw command remains similar to our previous example, instructing the GPU to render three vertices as a single triangle. However, the key difference now is that these vertices are sourced from our explicitly defined buffer, rather than being generated in the shader.

Upon submitting this command, you should see a solid triangle rendered on the screen. This approach of explicitly defining vertex data offers greater flexibility and control over the geometry we render, paving the way for more complex shapes and models in future tutorials.

+
1_02_triangle_with_vertices/index.html:76-119 Pipeline and Command Buffer Definition

The remaining portion of the code bears a strong resemblance to the previous tutorial, with only a few key differences. One notable change appears in the pipeline descriptor definition. Within the vertex stage, we now provide a buffer layout descriptor in the buffers field. It's important to note that this field can accommodate multiple buffer descriptors if needed.

Another significant change is in the primitive section of the pipeline descriptor. We specify frontFace: cw for clockwise, which corresponds to the order of vertices in our vertex buffer. This setting informs the GPU about the winding order of our triangles, which is crucial for correct face culling.

After creating the new pipeline using this updated descriptor, we need to set a vertex buffer when crafting a command with this pipeline. We accomplish this using the setVertexBuffer function. The first parameter represents an index, corresponding to the buffer layout indices of the buffers field when defining the pipeline. In this case, we specify that the positionBuffer, which resides on the GPU, should be used as the source of vertex data.

The draw command remains similar to our previous example, instructing the GPU to render three vertices as a single triangle. However, the key difference now is that these vertices are sourced from our explicitly defined buffer, rather than being generated in the shader.

Upon submitting this command, you should see a solid triangle rendered on the screen. This approach of explicitly defining vertex data offers greater flexibility and control over the geometry we render, paving the way for more complex shapes and models in future tutorials.

1.6 Understanding Uniforms

In this tutorial, we'll explore the concept of uniforms in WebGPU shaders. Uniforms provide a mechanism to supply data to shader programs, acting as constants throughout the entire execution of a shader.

Launch Playground - 1_06_uniforms

You might wonder how uniforms differ from attributes, which we've previously used to pass data to shader programs. The distinction lies in their intended use and behavior.

This distinction is crucial because it enables shaders to handle both per-vertex data (attributes) and shared, unchanging data (uniforms), offering flexibility and efficiency in the rendering process.

In this example, we'll create a uniform called "offset" to shift the positions of our vertices by a consistent amount. Using a uniform for this purpose is logical because we want the same offset applied to all vertices. If we used attributes for the offset, we'd have to duplicate the same value for every vertex, which would be inefficient and wasteful. Uniforms are the ideal choice when you need to pass the same information to the shader for all vertices.

By using a uniform for the offset, we can efficiently apply a global transformation to our geometry, easily update the offset value for dynamic effects, and reduce memory usage and data transfer compared to per-vertex attributes. This example will demonstrate how to declare, set, and use uniforms in WebGPU, illustrating their power in creating flexible and efficient shader programs.

Let's examine the syntax used to create a uniform in this program:

@group(0) @binding(0)
 var<uniform> offset: vec3<f32>;
-
1_06_uniforms/index.html:9-10 Define a Uniform in the Shader

The var<uniform> declaration indicates that offset is a uniform variable, signaling to the shader that this variable should be provided from a uniform buffer. The @binding(0) annotation serves a similar purpose to @location(0) for vertex attributes. It's an index that identifies the uniform within a uniform buffer. In a typical uniform buffer, you'll pack multiple uniform values, and this index helps the shader locate the correct value efficiently.

The @group(0) annotation relates to how uniforms are organized. In this simple case, we've placed all uniforms in a single group (group 0). However, for more complex shaders, using multiple groups can be advantageous. For instance, when rendering an animated scene, you might have camera parameters that change frequently and object colors that remain constant. By separating these into different groups, you can update only the data that changes frequently, thereby optimizing performance.

@vertex
+
1_06_uniforms/index.html:9-10 Define a Uniform in the Shader

The var<uniform> declaration indicates that offset is a uniform variable, signaling to the shader that this variable should be provided from a uniform buffer. The @binding(0) annotation serves a similar purpose to @location(0) for vertex attributes. It's an index that identifies the uniform within a uniform buffer. In a typical uniform buffer, you'll pack multiple uniform values, and this index helps the shader locate the correct value efficiently.

The @group(0) annotation relates to how uniforms are organized. In this simple case, we've placed all uniforms in a single group (group 0). However, for more complex shaders, using multiple groups can be advantageous. For instance, when rendering an animated scene, you might have camera parameters that change frequently and object colors that remain constant. By separating these into different groups, you can update only the data that changes frequently, thereby optimizing performance.

@vertex
 fn vs_main(
     @location(0) inPos: vec3<f32>,
     @location(1) inColor: vec3<f32>
@@ -168,12 +168,12 @@ 

1.6 Understanding Uniforms

In this tutorial, we'll explore the conce out.color = inColor; return out; } -

1_06_uniforms/index.html:15-24 Apply an Offset in the Vertex Shader

After defining the offset uniform, its usage in the shader becomes straightforward. We simply add the offset value to the position of each vertex, effectively shifting their positions. This uniform allows us to apply a consistent transformation to all vertices without the need for duplication or redundancy in the shader code.

After modifying the shader, we need to create the uniform buffer and supply the data on the JavaScript side. Uniforms, like attribute data, are provided in a GPU buffer. Let's break down this process:

const uniformData = new Float32Array([
+
1_06_uniforms/index.html:15-24 Apply an Offset in the Vertex Shader

After defining the offset uniform, its usage in the shader becomes straightforward. We simply add the offset value to the position of each vertex, effectively shifting their positions. This uniform allows us to apply a consistent transformation to all vertices without the need for duplication or redundancy in the shader code.

After modifying the shader, we need to create the uniform buffer and supply the data on the JavaScript side. Uniforms, like attribute data, are provided in a GPU buffer. Let's break down this process:

const uniformData = new Float32Array([
     0.1, 0.1, 0.1
 ]);
 
 let uniformBuffer = createGPUBuffer(device, uniformData, GPUBufferUsage.UNIFORM);
-
1_06_uniforms/index.html:95-99 Create and Load the Uniform Buffer

First, we create a uniformData buffer to hold our uniform values. In this example, it contains a 3-element vector representing our offset. We create the uniformBuffer using the GPUBufferUsage.UNIFORM flag to indicate its purpose. Then, we use our helper function to populate the GPU uniform buffer with the data.

let uniformBindGroupLayout = device.createBindGroupLayout({
+
1_06_uniforms/index.html:95-99 Create and Load the Uniform Buffer

First, we create a uniformData buffer to hold our uniform values. In this example, it contains a 3-element vector representing our offset. We create the uniformBuffer using the GPUBufferUsage.UNIFORM flag to indicate its purpose. Then, we use our helper function to populate the GPU uniform buffer with the data.

let uniformBindGroupLayout = device.createBindGroupLayout({
     entries: [
         {
             binding: 0,
@@ -182,7 +182,7 @@ 

1.6 Understanding Uniforms

In this tutorial, we'll explore the conce } ] }); -

1_06_uniforms/index.html:100-108 Create the Bind Group Layout

Next, we create a uniform binding group layout to describe the format of the uniform group, corresponding to our uniform group definition in the shader code. In this example, the layout has one entry, corresponding to our single uniform value. The binding index matches the one in the shader, while visibility is set to VERTEX as we use this uniform in the vertex shader. Finally, the empty buffer setting object means that we want to use defaults.

let uniformBindGroup = device.createBindGroup({
+
1_06_uniforms/index.html:100-108 Create the Bind Group Layout

Next, we create a uniform binding group layout to describe the format of the uniform group, corresponding to our uniform group definition in the shader code. In this example, the layout has one entry, corresponding to our single uniform value. The binding index matches the one in the shader, while visibility is set to VERTEX as we use this uniform in the vertex shader. Finally, the empty buffer setting object means that we want to use defaults.

let uniformBindGroup = device.createBindGroup({
     layout: uniformBindGroupLayout,
     entries: [
         {
@@ -193,9 +193,9 @@ 

1.6 Understanding Uniforms

In this tutorial, we'll explore the conce } ] }); -

1_06_uniforms/index.html:109-119 Create the Bind Group

We then create the uniform binding group, connecting the layout with the actual data storage. Here, we supply the uniform buffer as the resource for binding 0.

const pipelineLayoutDesc = { bindGroupLayouts: [uniformBindGroupLayout] };
+
1_06_uniforms/index.html:109-119 Create the Bind Group

We then create the uniform binding group, connecting the layout with the actual data storage. Here, we supply the uniform buffer as the resource for binding 0.

const pipelineLayoutDesc = { bindGroupLayouts: [uniformBindGroupLayout] };
 
• • •
passEncoder.setBindGroup(0, uniformBindGroup);
-
1_06_uniforms/index.html:120-166 Configure the Pipeline and Submit the Uniform Bind Group

In the pipeline layout descriptor, we include the uniform binding group layout. Finally, when encoding the render command, we use setBindGroup to specify the group ID and the corresponding binding group.

With these steps, we've successfully created a uniform buffer, defined its layout, and supplied the uniform data to the shader in the GPU pipeline. The result is the same triangle, but slightly offset based on our uniform values. Experiment with adjusting the offset values in the code sample to see how it affects the triangle's position.

+
1_06_uniforms/index.html:120-166 Configure the Pipeline and Submit the Uniform Bind Group

In the pipeline layout descriptor, we include the uniform binding group layout. Finally, when encoding the render command, we use setBindGroup to specify the group ID and the corresponding binding group.

With these steps, we've successfully created a uniform buffer, defined its layout, and supplied the uniform data to the shader in the GPU pipeline. The result is the same triangle, but slightly offset based on our uniform values. Experiment with adjusting the offset values in the code sample to see how it affects the triangle's position.

1.4 Using Different Vertex Colors

In this tutorial, we're making ano fn fs_main(in: VertexOutput) -> @location(0) vec4<f32> { return vec4<f32>(in.color, 1.0); } -

1_04_different_vertex_colors/index.html:9-28 Updated Shader Code With an Additional Color Attribute

By assigning different colors to different vertices, we're now in a position to address the interesting question we raised earlier: how are fragment colors generated for those fragments located in the middle of a triangle? This setup will allow us to observe the GPU's color interpolation in action, providing a visual demonstration of how data is processed between the vertex and fragment stages of the rendering pipeline.

Now, let's explore how to set up the pipeline for our new shader. The new pipeline is quite similar to the previous one, with the key difference being that we now need to create a color buffer containing colors for all vertices and feed it into our pipeline.

The steps involved closely mirror how we handled the position buffer. First, we create a color attribute descriptor. Note that we set shaderLocation to 1, corresponding to the inColor attribute at @location(1) in our shader. The format for the color attribute remains a vector of three floats.

const colorAttribDesc = {
+
1_04_different_vertex_colors/index.html:9-28 Updated Shader Code With an Additional Color Attribute

By assigning different colors to different vertices, we're now in a position to address the interesting question we raised earlier: how are fragment colors generated for those fragments located in the middle of a triangle? This setup will allow us to observe the GPU's color interpolation in action, providing a visual demonstration of how data is processed between the vertex and fragment stages of the rendering pipeline.

Now, let's explore how to set up the pipeline for our new shader. The new pipeline is quite similar to the previous one, with the key difference being that we now need to create a color buffer containing colors for all vertices and feed it into our pipeline.

The steps involved closely mirror how we handled the position buffer. First, we create a color attribute descriptor. Note that we set shaderLocation to 1, corresponding to the inColor attribute at @location(1) in our shader. The format for the color attribute remains a vector of three floats.

const colorAttribDesc = {
     shaderLocation: 1, // @location(1)
     offset: 0,
     format: 'float32x3'
@@ -200,7 +200,7 @@ 

1.4 Using Different Vertex Colors

In this tutorial, we're making ano ]); let colorBuffer = createGPUBuffer(device, colors, GPUBufferUsage.VERTEX); -

1_04_different_vertex_colors/index.html:60-88 Updated Shader Code With an Additional Color Attribute

Next, we create the buffer layout descriptor, which informs the pipeline how to interpret the color buffer for each vertex. We assign the color attribute descriptor to the attributes field. The arrayStride is set to 4 * 3 because a float occupies 4 bytes, and we have 3 floats for each color. The stepMode is set to vertex because each vertex will have one color.

After defining the RGB data in CPU memory using a Float32Array (with the first vertex being red, the second green, and the third blue), we proceed to create a GPU buffer and copy the data to the GPU.

Let's recap the process of creating and populating the GPU buffer:

  1. We define a buffer descriptor, specifying the buffer size and setting the usage flag as VERTEX since we'll use the color attribute in the vertex stage.

  2. We set mappedAtCreation to true, allowing immediate data copying upon buffer creation.

  3. We create the GPU buffer and, using the mapped buffer range, create a mirrored buffer in CPU memory.

  4. We copy the color data into this mapped buffer.

  5. Finally, we unmap the buffer, signaling that the data transfer is complete.

In our sample code, you might notice that we don't explicitly see these steps. This is because this process is a common procedure that we need to perform many times throughout our WebGPU programs. To streamline our code and reduce repetition, I've created a utility function to encapsulate these steps.

As previously mentioned, WebGPU can be quite verbose in its syntax. It's often a good practice to wrap common code blocks into reusable utility functions. This approach not only reduces our workload but also makes our code more readable and maintainable.

The createGPUBuffer function I've created encapsulates all these steps into a single, reusable function. Here's how it's defined:

function createGPUBuffer(device, buffer, usage) {
+
1_04_different_vertex_colors/index.html:60-88 Updated Shader Code With an Additional Color Attribute

Next, we create the buffer layout descriptor, which informs the pipeline how to interpret the color buffer for each vertex. We assign the color attribute descriptor to the attributes field. The arrayStride is set to 4 * 3 because a float occupies 4 bytes, and we have 3 floats for each color. The stepMode is set to vertex because each vertex will have one color.

After defining the RGB data in CPU memory using a Float32Array (with the first vertex being red, the second green, and the third blue), we proceed to create a GPU buffer and copy the data to the GPU.

Let's recap the process of creating and populating the GPU buffer:

  1. We define a buffer descriptor, specifying the buffer size and setting the usage flag as VERTEX since we'll use the color attribute in the vertex stage.

  2. We set mappedAtCreation to true, allowing immediate data copying upon buffer creation.

  3. We create the GPU buffer and, using the mapped buffer range, create a mirrored buffer in CPU memory.

  4. We copy the color data into this mapped buffer.

  5. Finally, we unmap the buffer, signaling that the data transfer is complete.

In our sample code, you might notice that we don't explicitly see these steps. This is because this process is a common procedure that we need to perform many times throughout our WebGPU programs. To streamline our code and reduce repetition, I've created a utility function to encapsulate these steps.

As previously mentioned, WebGPU can be quite verbose in its syntax. It's often a good practice to wrap common code blocks into reusable utility functions. This approach not only reduces our workload but also makes our code more readable and maintainable.

The createGPUBuffer function I've created encapsulates all these steps into a single, reusable function. Here's how it's defined:

function createGPUBuffer(device, buffer, usage) {
     const bufferDesc = {
         size: buffer.byteLength,
         usage: usage,
@@ -232,7 +232,7 @@ 

1.4 Using Different Vertex Colors

In this tutorial, we're making ano gpuBuffer.unmap(); return gpuBuffer; } -

utils/utils.js:1-32 A Helper Function to Create and Populate a GPU Buffer

At this point, we've successfully duplicated the color values on the GPU, ready for use in our shader.

const pipelineDesc = {
+
utils/utils.js:1-32 A Helper Function to Create and Populate a GPU Buffer

At this point, we've successfully duplicated the color values on the GPU, ready for use in our shader.

const pipelineDesc = {
     layout,
     vertex: {
         module: shaderModule,
@@ -263,7 +263,7 @@ 

1.4 Using Different Vertex Colors

In this tutorial, we're making ano passEncoder.end(); device.queue.submit([commandEncoder.finish()]); -

1_04_different_vertex_colors/index.html:95-138 Setup the Pipeline With Two Vertex Buffers

After creating our buffers, we define the pipeline descriptor. The key difference from our previous example is the addition of colorBufferLayoutDescriptor to the buffers list in the vertex stage. This informs the pipeline that we're now using two vertex buffers: one for positions and another for colors.

When encoding our render commands, we now need to set two vertex buffers. We use setVertexBuffer(0, positionBuffer) for the position data and setVertexBuffer(1, colorBuffer) for the color data. The indices 0 and 1 correspond to the buffer layouts when defining the pipeline descriptor. The rest of the rendering process remains largely unchanged.

Interpolated Colors on the Triangle
Interpolated Colors on the Triangle

Upon running this code, we're presented with a visually interesting result: a colorful triangle. Each vertex is rendered with its specified color - red, green, and blue. However, the most interesting aspect is what happens between these vertices. We observe a smooth transition of colors across the triangle's surface.

This automatic color blending is a feature performed by the GPU, a process we refer to as interpolation. It's important to note that this interpolation isn't limited to colors; any value we output at the vertex stage will be interpolated by the GPU to assign appropriate values for every fragment, particularly for those not located directly at the vertices.

The interpolation for a fragment's values is calculated based on its relative distance to the vertices, following a bilinear scheme. This mechanism is incredibly useful because, considering there are typically far more fragments than vertices in a scene, it would be impractical to specify values for all fragments individually. Instead, we rely on the GPU to generate these values efficiently based on the values defined only at each vertex.

This interpolation technique is a fundamental concept in computer graphics, enabling smooth transitions and gradients across surfaces with minimal input data.

+
1_04_different_vertex_colors/index.html:95-138 Setup the Pipeline With Two Vertex Buffers

After creating our buffers, we define the pipeline descriptor. The key difference from our previous example is the addition of colorBufferLayoutDescriptor to the buffers list in the vertex stage. This informs the pipeline that we're now using two vertex buffers: one for positions and another for colors.

When encoding our render commands, we now need to set two vertex buffers. We use setVertexBuffer(0, positionBuffer) for the position data and setVertexBuffer(1, colorBuffer) for the color data. The indices 0 and 1 correspond to the buffer layouts when defining the pipeline descriptor. The rest of the rendering process remains largely unchanged.

Interpolated Colors on the Triangle
Interpolated Colors on the Triangle

Upon running this code, we're presented with a visually interesting result: a colorful triangle. Each vertex is rendered with its specified color - red, green, and blue. However, the most interesting aspect is what happens between these vertices. We observe a smooth transition of colors across the triangle's surface.

This automatic color blending is a feature performed by the GPU, a process we refer to as interpolation. It's important to note that this interpolation isn't limited to colors; any value we output at the vertex stage will be interpolated by the GPU to assign appropriate values for every fragment, particularly for those not located directly at the vertices.

The interpolation for a fragment's values is calculated based on its relative distance to the vertices, following a bilinear scheme. This mechanism is incredibly useful because, considering there are typically far more fragments than vertices in a scene, it would be impractical to specify values for all fragments individually. Instead, we rely on the GPU to generate these values efficiently based on the values defined only at each vertex.

This interpolation technique is a fundamental concept in computer graphics, enabling smooth transitions and gradients across surfaces with minimal input data.