1 万个物体卡成 PPT?InstancedMesh:从 1 万次绘制到 1 次
- 2026-11-02 13:31:28
一句话导读:复制一万个物体,卡的不是你的电脑,是"喊 1 万次口令"的通信开销。今天用一个 API,把 1 万次绘制压成 1 次。
🎯 开场:一万个方块,帧率从 60 掉到 4
先看一段"看起来很合理"的代码:
for (let i = 0; i < 10000; i++) {const cube = new THREE.Mesh(geometry, material);cube.position.set(Math.random() * 100, 0, Math.random() * 100);scene.add(cube); // ❌ 一万个 Mesh,一万次绘制命令}
结果:风扇狂转、帧率跌到个位数。
但诡异的是——这 1 万个方块长得一模一样,材质一模一样,几何体也一模一样。GPU 明明最擅长"重复劳动",为什么反而最怕重复?
答案在 CPU 和 GPU 之间的那条窄通道上。
💡 本期核心观点:性能瓶颈很多时候不在"画得慢",而在"喊得太多"——每一句
render里的绘制命令(draw call)都是一次 CPU→GPU 的口令传递。InstancedMesh 的本质,就是把 1 万次口令变成 1 次。
📖 发生了什么:draw call 才是真凶
Three.js 每一帧要做的事,可以粗暴拆成两半:
[CPU 侧] 遍历场景 → 计算矩阵 → 组装绘制命令(draw call)↓ ← 这条通道又窄又贵(状态切换、数据拷贝)[GPU 侧] 收到命令 → 顶点变换 → 光栅化 → 上色
1 万个 Mesh =1 万次 draw call。CPU 大部分时间花在"排队喊口令"上,GPU 反而闲着。
而 InstancedMesh 的做法是:把"模型长什么样"和"每个复制品摆在哪"拆开。
普通 Mesh: 几何体 + 材质 + 位置 → 一次 draw callInstancedMesh: 几何体 + 材质 + [位置矩阵表 × 10000] → 还是一次 draw call
一张 GPU 侧的实例矩阵表(instanceMatrix)就装下了所有复制品的位移、旋转、缩放。一份几何体,一份材质,一张表,一次绘制。
new Mesh | |||
InstancedMesh |
🚀 核心 API:其实只有 4 个方法
const mesh = new THREE.InstancedMesh(geometry, material, COUNT); // ① 声明要画几份mesh.setMatrixAt(i, matrix); // ② 写第 i 份实例的变换矩阵mesh.setColorAt(i, color); // ③ 写第 i 份实例的颜色(可选)mesh.instanceMatrix.needsUpdate = true; // ④ 通知 GPU 更新
就这四个。剩下的全是"怎么算出那个矩阵"的问题。
💻 完整可跑示例:一万个"呼吸方块阵"
零外部依赖(仅需 three),复制粘贴即可运行。效果:一万个彩色方块此起彼伏地上下浮动 + 自转。
import * as THREE from 'three';// ---------- 基础三件套 ----------const scene = new THREE.Scene();scene.background = new THREE.Color(0x0b0f1a);const camera = new THREE.PerspectiveCamera(60, innerWidth / innerHeight, 0.1, 500);camera.position.set(0, 45, 90);camera.lookAt(0, 0, 0);const renderer = new THREE.WebGLRenderer({ antialias: true });renderer.setSize(innerWidth, innerHeight);renderer.setPixelRatio(Math.min(devicePixelRatio, 2));document.body.appendChild(renderer.domElement);// 光照(实例化后依然是标准材质,照常吃光照)scene.add(new THREE.HemisphereLight(0xffffff, 0x223344, 1.2));const dir = new THREE.DirectionalLight(0xffffff, 2);dir.position.set(30, 60, 20);scene.add(dir);// ---------- 关键部分:一份几何 + 一份材质 + 一张实例表 ----------const COUNT = 10000;const geometry = new THREE.BoxGeometry(0.6, 0.6, 0.6);const material = new THREE.MeshStandardMaterial({ roughness: 0.35, metalness: 0.1 });const mesh = new THREE.InstancedMesh(geometry, material, COUNT);mesh.instanceMatrix.setUsage(THREE.DynamicDrawUsage); // 每帧都要改 → 告诉 GPU 别做静态优化// 用一个”临时演员”把 position/rotation/scale 烘焙成矩阵const dummy = new THREE.Object3D();const color = new THREE.Color();const data = [];for (let i = 0; i < COUNT; i++) {const d = {x: (Math.random() - 0.5) * 140,z: (Math.random() - 0.5) * 140,scale: 0.4 + Math.random() * 1.8,phase: Math.random() * Math.PI * 2,};data.push(d);dummy.position.set(d.x, 0, d.z);dummy.scale.setScalar(d.scale);dummy.updateMatrix(); // ← 少这一步,矩阵全是单位阵,所有方块会叠在一起mesh.setMatrixAt(i, dummy.matrix);// 实例级颜色:无需改材质,GPU 侧一张颜色表搞定mesh.setColorAt(i, color.setHSL(0.55 + Math.random() * 0.25, 0.75, 0.55));}mesh.instanceMatrix.needsUpdate = true;if (mesh.instanceColor) mesh.instanceColor.needsUpdate = true; // 首次写入后别忘了scene.add(mesh);// ---------- 每帧更新实例矩阵 ----------const clock = new THREE.Clock();renderer.setAnimationLoop(() => {const t = clock.getElapsedTime();for (let i = 0; i < COUNT; i++) {const d = data[i];dummy.position.set(d.x, Math.sin(t * 1.6 + d.phase) * 3 + 4, d.z);dummy.rotation.set(0, t * 0.8 + d.phase, 0);dummy.scale.setScalar(d.scale);dummy.updateMatrix();mesh.setMatrixAt(i, dummy.matrix);}mesh.instanceMatrix.needsUpdate = true; // ← 每帧都要置位,否则画面一动不动renderer.render(scene, camera);});
想立刻感受到差距?把 COUNT 从 10000 改成 100000,用 InstancedMesh 依然能跑;而如果用 for 循环 new Mesh,1 万个就已经开始卡了。
⚠️ 三大高频坑(90% 的人第一次都踩)
坑 1:忘了 needsUpdate = true——"代码没报错,画面没反应"
setMatrixAt / setColorAt 只是在CPU 内存里改数据,GPU 侧那份还是旧的。
首次创建后 → 要置位一次 每帧动态修改后 → 每帧都要置位
最迷惑的是它不报错,你只会看到"一万个方块全叠在原点"或者"动画卡死在第一帧"。
mesh.setMatrixAt(i, dummy.matrix);mesh.instanceMatrix.needsUpdate = true; // ✅ 改完就通知
坑 2:忘了 dummy.updateMatrix()——所有实例挤成一个点
dummy.position 改了 ≠ dummy.matrix 变了。矩阵是从 position/rotation/scale烘焙出来的,必须手动调用:
dummy.position.set(x, y, z);dummy.updateMatrix(); // ← 千万别漏mesh.setMatrixAt(i, dummy.matrix);
如果图省事,也可以用 mesh.setMatrixAt(i, new THREE.Matrix4().compose(pos, quat, scale)),效果一样。
坑 3:想单独改某一个实例的材质——做不到
10000 个实例共享同一个 Material 对象。你不能只给第 37 号方块换个贴图。
正确姿势分三档:
setColorAt(i, color),免费 | |
onBeforeCompile | |
实操心法:能用一个材质表达的差异,就用 instanceColor;表达不了的,就分成几个 InstancedMesh(例如"红色组 + 蓝色组"),依然只有 2 次 draw call。
🎯 顺路收个尾:让一万个物体也能"点得中"
第 9 期我们学了 Raycaster。当时的速查表里埋了一个字段——instanceId。现在可以揭晓了:
renderer.domElement.addEventListener('click', (e) => {const rect = renderer.domElement.getBoundingClientRect();pointer.x = ((e.clientX - rect.left) / rect.width) * 2 - 1;pointer.y = -((e.clientY - rect.top) / rect.height) * 2 + 1;raycaster.setFromCamera(pointer, camera);// 注意:实例化物体用 intersectObject(单数),不需要 recursiveconst hits = raycaster.intersectObject(mesh);if (hits.length) {const id = hits[0].instanceId; // ← 第几号实例被点中了mesh.setColorAt(id, new THREE.Color(0xff2e93));mesh.instanceColor.needsUpdate = true;console.log('点中了第', id, '号方块');}});
一万个物体,依然能精确到"第 37 号"——point、face、uv、instanceId 一应俱全。这就是为什么 InstancedMesh 不是"偷懒的降级方案",而是正规的主力方案。
📋 选型速查表:什么时候该用它
Mesh | ||||
InstancedMesh | ||||
mergeGeometries | ||||
Points |
💡 一句话选型:要动 → InstancedMesh;不动 → 合并几何体;只是一个点 → Points。
🧠 深度解读:从"逐个摆件"到"批量复制"的思维转变
这一期表面在讲一个 API,实际在讲一个思维分界线:
初学者思维:场景 = 一堆物体的集合,我一个个 add进去。渲染思维:场景 = 一批绘制任务,我按"能不能合并"来组织数据。
一旦你开始用第二种方式看场景,很多性能问题会自己消失:
铝型材框架上的 200 个螺栓孔标记 → 一个 InstancedMesh 草坪上的 5 万根草 → 一个 InstancedMesh + 顶点着色器做风摆 3D 数据看板里的柱状图 → 一个 InstancedMesh,柱子高度写进矩阵 星空/烟花/流星雨(第 20 期会讲)→ 本质是 InstancedMesh 或 Points
这也解释了为什么第 9 期的instanceId值得单独埋一个伏笔——拾取和批量渲染,本来就是同一枚硬币的两面:你用什么方式画,就得用什么方式选。
性能提示:如果实例数以十万计且每帧都在变,CPU 逐帧重算矩阵会成为新瓶颈。进阶做法是把每个实例的"相位/高度"作为instance attribute传进 Shader,让 GPU 自己算动画——这一步我们留到第 19 期 ShaderMaterial 再展开。
🌟 喜欢这篇文章?
3 秒钟做 3 件小事,支持一下:
❤️ 点个「在看」,让更多 3D 爱好者看到 💬 评论区聊聊你被多少 draw call 卡哭过 📤 转发给那个还在for 循环里 new Mesh 的朋友
你的支持,是我持续更新的动力!🙏