阅读视图

发现新文章,点击刷新页面。

【ThreeJS】InstancedMesh 实战:从20000个Mesh到1个Draw Call

前言:不知道有没有小伙伴跟我一样,刚接触web3D,接触ThreeJS,然后在做类似智慧站房这种项目的时候,兴致冲冲的把设备放至到场景,每一个设备摆放的位置都恰到好处,包括管道,流向动画等等,觉得这样应该就万无一失了,然而实际运行起来却发现,卧槽,好卡!!!

正题: 关于如何优化ThreeJS里设备一多就卡的问题,今天我们就来讲讲“InstancedMesh”,这是ThreeJS官方的一个API,官方是这么描述的:

这是一个支持实例化渲染的特殊网格模型。如果您需要渲染大量具有相同几何体和材质但世界变换不同的对象,请使用此类。使用“InstancedMesh”有助于减少绘制调用次数,从而提高应用程序的整体渲染性能。

不知道大伙懵了没,反正我第一次看到这描述的时候,也是云里雾里的,不过现在我自认为算是比较了解了,就给大家大白话翻译一下,意思就是:这个API可以让你场景里的模型无损影分身,就是不耗费多的性能,但能让场景里的模型分身多个出来,怎么样,是不是像魔法一样,那它是怎么做到的呢?

这时候就不得不提3d场景的绘制原理了,我相信大部分小伙伴都知道显卡这个东西,也知道它是用来为电脑渲染图形,web3d当然也是靠它渲染,但是,我要说但是了,虽然渲染是它来做,但别忘了咱们电脑老大哥CPU啊,事实就是,像这种场景多个模型卡顿的原因,可能瓶颈并不在GPU身上,而是CPU身上,怎么样,是不是又蒙了,我当初了解到也疑惑了一下,我给大家说一下,其实原因很简单

打个比方,把CPU比作一家餐馆的接单播报员,而GPU就是后厨接单的厨子,但是接单员只有一个(需要处理各种复杂的订单、优惠券叠加、会员判定、退单逻辑、库存检查),而厨子有很多(只需要固定几个步骤炒菜,其它都不用管)

现在店里来了一百个客人,这些客人要的都是蛋炒饭,但接单员每一单都跑去后厨说有一桌要蛋炒饭,有一桌要蛋炒饭...直到派完这一百桌蛋炒饭,在喊的过程中,其它厨子是闲着的状态,因为还没给它派单,现在大伙发现有哪里不太对劲吗?没错!那就是为什么接单员明明一百桌都是同样的菜,但为什么要分一百次派单呢,一次派完不行吗,嘿嘿嘿,这时候如果你用的是普通Mesh,那么答案就是不行,这是由Mesh这个API决定的

这时候就讲到了我们今天的主题,InstancedMesh,它就是那个对这种重复需求更专业的接单员,有十桌蛋炒饭它就会直接跟后厨说现在需要炒十桌蛋炒饭,不会来回去通知,这样后厨就可以火力全开,一次性把十桌蛋炒饭都做好,现在明白为什么我说瓶颈不在GPU上了吧,Three.js的卡顿,往往不是"显卡不够好",而是"CPU在疯狂传话,没让显卡满负荷干活"。

开始我们今天的实验,我现在创建了20000个BoxMesh,帧数只有可怜的10fps,要知道我这可是rtx3080,要是显卡差一点的,我都不敢想象。。。

图片.png

大伙也可以复制以下代码自己去试试自己的电脑能跑多少FPS,可以把FPS打在评论区

  // 200 个 box:200 列 x 100 行,整齐排列
  const cols = 200;
  const rows = 100;
  const spacing = 1.3;
  const geometry = new THREE.BoxGeometry(0.8, 0.8, 0.8);
  const material = new THREE.MeshBasicMaterial({ color: 0x00ff00 });

  for (let i = 0; i < rows; i++) {
    for (let j = 0; j < cols; j++) {
      const box = new THREE.Mesh(geometry, material);
      box.position.x = (j - cols / 2 + 0.5) * spacing;
      box.position.z = (i - rows / 2 + 0.5) * spacing;
      scene.add(box);
    }
  }

我相信看到这的大伙对于性能优化都有一定的追求,对于这种情况是很难容忍的,所以,我们可以用InstancedMesh开始优化它了!

