目录

[译]原始WebGPU - Raw WebGPU

作者: Alain GalvanAlain Galvan

一份如何编写 WebGPU 应用程序的概述, 学习 WebGPU 在绘制中所需的关键数据结构和类型。

WebGPU 是一种用于 Web 的新的图形 API,它遵循现代计算图形 API (例如 Mircrosoft DirectX 12、 Vulkan 和 Apple Metal) 的框架。 Web 图形 API 的这种范式转变使用户能够享受与本机原生图形 API 相同的好处: 更快的应用程序归功于充分利用 GPU,更少的特定图形驱动程序错误,基于未来实现新功能的潜力。

WebGPU 可能是 Web 上所有渲染 API 中最复杂的,尽管这种复杂性可以通过 API 提供的性能提示和未来支持的保证来抵消。本文旨在揭秘该 API,使其更容易理解使用该 API 编写 Web 应用程序。

注意: 本文基于 2023 年 5 月 11 日的 WebGPU API,如果有任何变化,请在原文评论区或者 Twitter 上告诉作者,作者会尽快更新。 (也可以在本评论区留言,或者联系译者,译者将会代为联系作者)


作者已经准备了一个 Github 仓库,里面包含了你开始的所有所需内容。我们将一起编写使用 Typescript 编写的 WebGPU Hello Triangle 应用。

请查看作者在 WebGL 上的另一篇博文,该文介绍如何使用一种较旧但广泛支持的 Web 图形 API 来编写图形应用程序。

设定

WebGPU 在广泛的平台上得到支持:

  • Google Chrome: WebGPU 在 Chrome 113 中可用,并且目前作为初始试用版本提供。目前,Chrome Canary 的 Android 版不支持 WebGPU。
  • Mozilla Firefox: 你必须使用 Nightly 版,Firefox Nightly 的 Android 版支持 WebGPU。
  • Microsoft Edge: 目前可以通过 Canary 版本使用,但其功能与 Chrome 相同。
  • Apple Safari: Safari 团队目前正在致力于桌面端支持 WebGPU,但就目前而言,移动端还没有消息。

当你拥有了支持 WebGPU 的浏览器后安装以下内容:

  • 任何现代浏览器的 alpha 版本,例如:
    • Chrome 或者任何 基于 Chromium 的浏览器的 Canary 版本, 然后访问 about:flags 去启用 unsafe-webgpu
    • FireFox,访问 about:config 去启用 dom.webgpu.enabled
  • Git
  • Node.js
  • 一个文本编辑器,例如: Visual Studio CodeVimEmacs

然后在任意终端命令行输入以下内容,例如 VS Code’s 内置终端

# 🐑 克隆该仓库
git clone https://github.com/alaingalvan/webgpu-seed

# 💿 进入项目文件夹 
cd webgpu-seed

# 🔨 启动构建项目 
npm start

引用一个文章介绍node.js, packages

项目结构

├─ 📂 node_modules/   # 👶 依赖
│  ├─ 📁 gl-matrix      # ➕ 线性代数 
│  └─ 📁 ...            # 🕚 其他依赖 (TypeScript, Webpack, etc.)
├─ 📂 src/            # 🌟 源文件 
│  ├─ 📄 renderer.ts    # 🔺 三角形渲染器 
│  └─ 📄 main.ts        # 🏁 应用程序主入口 
├─ 📄 .gitignore      # 👁️ 在 Git 仓库中忽略特定的文件 
├─ 📄 package.json    # 📦 Node 包配置文件 
├─ 📄 license.md      # ⚖️ 你的 License (Unlicense)
└─ 📃readme.md        # 📖 请阅读它! 

依赖

  • gl-matrix - 一个 JavaScript 库,允许用户像编写 JavaScript 代码一样编写 glsl,包括向量、矩阵等类型。虽然在这个示例中没有使用,但是它对于编写更高级的内容例如相机矩阵非常有用。
  • TypeScript - 带有类型的 JavaScript,通过实时自动补全和类型检查使得编写 Web 应用程序变得非常容易。
  • Webpack - 一个JavaScript 编译工具,用于构建最小化的输出和更快地测试我们的应用程序。

概述

