Init webgpu
- adapter表示GPU(device)的一个代理,是对webgpu的一种具体实现的抽象,我们可以通过它来读取对webgpu实现了哪些功能和参数;
- device则表示的是逻辑层面,是JS访问webgpu api的入口;
- 如果仅仅是只有compute shader执行,那么就无需申请canvas;
- 我们可以通过
console.log(adapter, device)
打印出adapter和device有哪些内容; - adapter分别主要有
features
和limits
两个属性,前者表示功能扩展,后者表示规格。
webgpu概述
webgpu初始化代码
1 | // initialize webgpu device & config canvas context |
Pipeline
- render pipeline用于绑定vertex shader 和 fragment shader;
- compute shader有什么特殊之处;
- pipeline和pass的关系;
- 切换pipeline是一个对性能影响很大的操作,应尽量避免。
RenderPipeline概述
webgpu创建pipeline代码
1 | // create a simple pipiline |
Buffer
- storage buffer(
shader可读可写
,唯独不能修改vertex buffer,因此只适合不需要修改vertex buffer的场景); - uniform buffer(
shader只读
); - uniform buffer中内存大小
需要对齐256 byte
; - uniform buffer内存描述:buffer起始地址+偏移量;
- webgpu中有两种通用的buffer,一种是uniform buffer,另一种是storage buffer,它两的主要区别是:uniform适合一般只读的小数据(最大值只有64KB),并且在shader里是只读的,不能更改;而storage可以非常大,webgpu目前可以最大支持到2GB,而且在shader代码里可以更改写入数据。
VertexBuffer Slot
vertexBuffer Slot的结构及使用如下图所示,其中location传递的主要是vertexbuffer这类的局部数据
,只能在vertex shader中访问并传出。
bindGroup传递的则是uniformbuffer这类的全局数据
,可以同时在同一pipeline中的vertex shader和fragment shader中访问。也可以被不同的pipeline访问。
VertexBuffer在webgpu的使用示例
1 | const vertex = new Float32Array([ |
Texture
- web中纹理数据的来源类型(.webp、canvas贴图和video);
- 纹理的采样方式;
- texture buffer与bindgroup之间的关系;
- UV坐标一般随着vertexBuffer传入shader中。
UV坐标
寻址模式
采样方式
临近采样策略(根据UV坐标找到原始图像中对应的坐标点,然后选取最近的一个点):边缘的颜色会比较明显,但误差也比较大,会造成比较明显的像素点的信息缺失,它的好处是策略简单,GPU的计算量很小,是一种性能优先的缩放采样方法,webgpu也是默认使用这种策略进行采样缩放的
。
线性采样策略(根据UV坐标找到原始图像中对应的坐标点,选择最近的4个像素点的颜色来做加权平均):
两者的区别:线性计算的过程更复杂一些,计算量也更大一些,但最后的效果相对比,更加符合原始图像,像素点的过度比较均匀,但也一定程度上弱化了边缘的界限。一般来讲,临近采样更适合边缘分割明显、颜色区域少的这种图像。而颜色丰富、过度较多的图像,一般采样线性采样的效果会更好一些。
webgpu使用texture
1 | // web中加载图片有以下两种方式(一般推荐方式1): |
web中原生支持的图片格式(推荐使用webp)
web中原生支持的video格式
BindGroup
- 理解bind group所起的作用;
- bind group和buffer之间的关系;
- 分析bind group对性能的影响;
- bindGroup可以用对不同的buffer资源进行组合,打包传递给pipeline;
- 目前webgpu中,bindGroup最多支持8个资源的组合。
bindGroup在webgpu中的使用
1 | // create a uniform group for color |
Light
- 光照简单原理:物体最终颜色 = 物体颜色 + 光的颜色(环境光+漫反射+镜面反射)
- shadow map:从光源的视角出发,计算物体的深度图
- 不同光线类型的实现:平行光、点光源、聚光灯
- 我们在计算光照时,除了光照本身的颜色和角度的因素,还可以通过不同的因子进行调节,也就是我们常说的材质,来模拟不同的光照效果。
同物体的光照参数
三种主要的光照模型
forward rendering
多个光源效果进行叠加计算,它的算法相对简单也很容易实现,但缺点也很明显,对各个光源重复的遍历效果比较低,尤其是对于拥有大量光源的场景。
shadow
一条光线从光源出发,会依次击中场景中的片元,比如下图中会击中盒子的p1、p2以及地板的p3,那么离光源最近的p1点就会被点亮,也就是渲染光线颜色。那么,后击中的片元,p2和p3就会认为处于阴影之中,我们忽略光源的计算就可以。所以阴影的理论计算过程,应该是从光源发射出大量的射线,然后,依次计算每条射线击中的片元位置,对比击中的片元和光源的距离,那么距离最近的点我们就计算光线,那么其他的点,我们就认为在阴影中,可以忽略光线的计算。但这个方法有一个大问题,计算一条射线和场景中成千上万个片元的交点,是一个极端消耗性能的过程,更别说我们要模拟成千上万条光线了,起码对于实时渲染来说是不太可取的。所以,我们一般会采取一个近似的处理,我们不用手动的去计算射线的交点。这里我们可以巧妙的利用一个GPU的重要绘图机制,也就是利用深度缓冲来帮助我们得到交点。
如果从光源的位置来渲染整个场景,我们会得到一张深度贴图,这个贴图里存储的深度值,其实就是离光源位置最近的点,也就是从光源发出的所有射线击中的第一个交点。我们可以通过查询深度贴图中的深度值来判断一个片元是否是最近的交点,也就是说它是否处在阴影之中。总结一下刚刚的过程:为了能快速找到最近的交点,我们可以渲染两次场景,第一次,我们先从光源的位置沿光线的方向渲染一次场景,只是为了得到深度信息,所有片元坐标的z值会被写入到一个深度贴图中。然后,我们再从真正的相机视角第二次渲染这个场景,我们可以对比每一个片元的深度值和第一次深度贴图中的大小。比如下图中的p点深度值是0.9,我们可以查询这个片元在深度贴图中对应坐标的z值大小,结果会发现在灯光的深度贴图中,这个位置保存的深度值是0.4(及下图中的C点),也就是说,这个点的深度大于贴图中的数值,证明这个片元不是光线的第一个交点,也就是处于阴影之中,那么我们就无需计算这个片元的光线,反之,我们就可以认为这是第一个交点,从而应该计算光线。
从光源位置的角度渲染时,不同类型的光源会使用不同的投影矩阵,比如平行光使用正交投影矩阵,而点光源则使用透视投影矩阵:
Compute shader
GPU并行性的理解
对于多线程模式,有一种特殊形态,就是如果一个任务刚好可以被拆分成n个独立的子任务,且每个任务的运算逻辑都一样,可以满足多个子任务同时计算的模式,那么我们称之为并行计算。它跟普通多线程计算最大的不同是,要求父级任务或者数据必须能平均拆分,且子任务的处理逻辑要一致,所有的子任务们相互独立,所以对数据和算法的要求是比较高的。我们实际的生活中很少遇到单纯的并行计算,大多是以串行和多线程模式为主。
并行执行过程中数据的处理(host与device之间)
实际上,CPU的主要优化方向,还是以单线程的串行能力为主,尽可能的以尽快的方式来完成单个的计算,而CPU的多线程模式其实主要负责的是分配和管理复杂的任务。通常每个线程会运行不同的任务独立执行,满足一般的系统多任务的需求就可以了,所以一般的民用CPU也就几个或者十几个核心,一些顶级的CPU最多也不超过128个多线程的能力。
而GPU则不同,它从一开始就是为并行计算而生的,通常所有核心是一起运行所有的任务,完成之后再运行下一个。所以它的优化方向一直是以数量取胜,不用考虑复杂的逻辑或者多任务的管理。专注于并行的需求就可以了,一般普通的GPU,也能满足上千个并行计算的需求,一些顶级的GPU甚至能同时运行数万个并行模块,而且由于GPU这种并行特性,它也很容易将多块GPU并行使用,可以很方便的扩展并行能力。GPU的优点虽然很明显,性能很强大,但GPU的框架本身,是很难进行复杂的串行任务逻辑的,所以仍然需要CPU来进行逻辑分配和任务管理。
所以我们再来理解一下,为什么渲染管线中分了这么多个步骤,首先,整个渲染管线从整体上来看是串行的,每个步骤相互依赖,里面有严格的顺序,没办法跳过或者同时执行,而这部分本质上是由CPU程序进行控制的,也就是系统底层的图形学API,只是它们开放了中间的两个步骤的逻辑,让用户可以按照固定的规则去编写shader,其他复杂的操作,都是由系统内部进行完成的,但中间的每一步,又是典型的并行计算,我们已经学过所谓的draw(),本质上就是开启了顶点数乘以instance数量的线程去运行vertex shader,然后经过顶点组装、光栅化等相关步骤,最终如果我们需要渲染多少个像素,图形学API会在系统底层帮我们再次开启多少个线程去运行fragment shader,以及后续的深度测试等环节,所以所谓渲染管线,本质上就是利用GPU的硬件特性,去并行的执行一套固定的流程,随着计算机科学的发展,人们对计算的复杂度和性能的要求越来越高,一些典型的并行计算场景,比如说大数据处理、模拟仿真、AI训练等等,单纯靠CPU或者传统的分布式计算框架已经很难满足我们的开发需求了,而GPU的硬件特性就开始凸显了。那么,我们能不能利用GPU的并行能力来弥补这一块的需求呢,而不是单纯的用GPU来做图形的渲染工作。当然是可以的,这本身也不是新鲜事,人们很早以前就开始用GPU来进行并行计算了,Native端已经有了很多成熟的GPU计算框架,但对于web来说,之前的webgl或者OpenGL框架,并不能很好的满足GPU通用计算的开发需求,一般只能使用传统的渲染管线去模拟计算数据。因为它的本质上是运行N个vertex shader或者fragment shader,所以我们是可以将计算逻辑放在vertex shader或者fragment shader中进行模拟的,但是这种模拟的限制非常多,很不灵活。渲染管线需要额外的去进行图形装配、深度测试、光栅化等相关的步骤,浪费了一部分CPU和GPU性能,并且管线和管线之间共享数据非常困难,一般需要借助贴图来进行操作,全局的同步状态管理欠缺灵活的内存共享方式,所以在webgl时代,web应用其实是很难充分的释放GPU的通用计算能力的,所以webgpu中一个很重要的特性,就是开放了现代图形API中的计算管线(compute pipeline),使得web开发者可以直接进行GPU的通用编程。
compute pipeline只关注两个点,一个是数据是什么
,一个是怎么处理
。我们可以自由安排数据处理的逻辑以及数据处理的流程。具体要使用多少个线程进行并行处理以及用多少个compute shader去计算,它们的顺序怎么安排都是交由开发者去处理。
数据被要求是可以平均分割且互相不影响的,这是并行计算的基础。在下面例子中,理想情况下,我们当然是在GPU中直接启动100个线程去运行main()会比较好,但是如果数量很多,系统的线程资源不可能无限多,这个时候,我们可能要手动调节线程的数量和运行的次数。比如,在webgpu中,我们可以一组只启动10个线程去运行函数,但是我们可以开10组运行,系统会根据GPU的空闲程度去调度10组线程的运行,如果GPU有空闲,那么就多个组一起运行,如果资源不够,我们就排队运行。最坏的情况就是一组一组的串行执行,但这种情况在逻辑上,我们可以认为它们两个的并行度是一样的,都是100个独立线程,只是实际执行过程中,它的启动时间可能不一样而已。
但是分组数和线程数也一样,是有限制的,不可能无限多。如果数量超过了系统的限制怎么办呢,比如说线程和分组数最大都是10,也就是说我们的最大并行度是100,这个时候如果要处理1000个数字该怎么办呢,有两种思路:第一种做法比较简单,我们可以在CPU的逻辑里,添加for循环调度,也就是串行调度10次流程,它的好处是算法颗粒度并不用变,那我们可以仍然保持计算的逻辑统一,缺点是靠CPU去执行for循环,本质上是串行执行多次GPU程序,每次要等待上一次的完成,才执行下一个,而且需要CPU和GPU的两者能进行频繁的通讯,一般来讲速度上就会慢一些,如果for循环次数比较多是不推荐的:
第二种方法,是我们可以保持GPU的线程次数,比如说我们就调度一次,启动100个线程去计算,那么我们就需要改变分组的数据颗粒度,那么,原始数据我们还是可以分为100组,但是数据的颗粒度从1变成10,也就是我们要在算法内部进行10次加法的循环操作,它的好处是GPU只调度了一次,减少了CPU和GPU的通讯时间,原则上,我们推荐CPU和GPU交互次数越少越好。但它的缺点是在并行逻辑里,套嵌了串行的算法,编程难度会有所增加,并行的逻辑也会更复杂一些。
第一种CPU for循环执行本质上还是串行的流程,只不过里面套嵌了并行的计算,第二种则是并行的流程套嵌了串行的计算。两者各有不同的适用场景。
compute pipeline
首先跟渲染图形一样,我们必须要获得webgpu的操作权限,也就是初始化webgpu,不过因为做通用计算,不需要渲染图形,所以这里只需要获取到device就可以了,不需要去配置canvas、像素格式、画布、大小等信息。比起webgl必须要通过canvas来进行配置要简单很多,更方便web开发者来使用gpu的能力,其次,跟渲染流程一样,无论使用什么管线,本质上都是告诉gpu去运行什么程序,以及程序所需要的资源。所以我们还需要创建一个pipeline,只不过对于compute pipeline来讲,有专门的api,这里使用device.createComputePipeline
,它和device.createRenderPipeline
一样,有同步和异步两个版本,我们依然推荐的是使用异步进行操作。相比较renderPipeline来说,computePipeline的配置简单很多,核心只有一个参数,就是告诉它运行哪个shader以及入口函数即可,如果没有特殊的资源布局,那我们使用默认的layout:auto,就可以完成一个computePipeline的初始化,不需要设置复杂的顶点插槽、图形、深度模板、颜色等配置信息,可以说是非常简单了。那么有了管线后,我们当然还要有数据,webgpu中有两种传递数据的方法,顶点插槽和bindGroup,buffer都是一样的,不一样的是computePipeline没有顶点插槽,也就是没有办法通过vertexBuffer来传递数据信息,所以computePipeline只能通过bindGroup来传递通用的uniform buffer和storage buffer,uniform buffer最大只支持64KB,并且在shader中是只读的。而storage buffer可以很大,而且在shader中是可读可写的。我们之前的渲染管线的例子中,没有地方用到storage可写的相关特性,一般都是从JS中直接更新的buffer,但对于Compute Pipeline来说,storage buffer的可写操作将变得尤为重要,因为在renderPipeline中,整个绘制流程是底层API帮我们处理好的,内部的数据传递也不需要我们操心,最终输出的结果是像素颜色,但在Compute shader中,我们需要自己去处理数据的操作流程,输入输出都必须要靠我们自己管理,不能只靠局部的临时数据,必须要有全局的内存保持才可以,所以在shader中直接写入buffer数据是一个非常重要的需求,所以storage buffer对于compute Pipeline来说更为重要,不仅因为它足够大,而且可以直接写入新数据,我们就可以将结果保存,后续传回JS,或者共享给其它pipeline进行使用,这样才方便我们安排整个的处理流程。
初始化compute pipeline代码
1 | async function initPipeline(device: GPUDevice, modelMatrix:Float32Array, projection:Float32Array){ |
提交compute command
1 | const commandEncoder = device.createCommandEncoder() |
dispatchWorkgroups解释
render pipeline中draw()的本质,就是一组并行运行多少个vertex shader,总共要运行多少组。compute pipeline本质也是并行计算,也是一次要运行多少个compute shader,总共要运行多少组,只不过compute pipeline中把这两个参数拆分到了两个地方,同时运行几个compute shader,我们写在了shader中,JS则是单独来控制总共要运行多少组。如果compute pipeline使用dispatch(group_size, group_count)这种形式,跟render pipeline统一的话,大家可能会更熟悉。不过为什么webgpu要把它们拆分开,当然这不是webgpu规定的,而是底层图形API的限制,为了优化compute shader的性能,底层的API需要shader编译的时候,就需要知道具体要运行多少个并行线程,所以我们牺牲了一些便携性,要提前在shader中来标注。同时要运行多少个shader,而不能在JS中来临时设置参数,这是compute pipeline语法上的第一个不同点,那么第二个不同点,为了满足计算的灵活性,compute pipeline将group_size和组数拆分成了xyz三个维度,简单来说,就是一次要启动Sx*Sy*Sz个线程,并且要启动Nx*Ny*Nz个组。也就是本质上我们要运行这么多次相乘的compute shader。估计刚接触的同学,遇到这个三维的设置会比较懵,不明白是什么意思,其实并不复杂,并行计算的最重要的核心是什么呢?就是拆分数据,本质上就是把数据平均分配到每个GPU的线程资源上,我们平时可能会更习惯按照一维的线性思维去切分数据,比如10万个数字,每一万个数字为一组,分10组这种通用的设置,但是比如图片数据,单纯从数据角度来看,当然它还是10万个连续的数字,但是因为图形本身是有这种二维平面坐标的,所以如果按照线性的一维去划分,反而不方便处理了,因此切分图像可能更适合按照二维的角度去划分,比如说用3*3的格子来进行切分整个图形,当然还有一些复杂的数据,可能会同时包含3个维度信息,比如说深度信息,那么这个时候,可能就更适合用三维的角度去拆分数据,所以webgpu或者图形学API在这里设计了3个维度去灵活的划分GPU的资源。
我们来举一个简单的例子来说明,比如我们要处理一个8*8 64个像素的图像,需要去拆分数据并划分线程,因为最小单位就是一个像素了,所以我们可以把图像来拆分成64个单独数据,然后启动64个线程去分别处理它,刚才说了图像从纯数据的角度看,你也可以看成是一个一个连续的数字,我们当然可以把图像当成一个一维数组进行拆分,但更常规的处理,我们是可以将图像的按照二维性质来进行划分,比如说我们这里用一个4*4的格子来处理它。相当于一次要去启动16个线程去运行compute shader,想要完全处理完整个图像,要运行多少组呢?当然可以分别在x方向上和y方向上各运行2次就可以完成图像的处理了,所以我们可以在compute shader 中设置@group_size(4,4)
,然后在JS中通过dispatchWorkgroups
在两个维度上分别启动2次。当然这两个参数是相互关联的,如果group_size越大,理论上启动的group的数量就会少一些,反之如果group_size小一些,group的数量可能就要大一些。那么有同学可能要问,在compute shader中我们该如何区分每个线程对应的数据或者像素坐标呢,我们在vertex shader中是如何区分处理的是第几个顶点和第几个物体的呢,相信大家还记得,webgpu在vertex入口函数中传入内置的@vertex_index
和@instance_idnex
两个参数分别用来表明当前线程正在处理的顶点index和物体的index。那么在compute shader中当然也有这样的参数,分别是@local_invocation_id
和global_invocation_id
,它们分别对应当前组中的index和整体数据中的index。比如说下图中的这个格子,按照(4,4)为1组来划分,它在自己本地组中的坐标是(1,1),但是从整体图像上来看它的@global_invocation_id
就是(5,5),那么,我们就可以利用这两个值在shader中很方便的定位当前线程正在处理的是哪个像素或者数据。
从compute pipeline中取回结果buffer
一般来说,我们并不能直接读取gpu中的内容,就像写入GPUBuffer一样,我们需要通过webgpu的API来进行cpu和gpu之间的通讯,才能完成读取或者写入的操作,之前我们已经学习过writeBuffer这个API,它可以由JS同步写入一个对应的GPUBuffer中。那么读取数据呢?是不是也有一个对应的readBuffer API来完成同步的读取操作呢?在现在图形API中,为来优化读写性能,尤其是读的性能,webgpu并没有提供直接读的操作,我们需要借助gpu的临时缓冲区,来完成cpu和gpu的交互数据。所谓缓冲区,简单说就是gpu可以共享给cpu一段公共的显存,可以同时被cpu和gpu操作,主要就是用来交换数据的,cpu想读取数据的话,可以先申请一个临时缓存,再通知gpu去把目标buffer copy到临时缓存内,然后再传回CPU中,这样就能保证gpu内部的程序进行高效的数据操作,不会被cpu的读写操作进行干扰。
1 | // papare a read buffer to map mvp back to js |
Draw
- 绘制前需对pass进行配置:
1
2
3
4
5
6
7
8
9
10// 对性能消耗最大:因为它涉及到切换整个vertx shader和fragmfragment shader,还有深度测试、图形组装、颜色混合等相关配置
passEncoder.setPipeline(pipeline);
// 其次消耗第二大的是setVertexBuffer,因为该接口会根据管线配置要去识别和转换顶点数据,shader内部还要对应的生成一些局部变量,频繁的切换一样会影响渲染效率
passEncoder.setVertexBuffer(0, vertexBuffer);
// 最后消耗最小的是setBindGroup,bindGroup本身作为外部或者全局变量的引用,对管线的配置没有影响,大多数情况下只是内存指针的拷贝或者重新定向而已,是一种效率很高的操作
passEncoder.setBindGroup(0, bindGroup);
passEncoder.draw(vertexCount); - 原则上绘制过程中应该尽量的减少切换GPU的状态,比如绘制多物体的时候,应该优先选择切换bindGroup,然后再来选择切换vertexBuffer,最后才会来选择切换pipeline。也就是说绘制一个管线的时候,最好先把符合管线相关的所有物体都画完,再切换下一个,不要来回的频繁切换。
- instance draw无法修改vertex buffer可以修改bindgroup,适合绘制同一模型顶点数据的时候,优先使用instance模式来绘制。
- 使用连续的buffer需要提供offset及对齐256byte,效率上要比离散的buffer高,但内存消耗更大。
- 组装一个renderPass需要绑定的资源如图所示:
Webgpu API结构全貌图
图元类型
draw(3)绘制三个顶点时:
line-list:表示每两个点组成一条线,因为这里只有三个点所以只能画一条线
line-strip:strip的意思是首位连接组合模式,即三个点中1&2可以形成一个组合,2&3可以形成一个组合
triangle-list:表示每三个点才能组成一个三角面
triangle-strip:当有4个点的时候绘制结果如下,意思是顶点123,234会分别形成一个三角面,后者会复用前者的后两个顶点。
wgsl
- uniform buffer是如何绑定进来的;
- 语法特点;
- cpu数据的读取。
typescript
- 记录常用语法
- 依赖库的使用
常用语句
obj?.fun()
:打问号表示的是先判断obj是否为空,然后再调用funasync/await
:await的作用是用来返回异步操作中的返回值console.log()
:打印日志export
:可以将一个ts中声明的变量对外提供给另一个ts使用- 如果变量名和参数名一致,可以不用写冒号,比如
device:device
可以直接简写为device
?raw
是vite的一个语法的环境特性,表示将文件当作是纯文本的形式引用进来比如:1
2// import是默认无法引入纯字符串的,它必须是一个JS或者TypeScript的文件有export的对象才可以。所以需要用!raw
import triangleVert from './shaders/triangle.vert.wgsl?raw'
webgpu注意事项
- 在webgpu中如果有异步的形式,我们尽量以异步形式去操作,可以尽量避免JS形成的挂起等待,能有效的降低CPU的负载。
- webgpu会采用一种叫commandEncoder的机制,把所有的命令提前都先写入到encoder中,然后再进行一次性的提交给Native运行。
- webgpu绘制流程:初始化->创建pipelien->录制commands->提交->异步绘制
- 使用requestAnimationFrame(frame)来绘制,帧率可以根据设备来动态刷新。