海量粒子不卡顿:Three.js 实例化渲染与视锥剔除的工程实践
海量粒子不卡顿:Three.js 实例化渲染与视锥剔除的工程实践
一、从万个 DOM 到百万顶点:为什么传统绘制撑不住大规模场景
去年接手一个数字孪生项目,要把工厂里 12 万个传感器节点实时渲染到一个三维场景里。第一版用普通 Mesh 一个个塞,页面一加载就吃满 4G 内存,鼠标移一下掉帧 5 秒。这事我见过太多团队栽进去——以为是 GPU 算力不够,其实瓶颈根本不在那。
当可视化场景里出现十万、百万级的三维元素,比如点云扫描、星系粒子、城市建筑群,传统做法会迅速失效。最常见的问题是 Draw Call 爆炸。每个网格单独提交一次绘制指令,CPU 与 GPU 之间的通信开销会随元素数量线性增长。
绘制十万个球体,意味着十万次 Draw Call。主流设备的图形驱动每秒只能稳定处理几千到几万次调用。超出之后,帧率会断崖式下跌,页面直接失去交互能力。某点云可视化项目曾因未做合批,把 8 万点塞进普通 Mesh,FPS 从 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 与带宽开销。第四,形状差异大或交互独立的场景,回归普通网格更合适。最终在批次粒度、剔除精度与内存占用之间取得平衡。这条路在百万级粒子下能跑通,回报是值得的。
