技术博客

ParticlesProcessMaterial 的 GPU 模拟:粒子数据全程不进 CPU

第 57 / 149 篇 · 2025-11-14 · godot · 渲染管线GPU粒子系统

前面渲染器系列讲的都是"画什么",这篇讲"不算什么"——粒子的运动计算为什么全程不碰 CPU。答案在 GPUParticles3D 的实现路径上:它从节点到 GPU 只传参数,不传粒子。粒子本身的位置、速度、颜色在显存里出生、演化、消亡,CPU 从头到尾没见过任何一个粒子的数值。

节点层到存储层:只传参数

GPUParticles3D 节点和渲染服务器的关系,跟 MeshInstance3D 类似但更薄。节点初始化时向 RenderingServer 要一个 particles RID:

# 等价的 C++ 路径:GPUParticles3D 创建时
particles = RS::get_singleton()->particles_create()
particles_set_mode(particles, RS::PARTICLES_MODE_3D)
particles_set_amount(particles, 10000)          # 上限,buffer 按这个预分配
particles_set_process_material(particles, mat)  # 一个 RID,参数整体移交

之后每帧节点侧几乎无事可做。ParticlesProcessMaterial 也不是逐帧执行的脚本——它是一个参数包:初速度、重力、阻尼、发射形状、生命周期曲线,全部序列化成 uniform,连同一套编译好的 compute shader 一起交给 ParticlesStorage(servers/rendering/renderer_rd/storage_rd/particles_storage.cpp)。参数变了就更新 uniform,粒子本身一个字节都不动。

粒子状态住在哪些 buffer 里

ParticlesStorage 按 amount 上限预分配一组 SSBO(storage buffer),每种属性一张:

buffer 内容 每粒子字节量级
state 变换矩阵 + 存活标志 + 随机种子 ~64 B
velocity 当前速度 16 B
color 颜色 + 自定义渐变通道 16 B
custom 用户数据通道(process material 的 custom 曲线) 16 B

渲染需要的实例数据(变换、颜色)就在这些 buffer 里,draw 时直接绑成 instance buffer 用,连"拷到 instance buffer"这一步都省了。

每帧的 dispatch 序列

模拟本体是 particles.glsl 一族 compute shader(servers/rendering/renderer_rd/shaders/)。一帧的序列:

  1. frame uniform 更新:CPU 只写一小块 uniform——当前时间、delta、发射器变换、随机种子;
  2. 模拟 dispatch:按 ceil(amount / 256) 个 workgroup 发出去,每个线程管一个槽位。线程内按生命周期阶段分支:该发射的发射(rate × delta + burst)、该积分的积分(速度 += 重力×delta,再乘阻尼 $v \gets v \cdot (1 - d)^{\Delta t}$)、该回收的回收;
  3. 碰撞与吸引子:GPUParticlesCollision3D / GPUParticlesAttractor3D 的数量和参数作为另一组 buffer 传入,线程里逐个求交;
  4. 排序:透明粒子要按深度从远到近画,particles_copy.glsl 用 bitonic sort 对活粒子索引重排(写入一张排序后的索引表,不动原始数据);
  5. 绘制:普通 Mesh 走 instance draw,点精灵模式直接画点。

CPU 在这一整段里只做"发几次 dispatch + 一次 barrier",这就是"数据全程不进 CPU"的准确含义:不是用了什么零拷贝技巧,而是计算的位置决定了数据根本不需要过来。

为什么必须这样:一笔带宽账

假设你确实想每帧知道每个粒子的位置——比如想做粒子驱动的游戏逻辑。回读的代价:

$$ \text{回读量} = N \times (64 + 16 + 16 + 16)\,\text{B} $$

$N = 10^4$ 时约 1.1 MB/帧。听着不大,但 Vulkan/D3D 的回读要求 pipeline 把写到这些 buffer 的 compute pass 全部执行完,CPU 拿到数据时已经过了几帧;想同步等,就是把整条 GPU 管线 stall 在 60fps 的帧预算里。所以引擎的选择是干脆不让你回读——GPUParticles 的 API 里就没有"读粒子属性"的入口,想在粒子系统里跑游戏逻辑,正确姿势是 GPUParticles3D.emit_particle() 接口或改用 CPUParticles3D。

CPUParticles3D 是同一个 ParticlesProcessMaterial 的 CPU 参照实现,语义一致、数据在内存里随便读,代价是 1 万粒子就把脚本/原生代码的循环喂饱了。两个类并存不是历史遗留,是让"数据留在 GPU"和"逻辑摸得到数据"成为两个显式选项。

实务上的几个结论

  • amount 是预分配上限,不是当前数量。1 万的 amount 即使只发 10 个粒子,buffer 也占着;想清楚再填。
  • 自定义行为写不了 GDScript——process material 是声明式参数。要自定义就得换 ShaderMaterial 写自己的 particle shader,或者叠 attractor/collision 节点。
  • 粒子碰撞是采样式的(SDF、高度图、定向 box),不是精确接触流形;高速粒子和薄墙同样会隧穿,思路和物理 CCD 是同一个问题。

GPUParticles3D 一帧数据流:CPU 只发 dispatch,粒子数据不出显存 GPUParticles3D 场景节点 process_material 参数 → uniform set RenderingServer particles_* API ParticlesStorage(SSBO) state:变换 + 存活标志 velocity:当前速度 color:颜色 / 渐变 custom:用户数据通道 按 amount 上限预分配 particles.glsl 模拟 dispatch 发射 → 积分 → 吸引子 / 碰撞 particles_copy.glsl bitonic 排序 + 双缓冲拷贝 实例化绘制,数据不出 GPU 1 万粒子 ≈ 1.1 MB 状态;回读要求 compute 全部完成并 stall 管线——逻辑要摸数据就用 CPUParticles3D

本文基于 Godot 4.x 源码 servers/rendering/renderer_rd/storage_rd/particles_storage.cpp 与 servers/rendering/renderer_rd/shaders/particles.glsl、particles_copy.glsl(路径与文件名以 4.3/4.4 为准)。