最近发现我的场景容易出现卡顿的现象,这让很困惑,虽然数据量挺大,但也不至于那么大,而且我发现的奇怪现象是,GPU 的使用率非常低,倒是 CPU 使用了上升的比较快。
诊断
性能原因诊断:如果出现性能问题,你需要检查是 GPU 还是 CPU 瓶颈,使用 scene.overrideMaterial 覆盖所有的材质,如果性能提升了,那就是 GPU 问题,否则是 CPU 问题
Performance can mean many things, and a win in one area might sometimes mean a loss in others.
清单
影响性能的关键是三角面的数量,以及一些效果的计算量,性能优化清单
- 关注内存释放:对象及模型删除时,不光要从场景中移除,还需要手动释放资源(包括材质及 child 级联的资源)
- 确定不需要的,记得 dispose
- 后续可能加回来的,通过 object.visible、material.opacity、light.intensity 控制
- 减少 draw calls
- MergedGeometry,缺点:会失去对单个模型的控制
- InstancedMesh,使用实例化对象渲染大量具有相同几何体与材质,但具有不同世界变换的物体,帮助你减少 draw call 的数量,提升整体性能,缺点:会失去对单个模型的控制
- 模型轻量化
- 减面:如减少曲线、曲面的使用
- 合并几何体,纹理资源合并
- 模型及资源压缩:选取压缩比较高的模型格式,纯字符编码开启 gzip 压缩
- 轻量化展示:视锥体剔除(three.js 默认开启),遮挡剔除(three.js 不支持),背面剔除(three.js 默认实现)
- 多重 LOD:越远模型越粗糙,越近越精细,通常情况下,你会创建多个几何体,比如说三个,一个距离很远(低细节),一个距离适中(中等细节),还有一个距离非常近(高质量)。
- 批量绘制
- 少用性能消耗大方案
- 反射真实的环境,改为 envMap,做个假的环境贴图
- 阴影等,如果无必要,不添加
- 粒子群进行转换,而不是每个粒子
- 纹理图片尺寸一定得是 2 的幂次方,并尽可能的小
- 使用自定义 ShaderMaterial,多用 GPU 进行计算
- 使用代价较低的材质:StandardMaterial > PhongMaterial > LambertMaterial > BasicMaterial
- 使用少的光源,同时避免动态增减光源,这会导致 Renderer 重编译 shader programs,推荐使用过 light.visible = false 或 light.intensity = 0;点光源相比其他阴影类型更加昂贵
- 关闭抗锯齿:内置的抗锯齿,在使用了 post-processing 会失效(at least in WebGL 1),你需要使用 FXAA 或 SMAA
- 使视窗尽可能小
- 其他
- 复用 geometries, materials, textures 等
- 仅在需要的时候 render
- 通过 Layers 优化显示、隐藏、移除、添加操作?
- 对于静态对象或很少移动的对象,设置 matrixAutoUpdate 为 false,需要时再手动调用 object.updateMatrix
- 透明对象是很慢的,尽可能使用少的透明对象,或使用 alphatest,而不是标准的透明度,会更快一点
- 使用缓存缓存模型:IndexDB
- 启用 renderer.powerPreference 为 high-performance 属性
- 每个后置处理器需要重新渲染整个场景,考虑是否可以组合需要的处理器进一个自定义的处理器,会获得很好的性能提升
- 非必要不要开启 preserveDrawingBuffer
- WebGL 有两个缓冲区,一个用于绘制,一个用于显示,当你需要绘制时,你有两个选型
- 拷贝 drawing buffer 到 display buffer:这回涉及到缓存的拷贝操作
- 交换两个 buffer:这个操作时即时的,preserveDrawingBuffer 设置为 false 时,表示直接交换即可
- Web Workers
- 开销较大的数据处理或计算任务放到 worker 中
- 用线程池可减少 worker 频繁创建
- 超大模型拆分为多份数据,分包发送接收,分包进行解压,通过索引序列保证顺序
- 大的场景采取分包流式加载,加载解析完成后丢到主线程进行渲染,类似地图的分片加载
- Worker 数量:navigator.hardwareConcurrency 表示机器最多可并行执行的任务数量,因此 Worker 不大于这个即可
- 通信开销:默认通过结构化克隆完成,数据量较大时比较耗时,不大时可以通过 JSON.stringify/parse,此外 ArrayBuffer、ImageBitMap 支持按照引用传递,不会有克隆过程
- 离屏渲染
- OffscreenCanvas 是一个全局的 Canvas 对象,在窗口环境和 worker 环境均有效
- Transfer:在 Worker 线程创建一个 OffscreenCanvas 做后台渲染,再把渲染好的缓存区 Transfer 回主线程显示
- Commit:主线程中当前 DOM 数中的 Canvas 元素产生一个 OffscreenCanvas,发送给 Worker 线程进行渲染,渲染结构直接 Commit 到浏览器 Display Compositor 输出到当前窗口,相当于 Worker 线程直接更新 Canvas 元素的内容
- WebAssembly
- GPU 拾取
- Raycaster:采用包围盒过滤,计算光线与每个三角面是否相交实现
- GPU 拾取:创建选取材质,创建一个与渲染呈现的 Mesh 位置相同的 pickingMesh,将场景中每个 pickingMesh 模型的材质替换成不同的颜色,读取鼠标位置像素颜色,根据颜色判断是否位置的物体
- 官方示例:gpu picking
原因
一顿研究后,找到了具体的原因,数据中有很多的单线段,导致我创建了巨多的 Line 对象,这就是为啥 GPU 利用率低的主要原因。
为什么 GPU 利用率低,CPU 利用很高
- 原因就是太多的 draw calls,draw calls 是 CPU 发送给 GPU 的命令,当你发起一次 draw call 时,CPU 需要准备渲染状态,比如分配内存、绑定缓冲区等
- 尽管 GPU 渲染很快,但 CPU 在处理 GPU 的绘制调用时很慢,从而导致 CPU 利用率高,GPU 不高的现象,缓慢而陈旧的 CPU 在准备和发送数据时限制了 GPU,CPU 不能足够快地向 GPU 提供信息,这主要是 CPU 的瓶颈
- 使 draw call 昂贵的不是数据量,让绘制调用变得昂贵的是 GPU 命令的准备,即 CPU 到 GPU 的通信,无论你有一个三角形还是一千个三角形,绘制调用都需要这个开销
- 合并 geometry 就是从减少 draw call 角度出发,当然针对相同的 geometry,最佳的方式是使用 InstancedGeometry,它节省内存、减少发送给 GPU 的重复数据
我的屌丝电脑性能测试,创建很多球体做性能测试,简单记录下低于 60fps 情况
- 使用球体时,达到 7000 会导致低于 60fps,6000 时还能稳定在 60fps
- 使用立方体,到到 10000 会导致低于 60fps
针对大量线段的优化
- 使用 merge 的方式可以合并面和点,但针对线(Line)会导致首尾相连接,那么如何提高渲染大量不连接且顶点数量不同的线段呢
- 这时候就是 LineSegment 发挥作用的时候了,将所有线段通过每两个点一条线的方式表示出来,缺点是数据结构不太符合常规,需要处理一下,可以使用 index 复用顶点
相关资料
- 不错的分享资料:Web3D在可视化项目开发中的探索
- 教你手把手写 ShaderMaterial 和 InstancedGeometry:Rendering 100k spheres, instantiating and draw calls
- 使用 InstancedMesh 例子并且演示拾取问题:InstancedMesh
- 对比了原生、merged、以及 instance 三种方式 webgl_instancing_performance