写 cluster 那两篇时埋了个话头:ClusterBuilderRD 是个模板,render_forward_clustered.cpp 里不止一个实例。这篇兑现它——decal 确实"挤"进了 cluster 体系,用的是和光源完全同一套求交代码;而 fog volume 听起来像同类,实际走的是一条完全独立的路径。把两条路分开讲,是因为它们的设计动机不一样。
decal:模板的第二个实例
decal 和光源在数据结构上是同构的:每帧一份可见元素列表,每个元素一个视空间 AABB,都要回答"这个像素/格子受哪些元素影响"。所以 Forward+ 渲染器里 decal_cluster_builder 就是同一个模板的第二次实例化,构建流程一字不差——begin() 清表、逐 decal add_element()、update() 前缀和压缩、上传 SSBO。元素池和 cluster 表与光源各自独立,限额也是各管各的(rendering/limits/ 下分别设置)。
片元侧的消费在场景 shader(scene_forward_clustered.glsl)里:查表拿到当前 cell 的 decal 区间后,逐个把片元位置投影到该 decal 的投影盒(一个正交/透视小视锥)里采样。decal 的材质拆成 albedo、normal、ORM 三张纹理,采样结果按权重往表面属性上叠——法线用白化混合,粗糙度/金属度一般取覆盖侧的较小值,让贴花看起来"压"在表面上。
溢出行为和光源一致:超过元素上限的 decal 直接不进表,引擎打一次性警告。也就是说 decal 丢的时候不是变黑,是整张贴花消失,排查时优先数数量。
fog volume:不查 cluster,去注体素网格
FogVolume 如果也走屏幕 cluster 就麻烦了——雾不是逐像素的叠加物,它是参与raymarch 的体积介质,需要一个三维密度场。所以它的参与点是 volumetric fog 那一 pass:
- 每帧把可见 fog volume 列表(AABB、形状、密度、反照率)传给体雾 compute;
- compute 对相机空间体雾 3D 纹理的每个体素,遍历雾体列表做求交:体素中心落在雾体 AABB 内才写入;
- 写入值是密度 × 形状 falloff。以椭球为例,归一化半径
$$ q = \sqrt{\sum_{i \in \{x,y,z\}} \left(\frac{p_i - c_i}{r_i}\right)^2} $$
$p$ 是体素中心,$c$ 是雾体中心,$r$ 是三轴半径。$q > 1$ 在体外不写;体内按边缘衰减参数插值出密度权重,乘上 FogMaterial 的 density 写进纹理。
之后主 pass 的每个像素 raymarch 这条 3D 纹理时,看到的已经是"含雾"的密度场,跟光源、decal 的 cluster 表没有任何关系。体雾纹理分辨率独立设置(rendering/environment/volumetric_fog/ 一组),分辨率越低越糊但越快——这也是 volumetric fog 最大的质量旋钮。
同一个模式,三次复用
把三篇连起来看,Forward+ 里反复出现同一个抽象:每帧元素列表 × 元素与空间单元的求交 × 结果表给 shader 查询。光源用屏幕空间的 cluster 网格,decal 复用同一个网格和同一套代码,fog volume 换成相机空间的体素网格。三次复用,三次独立的限额和性能陷阱:灯数、decal 数、雾体数任何一个爆了,症状都是"某些效果整批消失",但要去三个不同的设置项里找上限。
这个复用方式本身就是答案:cluster builder 写成模板而不是写死光源,就是预见了"下一个进来的元素类型"。代价已经说过了——每类元素的预算要自己盯。
本文基于 Godot 4.x 源码 servers/rendering/renderer_rd/render_forward_clustered.cpp(双 cluster builder 实例)、servers/rendering/renderer_rd/shaders/scene_forward_clustered.glsl(片元消费)与 volumetric fog 通道(servers/rendering/renderer_rd/environment/)。