Three.js InstancedMesh 实例化渲染

场景里要放一万个一样的物体,最直接的写法是循环 new 一万个 Mesh,一个个 add 进场景。几十个没问题,上万之后帧率会掉下来一大截。

原因不在几何体:一万个立方体加起来也才 12 万个三角形,对 GPU 来说这点量很小。问题出在 DrawCall。

1. 先看看不优化会怎样

1
2
3
4
5
for (let i = 0; i < 10000; i++) {
const mesh = new THREE.Mesh(geometry, material);
mesh.position.set(Math.random() * 100, Math.random() * 100, Math.random() * 100);
scene.add(mesh);
}

这段代码能跑,画面也对,但代价是:

开销 数量
Mesh 对象 10000 个,各带一套 Object3D 状态、局部矩阵、包围盒
场景图遍历 + 矩阵更新 每帧 10000 次
视锥裁剪 每帧 10000 次
DrawCall 10000 次

前三行是 CPU 的算术开销,量大但线性,还能靠硬件扛一扛。最后一行是要命的:每次 DrawCall 都是一次「CPU 告诉 GPU 怎么画」的对话——设置管线状态、绑定缓冲、发指令。这场对话有固定的开场成本,跟你要画多少个三角形关系不大。

几何体越小,这笔固定成本占比越高。一千个立方体总共一万两千个面,却要走一千次 DrawCall,GPU 大部分时间在等 CPU 把话说完。

2. 思路:一份几何体,配 N 份变换

所有立方体的顶点数据是一模一样的,那为什么要存一万份?位置不同,差的只是一个变换而已。

1
2
3
4
5
6
7
8
普通写法                      InstancedMesh
----------- --------------
Geometry × 10000 Geometry × 1
Material × 10000 Material × 1
变换矩阵 隐含在各自 instanceMatrix
Object3D 里 × 10000

DrawCall × 10000 DrawCall × 1

一句话记住它:

InstancedMesh = 一份 Geometry + 一份 Material + 多份变换矩阵,一次 DrawCall 画很多个相同物体。

「实例」并不是真的一个独立对象,只是一行 4×4 矩阵。这个认知很关键——后面那些使用规则,都是从「共享几何体、只有矩阵不同」这一条推出来的。

3. 最小可用例子

先让东西转起来,再拆原理。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
import * as THREE from 'three';

const geometry = new THREE.BoxGeometry(1, 1, 1);
const material = new THREE.MeshStandardMaterial({ color: 0xffffff });

const count = 1000;
const mesh = new THREE.InstancedMesh(geometry, material, count);

const dummy = new THREE.Object3D();
const color = new THREE.Color();

for (let i = 0; i < count; i++) {
dummy.position.set(
(Math.random() - 0.5) * 40,
(Math.random() - 0.5) * 40,
(Math.random() - 0.5) * 40
);
dummy.rotation.set(Math.random() * Math.PI, Math.random() * Math.PI, 0);
dummy.scale.setScalar(0.4 + Math.random() * 0.8);
dummy.updateMatrix();

mesh.setMatrixAt(i, dummy.matrix);
mesh.setColorAt(i, color.setHSL(Math.random(), 0.6, 0.55));
}

mesh.instanceMatrix.needsUpdate = true;
mesh.instanceColor.needsUpdate = true; // 用了 setColorAt 就必须写
mesh.computeBoundingSphere(); // 不然视锥裁剪可能整片消失

scene.add(mesh);

构造函数三个参数:

1
2
3
4
5
new THREE.InstancedMesh(geometry, material, count)

geometry -> 所有实例共享的几何体
material -> 所有实例共享的材质
count -> 实例数量(容量上限,构造时确定)

循环体在做一件事:给每个实例算出一个变换矩阵,写进去。dummy 是个复用的「矩阵计算器」,它不参与渲染,所以不要 scene.add(dummy)——加了会平白多一个空对象。

用 Object3D 而不是手动拼矩阵,是因为:

1
2
3
4
5
6
7
8
9
position
rotation
scale
|
v
dummy.updateMatrix()
|
v
Matrix4

updateMatrix() 按 T × R × S 的顺序把三者合成,省得自己调 compose。

最后三行是全文的重点,第 5 节专门讲。这里先跑起来。

4. 拆开看:实例矩阵到底是什么

instanceMatrix 是一个 InstancedBufferAttribute,底层就是一块连续的 Float32Array,长度是 count × 16——16 是 4×4 矩阵展开后的元素个数。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
Geometry
+
Material
+
instanceMatrix (Float32Array, count × 16)
|
| [0..15] 第 0 个实例的矩阵
| [16..31] 第 1 个实例的矩阵
| [32..47] 第 2 个实例的矩阵
| ...
v
顶点着色器里逐实例取出对应矩阵
|
v
同一份顶点 × 各自的矩阵 = 各自的最终位置

顶点着色器做的事,简化后就是这样:

1
2
3
4
5
6
7
8
同一个 position
|
| 按当前实例编号取出 instanceMatrix
v
position × instanceMatrix
|
v
这一实例的世界坐标

「当前在画第几个实例」这个信息是硬件提供的,不用 CPU 逐个参与。这也是为什么它快——一万个实例的位置差异,全部在 GPU 侧解决,CPU 只说了一次「画一万个」。

顺带说一个 Object3D 的细节:rotation 和 quaternion 在内部是双向同步的,改任一个另一个会自动更新。所以后面动画里先 decompose 出 quaternion、再改 rotation.y,是生效的。

