Web 上的下一代 3D 图形

Apple 的 WebKit 团队今天在 W3C 提议成立了一个新的社区小组,以讨论 Web 上 3D 图形的未来,并开发一个标准 API,用于暴露现代 GPU 功能,包括低级图形和通用计算。W3C 社区小组允许所有人自由参与,我们邀请浏览器工程师、GPU 硬件供应商、软件开发者和 Web 社区加入我们

为了启动讨论,我们分享了一份API 提案,以及一个针对 WebKit 开源项目的该 API 原型。我们希望这是一个有用的起点,并期待随着社区小组的讨论进行,该 API 会不断发展。

更新:现在有了WebGPU 的原型实现和演示

让我们详细了解一下我们是如何走到这一步的,以及这个新小组与 WebGL 等现有 Web 图形 API 的关系。

首先,一点历史

曾几何时,基于标准的 Web 技术生成的是包含静态内容的页面,唯一的图形是嵌入式图像。不久之后,Web 开始添加更多开发者可以通过 JavaScript 访问的功能。最终,对完全可编程图形 API 的需求越来越大,以便脚本能够即时创建图像。于是,canvas 元素及其相关的2D 渲染 API 在 WebKit 内部诞生,迅速传播到其他浏览器引擎,并很快标准化。

随着时间的推移,人们为 Web 开发的应用程序和内容的类型变得更加宏大,并开始遇到平台限制。一个例子是游戏,其中性能和视觉质量至关重要。浏览器中对游戏有需求,但大多数游戏使用的是利用图形处理单元 (GPU) 强大功能的 3D 图形 API。Mozilla 和 Opera 展示了一些从 canvas 元素暴露 3D 渲染上下文的实验,它们非常引人注目,以至于社区决定聚集起来,将一种所有人都能实现的东西标准化。

所有浏览器引擎合作创建了WebGL,它是 Web 上渲染 3D 图形的标准。它基于 OpenGL ES,一个针对嵌入式系统的跨平台图形 API。这是一个正确的起点,因为它使得在所有浏览器中轻松实现相同的 API 成为可能,特别是由于大多数浏览器引擎都在支持 OpenGL 的系统上运行。即使系统不直接支持 OpenGL,该 API 也处于足够高的抽象级别,使得像 ANGLE 这样的项目可以在其他技术之上模拟它。随着 OpenGL 的发展,WebGL 也能跟进。

WebGL 为开放平台上的开发者释放了图形处理器的力量,所有主流浏览器都支持 WebGL 1,使得为 Web 构建主机质量的游戏成为可能,并且像 three.js 这样的社区得以蓬勃发展。从那时起,标准已经发展到 WebGL 2,所有主流浏览器引擎,包括 WebKit,都致力于支持它。

接下来是什么?

与此同时,GPU 技术得到了改进,并创建了新的软件 API,以更好地反映现代 GPU 的设计。这些新 API 处于较低的抽象级别,并且由于其开销较小,通常比 OpenGL 提供更好的性能。这一领域的主要平台技术包括 Microsoft 的 Direct3D 12、Apple 的 Metal 和 Khronos Group 的 Vulkan。虽然这些技术具有相似的设计理念,但不幸的是,没有一种可以在所有平台上使用。

那么这对 Web 意味着什么?这些新技术显然是内容从 GPU 强大功能中受益的下一个演进步骤。Web 平台的成功需要定义一个允许多种实现的通用标准,但在这里我们有几种具有细微架构差异的图形 API。为了暴露一种能够加速图形和计算的现代化、低级技术,我们需要设计一个可以在许多系统(包括上面提到的系统)之上实现的 API。随着图形技术领域的扩大,遵循一个特定的 API(如 OpenGL)已不再可能。

相反,我们需要评估和设计一个新的 Web 标准,它提供一套核心的必需功能,一个可以在混合平台上(具有不同的系统图形技术)实现的 API,以及暴露给 Web 所需的安全性和可靠性。

我们还需要考虑如何在图形上下文之外使用 GPU,以及新标准如何与其他 Web 技术协同工作。该标准应暴露现代 GPU 的通用计算功能。其设计应符合 Web 的既定模式,以便开发者易于采用该技术。它需要能够与 WebAssembly 和 WebVR 等其他关键的新兴 Web 标准很好地协作。最重要的是,该标准应公开开发,允许行业专家和更广泛的 Web 社区参与。

