性能优化
本教程共 40 篇 · 第 37 篇 · 更新于 2026-08-14 · 约 9 分钟阅读
本节目标:搞懂 three.js 场景卡顿的根源,学会几何体合并、InstancedMesh、LOD、像素比限制、纹理压缩这五招,并用 Stats.js 和 Chrome DevTools 量化优化效果。学完你能让上千个物体的场景保持流畅。
卡顿从哪来
浏览器要每秒绘制 60 帧,每帧只有约 16.7 毫秒。超过这个时间,画面就开始掉帧、卡顿。
three.js 场景慢,瓶颈通常不在 GPU,而在 CPU。渲染一帧,CPU 要逐条向 GPU 下达绘图命令:画这个盒子、画那个球、换一次材质、再换一次……每一条”画一个物体”的命令,就是一个 draw call。
WebGL 是典型的状态机。两次 draw call 之间,只要材质、几何体、纹理有任何不同,CPU 都要重新配置一堆状态,这是很重的开销。经验值是:一帧几百个 draw call 还能跑,上千个就开始吃力。
所以性能优化的核心思路很简单:减少 draw call 数量。下面几招都是围绕它展开的。
第一招:合并几何体
场景里有一批永远不分开移动的静态物体,可以把它们的顶点合并成一份几何体,用一个 Mesh 渲染。工具是 BufferGeometryUtils.mergeGeometries:
import * as BufferGeometryUtils from 'three/addons/utils/BufferGeometryUtils.js';
const geometries = [];
for (let i = 0; i < 100; i++) {
const box = new THREE.BoxGeometry(1, 1, 1);
box.translate(Math.random() * 20 - 10, 0, Math.random() * 20 - 10);
geometries.push(box);
}
const merged = BufferGeometryUtils.mergeGeometries(geometries);
const mesh = new THREE.Mesh(merged, material);
scene.add(mesh);
100 个盒子,之前 100 个 draw call,现在 1 个。
代价也要说清楚:
- 每个物体的顶点都会被复制一份,显存占用明显变大
- 合并后是一个整体,没法再单独移动、旋转、隐藏其中某一个
- 参与合并的几何体必须用同一种材质;
mergeGeometries在属性不匹配时返回null
Tip判断依据很简单:物体之间”永远相对静止”就适合合并。地形、建筑群、静态道具都是典型场景。
第二招:InstancedMesh 实例化
合并的硬伤是不能单独控制物体。如果有一大堆相同几何体、相同材质的物体,各自还要独立移动,就用 InstancedMesh(实例化网格)。
它在一次 draw call 里绘制全部实例,每个实例只多存一个变换矩阵:
const COUNT = 2000;
// 一个几何体 + 一个材质,但渲染 2000 个实例
const mesh = new THREE.InstancedMesh(
new THREE.BoxGeometry(0.4, 0.4, 0.4),
new THREE.MeshStandardMaterial({ color: 0x00aaff }),
COUNT
);
const matrix = new THREE.Matrix4();
const position = new THREE.Vector3();
for (let i = 0; i < COUNT; i++) {
position.set(
Math.random() * 30 - 15,
Math.random() * 30 - 15,
Math.random() * 30 - 15
);
matrix.setPosition(position);
mesh.setMatrixAt(i, matrix); // 第 i 个实例的变换矩阵
}
scene.add(mesh);
几个关键点:
- 构造参数:几何体、材质、实例数量。数量创建后改不了,只能重新 new
- 用
setMatrixAt(index, matrix)设置每个实例的位置旋转缩放 - 设置完要标记
mesh.instanceMatrix.needsUpdate = true,否则 GPU 不刷新数据 - 想给实例单独上色,用
setColorAt(index, color),同样要置instanceColor.needsUpdate = true
完整可运行的对比示例(2000 个立方体 + 帧率监控):
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="utf-8">
<title>InstancedMesh 性能示例</title>
<style>body { margin: 0; overflow: hidden; }</style>
</head>
<body>
<script type="importmap">
{
"imports": {
"three": "https://cdn.jsdelivr.net/npm/three@0.185.0/build/three.module.js",
"three/addons/": "https://cdn.jsdelivr.net/npm/three@0.185.0/examples/jsm/"
}
}
</script>
<script type="module">
import * as THREE from 'three';
import { OrbitControls } from 'three/addons/controls/OrbitControls.js';
import Stats from 'three/addons/libs/stats.module.js';
const COUNT = 2000;
const scene = new THREE.Scene();
const camera = new THREE.PerspectiveCamera(60, innerWidth / innerHeight, 0.1, 200);
camera.position.set(20, 15, 20);
const renderer = new THREE.WebGLRenderer({ antialias: true });
renderer.setSize(innerWidth, innerHeight);
document.body.appendChild(renderer.domElement);
new OrbitControls(camera, renderer.domElement);
// 2000 个实例只占 1 个 draw call
const mesh = new THREE.InstancedMesh(
new THREE.BoxGeometry(0.4, 0.4, 0.4),
new THREE.MeshStandardMaterial({ color: 0x00aaff }),
COUNT
);
const matrix = new THREE.Matrix4();
const position = new THREE.Vector3();
for (let i = 0; i < COUNT; i++) {
position.set(
Math.random() * 30 - 15,
Math.random() * 30 - 15,
Math.random() * 30 - 15
);
matrix.setPosition(position);
mesh.setMatrixAt(i, matrix);
}
scene.add(mesh);
scene.add(new THREE.AmbientLight(0xffffff, 1));
scene.add(new THREE.DirectionalLight(0xffffff, 2));
// FPS 监控
const stats = new Stats();
document.body.appendChild(stats.dom);
function animate() {
stats.begin();
mesh.rotation.y += 0.005;
renderer.render(scene, camera);
stats.end();
requestAnimationFrame(animate);
}
animate();
</script>
</body>
</html>
把 InstancedMesh 换成 2000 个普通 Mesh 试试,帧率差别立竿见影。
三种方案的取舍:
| 方案 | draw call | 显存 | 能否单独控制 |
|---|---|---|---|
| 多个普通 Mesh | 2000 | 小(几何体可共享) | 可以 |
| mergeGeometries 合并 | 1 | 大(顶点复制) | 不可以 |
| InstancedMesh | 1 | 小(每实例一个矩阵) | 可以 |
Tip粒子、草、森林、星空、人群,凡是”同一模型大量重复”,优先想 InstancedMesh。
第三招:LOD 细节层次
物体离相机远时,三角形再多也看不清。LOD(Level of Detail,细节层次)为同一物体准备多档精度的模型,按距离自动切换:
const lod = new THREE.LOD();
const material = new THREE.MeshStandardMaterial({ color: 0xffaa00 });
// 距离越远,精度越低
lod.addLevel(new THREE.Mesh(new THREE.IcosahedronGeometry(1, 3), material), 0);
lod.addLevel(new THREE.Mesh(new THREE.IcosahedronGeometry(1, 2), material), 20);
lod.addLevel(new THREE.Mesh(new THREE.IcosahedronGeometry(1, 1), material), 50);
scene.add(lod);
addLevel(物体, 距离):物体在”大于等于该距离”时显示,第一个参数是Mesh或Group- 渲染器每帧自动调用
lod.update(camera)做距离判断,autoUpdate默认true - 第三个参数
hysteresis(迟滞)是切换阈值,用来避免在临界距离来回切换导致的闪烁 - 距离阈值存在
levels数组里,lod.levels[0].distance是最近一档
切换瞬间可能有一跳,所以各档模型的外观(材质、颜色)最好保持一致。游戏里的树、建筑、角色模型,是 LOD 的最大受益者。
像素比:最容易被忽视的杀手
高分屏的 devicePixelRatio 是 2 甚至 3,像素总数按平方增长。4K 屏上把像素比拉满,要渲染的像素是 1080p 的好几倍,GPU 再强也扛不住。
// 限制像素比,最高 2
renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));
画面几乎看不出差别,性能却能省下一大截。追求极致性能的网站,甚至会限制到 1.5 或 1。
纹理压缩与贴图瘦身
纹理越大,显存占用和采样开销越大。一张 1024×1024 的 RGBA 贴图占 4 MB 显存,2048×2048 直接翻四倍。优化方向:
- 控制尺寸:贴图能 512 不 1024。WebGL2 虽然支持非 2 的幂贴图,但要生成 mipmap,尺寸还是 2 的幂最省心
- 生成 mipmap:远处的物体采样小图,默认开启,别关
- 开各向异性过滤:斜着看地面时纹理不发糊,
texture.anisotropy = renderer.capabilities.getMaxAnisotropy() - 用压缩纹理 KTX2:
KTX2Loader配合 Basis Universal 格式,显存占用可降到原来的四分之一左右,加载也更快 - 模型几何体压缩:glTF 模型用 DRACO 压缩,加载体积能小一个数量级
用数据说话:性能检测
优化前先量化。两个工具:
Stats.js 是 three.js 官方示例里最常见的帧率面板,从 addons 引入:
import Stats from 'three/addons/libs/stats.module.js';
const stats = new Stats();
document.body.appendChild(stats.dom);
function animate() {
stats.begin();
renderer.render(scene, camera);
stats.end();
requestAnimationFrame(animate);
}
左上角会实时显示 FPS、每帧耗时(ms)和内存占用(MB)三档;想看 draw call 数,用 renderer.info.render.calls。上一节的示例就是这么监控的。
Chrome DevTools 更全面:
- 打开 DevTools → Performance 面板,点录制,操作页面 5 秒左右停止
- 看 FPS 曲线:掉到 60 以下的地方就是瓶颈
- 切到帧(Frame)明细,能看到主线程(CPU)和 GPU 各自耗时
想数 draw call,可以用浏览器扩展 Spector.js,它能把每一帧的 GPU 命令完整列出来。
Note性能优化三连:先量化、再优化、再验证。不量化就动手,多半在瞎忙。
其他容易踩的坑
- 共享几何体和材质:100 个盒子共用一份
BoxGeometry和一个material,比 new 100 份省得多 - 记得 dispose:移除物体时调用
geometry.dispose()、material.dispose(),否则 GPU 内存只进不出 - 视锥剔除别关:相机看不到的物体默认不渲染,这是白送的优化,
frustumCulled = false慎用 - 阴影有成本:阴影贴图尺寸越大越贵,动态阴影只给主角开
- 页面隐藏时暂停:
requestAnimationFrame与renderer.setAnimationLoop在后台标签页都会自动暂停,无需手动处理,省电也省性能
性能优化不是一锤子买卖。场景复杂度上去之后,回头用 Stats.js 看一眼,通常还能再挤出一截。