5. 那几个「必须写」的调用,其实是同一个原因

第 3 节末尾的三行不是三条互相独立的注意事项,它们在回答同一个问题:数据在 CPU 和 GPU 各有一份,什么时候同步。

instanceMatrix.needsUpdate:不同步,画面就不动

1
2
mesh.setMatrixAt(i, matrix);
mesh.instanceMatrix.needsUpdate = true;

setMatrixAt 只是往 CPU 内存里那块 Float32Array 写数据,GPU 显存里那份还是老的。needsUpdate = true 才是在说「这块脏了,下一帧重新上传」。

漏了它的典型症状是代码明明改了,画面纹丝不动,或者偶尔对上一次——那是碰巧遇上别的原因触发了重传。

computeBoundingSphere:同步了坐标,也得同步包围球

包围球是视锥裁剪的依据:超出相机视野的物体直接跳过,不提交绘制。

问题在于,几何体的包围球是在局部原点附近算的。一个边长 1 的盒子,包围球半径不到 1;而我们的实例散在 ±20 的范围里。如果裁剪拿几何体那个半径不到 1 的球去判断,相机稍微偏离原点,整个对象就被判成「看不见」——一千个实例一起消失,哪怕其中很多本来还在画面中央。

computeBoundingSphere() 会把所有实例矩阵考虑进来,算出一个真正能包住全部实例的球,存在 instancedMesh.boundingSphere 上。

两个要点:

  • 它是缓存的。 算完就存着。所以构造完写完第一批矩阵要调一次,之后每次批量改动实例位置(动画、切数据、重新布局)都要再调一次。只调一次然后一直动实例,会出现「转到某个角度整片消失、转回来又出现」。
  • 旧版 Three.js 没有 boundingSphere 这个属性,裁剪会退回到用几何体的球——这就是「必须手动 computeBoundingSphere」这个说法的来源。较新版本里它能在必要时自动算,但「显式调用 + 改动后重算」在两种版本下都是正确的。

实在不想管,可以 instancedMesh.frustumCulled = false 直接关掉裁剪,代价是相机背对时也在提交绘制,只适合实例不多的场景。

instanceColor 的顺序:它是惰性创建的

1
2
3
4
mesh.setColorAt(i, color.setHSL(Math.random(), 0.6, 0.55));

// 循环结束后
mesh.instanceColor.needsUpdate = true;

setColorAt 第一次被调用时,才会创建 instanceColor 这个属性。所以刚 new 出来的时候 instancedMesh.instanceColor 是 null——此时写 needsUpdate 会直接报错。位置必须在设完颜色之后。

它和 instanceMatrix 是两块独立数据,也就是两次 needsUpdate。只标记了矩阵、忘了颜色,症状是位置更新了但颜色还是老的。

还有一个前提:instanceColor 生效依赖材质支持。内置的 MeshStandardMaterial、MeshBasicMaterial 都能直接用;换成完全自定义的 ShaderMaterial 就得自己写 #include <color_vertex> 之类的 chunk,不会自动生效。

每帧都改的话,顺手声明用途

1
mesh.instanceMatrix.setUsage(THREE.DynamicDrawUsage);

默认的 StaticDrawUsage 是给「写完就不动」的数据用的,驱动可以把它放到更优的位置。提前声明会频繁变,驱动的更新策略不一样。静态场景不用加。

6. 为什么快:从 DrawCall 到 drawElementsInstanced

1
2
3
4
5
6
7
8
9
普通写法                       InstancedMesh
----------- --------------
Mesh1 -> draw() Geometry + Material
Mesh2 -> draw() |
Mesh3 -> draw() v
... GPU 端逐实例读取矩阵
|
v
一次实例化绘制

底层对应的就是 WebGL2 的两个 API:

1
2
gl.drawElements(...)           // 普通绘制
gl.drawElementsInstanced(...) // 实例化绘制

所以 THREE.InstancedMesh 并没有变出什么新魔法,它就是把 GPU Instancing 封装好了。省下的就是那几千次 CPU 与 GPU 之间的往返。这和 Cesium 里用 DrawCommand 做实例化绘制是同一个思路,只是换了一层 API。

有一点必须分清楚:它优化的是 CPU 侧的提交开销,不是 GPU 侧的填充开销。一亿个三角形还是那么多三角形,顶点处理和片元着色一样不少。如果瓶颈本来就在 GPU,InstancedMesh 帮不上忙。


整条链路再看一遍:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
Geometry + Material + count
|
v
new THREE.InstancedMesh(...)
| 内部创建 Float32Array,
| 长度 count × 16,存实例矩阵
v
逐实例构造 Matrix4
|
| Object3D 设好 position / rotation / scale
| updateMatrix() 合成
v
setMatrixAt(i, matrix)
|
| 只写进 CPU 内存 —— 所以需要 ↓
v
instanceMatrix.needsUpdate = true
|
| 下一帧整块重新上传 GPU
v
computeBoundingSphere()
|
| 重算包围球缓存,保证视锥裁剪正确
v
GPU 逐实例读取矩阵 × 共享顶点数据
|
v
一次 drawElementsInstanced 画完

真正要记住的只有三件事:同一份几何体和材质、每个实例一个 4×4 矩阵、改完要同步 CPU 与 GPU 两份数据。那些「必须写」的调用——needsUpdate、computeBoundingSphere、setColorAt 的先后顺序——都是第三件事的不同表现。

作者

wangyawei

发布于

2026-09-14

更新于

2026-09-14

许可协议