首页 / Three.js 入门教程 / 性能优化

Three.js 入门教程

性能优化

本教程共 40 篇 · 第 37 篇 · 更新于 2026-08-14 · 约 9 分钟阅读

Three.js性能优化InstancedMeshLODdraw callWebGL

本节目标:搞懂 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显存能否单独控制
多个普通 Mesh2000小(几何体可共享)可以
mergeGeometries 合并1大(顶点复制)不可以
InstancedMesh1小(每实例一个矩阵)可以
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(物体, 距离):物体在”大于等于该距离”时显示,第一个参数是 MeshGroup
  • 渲染器每帧自动调用 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 直接翻四倍。优化方向:

  1. 控制尺寸:贴图能 512 不 1024。WebGL2 虽然支持非 2 的幂贴图,但要生成 mipmap,尺寸还是 2 的幂最省心
  2. 生成 mipmap:远处的物体采样小图,默认开启,别关
  3. 开各向异性过滤:斜着看地面时纹理不发糊,texture.anisotropy = renderer.capabilities.getMaxAnisotropy()
  4. 用压缩纹理 KTX2KTX2Loader 配合 Basis Universal 格式,显存占用可降到原来的四分之一左右,加载也更快
  5. 模型几何体压缩: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 更全面:

  1. 打开 DevTools → Performance 面板,点录制,操作页面 5 秒左右停止
  2. 看 FPS 曲线:掉到 60 以下的地方就是瓶颈
  3. 切到帧(Frame)明细,能看到主线程(CPU)和 GPU 各自耗时

想数 draw call,可以用浏览器扩展 Spector.js,它能把每一帧的 GPU 命令完整列出来。

Note

性能优化三连:先量化、再优化、再验证。不量化就动手,多半在瞎忙。

其他容易踩的坑

  • 共享几何体和材质:100 个盒子共用一份 BoxGeometry 和一个 material,比 new 100 份省得多
  • 记得 dispose:移除物体时调用 geometry.dispose()material.dispose(),否则 GPU 内存只进不出
  • 视锥剔除别关:相机看不到的物体默认不渲染,这是白送的优化,frustumCulled = false 慎用
  • 阴影有成本:阴影贴图尺寸越大越贵,动态阴影只给主角开
  • 页面隐藏时暂停requestAnimationFramerenderer.setAnimationLoop 在后台标签页都会自动暂停,无需手动处理,省电也省性能

性能优化不是一锤子买卖。场景复杂度上去之后,回头用 Stats.js 看一眼,通常还能再挤出一截。