1. 从零拆解一个烟花粒子系统的核心设计思路
1.1 为什么选择粒子系统来做烟花
在图形编程里,烟花特效几乎是每个想入门特效的人都会碰的一个经典项目。它看起来简单——不就是一堆点炸开然后落下来吗?但真正动手写过的人都知道,里面涉及的东西一点都不少:粒子发射、生命周期管理、物理模拟、颜色渐变、混合模式、性能优化,每一个环节都有坑。
我选择用 C++ 配合 DirectX 来实现这套烟花特效,原因很直接。C++ 能让我对内存布局和渲染管线有完全的控制权,而 DirectX 作为底层图形接口,在 Windows 平台上做粒子系统有着天然的优势——不管是顶点缓冲的动态更新,还是混合状态的灵活切换,都比上层引擎来得更直接。当然,如果你用的是 OpenGL 或者 Vulkan,核心思路是完全一样的,只是 API 调用不同而已。
这套烟花特效最终能做什么?简单说,你给它一个发射位置,它就能在屏幕上炸出一朵完整的烟花:先是上升的拖尾,然后是爆炸瞬间的球状扩散,接着粒子受重力影响自然下落,颜色从亮白过渡到暖色再逐渐暗淡消失。整个过程完全由代码驱动,不依赖任何预制动画资源。
适合谁来参考?如果你已经写过一些 C++ 的基础项目,对 DirectX 的初始化流程有基本了解,但还没做过完整的粒子系统,那这套方案就是为你准备的。我会把每个参数的计算过程、每个设计决策背后的理由都讲清楚,让你不只是抄代码,而是真正理解为什么这么写。
1.2 整体架构:CPU 模拟 + GPU 渲染的分工
粒子系统的架构设计,核心就一个问题:粒子的位置更新放在哪里算?
有两种主流方案。一种是把粒子数据全部放在 GPU 上,用计算着色器或者 Transform Feedback 来更新位置,CPU 完全不参与。这种方案性能上限高,适合百万级粒子,但调试困难,逻辑改起来也麻烦。另一种是 CPU 负责模拟,每帧计算好所有粒子的位置、颜色、大小,然后上传到 GPU 渲染。这种方案性能上限低一些,但逻辑清晰、调试方便,对于烟花这种几千到几万粒子的场景完全够用。
我选的是第二种方案。原因很简单:烟花的粒子行为需要频繁调整参数——爆炸力度、重力系数、颜色曲线、生命周期——这些在 CPU 端改起来直观得多。而且烟花特效的粒子数量通常在可控范围内,CPU 模拟完全扛得住。
整个系统的数据流是这样的:
- 粒子池管理:预分配一块连续内存存放所有粒子数据,避免频繁的 new/delete
- 发射器:负责在特定时刻生成新粒子,设置初始速度、颜色、生命周期
- 模拟循环:每帧遍历所有活跃粒子,更新速度、位置、颜色、剩余生命
- 渲染提交:把活跃粒子的数据打包成顶点缓冲,提交给 GPU 绘制
这个流程听起来简单,但每个环节都有值得展开的细节。接下来我会逐个拆解。
1.3 粒子池的设计:为什么不用 vector 动态增删
很多新手写粒子系统的第一反应是用std::vector<Particle>,发射的时候 push_back,粒子死亡的时候 erase。这个做法在小规模下没问题,但粒子数量一上去就会出大问题。
首先是内存碎片。频繁的 push_back 和 erase 会导致 vector 内部的元素不断搬移,每次 erase 都要把后面的元素往前挪,时间复杂度是 O(n)。几千个粒子每帧都在增删,帧率直接崩掉。
其次是缓存不友好。vector 扩容时会重新分配内存,所有粒子数据被搬到新地址,CPU 缓存全部失效。
我的做法是预分配一个固定大小的粒子数组,用一个activeCount来标记当前活跃粒子的数量。所有活跃粒子紧凑地排在数组前段,死亡粒子的位置由最后一个活跃粒子填补。这样做的代价是粒子的顺序会变,但对于烟花特效来说,渲染顺序本来就不重要(因为用的是加法混合,顺序无关)。
struct Particle { float posX, posY, posZ; float velX, velY, velZ; float life; // 剩余生命 float maxLife; // 总生命,用于计算归一化时间 float size; float r, g, b, a; }; const int MAX_PARTICLES = 10000; Particle particlePool[MAX_PARTICLES]; int activeCount = 0;这个设计的好处是内存完全连续,CPU 遍历时缓存命中率极高。而且没有动态分配,不会在运行时产生内存抖动。
注意:粒子池的大小要根据最坏情况来定。一朵烟花爆炸可能产生 500 到 2000 个粒子,如果同时有多朵烟花,峰值可能到上万。宁可多分配一些,也不要运行时扩容。
2. 烟花特效的核心细节与实操要点
2.1 发射阶段:从地面到空中的拖尾效果
烟花的第一个阶段是升空。这个阶段看起来简单,但要做出好看的拖尾效果,有几个关键点。
发射时,粒子从底部某个位置出发,给一个向上的初速度。但如果你只发射一个粒子,它就是一个光点往上飞,完全没有烟花的感觉。真正的烟花升空时,尾部会拖出一条亮线,这条线其实是由很多个小粒子组成的。
我的做法是在升空阶段持续发射小粒子,每个粒子有微小的随机偏移和不同的生命周期。这样它们会逐渐落后于主粒子,形成拖尾。具体参数上,主粒子的上升速度大约在 300 到 500 像素/秒(假设屏幕高度 720),拖尾粒子的发射频率是每帧 2 到 3 个,生命周期 0.3 到 0.5 秒。
这里有个细节:拖尾粒子的颜色要和主粒子有区分。主粒子偏亮白,拖尾粒子偏暖黄,这样视觉上更有层次。而且拖尾粒子要随着时间逐渐变暗变小,模拟燃烧殆尽的效果。
// 升空阶段每帧发射拖尾粒子 void EmitTrailParticle(float baseX, float baseY) { Particle& p = particlePool[activeCount++]; p.posX = baseX + RandomFloat(-3.0f, 3.0f); p.posY = baseY + RandomFloat(-3.0f, 3.0f); p.velX = RandomFloat(-20.0f, 20.0f); p.velY = RandomFloat(-50.0f, -20.0f); p.life = RandomFloat(0.3f, 0.5f); p.maxLife = p.life; p.size = RandomFloat(2.0f, 4.0f); p.r = 1.0f; p.g = 0.8f; p.b = 0.3f; p.a = 1.0f; }拖尾粒子的速度要略微向下,这样它们才会落在主粒子后面。如果速度也是向上的,拖尾就会跑到主粒子前面去,看起来就很奇怪。
2.2 爆炸瞬间:球面均匀分布的实现
升空到最高点后,主粒子消失,同时在那个位置生成大量爆炸粒子。这是整个特效最核心的部分。
爆炸粒子的速度方向必须是球面均匀分布的。如果你直接用rand()生成三个方向的分量然后归一化,会发现粒子在球面上的分布不均匀——靠近坐标轴方向会偏多。正确的做法是用球坐标来生成:
float theta = RandomFloat(0.0f, 2.0f * PI); float phi = acos(1.0f - 2.0f * RandomFloat(0.0f, 1.0f)); float speed = RandomFloat(minSpeed, maxSpeed); float vx = speed * sin(phi) * cos(theta); float vy = speed * sin(phi) * sin(theta); float vz = speed * cos(phi);这里的phi用acos(1 - 2 * rand)而不是直接rand * PI,是为了保证球面上的面积均匀。这个推导来自球面面积微元dA = sin(phi) * dphi * dtheta,要让面积均匀,phi的分布必须满足cos(phi)均匀分布。
爆炸速度的范围也有讲究。如果所有粒子速度一样,爆炸就是一个完美的球壳,看起来很假。实际烟花爆炸时,粒子的速度有一个分布,中间快边缘慢,或者反过来。我一般用minSpeed = 150,maxSpeed = 400,让速度在一定范围内随机,这样爆炸看起来更自然。
爆炸粒子的数量取决于你想要的效果。一般来说,500 到 1500 个粒子能做出比较饱满的球状爆炸。如果追求更细腻的效果,可以加到 3000 个,但要注意性能。
2.3 颜色渐变:从亮白到暖色的过渡
烟花的颜色变化是让它看起来真实的关键。真实的烟花爆炸时,中心温度极高,呈现亮白色,随着温度降低,颜色逐渐过渡到黄色、橙色、红色,最后暗淡消失。
在代码里,我用粒子的归一化生命值t = life / maxLife来控制颜色。t从 1 降到 0,对应粒子从刚生成到消亡。颜色曲线大致是这样的:
t > 0.8:接近纯白,RGB 大约 (1.0, 1.0, 0.9)0.5 < t < 0.8:过渡到暖黄,(1.0, 0.8, 0.3)0.2 < t < 0.5:橙色到红色,(1.0, 0.4, 0.1)t < 0.2:暗红到消失,(0.5, 0.1, 0.0) 且 alpha 逐渐降低
这个曲线可以用分段线性插值来实现,也可以用指数衰减。我试过几种方案,最后觉得用pow(t, 0.5)来做亮度衰减比较自然——前期衰减慢,后期衰减快,符合燃烧的物理直觉。
float t = p.life / p.maxLife; float brightness = powf(t, 0.5f); p.r = brightness; p.g = brightness * (0.3f + 0.7f * t); p.b = brightness * (0.1f + 0.9f * t * t); p.a = t > 0.1f ? 1.0f : t * 10.0f;这里p.b用了t * t,是为了让蓝色衰减得比绿色快,这样颜色会从白偏黄再偏红,过渡更自然。
2.4 重力与阻力:让粒子下落更真实
爆炸之后,粒子在重力的作用下开始下落。但如果你只加重力,粒子会像石头一样直直掉下来,看起来很生硬。真实的烟花粒子在下落时还会受到空气阻力的影响,速度会逐渐衰减。
我的做法是每帧对速度施加一个阻力系数:
p.velX *= dragFactor; p.velY *= dragFactor; p.velZ *= dragFactor; p.velY += gravity * deltaTime;dragFactor一般在 0.98 到 0.995 之间(按 60 帧每秒计算)。这个系数越小,粒子减速越快,看起来越"轻"。对于烟花来说,我一般用 0.99 左右,让粒子在下落时保持一定的速度感,但不会太快。
重力系数gravity用 200 到 400 像素/秒平方比较合适。太小了粒子飘着不落,太大了掉得太快没有美感。
实操心得:阻力和重力要配合调整。如果阻力大,重力也要相应加大,否则粒子会悬浮在空中不动。我一般先把重力定在 300,然后调阻力,直到下落速度看起来舒服为止。
3. 完整实操流程与关键环节实现
3.1 初始化 DirectX 环境与顶点缓冲
在写粒子逻辑之前,得先把 DirectX 的环境搭起来。这部分是标准流程,但有几个和粒子系统相关的点需要特别注意。
首先是顶点格式。粒子渲染本质上就是画一堆带纹理的四边形(billboard),每个四边形需要四个顶点。但为了简化,我直接用点精灵(point sprite)的方式——每个粒子就是一个顶点,在几何着色器或者顶点着色器里扩展成四边形。不过点精灵在不同硬件上的支持不一致,所以我更倾向于用实例化渲染或者直接构建四边形顶点。
我最终用的是动态顶点缓冲。每帧根据活跃粒子数量,把粒子数据转换成顶点数据写入缓冲。顶点结构包含位置、颜色、大小:
struct ParticleVertex { float x, y, z; float r, g, b, a; float size; }; D3D11_BUFFER_DESC bd = {}; bd.Usage = D3D11_USAGE_DYNAMIC; bd.ByteWidth = sizeof(ParticleVertex) * MAX_PARTICLES * 4; bd.BindFlags = D3D11_BIND_VERTEX_BUFFER; bd.CPUAccessFlags = D3D11_CPU_ACCESS_WRITE;每个粒子对应四个顶点(四边形的四个角),所以缓冲大小要乘以 4。索引缓冲用固定的四边形索引模式,每四个顶点对应两个三角形。
混合状态用加法混合(Additive Blending),这样粒子重叠的地方会更亮,符合发光效果:
D3D11_BLEND_DESC blendDesc = {}; blendDesc.RenderTarget[0].BlendEnable = TRUE; blendDesc.RenderTarget[0].SrcBlend = D3D11_BLEND_SRC_ALPHA; blendDesc.RenderTarget[0].DestBlend = D3D11_BLEND_ONE; blendDesc.RenderTarget[0].BlendOp = D3D11_BLEND_OP_ADD;加法混合的公式是最终颜色 = 源颜色 * 源Alpha + 目标颜色。这样多个粒子叠加时颜色会累加,产生发光的效果。
3.2 每帧更新循环的完整实现
更新循环是整个粒子系统的心脏。我把它分成几个步骤,按顺序执行:
第一步,处理发射器。如果当前有烟花正在升空,就发射拖尾粒子;如果到达爆炸点,就生成爆炸粒子。
第二步,遍历所有活跃粒子,更新它们的生命值。如果生命值降到 0 以下,就把它和最后一个活跃粒子交换,然后activeCount--。
第三步,对存活的粒子更新速度和位置。
第四步,根据归一化生命值更新颜色和大小。
void Update(float deltaTime) { // 发射逻辑 UpdateEmitters(deltaTime); // 更新粒子 for (int i = 0; i < activeCount; ) { Particle& p = particlePool[i]; p.life -= deltaTime; if (p.life <= 0.0f) { // 和最后一个活跃粒子交换 particlePool[i] = particlePool[activeCount - 1]; activeCount--; continue; // 不递增 i,因为新换过来的粒子还没处理 } // 物理更新 p.velX *= dragFactor; p.velY *= dragFactor; p.velZ *= dragFactor; p.velY += gravity * deltaTime; p.posX += p.velX * deltaTime; p.posY += p.velY * deltaTime; p.posZ += p.velZ * deltaTime; // 颜色更新 float t = p.life / p.maxLife; float brightness = powf(t, 0.5f); p.r = brightness; p.g = brightness * (0.3f + 0.7f * t); p.b = brightness * (0.1f + 0.9f * t * t); p.a = t > 0.1f ? 1.0f : t * 10.0f; i++; } }这里有个容易出错的地方:当粒子死亡被交换后,不能递增i,因为换过来的粒子还没有被处理。用continue跳过递增,下一轮循环会重新处理当前位置。
3.3 渲染提交:从粒子数据到屏幕像素
渲染阶段要把粒子数据转换成 GPU 能理解的顶点数据。每个粒子需要生成四个顶点,位置是粒子中心加上偏移量。偏移量取决于粒子的大小和相机的朝向——粒子始终面向相机,这就是 billboard 效果。
void Render() { D3D11_MAPPED_SUBRESOURCE mapped; context->Map(vertexBuffer, 0, D3D11_MAP_WRITE_DISCARD, 0, &mapped); ParticleVertex* verts = (ParticleVertex*)mapped.pData; int vertIndex = 0; for (int i = 0; i < activeCount; i++) { Particle& p = particlePool[i]; float halfSize = p.size * 0.5f; // 四个角 verts[vertIndex++] = { p.posX - halfSize, p.posY - halfSize, p.posZ, p.r, p.g, p.b, p.a, p.size }; verts[vertIndex++] = { p.posX + halfSize, p.posY - halfSize, p.posZ, p.r, p.g, p.b, p.a, p.size }; verts[vertIndex++] = { p.posX + halfSize, p.posY + halfSize, p.posZ, p.r, p.g, p.b, p.a, p.size }; verts[vertIndex++] = { p.posX - halfSize, p.posY + halfSize, p.posZ, p.r, p.g, p.b, p.a, p.size }; } context->Unmap(vertexBuffer, 0); // 绘制 UINT stride = sizeof(ParticleVertex); UINT offset = 0; context->IASetVertexBuffers(0, 1, &vertexBuffer, &stride, &offset); context->Draw(activeCount * 6, 0); // 每个粒子两个三角形,六个索引 }这里用的是D3D11_MAP_WRITE_DISCARD,它会丢弃缓冲里的旧数据,返回一块新的内存。这样做避免了 GPU 还在读取旧数据时 CPU 写入造成的同步等待。
顶点着色器里,粒子的位置直接输出,颜色和大小传给像素着色器。像素着色器里可以根据到中心的距离做一个圆形的 alpha 衰减,让粒子边缘柔和一些:
float2 center = float2(0.5, 0.5); float dist = length(input.uv - center) * 2.0; float alpha = saturate(1.0 - dist); return float4(input.color.rgb, input.color.a * alpha);这样粒子就不是硬边的方块,而是柔和的圆形光点。
3.4 多朵烟花的调度与随机化
单朵烟花看起来太单调,实际演示时通常会同时或连续发射多朵。我的做法是维护一个发射器列表,每个发射器有自己的状态(等待、升空、爆炸、完成)和参数(发射位置、爆炸高度、颜色主题、粒子数量)。
struct FireworkEmitter { float startX, startY; float targetY; float currentY; float riseSpeed; int state; // 0=等待, 1=升空, 2=爆炸, 3=完成 float colorR, colorG, colorB; int particleCount; };每朵烟花的参数都随机化:发射位置在屏幕底部一定范围内随机,爆炸高度在屏幕上半部分随机,颜色主题从预设的几组配色里选(红金、蓝银、绿紫等),粒子数量在 500 到 1500 之间随机。
发射时机也要随机化。如果所有烟花同时发射,看起来像齐射,不够自然。我一般让每朵烟花在上一朵发射后 0.5 到 2 秒内随机延迟发射。
实操心得:多朵烟花同时存在时,粒子池的峰值使用量会很高。我建议在粒子池快满的时候,优先让老粒子死亡(加速它们的生命衰减),而不是拒绝新粒子的生成。否则会出现新烟花炸不出来或者炸出来缺一块的情况。
4. 常见问题与排查技巧实录
4.1 粒子不显示或者显示为方块
这是最常见的问题,通常有几个原因。
第一个原因是混合状态没设置对。如果用了加法混合但 alpha 值全是 0,粒子就完全不可见。检查一下粒子的 alpha 是否在 0 到 1 之间,以及混合公式是否正确。
第二个原因是顶点缓冲没有正确更新。用Map之后一定要Unmap,否则 GPU 读不到数据。另外检查Draw的顶点数量是否正确——每个粒子 6 个索引,如果只画了activeCount个索引,就只能看到六分之一个粒子。
第三个原因是着色器里的坐标变换有问题。如果粒子的世界坐标没有正确转换到裁剪空间,它们可能被画到了屏幕外面。检查一下视图矩阵和投影矩阵是否设置正确。
如果粒子显示为硬边方块而不是圆形光点,那就是像素着色器里的圆形 alpha 计算没生效。检查 UV 坐标是否正确传递,以及saturate函数是否用对了。
4.2 帧率突然下降的排查思路
粒子系统最常见的性能问题是帧率突然下降。可能的原因和排查方法如下:
| 现象 | 可能原因 | 排查方法 |
|---|---|---|
| 粒子数量多时帧率线性下降 | CPU 模拟太慢 | 用性能分析工具看 Update 函数的耗时 |
| 粒子数量少时帧率也低 | 渲染状态切换太频繁 | 检查是否每帧都在创建/销毁缓冲 |
| 帧率周期性波动 | 内存分配抖动 | 检查是否有动态分配,改用粒子池 |
| 爆炸瞬间卡顿 | 单帧生成粒子太多 | 把爆炸粒子的生成分散到多帧 |
我踩过的一个坑是:在Update里用了std::vector的erase,导致每帧都有大量内存搬移。改成粒子池的交换删除后,帧率直接翻倍。
另一个坑是顶点缓冲的大小。如果每帧都重新创建缓冲,GPU 驱动会不停地分配和释放显存,性能极差。正确做法是创建一次,之后用Map/Unmap更新内容。
4.3 粒子颜色发灰或者过曝
颜色问题通常出在混合模式和颜色空间上。
如果粒子颜色发灰,可能是用了 alpha 混合而不是加法混合。alpha 混合会让粒子颜色和背景颜色做插值,导致亮度降低。烟花特效应该用加法混合,让粒子颜色叠加到背景上。
如果粒子过曝(一片死白),可能是粒子的 alpha 值太高,或者同时重叠的粒子太多。可以降低单个粒子的 alpha,或者减少爆炸粒子的数量。另外,在像素着色器里对最终颜色做一个 tone mapping(色调映射),把超过 1.0 的亮度压回来,也能改善过曝问题。
还有一个容易被忽略的点:如果渲染目标用的是 sRGB 格式,但着色器输出的是线性颜色,颜色会偏暗。需要在着色器里做 gamma 校正,或者把渲染目标改成线性格式。
4.4 粒子拖尾断裂或者不连续
拖尾断裂通常是因为发射频率不够。如果每帧只发射一个拖尾粒子,而粒子速度很快,两个粒子之间的间距就会很大,看起来就是断断续续的。
解决方法有两个:一是提高发射频率,每帧发射多个粒子,位置在上一帧和当前帧之间插值;二是增大拖尾粒子的大小,让它们互相重叠。
我一般用第一种方法,每帧发射 2 到 3 个拖尾粒子,位置在主粒子的当前位置和上一帧位置之间做线性插值。这样即使主粒子移动很快,拖尾也是连续的。
for (int i = 0; i < trailCount; i++) { float t = (float)i / trailCount; float px = lerp(prevX, currentX, t); float py = lerp(prevY, currentY, t); EmitTrailParticle(px, py); }4.5 爆炸形状不对称或者偏向一侧
如果爆炸粒子明显偏向某个方向,通常是随机数生成的问题。检查一下球面坐标的生成公式,特别是phi的计算。如果用rand() * PI而不是acos(1 - 2 * rand()),粒子会在两极聚集。
另一个可能的原因是速度的随机范围不对称。如果velX的范围是[-100, 200]而不是[-200, 200],爆炸就会偏向一侧。检查所有速度分量的随机范围是否关于零对称。
还有一种情况是粒子的初始位置有偏移。如果所有爆炸粒子都从同一个点生成,但那个点不在主粒子的爆炸位置上,爆炸中心就会偏。确保爆炸粒子的生成位置和主粒子的消失位置一致。
4.6 粒子池耗尽的处理策略
当粒子池满了,新的粒子无法生成时,有几种处理策略:
第一种是直接丢弃新粒子。这会导致烟花爆炸不完整,但不会崩溃。适合对视觉效果要求不高的场景。
第二种是强制回收最老的粒子。遍历粒子池,找到剩余生命最少的粒子,把它替换掉。这样做能保证新烟花完整,但老烟花会突然消失。
第三种是动态扩容。在粒子池满时分配一块更大的内存,把旧数据拷过去。这样做不会丢失任何粒子,但会有一次内存分配的开销,可能造成帧率波动。
我一般用第二种策略,但做一个小优化:不是找剩余生命最少的,而是找剩余生命比例最少的(life / maxLife最小)。这样能优先回收那些已经接近消亡的粒子,视觉上的突兀感最小。
注意:不管用哪种策略,都要确保粒子池的索引不会越界。在生成新粒子之前,一定要检查
activeCount < MAX_PARTICLES。
5. 进阶优化与效果提升的实操建议
5.1 用噪声让爆炸更自然
纯球面爆炸看起来太完美了,真实的烟花爆炸会有一些不规则性。我试过在速度方向上叠加一层噪声,效果提升很明显。
具体做法是用一个简单的 3D 噪声函数(比如 Perlin 噪声的简化版),根据粒子的方向向量采样一个噪声值,然后把速度乘以1 + noise * 0.3。这样爆炸就不是完美的球壳,而是有一些凸起和凹陷,看起来更自然。
噪声的频率也要调。频率太高,爆炸会变得太碎;频率太低,又看不出效果。我一般用频率 2 到 4,噪声强度 0.2 到 0.4。
5.2 二次爆炸:让烟花更有层次
单次爆炸的烟花看多了会腻。我后来加了一个二次爆炸的效果:一部分爆炸粒子在飞行一段时间后,再次爆炸成更小的粒子。
实现上,给粒子加一个isSecondary标记和secondaryTimer。当计时器归零时,把这个粒子替换成一组更小的粒子,速度方向随机,颜色偏暖。
这个效果的关键是控制二次爆炸的比例。如果所有粒子都二次爆炸,看起来太乱;我一般让 10% 到 20% 的粒子有二次爆炸,这样既有层次感又不会太杂乱。
5.3 性能优化的几个实用技巧
第一个技巧是减少每帧的顶点数据量。如果粒子很小(比如小于 2 像素),可以直接跳过渲染,因为根本看不清。这样能省下不少顶点。
第二个技巧是用 LOD(细节层次)。远处的烟花用更少的粒子,近处的用更多。判断标准可以是粒子到相机的距离,或者粒子在屏幕上的投影大小。
第三个技巧是把粒子的更新分成奇偶帧。奇数帧更新一半粒子,偶数帧更新另一半。这样每帧的 CPU 计算量减半,但视觉上几乎看不出差别,因为粒子的位置变化很小。
第四个技巧是用 SIMD 指令加速粒子更新。把粒子数据按 SoA(结构体数组转数组结构体)的方式重新排列,然后用 SSE 或 AVX 指令批量计算。这个优化比较底层,但效果显著,粒子数量多的时候能提升 2 到 3 倍性能。
5.4 参数调优的实战记录
最后分享一组我调了很久才满意的参数,可以直接参考:
| 参数 | 数值 | 说明 |
|---|---|---|
| 升空速度 | 400 像素/秒 | 太快看不清拖尾,太慢显得无力 |
| 爆炸粒子数 | 800 | 兼顾饱满度和性能 |
| 爆炸速度范围 | 150-400 像素/秒 | 速度差产生层次感 |
| 重力 | 300 像素/秒平方 | 下落速度适中 |
| 阻力系数 | 0.99 | 每帧衰减 1% |
| 粒子生命周期 | 1.5-2.5 秒 | 太短来不及下落,太长显得拖沓 |
| 拖尾发射频率 | 每帧 3 个 | 保证拖尾连续 |
| 拖尾生命周期 | 0.3-0.5 秒 | 刚好落在主粒子后面 |
这些参数不是绝对的,你可以根据自己的屏幕分辨率和审美偏好调整。我的建议是先把重力定下来,然后调爆炸速度,最后调生命周期和阻力。每次只改一个参数,改完看效果,这样能快速找到感觉。
我在实际项目里发现,烟花的颜色比形状更重要。同样的爆炸形状,配色好看的话整体效果会提升一个档次。我常用的几组配色是:红金(红为主,金色拖尾)、蓝银(蓝色爆炸,银色闪烁)、绿紫(绿色和紫色交替)、彩虹(每个粒子随机色相)。你可以多试几组,找到自己喜欢的风格。