前言:200个设备的感人帧率,当我正在为自己刚绘制好的站房设备欣喜时,却没发现,真正的噩梦才刚刚开始,渲染的帧率连10都破不了,整个场景不能用卡来形容了,因为已经不能算是动态页面了,跟静态页面相差无几,看着这感人的帧率,我最终踏上了性能优化的道路...
如果有小伙伴看过我之前的一篇性能优化文章 【ThreeJS】InstancedMesh 实战:从20000个Mesh到1个Draw Call 应该就知道为什么这么卡,没错,还是DrawCall的问题。那为什么区区200个设备,就能让我的场景变成PPT呢?那是因为模型文件导入Three.js后,通常是一个Group对象,里面塞满了几十上百个Mesh子对象。普通的一个Mesh一帧调用一次DrawCall,但一个复杂模型可能由上百个Mesh组成,相当于一个"Mesh大礼包"。我这200个设备,每个都有260个Mesh,总共52000个Mesh,每一帧绘制都要调用52000次DrawCall,CPU直接干冒烟了,你说这不卡才怪了。。
以下是当前帧率:
可以看到卡出翔来了,那么我们就开始进入今天的正题,怎么把这么多的DrawCall,优化到跟德芙一样丝滑~(德芙打钱...)
首先我们需要收集信息判断瓶颈。我目前的场景上有52000个Mesh,200个模型以及52000次DrawCall,明显是CPU端的DrawCall提交瓶颈(浏览器每帧建议控制在1000次以内)。我们要做的就是把这52000个Mesh合并成尽可能少的DrawCall。
第一步:Blender预处理(模型减面+烘焙)
目标是把260个Mesh合并成1个,并把14种材质烘焙成1张Texture Atlas。
1. 导入模型
File → Import → 选择你的格式(STEP/FBX/OBJ)
2. 合并所有零件
- 按
A全选所有物体 Ctrl+J合并为单一物体
注意:全选后如果右键没有合并选项,可以按网格类型选择:
然后去右侧大纲视图随便右键个当前已是选中状态的物体,再回到场景里右键就有合并选项了。
3. UV重新展开(可选)
这是最容易被忽略但最重要的一步! 合并后的模型UV如果重叠在一起,烘焙出来的贴图会一团糟。
- 进入
Edit Mode(Tab键) - 按
A全选面 - 按
U→Smart UV Project (智能UV投射),确保UV islands分布合理且不重叠
4. 烘焙贴图(Texture Atlas)
- 点击顶部
Shading选项进入着色页面,新建一个图像(比如叫Atlas_2048),这就是烘焙目标
- 给每个材质添加图像纹理节点,连接到基础色(Base Color),并选中新建的Atlas图像
重复操作直到所有材质(我这里是14个)都连上了Atlas图像。看这里的数字,显示14就代表14个材质共享这张贴图:
- 切换到
Render Properties面板,选择Cycles渲染器,烘焙类型选Combined(有金属材质)或Diffuse(纯漫反射,贴图更小)
等进度条走完,得到一张合并了14种材质的贴图:
5. 清理材质
把之前的14个材质都删了,只保留一个使用Atlas贴图的材质:
导出模型,替换到场景看看效果:
见证奇迹! DrawCall从52000降到了200次,帧率从个位数回升到30+。这是因为CPU不用再疯狂提交绘制指令了,但200次DrawCall还是有点多,毕竟我们追求的是draw call为1的极致丝滑~。
第二步:InstancedMesh 终极优化
现在要祭出大杀器了。注意:这个方法只适用于200个设备是相同模型的情况! 如果是200种不同的设备(比如有空调、有变压器、有配电柜),请看文章末尾的"方案局限与替代方案"。
现在我们的模型只有1个Mesh+1个材质,完美符合InstancedMesh的条件。把200次DrawCall压到1次:
200个设备,52000次DrawCall → 1次DrawCall! GPU通过硬件实例化特性,一次指令批量渲染200个副本,CPU终于解放了。
至于帧率为什么和200次DrawCall时差不多,是因为测试机的GPU顶点处理或像素填充到了瓶颈(我这里就是因为GPU满载了)。
但在低端设备或更复杂场景下,减少DrawCall的收益肯定会非常明显滴~。
以下是完整代码(带资源释放和差异化颜色):
// ---------- 加载 GLB,使用 InstancedMesh 批量渲染 ----------
const cols = 10;
const rows = 20;
const totalCount = cols * rows;
const spacing = 1.3;
const glbPath = '/v6.glb';
const loader = new GLTFLoader();
loader.load(glbPath, (gltf) => {
const model = gltf.scene;
// 根据模型尺寸计算缩放
const box = new THREE.Box3().setFromObject(model);
const size = box.getSize(new THREE.Vector3());
const maxDim = Math.max(size.x, size.y, size.z, 0.001);
const scale = 0.6 / maxDim;
// 提取第一个Mesh作为实例模板(合并后的模型应该只有一个Mesh)
let baseMesh = null;
model.traverse((obj) => {
if (obj.isMesh) {
obj.castShadow = obj.receiveShadow = true;
if (!baseMesh) baseMesh = obj;
}
});
if (!baseMesh) {
console.warn('GLB 中未找到 Mesh,无法创建 InstancedMesh');
return;
}
// 克隆几何体和材质(防止影响原模型)
const geometry = baseMesh.geometry.clone();
// 防御性处理:材质可能是数组
const rawMaterial = baseMesh.material;
const material = Array.isArray(rawMaterial)
? rawMaterial[0].clone()
: rawMaterial.clone();
// 创建 InstancedMesh
const instancedMesh = new THREE.InstancedMesh(geometry, material, totalCount);
instancedMesh.castShadow = true;
instancedMesh.receiveShadow = true;
// 获取原始模型的世界旋转,保持朝向一致
const worldQuat = new THREE.Quaternion();
baseMesh.getWorldQuaternion(worldQuat);
const dummy = new THREE.Object3D();
const color = new THREE.Color();
let idx = 0;
for (let i = 0; i < rows; i++) {
for (let j = 0; j < cols; j++) {
// 设置位置
dummy.position.set(
(j - cols / 2 + 0.5) * spacing,
0,
(i - rows / 2 + 0.5) * spacing
);
dummy.quaternion.copy(worldQuat);
dummy.scale.set(scale, scale, scale);
dummy.updateMatrix();
instancedMesh.setMatrixAt(idx, dummy.matrix);
// 示例:给不同设备设置不同颜色(比如根据运行状态)
// 前100个绿色,后100个红色
if (idx < 100) {
color.setHex(0x00ff00);
} else {
color.setHex(0xff0000);
}
instancedMesh.setColorAt(idx, color);
idx++;
if (idx >= totalCount) break;
}
if (idx >= totalCount) break;
}
instancedMesh.instanceMatrix.needsUpdate = true;
instancedMesh.instanceColor.needsUpdate = true; // 如果用了setColorAt,必须设置这个
scene.add(instancedMesh);
// 释放原始模型占用的内存(很重要!)
model.traverse((obj) => {
if (obj.isMesh) {
obj.geometry.dispose();
if (Array.isArray(obj.material)) {
obj.material.forEach(m => m.dispose());
} else {
obj.material.dispose();
}
}
});
console.log(`InstancedMesh 创建完成,共 ${totalCount} 个实例,DrawCall: 1`);
}, undefined, (err) => console.error('GLB 加载失败:', err));
⚠️ 重要:方案局限与替代方案
本文方案有两个硬前提:
- 200个设备必须是相同几何体(一模一样的模型)
- 经过烘焙后只剩下一个材质(或最多几个,但InstancedMesh要求同Geometry+同Material)
如果你的场景是200个不同的设备(不同型号、不同几何体):
- BatchedMesh(Three.js r151+):支持不同几何体合并绘制,但控制更复杂
- Merge Geometry:把多个不同几何体合并成一个大的BufferGeometry,适合静态场景(缺点是失去单个设备的控制能力,无法单独点击选中)
- LOD + Frustum Culling:远处设备降低精度,视野外设备不渲染
- 按需加载:只渲染视野内的设备,配合Octree空间分割
关于交互:
如果需要点击选中某个设备,使用raycaster.intersectObject(instancedMesh),通过instanceId属性知道点中了第几个实例,然后可以通过getMatrixAt获取其位置信息。
总结
优化路径:52000 DrawCall(原始)→ 200 DrawCall(烘焙合并)→ 1 DrawCall(InstancedMesh)
核心思路就是减少CPU向GPU发送绘制指令的次数。Blender负责把"多Mesh多材质"变成"单Mesh单材质",InstancedMesh负责把"绘制200次"变成"绘制1次"。
如果你的场景也是大批量重复设备,试试这个方案,让你的帧率飞起来~