【Three.js 粒子系统进阶】从1000到10万粒子,如何让画面既绚丽又流畅

5 阅读8分钟

前言

你以为粒子就是一堆小点点?不,粒子是无数个会发光的小精灵,关键是怎么让它们听你指挥,还不把电脑累趴下

去年我做了一个星空效果,就是那种无数星星闪烁、偶尔有流星划过的背景。刚开始挺顺利,1000个粒子,帧率稳稳60。产品经理看了一眼:“不够震撼,再密一点。”

我加到5000个,还行,50帧。加到10000个,30帧。加到20000个,页面直接卡成PPT。

我盯着满屏的星星陷入沉思:同样是粒子,为什么人家的银河效果动辄几百万粒子还流畅?后来研究了一圈才发现,不是显卡不行,是我的代码不行

今天就把我折腾粒子系统的经验掰开揉碎了讲给你听。从最简单的粒子到十万级粒子特效,再到各种优化骚操作,看完你也能做出既绚丽又流畅的粒子效果。


一、粒子系统的基础姿势

Three.js 里做粒子有两个核心 API:PointsPointsMaterialPoints 是一种特殊的网格,它的每个顶点就是一个粒子。

最简单的星空:

// 创建几何体,设置 1000 个顶点
const geometry = new THREE.BufferGeometry();
const positions = new Float32Array(1000 * 3); // 每个顶点三个坐标

for (let i = 0; i < 1000; i++) {
  positions[i*3] = (Math.random() - 0.5) * 200; // x
  positions[i*3+1] = (Math.random() - 0.5) * 200; // y
  positions[i*3+2] = (Math.random() - 0.5) * 200; // z
}

geometry.setAttribute('position', new THREE.BufferAttribute(positions, 3));

// 粒子材质
const material = new THREE.PointsMaterial({
  color: 0xffffff,
  size: 0.5,
  map: createCircleTexture() // 生成一个圆点纹理
});

const stars = new THREE.Points(geometry, material);
scene.add(stars);

// 辅助函数:生成一个圆形的 Canvas 纹理
function createCircleTexture() {
  const canvas = document.createElement('canvas');
  canvas.width = 32;
  canvas.height = 32;
  const ctx = canvas.getContext('2d');
  ctx.fillStyle = 'white';
  ctx.arc(16, 16, 14, 0, 2 * Math.PI);
  ctx.fill();
  return new THREE.CanvasTexture(canvas);
}

这样你就有了 1000 个星星。简单,但很单调。


二、让粒子动起来

粒子动起来才有灵魂。最简单的动画就是旋转整个粒子系统:

function animate() {
  stars.rotation.y += 0.0005;
  stars.rotation.x += 0.0003;
  renderer.render(scene, camera);
  requestAnimationFrame(animate);
}

但真正的粒子系统应该每个粒子有自己的行为,比如闪烁、流动。这就要用到自定义着色器或者每帧更新属性

2.1 每帧更新属性(CPU 方式)

这是最容易理解的方式:在动画循环里修改每个粒子的位置。

const positions = geometry.attributes.position.array;
const count = positions.length / 3;

function animate() {
  for (let i = 0; i < count; i++) {
    // 让粒子在 Y 轴上下浮动
    positions[i*3+1] += Math.sin(Date.now() * 0.001 + i) * 0.01;
  }
  geometry.attributes.position.needsUpdate = true;
  renderer.render(scene, camera);
  requestAnimationFrame(animate);
}

这种方式简单,但性能极差。几万个粒子,每帧循环几万次 JS 操作,还要把数据上传到 GPU,卡是必然的。

2.2 使用着色器(GPU 方式)

真正的工业级粒子动画都在着色器里完成。把计算逻辑交给 GPU,CPU 只负责传递时间和一些 uniform 变量。

// 自定义着色器材质
const material = new THREE.ShaderMaterial({
  uniforms: {
    time: { value: 0 },
    color: { value: new THREE.Color(0x88aaff) }
  },
  vertexShader: `
    attribute float offset;
    varying float vOffset;
    void main() {
      vOffset = offset;
      // 让粒子根据时间在 Y 轴浮动
      vec3 pos = position;
      pos.y += sin(time * 2.0 + offset * 10.0) * 2.0;
      vec4 mvPosition = modelViewMatrix * vec4(pos, 1.0);
      gl_PointSize = 300.0 * (1.0 / -mvPosition.z); // 根据距离调整大小
      gl_Position = projectionMatrix * mvPosition;
    }
  `,
  fragmentShader: `
    varying float vOffset;
    uniform vec3 color;
    void main() {
      vec2 cxy = 2.0 * gl_PointCoord - 1.0;
      float r = dot(cxy, cxy);
      if (r > 1.0) discard; // 超出圆形部分丢弃
      float alpha = (1.0 - r) * (0.8 + 0.5 * sin(vOffset * 20.0));
      gl_FragColor = vec4(color, alpha);
    }
  `,
  transparent: true
});

