WebGPU Fundamentals

repository·main·Indexed 21 days ago

https://github.com/webgpu/webgpufundamentals

A collection of lessons and tutorials designed to teach the WebGPU API, covering topics from basics to advanced 3D math and compute shaders. The documentation includes guides on debugging using error scopes, uncapturederror events, and tools like WebGPU-Inspector and WebGPU-Dev-Extension, as well as instructions for generating WGSL function references and contributing translations.

Tokens
211K
Snippets
461
Records
566
Agent score
75%

What's inside WebGPU Fundamentals

  1. Use webgpu-utils for common WebGPU tasks

    main

    The webgpu-utils library provides a collection of helper functions for common WebGPU operations, reducing boilerplate for tasks like texture management and mipmap generation.

    Key capabilities include:

    • Texture Importing: Functions like loadImageBitmap, createTextureFromSource, createTextureFromImage, and createTextureFromImages simplify loading image data into WebGPU textures.
    • Mipmap Generation: generateMips (including versions for multiple layers/cubemaps) handles the creation of mipmap levels.
    • Texture Copying: Utilities like copySourceToTexture and copySourcesToTexture manage data transfers.
    • Transparency: Includes support for premultipliedAlpha in blending scenarios.
  2. WebGPU Picking: Overview

    main

    Picking is the process of selecting objects in a 3D scene by clicking on the screen and identifying which specific objects were clicked. This lesson is part of a series for building 3D editor components. To understand the full context, it is recommended to follow the series in order:

    1. Highlighting: Visual feedback for selection.
    2. Camera Controls: Navigating the 3D space.
    3. Picking: Selecting objects via user input.
  3. Implement GPU-based picking in WebGPU

    main

    GPU picking works by rendering objects with unique integer IDs instead of colors. By sampling the texel under the pointer from an ID-encoded texture, you can identify which object was clicked.

    There are two primary strategies:

    1. Multi-target rendering: Modify the fragment shader to return both color and ID to a single render pass using multiple @location outputs. This is efficient as it only requires one render pass.
    2. Dual-pass rendering: Render the scene once for visual color and a second time into a specialized ID texture (e.g., using r32uint format). This is useful if you need to selectively render objects (for example, to implement 'cycling' through objects by excluding the currently selected one from the picking pass).
    // Example of a multi-target fragment shader output
    struct MyOutput {
      @location(0) color: vec4f,
      @location(1) id: vec4u,
    };
    
    @fragment fn fs(vsOut: VSOutput) -> MyOutput {
      return MyOutput(
        vsOut.color * uni.color,
        uni.id,
      );
    }
  4. Constraints and limitations of storage textures

    main

    When using storage textures, keep the following limitations in mind:

    • Read/Write Formats: Only r32float, r32sint, and r32uint formats support read_write access. Other formats are restricted to either read or write within a single shader.
    • Supported Formats: Not all texture formats support storage usage. Supported formats include:
      • rgba8(unorm/snorm/sint/uint)
      • rgba16(float/sint/uint)
      • rg32(float/sint/uint)
      • rgba32(float/sint/uint)
      • Note: bgra8unorm is not supported by default and requires a specific feature (see below).
    • No Samplers: You cannot use samplers with storage textures. Instead of textureSample, you must use textureLoad (to read) and textureStore (to write) which operate on individual pixels.
  5. Handle mat3x3f Memory Layout and Padding

    main

    In WebGPU, a mat3x3f in a uniform buffer is treated as three vec3fs. Because vec3fs are often padded to the size of a vec4f in memory, a mat3x3f actually occupies the space of 12 floats (3 columns * 4 floats per column) rather than 9.

    To avoid manual slicing and padding when uploading to a GPUBuffer, you should design your JavaScript matrix functions to return a 12-element array where each row/column is padded with a trailing zero.

    When using padded matrices, the multiply function must use a stride of 4 instead of 3 to access elements correctly.

    // Example of a padded identity matrix
    identity() {
      return [
        1, 0, 0, 0,
        0, 1, 0, 0,
        0, 0, 1, 0,
      ];
    }
    
    // Example of multiplication with 4-float stride
    multiply(a, b) {
      const a00 = a[0 * 4 + 0];
      const a01 = a[0 * 4 + 1];
      // ... and so on
    }
  6. Generate mipmaps for multi-layer textures

    main

    When generating mipmaps for textures with multiple layers (like cube maps), you must iterate through both the mip levels and the array layers.

    Crucially, when creating texture views for the mipmap generation process, you must explicitly set dimension: '2d'. By default, a multi-layer texture view might resolve to 2d-array, which is incompatible with the standard 2D mipmap generation shader. Use baseArrayLayer and arrayLayerCount: 1 to target a specific layer during the render pass.

    // Inside generateMips loop
    for (let baseMipLevel = 1; baseMipLevel < texture.mipLevelCount; ++baseMipLevel) {
      for (let layer = 0; layer < texture.depthOrArrayLayers; ++layer) {
        const bindGroup = device.createBindGroup({
          layout: pipeline.getBindGroupLayout(0),
          entries: [
            { binding: 0, resource: sampler },
            {
              binding: 1,
              resource: texture.createView({
                dimension: '2d',
                baseMipLevel: baseMipLevel - 1,
                mipLevelCount: 1,
                baseArrayLayer: layer,
                arrayLayerCount: 1,
              }),
            },
          ],
        });
    
        const renderPassDescriptor = {
          colorAttachments: [{
            view: texture.createView({
              dimension: '2d',
              baseMipLevel: baseMipLevel,
              mipLevelCount: 1,
              baseArrayLayer: layer,
              arrayLayerCount: 1,
            }),
            loadOp: 'clear',
            storeOp: 'store',
          }],
        };
        // ... perform render pass
      }
    }
  7. How Bind Group Layouts work in WebGPU

    main

    Bind Group Layouts (GPUBindGroupLayout) allow WebGPU to efficiently match Bind Groups with compute or render pipelines.

    1. Pipeline Layout: A pipeline (like GPUComputePipeline or GPURenderPipeline) uses a GPUPipelineLayout, which is an array of one or more GPUBindGroupLayouts. Each layout is assigned to a specific group index (e.g., @group(0), @group(1)).
    2. Bind Groups: Bind Groups are created using a specific GPUBindGroupLayout.
    3. Validation: When calling draw or dispatchWorkgroups, WebGPU performs a simple check to ensure the current Bind Group matches the GPUBindGroupLayout defined in the pipeline's GPUPipelineLayout. Most heavy validation occurs during Bind Group creation, making the actual draw/dispatch calls very fast.

    Automatic Layouts: If you use layout: 'auto' when creating a pipeline, WebGPU automatically generates a GPUPipelineLayout and populates it with the necessary GPUBindGroupLayouts based on your WGSL shader code.

  8. How WebGPU connects data via indices vs WebGL via names

    main

    WebGPU requires manual synchronization of data locations between the CPU and the GPU. Unlike WebGL, which uses string-based lookups, WebGPU uses array-like indexing.

    WebGL Pattern (Name-based):

    function likeWebGL(inputs) {
      const {position, texcoords, normal, color} = inputs;
      // ...
    }
    
    // Order doesn't matter, can skip parameters
    likeWebGL({color, position, normal});

    WebGPU Pattern (Index-based):

    function likeWebGPU(inputs) {
      const [position, texcoords, normal, color] = inputs;
      // ...
    }
    
    // Must know exact indices (locations)
    const inputs = [];
    inputs[0] = position;
    inputs[2] = normal;
    inputs[3] = color;
    likeWebGPU(inputs);
    function likeWebGL(inputs) {
      const {position, texcoords, normal, color} = inputs;
      ...
    }
    
    function likeWebGPU(inputs) {
      const [position, texcoords, normal, color] = inputs;
      ...
    }
    
    // WebGL usage
    const inputs = {};
    inputs.normal = normal;
    inputs.color = color;
    inputs.position = position;
    likeWebGL(inputs);
    
    // WebGPU usage
    const inputs = [];
    inputs[0] = position;
    inputs[2] = normal;
    inputs[3] = color;
    likeWebGPU(inputs);
  9. Efficiently manage mappable buffers with a buffer pool

    main

    Because buffer.mapAsync() is asynchronous, there is a delay between requesting a map and being able to use the buffer. To avoid waiting for this promise in performance-critical loops, you can implement a buffer pool pattern:

    1. Maintain a set (pool) of buffers that are kept in a permanently mapped state.
    2. When you need a buffer, check the pool for an available mapped buffer.
    3. If the pool is empty, create a new mapped buffer and add it to the pool.
    4. Once you have finished using a buffer and submitted the relevant GPU commands, call buffer.unmap() and immediately call buffer.mapAsync() again to prepare it for the next use. When the promise resolves, return it to the pool.
  10. Inspect WebGPU adapter limits and features

    main

    Before requesting a device, you can inspect the capabilities of a GPUAdapter.

    • Limits: Accessed via adapter.limits, these define the maximum values for various resources (e.g., maxColorAttachments, maxBufferSize). There are 'minimum limits' that all WebGPU devices must support, and higher limits available on specific hardware.
    • Features: Accessed via adapter.features, this is an array of strings representing optional capabilities (e.g., 'texture-compression-astc').

    You can use adapter.limits to check if a specific threshold is met and adapter.features.has('feature-name') to check for optional capabilities.

    const adapter = await navigator.gpu?.requestAdapter();
    
    // Check a specific limit
    console.log(adapter.limits.maxColorAttachments);
    
    // Check available features
    console.log(adapter.features);
    // Example output: ["texture-compression-astc", "texture-compression-bc"]
  11. Avoid race conditions in WebGPU compute shaders

    main

    A race condition occurs when multiple threads run in parallel and attempt to access or modify the same memory location simultaneously. Because compute shader invocations run in parallel, the final value in a shared memory location depends on which thread finishes last, which is non-deterministic.

    Example of a race condition in WGSL: In the following shader, 32 parallel threads all attempt to write to the same index result[0]. The final value of result[0] is unpredictable.

    @group(0) @binding(0) var<storage, read_write> result: array<f32>;
    
    @compute @workgroup_size(32) fn computeSomething(
        @builtin(local_invocation_id) local_invocation_id : vec3<u32>,
    ) {
      result[0] = local_invocation_id.x;
    }

    WebGPU Execution Guarantees (and lack thereof): When designing compute shaders, do not assume the following:

    1. Simultaneous Execution: You cannot assume different workgroups execute at the same time.
    2. Exclusivity: You cannot assume that executing one workgroup prevents others from executing. The implementation may run multiple workgroups concurrently or queue them.
    3. Ordering: You cannot assume that one specific workgroup starts before another. Workgroups do not start in a guaranteed order.