1. The beginning of the basic theory of WebGPU

1. The beginning of the basic theory of WebGPU

start

In a way, WebGPU is a very simple system. All it does is run 3 types of functions on the GPU: vertex shaders, fragment shaders, and compute shaders.

Vertex shaders calculate vertices. The shader returns the vertex position. For each set of 3 vertices, it returns the triangle drawn between those 3 positions.

Fragment shaders calculate colors. When you draw a triangle, the GPU calls your fragment shader for each pixel to be drawn. The fragment shader then returns the color.

Compute shaders are more versatile. It's really just a function that you call and say "execute this function N times". The GPU keeps calling your function each time it is passed the iteration number, so you can use this number to do something unique on each iteration.

This is similar to the functionality of a function passed to array.forEach or array.map. The functions you run on the GPU are just functions, just like JavaScript. The different part is that they run on the GPU, so to run them you copy all the data you want them to access to the GPU in the form of buffers and textures, and they just output to those buffers and textures. You need to specify the function's binding or location within the function in order to find the corresponding data. And, back to JavaScript, you need to save the data bound to the buffer and texture to a binding or location. Once you've done this you can tell the GPU to execute that function/method.

Maybe a picture will help. Here is a simplified diagram of a WebGPU setup for drawing triangles: by using a vertex shader and a fragment shader.

Insert image description here

Notes about this image

  • There is a pipeline . It contains the vertex shader and fragment shader that the GPU will run. It is also possible to use pipelines with compute shaders.
  • Shaders indirectly reference resources (buffers, textures, samplers) through binding groups
  • Pipes define properties by indirectly referencing buffers through internal state
  • Properties extract data from the buffer and feed the data into the vertex shader.
  • The vertex shader may feed data to the fragment shader
  • The fragment shader writes the texture description indirectly through the rendering pass

To execute shaders on the GPU, you need to create all these resources and set this state. Creation of resources is relatively simple. An interesting thing is that most WebGPU resources cannot be changed after they are created. You can change its content, but not its size, purpose, format, etc... If you need help, to change any of these you need to create a new resource and destroy the old one.

Certain states are set by creating and then executing command buffers. Command buffers are exactly what their name implies. They are buffer commands. You create the encoder. The encoder encodes commands into a command buffer. Then complete the encoder, which provides you with command buffer creation. You can then submit the command buffer and let WebGPU execute the command.

Below is some pseudocode for encoding a command buffer, followed by a representation of the command buffer created.

Insert image description here

After creating the command buffer, you can submit it for execution

device.queue.submit([commandBuffer]);

The above figure represents the status of a drawing command in the command buffer. Executing the command will set the internal state, and then the draw command will tell the GPU to execute the vertex shader (and indirectly the fragment shader). The dispatchWorkgroup command will tell the GPU to execute compute shaders.

Draw triangle to texture

In WebGPU, we can ask the canvas for a texture and then render to that texture.

To draw triangles using WebGPU we have to provide 2 "shaders". Again, shaders are functions that run on the GPU. These two shaders are

  1. vertex shader

    A vertex shader is a function that calculates the vertex positions of drawn triangles/lines/points

  2. fragment shader

    A fragment shader is a function that calculates the color (or other data) of each pixel to be drawn/rasterized when drawing a triangle/line/point

Let's start with a very small WebGPU program that draws a triangle.

We need a canvas to display our triangle:

<canvas></canvas>

Then write JS code.

WebGPU is an asynchronous API, so it's easiest to use in asynchronous functions. We first request the adapter and then request the device from the adapter.

async function main() {
    
    
  const adapter = await navigator.gpu?.requestAdapter();
  const device = await adapter?.requestDevice();
  if (!device) {
    
    
    fail('need a browser that supports WebGPU');
    return;
  }
}
main();

Get the adapter through navigator.gpu. An adapter represents a specific GPU. Some devices may have multiple GPUs.

Next, we find the canvas and create a context for it. This will give us a texture to render to. This texture will be used in the web page.

  // Get a WebGPU context from the canvas and configure it
  const canvas = document.querySelector('canvas');
  const context = canvas.getContext('webgpu');
  const presentationFormat = navigator.gpu.getPreferredCanvasFormat();
  context.configure({
    
    
    device,
    format: presentationFormat,
  });

