webGl作为一个带web前缀的“鸡肋”前端技术, 一直不温不火(希望作为实验特性的webGPU对GPU利用效率翻天覆地的优化能改变这一现状), 因此, 网路上的相关资源、教程也比较有局限性, 作为一个初学者, 本着不重复生产垃圾的的原则, 在这里与大伙分享一些网路上难以搜到/大部分教程不会深入的坑/奇淫巧技.
大伙轻点喷,友善讨论🤗🤗🤗
之前写的 一个初学者的Three.js踩坑分享 (1) 居然还是有些人看过并点赞/评论的, 倍加感动, 故继续写 (2).
还是性能优化
web3D的性能优化主要可以分几个方向: 加载时的性能优化(通过gltf/darco等压缩模型文件体积), 及渲染过程的性能优化.
渲染时的性能优化对于任何想把场景做大做细一点的人都是非常重要的. 毕竟WebGl性能其实比较一般, 基本让一个70MB左右的OBJ文件完完全全显示在屏幕里, 即使使用几乎不作恶的 Google 开发的拥有极致优化性能优化的 V8 引擎, 也可以观察到应用的帧率大幅下降, 帧率警犬出动!
本文将分享我自己踩过的, 渲染时性能优化的坑.
0. Drawcall 与 Overdraw
0.0 渲染管线
渲染管线,其实就是图形的渲染过程。 渲染流程大体可以分为三个阶段, 应用 -> 几何 -> 光栅化:

应用阶段
顾名思义, 该阶段是应用驱动的(也就是直接跑我们在js里写的各种计算), 例如碰撞检测、全局加速算法、物理模拟、光追、剔除...等. 这个阶段主要是由 CPU 与内存来负责计算工作, 计算完毕后的顶点坐标、法向量、纹理坐标、纹理数据就会通过总线传给 GPU,在几何阶段进行下一步处理.

