【ThreeJS实战】我把200个设备优化到了1个DrawCall

152 阅读8分钟

前言: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直接干冒烟了,你说这不卡才怪了。。

以下是当前帧率:

threejs-test - Google Chrome 2026-02-03 22-50-32.gif

可以看到卡出翔来了,那么我们就开始进入今天的正题,怎么把这么多的DrawCall,优化到跟德芙一样丝滑~(德芙打钱...)

首先我们需要收集信息判断瓶颈。我目前的场景上有52000个Mesh,200个模型以及52000次DrawCall,明显是CPU端的DrawCall提交瓶颈(浏览器每帧建议控制在1000次以内)。我们要做的就是把这52000个Mesh合并成尽可能少的DrawCall。

第一步:Blender预处理(模型减面+烘焙)

目标是把260个Mesh合并成1个,并把14种材质烘焙成1张Texture Atlas。

1. 导入模型

FileImport → 选择你的格式(STEP/FBX/OBJ)

2. 合并所有零件

  • A 全选所有物体
  • Ctrl+J 合并为单一物体

注意:全选后如果右键没有合并选项,可以按网格类型选择:

image.png

然后去右侧大纲视图随便右键个当前已是选中状态的物体,再回到场景里右键就有合并选项了。

image.png

3. UV重新展开(可选)

这是最容易被忽略但最重要的一步! 合并后的模型UV如果重叠在一起,烘焙出来的贴图会一团糟。

  • 进入 Edit Mode(Tab键)
  • A 全选面
  • USmart UV Project (智能UV投射),确保UV islands分布合理且不重叠

image.png

4. 烘焙贴图(Texture Atlas)

  • 点击顶部Shading选项进入着色页面,新建一个图像(比如叫Atlas_2048),这就是烘焙目标

image.png

  • 每个材质添加图像纹理节点,连接到基础色(Base Color),并选中新建的Atlas图像

image.png

重复操作直到所有材质(我这里是14个)都连上了Atlas图像。看这里的数字,显示14就代表14个材质共享这张贴图:

image.png

  • 切换到Render Properties面板,选择Cycles渲染器,烘焙类型选Combined(有金属材质)或Diffuse(纯漫反射,贴图更小)

image.png

等进度条走完,得到一张合并了14种材质的贴图:

image.png

5. 清理材质

把之前的14个材质都删了,只保留一个使用Atlas贴图的材质:

image.png

image.png

导出模型,替换到场景看看效果:

threejs-test - Google Chrome 2026-02-03 22-53-17.gif

见证奇迹! DrawCall从52000降到了200次,帧率从个位数回升到30+。这是因为CPU不用再疯狂提交绘制指令了,但200次DrawCall还是有点多,毕竟我们追求的是draw call为1的极致丝滑~。

第二步:InstancedMesh 终极优化

现在要祭出大杀器了。注意:这个方法只适用于200个设备是相同模型的情况! 如果是200种不同的设备(比如有空调、有变压器、有配电柜),请看文章末尾的"方案局限与替代方案"。

现在我们的模型只有1个Mesh+1个材质,完美符合InstancedMesh的条件。把200次DrawCall压到1次:

threejs-test - Google Chrome 2026-02-03 23-02-12.gif

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));

⚠️ 重要:方案局限与替代方案

本文方案有两个硬前提:

  1. 200个设备必须是相同几何体(一模一样的模型)
  2. 经过烘焙后只剩下一个材质(或最多几个,但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次"。

如果你的场景也是大批量重复设备,试试这个方案,让你的帧率飞起来~