什么是 WebGPU
WebGPU 是用于加速图形和计算的潜在网络标准和 JavaScript API 的工作名称,旨在提供“现代 3D 图形和计算能力”。与 WebGL 不同,WebGPU 不是任何现有原生 API 的直接端口。 它基于 Vulkan、Metal 和 Direct3D 12 提供的 API,旨在跨移动和桌面平台提供高性能。(来自维基百科)
翻译成白话就是开发者可以通过 WebGPU 提供的接口来让 gpu 为我们干活,比如说开发一款 3d 程序。虽然早在十几年前 web 开发者们就可以通过 webgl 来开发 3d 程序了,但时代在进步、科技在发展,gpu 硬件飞速地迭代,Vulkan、D3D 等图形 API 为了能够充分使用 GPU 的性能和特性也在不断更新升级,而 webgl 却已经是停步不前了。
Chromium 团队于 2017 年初展示了名为 NXT 的第一个概念原型。 Google Chrome 开发团队已将其命名为 WebGL/2 JavaScript API 的“继任者”。2023 年 4 月 6 日,谷歌宣布 Chromium 浏览器将在 ChromeOS、macOS 和 Windows 上启用 WebGPU 支持。
体验 WebGPU
WebGPU 在提供了在线示例以及这些示例的源码供开发者学习。
示例地址:webgpu.github.io/webgpu-samp…
示例源码地址:github.com/webgpu/webg…
注意:可能需要将 chrome 升级到最新版本才能看到效果
第一个示例
众所周知,编程语言的入门示例是在控制台上打印 hello wolrd,那么图形 API 的入门示例就是在屏幕上画一个三角形了(以下代码来自于 WebGPU 示例)
第一步:初始化 WebGPU
初始化 WebGPU 主要做两个事,获取 gpu 设备,将 gpu 设备和 canvas WebGPU 上下文绑定
const adapter = await navigator.gpu.requestAdapter();
const device = await adapter.requestDevice();
const context = canvas.getContext('webgpu') as GPUCanvasContext;
const devicePixelRatio = window.devicePixelRatio || 1;
canvas.width = canvas.clientWidth * devicePixelRatio;
canvas.height = canvas.clientHeight * devicePixelRatio;
const presentationFormat = navigator.gpu.getPreferredCanvasFormat();
context.configure({
device,
format: presentationFormat,
alphaMode: 'premultiplied',
});
- devicePixelRatio:当前显示设备的物理像素分辨率与 css 像素分辨率之比
- getPreferredCanvasFormat():该 gpu 方法返回用于当前系统上显示 8 位色深、标准动态范围(SDR)内容的最佳 canvas 纹理格式。
- configure():给 device 配置用于渲染的上下文,其中 alphaMode 可以填入 premultiplied 或 opaque,这个配置项决定了最终显示的像素颜色是否受颜色通道中的不透明度影响。比如说:某个像素的颜色值是 rgba(1, 0.8, 1, 0.8),那么 premultiplied 最终展示出来的颜色就是 rgb(0.8, 0.64, 0.8),而 opaque 则任是 rgb(1, 0.8, 1)。
第二步:创建顶点着色器和片元着色器
为了能让 gpu 帮助我们绘制出一个三角形,我们必须要给它提供三角形的三个顶点的位置,还需要告诉它这个三角形的颜色,这些需要让 gpu 知道的信息只能通过着色器编程来传递。WebGPU 的着色器语言是 wgsl,语法风格类似于 typescript / rust,是一种强类型语言,着色器语言是由 gpu 直接执行的。
顶点着色器用于控制最终顶点的信息,比如说,我们通过 js 给顶点着色器传入了三角形的三个顶点,我们就可以在顶点着色器中对这三个顶点进行平移操作,从而达到修改三角形位置或形状的目的。
片元着色器用于控制像素最终显示的颜色,比如我们想要得到一个红色的三角形,直接在片元着色器的主函数中返回代表红色的颜色值即可。
@vertex
fn main(
@builtin(vertex_index) VertexIndex : u32
) -> @builtin(position) vec4<f32> {
var pos = array<vec2<f32>, 3>(
vec2(0.0, 0.5),
vec2(-0.5, -0.5),
vec2(0.5, -0.5)
);
return vec4<f32>(pos[VertexIndex], 0.0, 1.0);
}
@fragment
fn main() -> @location(0) vec4<f32> {
return vec4(1.0, 0.0, 0.0, 1.0);
}
上述就是红色三角形的顶点着色器和片元着色器代码啦,代码的逻辑还是非常容易看懂的。需要注意的是,这里的顶点位置是位于 canvas 元素坐标系中的,即 canvas 元素中心为 (0, 0, 0),x 轴朝右,y 轴朝上,且 xyz 值均处于 [-1, 1] 区间内。所以上面的着色器代码就是在屏幕的中心区域画了一个红色的等腰三角形。
第三步:创建渲染流水线
我们可以想象出一条工厂中的流水线,流水线中一些固定的环节可以交给机器人完成,而一些复杂的、可以表现创意的环节就交给技术工人完成。渲染过程也是如此,一些固定的环节如光栅化由 gpu 自动完成,而我们开发人员就可以通过编写顶点 / 片元着色器代码来决定这条流水线最终会生成什么样的图像。
const pipeline = device.createRenderPipeline({
layout: 'auto',
vertex: {
module: device.createShaderModule({
code: triangleVertWGSL,
}),
entryPoint: 'main',
},
fragment: {
module: device.createShaderModule({
code: redFragWGSL,
}),
entryPoint: 'main',
targets: [
{
format: presentationFormat,
},
],
},
primitive: {
topology: 'triangle-list',
},
});
通过 device.createShaderModule 来将字符串格式的 shader 代码变为着色器模块绑定到渲染流水线中供 gpu 执行,entryPoint 就是着色器代码中的入口函数,由于片元着色器需要输出最终颜色值,因此需要指定颜色格式。而 primitive 则用于指定这条渲染流水线的配置,比如 topology 就可以指定以何种拓扑结构来组装顶点,triangle-list 用于将每三个顶点组装成一个三角形,3n 个顶点最终会得到 n 个三角形;同理 line-list 就是将每两个顶点组装成一条线段,共有五种图元拓扑,感兴趣的读者可以一一尝试。
第四步:启动渲染流水线
渲染流水线已经创建好了,着色器技术工人也已经在流水线上准备就绪,是时候给 gpu 下达启动渲染流水线的命令了。说人话 gpu 当然听不懂,所以我们需要将命令通过编码器 (commandEncoder) 翻译一下,又由于一个 GPU 中可能有多条渲染流水线,每条渲染流水线的配置(GPURenderPassDescriptor)都不相同,所以 GPU 又将每条渲染流水线交给渲染通道 (renderPass) 管理,渲染通道也是通过编码器(GPURenderPassEncoder)来获取渲染流水线的控制权(setPipeline),让渲染流水线开始绘制(draw),得到结果后停止渲染流水线(end),最后由 GPU 将得到的渲染结果显示到屏幕上(device.queue.submit)
function frame() {
// Sample is no longer the active page.
if (!pageState.active) return;
const commandEncoder = device.createCommandEncoder();
const textureView = context.getCurrentTexture().createView();
const renderPassDescriptor: GPURenderPassDescriptor = {
colorAttachments: [
{
view: textureView,
clearValue: { r: 0.0, g: 0.0, b: 0.0, a: 1.0 },
loadOp: 'clear',
storeOp: 'store',
},
],
};
const passEncoder = commandEncoder.beginRenderPass(renderPassDescriptor);
passEncoder.setPipeline(pipeline);
passEncoder.draw(3, 1, 0, 0);
passEncoder.end();
device.queue.submit([commandEncoder.finish()]);
}
frame();
上述代码中大多都是固定流程,值得关注的就是 渲染通道描述(GPURenderPassDescriptor),其中 colorAttachments 就是用于控制此次渲染结果的颜色输出的
- view:保存此次的渲染结果纹理的引用
- clearValue: 在渲染通道执行之前设置纹理的默认背景色
- loadOp: 在渲染通道执行之前设置加载方式
- storeOp:设置如何处理渲染结果
图形学小知识科普:
- 双缓冲:我们看到的屏幕上图像对应的是内存 / 显存中的一个二维数组,数组中的元素就是一个rgba颜色值,我们之所以能看到动画就是因为用于展示的二维数组中的元素在不断变换。为了不让用户看到绘制过程,我们需要创建两块内存地址,分别称为前台缓冲区和后台缓冲区,前台缓冲区既是当前屏幕上展示的内容,后台缓冲区则时 GPU 渲染流水线的输出地址(上述代码中的 view),所以后台缓冲区也被称为渲染目标(render target)。当后台缓冲区绘制完毕后,只需要交换一下前台缓冲区和后台缓冲区的内存地址,用户就可以看到最新的绘制完成的画面了,这个交换的过程称之为呈现(presenting)。前后台缓冲区不断交换就形成了交换链(swap chain),而一秒钟发生了多少次前后台缓冲区交换就是游戏玩家口中常说的 fps 了。
另一个可以设置的就是 draw 方法了
/**
* Draws primitives.
* See [[#rendering-operations]] for the detailed specification.
* @param vertexCount - The number of vertices to draw.
* @param instanceCount - The number of instances to draw.
* @param firstVertex - Offset into the vertex buffers, in vertices, to begin drawing from.
* @param firstInstance - First instance to draw.
*/
draw(
vertexCount: GPUSize32,
instanceCount?: GPUSize32,
firstVertex?: GPUSize32,
firstInstance?: GPUSize32
): undefined;
第五步:查看渲染结果
在示例的基础上玩一玩
- 在 pipeline 中的 primitive 配置中添加 cullMode 配置项并设置为 front 康康
primitive: {
topology: 'triangle-list',
cullMode: 'front',
},
可以看到,三角形已经看不到了。那是因为在三维世界中一个二维物体是有两个面的,一般而言,我们是看不到背面的,所以为了节约性能,开发者会开启背面剪裁,这样能大大提升渲染性能。那么如何决定一个面是前面还是背面呢?那就是通过三角形的顶点顺序,在 WebGPU 中规定逆时针排序的三个顶点组成的面为正面。在开启正面剪裁的基础上将顶点着色器中的第三个顶点和第二个顶点位置交换就将正面三角形变成背面三角形了,此时再回到浏览器就发现三角形又回来了。
- 修改背景色 & 画一个绿色的矩形
// main.ts
clearValue: { r: 1.0, g: 1.0, b: 0.0, a: 1.0 },
passEncoder.draw(6, 1, 0, 0);
// vertex shader
@vertex
fn main(
@builtin(vertex_index) VertexIndex : u32
) -> @builtin(position) vec4<f32> {
var pos = array<vec2<f32>, 6>(
vec2(-0.5, 0.5),
vec2(-0.5, -0.5),
vec2(0.5, -0.5),
vec2(0.5, -0.5),
vec2(0.5, 0.5),
vec2(-0.5, 0.5)
);
return vec4<f32>(pos[VertexIndex], 0.0, 1.0);
}
@fragment
fn main() -> @location(0) vec4<f32> {
return vec4(0.0, 0.0, 1.0, 1.0);
}
效果:
- 画一个三角形线框
// main.ts
primitive: {
topology: 'line-strip'
}
passEncoder.draw(4, 1, 0, 0);
// vertex shader
@vertex
fn main(
@builtin(vertex_index) VertexIndex : u32
) -> @builtin(position) vec4<f32> {
var pos = array<vec2<f32>, 4>(
vec2(0.0, 0.5),
vec2(-0.5, -0.5),
vec2(0.5, -0.5),
vec2(0.0, 0.5)
);
return vec4<f32>(pos[VertexIndex], 0.0, 1.0);
}
效果:
- 让这个绿色的线框动起来
思路很简单:就是通过传入一个 offsetX 变量来顶点着色器中控制三个顶点的最终形状,具体如何传入可以通过官方后续示例查看,或者等我的下一篇文章:)
struct Uniforms {
offsetX : f32,
}
@binding(0) @group(0) var<uniform> uniforms : Uniforms;
@vertex
fn main(
@builtin(vertex_index) VertexIndex : u32
) -> @builtin(position) vec4<f32> {
var pos = array<vec2<f32>, 4>(
vec2(0.0 + uniforms.offsetX, 0.5),
vec2(-0.5 + uniforms.offsetX, -0.5),
vec2(0.5 + uniforms.offsetX, -0.5),
vec2(0.0 + uniforms.offsetX, 0.5),
);
return vec4<f32>(pos[VertexIndex], 0.0, 1.0);
}