前面渲染器系列讲的都是"画什么",这篇讲"不算什么"——粒子的运动计算为什么全程不碰 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/)。一帧的序列:
- frame uniform 更新:CPU 只写一小块 uniform——当前时间、delta、发射器变换、随机种子;
- 模拟 dispatch:按
ceil(amount / 256)个 workgroup 发出去,每个线程管一个槽位。线程内按生命周期阶段分支:该发射的发射(rate × delta + burst)、该积分的积分(速度 += 重力×delta,再乘阻尼 $v \gets v \cdot (1 - d)^{\Delta t}$)、该回收的回收; - 碰撞与吸引子:
GPUParticlesCollision3D/GPUParticlesAttractor3D的数量和参数作为另一组 buffer 传入,线程里逐个求交; - 排序:透明粒子要按深度从远到近画,
particles_copy.glsl用 bitonic sort 对活粒子索引重排(写入一张排序后的索引表,不动原始数据); - 绘制:普通 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 是同一个问题。
本文基于 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 为准)。