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 策略:远处的粒子可以降低更新频率,或者直接不渲染。玩家根本注意不到。
- 预计算:如果粒子轨迹是固定的(比如火焰的飘动路径),可以预计算好位置,运行时直接读取。
知识体系结构图
下面这张图是我自己整理粒子系统时画的,帮你理清整体脉络:
实战中的坑与经验
最后分享几个我踩过的坑:
- 粒子数量暴增:有一次我忘了限制发射速率,结果一帧生成了 10 万个粒子,程序直接崩溃。后来我加了个
maxParticlesPerFrame限制。 - 纹理坐标错误:粒子纹理的 UV 坐标搞反了,结果火焰粒子显示成倒着的。排查了半天才发现是纹理加载时 Y 轴翻转的问题。
- 多线程更新:如果粒子数量很大,可以考虑用多线程更新。但要注意,粒子之间的更新是独立的,不需要加锁,用
std::for_each(std::execution::par, ...)就行。 - 内存碎片:如果粒子池频繁扩容,vector 会重新分配内存,导致性能抖动。我建议一开始就分配好最大容量,比如
reserve(10000)。
粒子系统说难不难,说简单也不简单。关键是把基础架构搭好,后面加什么特效都顺手。我现在的项目里,粒子系统已经成了标配,火焰、烟雾、魔法、爆炸……全是同一套代码在跑,只是参数不同而已。
嗯,今天就聊到这儿。代码都在上面了,你可以直接拿去用。有问题随时交流。