4. Babylonjs 中下雨

238 阅读4分钟

友情提示

这篇文章是我在做一版“实时雨效”时踩坑的记录。问题表面看起来很简单:SPS.setParticles() 每帧要跑 7ms+,Chrome Trace 里堆叠一片红。最后发现,真正的瓶颈并不在“移动 180 个粒子”,而是在 CPU 端“每帧逐顶点改数据”。如果你也在 Babylon.js 里用 SPS 做雨/雪/弹幕这类效果,下面这些优化能立刻救火。

进入正题

场景是这样的:有 180 个独立的小网格(雨滴),合入一个 SolidParticleSystem。每帧调用 sps.setParticles(),只是在更新每个粒子的 position。但为什么还是慢?SPS 的 setParticles() 干了很多“看不见”的活:

  • 逐顶点写入 positions,必要时还会旋转法线、写 colors/uvs。
  • 根据配置,可能重算法线、深度排序并重写 indices。
  • 最后把 positions/normals/colors/uvs 这些大缓冲更新到 GPU。

只要你的雨滴每个网格含有较多顶点,CPU 就在做“搬砖”——这才是 7ms 的来源,而不是 180 个循环本身。

快速减少耗时的开关

// 只更新位置,不动颜色/UV/旋转/包围盒/排序
sps.computeParticleColor = false;
sps.computeParticleTexture = false;
sps.computeParticleRotation = false;
sps.computeBoundingBox = false;
sps.depthSortParticles = false; // 同时构造时 enableDepthSort: false
sps.billboard = false;          // 如需面向相机,用实例化+billboard 更划算

// Mesh 侧再减负
sps.mesh.isPickable = false;    // 允许走 vb.updateDirectly,少一些状态切换
sps.mesh.freezeNormals();       // 不再上传法线;如材质可用,甚至禁用光照
// material.disableLighting = true; // 无光照可进一步省法线相关开销

// 若你不依赖自动可见性,锁定可见性盒
sps.isVisibilityBoxLocked = true; // 提前设置一个足够大的 boundingInfo

如果你的粒子不会“变形”(不改顶点),就不要开启 computeParticleVertex;否则会触发 ComputeNormals,这是 CPU 大头之一。

为什么这些开关有效

setParticles() 的关键路径大概是:

  • 每个粒子的每个顶点:写 positions,必要时旋转法线;可选写 colors/uvs;计算 AABB。
  • 如果允许变形/启用切面数据:重算法线。
  • 如果深度排序:排序所有粒子、重写 indices。
  • 最终把所有缓冲更新到 GPU。

雨滴是“整体平移”为主,这些逐顶点的 CPU 工作几乎都是冗余。因此“关开关”能立刻见效。

更彻底的优化:让 GPU 干该它干的事(Thin Instances)

雨滴非常适合用 Thin Instances:一个基础网格 + N 个实例矩阵。每帧只更新实例矩阵,顶点变换交给 GPU。CPU 时间从“按顶点写数组”变成“按实例改 16 个 float”。

// 1) 构建一个低面数的雨滴网格
const drop = BABYLON.MeshBuilder.CreateBox("drop", { width: 0.02, height: 0.3, depth: 0.02 }, scene);
drop.isPickable = false;
drop.alwaysSelectAsActiveMesh = false;
// 需要始终朝向相机可用 billboard,CPU 0 开销
drop.billboardMode = BABYLON.Mesh.BILLBOARDMODE_ALL;

// 2) 初始化实例矩阵
const count = 180;
const matrices = new Float32Array(count * 16);
for (let i = 0; i < count; i++) {
  const m = BABYLON.Matrix.Compose(
    new BABYLON.Vector3(1, 1, 1),
    BABYLON.Quaternion.Identity(),
    new BABYLON.Vector3(Math.random()*20-10, 10+Math.random()*5, Math.random()*20-10),
  );
  m.copyToArray(matrices, i * 16);
}
drop.thinInstanceSetBuffer("matrix", matrices, 16);

// 3) 每帧只改位移(矩阵第 14/13/12 分别是 x/y/z 平移)
scene.onBeforeRenderObservable.add(() => {
  for (let i = 0; i < count; i++) {
    const off = i * 16;
    matrices[off + 13] -= 0.2;         // 改 y
    if (matrices[off + 13] < -2) {
      matrices[off + 13] = 12;         // 重置回顶部
    }
  }
  drop.thinInstanceBufferUpdated("matrix");
});

这套下来,CPU 端每帧的花费通常是微乎其微的(远低于 1ms),而且随着粒子数增长,也比 SPS 的“按顶点写数组”扩展性好很多。

一点材料学

  • 雨滴网格尽量“少面”:细长平面(两三角形)+ billboard 就够了。
  • 透明材质尽量走 alphaTest 或小透明,避免排序;实在要半透明,Thin Instances 仍然比 SPS 重写 indices 要稳。
  • 不需要光照时,disableLighting = true 能再砍掉法线链路。

其他雨的做法

在 shadertoy 上经常看到用一个纯的fragment shader来做雨特效的,这样在一些场景下当然也是可以的,但是这个做法最大的问题就是shader太复杂,里面有很多for循环,这样也会导致gpu端的任务过高,毕竟下雨这个效果是基本上是全屏显示的,每一个像素点都要走fragment shader,可能会导致帧率偶发的不稳定。

结束语

做“雨”这种重复性很强的动态物体,SPS 的优势是“易改顶点”,但这恰好是 CPU 的负担;能不动顶点数据,就把工作交给 GPU。实测把 180 个雨滴从 SPS 改为 Thin Instances,帧内大头立马消失。如果你也在做雨的效果,可以直接使用Thin Instances。