Three.js InstancedMesh 实例化渲染
场景里要放一万个一样的物体,最直接的写法是循环 new 一万个 Mesh,一个个 add 进场景。几十个没问题,上万之后帧率会掉下来一大截。
原因不在几何体:一万个立方体加起来也才 12 万个三角形,对 GPU 来说这点量很小。问题出在 DrawCall。
1. 先看看不优化会怎样
1 | for (let i = 0; i < 10000; i++) { |
这段代码能跑,画面也对,但代价是:
| 开销 | 数量 |
|---|---|
Mesh 对象 |
10000 个,各带一套 Object3D 状态、局部矩阵、包围盒 |
| 场景图遍历 + 矩阵更新 | 每帧 10000 次 |
| 视锥裁剪 | 每帧 10000 次 |
| DrawCall | 10000 次 |
前三行是 CPU 的算术开销,量大但线性,还能靠硬件扛一扛。最后一行是要命的:每次 DrawCall 都是一次「CPU 告诉 GPU 怎么画」的对话——设置管线状态、绑定缓冲、发指令。这场对话有固定的开场成本,跟你要画多少个三角形关系不大。
几何体越小,这笔固定成本占比越高。一千个立方体总共一万两千个面,却要走一千次 DrawCall,GPU 大部分时间在等 CPU 把话说完。
2. 思路:一份几何体,配 N 份变换
所有立方体的顶点数据是一模一样的,那为什么要存一万份?位置不同,差的只是一个变换而已。
1 | 普通写法 InstancedMesh |
一句话记住它:
InstancedMesh = 一份 Geometry + 一份 Material + 多份变换矩阵,一次 DrawCall 画很多个相同物体。
「实例」并不是真的一个独立对象,只是一行 4×4 矩阵。这个认知很关键——后面那些使用规则,都是从「共享几何体、只有矩阵不同」这一条推出来的。
3. 最小可用例子
先让东西转起来,再拆原理。
1 | import * as THREE from 'three'; |
构造函数三个参数:
1 | new THREE.InstancedMesh(geometry, material, count) |
循环体在做一件事:给每个实例算出一个变换矩阵,写进去。dummy 是个复用的「矩阵计算器」,它不参与渲染,所以不要 scene.add(dummy)——加了会平白多一个空对象。
用 Object3D 而不是手动拼矩阵,是因为:
1 | position |
updateMatrix() 按 T × R × S 的顺序把三者合成,省得自己调 compose。
最后三行是全文的重点,第 5 节专门讲。这里先跑起来。
4. 拆开看:实例矩阵到底是什么
instanceMatrix 是一个 InstancedBufferAttribute,底层就是一块连续的 Float32Array,长度是 count × 16——16 是 4×4 矩阵展开后的元素个数。
1 | Geometry |
顶点着色器做的事,简化后就是这样:
1 | 同一个 position |
「当前在画第几个实例」这个信息是硬件提供的,不用 CPU 逐个参与。这也是为什么它快——一万个实例的位置差异,全部在 GPU 侧解决,CPU 只说了一次「画一万个」。
顺带说一个 Object3D 的细节:rotation 和 quaternion 在内部是双向同步的,改任一个另一个会自动更新。所以后面动画里先 decompose 出 quaternion、再改 rotation.y,是生效的。
5. 那几个「必须写」的调用,其实是同一个原因
第 3 节末尾的三行不是三条互相独立的注意事项,它们在回答同一个问题:数据在 CPU 和 GPU 各有一份,什么时候同步。
instanceMatrix.needsUpdate:不同步,画面就不动
1 | mesh.setMatrixAt(i, matrix); |
setMatrixAt 只是往 CPU 内存里那块 Float32Array 写数据,GPU 显存里那份还是老的。needsUpdate = true 才是在说「这块脏了,下一帧重新上传」。
漏了它的典型症状是代码明明改了,画面纹丝不动,或者偶尔对上一次——那是碰巧遇上别的原因触发了重传。
computeBoundingSphere:同步了坐标,也得同步包围球
包围球是视锥裁剪的依据:超出相机视野的物体直接跳过,不提交绘制。
问题在于,几何体的包围球是在局部原点附近算的。一个边长 1 的盒子,包围球半径不到 1;而我们的实例散在 ±20 的范围里。如果裁剪拿几何体那个半径不到 1 的球去判断,相机稍微偏离原点,整个对象就被判成「看不见」——一千个实例一起消失,哪怕其中很多本来还在画面中央。
computeBoundingSphere() 会把所有实例矩阵考虑进来,算出一个真正能包住全部实例的球,存在 instancedMesh.boundingSphere 上。
两个要点:
- 它是缓存的。 算完就存着。所以构造完写完第一批矩阵要调一次,之后每次批量改动实例位置(动画、切数据、重新布局)都要再调一次。只调一次然后一直动实例,会出现「转到某个角度整片消失、转回来又出现」。
- 旧版 Three.js 没有
boundingSphere这个属性,裁剪会退回到用几何体的球——这就是「必须手动 computeBoundingSphere」这个说法的来源。较新版本里它能在必要时自动算,但「显式调用 + 改动后重算」在两种版本下都是正确的。
实在不想管,可以 instancedMesh.frustumCulled = false 直接关掉裁剪,代价是相机背对时也在提交绘制,只适合实例不多的场景。
instanceColor 的顺序:它是惰性创建的
1 | mesh.setColorAt(i, color.setHSL(Math.random(), 0.6, 0.55)); |
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 | 普通写法 InstancedMesh |
底层对应的就是 WebGL2 的两个 API:
1 | gl.drawElements(...) // 普通绘制 |
所以 THREE.InstancedMesh 并没有变出什么新魔法,它就是把 GPU Instancing 封装好了。省下的就是那几千次 CPU 与 GPU 之间的往返。这和 Cesium 里用 DrawCommand 做实例化绘制是同一个思路,只是换了一层 API。
有一点必须分清楚:它优化的是 CPU 侧的提交开销,不是 GPU 侧的填充开销。一亿个三角形还是那么多三角形,顶点处理和片元着色一样不少。如果瓶颈本来就在 GPU,InstancedMesh 帮不上忙。
整条链路再看一遍:
1 | Geometry + Material + count |
真正要记住的只有三件事:同一份几何体和材质、每个实例一个 4×4 矩阵、改完要同步 CPU 与 GPU 两份数据。那些「必须写」的调用——needsUpdate、computeBoundingSphere、setColorAt 的先后顺序——都是第三件事的不同表现。
Three.js InstancedMesh 实例化渲染
https://wywzixin.github.io/2026/09/14/threejs实例化渲染InstancedMesh/