Skip to main content

性能优化

3D 应用的性能瓶颈通常不只在 Draw Call显存,还可能出现在 CPU 场景更新GPU 片元着色阴影 / 后处理资源加载链路大场景空间管理 上。

排查思路:

  1. 先判断是 CPU 卡 还是 GPU 卡
  2. 再判断是 顶点 / Draw Call 问题 还是 片元 / 过绘制问题
  3. 最后再做资源、场景结构和设备分级优化

下面这篇按 监控 → CPU → GPU → 资源 → 大场景 → 排查清单 的顺序整理,尽可能覆盖 Three.js / Web3D 项目里最常见的性能问题。

性能监控与调试手段

不能盲目优化,需要依赖数据说话:

  • Stats.js:最基础的性能面板,实时监控 FPS(帧率,须保持 60)和 MS(每帧渲染毫秒数,须保证在 16ms 以内),以及 MB(内存占用)。
  • renderer.info:Three.js 自带的渲染器信息对象。通过打印 renderer.info.render.calls 可以直接看到当前帧的 Draw Call 数量;通过 renderer.info.memory 可以查看当前驻留在显存中的几何体和纹理数量,是排查内存泄漏的利器。
  • Spector.js:强大的浏览器 WebGL 调试扩展。它可以截取 WebGL 的一帧,逐条列出底层的原生 Draw Call 指令、Shader 编译耗时以及绑定的 Buffer 状态,用于极深度的渲染管线调优。
  • Chrome Performance / DevTools:排查主线程卡顿、JS 热点函数、事件风暴、GC 停顿时必须用。很多 Web3D 项目不是 GPU 顶不住,而是 updateMatrixWorld、动画更新、Raycaster、React 重渲染把主线程拖慢了。

先判断:到底是 CPU 卡,还是 GPU 卡?

  • 办法 1:临时把 renderer.setPixelRatio(1),甚至降到 0.75。如果帧率明显回升,通常是 GPU 片元压力 或后处理太重。
  • 办法 2:临时隐藏一批物体,但保留主逻辑更新。如果帧率变化不大,往往是 CPU 场景更新 / 动画 / 交互逻辑 的问题。
  • 办法 3:临时关闭阴影、后处理、透明粒子。如果帧率立刻回升,问题多半在 GPU 渲染链路
  • 办法 4:临时关闭交互 / 拾取 / UI 联动。如果卡顿消失,问题多半在 CPU 高频逻辑

setPixelRatio 控制「画多细」 —— 实际渲染像素数 = canvas 逻辑尺寸 × pixelRatio

1. 减少 Draw Call 的核心方案

  • 几何体合并 (Geometry Merge):将多个材质相同的静态小物体通过 BufferGeometryUtils.mergeBufferGeometries 合并为一个大的 Geometry,只需 1 次 Draw Call。缺点是合并后无法单独控制单个物体的位移。
  • 实例化渲染 (InstancedMesh):如果场景中有成千上万个几何体和材质相同,但位置/缩放/颜色不同的物体(如森林中的树、草地、雨滴),必须使用 InstancedMesh。它通过一次 Draw Call 提交一个网格,并在 GPU 侧利用实例属性矩阵完成海量渲染,性能极高。
  • BatchedMesh (Three r161+):适合“材质相同,但几何体不完全相同”的批处理场景。它底层借助 MDI(Multi Draw Indirect)思想,把多个不同 geometry 批量提交,能在很多“几何体不一致、又想减少 Draw Call”的场景中替代传统 merge。
  • 材质/纹理复用:相同的材质(Material)和纹理(Texture)尽量在内存中只实例化一次,然后分配给不同的 Mesh,避免 GPU 频繁切换着色器状态。
  • 静态对象关闭矩阵自动更新:对于永远不动的物体,设置 mesh.matrixAutoUpdate = false,并在初始化后手动调用一次 mesh.updateMatrix()。这能减少每帧场景遍历时的矩阵计算成本,在大场景下收益稳定。
  • 利用 Shader 替代实体网格(数学绘图):对于大量简单的几何图案(如雷达箭头、光环、同心圆、波纹特效),不要让建模师建出真实的几何体(这会增加大量的顶点和 Draw Call)。最佳实践是只画一个简单的 Plane 平面或全屏 Quad,然后在 Fragment Shader(片元着色器)中利用数学公式(如基于 UV 坐标计算距离场 SDF)直接在 GPU 像素层面“画”出这些图形。这种方案是 0 多边形消耗,且天然支持无限放大不失真。

