技术博客

阴影贴图:CSM 级联与阴影 atlas 的分配 —— 定向光阴影的分页管理

第 141 / 149 篇 · 2025-09-26 · godot · 渲染管线阴影源码分析

场景里一盏 DirectionalLight 的阴影选项卡里,split 数、splits distribution、max distance 三个参数直接决定阴影成本和观感,但很少有人知道 split 的边界是按对数/均匀混合公式算出来的,也不知道局部光的阴影贴图根本不是"每灯一张"而是所有灯共享一张 2048×2048 的 atlas 分页。这篇拆 4.x 的 RendererSceneRenderRD 阴影子系统。

局部光:一张 atlas 管所有灯

OmniLight 和 SpotLight 的阴影贴图各自共享一张 2048² 的 atlas(shadow_atlas),由 RendererSceneRenderRD 持有。分配逻辑在 RendererLightStorage::shadow_atlas_update() 一类的函数里:每盏开阴影的灯申请一个或多个 quadrant 页面(2048² 切成 2×2 的 quadrant,再按 shadow_atlas_size 需求细分,聚光灯占 1/4、全向光六面占更多),分配失败——atlas 满了——这盏灯直接放弃阴影,没有排队和抢占。

这里有个容易踩的坑:atlas 分配是按灯在场景里的顺序来的,不是按重要性。第 17 盏开阴影的灯和第 1 盏灯争抢同样的空间,跑得快全靠场景里灯的数量稳定。所以 Godot 的做法是"能开几盏是几盏",而不是渲染 20 盏灯再选 4 盏最重要的。工程上的对应策略:静态场景预烘焙大范围照明,实时阴影只留给真正动态的对象。

定向光:CSM 的 split 数学

CSM(cascade shadow map)把视锥沿视线切成 split_count 段(4.2 起最多 4,4.4 放宽到 8),每段单独从光源视角渲染一次深度,近的段分辨率高、远的段糊一点但反正屏幕占比小。

split 边界用经典的 logarithmic/uniform blend:

$$ d_i = \lambda \, d_{near}\left(\frac{d_{far}}{d_{near}}\right)^{\frac{i}{N}} + (1-\lambda)\left(d_{near} + \frac{i}{N}(d_{far} - d_{near})\right) $$

$\lambda$ 就是 Inspector 里的 splits distribution,0 完全均匀(每段等宽,近处糊)、1 完全对数(近处极细,远处段之间跳变明显)。Godot 默认给 0.5 左右是经验折中。每个 cascade 是独立正交投影,每帧 shadow map size 是平方除以 split 数——4 级 4096 意味着每级实际只有 2048²。

抖动与 bias:两个单位别搞混

shadow bias 是阴影贴图深度比较的偏移(单位:深度值),防 acne;normal bias 是把采样点沿法线推出去的世界空间距离,防 peter-panning(阴影和物体脱节)。后者本质是把 self-shadow 问题从"抬高深度阈值"换成"位移采样位置",对斜面更稳。源码里这两个量分别乘上 1.0 - bias 和 normal_bias 传给 shader,改的时候分清自己在调哪个量。

CSM 特有的问题是级联接缝:相邻两段深度分辨率不同,物体横跨边界时会出现一条明暗交界。Godot 的做法是在 fragment 里对两个 cascade 的结果做一段重叠区混合(scene_forward_lights_inc.glsl 里按 fade 加权),混合区宽度由 shadow blend 参数控制。看到接缝先调大这个,别急着加 bias。

一图流

CSM 级联与 atlas 分配 视锥侧视(3 级示意) 级联 1 细 级联 2 级联 3 粗 局部光 atlas(2048²) 聚光灯 全向光 6 面 分配失败则该灯放弃阴影

实战推论

  • split 数比 shadow map size 更划算。4096 拆 4 级每级只有 2048²;把 split 从 4 降到 3 再调 distribution,往往比从 4096 升 8192 观感损失小得多(成本只有级联渲染 pass 数 × 场景 draw call)。
  • bias 方向错了会两头不讨好:acne 换 normal bias 加,peter-panning 换 shadow bias 减,别拿一个参数同时治两种病。
  • atlas 满导致的"某盏灯阴影时有时无"没有日志,排查方式是拿灯数反推:聚光灯每盏占 1/4,全向光 6 面要按六连拍算,2048 atlas 装不了几盏。

本文基于 Godot 4.x 源码:servers/rendering/renderer_rd/renderer_scene_render_rd.cpp(shadow atlas 持有者)、servers/rendering/renderer_rd/shadows_rd.cpp、servers/rendering/renderer_rd/shaders/scene_forward_lights_inc.glsl(cascade 混合与 bias 使用)、scene_forward_clustered.cpp(CSM 渲染 pass)。