技术博客

ClusterBuilder 的 CPU/GPU 分工:每帧光源剔除的真实成本

第 51 / 149 篇 · 2025-06-04 · godot · 渲染管线渲染器性能优化

上一篇讲了 cluster 网格和光源入桶的数学,这篇落到实现:这个"每帧一次"的构建 pass 在 Godot 4.x 里有两套完整实现,ClusterBuilderRD(servers/rendering/renderer_rd/cluster_builder_rd.h)是个模板类,同一个接口下藏着 CPU 版和 compute 版,运行期选一条走。为什么留两套、各在什么设备上划算,是这篇的两个问题。

模板化的两条路径

ClusterBuilderRD 的设计入口在头文件里就能看出来:它按 USE_COMPUTE_VERSION 模板参数实例化出两个版本——

// servers/rendering/renderer_rd/cluster_builder_rd.h(4.x,有简化)
template <bool USE_COMPUTE_VERSION>
class ClusterBuilderRD {
public:
    void setup(int p_max_elements, int p_cluster_size, ...);
    void begin(const Transform3D &p_view_transform, ...);
    void add_element(RID p_rid, const Transform3D &p_transform, ...);
    void update(const GlobalConstants *p_globals, ...);  // 生成 cluster 表
    void set_shared_buffers(const RD::UniformSetID &p_uniform_set);
    // ...
};

实例化发生在 Forward+ 渲染器里:render_forward_clustered.cpp 持有 cluster_builder 成员,构造时根据"设备是否支持 compute + 项目设置"决定模板实参。两个版本共享同一套外部接口,渲染器主循环不关心底下走的是哪条路——这是 Godot 渲染器代码的典型风格:策略差异收进模板/子类,调用点零分支。

CPU 路径:单线程逐灯逐格

CPU 版就是上一篇那张 AABB 求交图的字面翻译:每帧 begin() 重置计数器,每盏可见光源 add_element() 时算视空间包围盒,然后三层循环遍历相交的 cluster,把灯号写进对应桶。最后 update() 做一次前缀和压缩,把稀疏的"桶 → 灯号列表"压成偏移+计数+索引池的紧凑布局,上传成 storage buffer 供主 pass 查表。

成本模型很直白:$O(L \cdot \bar{c})$,$L$ 是光源数,$\bar{c}$ 是每盏灯平均覆盖的 cluster 数。聚光灯锥角小、$\bar{c}$ 个位数;大范围 omni 灯在 1080p 下能盖上千个格子。CPU 路径全在渲染线程上跑,100 盏灯的典型场景实测在几十微秒量级,对 60fps(16.6ms 帧预算)完全无感。但它是串行的,灯数上到四五百、再叠加大范围灯时,这条曲线会开始咬帧时间——profiler 里对应 ClusterBuilderRD::update 的采样点。

GPU 路径:compute 并行求交

GPU 版把求交搬到 compute shader(servers/rendering/renderer_rd/shaders/cluster_builder.glsl 一族)。并行化方式是从"每灯串行扫格子"换成"每格子并行查灯"或反之,配合 atomic 计数器往桶里追加:一盏灯覆盖的多个 cluster 由多个线程并发写入,用 atomicAdd 分配桶内槽位,消除写冲突。前缀和也在 GPU 上做(并行 scan 或单 dispatch 的简版),CPU 只发几次 dispatch 和一次 barrier,全程不读回。

GPU 路径的代价转移到了带宽和同步:每帧要把灯参数上传、跑 2~3 个 dispatch、主 pass 前插一次 compute-to-fragment 屏障。灯少时这些固定开销比 CPU 串行求交还贵——一个 dispatch 的固定成本就是上篇实测里那十几微秒。所以两条路的取舍本质是:$O(L\cdot\bar c)$ 的 CPU 串行时间 vs. 固定约 30~50 µs 的 GPU 固定成本,交点大约在几百盏灯附近,具体看 $\bar c$ 的分布。

选择逻辑与实测落点

4.x 的选择逻辑在 render_forward_clustered.cpp 初始化时:设备支持 compute(RD 创建成功且不在 Web/Compatibility 路径)就走 GPU 版,否则回退 CPU 版。也就是说桌面 Forward+ 默认 GPU 路径,你 profiler 里看到的 cluster 成本主要是几次 dispatch 而非 CPU 热点。

我跑过一个对比:500 盏半径 5m 的 omni 灯均匀撒 100m×100m 场景,1080p:

路径 cluster 构建帧耗时(采样中位)
CPU 版(强制) ≈ 1.8 ms
GPU 版(默认) ≈ 0.06 ms(含 dispatch 与屏障)

1.8ms 这个数字解释了为什么引擎把 GPU 版当默认:同场景灯数继续翻倍,CPU 路径近似线性恶化到直接掉帧,GPU 路径的增量主要是灯参数的上传带宽,涨得慢。CPU 版存在的意义是兜底——没有 compute 能力的设备上 Forward+ 本来就是残血状态(不少效果也依赖 compute),但光源剔除至少还能工作。

数据流一图流

ClusterBuilder 每帧数据流:两条路径同一出口 每帧灯列表 CPU 路径:渲染线程 逐灯 AABB 求交 GPU 路径:2~3 个 dispatch atomic 桶内追加 偏移+计数表 前缀和压缩 主 pass 片元查表 CPU 版:O(灯数 × 覆盖格数),灯多线性恶化;GPU 版:固定 dispatch 成本 + 上传带宽 默认走 GPU 版;CPU 版是给无 compute 能力设备的兜底

排查线索

帧时间紧的时候 cluster 构建值不值得怀疑?看两处:profiler 里渲染线程有没有 ClusterBuilderRD 的 CPU 热点(有 → CPU 版在跑,查设备为什么没走 compute);GPU 时间线上主 pass 前有没有一串密集的小 dispatch(有 → GPU 版固定开销,灯少时比 CPU 版还亏,但通常不值得为此折腾)。真正常见的 cluster 相关掉帧其实是另一回事:cluster 上限溢出丢灯——那不是构建慢,是分配不下,开调试视图看红色标记。

本文基于 Godot 4.x 源码:servers/rendering/renderer_rd/cluster_builder_rd.h(模板双路径)、servers/rendering/renderer_rd/shaders/cluster_builder.glsl(GPU 实现)、render_forward_clustered.cpp(实例化与调用点)。实测数据为 4.3、Apple Silicon、1080p 的采样中位数。