🔬 原理对比:Geometry Merge vs InstancedMesh vs BatchedMesh

这三者都能将 N 个物体合并,大幅减少 Draw Call,但适用场景和底层机制截然不同:

  • Geometry Merge (几何体合并):在 CPU 端将 N 个物体“烤”成 1 个包含海量顶点的巨型几何体。
    • 代价:显存暴增(O(N×顶点数));丧失独立控制权(无法单独移动或精准拾取,修改需重新合并)。
  • InstancedMesh (实例化):只存 1 份几何体数据,通过 instanceMatrix 携带 N 个变换矩阵,交由 GPU 批量绘制。
    • 优势:极省显存(O(顶点数+N));天然支持单实例动画(setMatrixAt)与精准拾取(返回 instanceId)。
    • 限制:所有实例必须是完全相同的几何体
  • BatchedMesh (批处理, r161+):底层利用 Multi-Draw 扩展,允许将不同的几何体打包进同一个大 Buffer 中批量绘制。
    • 优势:突破了实例化的“同几何体”限制;内置了单体视锥剔除(Frustum Culling)。

核心差异速查

维度Geometry Merge (合并)InstancedMesh (实例化)BatchedMesh (批处理)
几何体要求可不同几何体必须相同几何体可不同几何体
工作重心CPU 预计算顶点GPU 实时矩阵乘法GPU Multi-Draw 指令
独立动画/拾取❌ 不支持✅ 支持 (instanceId)✅ 支持 (batchId)
单体视锥剔除❌ 整体剔除❌ 默认整体剔除✅ 支持 (内置剔除逻辑)

⚠️ 注意:实例化"省"的是"同一份几何数据被重复上传 N 次",不是"顶点里的法线/UV 不需要了"。法线该有还得有,只是只存一份。另外 Three.js 默认的 InstancedMesh 包含“逐实例视锥剔除”(按整体包围盒算)。若需精细剔除海量相同物体,需自行分 Chunk;但如果几何体差异较大,直接上 BatchedMesh 是现代 Web3D 的更优解。

2. CPU 侧性能优化

  • 减少场景树遍历成本:层级过深、对象过多时,updateMatrixWorld() 会成为热区。能拍平层级就拍平,能减少节点数就减少节点数。
  • 静态对象冻结:静态物体关闭 matrixAutoUpdate,并避免每帧调用 lookAt()position.copy()rotation.set() 这类会触发矩阵脏更新的操作。
  • 高频交互节流:鼠标移动、拖拽、hover、框选等交互不要每个 DOM 事件都重算,建议做节流或帧同步(如只在 requestAnimationFrame 中处理)。
  • Raycaster 优化:默认 Raycaster 在大场景里是 CPU 串行遍历,数量一大就很重。静态复杂模型推荐配合 three-mesh-bvh,显著加速拾取与空间查询。
  • 动画更新收敛:骨骼动画、morph target、IK、粒子 CPU 更新都很贵。能 GPU 化的尽量 GPU 化,能降低更新频率的尽量降低。
  • 避免无意义 React 重渲染:在 React Three Fiber 或 React 包裹的 Three.js 项目里,很多卡顿来自组件级重复 render,而不是 WebGL 本身。复杂 3D 状态尽量脱离 React 高频状态流。