在这个应用程序中我们将要做以下几件事:

  1. 初始化 API - 检查Navigator.gpu是否存在,如果存在,则请求一个 GPUAdapter, 然后请求一个 GPUDevice,并获取该设备的默认 GPUQueue
  2. 设置帧支持 - 创建一个GPUCanvasContext并将其配置为接收当前帧的GPUTexture,以及您可能需要的任何其他附件(例如深度模板纹理等)。为这些纹理创建GPUTextureView
  3. 初始化资源 - 创建您的顶点和索引 GPUBuffers, 将 WebGPU Shading LanguageWGSL 着色器加载为GPUShaderModules,通过描述图形管线的每个阶段创建GPURenderPipeline。最后,使用您打算运行的渲染通道构建GPUCommandEncoder,然后使用渲染通道执行所有的绘图调用构建GPURenderPassEncoder
  4. 渲染 - 通过调用 .finish() 提交您的 GPUCommandEncoderGPUQueue。通过调用requestAnimationFrame 刷新你画布上下文。
  5. 销毁 - 在使用 API 完成后销毁所有数据结构。

初始化 API

入口

./os-window.svg
Hello Triangle

为了访问 WebGPU API,你需要检查全局变量navigator对象中是否存在一个gpu对象。

// 🏭  WebGPU 入口
const entry: GPU = navigator.gpu;
if (!entry) {
    throw new Error('WebGPU is not supported on this browser.');
}

适配器

./adapter.svg
适配器

适配器 描述了特定 GPU 的物理属性,例如它的名称、扩展和设备限制。

// ✋ 定义适配器句柄 
let adapter: GPUAdapter = null;

// 🙏 内置的异步获取...

// 🔌 物理设备适配器 
adapter = await entry.requestAdapter();

设备

./device.svg
设备

设备 是您访问 WebGPU API 的核心方式,它将允许您创建所需的数据结构。

// ✋ 定义设备句柄 
let device: GPUDevice = null;

// 🙏 内置的异步获取...

// 💻 逻辑设备 
device = await adapter.requestDevice();

队列

./queue.svg
队列

队列 允许您将工作异步的发送到 GPU。在原文撰写时,您只能从给定的 GPUDevice 访问一个默认的队列。

// ✋ 定义队列句柄 
let queue: GPUQueue = null;

// 📦 获取队列
queue = device.queue;

帧支持

Canvas 上下文

./canvas.svg
Canvas

为了看到你所绘制的内容,您需要一个 HTMLCanvasElement canvas/画布 元素, 并且需要从该画布获得一个 Canvas 上下文, Canvas 上下文管理一系列纹理,您将使用这些纹理来呈现最终的渲染输出到你的 canvas 元素。

// ✋ 定义上下文句柄 
const context: GPUCanvasContext = null;

// ⚪ 创建上下文
context = canvas.getContext('webgpu');

// ⛓️ 配置上下文 
const canvasConfig: GPUCanvasConfiguration = {
    device: this.device,
    format: 'bgra8unorm',
    usage: GPUTextureUsage.RENDER_ATTACHMENT | GPUTextureUsage.COPY_SRC,
    alphaMode: 'opaque'
};

context.configure(canvasConfig);

帧缓冲附件

./attachments.svg
附件

// ✋ 定义附件句柄 
let depthTexture: GPUTexture = null;
let depthTextureView: GPUTextureView = null;

// 🤔 创建深度支持 
const depthTextureDesc: GPUTextureDescriptor = {
    size: [canvas.width, canvas.height, 1],
    dimension: '2d',
    format: 'depth24plus-stencil8',
    usage: GPUTextureUsage.RENDER_ATTACHMENT | GPUTextureUsage.COPY_SRC
};

depthTexture = device.createTexture(depthTextureDesc);
depthTextureView = depthTexture.createView();

// ✋ 定义 Canvas 上下文图像句柄 
let colorTexture: GPUTexture = null;
let colorTextureView: GPUTextureView = null;

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

初始化资源

顶点和索引缓冲区

./buffer.svg
缓冲区

缓冲区Buffer 是一个数组数据,比如网格的位置数据、颜色数据、索引数据等。在只用基于光栅的图形管线渲染三角形的时候,你需要将一个或者多个顶点数据缓冲区「通常称为顶点缓冲对象VBO」,以及一个包含与要绘制的每个三角形顶点相对应的索引的缓冲区「也称为索引缓冲对象IBO

// 📈 位置顶点缓冲数据 
const positions = new Float32Array([
    1.0, -1.0, 0.0, -1.0, -1.0, 0.0, 0.0, 1.0, 0.0
]);

// 🎨 颜色顶点缓冲数据 
const colors = new Float32Array([
    1.0,
    0.0,
    0.0, // 🔴
    0.0,
    1.0,
    0.0, // 🟢
    0.0,
    0.0,
    1.0 // 🔵
]);

