在 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,交替进行。

JavaScript
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)对应一个粒子,我们在这里计算施加在粒子上的力并更新速度。

GLSL
// 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 自动注入的,代表当前纹理的尺寸。你不需要手动设置它。

第三步:位置更新着色器

位置更新很简单——读取速度,乘以时间步长,加到当前位置上:

GLSL
// 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 映射到纹理坐标:

GLSL — Vertex Shader
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 端每帧只需要做一件事——把计算结果的纹理绑定到渲染材质上,然后调用计算和渲染:

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
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 流场,实现有机的烟雾消散效果。