90、C++项目实战:游戏引擎(粒子系统)

粒子系统,听起来挺唬人的对吧?

说白了,它就是游戏里那些「一堆小东西乱飞」的效果——火焰、烟雾、爆炸、魔法光效……全是粒子系统的功劳。我最早接触粒子系统是在做一款2D横版射击游戏的时候,当时想给子弹加个拖尾效果,结果自己手写了个简陋的粒子池,跑起来卡成PPT。嗯,从那以后我就明白了:粒子系统看着简单,坑其实不少。

粒子系统的核心思想

粒子系统的本质是什么?

就是「批量管理大量相似的小对象」。每个粒子有自己的位置、速度、颜色、生命周期……但它们的行为逻辑高度一致。你想想看,如果每个粒子都 new 一下、delete 一下,那性能直接崩了。

所以,我个人的习惯是:对象池 + 内存连续。这是粒子系统高性能的基石。

核心要点:粒子系统 = 粒子池 + 发射器 + 更新器 + 渲染器。四者缺一不可。

粒子数据结构设计

先看一个最基础的粒子结构。我在项目中一般这样写:

struct Particle {
    glm::vec2 position;   // 位置
    glm::vec2 velocity;   // 速度
    glm::vec4 color;      // 颜色(RGBA)
    float life;           // 当前剩余生命
    float maxLife;        // 最大生命(用于插值)
    float size;           // 大小
    bool active;          // 是否存活
};

注意,这里我用的是 struct 而不是 class。为什么?因为粒子数据需要频繁读写,struct 默认 public,少一层封装,性能更好。我曾经见过有人把粒子封装得花里胡哨,各种 getter/setter,结果一帧更新几万个粒子,性能直接腰斩。

小技巧:把粒子数据放在连续内存里(比如 std::vector<Particle>),CPU 缓存命中率会高很多。我实测过,连续内存比链表快 5 倍以上。

粒子池管理

粒子池不能频繁创建销毁对象。我的做法是:

class ParticlePool {
public:
    ParticlePool(size_t maxSize)
        : particles(maxSize)
        , aliveCount(0) {}

    Particle* Spawn() {
        if (aliveCount >= particles.size()) return nullptr;
        Particle* p = &particles[aliveCount];
        p->active = true;
        p->life = 1.0f;
        aliveCount++;
        return p;
    }

    void Update(float dt) {
        for (size_t i = 0; i < aliveCount; ) {
            particles[i].life -= dt;
            if (particles[i].life <= 0.0f) {
                // 把死亡的粒子交换到末尾
                std::swap(particles[i], particles[aliveCount - 1]);
                aliveCount--;
            } else {
                i++;
            }
        }
    }

private:
    std::vector<Particle> particles;
    size_t aliveCount;
};

这里有个关键点:死亡粒子交换到末尾。这样存活粒子始终在数组前部,遍历时只需要遍历 aliveCount 个元素,效率极高。我刚开始做的时候用的是 erase 删除,结果每帧都在搬移内存,卡得不行。

发射器设计

发射器负责生成粒子。不同的效果需要不同的发射策略:

发射器类型 适用场景 发射方式
点发射器 爆炸、火花 从中心向四周随机方向
区域发射器 烟雾、火焰 在矩形/圆形区域内随机位置
方向发射器 喷泉、子弹拖尾 沿指定方向发射
跟随发射器 角色身上的光环 位置跟随目标移动

我个人习惯把发射器做成可组合的。比如火焰效果 = 点发射器 + 方向发射器 + 随机速度偏移。这样复用性很高。

粒子更新与渲染

更新逻辑其实很简单:

void UpdateParticle(Particle& p, float dt) {
    p.position += p.velocity * dt;
    p.velocity.y += gravity * dt;  // 重力
    p.color.a = p.life / p.maxLife; // 透明度随生命衰减
    p.size *= 0.98f;                // 逐渐缩小
}

渲染方面,我建议用 GPU 实例化渲染。把所有粒子的位置、颜色、大小打包成一个顶点缓冲区,一次 draw call 搞定。如果还用老办法每个粒子单独绘制,几千个粒子就能把 GPU 拖垮。

注意:粒子纹理要用带透明通道的 PNG,并且开启混合模式(Blend Mode)。否则粒子叠加时会出现黑边,效果非常难看。我曾经在这个坑里爬了整整一下午。

性能优化要点

做粒子系统,性能是绕不开的话题。我总结了几条实战经验:

  • 控制粒子数量:移动端建议不超过 5000 个,PC 端不超过 20000 个。超过这个数,再好的优化也扛不住。
  • 使用对象池:绝对不要 new/delete 单个粒子。一次分配,重复使用。
  • 减少分支预测失败:更新粒子时尽量用连续内存,避免 if-else 嵌套太深。
  • LOD 策略:远处的粒子可以降低更新频率,或者直接不渲染。玩家根本注意不到。
  • 预计算:如果粒子轨迹是固定的(比如火焰的飘动路径),可以预计算好位置,运行时直接读取。

知识体系结构图

下面这张图是我自己整理粒子系统时画的,帮你理清整体脉络:

粒子系统知识体系 粒子系统 粒子池管理 发射器 更新器 渲染器 对象池 连续内存存储 死亡交换策略 预分配大小 发射策略 点/区域/方向 随机参数生成 可组合设计 物理更新 位置/速度/重力 生命周期衰减 颜色/大小插值 GPU实例化 单次Draw Call 纹理混合模式 LOD层级控制 性能优化:对象池 + 连续内存 + LOD

实战中的坑与经验

最后分享几个我踩过的坑:

  • 粒子数量暴增:有一次我忘了限制发射速率,结果一帧生成了 10 万个粒子,程序直接崩溃。后来我加了个 maxParticlesPerFrame 限制。
  • 纹理坐标错误:粒子纹理的 UV 坐标搞反了,结果火焰粒子显示成倒着的。排查了半天才发现是纹理加载时 Y 轴翻转的问题。
  • 多线程更新:如果粒子数量很大,可以考虑用多线程更新。但要注意,粒子之间的更新是独立的,不需要加锁,用 std::for_each(std::execution::par, ...) 就行。
  • 内存碎片:如果粒子池频繁扩容,vector 会重新分配内存,导致性能抖动。我建议一开始就分配好最大容量,比如 reserve(10000)

粒子系统说难不难,说简单也不简单。关键是把基础架构搭好,后面加什么特效都顺手。我现在的项目里,粒子系统已经成了标配,火焰、烟雾、魔法、爆炸……全是同一套代码在跑,只是参数不同而已。

嗯,今天就聊到这儿。代码都在上面了,你可以直接拿去用。有问题随时交流。

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