几何阶段
几何阶段负责每个多边形和每个顶点的操作,通常这个阶段可以分成以下几个功能阶段:模型变换 —> 顶点着色器 -> 投影 -> 裁剪 -> 屏幕映射。
模型变化:每个模型都有它自己的坐标系,我们要把模型放到时间坐标系去中,就需要通过模型变换。每个模型都有一个与之相关的模型变换,通过这个模型变换可以把这个模型放在世界坐标系中合适的位置。
视觉变换:在场景中的所有模型,都必须在相机的可视范围内才会变渲染出来,而这个相机也是放置在世界坐标系中的某个方位。为了方便后面裁剪和投影的计算,我们需要把世界坐标系变换成相机坐标系,也就是变换世界坐标系使相机处在坐标系的原点,X轴指向右边,Y轴指向上方,朝向-Z轴(有些可能使用+Z轴)。而这个过程是通过视觉变换来完成的。
顶点着色器:决定光线在某个材质上产生的效果的这个操作叫做shading。它涉及在不同的顶点上计算shading方程,结果将用于后面的光栅化。
投影:把可视空间(view volume)变换到单位立方体中,这个立方体的坐标范围在(-1,-1,-1)到(1,1,1)之间,这个立方体叫做正规可视化空间(canonical view volume)。包括两种投影,正交投影和透视投影。投影后的坐标系称作标准设备坐标系(normalized device coordinates)。尽管这个过程是从一个空间变换到另一个空间,但这里仍用投影这个术语是因为经过显示后z坐标不在存储在图像中(而是保存在Z-buffer中)。从这个角度上看,这是一个从三维到二维的变换。这里指的是经过显示后的坐标,但其实投影后坐标还是三维的。
裁剪:只有全部或者部分基本图元的顶点出现在可视空间内才会被传到光栅化阶段,然后显示在屏幕上。一个图元的全部顶点都在可视空间内时,这个图元会直接被传到光栅化阶段,如果全部顶点都不在可视空间内时,这个图元就会直接被丢弃,因此只有那些部分顶点在可视空间的图元才需要进行裁剪。不像其他可编程的阶段,大部分GPU支持硬件裁剪运算.
屏幕映射:经过这个阶段后坐标将从三维变成二维,这里的二维坐标将变成屏幕上的坐标。在DX10 之前每个像素的中心坐标为“0.0”形式,DX10以后以及OpenGL每个像素的中心坐标为“0.5”形式。另外,OpenGL以左下角为原点,DX以右上角为原点。
光栅化阶段
上文的几何阶段中, 模型数据经过各种矩阵变换后, 终于被转换成了屏幕坐标. 但这时又出现了一个大问题, 我们计算出来的线和面 (屏幕是二维的, 所以没有体) 都是连续的, 而屏幕不是, 屏幕上的每个像素都是独立/离散的. 而光栅化, 就是用来计算各个像素上的颜色的. 它可以分为: 三角形设置——三角形遍历——像素shading——合并 三个阶段
在图形学中广泛运用三角形来表示一个物体,之所以用三角形,有如下原因:
1. 三角形作为**最简单的多边形(顶点数量最少的多边形)** 有着很强的**表现能力**;
2. **三角形是所有多边形的基础图形**,任何多边形都可拆解成不同的三角形组合;
3. 只要给定三个顶点,就能确定唯一的一个三角形,且三角形必然是唯一的一个**平面**(这里不讨论曲面),**三个顶点确定唯一三角形,三角形确定唯一平面**;
4. 三角形**内外定义明确**,因为三角形**一定是凸多边形**,向量叉乘可以确定某个点对于三角形的内外情况(凹多边形无法用简单的向量叉乘判断点在多边形的内外情况,需要使用诸如 射线算法 等其它方法来判断);
5. 确定了三角形三个顶点的属性后,可以在其内部做一种渐变处理,依据三角形内的点距离三个顶点不同距离的比值来获得三个顶点不同比重的属性,产生一种从一个顶点逐渐变化到另一个顶点的过程,即内部顶点如何插值,本质上是**求三角形重心坐标的插值**。
三角形设置
三角形遍历:这个阶段用于检查每个像素是三角形和三角形的包含关系,fragment在这个阶段产生.
像素shading:使用插值shading数据作为输入,输出一个或者多个颜色值用于下一阶段使用。这个阶段是可以通过编程来控制GPU执行,在这个阶段最常用的技术是纹理技术。
合并: 在大部分场景中, 屏幕上不太可能没有三角形重叠, 当带有透明度的三角形重叠时, 就肯定要对对应的像素进行额外计算. 所以每个像素的颜色值存储在颜色缓存(color buffer)中,并在这个阶段进行颜色合并。这个阶段会通过Z-buffer负责解决能见度(visibility)问题。除了color buffer和Z-buffer外,还有其他的一些buffer也在这个阶段产生作用,比如alpha通道,用于执行alpha test,stencil buffer,用于记录已渲染图元的位置。
渲染管线主要包括两个功能:一是将物体 3D 坐标转变为屏幕空间 2D 坐标,二是为屏幕每个像素点进行着色,渲染管线的一般流程如下图所示,分别是:顶点处理、裁剪和图元组装、光栅化、处理: 顶点着色器(Vertex Shader)、片元着色器(Fragment Shader)是我们编写 Shader最常用的二个。另外二个曲面细分着色器(Tessellation Shader)、几何着色器(Geometry Shader)都是可选着色器。
· 顶点着色器, 将顶点世界坐标转化为屏幕坐标
· 图元装配, 指根据顶点数据和绘制方式结合成图像信息
· 光栅化: 利用图元计算出(转化为)为屏幕上每个像素点
· 片元着色器, 操作纹理, 颜色, 雾面效果等(给像素上色)
DrawCall 也是一种 CALL (调用), 即 CPU 通过 API 对 GPU 进行CALL(调用)的过程, 比如 glDraw.... 为了CPU和GPU可以并行工作,就需要一个命令缓冲区(Command Buffer). 命令缓冲区包含了一个命令队列,由CPU向其中添加命令,而由GPU从中读取命令。添加和读取的过程是相互独立的,因此命令缓冲区可以使CPU和GPU相互独立工作。当CPU需要渲染一些对象时,它可以向命令缓冲区添加命令,而GPU完成了上一次的渲染任务后,它就可以从命令队列里取出一个命令并执行它。
1. 尽量不要透明捏! 🤗🤗🤗
哎呀这不透明材质吗, 还是看看远方的 overDraw 吧
当然, 整个渲染顺序, 各种 alphatest alphablend 的内容太过复杂, 我作为图形学小菜鸡也学得不深, 无法详细解释, 只能图一乐
overDraw从字面意思上看, 就是重复/过度渲染, 就是指在同一帧中, 对同一个像素的多次绘制的次数. openGl / webGl 中, 会先绘制不透明的数据, 然后再对半透明的物体进行绘制, 依次将其与其后面(更深)的物体混合, 进行计算/绘制 详细渲染顺序可见: 图形学|shader|用一篇文章理解半透明渲染、透明度测试和混合、提前深度测试并彻底理清渲染顺序。 - 知乎 (zhihu.com)
总之就是, 尽量减少透明材质的使用, 以此减少渲染次数, 可以大幅提升性能.
2. LOD及其使用注意事项
LOD(Level of Details)技术指的是将场景中的模型按不同精度分为 N 套,按照模型与相机的距离远近,动态切换模型的精度,距离相机较近的模型采用精细模型展示,而距离相机较远的模型使用较为粗糙的模型进行展示。对于大的场景,使用 LOD 技术也是一个有效提升帧率的手段,可以有效减少整个场景中的渲染三角面数。
使用 LOD 技术一个非常重要的问题是如何生成多套不同精度的模型。一般来说有一下几个方案:
一是在建模软件中,使用减面工具,直接生成多套模型,这部分一般是美术建模人员来完成。虽然程序少了很多事儿,但是通用性较差,自动化程度较低。
二是选用开源的减面方案进行程序减面。一般来说可以选取一些 QEM 算法减面,使用程序进行减面。但是一般这类算法都有一些局限,比如贴图问题,破面问题。需要程序不断调试,找到比较合适的参数。
Three.js官网的用例: Three.js LOD examples
不过在实际使用时, 对导入模型做LOD需要注意: three 自带的 LOD 库中, 对模型位置及模型-相机距离的计算过程只考虑了模型 Object3D 对象的 position 属性, 直接用其进行计算, 而并没有考虑到导入模型中 geometry 中所带的位置属性, 在使用时, 为矫正位置的偏差, 可以对源码进行hack来满足对导入模型位置计算的需求.
同时需要注意, 使用lod时需要对模型进行合理分组, 如果很多个模型塞到一起, 就会导致距离变化时, 会同时有太多模型变化, 视觉效果不是很好. 但如果分组分得很细, 视觉效果可能很好, 但是由于lod需要对每一组模型的位置进行计算, 可能需要消耗大量的cpu性能, 从而导致js性能降低.
3. 原合批处理
batch, 这个词在 vue/react 中也有, 在各种 MVxx 框架中, Batch Update 可以理解为将一段时间内对 model 的修改批量更新到 view 的机制, 以 React 为例, 通过 batch, 把类似的操作(例如多个setState)进行 batch , 统一放到 update queue 中, 等 transition close 以后再统一进行一次渲染, 性能不知高到哪里去了.
说回 webGL/three.js, webGL 中与上文对应的“渲染”过程为 DrawCall, 上文 0. Drawcall 与 Overdraw 中说过, 每次 drawcall 都有一个 CPU 与 GPU 通信, 调用 GPU 的 API 的过程, 而这个过程中 GPU 渲染mesh的速度其实是非常快的, 通常就会导致 GPU 很闲, 但是 CPU 为了准备很多个 drawCall, 把自己的运算能力或者bus的通信能力已经拉满了. 所以, 我们可以尽量把 drawcall 打包, 一次性让 gpu 进行大量的渲染, 从而减少cpu的准备与通信时间, 把时间都留给渲染.
首先是材质相同的进行 batch,材质不同的无法进行 batch。一个 batch 其实就是一个 drawcall,对应的其实一种材质,不同种材质效果需要使用不同的 shader 实现所以无法实现合批展示。
three.js中, 可以使用 geometry 的 merge() 进行合批操作
4. web worker的使用
如上文所说, 其实整个渲染过程, 就是 CPU GPU 对一堆数据的计算过程, 在上文所提的 应用阶段(碰撞检测、全局加速算法、物理模拟、光追、剔除、LOD中对位置的计算...等) 中, CPU上会进行大量的计算, 此时, 单线程的JS引擎便会被这些大量的计算阻塞, 也没法给 GPU 送数据了, 场景性能大幅下降. 这时候, 就可以让 webWorker 来对这些耗性能的操作进行计算. 由于转向了react, 于是并没有研究太多 webworker的使用, 而是直接用了 useWorker. 当然, 在 webpack 中如何使用 webWorker 的文章, 网上也很容易搜到, 此处就不再拾人牙慧了😥