1. 从零构建粒子系统:为什么选择CPU粒子而非GPU
1.1 项目缘起与核心需求拆解
烟花特效这个需求,乍一看像是游戏引擎里拖个粒子组件就能搞定的事,但真正动手从底层写一遍,你会发现它涉及的知识面远比想象中宽。我最初做这个项目的动机很简单:想在一个自研的2D渲染框架里加一套不依赖任何第三方粒子库的特效系统,要求是纯C++实现、基于DirectX、能同时跑几百个粒子还保持60帧稳定。
核心需求可以拆成这么几块:第一,粒子要能独立地生成、运动、衰减、消亡,每个粒子有自己的生命周期;第二,烟花要有“发射—升空—爆炸—扩散—坠落”这几个阶段的视觉层次;第三,颜色和透明度要随时间变化,不能是死板的固定色;第四,整体性能要扛得住,不能因为粒子数量上去了就掉帧。
适合谁来参考这篇内容?如果你已经写过一些DirectX的基础渲染,比如画过三角形、贴过纹理,但对粒子系统的架构没有完整概念,那这篇会非常对口。如果你是用Unity或Unreal的开发者,想了解底层粒子是怎么算出来的,也能从这里看到CPU侧粒子模拟的完整逻辑。
1.2 CPU粒子 vs GPU粒子:选型背后的真实考量
粒子系统的主流实现路线有两条:CPU粒子和GPU粒子。GPU粒子把粒子的位置、速度、生命周期全部塞进顶点缓冲或纹理里,用着色器去更新状态,优点是能轻松跑几十万个粒子;CPU粒子则是在主线程或工作线程里逐个更新粒子属性,然后每帧把数据传给GPU渲染。
我选CPU粒子,原因有三。第一,烟花特效的粒子数量级通常在几百到几千,这个量级CPU完全扛得住,没必要上GPU那套复杂的状态回读机制。第二,CPU侧调试直观得多,你可以在任意时刻打断点看某个粒子的速度、加速度、颜色值,GPU里你只能靠RenderDoc抓帧去猜。第三,烟花粒子的运动逻辑里有不少非线性行为,比如爆炸瞬间的径向速度分配、重力影响下的抛物线轨迹、随机扰动,这些在CPU上用C++写起来更灵活。
当然,CPU粒子不是没有代价。每帧要遍历所有粒子更新状态,粒子数一多,主线程压力就上来了。我的做法是把粒子更新和渲染分开:更新在一个独立的数据数组里做,渲染时只负责把当前帧的粒子数据映射到GPU缓冲。这样即使粒子数到几千,更新耗时也就几毫秒。
注意:如果你的项目目标是几万以上的持续粒子,CPU方案会很快成为瓶颈,那时候应该考虑GPU粒子或者混合方案。烟花这种爆发式、短生命周期的特效,CPU方案是性价比最高的选择。
1.3 整体架构设计:三层分离的思路
整个系统我分成三层来设计。最底层是粒子数据结构层,定义单个粒子的所有属性:位置、速度、加速度、颜色、透明度、大小、生命周期、存活状态。这一层只管数据,不含任何逻辑。
中间层是粒子系统管理层,负责粒子的批量生成、更新、回收。它维护一个粒子池,避免频繁的内存分配和释放。每次烟花爆炸时,从池里取出一批空闲粒子,初始化它们的属性,然后每帧统一更新。
最上层是特效控制层,负责编排烟花的发射节奏、爆炸时机、颜色方案。这一层决定“什么时候放烟花”“烟花是什么颜色”“爆炸半径多大”,它调用粒子系统管理层来实际生成粒子。
三层分离的好处是:粒子系统管理层可以复用到其他特效(比如烟雾、火花、雨雪),特效控制层可以独立调整视觉风格而不影响底层逻辑。我在后续加了一个“流星拖尾”特效,只改了特效控制层的代码,粒子系统管理层一行没动。
2. 粒子数据结构与生命周期管理
2.1 单个粒子的属性设计
一个粒子需要哪些属性?我最终定下来的字段是这样的:
struct Particle { float posX, posY; // 当前位置 float velX, velY; // 当前速度 float accX, accY; // 加速度(重力等) float size; // 粒子大小 float sizeDecay; // 大小衰减速率 float life; // 剩余生命值(秒) float maxLife; // 初始生命值,用于归一化 float r, g, b, a; // 颜色与透明度 float colorFade; // 透明度衰减速率 bool active; // 是否存活 };这里有几个设计决策值得展开说。位置和速度用float而不是double,因为粒子精度要求不高,float足够,而且float在SIMD优化时更友好。加速度单独存,而不是每帧现算,是因为不同粒子可能受不同的力,比如爆炸碎片受重力,而烟雾粒子受浮力,分开存更灵活。
maxLife字段是我踩过坑之后加的。最初我只存life,更新时递减,到0就标记死亡。但后来想做“粒子越老越透明”的效果,需要知道粒子当前生命占总生命的比例,没有maxLife就没法归一化。加上这个字段后,透明度、大小、颜色的衰减都可以基于归一化时间来做,效果自然多了。
2.2 粒子池:避免每帧new/delete
烟花爆炸时可能瞬间生成几百个粒子,如果每次都new一个Particle对象,爆炸结束后再delete,内存分配器会被频繁调用,产生碎片不说,性能也会抖动。我的做法是预分配一个固定大小的粒子数组,比如4096个,用一个active标记区分空闲和占用。
class ParticlePool { public: static const int MAX_PARTICLES = 4096; Particle particles[MAX_PARTICLES]; Particle* acquire() { for (int i = 0; i < MAX_PARTICLES; ++i) { if (!particles[i].active) { particles[i].active = true; return &particles[i]; } } return nullptr; // 池满了 } void release(Particle* p) { p->active = false; } };这个acquire的线性查找在粒子数少时没问题,但如果池里大部分粒子都活跃,每次acquire都要扫过大量已占用的槽位。优化方案是维护一个空闲索引栈,acquire时直接pop,release时push。我实测下来,在4096个粒子的规模下,线性查找和索引栈的差距不到0.1毫秒,所以为了代码简洁,我保留了线性查找。但如果你要把粒子数提到几万,索引栈是必须的。
提示:粒子池的大小要根据最坏情况来定。烟花特效最坏情况是同时有多个烟花在爆炸,每个烟花产生200到500个粒子。我按同时8个烟花算,4096个槽位刚好够用,留了一点余量。
2.3 生命周期:从诞生到消亡的完整流程
每个粒子的生命周期是这样的:acquire时初始化所有属性,active设为true;每帧更新时,life减去deltaTime,如果life小于等于0,标记为不活跃并释放回池;如果life大于0,继续更新位置、速度、颜色。
更新顺序很重要。我试过先更新颜色再更新位置,也试过反过来,最终发现先更新速度(受加速度影响),再更新位置(受速度影响),最后更新颜色和大小,这样物理上最合理。因为位置的变化依赖于当前帧的速度,而速度的变化依赖于加速度,顺序反了会导致轨迹不准确。
void updateParticle(Particle& p, float dt) { // 1. 更新速度 p.velX += p.accX * dt; p.velY += p.accY * dt; // 2. 更新位置 p.posX += p.velX * dt; p.posY += p.velY * dt; // 3. 更新生命 p.life -= dt; if (p.life <= 0.0f) { p.active = false; return; } // 4. 基于归一化时间更新视觉属性 float t = 1.0f - (p.life / p.maxLife); // t从0到1 p.a = (1.0f - t) * p.colorFade; p.size = p.size * (1.0f - p.sizeDecay * dt); }这里有个细节:透明度衰减用线性还是曲线?线性衰减(a = 1 - t)看起来比较生硬,粒子到最后会突然消失。我改成二次衰减(a = (1-t)^2),粒子在生命末期透明度下降得更快,视觉上更柔和。你也可以用smoothstep做平滑过渡,但计算量稍大,看你的性能预算。
3. 烟花特效的核心算法实现
3.1 发射阶段:从地面到空中的抛物线
烟花的第一个阶段是发射:一个亮点从屏幕底部升到空中,拖出一条尾迹。这个阶段其实就是一个受重力的抛物线运动。初始速度给一个向上的分量,重力给一个向下的加速度,粒子就会自然减速、到达顶点、然后开始下落。
// 发射一个烟花弹 Particle* rocket = pool.acquire(); rocket->posX = screenWidth * 0.5f + random(-50.0f, 50.0f); rocket->posY = screenHeight - 10.0f; rocket->velX = random(-20.0f, 20.0f); rocket->velY = -600.0f; // 向上,Y轴向下为正 rocket->accX = 0.0f; rocket->accY = 400.0f; // 重力 rocket->life = 1.5f; rocket->maxLife = 1.5f; rocket->size = 4.0f; rocket->r = 1.0f; rocket->g = 0.9f; rocket->b = 0.6f; rocket->a = 1.0f;这里Y轴向下为正,所以向上的速度是负值,重力加速度是正值。初始垂直速度-600、重力400,意味着大约1.5秒后到达顶点,上升高度大约是600*1.5/2 = 450像素。这个高度在1080p屏幕上差不多是屏幕中上部,视觉效果比较合适。
发射阶段的尾迹怎么实现?我的做法是每帧在火箭当前位置生成一个短生命的小粒子,颜色偏暖,透明度快速衰减。这样火箭上升时就会拖出一条渐隐的尾迹。尾迹粒子的生成频率不用太高,每两帧生成一个就够了,太高会浪费粒子池。
3.2 爆炸阶段:径向速度分配与颜色方案
火箭到达顶点或者生命结束时,触发爆炸。爆炸的核心是生成一批粒子,每个粒子获得一个从爆炸中心向外的径向速度。速度的方向用角度来分配,速度的大小加一点随机扰动,这样爆炸看起来不是完美的圆形,而是有自然的不规则感。
void explode(ParticlePool& pool, float cx, float cy, int count) { for (int i = 0; i < count; ++i) { Particle* p = pool.acquire(); if (!p) break; // 角度均匀分布 + 随机扰动 float angle = (2.0f * PI * i) / count + random(-0.1f, 0.1f); float speed = random(150.0f, 350.0f); p->posX = cx; p->posY = cy; p->velX = cosf(angle) * speed; p->velY = sinf(angle) * speed; p->accX = 0.0f; p->accY = 200.0f; // 爆炸碎片受重力 p->life = random(0.8f, 1.8f); p->maxLife = p->life; p->size = random(2.0f, 5.0f); p->sizeDecay = 0.5f; p->colorFade = 1.0f; // 颜色方案:从暖色到冷色的渐变 float hue = random(0.0f, 1.0f); if (hue < 0.33f) { p->r = 1.0f; p->g = random(0.3f, 0.8f); p->b = 0.1f; // 红橙 } else if (hue < 0.66f) { p->r = 0.2f; p->g = 0.6f; p->b = 1.0f; // 蓝 } else { p->r = 1.0f; p->g = 1.0f; p->b = 0.3f; // 黄 } p->a = 1.0f; } }角度分配这里有个细节:如果直接用2*PI*i/count,粒子会均匀分布在一个圆上,看起来太规整。我加了random(-0.1, 0.1)的角度扰动,让粒子稍微偏离完美圆,爆炸边缘就有了自然的毛刺感。速度大小也用random(150, 350)而不是固定值,这样爆炸不是一层壳,而是有厚度的球壳。
颜色方案我用了三种主色调随机分配:红橙、蓝、黄。每种色调内部还有随机变化,比如红橙色的绿色分量在0.3到0.8之间随机,这样同一批爆炸的粒子颜色有层次,不会像一坨纯色。
3.3 扩散与坠落:重力、阻尼与空气阻力
爆炸后的粒子在重力和阻尼的作用下扩散、减速、坠落。重力是恒定的向下加速度,阻尼是速度乘以一个小于1的系数,模拟空气阻力。
// 在updateParticle中加入阻尼 p.velX *= (1.0f - 0.5f * dt); // 水平阻尼 p.velY *= (1.0f - 0.5f * dt); // 垂直阻尼阻尼系数0.5是我试出来的。太高(比如2.0)粒子很快就停了,看起来像在糖浆里;太低(比如0.1)粒子飞得太远,爆炸范围太大。0.5在1秒的粒子生命里,速度会衰减到初始的约60%,扩散范围适中。
重力我设的是200,比发射阶段的400小。为什么?因为爆炸碎片如果受太强的重力,会很快往下掉,爆炸的“绽放”感就没了。200的重力让粒子先向外扩散,然后缓慢下坠,视觉上更像真实的烟花。
实操心得:阻尼和重力的组合决定了烟花的“性格”。阻尼大、重力小,烟花显得蓬松、扩散慢;阻尼小、重力大,烟花显得紧凑、下坠快。你可以把这两个参数暴露成可调变量,运行时用键盘调,找到最顺眼的组合再固化。
3.4 颜色与透明度的时变控制
粒子的颜色和透明度随时间变化,是烟花看起来“活”的关键。我的做法是用归一化时间t(0到1)来驱动颜色插值。
// 颜色从亮白渐变到主色,再渐变到暗色 float t = 1.0f - (p.life / p.maxLife); if (t < 0.2f) { // 前20%生命:从白色过渡到主色 float k = t / 0.2f; p.r = lerp(1.0f, baseR, k); p.g = lerp(1.0f, baseG, k); p.b = lerp(1.0f, baseB, k); } else { // 后80%生命:主色逐渐变暗 float k = (t - 0.2f) / 0.8f; p.r = lerp(baseR, baseR * 0.3f, k); p.g = lerp(baseG, baseG * 0.3f, k); p.b = lerp(baseB, baseB * 0.3f, k); }这个两段式颜色变化模拟了真实烟花的燃烧过程:爆炸瞬间温度极高,发出白光;然后温度下降,显现出金属盐的颜色(红、绿、蓝等);最后燃烧殆尽,颜色变暗消失。前20%的白光过渡让爆炸瞬间更有冲击力。
透明度我用的是a = (1-t)^2,前面说过,二次衰减比线性更柔和。但这里有个问题:如果粒子在生命末期透明度已经很低了,颜色再变暗,就几乎看不见了。所以我在颜色变暗的同时,透明度衰减稍微放缓,让暗色粒子还能被看到一小会儿。
4. 渲染管线与性能优化
4.1 顶点缓冲的动态更新策略
粒子渲染我用的是点精灵(Point Sprite)方案:每个粒子作为一个顶点提交,顶点着色器里设置点的大小,像素着色器里画圆形。这样每个粒子只占一个顶点,4096个粒子就是4096个顶点,数据量很小。
顶点格式很简单:位置(float2)、颜色(float4)、大小(float1)。每帧更新时,遍历粒子池,把活跃粒子的数据写入一个动态顶点缓冲。
struct ParticleVertex { float x, y; float r, g, b, a; float size; }; // 每帧更新 ParticleVertex vertices[MAX_PARTICLES]; int count = 0; for (int i = 0; i < MAX_PARTICLES; ++i) { if (pool.particles[i].active) { vertices[count].x = pool.particles[i].posX; vertices[count].y = pool.particles[i].posY; vertices[count].r = pool.particles[i].r; // ... 填充其他字段 count++; } } // 映射缓冲,拷贝数据,解除映射动态顶点缓冲的更新方式有两种:DISCARD和NO_OVERWRITE。DISCARD会丢弃整个缓冲重新写入,适合每帧全量更新的场景;NO_OVERWRITE适合部分更新。粒子系统每帧都是全量更新,所以用DISCARD。
注意:顶点缓冲的大小要按最大粒子数来分配,不要每帧重新创建缓冲。创建和销毁GPU资源是昂贵操作,每帧做一次会让帧率直接崩掉。
4.2 点精灵的圆形裁剪与边缘柔化
点精灵默认是正方形,要画成圆形需要在像素着色器里做裁剪。最简单的方式是用纹理坐标计算到中心的距离,超过半径就discard。
float2 center = float2(0.5, 0.5); float dist = distance(texCoord, center); if (dist > 0.5) discard;但这样边缘是硬切的,有锯齿。更好的做法是用smoothstep做柔化:
float alpha = 1.0 - smoothstep(0.4, 0.5, dist); return float4(color.rgb, color.a * alpha);这样粒子边缘有大约10%的渐变区域,看起来柔和很多。代价是overdraw稍微增加,但在粒子数几千的规模下,性能影响可以忽略。
如果你想要更丰富的粒子形状,可以加载一张圆形渐变纹理,用纹理采样代替距离计算。纹理采样的优点是形状可以任意定制(星形、花瓣形等),缺点是多了纹理带宽。我实测下来,在1080p分辨率、2000个粒子的场景下,距离计算和纹理采样的帧率差距不到2帧,所以为了灵活性,我最终用了纹理方案。
4.3 混合模式:加法混合为什么更适合烟花
烟花的视觉特征是“发光”,而加法混合(Additive Blending)正是模拟发光的正确方式。加法混合的公式是:最终颜色 = 源颜色 * 源Alpha + 目标颜色。多个粒子重叠时,颜色会累加,重叠区域更亮,看起来就像光在叠加。
// 设置加法混合 device->SetRenderState(D3DRS_ALPHABLENDENABLE, TRUE); device->SetRenderState(D3DRS_SRCBLEND, D3DBLEND_SRCALPHA); device->SetRenderState(D3DRS_DESTBLEND, D3DBLEND_ONE);对比一下Alpha混合(普通透明):最终颜色 = 源颜色 * 源Alpha + 目标颜色 * (1 - 源Alpha)。Alpha混合适合玻璃、水面这种半透明物体,但烟花用Alpha混合会显得“脏”,因为粒子重叠时颜色不会变亮,反而会互相遮挡。
加法混合的缺点是粒子在亮色背景上会“消失”,因为加法混合只能让颜色变亮,不能变暗。所以烟花特效通常放在深色背景上,这也是为什么烟花总是在夜晚放。
4.4 性能实测:2000个粒子如何稳住60帧
我在一台中等配置的机器上做了性能测试:1080p分辨率,2000个活跃粒子,加法混合,点精灵渲染。结果如下:
| 阶段 | 耗时(毫秒) | 占比 |
|---|---|---|
| 粒子更新(CPU) | 1.2 | 7% |
| 顶点缓冲填充 | 0.8 | 5% |
| 顶点缓冲上传 | 0.5 | 3% |
| 绘制调用 | 0.3 | 2% |
| GPU渲染 | 12.0 | 72% |
| 其他(交换链等) | 1.8 | 11% |
| 总计 | 16.6 | 100% |
GPU渲染是大头,因为2000个粒子每个都要跑像素着色器,而且加法混合的overdraw比较严重。优化方向有几个:降低粒子大小(减少overdraw)、用更简单的像素着色器、或者把粒子按大小排序先画大的再画小的(减少小粒子被大粒子覆盖的浪费)。
我试过把粒子大小从平均4像素降到3像素,GPU耗时从12毫秒降到了9毫秒,帧率从60帧升到了75帧。但粒子看起来稍微小气了一点,所以最终折中用了3.5像素。
实操心得:粒子特效的性能瓶颈几乎总是在GPU的overdraw上,而不是CPU的粒子更新。优化时优先看像素着色器的复杂度和粒子覆盖面积,而不是急着去优化C++代码。
5. 常见问题与排查实录
5.1 粒子拖尾不连贯:时间步长与生成频率的匹配
最初做发射尾迹时,我发现尾迹是一段一段的,不连贯。原因是尾迹粒子的生成频率和帧率不匹配:如果每帧生成一个,60帧下就是每秒60个,但如果粒子生命只有0.3秒,那同时存在的尾迹粒子只有18个,在快速运动的火箭后面就显得稀疏。
解决方案有两个:一是提高生成频率,每帧生成两个或三个;二是增大尾迹粒子的生命,让更多粒子同时存在。我选了后者,把尾迹生命从0.3秒提到0.6秒,同时存在的粒子数翻倍,尾迹就连贯了。但生命太长会导致尾迹拖得太长,所以又加了一个透明度快速衰减,让尾迹在视觉上不会延伸太远。
5.2 爆炸不对称:随机数种子的陷阱
有段时间我发现烟花爆炸总是不对称,有一侧粒子特别多。排查后发现是随机数种子的问题:我在程序启动时用srand(time(nullptr))初始化了一次,但爆炸时调用的rand()序列在多次爆炸之间是连续的,导致某些角度范围的随机扰动总是偏大或偏小。
修复方法是每次爆炸前用当前时间重新播种,或者改用C++11的std::mt19937配合std::uniform_real_distribution。后者更好,因为mt19937的随机质量比rand()高得多,而且分布控制更精确。
std::random_device rd; std::mt19937 gen(rd()); std::uniform_real_distribution<float> dist(-0.1f, 0.1f); float angleJitter = dist(gen);5.3 粒子池耗尽:溢出保护与降级策略
当屏幕上同时有太多烟花时,粒子池会被耗尽,acquire()返回nullptr,新的爆炸就生成不了粒子。最初的代码没有处理这种情况,导致爆炸时有时无,看起来像卡顿。
我加了两层保护:第一,acquire()返回nullptr时,爆炸函数直接跳过这次生成,不崩溃;第二,在特效控制层加一个计数器,如果连续多次acquire失败,就降低爆炸的粒子数量,从500降到300再降到200,直到池里有足够空间。
int particleCount = 500; while (particleCount > 100) { if (pool.hasFreeSlots(particleCount)) break; particleCount -= 100; } explode(pool, cx, cy, particleCount);这个降级策略保证了即使在高负载下,烟花仍然能放出来,只是粒子少一点,视觉上不会完全消失。
5.4 帧率波动:垂直同步与帧时间平滑
开启垂直同步后,帧率被锁在60帧,但粒子更新用的是实际帧时间(deltaTime),如果某一帧的deltaTime突然变大(比如窗口被拖动),粒子会“跳”一下。这是因为deltaTime不稳定导致的。
解决方案是对deltaTime做平滑:用最近几帧的平均值,或者限制deltaTime的最大值(比如不超过1/30秒)。我用了后者,简单有效:
float dt = min(rawDeltaTime, 1.0f / 30.0f);这样即使某一帧卡了,粒子也不会因为巨大的deltaTime而瞬移。代价是慢动作效果——如果帧率真的掉到30以下,粒子运动会变慢。但对于烟花特效来说,慢动作反而好看,所以这个代价可以接受。
5.5 常见问题速查表
| 问题现象 | 可能原因 | 排查方法 | 解决方案 |
|---|---|---|---|
| 粒子不显示 | 顶点缓冲未更新/混合模式错误 | 检查缓冲映射和混合状态 | 确认DISCARD映射和加法混合 |
| 粒子颜色发白 | 加法混合过度叠加 | 减少粒子重叠或降低Alpha | 调整粒子大小或透明度 |
| 爆炸形状太圆 | 角度扰动不足 | 增大角度随机范围 | 角度扰动加到0.2弧度 |
| 粒子飞出屏幕不消失 | 生命值未递减 | 检查update中的life减法 | 确认dt传入正确 |
| 帧率随粒子数线性下降 | GPU overdraw过高 | 用性能分析工具看GPU耗时 | 减小粒子大小或简化着色器 |
| 尾迹断断续续 | 生成频率低于帧率 | 统计同时存在的尾迹粒子数 | 提高生成频率或延长生命 |
| 爆炸不对称 | 随机数序列偏差 | 检查随机数种子和分布 | 改用mt19937和均匀分布 |
| 粒子池耗尽 | 同时爆炸太多 | 打印acquire失败次数 | 加降级策略或扩大池 |
6. 扩展思路:从烟花到通用粒子系统
6.1 参数化烟花:让每次爆炸都不一样
现在的烟花爆炸参数是硬编码的,每次爆炸看起来差不多。要让它更有趣,可以把爆炸参数暴露成配置:粒子数量、速度范围、颜色方案、重力大小、阻尼系数。然后每次爆炸时从配置里随机选一组,或者用多个预设方案轮换。
我后来加了一个简单的方案轮换:预设了“球形爆炸”“环形爆炸”“双色爆炸”三种方案,每次爆炸随机选一种。球形就是现在的径向均匀分布;环形是速度大小固定、角度均匀,看起来像一个扩张的圆环;双色是粒子分成两组,每组一种颜色,爆炸时两种颜色交替出现。
6.2 多阶段烟花:二次爆炸与子粒子
真实的烟花经常有二次爆炸:一个大烟花爆炸后,里面的小烟花再爆炸一次。实现方式是给部分爆炸粒子加一个“子爆炸”标记,当这些粒子的生命结束时,不直接消亡,而是触发一次小规模的爆炸。
if (p.life <= 0.0f && p.hasChildExplosion) { explode(pool, p.posX, p.posY, 50); // 子爆炸 }这个机制要注意递归深度:如果子爆炸的粒子也有子爆炸,会无限递归。我的做法是只允许一级子爆炸,子爆炸的粒子不再有子爆炸标记。
6.3 与游戏引擎的集成思路
如果你要把这套粒子系统集成到游戏引擎里,关键是把粒子更新和渲染解耦。粒子更新可以放在游戏逻辑线程,渲染放在渲染线程,两者通过一个双缓冲的粒子数据数组交换。这样即使渲染线程偶尔卡顿,粒子逻辑也不会受影响。
另一个集成点是粒子的排序。在2D游戏里,粒子通常需要按Y轴排序,让下方的粒子遮挡上方的粒子。我的做法是在填充顶点缓冲时按Y轴排序,虽然多了一次排序开销,但视觉正确性提升明显。
6.4 从2D到3D:需要补充什么
这套系统是2D的,要扩展到3D,需要补充几样东西:粒子的位置和速度从float2变成float3,增加Z轴;渲染从点精灵改成billboard(始终面向相机的四边形);爆炸的径向速度分配从2D圆变成3D球面,用球坐标生成方向向量。
3D粒子的性能压力比2D大得多,因为billboard的面积通常比点精灵大,overdraw更严重。优化方向包括:用GPU粒子、用低分辨率渲染粒子再上采样、或者用 impostor 技术把远处的粒子烘焙成纹理。
我个人在实际操作中的体会是,2D粒子系统是理解粒子特效原理的最佳起点,它的所有概念——生命周期、速度、加速度、颜色插值、混合模式——在3D里完全适用,只是多了一个维度。先把2D的烟花做透,再上3D,会少踩很多坑。