Day 106 · 几何进阶与性能 — BufferGeometry 原理与实例化
Day 102 你直接用
BoxGeometry(1,1,1)造零件——好用,但没想过"它背后是什么"。今天掀开盖子看两层:几何体的数据本质(BufferGeometry:顶点、索引、attribute),和3D 性能的第一瓶颈(draw call)。最后用 Three.js 最实用的一招——InstancedMesh 实例化——把"50 台设备"的 draw call 从 50 降到 1。这是"设备上量"的关键一课,也是 Day 105 待办清单里的性能主线。
目录
一、几何体的数据本质:BufferGeometry
1.1 一切几何都是"顶点数组"
3D 几何没有魔法,任何形状最终都是顶点坐标 + 索引的数组。BoxGeometry(1,1,1) 背后是:
顶点(position):8 个顶点 × xyz = 24 个数字
索引(index):12 个三角形 × 3 = 36 个索引
法线(normal):每个顶点一个方向(光照计算用,Day 101 的"法线朝向")
import * as THREE from "three";
// 一个最简三角形(BufferGeometry 的最小形态)——手写几何体入门
const geometry = new THREE.BufferGeometry();
// 3 个顶点:A(0,0,0) B(1,0,0) C(0,1,0)
const positions = new Float32Array([0, 0, 0, 1, 0, 0, 0, 1, 0]);
geometry.setAttribute("position", new THREE.BufferAttribute(positions, 3));
// 法线(朝 +Z,让光照能算)——缺了法线,Standard 材质全黑(Day 101 坑)
geometry.setAttribute("normal", new THREE.BufferAttribute(new Float32Array([0,0,1, 0,0,1, 0,0,1]), 3));
const mesh = new THREE.Mesh(geometry, new THREE.MeshStandardMaterial({ color: 0x00c6ff }));
记忆钩子:
Float32Array是"紧挨着的内存块",GPU 直接读——这就是为什么叫 Buffer(缓冲区)Geometry。2D 里你关心"怎么画",3D 里你关心"数据怎么喂给 GPU"。
1.2 三个核心 attribute
今天先理解 position/normal;
uv是 Day 107 贴图的主场,记住"它存在"即可。
二、从 BoxGeometry 看 Three.js 替你做了什么
对照表第 4 行(几何数据)——今天可以写全了:
结论:BoxGeometry 替你做了"算顶点、算法线、排索引"三件事。你不需要重写,但要能说出"一个立方体背后是 24 个顶点 + 36 个索引"——这就是封装的两面:用其便利,知其成本。
三、draw call:3D 性能的第一瓶颈
3.1 什么是 draw call
每次 renderer.render() 时,引擎要把"每个物体"提交给 GPU 绘制一次——一次提交就是一个 draw call。瓶颈不在 GPU 画得多快,而在于 CPU→GPU 的提交次数:
50 台设备 = 50 个独立 Mesh → 每帧 50 次提交(CPU 挨个指挥 GPU)
↓ 每个提交都有固定开销(状态切换、缓冲区绑定、命令下发)
↓ 设备一多,CPU 先被拖垮,帧率就崩了
3.2 draw call 的量级认知
大屏工业场景动辄几十上百个设备/传感器节点——实例化是"3D 上量"的必修课,也是 Day 105 待办"设备 > 500 时重审"的答案预演。
四、InstancedMesh:一调多画
4.1 原理
同一份几何 + 同一份材质,画 N 次——GPU 一次提交,循环画 N 个实例,每个实例只传一个"变换矩阵"(position/rotation/scale):
普通:50 个 Mesh = 50 次 draw call(50 份几何引用,各自提交)
实例化:1 个 InstancedMesh = 1 次 draw call(1 份几何,N 个矩阵)
4.2 基本用法
import * as THREE from "three";
// 1. 一份几何 + 一份材质(复用纪律 Day 102 的终极版)
const geometry = new THREE.BoxGeometry(1, 1, 1);
const material = new THREE.MeshStandardMaterial({ color: 0x00c6ff, metalness: 0.7, roughness: 0.4 });
// 2. 创建实例化网格:50 个实例
const count = 50;
const instanced = new THREE.InstancedMesh(geometry, material, count);
scene.add(instanced);
// 3. 用临时矩阵给每个实例定位(dummy 复用,避免每帧 new)
const dummy = new THREE.Object3D(); // 临时载体,只用来算矩阵
for (let i = 0; i < count; i++) {
dummy.position.set((i % 10) * 2, 0, Math.floor(i / 10) * 2); // 10×5 网格
dummy.updateMatrix(); // 把 position/rotation/scale 合成矩阵
instanced.setMatrixAt(i, dummy.matrix); // 写进第 i 个实例
}
instanced.instanceMatrix.needsUpdate = true; // 标记矩阵需要上传 GPU
4.3 每帧更新实例
// 循环里:只改 dummy 再 setMatrixAt——别在循环里 new Object3D(Day 100 坑 4)
const tick = () => {
const delta = clock.getDelta();
for (let i = 0; i < count; i++) {
dummy.position.set((i % 10) * 2, 0, Math.floor(i / 10) * 2);
dummy.rotation.y += 0.5 * delta; // 每个实例自转
dummy.updateMatrix();
instanced.setMatrixAt(i, dummy.matrix);
}
instanced.instanceMatrix.needsUpdate = true; // 每帧更新一次即可(批量)
renderer.render(scene, camera);
};
注意:NeedsUpdate 只在矩阵整体变化后置一次,不是每个实例都置——批量提交、单次标记,这正是实例化的性能哲学。
五、实战:50 台设备的实例化
把 Day 102 的 buildFanDevice 简化成"可实例化"形态(实例化要求单一几何 + 单一材质,复杂 Group 不适合直接实例化——见坑 3):
// src/core/three/create-instanced-devices.ts
import * as THREE from "three";
/**
* 用实例化批量创建"简单设备"(50 台,1 次 draw call)
* 注意:实例化只适合"同形状同材质"的重复物体;
* 复杂设备(多零件+多材质)用普通 Mesh 或模型(Day 108)
*/
export function createInstancedDevices(
scene: THREE.Scene,
layout: { x: number; z: number }[]
): THREE.InstancedMesh {
// 设备单元:圆柱机身(用 BoxGeometry 拉长更省,此处用 Cylinder 体现"罐体")
const geometry = new THREE.CylinderGeometry(0.4, 0.4, 1.2, 12);
const material = new THREE.MeshStandardMaterial({
color: 0x16233a, metalness: 0.7, roughness: 0.4,
});
const instanced = new THREE.InstancedMesh(geometry, material, layout.length);
scene.add(instanced);
const dummy = new THREE.Object3D();
layout.forEach((pos, i) => {
dummy.position.set(pos.x, 0.6, pos.z); // 罐体底部贴地(高 1.2 → 中心在 0.6)
dummy.rotation.y = Math.random() * Math.PI * 2; // 随机朝向,消除"复制感"
dummy.updateMatrix();
instanced.setMatrixAt(i, dummy.matrix);
});
instanced.instanceMatrix.needsUpdate = true;
return instanced;
}
观察点:随机 rotation 消除"复制粘贴感"——真实车间不会有 50 台朝向完全一致的罐体。这一个
Math.random()让画面从"测试网格"变成"场景"。
六、实例化后的拾取(InstancedMesh + Raycaster)
6.1 问题:命中后拿不到"是第几个"
InstancedMesh 是一个对象,intersectObjects 命中的是整个实例化网格,不是具体某一台。但 Three 提供了 instanceId:
// 拾取实例化物体:命中对象 + instanceId
const raycaster = new THREE.Raycaster();
raycaster.setFromCamera(ndc, camera);
const hits = raycaster.intersectObject(instanced); // 注意是 intersectObject 单对象
if (hits.length > 0) {
const instanceId = hits[0].instanceId; // ← 命中第几台(0~49)
const deviceId = `tank-${instanceId}`; // 由 id 反推设备逻辑 id(Day 103 桥契约)
emit({ type: "select", deviceId });
}
6.2 与 Day 104 拾取流程的衔接
Day 104:pickables = [deviceGroup, deviceGroup, ...](每个独立拾取)
今天: pickables = [instancedMesh](一个对象,靠 instanceId 区分)
边界不变:出向桥仍然只传 deviceId(string)。实例化的"物理细节"完全留在引擎内部——Vue 侧无感知,这是 Day 103 契约的可替换性红利(换实现方式不动接口)。
七、常见坑点
坑 1:忘了法线(normal attribute)
自定义几何没设 normal → Standard 材质全黑。修法:geometry.computeVertexNormals()(自动算法线)。
坑 2:每帧更新实例但忘了 needsUpdate
症状:实例动了,画面不动。原因:矩阵改了没标记上传。修法:批量更新后 instanceMatrix.needsUpdate = true。
坑 3:拿复杂 Group 硬套实例化
症状:多零件多材质设备"实例化不出来"或报错。原因:实例化要求单一几何+单一材质。修法:① 同形状重复件用实例化(罐体阵列)② 复杂设备用模型(Day 108)。
坑 4:拾取拿不到 instanceId
症状:点击整个网格都触发,或永远 null。原因:忘了 hits[0].instanceId,或用了 intersectObjects 数组形式。修法:intersectObject + 读 instanceId。
坑 5:循环里 new Object3D
症状:FPS 骤降。原因:for 循环里每次 new Object3D。修法:dummy 只建一次,循环里改属性(Day 100 纪律的实例化版)。
八、自测挑战
T1 · 手写三角形(40 分钟)
用 BufferGeometry 手写一个三角形,computeVertexNormals() 后加三灯照明,确认能看见。再写一个四边形(4 顶点 + 索引)——体会"索引省内存"。
T2 · 50 台罐体实例化(60 分钟)
完成第五节:10×5 罐体阵列、随机朝向、缓慢自转。在 Performance 面板看 draw call——用 Console 对比"50 个普通 Mesh" vs "50 实例"的 draw call 数量。
T3 · 实例化拾取(40 分钟)
实现第六节:点击罐体弹面板(显示 tank-N)。确认出向桥仍然只传 id,Vue 侧零改动。
T4 · 性能记录(20 分钟)
把"50 台"提到"500 台",记录 FPS 与 draw call 变化,写进笔记。这条数据是 Day 112 性能验收的弹药。
九、总结
50 台罐体一个 draw call,还都能点。明天给它们"皮肤":贴图与材质质感。
明日预告:Day 107 材质与贴图——纹理(Texture)与 UV、贴图类型(颜色/法线/粗糙度)、以及环境贴图(Environment)——彻底解决 Day 101 的"金属发黑",让材质有真实质感。