
海量粒子不卡顿Three.js 实例化渲染与视锥剔除的工程实践一、从万个 DOM 到百万顶点为什么传统绘制撑不住大规模场景去年接手一个数字孪生项目要把工厂里 12 万个传感器节点实时渲染到一个三维场景里。第一版用普通 Mesh 一个个塞页面一加载就吃满 4G 内存鼠标移一下掉帧 5 秒。这事我见过太多团队栽进去——以为是 GPU 算力不够其实瓶颈根本不在那。当可视化场景里出现十万、百万级的三维元素比如点云扫描、星系粒子、城市建筑群传统做法会迅速失效。最常见的问题是 Draw Call 爆炸。每个网格单独提交一次绘制指令CPU 与 GPU 之间的通信开销会随元素数量线性增长。绘制十万个球体意味着十万次 Draw Call。主流设备的图形驱动每秒只能稳定处理几千到几万次调用。超出之后帧率会断崖式下跌页面直接失去交互能力。某点云可视化项目曾因未做合批把 8 万点塞进普通 MeshFPS 从 60 直降到 3。另一个隐藏问题是内存。每个独立网格都持有自己的几何与材质副本。百万级重复几何会撑爆显存触发上下文丢失。这正是大规模三维可视化最典型的崩溃来源。实例化渲染Instanced Rendering正是为解决这两个问题而生。它把相同几何合并为一次绘制用属性数组区分每个实例的位姿与外观。二、GPU 批处理与视锥剔除实例化绘制的底层机制实例化渲染的核心是「一次提交多次变换」。GPU 接收一份基础几何再读取一个实例属性缓冲区。缓冲区里存放每个实例的变换矩阵、颜色、缩放等。着色器在顶点阶段把基础顶点乘以对应实例矩阵得到最终坐标。这样做把 Draw Call 数量从 N 降到 1。CPU 提交开销几乎消失显存也只保留一份几何。瓶颈从通信转移到了顶点计算而顶点计算正是 GPU 的强项。但即便只有一次 Draw Call屏幕外的实例仍会被完整计算。视锥剔除Frustum Culling能在提交前过滤掉相机视野之外的实例。下面是实例从数据到像素的完整链路这条链路把计算量控制在视野范围内。对于城市级场景视锥剔除往往能砍掉七成以上的无效实例帧率提升立竿见影。前面那个数字孪生项目加上视锥剔除后 FPS 从 12 回到 55。三、生产级实例化点云实现下面给出一个可复用的点云封装。它用InstancedMesh承载海量元素支持视锥剔除开关并处理属性更新与资源释放。import * as THREE from three; interface PointData { position: THREE.Vector3; color: THREE.Color; scale: number; } export class InstancedPointCloud { private mesh: THREE.InstancedMesh; private dummy new THREE.Object3D(); // 复用矩阵与颜色对象避免每帧创建造成 GC 抖动 private frustum new THREE.Frustum(); private projScreen new THREE.Matrix4(); constructor(geom: THREE.BufferGeometry, mat: THREE.Material, capacity: number) { // capacity 预留上限避免运行时频繁扩容触发整块缓冲重建 this.mesh new THREE.InstancedMesh(geom, mat, capacity); this.mesh.instanceMatrix.setUsage(THREE.DynamicDrawUsage); this.mesh.frustumCulled false; // 由我们手动剔除关闭内置整体包围盒剔除 } update(points: PointData[], camera: THREE.Camera) { if (points.length this.mesh.count) { throw new Error(实例数量超过容量上限请扩容或分块); } this.projScreen.multiplyMatrices(camera.projectionMatrix, camera.matrixWorldInverse); this.frustum.setFromProjectionMatrix(this.projScreen); let visible 0; for (let i 0; i points.length; i) { const p points[i]; // 视锥测试跳过视野外实例减少无效顶点计算 if (!this.frustum.containsPoint(p.position)) continue; this.dummy.position.copy(p.position); this.dummy.scale.setScalar(p.scale); this.dummy.updateMatrix(); this.mesh.setMatrixAt(visible, this.dummy.matrix); this.mesh.setColorAt(visible, p.color); visible; } this.mesh.count visible; // 仅上传变更部分降低总线带宽占用 this.mesh.instanceMatrix.needsUpdate true; if (this.mesh.instanceColor) this.mesh.instanceColor.needsUpdate true; } dispose() { this.mesh.geometry.dispose(); (this.mesh.material as THREE.Material).dispose(); this.mesh.dispose(); } }关键点在于三处。其一关闭内置整体剔除改用逐实例视锥测试避免整块被误删。其二复用Object3D与矩阵对象防止逐帧分配引发垃圾回收卡顿。其三更新时只标记needsUpdate驱动只上传脏数据。四、实例化的代价属性上限、剔除误差与适用边界实例化并非没有代价。首先实例属性缓冲有容量上限。超出后要么分块管理要么整体扩容。扩容会触发整块缓冲重建造成瞬时卡顿应在初始化时预估上限。某游戏项目因低估玩家数量缓冲区扩到三倍扩容瞬间主线程冻了 600 毫秒。其次逐实例视锥测试本身有成本。十万实例做一次遍历在 CPU 上仍需数毫秒。若场景频繁全量更新剔除收益可能被遍历开销抵消。对此应做空间分块只测试相机附近的区块。第三实例共享同一几何无法做逐实例的几何差异。需要不同形状时只能拆分为多个InstancedMesh。这会重新引入少量 Draw Call需在批次粒度上权衡。适用边界形状一致、数量巨大、位姿各异的场景收益最高如粒子、点云、重复建筑。形状各异、数量稀少、需独立交互的场景用普通网格反而更简单清晰。五、总结实例化渲染把大规模重复几何的 Draw Call 压缩到一次是三维可视化的基础性能手段。落地建议第一预估实例上限并预留容量避免运行时扩容抖动。第二结合逐实例视锥剔除只在视野内提交砍掉无效计算。第三复用变换对象并增量上传脏数据抑制 GC 与带宽开销。第四形状差异大或交互独立的场景回归普通网格更合适。最终在批次粒度、剔除精度与内存占用之间取得平衡。这条路在百万级粒子下能跑通回报是值得的。