W3C 为这种状况提供了社区小组平台。“Web 上的 GPU”社区小组现已开放成员资格。

WebKit 的初步 API 提案

我们几年前就预见到了下一代图形 API 的情况,并开始在 WebKit 中进行原型开发,以验证我们是否可以将一个非常低级的 GPU API 暴露给 Web,并仍然获得有价值的性能改进。我们的结果非常令人鼓舞,因此我们正在与 W3C 社区小组分享该原型。我们也将很快开始在 WebKit 中提交代码,这样你就可以亲自试用。我们不期望这会成为最终标准中的实际 API,甚至不一定是社区小组决定从头开始的 API,但我们认为工作代码具有很大的价值。其他浏览器引擎也制作了他们自己的类似原型。与社区合作,为图形开发一项伟大的新技术将令人兴奋。

让我们详细看看我们的实验,我们称之为“WebGPU”。

获取渲染上下文和渲染管线

WebGPU 的接口,正如预期的那样,通过 canvas 元素。

let canvas = document.querySelector("canvas");
let gpu = canvas.getContext("webgpu"); 

WebGPU 比 WebGL 更面向对象。事实上,这就是一些效率的来源。WebGPU 允许你创建和存储表示状态的对象,以及可以处理一组命令的对象,而不是在每次绘制操作之前设置状态。这样我们可以在状态创建时进行一些预先验证,从而减少我们在绘制操作期间需要执行的工作。

WebGPU 上下文暴露图形命令和并行计算命令。我们假设我们想绘制一些东西,所以我们将使用图形管线。管线中最重要的元素是着色器,它们是在 GPU 上运行的程序,用于处理几何数据并为每个绘制的像素提供颜色。着色器通常使用专门用于图形的语言编写。

在 Web API 中选择着色语言很有趣,因为有许多因素需要考虑。我们需要一种功能强大、易于创建程序、可以序列化为高效传输格式,并且可以由浏览器验证以确保着色器安全的语言。行业的一部分正在转向可以从多种源格式生成的着色器表示,有点像汇编语言。同时,Web 凭借“查看源代码”方法蓬勃发展,其中人类可读代码很有价值。我们预计围绕着色语言的讨论将是标准化过程中最有趣的部分之一,并期待听到社区意见。

对于我们的 WebGPU 原型,我们决定暂时搁置这个问题,只接受一种现有语言。由于我们是在 Apple 平台上构建的,我们选择了Metal 着色语言。我们如何将着色器加载到 WebGPU 中?

let library = gpu.createLibrary( /* source code */ );

let vertexFunction = library.functionWithName("vertex_main");
let fragmentFunction = library.functionWithName("fragment_main");

我们要求 gpu 对象从源代码加载和编译着色器,生成一个 WebGPULibrary。着色器代码本身并不重要——想象一个非常简单的顶点和片段组合。一个库可以包含多个着色器函数,所以我们按名称提取我们想在这个管线中使用的函数。

现在我们可以创建我们的管线了。

// The details of the pipeline.
let pipelineDescriptor = new WebGPURenderPipelineDescriptor();
pipelineDescriptor.vertexFunction = vertexFunction;
pipelineDescriptor.fragmentFunction = fragmentFunction;
pipelineDescriptor.colorAttachments[0].pixelFormat = "BGRA8Unorm";

let pipelineState = gpu.createRenderPipelineState(pipelineDescriptor);

我们通过传入我们需要的描述,从上下文中获取一个新的 WebGPURenderPipelineState 对象。在这种情况下,我们说明将使用哪个顶点和片段着色器,以及我们想要的图像数据类型。

缓冲区

为了绘制一些东西,你需要使用缓冲区向渲染管线提供数据。WebGPUBuffer 是可以保存此类数据的对象,例如几何坐标、颜色和法向量。

let vertexData = new Float32Array([ /* some data */ ]);
let vertexBuffer = gpu.createBuffer(vertexData);

在这种情况下,我们的几何体中每个要绘制的顶点数据都在一个 Float32Array 中,然后从该数据创建一个 WebGPUBuffer。我们稍后在发出绘制操作时将使用此缓冲区。

像这样的顶点数据很少改变,但是有一些数据几乎每次绘制都会改变。这些被称为统一变量(uniforms)。一个常见的统一变量示例是表示摄像机位置的当前变换矩阵。WebGPUBuffer 也用于统一变量,但在这种情况下,我们希望在创建缓冲区后写入它。

