技术博客

Forward+ 的 cluster shading:光源如何被分配到 cluster

第 50 / 149 篇 · 2026-02-12 · godot · 渲染管线渲染器源码分析

前向渲染的老大难是光源数量:传统 forward 是每像素遍历场景全部光源,$O(\text{像素} \times \text{光源})$。延迟渲染把光照挪到 G-buffer 之后解耦了数量,但代价是带宽和多 pass。Cluster shading(常叫 clustered forward)是前向阵营的解法:保留 forward 的单 pass 结构,用一张"屏幕 tile × 深度切片"的三维网格预先把光源分桶,像素着色时只查自己那个桶。Godot 4 的 Forward+ 渲染器用的就是这套,这篇讲网格怎么划分、光源怎么入桶、数学上为什么是 O(1)。

网格的三维划分

把相机视锥切成三维格子,前两维是屏幕、第三维是深度:

  • x、y 轴:屏幕按 16×16 像素一块切 tile。1920×1080 的视口就是 120×68 个 tile。
  • z 轴:视锥深度方向切成若干切片,按对数分布——近处切片薄、远处切片厚。

深度对数分布不是拍脑袋。透视投影下,屏幕一个像素对应的世界空间面积随 $z^2$ 增长,等距切片会让远处一个 cluster 里塞进指数级变多的世界空间、挤进的光源数也失控。对数分布让每个切片的世界空间体积近似恒定。设近远平面 $z_n, z_f$,切片 $i \in [0, N_z)$ 的边界是:

$$ z_i = z_n \cdot \left(\frac{z_f}{z_n}\right)^{\frac{i}{N_z}} $$

第 $i$ 切片的厚度 $z_{i+1} - z_i = z_n \left(\frac{z_f}{z_n}\right)^{i/N_z}\left[\left(\frac{z_f}{z_n}\right)^{1/N_z} - 1\right]$,正好正比于 $z_i$——越近越薄。Godot 的默认切片数和 tile 尺寸在 servers/rendering/renderer_rd/forward_clustered/render_forward_clustered.cpp 初始化 cluster 数据时定死(4.x 为 16×16 tile、深度切片数在渲染器常量里),不属于 project settings 可调项——调它要动引擎。

像素到 cluster 的索引

片元着色时反推自己属于哪个格子,是三条除法和一条对数:

$$ \begin{aligned} c_x &= \left\lfloor u \cdot W / 16 \right\rfloor \\ c_y &= \left\lfloor v \cdot H / 16 \right\rfloor \\ c_z &= \left\lfloor N_z \cdot \frac{\ln(z / z_n)}{\ln(z_f / z_n)} \right\rfloor \end{aligned} $$

$u,v$ 是屏幕 UV,$z$ 是视空间深度。第三条就是第一条的逆函数,一个 log2 搞定(Godot 的 shader 里预计算了 $\log_2(z_f/z_n)$ 的倒数作为 uniform,片上只留一次乘加)。线性索引 $c_z \cdot (W/16) \cdot (H/16) + c_y \cdot (W/16) + c_x$ 查表。

光源入桶:AABB 对格子求交

分配发生在每帧一次的构建 pass(CPU 或 compute,下一篇细讲)。每个 omni/spot 光源算出视空间 AABB——球光近似为立方体,聚光是截锥但 Godot 用包围盒保守近似——然后遍历与之相交的 cluster,把自己的索引追加进去:

// 概念代码,对应 renderer_rd/shaders/cluster_render.glsl 的分配逻辑
for (uint z = z_min; z <= z_max; z++)
  for (uint y = y_min; y <= y_max; y++)
    for (uint x = x_min; x <= x_max; x++)
      append_light(cluster_index(x, y, z), light_id);

保守是关键词:AABB 相交宁可多给不可少给,多给的结果是某个像素多算一盏灯的贡献(浪费),少给就是直接缺光照(错误)。这个交易在所有剔除系统里都一样——剔除的假阴性是错误,假阳性只是开销。

每个 cluster 有光源数上限(4.x 默认每 cluster 32 盏 omni + 32 盏 spot,超出的光源会被 silently 丢弃并在调试渲染里标红——项目设置里可以调大,代价是显存)。主流水线里的 cluster 数据结构是两张紧凑表:一张"每 cluster 的偏移+计数",一张全局光源索引池,标准的前缀和压缩布局。

一图看懂分配

视锥的 cluster 网格与光源影响域 z 近 z 远 omni 光 AABB 覆盖到的格子全部入桶 近处切片薄 远处切片厚 对数深度分布

复杂度账

预筛之后,像素侧的开销从 $O(L)$($L$ = 场景光源总数)降到 $O(L_c)$($L_c$ = 本 cluster 光源数),配上每 cluster 32 盏的上限就是严格 O(1)。付出的是:每帧一次构建 pass 的 CPU/GPU 成本、两张 cluster 表的显存(1080p 下每类光源约 2~4 MB 量级)、以及每桶上限这个新的资源约束——超过 32 盏灯重叠的 cluster 在 Godot 里是直接丢灯,不是降级,这个是 Forward+ 被诟病的点之一,调试渲染模式(View Cluster 层)就是用来抓这个的。

和 tiled deferred 的渊源也值得一句:cluster shading 是 tiled 思路往深度方向的自然推广(tile → tile×slice),解决的是 tiled forward 里"深度不连续表面互相污染光源列表"的问题——同一 tile 里近处桌面和远处墙面,tiled 共用一个光源列表,clustered 分处不同深度切片各用各的。

本文基于 Godot 4.x 源码:servers/rendering/renderer_rd/forward_clustered/render_forward_clustered.cpp(cluster 初始化与主循环)、servers/rendering/renderer_rd/shaders/cluster_render.glsl(GPU 分配逻辑)。默认 tile 尺寸与切片数以引擎常量为准,不同 4.x 小版本间有微调。