前言
你以为粒子就是一堆小点点?不,粒子是无数个会发光的小精灵,关键是怎么让它们听你指挥,还不把电脑累趴下
去年我做了一个星空效果,就是那种无数星星闪烁、偶尔有流星划过的背景。刚开始挺顺利,1000个粒子,帧率稳稳60。产品经理看了一眼:“不够震撼,再密一点。”
我加到5000个,还行,50帧。加到10000个,30帧。加到20000个,页面直接卡成PPT。
我盯着满屏的星星陷入沉思:同样是粒子,为什么人家的银河效果动辄几百万粒子还流畅?后来研究了一圈才发现,不是显卡不行,是我的代码不行。
今天就把我折腾粒子系统的经验掰开揉碎了讲给你听。从最简单的粒子到十万级粒子特效,再到各种优化骚操作,看完你也能做出既绚丽又流畅的粒子效果。
一、粒子系统的基础姿势
Three.js 里做粒子有两个核心 API:Points 和 PointsMaterial。Points 是一种特殊的网格,它的每个顶点就是一个粒子。
最简单的星空:
// 创建几何体,设置 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着色器动画 |
|---|---|---|
| 1000 | 60 fps | 60 fps |
| 10000 | 35 fps | 60 fps |
| 50000 | 8 fps | 55 fps |
| 100000 | 1 fps | 40 fps |
可以看到,GPU 方式的性能优势非常明显,而且粒子越多差距越大。
六、坑点汇总
- 粒子排序问题:
Points默认按深度排序,但如果你用了透明纹理和混合模式,可能会出现前后错乱。可以用depthWrite: false配合合适的混合模式解决。 - 纹理格式:粒子纹理最好是 Canvas 生成的或者 PNG,避免 JPEG 的压缩 artifacts。
- 内存占用:粒子数量多时,每个粒子的属性都会占用内存。可以用
Float16Array代替Float32Array来减半内存,但要注意精度是否够用。 - 移动端性能:移动 GPU 对点精灵的支持可能有限,大量粒子时要测试。
- 粒子大小限制:
gl_PointSize在不同设备上有最大限制(通常是 64 或 128),太大的点会被截断。
七、总结
粒子系统的精髓就一句话:把计算交给 GPU,让 CPU 只管指挥。
- 几千个粒子:随便用
PointsMaterial和每帧更新也没事。 - 几万个粒子:必须用着色器,避免 CPU 循环。
- 十万以上:还得加上纹理图集、动态 LOD 等高级优化。
掌握了这些,你就可以做出各种炫酷的特效:星空、银河、火焰、烟雾、魔法阵……只要你敢想,Three.js 就能帮你实现。
互动
你在项目里做过最炫的粒子效果是啥?用了多少粒子?评论区晒出你的作品,让我也开开眼 😏
下篇预告:【Three.js 与 Shader】编写你的第一个自定义着色器,让模型拥有灵魂