3. 渲染层面优化(可见性与主线程)

  • LOD (Level of Detail):根据相机距离动态切换模型精度。离得近展示高精度模型(几万面),离得远展示低精度模型(几百面)甚至贴图(Billboard),大幅降低 GPU 顶点计算量。
  • 视椎体剔除 (Frustum Culling):Three.js 默认开启(mesh.frustumCulled = true)。不在相机视野范围内的物体不会送给 GPU 渲染。但在包含海量对象的场景下,CPU 逐个计算包围盒是否在视椎体内也是负担,可以配合空间索引(如八叉树 Octree、BVH)做粗粒度剔除。
  • 离屏渲染 (OffscreenCanvas):配合 Web Worker,将渲染逻辑和 Three.js 引擎完全剥离到 Worker 线程中执行,避免阻塞主线程的 UI 交互。
  • 按块提交而不是整场景提交:对园区、地图、工厂、城市级场景,最佳实践不是“把所有对象一次性挂进 scene”,而是按 chunk / tile / sector 做分块管理。
  • UI 与 3D 解耦:复杂 UI 动画、图表、列表虚拟滚动如果和 Three 主线程抢资源,也会导致 3D 掉帧。场景渲染和 DOM 更新要尽量错峰。

4. GPU 侧性能优化(顶点、片元与过绘制)

  • 控制顶点量:高模、细分曲面、过高精度的地形网格会明显增加 vertex shader 压力。LOD、简模和实例化是第一优先级。
  • 控制片元量(Fill Rate):屏幕上被涂色的像素越多,GPU 片元阶段越重。全屏后处理、透明烟雾、体积光、大面积粒子都容易把 fill rate 打爆。
  • 限制 pixelRatio:移动端和 4K 屏设备千万别直接吃原生 DPR,常见做法是:
    renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));
    低端设备甚至可以动态降到 10.75
  • 减少过绘制 (Overdraw):半透明物体、粒子、玻璃、雾效会让同一个像素被重复绘制很多次。看起来“几何体不多”,实际 GPU 已经在同一片区域算了十几遍片元。
  • 简化 Shader:PBR、环境贴图、法线贴图、clearcoat、transmission、多个灯光、阴影、雾、后处理叠加后,fragment shader 会很重。低端设备要敢于关特性。
  • 能用 alphaTest 就别用 transparent:像树叶、围栏、草地这类“镂空贴图”场景,alphaTest 往往比真正的半透明混合更省。

5. 阴影、透明与后处理专项优化

  • 阴影是性能大户:开阴影不只是“多一张图”,而是会增加额外的 shadow pass。castShadow / receiveShadow 不要全开,只给关键物体开。
  • 控制阴影贴图分辨率light.shadow.mapSize 不是越大越好。1024 往往已经够用,2048/4096 很容易让 GPU 吃不消。
  • 优先静态烘焙:静态建筑、地面、室内环境优先烘焙光照 / AO / 阴影贴图,把实时阴影留给少数关键动态角色。
  • 透明材质谨慎使用transparent = true 会带来排序、混合和 overdraw 成本。UI 面板、玻璃、粒子要重点审查。
  • 后处理做分级:Bloom、SSAO、DOF、TAA 都很贵。不要默认全开,应该按设备性能决定开启哪些 pass。
  • Pass 数量越多,RenderTarget 切换越多:后处理链不是“加几个效果而已”,每多一个 pass 都意味着一次完整的全屏采样和写回。

6. 模型与资源优化

  • 纹理压缩:使用 KTX2 / Basis 格式的 GPU 压缩纹理。普通 JPG/PNG 在解析后会解压成庞大的位图占用显存,而压缩纹理可以直接以压缩状态保留在显存中供 GPU 读取。
  • 模型压缩:使用 Draco 算法压缩 GLTF/GLB 模型,可将体积缩减 70% 以上(利用了 WebAssembly 进行前端极速解码)。
  • 纹理尺寸控制:比“用什么格式”更常见的问题是“分辨率滥用”。2K 贴图能解决的问题就别上 4K,远处小物体完全没必要用超大贴图。
  • 合理使用 mipmap:绝大多数可缩放纹理都应使用 mipmap,能减少远处闪烁和采样成本。
  • 贴图集 / Atlas:大量小图标、小标签、小精灵如果每张都是独立纹理,会导致频繁状态切换。合并成 atlas 通常更稳。
  • 环境贴图控制成本:HDR / PMREM 对质感帮助很大,但也会提高显存和采样压力。低端设备可以使用更低分辨率环境图。
  • 加载链路优化:首屏不要一次性加载完整场景。优先加载首屏可见内容,其他模型和纹理按需、分批、懒加载。

7. 内存与对象管理

