4、Three.js性能基础:场景图优化、对象池模式、几何体复用与BufferGeometry

场景图优化:别让父子关系拖垮你

Three.js的场景图,说白了就是一个树形结构。每个物体挂在哪,决定了它的变换计算量。我见过不少新手,把所有模型直接扔到scene下面,结果一拖动场景,卡得跟幻灯片似的。

为什么会这样?因为每次更新变换矩阵,Three.js都要从根节点往下遍历。节点越多、层级越深,计算量就越大。我个人习惯是:把静态物体和动态物体分开管理。静态的放一个组里,动态的放另一个组里。这样更新动态物体时,不会波及到静态部分。

核心原则:能合并的节点就合并,能扁平化的层级就扁平化。别为了代码好看,搞出七八层嵌套。

举个例子。你有一堆地面上的小石头,它们不会动。那就把它们全部挂到一个Group下,然后把这个Group的matrixAutoUpdate设为false。手动更新一次矩阵后,后面就再也不计算了。

const stoneGroup = new THREE.Group();
stoneGroup.matrixAutoUpdate = false;
stoneGroup.updateMatrix(); // 手动更新一次
scene.add(stoneGroup);

嗯,这里要注意:如果你之后要移动这个Group,记得把matrixAutoUpdate改回true。我在项目中就吃过这个亏,调了半天发现石头不动了,原来是忘了改回来。

对象池模式:别频繁创建销毁对象

游戏里经常有子弹、粒子、敌人这类频繁出现又消失的东西。每次new一个对象,再把它remove掉,对垃圾回收器来说简直是噩梦。你想想看,每帧都在创建销毁,GC一触发,帧率直接掉到30以下。

对象池模式就是解决这个问题的。说白了,就是提前创建好一批对象,用的时候取出来,不用的时候放回去。不销毁,只隐藏。

我的经验:对象池的大小要根据场景峰值来定。我曾经做过一个射击游戏,子弹池设了100个,结果玩家疯狂扫射时,子弹不够用,新子弹出不来。后来改成200个,问题解决。

实现起来很简单。维护一个数组,里面存着所有可复用的对象。需要时从数组里pop一个出来,设置位置、旋转,然后加到场景里。用完了,从场景移除,再push回数组。

class ObjectPool {
  constructor(createFn, size = 50) {
    this.pool = [];
    for (let i = 0; i < size; i++) {
      this.pool.push(createFn());
    }
  }
  acquire() {
    return this.pool.pop() || null;
  }
  release(obj) {
    obj.visible = false;
    this.pool.push(obj);
  }
}

注意一点:对象放回池子时,记得重置它的状态。位置归零、旋转归零、可见性设为false。不然下次取出来,还带着上次的残留数据,画面就乱了。

几何体复用:同一个几何体,别new两次

这个道理其实很简单。你有一万个立方体,它们的位置、颜色不同,但形状是一样的。那就共用一个Geometry,每个立方体只保存自己的变换矩阵和材质参数。

我见过最离谱的代码,是在循环里new了一万个BoxGeometry。每个几何体都占内存,一万个就是一万份顶点数据。你想想看,这得浪费多少显存?

避坑指南:我曾经接手过一个项目,场景里全是树。每棵树都new了一个SphereGeometry和CylinderGeometry。我改成共用几何体后,内存占用从800MB降到了120MB。嗯,就是这么夸张。

正确的做法是:

const sharedGeo = new THREE.BoxGeometry(1, 1, 1);
for (let i = 0; i < 10000; i++) {
  const mesh = new THREE.Mesh(sharedGeo, material);
  mesh.position.set(Math.random() * 100, 0, Math.random() * 100);
  scene.add(mesh);
}

这里有个小技巧:如果你要修改几何体,比如改变顶点位置,记得用geometry.clone()。不然所有共用的Mesh都会跟着变。我刚开始学Three.js时就犯过这个错,改了一个立方体的顶点,结果所有立方体都变形了。

BufferGeometry:现代Three.js的标配

老版本的Three.js用的是Geometry,现在已经被标记为废弃了。新项目一律用BufferGeometry。为什么?因为BufferGeometry直接操作缓冲区,数据更紧凑,传输到GPU更快

说白了,Geometry是把数据存在JavaScript对象里,每次渲染都要转换成BufferGeometry。而BufferGeometry直接就是二进制数组,GPU拿来就能用。性能差距,你想想看。

创建BufferGeometry的典型方式:

const geometry = new THREE.BufferGeometry();
const vertices = new Float32Array([
  -1, -1, 0,
   1, -1, 0,
   0,  1, 0
]);
geometry.setAttribute('position', new THREE.BufferAttribute(vertices, 3));

这里要注意:setAttribute的第一个参数是属性名,第二个参数是BufferAttribute。属性名必须是Three.js认识的,比如'position'、'normal'、'uv'。写错了,渲染出来就是一团黑。

性能关键:尽量使用Float32Array而不是普通数组。普通数组在传给GPU前要转换,多了一步操作。直接上Float32Array,一步到位。

还有一个优化点:合并顶点属性。如果你有位置、法线、UV,可以放在同一个BufferAttribute里,或者至少保证它们的长度一致。这样GPU读取时,内存访问更连续,缓存命中率更高。

我个人习惯是,在创建几何体时,一次性把所有属性都准备好。不要后面再动态添加属性,那样会触发缓冲区重新分配,性能损耗不小。

总结一下

这四个点,说白了就是减少计算、减少内存、减少传输。场景图优化减少矩阵计算,对象池减少GC压力,几何体复用减少内存占用,BufferGeometry减少数据传输开销。

你把这些基础打牢了,后面讲的高级优化技巧才能发挥效果。不然你光想着用InstancedMesh,结果场景图一团糟,照样卡。

嗯,下一章我会讲纹理压缩与Mipmap,那是另一个性能大头。到时候见。