第十四章:粒子系统——让GPU帮你放烟花
粒子系统,说白了就是一群小点点的狂欢。每个粒子都有自己的位置、速度、颜色、大小,还有——生命周期。嗯,这个生命周期很关键,它决定了粒子什么时候出生、什么时候消亡。
我在做第一个粒子特效时,犯过一个低级错误:所有粒子同时出生,同时死亡。结果画面就像呼吸灯一样,一亮一灭,毫无美感。后来才明白,真正的粒子系统,每个粒子都有自己的「人生轨迹」。
14.1 粒子的生命周期管理
一个粒子从生到死,通常经历这几个阶段:
- 出生(Spawn):初始化位置、速度、颜色、大小
- 活跃(Active):每帧更新位置、颜色衰减、大小变化
- 消亡(Dead):生命值归零,回收或隐藏
每个粒子都有一个 life 属性,从 1.0 逐渐减到 0.0。当 life ≤ 0 时,粒子就该「转世投胎」了。
核心数据结构:每个粒子至少包含以下字段
// 单个粒子的属性
struct Particle {
vec3 position; // 位置
vec3 velocity; // 速度
vec4 color; // 颜色(含透明度)
float size; // 大小
float life; // 剩余生命 [0.0, 1.0]
};
你想想看,如果每个粒子都在 CPU 上单独更新,几千个粒子还好,几万个呢?几十万个呢?CPU 会直接崩溃给你看。所以,我们要把更新逻辑搬到 GPU 上。
14.2 GPU粒子更新——为什么快?
GPU 的强项是并行计算。每个粒子对应一个线程,互不干扰。我习惯用 Transform Feedback 或 Compute Shader 来做这件事。这里我用 Transform Feedback 举例,因为它兼容性更好。
核心思路:
- 准备两个 VBO,一个读,一个写
- 每帧用顶点着色器更新粒子属性
- 更新完后交换两个 VBO
- 用更新后的数据渲染粒子
我的经验:Transform Feedback 的「乒乓缓冲」模式,说白了就是双缓冲。一个缓冲区存当前帧数据,另一个存下一帧数据。帧尾交换指针,简单高效。
14.3 顶点着色器中的粒子更新逻辑
顶点着色器里,我们做两件事:更新位置、更新生命值。
// 顶点着色器:粒子更新
#version 300 es
in vec3 aPosition;
in vec3 aVelocity;
in vec4 aColor;
in float aSize;
in float aLife;
out vec3 vPosition;
out vec3 vVelocity;
out vec4 vColor;
out float vSize;
out float vLife;
uniform float uDeltaTime;
uniform vec3 uGravity; // 重力,比如 (0, -9.8, 0)
uniform vec3 uEmitterPos; // 发射器位置
void main() {
// 如果粒子还活着
if (aLife > 0.0) {
// 更新速度(加重力)
vec3 newVel = aVelocity + uGravity * uDeltaTime;
// 更新位置
vec3 newPos = aPosition + newVel * uDeltaTime;
// 生命衰减
float newLife = aLife - uDeltaTime;
vPosition = newPos;
vVelocity = newVel;
vColor = aColor;
vSize = aSize;
vLife = newLife;
} else {
// 粒子死亡,重新出生在发射器位置
vPosition = uEmitterPos + randomOffset(); // 随机偏移
vVelocity = randomDirection() * initialSpeed;
vColor = vec4(1.0, 0.5, 0.0, 1.0); // 重置颜色
vSize = 10.0;
vLife = 1.0; // 满血复活
}
}
注意:上面的 randomOffset() 和 randomDirection() 是伪随机函数,需要自己实现。我一般用 sin(dot(coord, vec2(12.9898, 78.233))) * 43758.5453 这种技巧来生成随机数。
14.4 实现烟花特效
烟花的核心是「爆炸扩散」。一个烟花粒子从中心炸开,速度方向随机,颜色从亮变暗,大小从大变小。
我在项目中做过一个国庆烟花秀,当时踩了个坑:粒子数量太多,帧率直接掉到 20。后来发现是粒子大小没控制好,屏幕上一堆大圆点,GPU 填充率爆了。解决办法是:根据距离调整粒子大小,远处的粒子小一点。
烟花粒子的关键参数:
| 参数 | 说明 | 典型值 |
|---|---|---|
| 初始速度 | 爆炸时的扩散速度 | 50-200 单位/秒 |
| 生命时长 | 粒子存活时间 | 1.0-3.0 秒 |
| 颜色变化 | 从亮色渐变为暗色 | rgba → rgba(0,0,0,0) |
| 大小变化 | 从大到小 | 20.0 → 2.0 |
14.5 实现火焰特效
火焰和烟花不同。火焰是持续喷射的,粒子从底部升起,向上飘散,颜色从黄变红再变透明。
我记得第一次做火焰时,效果像一坨橙色的果冻。后来加了两个关键改进:
- 速度扰动:每个粒子的水平速度加一点随机抖动,火焰看起来更自然
- 透明度渐变:粒子上升过程中,alpha 值逐渐降低,尾部消失更平滑
// 火焰粒子更新(片段着色器中的颜色计算)
if (life > 0.5) {
// 底部:亮黄色
color = mix(vec4(1.0, 0.8, 0.0, 1.0), vec4(1.0, 0.3, 0.0, 0.8), (1.0 - life) * 2.0);
} else {
// 顶部:暗红色,逐渐透明
color = mix(vec4(0.8, 0.1, 0.0, 0.6), vec4(0.0, 0.0, 0.0, 0.0), (0.5 - life) * 2.0);
}
14.6 知识体系总览
下面这张图,是我自己总结的粒子系统核心流程。你看一遍,基本就明白整个架构了。
14.7 性能优化建议
做粒子系统,性能是绕不开的坎。我踩过的坑,总结成几条:
- 粒子数量控制:移动端建议不超过 5000 个粒子,PC 端可以到 20000
- 使用点精灵(Point Sprite):比三角形渲染快得多,一个点就是一个粒子
- 合并绘制调用:所有粒子一次 draw call 搞定,别分开画
- 避免 CPU 读取 GPU 数据:Transform Feedback 的数据不要读回 CPU,否则卡死你
我的小技巧:如果粒子数量实在太多,可以考虑 LOD(细节层次)。远处的粒子用更少的顶点,或者干脆不渲染。人眼对远处的细节不敏感,省下来的性能留给近处的粒子。
嗯,粒子系统就讲到这里。你动手试试,先做一个简单的烟花,再慢慢加火焰、加拖尾效果。代码跑起来的那一刻,你会觉得一切都值了。