我先卖个关子,给大家看看优化后的截图:

image.png

指标 20000个Mesh InstancedMesh 优化倍数
Draw Calls 20000 1 20000x
帧率(FPS) 10 144 14x
内存占用 ~500MB+ ~80MB 6x
CPU占用 90% 15% 6x

我就说一句,牛不牛!刚刚只有10fps,没眼看,现在一下把我显示器的刷新率跑满了,144FPS,并且从原来20000的Draw Call,降到了1Draw Call,也就是说接单员一次性跟后厨说要做这么多份蛋炒饭,然后后厨火力全开,一次性搞定!

好了,魔术总有揭秘的那天,下面就是优化后的代码:

  // ---------- InstancedMesh:200 列 x 100 行 = 20000 个 box,单次 draw call ----------
  const cols = 200;
  const rows = 100;
  const count = cols * rows;
  const spacing = 1.3; // 实例之间的间距
  const geometry = new THREE.BoxGeometry(0.8, 0.8, 0.8);
  const material = new THREE.MeshBasicMaterial({ color: 0x00ff00 });
  const instancedMesh = new THREE.InstancedMesh(geometry, material, count);
  // 设置为静态绘制,避免每帧更新实例矩阵
  instancedMesh.instanceMatrix.setUsage(THREE.StaticDrawUsage);
  const matrix = new THREE.Matrix4();
  const position = new THREE.Vector3();
  let idx = 0;
  for (let i = 0; i < rows; i++) {
    for (let j = 0; j < cols; j++) {
      // 以场景中心为原点,按行列均匀排布
      position.set(
        (j - cols / 2 + 0.5) * spacing,
        0,
        (i - rows / 2 + 0.5) * spacing
      );
      matrix.setPosition(position);
      instancedMesh.setMatrixAt(idx, matrix);
      idx++;
    }
  }
  // 更新包围球,便于视锥剔除等优化
  instancedMesh.computeBoundingSphere();
  scene.add(instancedMesh);

有几个明显的不同,第一是没有new mesh这个环节了,关于新增mesh都放在了 new THREE.InstancedMesh(geometry, material, count); 这个count就是告诉InstanceMesh需要新增多少个Mesh,然后在遍历之后把这个InstanceMesh添加到Scene里,中间的环节只是决定InstanceMesh里的Mesh应该在什么位置。

我再给大家讲几个InstanceMesh的坑,新手非常容易忽略

1.动态更新位置的正确姿势

// 错误:每帧都设置 needsUpdate,卡成PPT
function animate() {
  for(let i=0; i<20000; i++) {
    instancedMesh.setMatrixAt(i, newMatrix); // 别在循环里干这个!
  }
  instancedMesh.instanceMatrix.needsUpdate = true;
  requestAnimationFrame(animate);
}

// 正确:只更新变化的,且设置 DynamicDrawUsage
instancedMesh.instanceMatrix.setUsage(THREE.DynamicDrawUsage);
// 只在数据真的变了才 setMatrixAt + needsUpdate = true

2.Raycaster(射线) 咋交互InstanceMesh实例

const intersection = raycaster.intersectObject(instancedMesh);
if (intersection.length > 0) {
  const instanceId = intersection[0].instanceId;
  // 高亮该实例:需要配合 setColorAt
  instancedMesh.setColorAt(instanceId, new THREE.Color(0xff0000));
  instancedMesh.instanceColor.needsUpdate = true;
}

3.dispose 内存泄漏 -----(这个一定要重视,我有过很惨痛的经历....)

// 组件卸载时一定要:
geometry.dispose();
material.dispose();
instancedMesh.dispose(); // 释放GPU缓冲区
scene.remove(instancedMesh);
}

总结:InstancedMesh 不是魔法,它只是让 CPU 从 "发了20000个快递包裹" (每次都要打包、贴单、叫车)变成了 "发了一车货" (整车直达)。在智慧站房、智慧工厂这种 "设备多但型号少" 的场景,这是性价比最高的优化方案。

但别急着走——如果你的设备需要各自不同的贴图(比如每台机器显示不同的温度数字),InstancedMesh就力不从心了。这时候该用 Texture Atlas(纹理图集) 还是 合并几何体(MergeGeometry)

