【Three.js】day106-geometry-performance

作者:mario 发布时间: 2026-09-07 阅读量:7 评论数:0

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

attribute

内容

缺了会怎样

position

顶点坐标 (x,y,z)

形状不存在

normal

法线方向 (nx,ny,nz)

光照计算错误/全黑

uv

纹理坐标 (u,v)

贴图无法定位(Day 107 详解)

今天先理解 position/normal;uv 是 Day 107 贴图的主场,记住"它存在"即可。


二、从 BoxGeometry 看 Three.js 替你做了什么

对照表第 4 行(几何数据)——今天可以写全了:

环节

手写 WebGL

Three.js

渲染循环

rAF + 手动矩阵 + draw

renderer.render(scene, camera)

光照

手写着色器

new DirectionalLight(...)

几何体

手动数顶点/索引(BoxGeometry 24 顶点 36 索引)

new THREE.BoxGeometry(1,1,1)

几何数据

手动填 Float32Array + 手动绑定 attribute

构造函数内部完成 setAttribute + 法线计算 + 索引生成

结论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 的量级认知

数量级

表现

对策

< 100

随便跑

不用管

100 ~ 300

开始吃力

合并几何/实例化

> 500

明显卡顿

必须实例化/合批

大屏工业场景动辄几十上百个设备/传感器节点——实例化是"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 性能验收的弹药


九、总结

环节

要点

BufferGeometry

几何 = 顶点/法线/UV 数组;computeVertexNormals() 补法线

封装两面

BoxGeometry 替你算了 24 顶点 36 索引——用其便利,知其成本

draw call

CPU→GPU 提交次数 = 3D 第一瓶颈;上量必须处理

InstancedMesh

一份几何 N 个矩阵 = 1 次 draw call(50→1)

拾取

hits[0].instanceId → deviceId,边界不变(桥只传 id)

对照表

第 4 行补全:几何数据封装前后

50 台罐体一个 draw call,还都能点。明天给它们"皮肤":贴图与材质质感。


明日预告:Day 107 材质与贴图——纹理(Texture)与 UV、贴图类型(颜色/法线/粗糙度)、以及环境贴图(Environment)——彻底解决 Day 101 的"金属发黑",让材质有真实质感。

评论