第十四章:粒子系统——让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 FeedbackCompute Shader 来做这件事。这里我用 Transform Feedback 举例,因为它兼容性更好。

核心思路:

  1. 准备两个 VBO,一个读,一个写
  2. 每帧用顶点着色器更新粒子属性
  3. 更新完后交换两个 VBO
  4. 用更新后的数据渲染粒子

我的经验: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 知识体系总览

下面这张图,是我自己总结的粒子系统核心流程。你看一遍,基本就明白整个架构了。

粒子系统核心流程 1. 粒子初始化 位置/速度/颜色/生命 2. GPU更新(每帧) Transform Feedback 3. 渲染粒子 点精灵/三角形 下一帧循环 生命周期判断 life > 0 life ≤ 0 更新位置/速度/颜色 重置到发射器位置 输出到渲染管线

14.7 性能优化建议

做粒子系统,性能是绕不开的坎。我踩过的坑,总结成几条:

  • 粒子数量控制:移动端建议不超过 5000 个粒子,PC 端可以到 20000
  • 使用点精灵(Point Sprite):比三角形渲染快得多,一个点就是一个粒子
  • 合并绘制调用:所有粒子一次 draw call 搞定,别分开画
  • 避免 CPU 读取 GPU 数据:Transform Feedback 的数据不要读回 CPU,否则卡死你

我的小技巧:如果粒子数量实在太多,可以考虑 LOD(细节层次)。远处的粒子用更少的顶点,或者干脆不渲染。人眼对远处的细节不敏感,省下来的性能留给近处的粒子。

嗯,粒子系统就讲到这里。你动手试试,先做一个简单的烟花,再慢慢加火焰、加拖尾效果。代码跑起来的那一刻,你会觉得一切都值了。


公众号:蓝海资料掘金营,微信deep3321