// 📇 索引缓冲数据 
const indices = new Uint16Array([0, 1, 2]);

// ✋ 定义缓冲区句柄 
let positionBuffer: GPUBuffer = null;
let colorBuffer: GPUBuffer = null;
let indexBuffer: GPUBuffer = null;

// 👋 辅助函数便于其他类型数组转化为 GPUBuffer(s) 
const createBuffer = (arr: Float32Array | Uint16Array, usage: number) => {
    // 📏 对齐到 4 字节 (感谢 @chrimsonite)
    let desc = {
        size: (arr.byteLength + 3) & ~3,
        usage,
        mappedAtCreation: true
    };
    let buffer = device.createBuffer(desc);

    const writeArray =
        arr instanceof Uint16Array
            ? new Uint16Array(buffer.getMappedRange())
            : new Float32Array(buffer.getMappedRange());
    writeArray.set(arr);
    buffer.unmap();
    return buffer;
};

positionBuffer = createBuffer(positions, GPUBufferUsage.VERTEX);
colorBuffer = createBuffer(colors, GPUBufferUsage.VERTEX);
indexBuffer = createBuffer(indices, GPUBufferUsage.INDEX);

着色器

./shaders.svg
顶点着色器和片段着色器

伴随着 WebGPU 来的还有一个新的着色器语言: WebGPU Shading LanguageWGSL

WebGPU 着色器语言类似于其他语言,如 Rust 、 Metal 着色器语言和 DirectX 高级着色器语言,具有 JavaScript 风格的装饰器, 例如 @location(0), 采用 蛇形命名法snake_case函数/成员驼峰命名法CamelCase的结构体,以及函数遵循 fn my_func() -> i32 的 Rust 代码格式。

以下是顶点着色器的源代码:

struct VSOut {
    @builtin(position) nds_position: vec4<f32>,
    @location(0) color: vec3<f32>,
};

@vertex
fn main(@location(0) in_pos: vec3<f32>,
        @location(1) in_color: vec3<f32>) -> VSOut {
    var vs_out: VSOut;
    vs_out.nds_position = vec4<f32>(in_pos, 1.0);
    vs_out.color = inColor;
    return vsOut;
}

以下是片段着色器的源代码:

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

使用像 Mozilla Naga 或者 Google Tint 这样的转换器可以将其他着色器语(例如 HLSL 或 GLSL)转换为 WGSL,但是这个过程有点复杂。

着色器模块

./shadermodules.svg
着色器模块

着色器模块 是一种普通的文本 WGSL 文件, 在 GPU 上使用给定的图形管道执行。

// 📄 引入或者在行内定义您的WGSL代码:
import vertShaderCode from './shaders/triangle.vert.wgsl';
import fragShaderCode from './shaders/triangle.frag.wgsl';

// ✋ 定义着色器模块句柄 
let vertModule: GPUShaderModule = null;
let fragModule: GPUShaderModule = null;

const vsmDesc = { code: vertShaderCode };
vertModule = device.createShaderModule(vsmDesc);

const fsmDesc = { code: fragShaderCode };
fragModule = device.createShaderModule(fsmDesc);

Uniform 缓冲区

./uniform-buffer.svg
Uniform 缓冲区

您通常需要直接向着色器模块提供数据,并且为此需要指定一个统一变量。要在着色器中创建 Uniform 缓冲区,请在主函数之前声明以下内容:

struct UBO {
  modelViewProj: mat4x4<f32>,
  primaryColor: vec4<f32>,
  accentColor: vec4<f32>
};

@group(0) @binding(0)
var<uniform> uniforms: UBO;

// ❗在您的顶点着色器的主文件中 ,
// 将倒数第四行替换为:
vsOut.Position = uniforms.modelViewProj * vec4<f32>(inPos, 1.0);

在您的 JavaScript 代码中,创建一个 Uniform 缓冲区,就像使用索引/顶点缓冲区一样。

为了更好地管理线性代数计算(例如矩阵乘法),您可能需要使用像 gl-Matrix 这样的库。

// 👔 uniform数据 
const uniformData = new Float32Array([

    // ♟️ 模型视图投影矩阵 (单位矩阵)
    1.0, 0.0, 0.0, 0.0
    0.0, 1.0, 0.0, 0.0
    0.0, 0.0, 1.0, 0.0
    0.0, 0.0, 0.0, 1.0

    // 🔴 主颜色 
    0.9, 0.1, 0.3, 1.0

    // 🟣 强调色 
    0.8, 0.2, 0.8, 1.0
]);

