WebGPU迁移指南

迁移到 WebGPU 不仅仅意味着切换图形 API。 这也是迈向网络图形未来的一步。 但是,通过准备和理解,这种迁移会变得更好。在本文中,我们将讨论 WebGL 和即将推出的 WebGPU 之间的差异,并将介绍如何准备项目进行迁移。

1、WebGL和WebGPU的时间线

WebGL 与许多其他 Web 技术一样,其根源可以追溯到很久以前。 要了解转向 WebGPU 背后的动力和动机,首先快速浏览一下 WebGL 开发的历史会很有帮助:

  • OpenGL 桌面版 (1993) OpenGL 桌面版首次亮相。
  • WebGL 1.0 (2011):这是 WebGL 的第一个稳定版本,基于 OpenGL ES 2.0,该版本本身于 2007 年推出。它为 Web 开发人员提供了直接在浏览器中使用 3D 图形的能力,而无需额外的插件。
  • WebGL 2.0 (2017):在第一个版本六年后推出,WebGL 2.0 基于 OpenGL ES 3.0 (2012)。 该版本带来了许多改进和新功能,使网络上的 3D 图形更加强大。

近年来,人们对新的图形 API 的兴趣激增,这些 API 为开发人员提供了更多的控制力和灵活性:

  • Vulkan (2016):由 Khronos 小组创建,这个跨平台 API 是 OpenGL 的“继承者”。 Vulkan 提供对图形硬件资源的较低级别访问,允许高性能应用程序更好地控制图形硬件。
  • D3D12 (2015):该 API 由 Microsoft 创建,专用于 Windows 和 Xbox。 D3D12 是 D3D10/11 的后继版本,为开发人员提供了对图形资源更深入的控制。
  • Metal (2014):Metal 由 Apple 创建,是 Apple 设备的专有 API。 它的设计考虑到了 Apple 硬件上的最佳性能。

2、WebGPU 的当前状态以及未来发展

如今,从版本 113 开始,WebGPU 可通过 Google Chrome 和 Microsoft Edge 浏览器在 Windows、Mac 和 ChromeOS 等多个平台上使用。预计在不久的将来将支持 Linux 和 Android。

以下是一些已经支持(或提供实验性支持)WebGPU 的引擎:

  • Babylon JS:完全支持 WebGPU。
  • ThreeJS:目前处于实验支持状态。
  • PlayCanvas:正在开发中,但前景非常光明。
  • Unity:2023.2 alpha 版本中宣布了非常早期的实验性 WebGPU 支持。
  • Cocos Creator 3.6.2:正式支持WebGPU,成为该领域的先驱之一。
  • Construct:目前仅在 Windows、macOS 和 ChromeOS 的 v113+ 中受支持。

考虑到这一点,过渡到 WebGPU 或至少为这种过渡准备项目似乎是在不久的将来采取的及时步骤。

3、高层概念差异

让我们从初始化开始,看看 WebGL 和 WebGPU 之间的一些高级概念差异。

3.1 初始化

当开始使用图形 API 时,第一步是初始化用于交互的主对象。 WebGL 和 WebGPU 之间的这个过程有所不同,两个系统都有一些特殊之处。

  • WebGL:上下文模型

在 WebGL 中,这个对象被称为“上下文”,它本质上代表了在 HTML5 画布元素上绘图的接口。 获取这个上下文非常简单:

const gl = canvas.getContext('webgl');

WebGL 的上下文实际上与特定的画布相关联。 这意味着如果您需要在多个画布上渲染,则将需要多个上下文。

  • WebGPU:设备模型

WebGPU引入了一个称为“设备”的新概念。 该设备代表你将与之交互的 GPU 抽象。 初始化过程比 WebGL 中的稍微复杂一些,但它提供了更多的灵活性:

const adapter = await navigator.gpu.requestAdapter();
const device = await adapter.requestDevice();