关注下篇: 【ThreeJS】多材质设备优化:一张贴图管理1000个设备的独立显示 ,咱们继续折腾性能优化。

互动:你的项目最多渲染过多少个Mesh?在评论区晒晒帧率,看看谁的优化更狠😏

10分钟带你用Three.js手搓一个3D世界,代码少得离谱!

🎬 核心概念:上帝的“片场”

在 Three.js 的世界里,想要画面动起来,你只需要凑齐这“四大金刚”:

1. 场景 (Scene) —— 你的片场

想象你是一个导演,首先你得有个场地。在 Three.js 里,Scene 就是这个场地。它是一个容器,用来放置所有的物体、灯光和摄像机。

const scene = new THREE.Scene(); //(这就开辟了一个场地)

2. 摄像机 (Camera) —— 你的眼睛

片场有了,观众怎么看?得架摄像机。 Three.js 里最常用的是 透视摄像机 (PerspectiveCamera)。 这就好比人的眼睛,近大远小

  • 你需要告诉它:
    • 视角 (FOV):镜头是广角还是长焦?
    • 长宽比 (Aspect):电影是 16:9 还是 4:3?
    • 近剪切面 & 远剪切面:太近看不见,太远也看不见。

3. 渲染器 (Renderer) —— 你的放映机

场景布置好了,摄像机架好了,谁把画面画到屏幕(Canvas)上?这就是渲染器的工作。它负责计算每一帧画面,把 3D 的数据“拍扁”成 2D 的像素点显示在网页上。

4. 网格 (Mesh) —— 你的演员 🕺

这是最关键的部分!片场不能是空的,得有东西。在 Three.js 里,一个可见的物体通常被称为 Mesh (网格)。 一个 Mesh 由两部分组成(缺一不可):

  • 几何体 (Geometry)演员的身材。是方的?圆的?还是复杂的角色模型?(比如 BoxGeometry 就是个立方体骨架)。
  • 材质 (Material)演员的衣服。是金属质感?塑料质感?还是发光的?什么颜色?(比如 MeshPhongMaterial 就是一种这就好比给骨架穿上了皮肤)。

⚡️ 实战:3分钟手搓一个旋转立方体

别眨眼,核心代码真的少得离谱。我们来把上面的概念串起来:

第一步:搭建舞台(初始化)

// 1. 创建场景
const scene = new THREE.Scene();

// 2. 创建摄像机 (视角75度, 宽高比, 近距0.1, 远距1000)
const camera = new THREE.PerspectiveCamera(75, window.innerWidth / window.innerHeight, 0.1, 1000);
// 把摄像机往后拉一点,不然就在物体肚子里了
camera.position.z = 5;

// 3. 创建渲染器
const renderer = new THREE.WebGLRenderer();
renderer.setSize(window.innerWidth, window.innerHeight);
// 把渲染出来的 canvas 塞到页面里
document.body.appendChild(renderer.domElement);

第二步:请演员入场(创建物体)

// 1. 骨架:一个 1x1x1 的立方体
const geometry = new THREE.BoxGeometry(1, 1, 1);

// 2. 皮肤:绿色的,对光照有反应的材质
const material = new THREE.MeshPhongMaterial({ color: 0x44aa88 });

// 3. 合体:创建网格
const cube = new THREE.Mesh(geometry, material);

// 4. 放到场景里!
scene.add(cube);

第三步:打光(Light)

如果你用的是 MeshPhongMaterial 这种高级材质,没有光就是漆黑一片。

// 创建一个平行光(类似太阳光)
const light = new THREE.DirectionalLight(0xFFFFFF, 1);
light.position.set(-1, 2, 4);
scene.add(light);

第四步:Action!(动画循环)

电影是每秒 24 帧的静态图,Three.js 也一样。我们需要一个循环,不停地让渲染器“拍照”。

function animate() {
    requestAnimationFrame(animate); // 浏览器下次重绘前调用我

    // 让立方体动起来
    cube.rotation.x += 0.01;
    cube.rotation.y += 0.01;

    // 咔嚓!渲染一帧
    renderer.render(scene, camera);
}

animate(); // 开始循环

📂 核心代码与完整示例:  my-three-app

总结

如果你喜欢本教程,记得点赞+收藏!关注我获取更多Three.js开发干货

❌