《GPU 粒子与特效系统:计算着色器、Transform Feedback 与十万级粒子》

粒子特效是游戏中最常见也最吃性能的效果之一。本文对比 CPU 与 GPU 粒子的适用场景,讲解计算着色器模拟、Transform Feedback 与顶点着色器方案、粒子数据的 SoA/AoS 内存布局、半透明排序与混合策略,剖析 UE Niagara 与 Unity VFX Graph 的架构思路,并给出十万级粒子的性能优化清单与间接绘制方案。

粒子特效是「视觉冲击力 / 性能开销」比值最极端的系统:一场爆炸可能只有几百个粒子,而一场沙尘暴可以轻松上十万。传统 CPU 逐粒子更新在几千个量级就会吃满一帧的主线程预算,而现代 GPU 粒子系统能同时驱动百万级粒子仍保持 60 FPS。本文从 CPU/GPU 的分野讲起,深入计算着色器模拟、数据布局、排序混合与 VFX Graph 架构,给出可落地的工程方案。相关背景可参考 https://plumephp.com/graphics-deferred-tiled-rendering/。


一、粒子系统基础与 CPU/GPU 分野

一句话:CPU 粒子胜在灵活(任意逻辑、物理交互),GPU 粒子胜在规模(十万级),现代引擎通常两者并存、按需选择。

1.1 粒子的生命周期

无论 CPU 还是 GPU,粒子系统都遵循同一套生命周期:

  1. 发射(Emit):在发射器位置按速率与形状生成新粒子。
  2. 模拟(Simulate):更新位置、速度、寿命、颜色、大小。
  3. 排序(Sort):半透明粒子需按深度排序。
  4. 渲染(Render):作为 billboard / mesh / 拖尾绘制。
  5. 回收(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 FeedbackCompute Shader
引入版本GL 3.0 / D3D11GL 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 粒子与特效系统的核心脉络:

  1. 架构选择:CPU 粒子灵活、GPU 粒子规模大,现代引擎按「逻辑复杂度 vs 粒子数量」混合使用。
  2. 模拟:计算着色器是主流,Transform Feedback 是遗留方案,解析式运动是最省的零状态方案。
  3. 数据:SoA 布局 + 存活压缩 + 原子分配,是十万级粒子的内存基础。
  4. 渲染:排序是最大难题,能加法混合就不排序,能分桶就不全局排序,OIT 是高质量替代。
  5. 架构:VFX Graph / Niagara 用数据流图 + GPU 常驻,把粒子从「逐帧更新」变成「持续模拟」。
  6. 优化:间接绘制 + 半分辨率 + LOD + 异步计算,把百万级粒子压进帧预算。

粒子特效是最能体现「GPU 驱动」思想的效果系统——把决定权交给 GPU,把 CPU 从逐粒子循环中解放出来。掌握这套体系后,建议继续阅读 https://plumephp.com/graphics-gpu-driven-rendering-lod/ 理解 GPU 驱动渲染的通用范式,以及 https://plumephp.com/gpu-architecture-compute/ 深入计算着色器的硬件细节。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「计算机图形学」更多文章

  1. 《物理模拟与渲染集成:固定步长、插值与确定性同步》
  2. 《色彩管理与 HDR 管线:线性工作流、ACES 色调映射与宽色域》
  3. 《可见性剔除系统:视锥、BVH、Hi-Z 与 GPU Driven 剔除》