First get the context of webgpu from the canvas canvas.

Then determine what the format of the canvas is. The format here can be "rgba8unorm" or "bgra8unorm".

Next, we create a shader module. A shader module contains one or more shader functions. In our example we will make 1 vertex shader function and 1 fragment shader function.

const module = device.createShaderModule({
    
    
    label: 'our hardcoded red triangle shaders',
    code: `
      @vertex fn vs(
        @builtin(vertex_index) vertexIndex : u32
      ) -> @builtin(position) vec4f {
        let pos = array(
          vec2f( 0.0,  0.5),  // top center
          vec2f(-0.5, -0.5),  // bottom left
          vec2f( 0.5, -0.5)   // bottom right
        );
 
        return vec4f(pos[vertexIndex], 0.0, 1.0);
      }
 
      @fragment fn fs() -> @location(0) vec4f {
        return vec4f(1.0, 0.0, 0.0, 1.0);
      }
    `,
  });

Shaders are written in the WGSL language, often pronounced wig-sil.

You can see that above we defined a vs function, which is declared through the @vertex attribute. This means it is a vertex shader function.

     @vertex fn vs(
        @builtin(vertex_index) vertexIndex : u32
      ) -> @builtin(position) vec4f {
    
    
         ...

It accepts a parameter we named vertexIndex. vertexIndex is a u32, meaning a 32-bit unsigned integer. It gets its value from a built-in function called vertex_index. vertex_index is like an iteration number, similar to an index in a JavaScript array. Map (function(value, index){…}). If we tell the GPU to execute this function 10 times by calling draw, the first time vertex_index will be 0, the second time it will be 1, the third time it will be 2, etc...

Our vs function is declared to return a vec4f, which is a vector of 4 32-bit floating point values. Think of it as an array of 4 values, or an object with 4 properties, such as {x: 0, y: 0, z: 0, w: 0}. This return value will be assigned to the built-in location. In "triangle-list" mode, every 3 times the vertex shader is executed, a triangle is drawn connecting the 3 position values ​​we returned.

The positions in WebGPU need to be returned in clipping space, where X goes from -1.0 on the left to +1.0 on the right, and Y goes from -1.0 on the bottom to +1.0 on the top. This is true regardless of the size of the texture we draw.

Insert image description here

The vs function declares an array containing 3 vec2fs. Each vec2f consists of two 32-bit floating point values.

        let pos = array(
          vec2f( 0.0,  0.5),  // top center
          vec2f(-0.5, -0.5),  // bottom left
          vec2f( 0.5, -0.5)   // bottom right
        );

Finally, it uses vertexIndex to return one of the 3 values ​​of the array. Since the function requires 4 floats as return type and pos is an array of vec2f, the code provides 0.0 and 1.0 for the remaining 2 values.

 return vec4f(pos[vertexIndex], 0.0, 1.0);

The shader module also declares a function called fs, which is declared with the @fragment attribute, making it a fragment shader function.

  @fragment fn fs() -> @location(0) vec4f {
    
    

This function takes no parameters and returns the vec4f at location(0). This means it will be written to the first render target. We will later make the first render target our canvas texture.

  return vec4f(1, 0, 0, 1);

The code returns 1,0,0,1, which means it is red. In WebGPU, colors are usually specified as floating point values ​​from 0.0 to 1.0, where the above four values ​​correspond to red, green, blue, and alpha respectively.

When the GPU rasterizes the triangle (draws it using pixels), it calls the fragment shader to find out the color of each pixel. In our case, we just return red.

Another thing to note is the labels. Almost every object you create with WebGPU can have a tag. Labels are completely optional, but a good idea is to label everything you make. The reason is that when you get an error, most WebGPU implementations will print an error message that includes a label for something related to the error.

In a normal application you would have 100's or 1000's of buffers, textures, shader modules, pipelines, etc... If you get an error like "WGSL syntax error in shaderModule at line 10", if you have 100 shader modules, which one is wrong? If you mark this module, then you will see an error message similar to "WGSL syntax error in shaderModule('our hardcoded red triangle shaders') at line 10", which is a more useful error message and will save you a lot of time tracking down the problem.

Now that we have created a shader module, we need to make a rendering pipeline

  const pipeline = device.createRenderPipeline({
    
    
    label: 'our hardcoded red triangle pipeline',
    layout: 'auto',
    vertex: {
    
    
      module,
      entryPoint: 'vs',
    },
    fragment: {
    
    
      module,
      entryPoint: 'fs',
      targets: [{
    
     format: presentationFormat }],
    },
  });

In this case, there is nothing to see. We set layout to auto, which means the layout that asks WebGPU to get data from the shader. But we didn't use any data.

We then tell the rendering pipeline to use the vs function from the shader module as the vertex shader and the fs function as the fragment shader. Otherwise, we tell it the format of the first render target. The "render target" represents the texture we are going to render to. We create a pipeline and we have to specify the format of the texture that we will use for the final render.

Element 0 of the target array corresponds to position 0 that we specified for the fragment shader return value. Later, we set the target to the canvas's texture. Next we prepare a GPURenderPassDescriptor which describes which textures we want to draw and how to use them.

  const renderPassDescriptor = {
    
    
    label: 'our basic canvas renderPass',
    colorAttachments: [
      {
    
    
        // view: <- to be filled out when we render
        clearValue: [0.3, 0.3, 0.3, 1],
        loadOp: 'clear',
        storeOp: 'store',
      },
    ],
  }; 

A GPURenderPassDescriptor has an array for color attachments colorAttachments, which lists the textures we will render to and how to handle them. We'll wait to fill the texture we actually want to render. Now we set an explicit value semi-dark gray and set loadOp and storeOp. loadOp: clearSpecifies that the texture is cleared to the clear value before drawing. Another option is that loadthis means loading the existing contents of the texture into the GPU so that we can draw on top of what is already there. storeOp: storemeans to store the results we draw. We can also pass in that discardand it will discard whatever we draw. We’ll cover why you want to do this in another article.

Now it's time to render.

  function render() {
    
    
    // Get the current texture from the canvas context and
    // set it as the texture to render to.
    renderPassDescriptor.colorAttachments[0].view =
        context.getCurrentTexture().createView();
 
    // make a command encoder to start encoding commands
    const encoder = device.createCommandEncoder({
    
     label: 'our encoder' });
 
    // make a render pass encoder to encode render specific commands
    const pass = encoder.beginRenderPass(renderPassDescriptor);
    pass.setPipeline(pipeline);
    pass.draw(3);  // call our vertex shader 3 times
    pass.end();
 
    const commandBuffer = encoder.finish();
    device.queue.submit([commandBuffer]);
  }
 
  render();

First, we call context.getcurrentexture() to get a texture that will appear on the canvas.

Calling createView gets a view to a specific part of the texture, but without arguments it will return the default part, which is what we want in this example.

Currently, our only color attachment is the texture view on the canvas, which is obtained through the context we created at the beginning. Likewise, element 0 of the colorAttachments array corresponds to @location(0), which is what we specified for the fragment shader's return value.

Next we create a command encoder. Command encoders are used to create command buffers. We use it to encode commands and then "submit" the command buffer it creates to execute the command.

We then use the command encoder to create a render pass encoder by calling beginRenderPass. Render pass encoders are specific encoders used to create rendering-related commands.

We pass in the renderPassDescriptor to tell it which texture we want to render to. We code the command setPipeline, set up the pipeline, and then tell it to execute our vertex shader 3 times by calling draw with 3.

By default, every 3 times our vertex shader is executed, it will draw a triangle by concatenating the 3 values ​​just returned from the vertex shader.

We end the rendering process and then finish the encoder. This gives us a command buffer representing the step we just specified. Finally, we submit the command buffer for execution.

When the draw command is executed, this will be our state:

Insert image description here

In the above example there are no textures, no buffers, no binding groups, but we do have a pipeline, a vertex and fragment shader, and a render pass descriptor that tells our shader to render to the canvas texture.

The following is our code and its running results. The code here has been slightly adjusted and written using TypeScript:

HTML:

<!--
 * @Description: 
 * @Author: tianyw
 * @Date: 2022-11-11 12:50:23
 * @LastEditTime: 2023-04-09 15:58:11
 * @LastEditors: tianyw
-->
<!DOCTYPE html>
<html lang="en">

<head>
    <meta charset="UTF-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title>001hello-triangle</title>
    <style>
        html,
        body {
      
      
            margin: 0;
            width: 100%;
            height: 100%;
            background: #000;
            color: #fff;
            display: flex;
            text-align: center;
            flex-direction: column;
            justify-content: center;
        }

        div,
        canvas {
      
      
            height: 100%;
            width: 100%;
        }
    </style>
</head>

<body>
    <div id="001hello-triangle">
        <canvas id="gpucanvas"></canvas>
    </div>
    <script type="module" src="./001hello-triangle.ts"></script>

</body>

</html>

TS:

/*
 * @Description:
 * @Author: tianyw
 * @Date: 2023-04-08 20:03:35
 * @LastEditTime: 2023-09-16 01:07:44
 * @LastEditors: tianyw
 */
export type SampleInit = (params: {
    
    
  canvas: HTMLCanvasElement;
}) => void | Promise<void>;

import triangleVertWGSL from "./shaders/triangle.vert.wgsl?raw";
import redFragWGSL from "./shaders/red.frag.wgsl?raw";
const init: SampleInit = async ({
    
     canvas }) => {
    
    
  const adapter = await navigator.gpu?.requestAdapter();
  if (!adapter) return;
  const device = await adapter?.requestDevice();
  if(!device) {
    
    
    console.error("need a browser that supports WebGPU");
    return;
  }
  const context = canvas.getContext("webgpu");
  if (!context) return;
  const devicePixelRatio = window.devicePixelRatio || 1;
  canvas.width = canvas.clientWidth * devicePixelRatio;
  canvas.height = canvas.clientHeight * devicePixelRatio;
  const presentationFormat = navigator.gpu.getPreferredCanvasFormat();

  context.configure({
    
    
    device,
    format: presentationFormat,
    alphaMode: "premultiplied"
  });

  const pipeline = device.createRenderPipeline({
    
    
    layout: "auto",
    vertex: {
    
    
      module: device.createShaderModule({
    
    
        code: triangleVertWGSL
      }),
      entryPoint: "main"
    },
    fragment: {
    
    
      module: device.createShaderModule({
    
    
        code: redFragWGSL
      }),
      entryPoint: "main",
      targets: [
        {
    
    
          format: presentationFormat
        }
      ]
    },
    primitive: {
    
    
      // topology: "line-list"
      // topology: "line-strip"
      //  topology: "point-list"
      topology: "triangle-list"
      // topology: "triangle-strip"
    }
  });

  function frame() {
    
    
    const commandEncoder = device.createCommandEncoder();
    if (!context) return;
    const textureView = context.getCurrentTexture().createView();
    const renderPassDescriptor: GPURenderPassDescriptor = {
    
    
      colorAttachments: [
        {
    
    
          view: textureView,
          clearValue: {
    
     r: 0.0, g: 0.0, b: 0.0, a: 1.0 },
          loadOp: "clear",
          storeOp: "store"
        }
      ]
    };

    const passEncoder = commandEncoder.beginRenderPass(renderPassDescriptor);
    passEncoder.setPipeline(pipeline);
    passEncoder.draw(3, 1, 0, 0);
    passEncoder.end();

    device.queue.submit([commandEncoder.finish()]);
    requestAnimationFrame(frame);
  }

  requestAnimationFrame(frame);
};

const canvas = document.getElementById("gpucanvas") as HTMLCanvasElement;
init({
    
     canvas: canvas });

Shader:

Fragment shader:

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

Vertex shader:

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

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

Directory Structure:

Insert image description here

operation result:

Insert image description here

It's important to emphasize that all these functions we call, like setPipeline and draw, just add commands to the command buffer. They don't actually execute these commands. When we submit the command buffer to the device queue, the command will be executed.

WebGPU uses every 3 vertices we return from the vertex shader to rasterize the triangle. It does this by determining which pixels' centers are within the triangle. It then calls our fragment shader for each pixel to ask what color to set it to.

Imagine the texture we are rendering is 15x11 pixels. These are the pixels to draw

Insert image description here

So, now we've seen a very small WebGPU example. Obviously, hardcoding a triangle in the shader is inflexible. We need methods to provide the data, which we will describe in the following articles. As can be seen from the code above:

  • WebGPU just runs shaders. It's up to you to populate them with code to do useful things
  • Shaders are specified in the shader module and then converted to a pipeline
  • WebGPU can draw triangles
  • WebGPU draws texture (we happen to get a texture from canvas)
  • WebGPU works by encoding commands and then submitting them.

Run calculations on GPU

Let's write a basic example doing some calculations on the GPU.

We start with the same code to get the WebGPU device:

async function main() {
    
    
  const adapter = await gpu?.requestAdapter();
  const device = await adapter?.requestDevice();
  if (!device) {
    
    
    fail('need a browser that supports WebGPU');
    return;
  }

Create shader:

  const module = device.createShaderModule({
    
    
    label: 'doubling compute module',
    code: `
      @group(0) @binding(0) var<storage, read_write> data: array<f32>;
 
      @compute @workgroup_size(1) fn computeSomething(
        @builtin(global_invocation_id) id: vec3<u32>
      ) {
        let i = id.x;
        data[i] = data[i] * 2.0;
      }
    `,
  });

First declare a variable data of type storage, we want it to be both readable and writable.

 @group(0) @binding(0) var<storage, read_write> data: array<f32>;

We declare its type as array<f32> which means an array of 32-bit floating point values. We tell it that we will specify this array at binding position 0 (binding(0)) in bindGroup 0 (@group(0)). Then we declare a function called computeSomething with the @compute attribute, making it a compute shader.

    @compute @workgroup_size(1) fn computeSomething(
        @builtin(global_invocation_id) id: vec3u
      ) {
        ...

Compute shaders need to declare the size of the working group, which we will cover later. Now we just set it to 1 via the property @workgroup_size(1). We declare it to have a parameter id using vec3u. vec3u are three unsigned 32 integer values. Just like the vertex shader above, this is the number of iterations. The difference is that calculating the number of shader iterations is 3-dimensional (has 3 values). We declare the id to get the value from the built-in global_invocation_id.

You can think of a compute shader running like this. This is an oversimplified example, but it'll do for now.

// pseudo code
function dispatchWorkgroups(width, height, depth) {
    
    
  for (z = 0; z < depth; ++z) {
    
    
    for (y = 0; y < height; ++y) {
    
    
      for (x = 0; x < width; ++x) {
    
    
        const workgroup_id = {
    
    x, y, z};
        dispatchWorkgroup(workgroup_id)
      }
    }
  }
}
 
function dispatchWorkgroup(workgroup_id) {
    
    
  // from @workgroup_size in WGSL
  const workgroup_size = shaderCode.workgroup_size;
  const {
    
    x: width, y: height, z: depth} = workgroup.size;
  for (z = 0; z < depth; ++z) {
    
    
    for (y = 0; y < height; ++y) {
    
    
      for (x = 0; x < width; ++x) {
    
    
        const local_invocation_id = {
    
    x, y, z};
        const global_invocation_id =
            workgroup_id * workgroup_size + local_invocation_id;
        computeShader(global_invocation_id)
      }
    }
  }
}

Since we set @workgroup_size(1), the pseudocode above actually becomes

// pseudo code
function dispatchWorkgroups(width, height, depth) {
    
    
  for (z = 0; z < depth; ++z) {
    
    
    for (y = 0; y < height; ++y) {
    
    
      for (x = 0; x < width; ++x) {
    
    
        const workgroup_id = {
    
    x, y, z};
        dispatchWorkgroup(workgroup_id)
      }
    }
  }
}
 
function dispatchWorkgroup(workgroup_id) {
    
    
  const global_invocation_id = workgroup_id;
  computeShader(global_invocation_id)
}

Finally, we index the data using the x attribute of the id and multiply each value by 2

   let i = id.x;
        data[i] = data[i] * 2.0;

Above, i is just the first of 3 iteration numbers. Now that we have created the shader, we need to create a pipeline

const pipeline = device.createComputePipeline({
    
    
    label: 'doubling compute pipeline',
    layout: 'auto',
    compute: {
    
    
      module,
      entryPoint: 'computeSomething',
    },
  });

Here we just tell it that we are using the compute stage in the shader module we created and want to call the computeSomething function. Layout is again auto, telling WebGPU to figure out the layout from the shader. Next we need some data

  const input = new Float32Array([1, 3, 5]);

This data only exists in JavaScript. In order for WebGPU to use it, we need to create a buffer on the GPU and copy the data into the buffer.

 // create a buffer on the GPU to hold our computation
  // input and output
  const workBuffer = device.createBuffer({
    
    
    label: 'work buffer',
    size: input.byteLength,
    usage: GPUBufferUsage.STORAGE | GPUBufferUsage.COPY_SRC | GPUBufferUsage.COPY_DST,
  });
  // Copy our input data to that buffer
  device.queue.writeBuffer(workBuffer, 0, input);

Create a buffer by calling device.createBuffer. size is the size in bytes, in this case it is 12, because the size of a Float32Array consisting of 3 values ​​is 12 in bytes.

Every WebGPU buffer we create must specify usage. We can pass a bunch of flags to use, but not all flags can be used together. Here, we make this buffer available for storage by passing the GPUBufferUsage.STORAGE parameter. This makes it match/compatible with var<storage,…> from the shader. Additionally, we want to be able to copy data to this buffer, so we need to include the GPUBufferUsage.COPY_DST flag. Finally, we want to be able to copy data from the buffer, so we introduce GPUBufferUsage.COPY_SRC.

Note that you cannot read the contents of the WebGPU buffer directly from JavaScript. Instead you have to "map" it, which is another way of accessing the buffer from WebGPU requests, since the buffer may be in use and it may only exist on the GPU.

WebGPU buffers that can be mapped in JavaScript can't be used in much else. In other words, we cannot map the buffer we just created, and if we try to add a flag/flag to make it mappable, we will get an error that it is not compatible with usage STORAGE.

Therefore, in order to see the calculation results, we need another buffer. After running the calculation, we copy the above buffer to this result buffer and set its flags so we can map it.

// create a buffer on the GPU to get a copy of the results
  const resultBuffer = device.createBuffer({
    
    
    label: 'result buffer',
    size: input.byteLength,
    usage: GPUBufferUsage.MAP_READ | GPUBufferUsage.COPY_DST
  });

MAP_READ means we want to be able to map this buffer to read data. In order to tell the shader which buffer we want to process we need to create a bindGroup

// Setup a bindGroup to tell the shader which
  // buffer to use for the computation
  const bindGroup = device.createBindGroup({
    
    
    label: 'bindGroup for work buffer',
    layout: pipeline.getBindGroupLayout(0),
    entries: [
      {
    
     binding: 0, resource: {
    
     buffer: workBuffer } },
    ],
  });

We get the bindGroup's layout from the pipeline. Then we set the bindGroup entries. The 0 in pipeline.getBindGroupLayout(0) corresponds to @group(0) in the shader. {binding: The value 0... corresponds to @group(0) @binding(0) in the shader.

Now we can start coding commands

// Encode commands to do the computation
  const encoder = device.createCommandEncoder({
    
    
    label: 'doubling encoder',
  });
  const pass = encoder.beginComputePass({
    
    
    label: 'doubling compute pass',
  });
  pass.setPipeline(pipeline);
  pass.setBindGroup(0, bindGroup);
  pass.dispatchWorkgroups(input.length);
  pass.end();

We create a command encoder. We start a compute pass. We set up the pipeline and then the bindGroup. Here the 0 passed in setBindGroup(0, bindGroup) corresponds to @group(0) in the shader. We then call dispatchWorkgroups, in this case we pass it an input.length value of 3, telling WebGPU to run the compute shader 3 times. Then we finish the pass.

The following is what happens when dispatchWorkgroups is executed:

Insert image description here

After the calculation is completed, we request WebGPU to copy from the workBuffer to the resultBuffer

 // Encode a command to copy the results to a mappable buffer.
  encoder.copyBufferToBuffer(workBuffer, 0, resultBuffer, 0, resultBuffer.size);

Now we can finish the encoder to get the command buffer and then submit the command buffer.

  // Finish encoding and submit the commands
  const commandBuffer = encoder.finish();
  device.queue.submit([commandBuffer]);

We then map the result buffer and get a copy of the data

 // Read the results
  await resultBuffer.mapAsync(GPUMapMode.READ);
  const result = new Float32Array(resultBuffer.getMappedRange());
 
  console.log('input', input);
  console.log('result', result);
 
  resultBuffer.unmap();

To map the result buffer, we call mapAsync and have to wait for it to complete. Once mapped, we can call resultBuffer.getMappedRange() which takes no arguments and will return an ArrayBuffer of the entire buffer. We put it in an array view of type Float32Array and then we can view the values. An important detail is that the ArrayBuffer returned by getMappedRange is only valid before calling unmap. After setting its length to 0, it is unmapped and its data is no longer accessible.
Running it, we can see that we get the result, all the numbers are doubled.

Insert image description here

We'll cover how to actually use compute shaders in other articles. Now, you hopefully have some understanding of what WebGPU is capable of. Everything else is up to you! Think of WebGPU similar to other programming languages. It provides some basic functionality and leaves the rest to your own creation. What's special about WebGPU programming is that these functions, vertex shaders, fragment shaders, and compute shaders, all run on the GPU. A GPU may have over 10,000 processors, which means they may perform over 10,000 calculations in parallel, which may be 3 or more orders of magnitude greater than your CPU's ability to perform in parallel.

The following is the above coding and its running results:

HTML:

<!--
 * @Description: 
 * @Author: tianyw
 * @Date: 2022-11-11 12:50:23
 * @LastEditTime: 2023-09-17 16:33:32
 * @LastEditors: tianyw
-->
<!DOCTYPE html>
<html lang="en">

<head>
    <meta charset="UTF-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title>001hello-triangle</title>
    <style>
        html,
        body {
      
      
            margin: 0;
            width: 100%;
            height: 100%;
            background: #000;
            color: #fff;
            display: flex;
            text-align: center;
            flex-direction: column;
            justify-content: center;
        }

        div,
        canvas {
      
      
            height: 100%;
            width: 100%;
        }
    </style>
</head>

<body>
    <div id="002hello-compute">
        <canvas id="gpucanvas"></canvas>
    </div>
    <script type="module" src="./002hello-compute.ts"></script>

</body>

</html>

TS:

/*
 * @Description:
 * @Author: tianyw
 * @Date: 2023-04-08 20:03:35
 * @LastEditTime: 2023-09-17 16:41:00
 * @LastEditors: tianyw
 */
export type SampleInit = (params: {
    
    
  canvas: HTMLCanvasElement;
}) => void | Promise<void>;

import triangleVertWGSL from "./shaders/triangle.vert.wgsl?raw";
import redFragWGSL from "./shaders/red.frag.wgsl?raw";
import dataComputeWGSL from "./shaders/data.compute.wgsl?raw";
const init: SampleInit = async ({
    
     canvas }) => {
    
    
  const adapter = await navigator.gpu?.requestAdapter();
  if (!adapter) return;
  const device = await adapter?.requestDevice();
  if (!device) {
    
    
    console.error("need a browser that supports WebGPU");
    return;
  }
  const context = canvas.getContext("webgpu");
  if (!context) return;
  const devicePixelRatio = window.devicePixelRatio || 1;
  canvas.width = canvas.clientWidth * devicePixelRatio;
  canvas.height = canvas.clientHeight * devicePixelRatio;
  const presentationFormat = navigator.gpu.getPreferredCanvasFormat();

  context.configure({
    
    
    device,
    format: presentationFormat,
    alphaMode: "premultiplied"
  });

  const renderPipeline = device.createRenderPipeline({
    
    
    layout: "auto",
    vertex: {
    
    
      module: device.createShaderModule({
    
    
        code: triangleVertWGSL
      }),
      entryPoint: "main"
    },
    fragment: {
    
    
      module: device.createShaderModule({
    
    
        code: redFragWGSL
      }),
      entryPoint: "main",
      targets: [
        {
    
    
          format: presentationFormat
        }
      ]
    },
    primitive: {
    
    
      // topology: "line-list"
      // topology: "line-strip"
      //  topology: "point-list"
      topology: "triangle-list"
      // topology: "triangle-strip"
    }
  });
  async function createCompute() {
    
    
    const computePipeLine = device.createComputePipeline({
    
    
      label: "doubling compute module",
      layout: "auto",
      compute: {
    
    
        module: device.createShaderModule({
    
    
          label: "doubling compute module",
          code: dataComputeWGSL
        }),
        entryPoint: "computeSomething"
      }
    });
    const input = new Float32Array([1, 3, 5]);
    const commandEncoder = device.createCommandEncoder({
    
    
      label: "doubling encoder"
    });
    const computePass = commandEncoder.beginComputePass({
    
    
      label: "doubling compute pass"
    });

    const workBuffer = device.createBuffer({
    
    
      label: "work buffer",
      size: input.byteLength,
      usage:
        GPUBufferUsage.STORAGE |
        GPUBufferUsage.COPY_SRC |
        GPUBufferUsage.COPY_DST
    });
    device.queue.writeBuffer(workBuffer, 0, input);

    const resultBuffer = device.createBuffer({
    
    
      label: "result buffer",
      size: input.byteLength,
      usage: GPUBufferUsage.MAP_READ | GPUBufferUsage.COPY_DST
    });

    const bindGroup = device.createBindGroup({
    
    
      label: "bindGroup for work buffer",
      layout: computePipeLine.getBindGroupLayout(0),
      entries: [{
    
     binding: 0, resource: {
    
     buffer: workBuffer } }]
    });

    computePass.setPipeline(computePipeLine);
    computePass.setBindGroup(0, bindGroup);
    computePass.dispatchWorkgroups(input.length);
    computePass.end();

    commandEncoder.copyBufferToBuffer(
      workBuffer,
      0,
      resultBuffer,
      0,
      resultBuffer.size
    );

    const commandBuffer = commandEncoder.finish();
    device.queue.submit([commandBuffer]);

    await resultBuffer.mapAsync(GPUMapMode.READ);
    const result = new Float32Array(resultBuffer.getMappedRange().slice(0));
    resultBuffer.unmap();

    console.log("input", input);
    console.log("result", result);
  }

  function frame() {
    
    
    const renderCommandEncoder = device.createCommandEncoder({
    
    
      label: "render vert frag"
    });
    if (!context) return;

    const textureView = context.getCurrentTexture().createView();
    const renderPassDescriptor: GPURenderPassDescriptor = {
    
    
      colorAttachments: [
        {
    
    
          view: textureView,
          clearValue: {
    
     r: 0.0, g: 0.0, b: 0.0, a: 1.0 },
          loadOp: "clear",
          storeOp: "store"
        }
      ]
    };
    const renderPass =
      renderCommandEncoder.beginRenderPass(renderPassDescriptor);
    renderPass.setPipeline(renderPipeline);
    renderPass.draw(3, 1, 0, 0);
    renderPass.end();
    const renderBuffer = renderCommandEncoder.finish();
    device.queue.submit([renderBuffer]);

    requestAnimationFrame(frame);
  }
  createCompute();
  requestAnimationFrame(frame);
};

const canvas = document.getElementById("gpucanvas") as HTMLCanvasElement;
init({
    
     canvas: canvas });

Shaders:

vert:

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

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

frag:

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

compute:

@group(0) @binding(0) var<storage, read_write> data: array<f32>;

@compute @workgroup_size(1)fn computeSomething(@builtin(global_invocation_id) id: vec3<u32>) {
    let i = id.x;
    data[i] = data[i] * 2.0;
}

Insert image description here

operation result:

Insert image description here

Guess you like

Origin blog.csdn.net/yinweimumu/article/details/133047916