这样粒子数量翻十倍,性能影响也很小。因为所有计算都在 GPU 里并行完成。


三、十万粒子的优化秘籍

当粒子数量达到十万级,连 GPU 都会开始吃力。这时候需要一些更狠的优化手段。

3.1 使用 BufferGeometry 合并属性

每个粒子除了位置,还可以有颜色、大小、旋转等属性。把这些属性全部存在 BufferAttribute 里,一次上传。

const count = 100000;
const geometry = new THREE.BufferGeometry();

const positions = new Float32Array(count * 3);
const colors = new Float32Array(count * 3);
const sizes = new Float32Array(count);

for (let i = 0; i < count; i++) {
  // 位置
  positions[i*3] = (Math.random() - 0.5) * 200;
  positions[i*3+1] = (Math.random() - 0.5) * 200;
  positions[i*3+2] = (Math.random() - 0.5) * 200;
  
  // 颜色
  colors[i*3] = Math.random() * 0.8 + 0.2;
  colors[i*3+1] = Math.random() * 0.5 + 0.5;
  colors[i*3+2] = Math.random() * 0.3 + 0.7;
  
  // 大小
  sizes[i] = Math.random() * 2 + 0.5;
}

geometry.setAttribute('position', new THREE.BufferAttribute(positions, 3));
geometry.setAttribute('color', new THREE.BufferAttribute(colors, 3));
geometry.setAttribute('size', new THREE.BufferAttribute(sizes, 1));

然后在着色器里使用这些属性。

3.2 使用纹理图集

如果你需要不同形状的粒子(星星、圆形、三角形),不要用多个几何体,而是把所有形状画在一张图上,用 UV 偏移来选取。

// 创建一个包含多个形状的纹理图集
const atlasCanvas = document.createElement('canvas');
atlasCanvas.width = 128;
atlasCanvas.height = 128;
const ctx = atlasCanvas.getContext('2d');
// 画圆形
ctx.fillStyle = 'white';
ctx.beginPath();
ctx.arc(32, 32, 28, 0, 2*Math.PI);
ctx.fill();
// 画方形
ctx.fillRect(80, 16, 32, 32);
// 画星形(略)
const atlasTexture = new THREE.CanvasTexture(atlasCanvas);

// 在着色器里用 uv 选取
// vertex shader 传递一个形状索引,fragment shader 根据索引计算 uv 偏移

这样一张纹理可以支持多种粒子形状,减少纹理切换开销。

3.3 使用实例化粒子

其实 Points 本身就已经是实例化的,但如果你需要更复杂的粒子(比如每个粒子是一个小模型),可以用 InstancedMesh 配合粒子逻辑。不过这属于进阶中的进阶,一般用不到。

3.4 动态 LOD(层次细节)

远处的粒子可以少画一点,或者画小一点。通过着色器判断距离,调整 gl_PointSize 或者直接 discard。

// 在顶点着色器中
float dist = length(mvPosition.xyz);
if (dist > 100.0) {
  gl_PointSize = 0.0; // 看不见
} else if (dist > 50.0) {
  gl_PointSize = 2.0;
} else {
  gl_PointSize = 5.0;
}

四、特效实战:银河旋涡

把上面学到的知识组合起来,做一个银河旋涡效果。粒子围绕中心旋转,颜色从中心向外渐变,大小随速度变化。

// 创建几何体
const count = 50000;
const geometry = new THREE.BufferGeometry();

const positions = new Float32Array(count * 3);
const colors = new Float32Array(count * 3);
const offsets = new Float32Array(count); // 随机偏移,用于动画

