迁移到即将推出的 WebGPU 不仅仅意味着切换图形 API。这也是迈向网络图形未来的一步。但是,通过准备和理解,这种迁移会变得更好——本文将帮助您做好准备。
大家好,我叫 Dmitrii Ivashchenko,是 MY.GAMES 的软件工程师。在本文中,我们将讨论 WebGL 和即将推出的 WebGPU 之间的差异,并将介绍如何准备项目进行迁移。
WebGL 和 WebGPU 的时间表
WebGPU 的当前状态以及未来发展
高层概念差异
初始化
• WebGL:上下文模型
• WebGPU:设备模型
计划和管道
• WebGL:程序
• WebGPU:管道
制服
• WebGL 1 中的制服
• WebGL 2 中的制服
• WebGPU 中的制服
着色器
• 着色器语言:GLSL 与 WGSL
• 数据类型比较
• 结构
• 函数声明
• 内置功能
• 着色器转换
约定差异
纹理
• 视口空间
• 剪辑空间
WebGPU 提示与技巧
• 最大限度地减少您使用的管道数量。
• 提前创建管道
• 使用渲染包
概括
WebGL与许多其他 Web 技术一样,其根源可以追溯到很久以前。要了解转向 WebGPU 背后的动力和动机,首先快速浏览一下 WebGL 开发的历史会很有帮助:
近年来,人们对新的图形 API 的兴趣激增,这些 API 为开发人员提供了更多的控制力和灵活性:
如今,从版本 113 开始,WebGPU 可通过 Google Chrome 和 Microsoft Edge 浏览器在 Windows、Mac 和 ChromeOS 等多个平台上使用。预计在不久的将来将支持 Linux 和 Android。
以下是一些已经支持(或提供实验性支持)WebGPU 的引擎:
考虑到这一点,过渡到 WebGPU 或至少为这种过渡准备项目似乎是在不久的将来采取的及时步骤。
让我们从初始化开始,看看 WebGL 和 WebGPU 之间的一些高级概念差异。
当开始使用图形 API 时,第一步是初始化用于交互的主对象。 WebGL 和 WebGPU 之间的这个过程有所不同,两个系统都有一些特殊之处。
在 WebGL 中,这个对象被称为“上下文”,它本质上代表了在 HTML5 画布元素上绘图的接口。获取这个上下文非常简单:
const gl = canvas.getContext('webgl');
WebGL 的上下文实际上与特定的画布相关联。这意味着如果您需要在多个画布上渲染,则将需要多个上下文。
WebGPU引入了一个称为“设备”的新概念。该设备代表您将与之交互的 GPU 抽象。初始化过程比 WebGL 中的稍微复杂一些,但它提供了更多的灵活性:
const adapter = await navigator.gpu.requestAdapter(); const device = await adapter.requestDevice(); const context = canvas.getContext('webgpu'); context.configure({ device, format: 'bgra8unorm', });
该模型的优点之一是一台设备可以在多个画布上渲染,甚至可以在没有画布上渲染。这提供了额外的灵活性;例如,一个设备可以控制多个窗口或上下文中的渲染。
WebGL 和 WebGPU 代表了管理和组织图形管道的不同方法。
在WebGL中,主要关注的是着色器程序。该程序结合了顶点和片段着色器,定义了顶点应如何变换以及每个像素应如何着色。
const program = gl.createProgram(); gl.attachShader(program, vertShader); gl.attachShader(program, fragShader); gl.bindAttribLocation(program, 'position', 0); gl.linkProgram(program);
在 WebGL 中创建程序的步骤:
此过程允许灵活的图形控制,但也可能很复杂并且容易出错,特别是对于大型复杂的项目。
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 将渲染的各个方面分开,而 WebGPU 则尝试将更多方面封装到单个对象中,使系统更加模块化和灵活。 WebGPU 不像 WebGL 那样单独管理着色器和渲染状态,而是将所有内容组合到一个管道对象中。这使得该过程更加可预测并且不易出错:
统一变量提供可供所有着色器实例使用的常量数据。
在基本的WebGL中,我们能够直接通过API调用来设置uniform
变量。
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]);
此方法很简单,但需要对每个uniform
变量进行多次 API 调用。
随着 WebGL 2 的到来,我们现在能够将uniform
变量分组到缓冲区中。尽管您仍然可以使用单独的统一着色器,但更好的选择是使用统一缓冲区将不同的统一分组为更大的结构。然后,您可以立即将所有这些统一数据发送到 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 时传递偏移量列表。
WebGPU 为我们提供了一种更好的方法。在这种情况下,不再支持单独的uniform
变量,并且工作仅通过uniform
缓冲区完成。
工作组SL :
[[block]] struct Params { u_LightPos : vec4<f32>; u_LightColor : vec4<f32>; u_LightDirection : vec4<f32>; }; [[group(0), binding(0)]] var<uniform> ub_Params : Params;
JavaScript :
const buffer = device.createBuffer({ usage: GPUBufferUsage.UNIFORM, size: 8 });
现代 GPU 更喜欢将数据加载到一个大块中,而不是许多小块中。不要每次都重新创建和重新绑定小缓冲区,而是考虑创建一个大缓冲区并将其不同部分用于不同的绘制调用。这种方法可以显着提高性能。
WebGL 更加命令式,每次调用都会重置全局状态,并力求尽可能简单。另一方面,WebGPU 的目标是更加面向对象并注重资源重用,从而提高效率。
由于方法的差异,从 WebGL 过渡到 WebGPU 可能看起来很困难。然而,从过渡到 WebGL 2 作为中间步骤可以简化您的生活。
从 WebGL 迁移到 WebGPU 不仅需要更改 API,还需要更改着色器。 WGSL 规范旨在使这种过渡平滑且直观,同时保持现代 GPU 的效率和性能。
WGSL 旨在成为 WebGPU 和本机图形 API 之间的桥梁。与 GLSL 相比,WGSL 看起来更冗长一些,但结构仍然很熟悉。
这是纹理着色器的示例:
GLSL :
sampler2D myTexture; varying vec2 vTexCoord; void main() { return texture(myTexture, vTexCoord); }
工作组SL :
[[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); }
下表显示了 GLSL 和 WGSL 中基本数据类型和矩阵数据类型的比较:
从 GLSL 过渡到 WGSL 表明了对更严格的类型和数据大小的明确定义的渴望,这可以提高代码可读性并减少错误的可能性。
声明结构的语法也发生了变化:
GLSL:
struct Light { vec3 position; vec4 color; float attenuation; vec3 direction; float innerAngle; float angle; float range; };
工作组:
struct Light { position: vec3<f32>, color: vec4<f32>, attenuation: f32, direction: vec3<f32>, innerAngle: f32, angle: f32, range: f32, };
引入显式语法来声明 WGSL 结构中的字段强调了对更高清晰度的渴望,并简化了对着色器中数据结构的理解。
GLSL :
float saturate(float x) { return clamp(x, 0.0, 1.0); }
工作组SL :
fn saturate(x: f32) -> f32 { return clamp(x, 0.0, 1.0); }
更改 WGSL 中函数的语法反映了声明和返回值方法的统一,使代码更加一致和可预测。
在 WGSL 中,许多内置 GLSL 函数已被重命名或替换。例如:
重命名WGSL中的内置函数不仅简化了它们的名称,而且使它们更加直观,这可以方便熟悉其他图形API的开发人员的过渡过程。
对于那些计划将项目从 WebGL 转换为 WebGPU 的人来说,重要的是要知道有一些工具可以自动将 GLSL 转换为 WGSL,例如 **[Naga](https://github.com/gfx-rs/naga /)**,这是一个用于将 GLSL 转换为 WGSL 的 Rust 库。在 WebAssembly 的帮助下,它甚至可以在浏览器中正常工作。
以下是 Naga 支持的端点:
迁移后,您可能会遇到翻转图像形式的惊喜。那些曾经将应用程序从 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 等库的用户,有一个简单的perspective
方案:您可以使用perspectiveZO
;类似的函数可用于其他矩阵运算。
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 的范围,您可以将投影矩阵与另一个校正深度范围的矩阵预乘。
现在,我们来讨论一些使用 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 性能都保持不变。
过渡到 WebGPU 不仅仅意味着切换图形 API。这也是迈向 Web 图形未来的一步,结合了各种图形 API 的成功功能和实践。这种迁移需要对技术和理念变化有透彻的理解,但好处是显着的。