Three.js 大规模粒子系统:WebGL 合批渲染与 GPU 内存的精准管控
一、炫目的粒子效果背后是 GPU 的瓶颈
Three.js 的粒子系统(Points + BufferGeometry)是 Web3 可视化中最常用的特效方案——数据流、节点网络、粒子风暴。一个 10 万粒子的场景在开发机上可能流畅运行 60fps,但放到集成显卡的笔记本或手机上,帧率可能跌到个位数。
性能瓶颈不在 CPU 侧的计算(10 万粒子的位置更新在 JS 中成本不到 1ms),而在 GPU 的两个关键位置:绘制调用数量和显存带宽。每个独立的 Points 对象都是一个 Draw Call。如果有 100 个独立的粒子组,就是 100 次 Draw Call——即使每组只有 1000 个粒子,GPU 的绘制管线也要切换 100 次上下文。
大规模粒子系统的优化路径清晰但容易被忽视:合并 BufferGeometry → 用 InstancedMesh 替代多个 Points → 控制 Attributes 的精度(float32 → float16)→ 正确管理 GPU 内存的分配和释放。
二、优化架构:从独立渲染到合并管线
flowchart TD
A[100 组独立粒子<br/>100 Draw Calls] –> B{优化路径}
B –> C[方案1: 合并 BufferGeometry<br/>1 Draw Call]
B –> D[方案2: InstancedMesh<br/>1 Draw Call + GPU Instancing]
B –> E[方案3: 自定义 Shader<br/>完全 GPU 计算]
C –> F[优势: 减少 99% Draw Call]
C –> G[劣势: 统一材质/粒子大小]
D –> H[优势: 支持不同颜色/大小]
D –> I[劣势: 需要 Transform 矩阵]
E –> J[优势: 百万级粒子]
E –> K[劣势: 开发复杂度高]
方案选择不是一刀切:合并 BufferGeometry 适合纯粒子(统一大小/颜色);InstancedMesh 适合需要不同变换的粒子组;自定义 Shader(GPU Particle)适合超大规模场景(> 50 万粒子)。实际场景常常是三层混合使用。
三、生产级实现
方案一:合并 BufferGeometry——减少 Draw Call
// lib/rendering/MergedParticleSystem.ts
import * as THREE from "three";
/**
* 合并粒子系统——把多个独立粒子组合并到一个 BufferGeometry
*
* 设计决策:
* – 预分配 Buffer:一次性分配总容量,避免运行时动态扩容导致的 GC
* – 使用 Float32Array 而非 Array:直接对接 GPU buffer,避免序列化开销
* – maxCount 上限 50 万:超出后建议走 GPU Particle 方案
*/
interface ParticleGroup {
positions: Float32Array; // [x1,y1,z1, x2,y2,z2, …]
colors: Float32Array; // [r1,g1,b1, r2,g2,b2, …] (optional)
sizes: Float32Array; // [s1, s2, …] (optional)
count: number;
}
class MergedParticleSystem {
private geometry: THREE.BufferGeometry;
private points: THREE.Points;
private totalCapacity: number;
private totalCount: number = 0;
/**
* @param maxCount 最大粒子总数——预分配 Buffer 的容量
* @param material 共享的 PointsMaterial(合并后只能用同一材质)
*
* 限制:合并后所有粒子共用材质。如果需要不同纹理或混合模式,
* 考虑 InstancedMesh 或分多个合并组。
*/
constructor(maxCount: number, material: THREE.PointsMaterial) {
this.totalCapacity = maxCount;
// 预分配 GPU Buffer——避免运行时 resize 导致的 buffer 重建
const positions = new Float32Array(maxCount * 3);
const colors = new Float32Array(maxCount * 3);
const sizes = new Float32Array(maxCount);
this.geometry = new THREE.BufferGeometry();
this.geometry.setAttribute(
"position",
new THREE.BufferAttribute(positions, 3)
);
this.geometry.setAttribute(
"color",
new THREE.BufferAttribute(colors, 3)
);
this.geometry.setAttribute(
"size",
new THREE.BufferAttribute(sizes, 1)
);
// 设置绘制范围——只绘制有数据的部分,未初始化的粒子不渲染
this.geometry.setDrawRange(0, 0);
this.points = new THREE.Points(this.geometry, material);
}
/**
* 合并一组粒子
* @returns 起始索引——后续更新子集时使用
*
* 并发安全:此方法不应在 requestAnimationFrame 回调中并发调用。
* 建议在初始化阶段或通过队列批量处理。
*/
merge(group: ParticleGroup): { startIndex: number; count: number } {
if (this.totalCount + group.count > this.totalCapacity) {
throw new Error(
`MergedParticleSystem: capacity exceeded. ` +
`${this.totalCount + group.count} > ${this.totalCapacity}`
);
}
const startIndex = this.totalCount;
// 直接写入预分配的 Float32Array——零拷贝
const posAttr = this.geometry.getAttribute("position") as THREE.BufferAttribute;
const colAttr = this.geometry.getAttribute("color") as THREE.BufferAttribute;
const sizeAttr = this.geometry.getAttribute("size") as THREE.BufferAttribute;
const posArray = posAttr.array as Float32Array;
const colArray = colAttr.array as Float32Array;
const sizeArray = sizeAttr.array as Float32Array;
// 位置:逐点写入
for (let i = 0; i < group.count; i++) {
const srcIdx = i * 3;
const dstIdx = (startIndex + i) * 3;
posArray[dstIdx] = group.positions[srcIdx];
posArray[dstIdx + 1] = group.positions[srcIdx + 1];
posArray[dstIdx + 2] = group.positions[srcIdx + 2];
}
// 颜色:如果有自定义颜色就用,否则用白色
if (group.colors) {
for (let i = 0; i < group.count; i++) {
const srcIdx = i * 3;
const dstIdx = (startIndex + i) * 3;
colArray[dstIdx] = group.colors[srcIdx];
colArray[dstIdx + 1] = group.colors[srcIdx + 1];
colArray[dstIdx + 2] = group.colors[srcIdx + 2];
}
} else {
for (let i = 0; i < group.count; i++) {
const dstIdx = (startIndex + i) * 3;
colArray[dstIdx] = 1.0;
colArray[dstIdx + 1] = 1.0;
colArray[dstIdx + 2] = 1.0;
}
}
// 大小
if (group.sizes) {
for (let i = 0; i < group.count; i++) {
sizeArray[startIndex + i] = group.sizes[i];
}
} else {
sizeArray.fill(material.size || 0.05, startIndex, startIndex + group.count);
}
this.totalCount += group.count;
// 更新绘制范围——包括新增的粒子
this.geometry.setDrawRange(0, this.totalCount);
// 标记 GPU 端 Buffer 需要更新
posAttr.needsUpdate = true;
colAttr.needsUpdate = true;
sizeAttr.needsUpdate = true;
return { startIndex, count: group.count };
}
/**
* 更新子集的粒子位置——用于运行时动画
* @param startIndex merge 时返回的起始索引
* @param count 粒子数量
* @param updateFn 位置更新回调:(index, position: [x,y,z]) => void
*
* 设计决策:
* – 不重建整个 Buffer——只更新有变化的范围
* – updateFn 参数使用出参模式避免每帧分配临时数组
*/
updatePositions(
startIndex: number,
count: number,
updateFn: (index: number, outPosition: number[]) => void
) {
const posAttr = this.geometry.getAttribute("position") as THREE.BufferAttribute;
const posArray = posAttr.array as Float32Array;
const tempPos = [0, 0, 0];
for (let i = 0; i < count; i++) {
updateFn(i, tempPos);
const dstIdx = (startIndex + i) * 3;
posArray[dstIdx] = tempPos[0];
posArray[dstIdx + 1] = tempPos[1];
posArray[dstIdx + 2] = tempPos[2];
}
posAttr.needsUpdate = true;
}
getObject(): THREE.Points {
return this.points;
}
getCount(): number {
return this.totalCount;
}
/**
* 释放 GPU 内存——在组件卸载或场景销毁时调用
* 不做这一步会导致显存泄漏,尤其在 SPA 中多次创建销毁粒子场景时
*/
dispose() {
this.geometry.dispose();
if (Array.isArray(this.points.material)) {
this.points.material.forEach((m) => m.dispose());
} else {
this.points.material.dispose();
}
}
}
方案二:InstancedMesh——GPU Instancing
// lib/rendering/InstancedParticleSystem.ts
import * as THREE from "three";
/**
* InstancedMesh 粒子系统——利用 GPU Instancing 绘制大量同类粒子
*
* 设计决策:
* – 使用 InstancedMesh 而非多个 Points:InstancedMesh 对 GPU 来说是
* 一次 Draw Call 绘制 N 个 instance,GPU 自动处理重复数据的传输
* – 每个 Instance 有独立的 transform 矩阵(4×4),
* 可以设置不同位置、旋转、缩放——比合并 Points 更灵活
* – 颜色通过 instanceColor 设置(Three.js 0.152+),
* 避免为每个 instance 传颜色纹理
*/
class InstancedParticleSystem {
private mesh: THREE.InstancedMesh;
private dummy: THREE.Object3D;
private matrix: THREE.Matrix4;
constructor(
count: number,
particleGeometry: THREE.BufferGeometry,
material: THREE.Material
) {
// 创建 InstancedMesh——所有 instance 共享几何体和材质
this.mesh = new THREE.InstancedMesh(particleGeometry, material, count);
// 为每个 instance 配置独立的 transform
this.mesh.instanceMatrix.setUsage(THREE.DynamicDrawUsage);
this.dummy = new THREE.Object3D();
this.matrix = new THREE.Matrix4();
}
/**
* 设置第 i 个粒子的变换
* @param index 粒子索引
* @param position 位置
* @param scale 缩放(粒子大小)
* @param rotation 旋转(对于圆形粒子可以忽略)
*/
setInstance(
index: number,
position: THREE.Vector3,
scale: number = 0.05,
rotation: THREE.Euler = new THREE.Euler(0, 0, 0)
) {
this.dummy.position.copy(position);
this.dummy.scale.setScalar(scale);
this.dummy.rotation.copy(rotation);
this.dummy.updateMatrix();
this.mesh.setMatrixAt(index, this.dummy.matrix);
}
/**
* 批量设置颜色——比逐个设快,因为只触发一次 GPU upload
*/
setColors(colors: THREE.Color[]) {
for (let i = 0; i < colors.length && i < this.mesh.count; i++) {
this.mesh.setColorAt(i, colors[i]);
}
// instanceColor 需要手动标记更新
if (this.mesh.instanceColor) {
this.mesh.instanceColor.needsUpdate = true;
}
}
/**
* 在每帧动画循环中批量更新 position
*
* 性能关键:
* – 跳过不可见粒子的矩阵计算
* – updateFn 返回 false 时标记粒子为不可见(scale 0)
*/
updateAll(updateFn: (index: number, pos: THREE.Vector3) => boolean) {
for (let i = 0; i < this.mesh.count; i++) {
const pos = new THREE.Vector3();
const visible = updateFn(i, pos);
if (visible) {
this.setInstance(i, pos, 0.05);
} else {
// 不可见粒子:设 scale 为 0(GPU 会自动跳过渲染)
this.dummy.scale.setScalar(0);
this.dummy.updateMatrix();
this.mesh.setMatrixAt(i, this.dummy.matrix);
}
}
this.mesh.instanceMatrix.needsUpdate = true;
}
getObject(): THREE.InstancedMesh {
return this.mesh;
}
dispose() {
this.mesh.geometry.dispose();
if (Array.isArray(this.mesh.material)) {
this.mesh.material.forEach((m) => m.dispose());
} else {
this.mesh.material.dispose();
}
}
}
GPU 内存监控——在生产环境中及时发现问题
// lib/rendering/GpuMemoryMonitor.ts
/**
* GPU 内存使用监控
*
* 设计决策:
* – 使用 performance.memory(Chromium 专属)做粗略估算
* – 在每 60 帧输出一次(约 1 秒间隔),避免日志风暴
* – 超过阈值触发降级:减少粒子数、降低纹理分辨率
*/
class GpuMemoryMonitor {
private frameCount = 0;
private onHighMemory: (usageMB: number) => void;
private thresholdMB: number;
constructor(thresholdMB = 1024, onHighMemory: (mb: number) => void) {
this.thresholdMB = thresholdMB;
this.onHighMemory = onHighMemory;
}
tick(renderer: THREE.WebGLRenderer) {
this.frameCount++;
if (this.frameCount % 60 !== 0) return;
const info = renderer.info;
const geometries = info.memory.geometries;
const textures = info.memory.textures;
const drawCalls = info.render.calls;
// Three.js 的 renderer.info 不直接提供 MB 数值
// 这里用 geometry/texture 数量和 draw call 做粗略预警
if (geometries > 500 || textures > 200) {
console.warn(
`[GPU Monitor] geometries: ${geometries}, textures: ${textures}, drawCalls: ${drawCalls}`
);
}
// Chromium 专属:更精确的 GPU 内存
const memory = (
performance as any
).memory;
if (memory) {
const usedMB = memory.usedJSHeapSize / (1024 * 1024);
if (usedMB > this.thresholdMB) {
this.onHighMemory(usedMB);
}
}
}
}
四、边界分析:优化是有限的
合并 BufferGeometry 的边界:
- 只能使用统一材质。如果需要不同透明度、混合模式或纹理,需要拆分为多个合并组
- 粒子更新需要知道 startIndex 和 count——如果合并组的成员在运行时动态增删,索引管理变复杂
- Float32Array 预分配占大量 JS 堆内存:50 万粒子 × (3+3+1) × 4 字节 = 14MB,加上 GPU 侧的镜像 buffer 还要再加一份
InstancedMesh 的边界:
- instanceMatrix 是 4×4 矩阵(16 floats),50 万 instance = 32MB VRAM——只为实现"不同位置"需要 32MB
- 对比 Points + BufferGeometry(3 floats 存位置),InstancedMesh 的显存开销是 5 倍
- InstancedMesh 不支持每个 instance 不同的纹理——所有 instance 共享同一材质
GPU Particle(自定义 Shader)的边界:
- 百万级粒子需要经验丰富的 GLSL 编程——shader 调试困难、跨 GPU 兼容性问题多
- Compute Shader 在 WebGL 2.0 中不可用——需要 WebGPU,但浏览器兼容性仍在推进
禁用场景:
- 移动端 WebGL Context 丢失频繁——需要 lostContext 事件处理和重建逻辑
- 集成显卡显存小(< 512MB)时——不能依赖 GPU 内存余量假设
- Safari 对某些 WebGL 扩展支持不完整——ANGLE_instanced_arrays 在旧版本 iOS 上可能缺失
常见误区: geometry.setDrawRange(0, count) 不等于释放了超出范围的 GPU 内存。Buffer 的空间已经分配(maxCount 大小),超出 count 的部分 GPU 不会读取,但显存已被占用。如果需要动态缩容,必须重建 BufferGeometry。
WebGL 上下文的生命周期管理:
在 SPA(如 Next.js DApp)中,Three.js 场景的生命周期和页面路由绑定。用户从 Dashboard 切到 Swap 页面再切回来,如果没有正确处理 lostContext 和场景重建,可能出现三种问题:一是前一个页面的 Three.js 场景没有 dispose,GPU 内存被泄漏;二是页面返回后场景重新初始化,但 WebGL 上下文可能被浏览器回收,导致黑屏;三是多个隐式场景实例共享同一上下文(如 THREE.Cache),相互干扰。
解决思路是在 React 组件的 cleanup 阶段显式管理:进入页面时创建 Renderer + Scene,退出时依次 dispose 所有 geometries、materials、textures,再调用 renderer.dispose()。对于频繁切换的场景,可以考虑将 Renderer 挂载到 _app 层并用 renderer.domElement 做 detach/attach,避免重复创建 WebGL 上下文。
粒子动画的 CPU-GPU 同步开销:
每帧更新 10 万粒子的位置意味着 JS 主线程需要写 30 万个 float 到 Float32Array,然后标记 needsUpdate = true。Three.js 在下一帧的 render() 调用中把这些数据上传到 GPU。如果 JS 主线程被其他任务(React reconciliation、fetch 回调)阻塞,上传可能会延迟,导致粒子出现画面撕裂或跳帧。优化手段包括:用 Web Worker 做位置计算(OffscreenCanvas)、使用 renderer.setAnimationLoop 替代 requestAnimationFrame 获得更稳定的帧调度,以及把 needsUpdate 标记放在每帧的最后一步以减少无效上传。
五、总结
Three.js 大规模粒子系统的优化靠三层方案:合并 BufferGeometry(减少 Draw Call,统一材质限制)、InstancedMesh(GPU Instancing,灵活但有显存 overhead)、自定义 Shader(GPU Particle,开发成本高)。
优先使用合并 BufferGeometry + 预分配 Float32Array,每帧只更新变化的范围。GPU 内存管理要在创建时预分配容量,销毁时 dispose() 释放,运行中监控 geometries/textures 数量。显存 vs Draw Call 是永恒权衡:合并减少 Draw Call 但无法动态缩容,InstancedMesh 灵活但显存开销大。建议 50 万以下用合并,50 万-200 万用 InstancedMesh,200 万以上评估 GPU Particle。