// Imagine "buffer" is a WebGPUBuffer that was allocated earlier.
// buffer.contents exposes an ArrayBufferView, that we then interpret
// as an array of 32-bit floating point numbers.
let uniforms = new Float32Array(buffer.contents);

// Set the uniform of interest.
uniforms[42] = Math.PI;

其中一个优点是,JavaScript 开发者可以使用自定义 getter 和 setter 将 ArrayBufferView 封装在一个类或 Proxy 对象中,这样外部接口看起来就像典型的 JavaScript 对象。然后,包装对象会更新缓冲区正在使用的底层数组中的正确范围。

绘制

在我们可以告诉 WebGPU 上下文绘制一些东西之前,我们需要设置一些状态。这包括渲染目的地(一个最终会显示在 canvas 中的 WebGPUTexture),以及该纹理如何初始化和使用的描述。该状态存储在 WebGPURenderPassDescriptor 中。

// Ask the context for the texture it expects the next
// frame to be drawn into.
let drawable = gpu.nextDrawable();

let passDescriptor = new WebGPURenderPassDescriptor();
passDescriptor.colorAttachments[0].loadAction = "clear";
passDescriptor.colorAttachments[0].storeAction = "store";
passDescriptor.colorAttachments[0].clearColor = [0.8, 0.8, 0.8, 1.0];
passDescriptor.colorAttachments[0].texture = drawable.texture;

首先,我们要求 WebGPU 上下文提供一个对象,该对象代表我们可以绘制的下一帧。这最终会被复制到 canvas 元素中。完成绘图代码后,我们告诉 WebGPU 我们已经完成了可绘制对象,以便它可以显示结果并准备下一帧。

WebGPURenderPassDescriptor 初始化时指示我们不会在绘制操作中从该纹理读取(loadActionclear),我们将在绘制后使用该纹理(storeActionstore),以及它应该用什么颜色填充纹理。

接下来,我们创建需要保存实际绘制操作的对象。一个 WebGPUCommandQueue 包含一组 WebGPUCommandBuffer。我们使用 WebGPUCommandEncoder 将操作推送到 WebGPUCommandBuffer 中。

let commandQueue = gpu.createCommandQueue();
let commandBuffer = commandQueue.createCommandBuffer();

// Use the descriptor we created above.
let commandEncoder = commandBuffer.createRenderCommandEncoderWithDescriptor(
                        passDescriptor);

// Tell the encoder which state to use (i.e. shaders).
commandEncoder.setRenderPipelineState(pipelineState);

// And, lastly, the encoder needs to know which buffer
// to use for the geometry.
commandEncoder.setVertexBuffer(vertexBuffer, 0, 0);

此时,我们已经设置了一个带有着色器的渲染管线、一个保存几何体的缓冲区、一个我们将提交绘制操作的队列以及一个可以将命令提交到队列的编码器。现在我们只需将实际的绘制命令推送到编码器中。

// We know our buffer has three vertices. We want to draw them
// with filled triangles.
commandEncoder.drawPrimitives("triangle", 0, 3);
commandEncoder.endEncoding();

// All drawing commands have been submitted. Tell WebGPU to
// show/present the results in the canvas once the queue has
// been processed.
commandBuffer.presentDrawable(drawable);
commandBuffer.commit();

像大多数 3D 图形示例代码一样,为了绘制一个简单的形状,感觉需要做大量工作。但这并非浪费。这些现代 API 的一个优点是,大部分代码都是创建可以重复使用的对象来绘制其他东西。例如,通常内容只需要一个 WebGPUCommandQueue 实例,或者可以为不同的着色器预先创建多个 WebGPURenderPipelineState 对象。再说一次,浏览器可以进行大量的早期验证,以减少绘制操作期间的开销。

希望这让你对 WebGPU 提案有所了解。尽管 W3C 社区小组最终产生的 API 可能非常不同,但我们预计许多通用设计原则将是共同的。

公开邀请

Apple 的 WebKit 团队提议成立 W3C Web 上的 GPU 社区小组作为这项工作的论坛,今天我们邀请你加入我们,共同定义 GPU 的下一个标准。我们的提案受到了其他浏览器引擎、GPU 供应商和框架开发商的同事的积极响应。在业界的鼎力支持下,我们邀请所有对此领域有兴趣或专长的人士加入社区小组。