Three.js 不会自动清理 GPU 显存!当你从场景中 scene.remove(mesh) 时,它的几何体和材质依然驻留在显存中。

  • 及时 dispose():必须显式调用 geometry.dispose()material.dispose()texture.dispose() 才能彻底释放 WebGL 资源,否则会导致 OOM (Out Of Memory) 崩溃。
  • 对象池 (Object Pool):在需要频繁创建和销毁物体的场景(如射击游戏的子弹、下雨特效的雨滴)中,频繁调用 new THREE.Mesh()dispose() 会引发严重的 JS 垃圾回收(GC)停顿(Jank),导致画面卡顿。最佳实践是初始化时预先创建固定数量的对象放入“池”中,需要时取出显示,不需要时隐藏(visible = false),循环复用。
  • 共享 Geometry / Material:不要给每个 Mesh 都 new BoxGeometry() / new MeshStandardMaterial()。能共享就共享,否则 JS 内存和 GPU 资源都会爆炸。
  • 关注纹理生命周期:很多项目以为“只是切换了一张图片”,其实旧纹理一直没释放,久了就会显存泄漏。
  • 大对象引用链排查:调试内存泄漏时,除了 WebGL 资源本身,也要关注 JS 层闭包、缓存 Map、事件监听是否还引用着已卸载场景。

8. 高清文本渲染优化

  • SDF (Signed Distance Field) 文字:在 3D 场景中渲染海量文字标签时,传统做法是用 Canvas 2D 绘制文字并转成贴图(Texture)。这种做法不仅极度消耗显存,而且放大后边缘会严重模糊(马赛克)。SDF 算法通过记录像素到文字边缘的距离,配合片元着色器(Shader)进行插值,能够以极低的纹理分辨率渲染出无限放大不失真的锐利文字。在 Three.js 中,大厂通常推荐使用 troika-three-text 库来实现工业级的 SDF 文字渲染。

9. 大场景 Web3D 的专项优化

  • 空间分块 (Chunk / Tile / Sector):地图、园区、工厂、城市级项目的核心不是“优化单个 mesh”,而是“别把所有东西同时挂进场景”。
  • 动态加载 / 卸载:根据相机位置、朝向、层级,只加载当前区域附近的资源,离开后及时卸载。
  • 粗粒度剔除优先:先按块剔除,再进入块内做更细粒度的 frustum / BVH / instance 处理。不要直接对 10 万对象逐个判断。
  • 远处替身 (Impostor / Billboard):远景建筑、树林、标牌没必要保持真实网格,用 billboard 或 baked impostor 往往更划算。
  • 编辑器场景与运行时场景分离:编辑器里可以保留大量辅助 gizmo、helper、包围盒;运行时必须剔掉这些调试对象。

10. 设备分级与自适应降级

  • 分级策略比“统一最高画质”更现实:高端机可以开阴影 + Bloom + 高 DPR,低端机应自动关闭阴影、降低贴图、降低后处理。
  • 动态调节 pixelRatio:连续多帧低于目标 FPS 时,自动把 pixelRatio2 → 1.5 → 1,这是最有效的保命策略之一。
  • 按能力开特性:可以根据 renderer.capabilities、设备类型、实时 FPS 决定是否开启抗锯齿、阴影、SSAO、透明粒子。
  • 不要迷信桌面端:很多高分屏办公本和核显机器,Three.js 实际性能远没有你想的那么宽裕。

11. 实战排查清单

当你遇到“场景很卡”时,按这个顺序排查最稳:

  1. Stats.jsrenderer.info,先确认是 FPS、Draw Call、纹理还是几何体数量异常
  2. pixelRatio 降到 1,看帧率是否明显恢复,判断 GPU 片元压力
  3. 关闭阴影、Bloom、SSAO、透明粒子,看是不是渲染链路太重
  4. 暂时关闭 Raycaster、hover、拖拽、动画更新,看是不是 CPU 主线程瓶颈
  5. 检查是否存在海量小 Mesh、重复材质、重复纹理、未 dispose 资源
  6. 检查是否把整个大场景一次性塞进 scene,而没有 chunk / tile / LOD / 流式加载
  7. 检查是否所有设备都强制用了相同画质参数,而没有做分级降级