在 Web 端实现大规模粒子系统时,CPU 更新逐粒子位置的方式很快就会成为瓶颈——一万个粒子已经能感受到明显的帧率下降。本文记录我用 Three.js GPUComputationRenderer 在 GPU 上完成全部位置与速度计算,渲染十万级粒子的完整方案。
为什么需要 GPGPU
传统粒子系统在 CPU 端每帧遍历所有粒子,更新位置后上传到 GPU。对于一个十万粒子的系统,这意味着每帧上传 100000 × 3 × 4 = 1.2MB 的位置数据到 GPU——仅数据传输就足以拖垮帧率。
GPGPU(General-Purpose GPU)的核心思路是:把粒子数据存储在纹理中,用着色器在 GPU 端完成所有计算,全程不需要 CPU 参与。
纹理(Texture)本质上是二维数组。RGBA 四通道纹理的一格可以存储 vec4——恰好够存一个三维位置加一个额外分量。通过 Shader 读写纹理,就能实现 GPU 端的并行计算。
架构总览
整个系统由两条管线组成:一条负责物理模拟(计算),另一条负责渲染(显示)。
(Position Texture)
(Velocity Shader)
(Velocity Texture)
(Position Shader)
(更新后)
位置纹理同时被渲染管线读取——顶点着色器从中取粒子坐标,直接渲染到屏幕。整个过程形成闭环:计算管线写纹理,渲染管线读纹理。
第一步:初始化 GPUComputationRenderer
Three.js 提供了 GPUComputationRenderer 来管理 ping-pong 纹理对。它的原理是维护两份纹理,一帧读 A 写 B,下一帧读 B 写 A,交替进行。
const gpuCompute = new GPUComputationRenderer(
1024, // 纹理宽度
128, // 纹理高度 → 1024 × 128 = 131072 个粒子
renderer
);
// 创建初始位置纹理
const dtPosition = gpuCompute.createTexture();
const dtVelocity = gpuCompute.createTexture();
// 填充初始数据:随机分布在球体内
const posArray = dtPosition.image.data;
for (let i = 0; i < posArray.length; i += 4) {
const r = Math.random() * 50;
const theta = Math.random() * Math.PI * 2;
const phi = Math.acos(Math.random() * 2 - 1);
posArray[i] = r * Math.sin(phi) * Math.cos(theta);
posArray[i + 1] = r * Math.sin(phi) * Math.sin(theta);
posArray[i + 2] = r * Math.cos(phi);
posArray[i + 3] = 1; // w 分量留空
}
第二步:编写速度更新着色器
速度着色器是物理模拟的核心。每个纹素(texel)对应一个粒子,我们在这里计算施加在粒子上的力并更新速度。
// velocityFragmentShader.glsl
uniform float uDelta; // 帧时间间隔
uniform vec3 uAttractor; // 引力中心
uniform float uDamping; // 阻尼系数
void main() {
vec2 uv = gl_FragCoord.xy / resolution.xy;
// 读取当前速度和位置
vec4 vel = texture2D(textureVelocity, uv);
vec4 pos = texture2D(texturePosition, uv);
// 计算朝向吸引点的引力
vec3 dir = uAttractor - pos.xyz;
float dist = length(dir);
vec3 force = normalize(dir) * (20.0 / (dist * dist + 1.0));
// 应用力 + 阻尼
vel.xyz += force * uDelta;
vel.xyz *= uDamping;
gl_FragColor = vel;
}
注意 resolution uniform 是 GPUComputationRenderer 自动注入的,代表当前纹理的尺寸。你不需要手动设置它。
第三步:位置更新着色器
位置更新很简单——读取速度,乘以时间步长,加到当前位置上:
// positionFragmentShader.glsl
uniform float uDelta;
void main() {
vec2 uv = gl_FragCoord.xy / resolution.xy;
vec4 pos = texture2D(texturePosition, uv);
vec4 vel = texture2D(textureVelocity, uv);
// 位置 += 速度 × dt
pos.xyz += vel.xyz * uDelta;
gl_FragColor = pos;
}
第四步:渲染粒子
渲染管线中,顶点着色器从位置纹理读取粒子坐标。这里的关键技巧是用 gl_VertexID 映射到纹理坐标:
uniform sampler2D texturePosition;
uniform vec2 uTextureSize; // 1024 × 128
void main() {
// 将顶点 ID 映射到纹理 UV
float idx = float(gl_VertexID);
vec2 uv = vec2(
mod(idx, uTextureSize.x) / uTextureSize.x,
floor(idx / uTextureSize.x) / uTextureSize.y
);
// 从位置纹理读取粒子坐标
vec4 pos = texture2D(texturePosition, uv);
gl_Position = projectionMatrix * modelViewMatrix * vec4(pos.xyz, 1.0);
gl_PointSize = 2.0;
}
JavaScript 端每帧只需要做一件事——把计算结果的纹理绑定到渲染材质上,然后调用计算和渲染:
function animate() {
requestAnimationFrame(animate);
// 1. GPU 计算一步
gpuCompute.compute();
// 2. 把位置纹理绑定到粒子材质
const posTexture = gpuCompute.getCurrentRenderTarget(posVariable).texture;
particleMaterial.uniforms.texturePosition.value = posTexture;
// 3. 渲染
renderer.render(scene, camera);
}
性能对比
在同一台机器(M1 Pro / Chrome 126)上测试不同粒子数量下的帧率:
| 粒子数量 | CPU 更新 (FPS) | GPGPU (FPS) | 提升 |
|---|---|---|---|
| 10,000 | 60 | 60 | 1× |
| 50,000 | 28 | 60 | 2.1× |
| 100,000 | 12 | 59 | 4.9× |
| 500,000 | < 1 | 52 | 52× |
| 1,000,000 | — | 44 | — |
在十万粒子时 GPGPU 方案几乎无损耗,百万粒子时仍能保持 44 FPS。而 CPU 方案在五万粒子时就已经不可用了。
踩坑记录
1. 纹理浮点精度
必须使用 THREE.FloatType 纹理类型。如果用默认的 UnsignedByteType(8 位),位置精度只有 1/255,粒子会出现严重的量化跳跃。在移动端如果浮点纹理不支持,可以降级为 HalfFloatType。
2. WebGL2 与 GLSL3
如果使用 gl_VertexID,需要 WebGL2 上下文并在着色器中声明 #version 300 es。Three.js 的 RawShaderMaterial 不会自动添加版本声明,需要手动处理。WebGL1 降级方案是用 attribute 传入一个顶点 ID 数组。
3. 纹理尺寸限制
GPU 对纹理尺寸有上限(通常 4096×4096 或 16384×16384)。百万级粒子需要 1024×1024 的纹理,这在大多数设备上没问题。但如果要支持百万以上的粒子,建议分多个纹理或使用 Storage Buffer(WebGPU)。
完整源码已开源在 GitHub,包含可交互的在线 Demo 和性能分析工具。
总结
GPGPU 粒子系统的核心就三件事:把数据存进纹理、用着色器算物理、渲染时从纹理读坐标。理解了 ping-pong 机制后,可以很容易扩展出更复杂的效果——流场、碰撞、引力涡旋等。下一篇我会写如何在这个基础上加入 Curl Noise 流场,实现有机的烟雾消散效果。