WebGPU是一种新兴的Web标准,旨在为Web应用程序提供高性能的图形和计算功能。它是一种低级别的图形API,为开发人员提供了对现代GPU的直接访问,以实现更高效的图形渲染和通用计算。
WebGPU的设计目标是提供与现代图形API(如Vulkan和DirectX 12)类似的功能和性能,并且跨平台、可移植。它旨在解决现有Web图形API(如WebGL)的一些限制和性能瓶颈,并提供更好的控制权和更高的性能。
我是跟着 [Orillusion官方](https://www.bilibili.com/video/BV1uu411B7uq/?spm_id_from=333.337.search-card.all.click&vd_source=32b963024a0f192400a68b86871ed132)学习的,大家可以直接去看他们的视频。
示例代码地址
以下是WebGPU的一些关键特性和优势:
webgpu的工作流程大概是这样的:
废话不多说,我们来完成图形编程界的”Hello world“,来画一个三角形把。
通过 Navigator.gpu 属性获取 GPU 对象,然后通过 GPU.requestAdapter () 方法请求一个 GPUAdapter 对象,这个对象表示一个物理 GPU 和可用的驱动程序1。这个方法返回一个 Promise,如果成功,它会解析为一个 GPUAdapter 对象,你可以用它来获取 GPUDevice 对象,这个对象表示一个逻辑设备,你可以用它来访问 WebGPU 的所有功能1。你可以通过传递一个可选的设置对象来指定你想要的适配器类型,比如高性能或低功耗1。如果没有传递设置对象,设备会提供对默认适配器的访问,这通常对于大多数用途来说足够了。
const adapter = await navigator.gpu.requestAdapter();
if (!adapter) {
throw new Error();
}
显卡适配器(GPUAdapter)是一个对象,它表示一个物理 GPU 和可用的驱动程序。你需要它,因为不同的系统可能有不同的 GPU 类型和本地 GPU API,而 WebGPU 需要提供一个统一的接口来访问 GPU 的功能。这段代码是通过 Navigator.gpu 属性获取 GPU 对象,然后通过 GPU.requestAdapter () 方法请求一个显卡适配器对象,并等待它返回一个 Promise,如果成功,它会解析为一个 GPUAdapter 对象。
这段代码的意思是,通过 adapter.requestDevice () 方法请求一个 GPUDevice 对象,这个对象表示一个逻辑设备,你可以用它来访问 WebGPU 的所有功能¹。这个方法返回一个 Promise,如果成功,它会解析为一个 GPUDevice 对象²。你可以通过传递一个可选的描述符对象来指定你想要的设备的特性和限制²。如果没有传递描述符对象,设备会使用默认的特性和限制。
const device = await adapter.requestDevice();
GPUDevice 是一个对象,它表示一个逻辑设备,你可以用它来访问 WebGPU 的所有功能¹。它是通过 GPUAdapter.requestDevice () 方法从显卡适配器获取的²。你可以用它来创建渲染管线、计算管线、纹理、缓冲区、着色器模块等对象,以及发送渲染或计算命令到 GPU 队列³。你也可以用它来监听设备丢失或错误事件,以及销毁设备⁴。
const canvas = document.querySelector('#triangle') as HTMLCanvasElement;
const context = canvas?.getContext('webgpu') as GPUCanvasContext;
webgpu上下文的作用是,让 HTML 上的 canvas 元素,作为 WebGPU 中的一个纹理,与 WebGPU 进行渲染互动¹。你可以通过 canvas 元素的 getContext (‘webgpu’) 方法获取 webgpu 上下文对象,它是一个 GPUCanvasContext 类型的对象²。你可以用它来配置 canvas 的显示属性,以及将渲染结果输出到 canvas 上³。
通过 navigator.gpu.getPreferredCanvasFormat() 方法返回一个最佳的 canvas 纹理格式,用于在当前系统上显示 8 位深度,标准动态范围的内容。这个方法通常用于提供给 GPUCanvasContext.configure() 调用的最佳格式值。
const presentationFormat = navigator.gpu.getPreferredCanvasFormat();
context.configure({
device,
format: presentationFormat,
});
这是推荐的做法,因为如果你不使用最佳格式来配置 canvas 上下文,你可能会产生额外的开销,比如额外的纹理拷贝,这取决于平台。这个方法不需要参数,返回值是一个字符串,表示一个 canvas 纹理格式,可以是 rgba8unorm 或 bgra8unorm。
const vertexArray = new Float32Array([
0.0, 0.0, 0.0,
1.0, 0.0, 0.0,
0.0, 1.0, 0.0,
]);
const vertexBuffer = device.createBuffer({
size: vertexArray.byteLength,
usage: GPUBufferUsage.VERTEX | GPUBufferUsage.COPY_DST
});
device.queue.writeBuffer(vertexBuffer, 0, vertexArray);
let pipeline = device.createRenderPipeline({
layout: 'auto',
primitive: {
topology: 'triangle-list'
},
fragment: {
module: device.createShaderModule({code: fragmentShader}),
entryPoint: 'main',
targets: [
{
format: presentationFormat
}
]
},
vertex: {
module: device.createShaderModule({
code: vertexShader
}),
entryPoint: 'main',
buffers: [
{
arrayStride: 12,
attributes: [
{
shaderLocation: 0,
offset: 0,
format: 'float32x3'
}
]
}
]
}
});
layout
layout是一个GPUPipelineLayout对象,用于描述一个渲染管线的布局1。渲染管线的布局指定了渲染管线需要的资源,如缓冲区、纹理、采样器等,以及它们在着色器中的绑定方式。在这段代码中,layout被设置为’auto’,表示由WebGPU自动推断渲染管线的布局,而不需要显式地创建一个GPUPipelineLayout对象3。
fragment
fragment是一个GPUFragmentState对象,用于描述片元着色器的状态。片元着色器是用于计算每个像素的颜色的程序。在这段代码中,fragment对象有三个属性:
main
。vertex
vertex是一个GPUVertexState对象,用于描述顶点着色器的状态。顶点着色器是用于计算每个顶点的位置和属性的程序。在这段代码中,vertex对象有三个属性:
primitive
primitive是一个GPUPrimitiveState对象,用于描述图元的状态。图元是由顶点组成的基本图形,如点、线、三角形等。在这段代码中,primitive对象有一个属性:
@vertex
fn main(@location(0) pos:vec3)-> @builtin(position) vec4{
var pos2 = vec4(pos, 1.0);
pos2.x -= 0.2;
return pos2;
}
@fragment
fn main()->@location(0) vec4{
return vec4(1.0,0.0,0.0,1.0);
用阶段是在CPU中进行的,主要任务是准备好场景数据,设置好渲染状态,然后输出渲染图元,即为下一阶段提供所需的几何信息。什么是图元?图元是指渲染的基本图形,通俗来讲图元可以是顶点,线段,三角面等,复杂的图形可以通过渲染多个三角形来实现。
应用阶段可细分为3个子阶段
几何阶段是在GPU上进行的,主要任务是输出屏幕空间的顶点信息。几何阶段用于处理从上一阶段接收到的待绘制物体的几何数据(可以理解为Draw Call指向的图元列表),与每个渲染图元打交道,进行逐顶点,逐多边形的操作。几何阶段的一个重要任务就是把顶点坐标变换到屏幕空间中,再交给光栅化器进行处理。通过对输入的图元进行多步处理后,这一阶段将会输出屏幕空间的二维顶点坐标,每个顶点对应的深度值,着色等相关信息。
顶点着色器的处理单位是顶点,输入进来的每个顶点都会调用一次顶点着色器。顶点着色器本身不可以创建或者销毁任何顶点,而且无法得到顶点和顶点之间的关系,例如我们无法得知两个顶点是否属于同一个三角网格。但正因为这样的相互独立性,GPU可以利用本身的特性并行化处理每一个顶点,这意味着这一阶段的处理速度会很快。
顶点着色器完成的工作主要有:坐标变换和逐顶点光照。
顶点着色器必须进行顶点的坐标变换,需要时还可以计算和输出顶点的颜色。例如我们可能需要进行逐顶点的光照。坐标变换,就是对顶点的坐标进行某种变换。顶点着色器可以在这一步中改变顶点的位置,这在顶点动画中是非常有用的。无论我们在顶点着色器中怎样改变顶点的位置,一个基本的顶点着色器必须要完成的一个工作是,把顶点坐标从模型空间转换到齐次裁剪空间。
把顶点坐标转换到齐次裁剪空间后,接着通常再由硬件做透视除法,最终得到归一化的设备坐标(NDC)。
这一步输入的坐标仍然是三维坐标系下的坐标(范围在单位立方体内)。屏幕映射的任务是把每个图元的x和y坐标转换到屏幕坐标系下,这实际上是一个缩放的过程。屏幕坐标系是一个二维坐标系,它和我们用于显示画面的分辨率有很大关系
裁剪阶段的目的是将那些不在摄像机视野内的顶点裁减掉,并剔除某些三角图元的面片(面片通常是由一个一个更小的图元来构成的)。
这一阶段也是在GPU上执行的,将会使用上个阶段传递的数据来产生屏幕上的像素,并输出最终的图像。光栅化的任务主要是决定每个渲染图元中的哪些像素应该被绘制在屏幕上。它需要对上一个阶段得到的逐顶点数据(例如纹理坐标,顶点颜色等)进行插值,然后再进行逐像素处理。可以这样理解,几何阶段只是得到了图元顶点的相关信息,例如对于三角形图元,得到的就是三个顶点的坐标和颜色信息等。而光栅化阶段要做的就是根据这三个顶点,计算出这个三角形覆盖了哪些像素,并为这些像素通过插值计算出它们的颜色。
这个阶段会计算光栅化一个三角网格所需的信息。具体来说,上一个阶段输出的都是三角网格的顶点,但如果要得到整个三角网格对像素的覆盖情况,我们就必须计算每条边上的像素坐标。为了能够计算边界像素的坐标信息,我们就需要得到三角形边界的表示方式。这样一个计算三角网格表示数据的过程就叫做三角形设置。它的输出是为了给下一个阶段做准备。
三角形遍历阶段将会检查每个像素是否被一个三角网格所覆盖。如果被覆盖的话,就会生成一个片元。而这样一个找到哪些像素被三角网格覆盖的过程就是三角形遍历,这个阶段也被称为扫描变换。
三角形遍历阶段会根据上一个阶段的计算结果来判断一个三角网格覆盖了哪些像素,并使用三角网格3个顶点的顶点信息对整个覆盖区域的像素进行插值。像素和片元是一一对应的,每个像素都会生成一个片元,片元中的状态记录了对应像素的信息,是对三个顶点的信息进行插值得到的。
这一步的输出就是得到一个片元序列。需要注意的是一个片元并不是真正意义上的像素,而是包含了很多状态的集合,这些状态用于计算每个像素的最终颜色。这些状态包括了但不限于它的屏幕坐标,深度信息,以及其他从几何阶段输出的顶点信息,例如法线,纹理坐标等。
片元着色器用于实现逐片元的着色操作,输出是一个或者多个颜色值(即计算该片元对应像素的颜色,但不是最终颜色)。这一阶段可以完成很多重要的渲染技术,其中最重要的技术之一就是纹理采样。为了在片元着色器中进行纹理采样,我们通常会在顶点着色器阶段输出每个顶点对应的纹理坐标,然后经过光栅化阶段对三角网格的3个顶点对应的纹理坐标进行插值后,就可以得到其覆盖的片元的纹理坐标了。
根据上一步插值后的片元信息,片元着色器计算该片元的输出颜色
虽然片元着色器可以完成很多重要效果,但它的局限在于,它仅可以影响单个片元。也就是说,当执行片元着色器时,它不可以将自己的任何结果直接发送给它的邻居们。当然导数信息例外。
GPUCommandEncoder 是 WebGPU API 的一个接口,用于编码要发送给 GPU 的命令。
创建 GPUCommandEncoder 对象,用于编码要发送给GPU的命令。GPUCommandEncoder 对象是通过 device.createCommandEncoder() 方法创建的,它可以调用不同的方法来开始渲染或计算通道,清除缓冲区,复制数据,写入时间戳等。GPUCommandEncoder 对象与 GPUBuffer 对象的方法不同,它们是“缓冲”的,意味着它们会在某个时刻批量地发送给GPU。而 GPUBuffer 对象的方法是“非缓冲”的,意味着它们在被调用时就立即执行。
const commandEncoder = device.createCommandEncoder();
开始一个渲染通道,返回一个 GPURenderPassEncoder 对象,用于控制渲染过程。这段代码指定了一个颜色附件,它是一个 GPUTextureView 对象,用于表示要渲染到的纹理。这段代码还指定了清除值(r: 0.5, g: 0.5, b: 0.5, a: 0.0),加载操作(‘clear’)和存储操作(‘store’),分别表示在渲染开始时要清除纹理的颜色,以及在渲染结束时要保留纹理的颜色。
const renderPass = commandEncoder.beginRenderPass({
colorAttachments: [
{
view: context.getCurrentTexture().createView(),
clearValue: {r: 0.5, g: 0.5, b: 0.5, a: 0.0},
loadOp: 'clear',
storeOp: 'store'
}
]
});
在渲染通道中设置渲染管线,顶点缓冲区,绘制三角形,结束渲染通道,完成命令编码,生成命令缓冲区,并将命令缓冲区提交给设备队列,以便在GPU上执行。
renderPass.setPipeline(pipeline);
renderPass.setVertexBuffer(0, vertexBuffer);
renderPass.draw(3);
renderPass.end();
let commandBuffer = commandEncoder.finish();
device.queue.submit([commandBuffer]);
经过前面的学习,我们知道画三角形只需要指定三个顶点即可。并且我们的图元拓扑结构为 triangle-list
。因此我们直接在绘制一个三角形,让两个三角形长边重合即可。因此直接改变顶点数组即可,顺带也要修改 renderPass.draw(6);
由原本的3变为6,因为一共有六个点了现在:
const vertexArray = new Float32Array([
0.0, 0.0, 0.0,
1.0, 0.0, 0.0,
0.0, 1.0, 0.0,
1.0, 0.0, 0.0,
0.0, 1.0, 0.0,
1.0, 1.0, 1.0
]);
效果如下:
首先我们要知道,屏幕上显示的所有物体本质都是像素,我们可以认为这些像素组成的图形就是平面的,所谓的立体感不过是欺骗眼睛的来。就想画画的时候,只要满足近大远小的透视规则,就可以让画看起来很立体。
因此我们只要实现类似的效果即可,那么怎么实现呢,那就要介绍一下mvp矩阵了。
MVP分别是模型(Model)、观察(View)、投影(Projection)三个矩阵,它们用来将三维模型的顶点坐标从模型空间变换到屏幕空间。具体来说:
具体的公式可以参考以下:
模型矩阵:
M = T × R × S M = T \times R \times S M=T×R×S
观察矩阵:
V = [ R − R T × C O 1 ] V = \begin{bmatrix}R & -RT \times C \\ O & 1\end{bmatrix} V=[RO−RT×C1]
投影矩阵:
P = M 正交 × M 挤压 P = M_{正交} \times M_{挤压} P=M正交×M挤压
其中, T 是平移矩阵, R 是旋转矩阵, S 是缩放矩阵, C 是摄像机位置, M 正交 是正交投影或透视投影矩阵, M 挤压 是将视锥体挤压成立方体的矩阵。 其中,T 是平移矩阵,R是旋转矩阵,S是缩放矩阵,C是摄像机位置,M_{正交}是正交投影或透视投影矩阵,M_{挤压}是将视锥体挤压成立方体的矩阵。 其中,T是平移矩阵,R是旋转矩阵,S是缩放矩阵,C是摄像机位置,M正交是正交投影或透视投影矩阵,M挤压是将视锥体挤压成立方体的矩阵。
说人话就是,三维物体每个顶点肯定对应 x,y,z。M矩阵的作用就是判断这些顶点通过平移,旋转,缩放等变换之后的位置。V矩阵的作用就是根据你看的角度和距离的不通,这些顶点可能就在屏幕的不同的位置。比如正视一个人的时候那个人就在你眼睛画面的正中间,但是你用余光看的时候他就在你眼睛画面的最边上,相对于你的眼睛,那个人的位置就变了。而投影矩阵类似皮影戏的效果,物体的影子投射在幕布上,此时三维的物体就被平面化了。并且超过幕布的部分我们就不显示了。
这里使用 gl-matrix
库进行矩阵运算。
模型,观察矩阵如下:
const mvMatrix = mat4.create()
mat4.translate(mvMatrix, mvMatrix, vec3.fromValues(position.x, position.y, position.z))
mat4.rotateX(mvMatrix, mvMatrix, rotation.x);
mat4.rotateY(mvMatrix, mvMatrix, rotation.y);
mat4.rotateZ(mvMatrix, mvMatrix, rotation.z);
mat4.scale(mvMatrix, mvMatrix, vec3.fromValues(scale.x, scale.y, scale.z));
投影矩阵如下:
const projectMatrix = mat4.create();
mat4.perspective(projectMatrix, Math.PI / 4, size.width / size.height, 1, 100);
mvp矩阵如下:
const mvpMatrix = mat4.create();
mat4.multiply(mvpMatrix, projectMatrix, mvMatrix);
写如GPUBuffer中:
device.queue.writeBuffer(mvpMatrixBuffer, 0, mvpMatrix as Float32Array)
更改顶点着色器:
//通过绑定获取mvp矩阵
@group(0) @binding(1) var<uniform> mvp:mat4x4<f32>;
struct VertexOutput{
@builtin(position) position: vec4<f32>,
@location(0) fragPostion:vec4<f32>
}
@vertex
fn main(@location(0) pos:vec3<f32>)-> VertexOutput{
var out:VertexOutput;
// mvp矩阵乘以顶点位置,就可以得到对应变换后的坐标
out.position= mvp*vec4<f32>(pos,1.0);
// 偏远颜色根据坐标位置变化
out.fragPostion = vec4<f32>(pos,1.0);
return out;
}
效果如下:
在肉眼可见的未来WebGPU肯定会替代WebGL,目前各大主流的封装库比如three和babylon也都在积极兼容WebGL,甚至也出现了纯WebGPU实现的引擎比如Orillusion。我们学习WebGPU并不意味着一定用它来完成项目,更多的还是使用其封装库去实现。学习只是为了加深理解,知其所以然,亦或者做到对程序的更高程度的掌控,做到更好的特质化。
限于篇幅和能力,只能挑部分说,文中所有的代码我都在Github开源,大家可以自行下载运行查看效果。
关于WebGPU的计算着色器我打算单独写文章来谈谈了。
示例代码地址
一篇文章搞懂到底什么是渲染流水线
WebGPU小白入门