Three.js 性能优化全流程指南
从测量到定位再到针对性优化,构建可维护的系统性方法论
0. 核心原则:永远先测量,再优化
不基于数据的优化如同蒙眼射击。优化是一个"测量 → 定位 → 优化 → 验证"的闭环,每次只改动一处,立即验证效果,避免引入隐性代价或代码债务。
第一步:测量 ------ 建立性能基线
1.1 实时监控:帧率与帧时间
-
Stats.js
最常用的轻量面板,添加方式:javascriptconst stats = new Stats(); document.body.appendChild(stats.dom); // 动画循环中 stats.begin() / stats.end()关注 帧时间(ms) 而非仅 FPS,尤其在移动端 30fps 时更有意义。
-
Chrome 内置 FPS Meter
DevTools → Rendering → 勾选"FPS Meter",无需任何代码,真实反映合成帧率。 -
自定义 FPS 计数器
利用performance.now()计算,可绘制到角落 canvas,完全可控。
1.2 Three.js 内置渲染统计:renderer.info
每帧渲染后立即获取:
javascript
renderer.render(scene, camera);
const { calls, triangles, points, lines } = renderer.info.render;
- calls:draw call 数量。桌面端建议 < 1000,移动端 < 500。
- triangles:三角面总数。单 Mesh 超 50 万面需警惕。
- 进阶:可累积多帧计算平均值,识别周期性飙升。
1.3 Chrome DevTools Performance 录制
录制 3~5 秒渲染帧,关注:
- Main 线程火焰图 :查找耗时长的
traverse、updateMatrixWorld、sort调用。 - GPU 轨道:若 GPU 柱状图持续饱满,片段着色器或纹理采样是瓶颈。
- Frames 视图:红色掉帧块可放大查看该帧的任务组成。
- 高级 :在
chrome://tracing中启用webgl类别,查看 GPU 命令队列详情。
1.4 抓帧与诊断工具
- Spector.js
浏览器扩展,捕获一帧内所有 WebGL 命令、纹理、着色器、draw call。可直观看到几何体绑定次数、纹理切换、状态冗余。 - WebGL Inspector
类似工具,嵌入页面即可录制。 - Chrome Task Manager (Shift+Esc)
右键列勾选 "GPU Memory",实时观察显存占用,排查泄漏。
1.5 自定义微基准
在代码中插入 performance.mark/measure 拆分 render 阶段:
javascript
performance.mark('traverse-start');
// ... traverse
performance.mark('traverse-end');
performance.measure('Traverse', 'traverse-start', 'traverse-end');
在 Performance 面板中即可查看各阶段精确耗时。
⚠️ 测量时的常见坑:
- 开着 DevTools 测量会额外消耗性能,建议用 FPS Meter 叠加层做无干扰测量。
- 移动端必须真机测试,模拟器 GPU 行为与真实设备不同。
- 抓帧工具会拖慢渲染,仅用于定位,不用于看绝对值。
第二步:定位瓶颈 ------ 确定性能元凶
性能瓶颈通常分四类:CPU 端 、GPU 端 、内存/带宽 、GC 抖动。下面提供针对性的定位方法。
2.1 CPU 端瓶颈(JavaScript 执行)
典型症状 :draw call 多(>1000)、场景对象数量巨大、每帧有大量矩阵计算或对象创建。
定位方法:
renderer.info.render.calls直接判断 draw call 是否过高。- Performance 火焰图中
render函数内部traverse、sort耗时显著,说明场景图遍历开销大。 - 观察
updateMatrixWorld调用栈,若每帧重新计算大量矩阵,考虑关闭matrixAutoUpdate或展平层级。 - 利用微基准标记拆解帧内工作,定位具体耗时函数。
- 使用
console.count检查每帧新建Vector3、Quaternion等临时对象数量。
2.2 GPU 端瓶颈(填充率/着色器/顶点)
典型症状 :场景看似简单但高分辨率下卡顿、复杂材质下帧率骤降。
定位方法:
- 材质替换法 :将所有材质临时换成
MeshBasicMaterial({color: 0xffffff}),若帧率暴涨 → 瓶颈在片元着色器或纹理采样。 - 像素比切换法 :
renderer.setPixelRatio(0.5)若帧率几乎翻倍 → 填充率受限。 - 几何体简化测试:将所有物体替换为最简立方体,若帧率变化不大且 draw call 不变,则顶点处理不是瓶颈。
- Chrome DevTools → Performance → 勾选 "Advanced paint instrumentation" 后录制,查看 "Layers" 面板的绘制开销。
- 使用 Spector.js 观察单帧绘制调用,定位耗时 draw call 对应的高面数网格或复杂着色器。
2.3 内存/带宽瓶颈
典型症状 :纹理加载延迟、画面突然出现黑块或闪烁、长时间运行后崩溃或降帧。
定位方法:
- Chrome Task Manager 观察 GPU Memory 是否持续攀升不回落(泄漏)。
- Performance 录制中关注 "Rasterize" 和 "Composite Layers" 耗时,异常高说明纹理上传或图层合成开销大。
- 使用
renderer.capabilities.maxTextures等查询 GPU 限制,估算纹理总占用。 - 统计场景中纹理数量与分辨率,一张 4K RGBA 纹理占 64MB 显存,10 张即占满移动设备。
2.4 GC 引起的周期性卡顿
典型症状 :帧率平滑但有规律尖刺,Memory 面板显示锯齿状堆内存曲线。
定位方法:
- DevTools → Performance → 勾选 "Memory",观察 JS Heap 曲线,持续上升伴随周期陡降 → 频繁 GC。
- Memory Profiler 录制 "Allocation instrumentation on timeline",可精确定位每帧大量分配的代码行。
- 检查循环中是否大量使用
new Vector3()、new Color()等,或数组字面量反复创建。
第三步:针对性优化 ------ 组合策略与替代方案
优化应按优先级从高到低进行,遵循"影响大、实现成本低"的原则。
3.1 减少 Draw Call(最优先)
手段一:InstancedMesh(同类物体数 > 20 时必用)
javascript
const im = new THREE.InstancedMesh(geometry, material, count);
const dummy = new THREE.Object3D();
for (let i = 0; i < count; i++) {
dummy.position.set(...);
dummy.updateMatrix();
im.setMatrixAt(i, dummy.matrix);
}
- 替代方案 :
BatchedMesh(Three.js 145+)可合并不同几何体,更灵活但受材质限制。 - 动态更新:预分配最大数量,隐藏实例通过移出视野或缩放为 0 实现,避免重建。
手段二:合并静态几何体
javascript
const merged = BufferGeometryUtils.mergeGeometries(geometries, false);
- 前提:物体不再需要独立变换,材质相同。
- 增强:按纹理分组后再合并,避免材质切换;将颜色信息烘焙为顶点颜色,保持外观多样。
手段三:共享材质与几何体
相同的几何体和材质只创建一次,多个 Mesh 引用同一实例,减少数据上传和着色器切换。
3.2 降低几何体复杂度
手段一:LOD(多细节层次)
javascript
const lod = new THREE.LOD();
lod.addLevel(highMesh, 0);
lod.addLevel(lowMesh, 50);
scene.add(lod);
- 优化 :设置
lod.levels[i].hysteresis防抖动;结合 Instancing 为不同 LOD 层级准备多个 InstancedMesh。 - 替代 :根据相机距离手动控制
visible属性,更轻量。
手段二:视锥体剔除(默认开启,但需维护)
- 确保包围盒/球正确(蒙皮动画需手动更新
geometry.computeBoundingSphere)。 - 大量粒子等微小物体可关闭剔除 (
frustumCulled = false),因测试成本可能高于绘制。 - 进阶剔除 :使用
three-mesh-bvh构建 BVH 实现更精确的层次剔除;遮挡剔除可通过 Web Worker 近似或未来 WebGPU 支持。
3.3 纹理与材质优化
压缩与格式
- 使用 KTX2/Basis 纹理,
THREE.KTX2Loader加载,GPU 内存占用小且支持跨平台。 - 普通图片使用 WebP/AVIF 格式减少网络体积,不影响显存。
- 开启 mipmap(默认),关闭时远处纹理闪烁且浪费带宽。
纹理图集与数组
- 多个小图标合并为一张大图集,通过 UV 偏移使用,减少纹理单元切换。
- 对尺寸相同但内容不同的小纹理,使用
DataArrayTexture在着色器中用索引选择层。
分辨率控制
- 纹理尺寸遵循 2 的幂,非 2 的幂无法生成 mipmap 且内存占用更高(WebGL2 支持 NPOT,但建议沿用)。
- 降低不需要高精度的纹理分辨率,如金属度、粗糙度贴图可缩至 512×512。
- 虚拟纹理 :适合超大地形,动态加载可见区域瓦片(如
three-virtual-texture库)。
避免纹理泄漏
- 移除物体时调用
texture.dispose()释放显存,使用资源管理器自动计数引用。
3.4 阴影优化
- 分辨率 :
light.shadow.mapSize通常 1024×1024 足够,远低于 2048。 - 阴影相机范围 :收紧
shadow.camera.near/far,排除无关物体,减少阴影图渲染内容。 - 类型选择 :
BasicShadowMap性能最好,PCFSoftShadowMap画质高但消耗大。 - 限制投射者 :只让大型或重要物体
castShadow = true,利用包围盒剔除阴影相机外物体。 - 替代/增强 :
- 静态物体烘焙阴影贴图,完全不消耗实时性能。
- 接触阴影(Screen Space Shadows)作为后期效果,适合远距离物体。
- 多光源时每帧只更新部分光源阴影,分摊开销(需自行管理)。
3.5 光照计算优化
- 减少动态光源 :超过 4~5 个动态光即需谨慎。使用光照探针(
LightProbe)或烘焙lightMap代替。 - 光源类型选择 :
AmbientLight极廉价,DirectionalLight和SpotLight比PointLight开销小。 - 前向着色器限制 :Three.js 默认材质支持的动态光数量有限(取决于 precision),可通过
material.lights查看。 - 前瞻方案:WebGPU 后端的集群光照可高效处理大量动态光;当前可通过第三方延迟渲染库实现。
3.6 运行时内存与 GC 管理
- 对象池模式 :复用
Vector3、Matrix4等临时对象,使用pool.get()/pool.release()替代new。 - 关闭自动矩阵更新 :对大量静态物体设置
object.matrixAutoUpdate = false,手动更新仅必要时。 - 及时释放 GPU 资源 :
scene.remove(obj)不会自动释放,必须显式调用geometry.dispose()、material.dispose()、texture.dispose()。 - 场景流式加载:按相机位置动态加载/卸载区域模型,控制常驻内存量。
- 资源引用计数:自己封装资源管理,防止共享材质被过早释放。
3.7 渲染状态与管线技巧
- 透明与不透明分离 :不透明物体先渲染(深度写入),透明物体按从远到近排序,Three.js 已自动处理,但需注意自定义着色器的
depthWrite设置。 - 使用 Layers 分组 :通过
camera.layers和object.layers选择性渲染,实现多视角或 UI 隔离,减少无效遍历。 - 避免渲染未改变的场景 :用
renderer.setAnimationLoop替代requestAnimationFrame,在不需更新时暂停(需自行逻辑判断)。
第四步:优化决策树 ------ 快速应对常见情况
- FPS 低,renderer.info.calls > 1000
→ 首先用 InstancedMesh 或合并几何体减少 draw call。 - 三角面数极高(>500万),但 draw call 合理
→ 启用 LOD、简化远处模型、检查是否可合并静态物体。 - 缩小浏览器窗口后 FPS 明显提升
→ 填充率瓶颈:降低pixelRatio至Math.min(devicePixelRatio, 2),简化片元着色器(去掉不必要的纹理查找)。 - FPS 稳定但周期卡顿
→ 排查 GC:使用对象池,避免每帧 new 对象,利用 Memory Profiler 定位。 - 加载慢或长时间运行后崩溃
→ 纹理过大或泄漏:压缩纹理、降低分辨率、检查dispose调用。 - 复杂光照场景卡顿
→ 烘焙灯光、减少动态光、用光照探针替代部分实时光。
新增:第三步前置 ------ 三维模型制作阶段优化(以 Blender 为例)
这是性能优化的第一道关口,目标是在视觉质量损失可控的前提下,最小化需要存储、传输和渲染的数据量。应作为项目规范强制实施。
1. 几何体"瘦身"四步法
- 智能减面
- 使用
Decimate修改器:塌陷(比例减面)、反细分(还原细分)、平面(合并共面三角面)。 - 重新拓扑:对高模使用 Instant Meshes、Retopoflow 等生成干净低模。
- 合并重复顶点:
Merge by Distance清理冗余。
- 使用
- 细节转移至法线贴图 (黄金手段)
- 将高模细节烘焙为法线贴图,赋予包裹它的低模。面数可降至 1% 而保留几乎全部光影细节。
- 删除永远不可见的面
- 建筑内部、物体背面、布尔运算后的内部重叠面。清理松散几何体(
Delete Loose、Degenerate Dissolve)。
- 建筑内部、物体背面、布尔运算后的内部重叠面。清理松散几何体(
- 实例化替代复制
- 重复物体使用
Alt+D(关联复制)或集合实例,底层共享网格数据,内存与文件体积保持近一份。
- 重复物体使用
2. 纹理与材质高效化
- 纹理尺寸"足够即可"
- 4K 非必需时缩至 2K/1K,粗糙度、金属度等单通道贴图可低至 512px。
Image Editor > Resize。
- 4K 非必需时缩至 2K/1K,粗糙度、金属度等单通道贴图可低至 512px。
- 通道打包(ORM 贴图)
- 将 AO、Roughness、Metallic 分别放入一张图的 R、G、B 通道,减少纹理数量和采样开销。
- 纹理图集
- 多个物体的材质合并为一张大纹理,重新排布 UV,只需一次材质绑定,极大减少 Draw Call。
- 格式选择
- Blender 内部可用 JPEG、WebP 代理无压缩 PNG;导出时转为压缩纹理格式。
3. 场景结构清理
- 塌陷应用修改器
- 导出前务必
Apply所有需固化的修改器,避免加载时重复计算。
- 导出前务必
- 适当合并静态物体
- 共用材质、不独立运动的小物体用
Ctrl+J合并,减少渲染时的 Draw Call 数量。
- 共用材质、不独立运动的小物体用
- 清除冗余数据层
- 删除无用的顶点组、形态键、UV 贴图、顶点颜色、辅助骨骼、多余关键帧(
Clean Keyframes)。
- 删除无用的顶点组、形态键、UV 贴图、顶点颜色、辅助骨骼、多余关键帧(
- 清理未使用数据块
File > Clean Up > Recursive Unused Data-Blocks一键清除游离材质、贴图等。
4. 导出格式与压缩
- 首选 glTF 2.0
- 开启
Apply Modifiers,启用 Draco 网格压缩,几何体数据可压缩至 1/10。 - 使用
glTF Transform或插件将纹理转为 KTX2/Basis Universal,同时降低文件体积和 GPU 内存占用。
- 开启
- FBX 导出
- 同样需应用修改器、剔除无用元素,并在目标引擎中按需压缩。
- Web 特别配置
- 组合 glTF + Draco + KTX2,配合
gltfpack等工具可做到最小传输包体。
- 组合 glTF + Draco + KTX2,配合
5. 操作习惯与验证
- 始终保留高模源文件,优化在副本上进行。
- 开启统计信息(Viewport Overlays 中的 Statistics),实时监控顶点、三角面、对象数。
- 目标环境测试:在 Sketchfab、Babylon.js Sandbox 或实际应用中对比加载时间与帧率,确保优化有效且视觉可接受。
将此项融入完整性能优化体系
优化模型的成果会直接反映在渲染统计上(面数、Draw Call、纹理内存下降),因此在测量阶段就能看到显著改善。更新后的优化决策树增加前置判断:
- 模型是否已在制作端优化?
→ 若未优化,先按本节方案处理模型,再进入运行时测量。 - FPS 低,
renderer.info.render.calls仍 > 1000
→ 继续用 InstancedMesh、合并几何体等方法。 - 三角面数依然极高
→ 可能 LOD 未正确设置,或模型本身仍有优化空间(回头检查减面与法线烘焙)。
......
这样,从制作到运行时的全链路优化便形成了闭环。优化不再是单一的代码调优,而是数据源头控制 + 引擎高效利用的结合,能有效避免"垃圾进,垃圾出"的困境,让精细化模型在保持视觉震撼的同时,也能在浏览器中流畅运行。
最后提醒:
优化是权衡的艺术,不要提前过度优化。始终在目标设备上验证,保持"测量 → 定位 → 改进 → 复测"的循环,让每一行优化代码都有数据支撑。