粒子特效是「视觉冲击力 / 性能开销」比值最极端的系统:一场爆炸可能只有几百个粒子,而一场沙尘暴可以轻松上十万。传统 CPU 逐粒子更新在几千个量级就会吃满一帧的主线程预算,而现代 GPU 粒子系统能同时驱动百万级粒子仍保持 60 FPS。本文从 CPU/GPU 的分野讲起,深入计算着色器模拟、数据布局、排序混合与 VFX Graph 架构,给出可落地的工程方案。相关背景可参考 https://plumephp.com/graphics-deferred-tiled-rendering/。
一、粒子系统基础与 CPU/GPU 分野
一句话:CPU 粒子胜在灵活(任意逻辑、物理交互),GPU 粒子胜在规模(十万级),现代引擎通常两者并存、按需选择。
1.1 粒子的生命周期
无论 CPU 还是 GPU,粒子系统都遵循同一套生命周期:
- 发射(Emit):在发射器位置按速率与形状生成新粒子。
- 模拟(Simulate):更新位置、速度、寿命、颜色、大小。
- 排序(Sort):半透明粒子需按深度排序。
- 渲染(Render):作为 billboard / mesh / 拖尾绘制。
- 回收(Kill):寿命耗尽或出界后回到池中。
1.2 CPU vs GPU 对比
| 维度 | CPU 粒子 | GPU 粒子 |
|---|---|---|
| 规模上限 | 数千~数万 | 十万~百万 |
| 灵活性 | 高(任意 C++ 逻辑) | 中(受限于着色器) |
| 物理交互 | 易(碰撞、射线) | 难(需回读或 GPU 碰撞) |
| 排序 | 易 | 难(需 GPU 排序) |
| 内存回读 | 无 | 需 staging buffer |
| 适用 | 关键特效、逻辑驱动 | 大规模、GPU 驱动 |
// C++:CPU 粒子的典型更新循环
void UpdateCPUParticles(float dt) {
for (auto& p : particles) {
if (!p.alive) continue;
p.velocity += gravity * dt; // 重力
p.velocity *= pow(damping, dt); // 阻尼
p.position += p.velocity * dt; // 积分
p.life -= dt;
if (p.life <= 0.0f) p.alive = false;
}
}
1.3 何时选哪个
- 选 CPU:粒子数量少但逻辑复杂(需要碰撞、需要驱动游戏逻辑、需要读取粒子状态)。
- 选 GPU:数量大、规则统一(烟雾、火花、雨雪、魔法阵)。
- 混合:用 GPU 模拟主体,CPU 只处理「少量关键粒子 + 事件回读」。
一句话:判断标准不是「粒子多不多」,而是「模拟逻辑是否依赖 CPU 侧的游戏状态」。
二、计算着色器粒子模拟
一句话:计算着色器把每个粒子交给一个线程,模拟全部在 GPU 内完成,CPU 只负责发射参数与绘制命令。
2.1 数据布局:SoA vs AoS
GPU 粒子的内存布局决定访存效率。SoA(Structure of Arrays) 把每个属性拆成独立数组,线程访问同一属性时完全连续,是 GPU 粒子的首选:
// HLSL:SoA 布局,各属性独立缓冲
struct ParticleBuffers {
RWStructuredBuffer<float3> position; // 位置
RWStructuredBuffer<float3> velocity; // 速度
RWStructuredBuffer<float4> color; // 颜色
RWStructuredBuffer<float> life; // 剩余寿命
RWStructuredBuffer<float> size; // 大小
RWStructuredBuffer<uint> aliveIndex; // 存活索引(压缩)
};
// AoS 布局(对比):交错存储,访存跨步大
struct Particle { float3 pos; float3 vel; float4 color; float life; };
// RWStructuredBuffer<Particle> particles; // 不推荐用于大规模
| 布局 | 访存模式 | 缓存效率 | 适用 |
|---|---|---|---|
| SoA | 连续 | 高 | GPU 模拟 |
| AoS | 跨步 | 低 | CPU 模拟、小规模 |
2.2 模拟 Compute Shader
// HLSL:粒子模拟 compute shader
[numthreads(256, 1, 1)]
void SimulateParticles(uint3 id : SV_DispatchThreadID) {
uint i = id.x;
if (i >= particleCount) return;
float dt = deltaTime;
float3 pos = position[i];
float3 vel = velocity[i];
float l = life[i];
if (l <= 0.0f) {
// 死亡:从存活列表移除(用压缩/标记,稍后 compact)
aliveIndex[i] = 0xFFFFFFFF;
return;
}
// 力累加:重力 + 风 + 湍流
float3 force = gravity;
force += wind * windStrength;
force += curlNoise(pos * noiseScale) * turbulence;
vel += force * dt;
vel *= exp(-drag * dt); // 空气阻力
pos += vel * dt; // 显式欧拉(或用 RK4)
l -= dt;
position[i] = pos;
velocity[i] = vel;
life[i] = l;
aliveIndex[i] = i; // 标记存活
}
2.3 发射与压缩
发射时用环形缓冲 / 空闲列表分配槽位;压缩(compaction)时用前缀和把存活粒子紧凑排列,配合 InterlockedAdd 分配绘制索引:
// HLSL:用 InterlockedAdd 压缩存活粒子
RWStructuredBuffer<uint> drawIndices;
RWByteAddressBuffer drawArgs; // IndirectArgs
[numthreads(256,1,1)]
void CompactAlive(uint3 id : SV_DispatchThreadID) {
uint i = id.x;
if (life[i] <= 0.0f) return;
uint slot;
drawIndices.InterlockedAdd(counterOffset, 1, slot); // 原子分配
drawIndices[slot] = i;
}
2.4 时间步与确定性
GPU 粒子用固定步长(如 1/60 s)避免帧率相关;若需与 CPU 一致(网络同步),需固定种子与顺序,但 GPU 并行执行本质上是非确定的——这是 GPU 粒子的固有局限。
一句话:Compute 粒子的关键三件套是「SoA 布局 + 固定步长 + 原子压缩」,缺一不可。
三、Transform Feedback 与顶点着色器方案
一句话:Transform Feedback(OpenGL)与 Stream Output(D3D)让顶点着色器输出回写到缓冲,是计算着色器出现前的主流 GPU 粒子方案。
3.1 Transform Feedback 原理
Transform Feedback 允许顶点着色器把输出变量写入 buffer object,下一帧再作为顶点输入。用它做粒子模拟:一个顶点 = 一个粒子,顶点着色器读上一帧状态、算新状态、输出到缓冲。
// GLSL:Transform Feedback 粒子更新
#version 430 core
layout(location = 0) in vec3 inPosition;
layout(location = 1) in vec3 inVelocity;
layout(location = 2) in float inLife;
out vec3 outPosition;
out vec3 outVelocity;
out float outLife;
uniform float dt;
uniform vec3 gravity;
void main() {
vec3 vel = inVelocity + gravity * dt;
vec3 pos = inPosition + vel * dt;
float life = inLife - dt;
outPosition = pos;
outVelocity = vel;
outLife = life;
gl_Position = vec4(0.0); // 不实际渲染,仅用于 TF
}
// C++:绑定 Transform Feedback(OpenGL)
glEnable(GL_RASTERIZER_DISCARD); // 关闭光栅化,只跑 TF
glBindTransformFeedback(GL_TRANSFORM_FEEDBACK, tfbo);
glBindBufferBase(GL_TRANSFORM_FEEDBACK_BUFFER, 0, particleBufferNext);
glBeginTransformFeedback(GL_POINTS);
glDrawArrays(GL_POINTS, 0, particleCount);
glEndTransformFeedback();
glDisable(GL_RASTERIZER_DISCARD);
// 双缓冲交换:particleBufferNext ↔ particleBufferCurrent
3.2 与 Compute 的对比
| 维度 | Transform Feedback | Compute Shader |
|---|---|---|
| 引入版本 | GL 3.0 / D3D11 | GL 4.3 / D3D11 |
| 灵活性 | 低(顶点模型受限) | 高(任意线程) |
| 共享内存 | 无 | 有(group shared) |
| 原子操作 | 有限 | 完整 |
| 排序 | 需额外 pass | 可内建 |
| 现状 | 遗留 / 简单场景 | 主流 |
3.3 顶点着色器「伪模拟」
还有一种更省事的方案:不做真实模拟,而是让每个粒子的运动完全由「出生时间 + 解析函数」决定,顶点着色器按 t = now - birthTime 直接算位置:pos = origin + v₀·t + ½·g·t²。这适合烟花、喷泉等可预测效果,完全无状态、零模拟开销。
一句话:如果粒子轨迹能写成解析式,就完全不需要模拟——这是最便宜也最稳定的方案。
四、排序与混合
一句话:半透明粒子必须「从后往前」混合,而 GPU 粒子的深度是动态的,排序成了最大的工程难题。
4.1 为什么排序
Alpha 混合不满足交换律:mix(mix(a, b, αb), c, αc) ≠ mix(mix(a, c, αc), b, αb)。排序错误会导致粒子看起来「顺序颠倒」。
4.2 排序方案对比
| 方案 | 质量 | 成本 | 适用 |
|---|---|---|---|
| 每帧 CPU 排序 | 高 | 高(回读 + 排序) | 少量粒子 |
| GPU Bitonic Sort | 高 | 中 | 万级粒子 |
| 深度分桶(Depth Bucket) | 中 | 低 | 大量粒子 |
| 不排序 + 加法混合 | 低 | 零 | 火花、光效 |
| OIT(加权混合) | 高 | 中高 | 高画质 |
4.3 GPU Bitonic 排序
在 compute shader 里对粒子按深度做双调排序(Bitonic Sort),每帧若干 pass:
// HLSL:Bitonic sort 的一步(简化)
[numthreads(256,1,1)]
void BitonicSortStep(uint3 id : SV_DispatchThreadID) {
uint i = id.x;
uint j = i ^ stage; // 比较伙伴
if (j <= i) return; // 每对只处理一次
bool ascending = ((i & (stage << 1)) == 0) == (direction == 0);
float di = depthKey[i];
float dj = depthKey[j];
if ((di > dj) == ascending) { // 交换
depthKey[i] = dj; depthKey[j] = di;
uint ti = index[i]; index[i] = index[j]; index[j] = ti;
}
}
4.4 加法混合与软粒子
很多效果(火花、魔法、能量)其实不需要排序——用加法混合(Additive) 即可,因为它满足交换律。软粒子(Soft Particles)用深度缓冲让粒子在靠近几何时淡出,避免硬边:
// GLSL:软粒子,靠近不透明几何时淡出
float softParticleFade(float sceneDepth, float particleDepth, float fadeDist) {
float diff = sceneDepth - particleDepth;
return saturate(diff / fadeDist);
}
一句话:能加法混合就不排序,能分桶就不做全局排序——排序是万级粒子的性能分水岭。
五、VFX Graph / Niagara 的架构思路
一句话:现代 VFX 系统的核心是「数据流图 + GPU 常驻」,把粒子系统从「逐帧更新」变成「GPU 上的持续模拟」。
5.1 数据流图模型
UE Niagara 与 Unity VFX Graph 都用节点图描述粒子行为,编译成 GPU 程序:
[Emitter] → [Spawn Rate] → [Initialize Particle] → [Update Particle] → [Render]
↑ ↑
[Random Velocity] [Gravity Force]
[Curl Noise Force]
[Collision]
每个阶段都是一个可并行的 GPU 阶段(Spawn / Initialize / Update / Render),粒子数据常驻显存,无需 CPU 往返。
5.2 关键架构点
| 特性 | 说明 |
|---|---|
| GPU 常驻 | 粒子数据留在显存,避免回读 |
| 阶段化 | Spawn / Update / Render 分离 |
| 事件系统 | 粒子可触发事件(碰撞、死亡) |
| 曲线与梯度 | 生命周期内插值颜色/大小 |
| 模块复用 | 力、碰撞、噪声作为可组合模块 |
5.3 事件回读
当游戏逻辑需要知道「粒子撞到了什么」时,需要 GPU→CPU 回读:把事件写入 staging buffer,延迟 1~3 帧读回。这带来延迟,但避免每帧同步。
// C++:异步回读粒子事件
struct FParticleEvent { uint32 Type; uint32 ParticleID; float3 Position; };
// 1. Compute 写入 eventBuffer
// 2. CopyResource 到 staging buffer
// 3. 若干帧后 Map 读取(避免 stall)
一句话:VFX 图的价值不在「可视化编辑」,而在把粒子模拟变成 GPU 常驻的、可组合的数据流。
六、十万级粒子性能优化
一句话:十万级粒子的瓶颈通常不是模拟而是「带宽 + 混合 + 绘制」,优化要三管齐下。
6.1 优化清单
| 优化 | 收益 | 说明 |
|---|---|---|
| SoA 布局 | 1.5×~3× | 连续访存 |
| 存活压缩 | 视死亡率 | 只模拟/绘制存活粒子 |
| 间接绘制 | 省 CPU 往返 | GPU 决定绘制数量 |
| 加法混合 | 省排序 | 免排序 |
| 降分辨率渲染 | 4× | 粒子是低频信号 |
| LOD + 异步计算 | 2×~4× | 远处少粒子,计算与渲染重叠 |
6.2 间接绘制
用 compute shader 把存活粒子数写入 IndirectArgs,DrawInstancedIndirect 直接消费,CPU 完全不知道实际绘制多少:
// C++:间接绘制调用
struct FDrawArgs {
uint32 VertexCountPerInstance; // 4(billboard 四边形)
uint32 InstanceCount; // 由 GPU 写入
uint32 StartVertexLocation;
uint32 StartInstanceLocation;
};
// GPU: 把存活数写入 InstanceCount
// CPU: DrawInstancedIndirect(argsBuffer, 0)
6.3 半分辨率与重建
粒子通常是低频、柔和的,可以半分辨率渲染再上采样;配合深度感知上采样(用粒子深度)避免与几何边缘冲突。这与体积云、后处理降采样是同一套思路,参见 https://plumephp.com/graphics-post-processing/。
6.4 预算分配
| 效果类型 | 典型粒子数 | 预算占比 |
|---|---|---|
| 角色技能 | 500~5000 | 低 |
| 环境天气(雨雪) | 1 万~10 万 | 中 |
| 大规模破坏 | 10 万~100 万 | 高 |
一句话:十万级粒子的秘诀是「GPU 决定画多少(间接绘制)+ 加法混合免排序 + 半分辨率省填充率」。
七、模拟质量与高级效果
一句话:真实感来自力的多样性——重力、阻力、湍流、涡度约束与碰撞,共同塑造自然运动。
7.1 湍流与 Curl Noise
无散度的 Curl Noise 产生「涡旋状」的流体感,是烟雾、魔法效果的标配。它由势函数 ψ 求旋度得到:v = ∇ × ψ,数学上保证 ∇·v = 0(无源无汇),因此粒子不会被「吸进」或「推出」某个点:
// GLSL:Curl Noise(由势函数求旋度,保证无散度)
vec3 curlNoise(vec3 p) {
const float e = 0.1;
// 势函数三通道在 ±e 处的差分,构成旋度
vec3 dx = vec3(e, 0, 0), dy = vec3(0, e, 0), dz = vec3(0, 0, e);
float x = potential(p + dy).z - potential(p - dy).z
- potential(p + dz).y + potential(p - dz).y;
float y = potential(p + dz).x - potential(p - dz).x
- potential(p + dx).z + potential(p + dx).z;
return normalize(vec3(x, y, 0.0) / (2.0 * e));
}
7.2 碰撞与交互
GPU 粒子难以做真实碰撞,常用近似:
| 方案 | 精度 | 成本 |
|---|---|---|
| 深度缓冲碰撞 | 中 | 低 |
| SDF 碰撞 | 高 | 中 |
| 物理引擎回读 | 最高 | 高 |
7.3 拖尾与 Ribbon
拖尾粒子(Trail / Ribbon)用历史位置生成条带网格,需要保存每个粒子的前 N 帧位置。这对内存布局提出更高要求(环形历史缓冲)。
// C++:拖尾的历史位置环形缓冲
struct FTrailParticle {
static constexpr int HistoryCount = 8;
float3 history[HistoryCount]; // 环形
int head;
};
八、工程实践与常见问题(FAQ)
Q:GPU 粒子看不到,怎么调试?
A:先确认 dispatch 的线程数覆盖粒子总数;再检查 buffer 是否正确绑定为 UAV/SRV;最后用 DrawInstancedIndirect 的 args 回读确认 instance count。
Q:粒子闪烁 / 抖动?
A:多半是模拟步长随帧率变化。改用固定步长 + 累加器,或把 dt 钳制在合理范围。
Q:粒子穿透地面?
A:GPU 粒子默认无碰撞。用深度缓冲或 SDF 做近似,或在 CPU 侧对关键粒子做校正。
Q:排序开销太大?
A:改用加法混合;或按深度分桶(16~64 桶),桶内不排序;或用 OIT 加权混合。
Q:粒子数量上不去?
A:检查是否每帧回读、是否用了 AoS、是否未做存活压缩、是否全分辨率渲染。
Q:WebGL2 没有 compute shader 怎么办?
A:用 Transform Feedback 或解析式运动;WebGPU 有 compute 但需注意 workgroup_size 限制。
一句话:粒子系统的调试顺序永远是「数量 → 布局 → 混合 → 分辨率」,按这个顺序排查最省时间。
总结
GPU 粒子与特效系统的核心脉络:
- 架构选择:CPU 粒子灵活、GPU 粒子规模大,现代引擎按「逻辑复杂度 vs 粒子数量」混合使用。
- 模拟:计算着色器是主流,Transform Feedback 是遗留方案,解析式运动是最省的零状态方案。
- 数据:SoA 布局 + 存活压缩 + 原子分配,是十万级粒子的内存基础。
- 渲染:排序是最大难题,能加法混合就不排序,能分桶就不全局排序,OIT 是高质量替代。
- 架构:VFX Graph / Niagara 用数据流图 + GPU 常驻,把粒子从「逐帧更新」变成「持续模拟」。
- 优化:间接绘制 + 半分辨率 + LOD + 异步计算,把百万级粒子压进帧预算。
粒子特效是最能体现「GPU 驱动」思想的效果系统——把决定权交给 GPU,把 CPU 从逐粒子循环中解放出来。掌握这套体系后,建议继续阅读 https://plumephp.com/graphics-gpu-driven-rendering-lod/ 理解 GPU 驱动渲染的通用范式,以及 https://plumephp.com/gpu-architecture-compute/ 深入计算着色器的硬件细节。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。