const context = canvas.getContext('webgpu');
context.configure({
   device,
   format: 'bgra8unorm',
});

该模型的优点之一是一台设备可以在多个画布上渲染,甚至可以在没有画布上渲染。 这提供了额外的灵活性; 例如,一个设备可以控制多个窗口或上下文中的渲染。

3.2 程序和管道

WebGL 和 WebGPU 代表了管理和组织图形管道的不同方法。

  • WebGL:程序

在WebGL中,主要关注的是着色器程序。 该程序结合了顶点和片段着色器,定义了顶点应如何变换以及每个像素应如何着色。

const program = gl.createProgram();
gl.attachShader(program, vertShader);
gl.attachShader(program, fragShader);
gl.bindAttribLocation(program, 'position', 0);
gl.linkProgram(program);

在 WebGL 中创建程序的步骤:

  • 创建着色器:编写并编译着色器的源代码。
  • 创建程序:将编译好的着色器附加到程序中,然后进行链接。
  • 使用程序:渲染前激活程序。
  • 数据传输:数据传输到激活的程序。

此过程允许灵活的图形控制,但也可能很复杂并且容易出错,特别是对于大型复杂的项目。

  • WebGPU:管道

WebGPU 引入了“管道”的概念,而不是单独的程序。 该管道不仅结合了着色器,还结合了其他信息,这些信息在 WebGL 中被建立为状态。 因此,在 WebGPU 中创建管道看起来更复杂:

 const pipeline = device.createRenderPipeline({
 layout: 'auto',
 vertex: {
   module: shaderModule, entryPoint: 'vertexMain',
   buffers: [{
     arrayStride: 12,
     attributes: [{
       shaderLocation: 0, offset: 0, format: 'float32x3'
     }]
   }],
 },
 fragment: {
   module: shaderModule, entryPoint: 'fragmentMain',
   targets: [{ format, }],
 },
});

在 WebGPU 中创建管道的步骤:

  • 着色器定义:着色器源代码的编写和编译,类似于在 WebGL 中的完成方式。
  • 管道创建:将着色器和其他渲染参数组合到管道中。
  • 管道使用:管道在渲染之前激活。

WebGL 将渲染的各个方面分开,而 WebGPU 则尝试将更多方面封装到单个对象中,使系统更加模块化和灵活。 WebGPU 不像 WebGL 那样单独管理着色器和渲染状态,而是将所有内容组合到一个管道对象中。 这使得该过程更加可预测并且不易出错。

3.3 Uniform

统一变量提供可供所有着色器实例使用的常量数据。

  • WebGL 1 中的Uniforms

在基本的WebGL中,我们能够直接通过API调用来设置统一变量。

GLSL:

uniform vec3 u_LightPos;
uniform vec3 u_LightDir;
uniform vec3 u_LightColor;

JavaScript:

const location = gl.getUniformLocation(p, "u_LightPos");
gl.uniform3fv(location, [100, 300, 500]);

此方法很简单,但需要对每个统一变量进行多次 API 调用。

  • WebGL 2 中的Uniform

随着 WebGL 2 的到来,我们现在能够将统一变量分组到缓冲区中。 尽管您仍然可以使用单独的统一着色器,但更好的选择是使用统一缓冲区将不同的统一分组为更大的结构。 然后,您可以立即将所有这些统一数据发送到 GPU,类似于在 WebGL 1 中加载顶点缓冲区的方式。这具有多个性能优势,例如减少 API 调用并更接近现代 GPU 的工作方式。

GLSL:

layout(std140) uniform ub_Params {
   vec4 u_LightPos;
   vec4 u_LightDir;
   vec4 u_LightColor;
};

JavaScript:

gl.bindBufferBase(gl.UNIFORM_BUFFER, 1, gl.createBuffer());

要在 WebGL 2 中绑定大型统一缓冲区的子集,您可以使用称为 bindBufferRange 的特殊 API 调用。 在 WebGPU 中,有类似的称为动态统一缓冲区偏移量,您可以在调用 setBindGroup API 时传递偏移量列表。

4、着色器

从 WebGL 迁移到 WebGPU 不仅需要更改 API,还需要更改着色器。 WGSL 规范旨在使这种过渡平滑且直观,同时保持现代 GPU 的效率和性能。

4.1 着色器语言:GLSL 与 WGSL

WGSL 旨在成为 WebGPU 和本机图形 API 之间的桥梁。 与 GLSL 相比,WGSL 看起来更冗长一些,但结构仍然很熟悉。

这是纹理着色器的示例:

GLSL:

sampler2D myTexture;
varying vec2 vTexCoord;
void main() {
  return texture(myTexture, vTexCoord);
}

WGSL:

[[group(0), binding(0)]] var mySampler: sampler;
[[group(0), binding(1)]] var myTexture: texture_2d<f32>;
[[stage(fragment)]]
fn main([[location(0)]] vTexCoord: vec2<f32>) -> [[location(0)]] vec4<f32> {
  return textureSample(myTexture, mySampler, vTexCoord);
}

4.2 数据类型比较

下表显示了 GLSL 和 WGSL 中基本数据类型和矩阵数据类型的比较:

从 GLSL 过渡到 WGSL 表明了对更严格的类型和数据大小的明确定义的渴望,这可以提高代码可读性并减少错误的可能性。

4.3 结构

声明结构的语法也发生了变化:

GLSL:

struct Light {
  vec3 position;
  vec4 color;
  float attenuation;
  vec3 direction;
  float innerAngle;
  float angle;
  float range;
};

WGSL:

struct Light {
  position: vec3<f32>,
  color: vec4<f32>,
  attenuation: f32,
  direction: vec3<f32>,
  innerAngle: f32,
  angle: f32,
  range: f32,
};

引入显式语法来声明 WGSL 结构中的字段强调了对更高清晰度的渴望,并简化了对着色器中数据结构的理解。

4.4 函数声明

GLSL:

float saturate(float x) {
	return clamp(x, 0.0, 1.0);
}

WGSL:

fn saturate(x: f32) -> f32 {
  return clamp(x, 0.0, 1.0);
}

更改 WGSL 中函数的语法反映了声明和返回值方法的统一,使代码更加一致和可预测。

4.5 内置函数

在 WGSL 中,许多内置 GLSL 函数已被重命名或替换。 例如:

重命名WGSL中的内置函数不仅简化了它们的名称,而且使它们更加直观,这可以方便熟悉其他图形API的开发人员的过渡过程。

5、着色器转换

对于那些计划将项目从 WebGL 转换为 WebGPU 的人来说,重要的是要知道有一些工具可以自动将 GLSL 转换为 WGSL,例如 Naga,这是一个用于将 GLSL 转换为 WGSL 的 Rust 库。 在 WebAssembly 的帮助下,它甚至可以在浏览器中正常工作。

以下是 Naga 支持的端点:

6、转换差异

纹理

迁移后,您可能会遇到翻转图像形式的惊喜。 那些曾经将应用程序从 OpenGL 移植到 Direct3D(或反之亦然)的人已经面临过这个经典问题。

在 OpenGL 和 WebGL 的上下文中,纹理通常以起始像素对应于左下角的方式加载。 但在实际应用中,很多开发者会从左上角开始加载图片,从而导致图片翻转错误。 然而,这个错误可以通过其他因素来补偿,最终解决问题。

与 OpenGL 不同,Direct3D 和 Metal 等系统传统上使用左上角作为纹理的起点。 考虑到这种方法对于许多开发人员来说似乎是最直观的,WebGPU 的创建者决定遵循这种做法。

视口空间

如果你的 WebGL 代码从帧缓冲区选择像素,请做好准备,因为 WebGPU 使用不同的坐标系。 您可能需要应用简单的“y = 1.0 - y”操作来纠正坐标。

剪辑空间

当开发人员面临对象比预期更早被剪切或消失的问题时,这通常与深度域的差异有关。 WebGL 和 WebGPU 之间的区别在于它们如何定义剪辑空间的深度范围。 WebGL 使用从 -1 到 1 的范围,而 WebGPU 使用从 0 到 1 的范围,类似于 Direct3D、Metal 和 Vulkan 等其他图形 API。 做出此决定是由于使用 0 到 1 范围的多个优点,这些优点是在使用其他图形 API 时发现的。

将模型位置转换为剪辑空间的主要责任在于投影矩阵。 调整代码的最简单方法是确保投影矩阵输出结果在 0 到 1 的范围内。对于使用 gl-matrix 等库的用户,有一个简单的解决方案:您可以使用 透视ZO; 类似的函数可用于其他矩阵运算。

if (webGPU) {
	// Creates a matrix for a symetric perspective-view frustum
  // using left-handed coordinates
  mat4.perspectiveZO(out, Math.PI / 4, ...);
} else {
  // Creates a matrix for a symetric perspective-view frustum
  // based on the default handedness and default near
  // and far clip planes definition.
  mat4.perspective(out, Math.PI / 4, …);
}

但是,有时你可能有一个现有的投影矩阵,并且无法更改其源。 在这种情况下,要将其转换为 0 到 1 的范围,您可以将投影矩阵与另一个校正深度范围的矩阵预乘。

7、WebGPU 提示与技巧

现在,我们来讨论一些使用 WebGPU 的提示和技巧。

  • 最大限度地减少你使用的管道数量。

使用的管道越多,状态切换就越多,性能就越差; 这可能不是一件小事,具体取决于您的资产来自哪里。

  • 提前创建管道

创建管道并立即使用它可能会起作用,但不建议这样做。 相反,创建立即返回并开始在不同线程上工作的函数。 当您使用管道时,执行队列需要等待待处理的管道创建完成。 这可能会导致严重的性能问题。 为了避免这种情况,请确保在创建管道和首次使用管道之间留出一些时间。

或者,更好的是,使用 create*PipelineAsync 变体! 当管道准备好使用时,承诺就会解决,不会出现任何停顿。

device.createComputePipelineAsync({
 compute: {
   module: shaderModule,
   entryPoint: 'computeMain'
 }
}).then((pipeline) => {
  const commandEncoder = device.createCommandEncoder();
  const passEncoder = commandEncoder.beginComputePass();
  passEncoder.setPipeline(pipeline);
  passEncoder.setBindGroup(0, bindGroup);
  passEncoder.dispatchWorkgroups(128);
  passEncoder.end();
  device.queue.submit([commandEncoder.finish()]);
});
  • 使用渲染包

渲染包是预先记录的、部分的、可重复使用的渲染通道。 它们可以包含大多数渲染命令(除了设置视口之类的命令),并且可以在稍后作为实际渲染通道的一部分“重播”。

const renderPass = encoder.beginRenderPass(descriptor);

renderPass.setPipeline(renderPipeline);
renderPass.draw(3);

renderPass.executeBundles([renderBundle]);

renderPass.setPipeline(renderPipeline);
renderPass.draw(3);

renderPass.end();

渲染包可以与常规渲染通道命令一起执行。 每次捆绑包执行之前和之后,渲染通道状态都会重置为默认值。 这样做主要是为了减少绘图的 JavaScript 开销。 无论采用何种方法,GPU 性能都保持不变。

8、结束语

过渡到 WebGPU 不仅仅意味着切换图形 API。 这也是迈向 Web 图形未来的一步,结合了各种图形 API 的成功功能和实践。 这种迁移需要对技术和理念变化有透彻的理解,但好处是显着的。


原文链接:Migrating from WebGL to WebGPU

BimAnt翻译整理,转载请标明出处