// ✋ 定义缓冲区句柄 
let uniformBuffer: GPUBuffer = null;

uniformBuffer = createBuffer(uniformData, GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST);

管线布局

./pipeline-layout.svg
管线布局

译者注: 管线布局 本质上是一个着色器阶段的资源绑定布局描述,它定义了着色器需要的资源如何被绑定到管线上。它在创建渲染管线时被指定,确保着色器能够正确地访问所需的资源。

一旦您有了一个统一的 uniform ,您就可以创建一个 管线布局 来描述在执行图形管线时该 uniform 的位置。

let bindGroupLayout: GPUBindGroupLayout = null;
let uniformBindGroup: GPUBindGroup = null;

// 👨‍🔧 创建您的图形管线...

// 🧙‍♂️ 获取您的隐式管线布局:
bindGroupLayout = pipeline.getBindGroupLayout(0);

// 🗄️ 绑定组 
// ✍ 这将用于 *编码指令*
uniformBindGroup = device.createBindGroup({
    layout: bindGroupLayout,
    entries: [
        {
            binding: 0,
            resource: {
                buffer: uniformBuffer
            }
        }
    ]
});

或者您事先知道你的布局, 你可以自己在管线创建的时候创建并使用它。

// ✋ 定义句柄 
let uniformBindGroupLayout: GPUBindGroupLayout = null;
let uniformBindGroup: GPUBindGroup = null;
let layout: GPUPipelineLayout = null;

// 📁 绑定组layout 
uniformBindGroupLayout = device.createBindGroupLayout({
    entries: [
        {
            binding: 0,
            visibility: GPUShaderStage.VERTEX,
            buffer: {}
        }
    ]
});

// 🗄️ 绑定组 
// ✍ 这将用于 *编码指令*
uniformBindGroup = device.createBindGroup({
    layout: uniformBindGroupLayout,
    entries: [
        {
            binding: 0,
            resource: {
                buffer: uniformBuffer
            }
        }
    ]
});

// 🗂️ Pipeline Layout
// 👩‍🔧 这将用作 GPUPipelineDescriptor 的成员,在创建管线的时候使用 
const pipelineLayoutDesc = { bindGroupLayouts: [uniformBindGroupLayout] };
layout = device.createPipelineLayout(pipelineLayoutDesc);

当进行指令编码的时候,您可以使用 setBindGroup

// ✍  在您指令编码后:
passEncoder.setBindGroup(0, uniformBindGroup);

图形管线

./raster-pipeline.svg
图形管线

图形管线 描述了所有要输入到光栅绘图管线执行中的数据。包括以下:

  • 🔣 输入组件 - 每个顶点是什么样子?哪些属性在哪里,以及他们在内存中是如何对齐的?
  • 🖍️ 着色器模块 - 在执行此图形管线的时候,您将使用哪些着色器模块?
  • ✏️ Depth/Stencil State - 您应该执行深度测试吗?如果是这样,您应该使用什么函数来测试深度?
  • 🍥 混合状态 - 在先前的颜色和当前的颜色之间应该如何混合颜色?
  • 🔺 光栅化 - 在执行图形管线时,光纤化器的行为是如何的?它是否剔除面?应该剔除哪个方向的面?
  • 💾 Uniform 数据 - 您的着色器应该需要什么样格式的统一数据?在 WebGPU 中,这是通过描述管道布局来实现的。

WebGPU 为图形管线状态提供了只能默认设置,所以大多数时候您甚至不需要设置它,下面是一部分:

// ✋ Declare pipeline handle
let pipeline: GPURenderPipeline = null;

// ⚗️ Graphics Pipeline

// 🔣 Input Assembly
const positionAttribDesc: GPUVertexAttribute = {
    shaderLocation: 0, // @location(0)
    offset: 0,
    format: 'float32x3'
};
const colorAttribDesc: GPUVertexAttribute = {
    shaderLocation: 1, // @location(1)
    offset: 0,
    format: 'float32x3'
};
const positionBufferDesc: GPUVertexBufferLayout = {
    attributes: [positionAttribDesc],
    arrayStride: 4 * 3, // sizeof(float) * 3
    stepMode: 'vertex'
};
const colorBufferDesc: GPUVertexBufferLayout = {
    attributes: [colorAttribDesc],
    arrayStride: 4 * 3, // sizeof(float) * 3
    stepMode: 'vertex'
};

// 🌑 Depth
const depthStencil: GPUDepthStencilState = {
    depthWriteEnabled: true,
    depthCompare: 'less',
    format: 'depth24plus-stencil8'
};