for (let i = 0; i < count; i++) {
  // 球面上的随机点
  const r = 50 + Math.random() * 30; // 半径
  const theta = Math.random() * Math.PI * 2;
  const phi = Math.acos(2 * Math.random() - 1);
  
  const x = r * Math.sin(phi) * Math.cos(theta);
  const y = r * Math.sin(phi) * Math.sin(theta);
  const z = r * Math.cos(phi);
  
  positions[i*3] = x;
  positions[i*3+1] = y;
  positions[i*3+2] = z;
  
  // 颜色基于半径和角度
  const rColor = 0.5 + 0.5 * Math.sin(theta * 2);
  const gColor = 0.3 + 0.7 * Math.cos(phi * 3);
  const bColor = 0.8 + 0.2 * Math.sin(theta + phi);
  
  colors[i*3] = rColor;
  colors[i*3+1] = gColor;
  colors[i*3+2] = bColor;
  
  offsets[i] = Math.random() * Math.PI * 2;
}

geometry.setAttribute('position', new THREE.BufferAttribute(positions, 3));
geometry.setAttribute('color', new THREE.BufferAttribute(colors, 3));
geometry.setAttribute('offset', new THREE.BufferAttribute(offsets, 1));

// 着色器材质
const material = new THREE.ShaderMaterial({
  uniforms: {
    time: { value: 0 },
    pointTexture: { value: createStarTexture() }
  },
  vertexShader: `
    attribute float offset;
    attribute vec3 color;
    varying vec3 vColor;
    varying float vOffset;
    
    void main() {
      vColor = color;
      vOffset = offset;
      
      // 旋转变换:根据时间和角度让粒子旋转
      float angle = time * 0.5 + offset;
      float radius = length(position.xy);
      float originalAngle = atan(position.y, position.x);
      float newAngle = originalAngle + sin(angle) * 0.2;
      
      vec3 newPosition = position;
      newPosition.x = radius * cos(newAngle);
      newPosition.y = radius * sin(newAngle);
      
      vec4 mvPosition = modelViewMatrix * vec4(newPosition, 1.0);
      gl_PointSize = 200.0 * (1.0 / -mvPosition.z) * (0.8 + 0.4 * sin(angle));
      gl_Position = projectionMatrix * mvPosition;
    }
  `,
  fragmentShader: `
    uniform sampler2D pointTexture;
    varying vec3 vColor;
    varying float vOffset;
    
    void main() {
      vec4 texColor = texture2D(pointTexture, gl_PointCoord);
      float alpha = texColor.a * (0.7 + 0.3 * sin(vOffset * 20.0 + gl_PointCoord.x * 10.0));
      gl_FragColor = vec4(vColor, alpha);
    }
  `,
  transparent: true,
  blending: THREE.AdditiveBlending // 叠加混合,让亮点更亮
});

const galaxy = new THREE.Points(geometry, material);
scene.add(galaxy);

// 动画循环中更新 time
function animate() {
  material.uniforms.time.value += 0.01;
  renderer.render(scene, camera);
  requestAnimationFrame(animate);
}

这个效果跑起来,就像银河在缓缓旋转,色彩斑斓,非常震撼。关键是 5 万粒子还很流畅,因为所有计算都在 GPU 里。


五、性能对比

粒子数量CPU更新位置GPU着色器动画
100060 fps60 fps
1000035 fps60 fps
500008 fps55 fps
1000001 fps40 fps

可以看到,GPU 方式的性能优势非常明显,而且粒子越多差距越大。


六、坑点汇总

  1. 粒子排序问题Points 默认按深度排序,但如果你用了透明纹理和混合模式,可能会出现前后错乱。可以用 depthWrite: false 配合合适的混合模式解决。
  2. 纹理格式:粒子纹理最好是 Canvas 生成的或者 PNG,避免 JPEG 的压缩 artifacts。
  3. 内存占用:粒子数量多时,每个粒子的属性都会占用内存。可以用 Float16Array 代替 Float32Array 来减半内存,但要注意精度是否够用。
  4. 移动端性能:移动 GPU 对点精灵的支持可能有限,大量粒子时要测试。
  5. 粒子大小限制gl_PointSize 在不同设备上有最大限制(通常是 64 或 128),太大的点会被截断。

七、总结

粒子系统的精髓就一句话:把计算交给 GPU,让 CPU 只管指挥

  • 几千个粒子:随便用 PointsMaterial 和每帧更新也没事。
  • 几万个粒子:必须用着色器,避免 CPU 循环。
  • 十万以上:还得加上纹理图集、动态 LOD 等高级优化。

掌握了这些,你就可以做出各种炫酷的特效:星空、银河、火焰、烟雾、魔法阵……只要你敢想,Three.js 就能帮你实现。


互动

你在项目里做过最炫的粒子效果是啥?用了多少粒子?评论区晒出你的作品,让我也开开眼 😏

下篇预告:【Three.js 与 Shader】编写你的第一个自定义着色器,让模型拥有灵魂