// 🦄 Uniform Data
const pipelineLayoutDesc = { bindGroupLayouts: [] };
const layout = device.createPipelineLayout(pipelineLayoutDesc);

// 🎭 Shader Stages
const vertex: GPUVertexState = {
    module: vertModule,
    entryPoint: 'main',
    buffers: [positionBufferDesc, colorBufferDesc]
};

// 🌀 Color/Blend State
const colorState: GPUColorTargetState = {
    format: 'bgra8unorm'
};

const fragment: GPUFragmentState = {
    module: fragModule,
    entryPoint: 'main',
    targets: [colorState]
};

// 🟨 Rasterization
const primitive: GPUPrimitiveState = {
    frontFace: 'cw',
    cullMode: 'none',
    topology: 'triangle-list'
};

const pipelineDesc: GPURenderPipelineDescriptor = {
    layout,
    vertex,
    fragment,
    primitive,
    depthStencil
};

pipeline = device.createRenderPipeline(pipelineDesc);

指令编码器

./command-encoder.svg
指令编码器

指令编码器 会将您打算在 渲染通道编码器 组中执行的所有的绘制指令进行编码。一旦完成了指令的编码,您将收到一个指令缓冲区,可以将其提交到队列中。

这个意义在于指令缓冲区类似于在 GPU 提交到队列后执行绘图函数的 回调

// ✋ Declare command handles
let commandEncoder: GPUCommandEncoder = null;
let passEncoder: GPURenderPassEncoder = null;

// ✍️ Write commands to send to the GPU
const encodeCommands = () => {
    let colorAttachment: GPURenderPassColorAttachment = {
        view: this.colorTextureView,
        clearValue: { r: 0, g: 0, b: 0, a: 1 },
        loadOp: 'clear',
        storeOp: 'store'
    };

    const depthAttachment: GPURenderPassDepthStencilAttachment = {
        view: this.depthTextureView,
        depthClearValue: 1,
        depthLoadOp: 'clear',
        depthStoreOp: 'store',
        stencilClearValue: 0,
        stencilLoadOp: 'clear',
        stencilStoreOp: 'store'
    };

    const renderPassDesc: GPURenderPassDescriptor = {
        colorAttachments: [colorAttachment],
        depthStencilAttachment: depthAttachment
    };

    commandEncoder = device.createCommandEncoder();

    // 🖌️ Encode drawing commands
    passEncoder = commandEncoder.beginRenderPass(renderPassDesc);
    passEncoder.setPipeline(pipeline);
    passEncoder.setViewport(0, 0, canvas.width, canvas.height, 0, 1);
    passEncoder.setScissorRect(0, 0, canvas.width, canvas.height);
    passEncoder.setVertexBuffer(0, positionBuffer);
    passEncoder.setVertexBuffer(1, colorBuffer);
    passEncoder.setIndexBuffer(indexBuffer, 'uint16');
    passEncoder.drawIndexed(3);
    passEncoder.endPass();

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

渲染

./triangle-raster.gif
渲染

渲染 是在 WebGPU 中简单地更新任务你打算更新的 uniform, 从你的上下文或者下一个附件,提交你的指令编码器以执行,然后使用 requestAnimationFrame 回调再次执行所有这些操作。

const render = () => {
    // ⏭ Acquire next image from context
    colorTexture = context.getCurrentTexture();
    colorTextureView = colorTexture.createView();

    // 📦 Write and submit commands to queue
    encodeCommands();

    // ➿ Refresh canvas
    requestAnimationFrame(render);
};

结束语

WebGPU 可能比其他图形 API 更难,但是它的API 设计的与现在显卡相结合的更紧密,因此,不仅可以让应用程序更快,并且更长效。

有些事情作者没有在这篇文章中涉及,因为它们超出了这篇文章的范围,比如:

  • 矩阵 不管是用于视角还是场景中的物体转换。 gl-Matrix 是一种无价的资源。
  • WebGPU 类型定义 详细概述了绘图管线的每种可能状态,是非常有帮助的。
  • 混合模式 Anders Riggelsen 在这里编写了一个工具,可以帮助您以可视化的方式查看 OpenGL 混合模式的行为。
  • 计算管线 如果您想尝试的化,请查看下面的说明或者一些示例。
  • 加载纹理 这可能有点复杂,下面的示例很好的介绍了如何加载纹理。

其他资源

下面是一些关于 WebGPU 的文章/项目,顺序不分先后:

这有许多开源项目:

WebGPU 和 WebGPU 着色器语言的规范也值得看一看: