在这个专题的第一篇里说过,光栅化的核心是“每个三角形各画各的”,所以阴影这种跨物体的关系它自己算不出来。但游戏里的影子是真的。这一篇看它怎么用一张深度图绕过去、留下了什么代价,以及为什么光追的影子能一半硬、一半软。
阴影只回答一件事:这个点看得见灯吗
一个表面点该亮还是该暗,只取决于从它出发朝灯的方向看,中间有没有东西挡着。挡着就是阴影。这是一道可见性问题,和表面自己的朝向、材质都无关。
难的地方在于:光栅化在画地面那个三角形的时候,手上只有地面自己的数据,它不知道远处还站着一个角色。
光栅化的绕路:从灯的位置拍一张深度图
阴影贴图(shadow map)的想法很聪明:光栅化擅长从一个视点渲染深度,那就把灯当成视点,把整个场景渲染一遍,只保留深度,得到一张“灯往每个方向看到的最前面那个表面有多远”的图。然后再按正常流程渲染画面,对每个像素多问一句:我在灯看来有多远?如果比那张图里记的更远,就说明有东西挡在前面,我在阴影里 12。
第二遍渲染没有引入新能力,复用的全是光栅化本来就会的东西,这正是它沿用至今的原因。
这张图天生对不准:acne 和 peter-panning
问题出在一对多上。那张图里的一个 texel 只存了一个深度,是灯看来这个 texel 中心方向上最前面那个点的深度;而第二遍渲染要提问的,是连续表面上任意位置的像素。屏幕上挨着的两个像素,在灯看来常常落在同一个 texel 里,各自的真实深度却并不一样。
于是斜对着光的表面上,比较结果变得随机:本该被照亮的一部分像素算出“比记录值更远”,自己给自己投出一片细密的黑色条纹和摩尔纹,这叫 shadow acne 3。修法是比较时放宽一点,允许“近一点点也算没被挡”,这个宽容量叫 bias。bias 太小,acne 还在;bias 一大,问题从另一头冒出来——本该从物体脚底开始的影子往后退了一段,物体像浮在空中,这叫 peter-panning 13。
这不是两个 bug,是同一个滑杆的两端。安全区间还会被两头挤压:灯越接近平着照,一个 texel 覆盖的表面在深度上跨度越大;贴图分辨率越低,texel 越大。两种情况都要求更大的 bias 2。所以游戏画质选项里的 Shadow Bias 是替你选一个折中点,不是把误差消掉。常见的补救是让 bias 随表面相对光的斜率变大(slope-scaled bias),或把采样点沿法线挪出去一点(normal offset)12;分辨率不够,就把视野切成近中远几段各用一张贴图 1。这些都在补同一个前提,而不是绕开它。
它只认识一个点,所以只会画硬边
真正决定上限的是另一件事:整张图是从一个点拍出来的,每个 texel 的回答只有两种,挡住了或没挡住,所以影子边缘是一条硬线。
现实里几乎不存在这种影子,因为灯不是点:太阳是一块圆盘,灯泡是一小片玻璃。有面积的灯意味着,离遮挡物有一定距离的接收点会看到灯被挡住一部分、露出一部分,露出的那部分叫半影(penumbra),灯被完全挡住的一块叫本影(umbra)。
光栅化这里没有“挡住一半”这个状态,工业界的补法是 PCF:一个像素不比较一次,而是比较周围若干次,把结果平均成 0 到 1 之间的数,比如 0.4 4。
但要记牢:PCF 只是把边缘磨毛,得到的不是真正的软阴影。它的柔化宽度是你设的滤波核大小,是个固定值,与灯多大、遮挡物离接收面多远都无关。于是箱子放在地上和悬在半空,影子边缘糊的程度差不多。现实中这两者差别很大——遮挡物越贴近接收面,半影越窄,贴到一起时边缘完全锐利 8。这种随距离变锐的现象叫接触硬化(contact hardening),PCF 给不出来。
光栅化也能逼近它,做法叫 PCSS:先在一个核内采样,估计遮挡物离灯的平均距离,用这个距离推算出半影该多宽,再用对应宽度的核做 PCF 5。代价落在两步采样上,遮挡物搜索和阴影滤波各要若干次采样,比普通 PCF 贵出数倍;而且这套推算假设场景里只有一层平板状遮挡物、并且它和接收面都平行于光源,遇到前后错落的遮挡物就会算错 6。
光追:不用比对深度,直接问“这条路通不通”
光追把整件事还原成原话:从这个表面点朝灯射一条光线,看它到达灯之前有没有撞上东西 2。没有深度图,就没有“一个 texel 对一个像素”的错配,acne 和 peter-panning 这整条链路也就不存在,不需要挑 bias,也不需要级联来分配分辨率;栏杆、头发、树叶也不会因为分辨率不够而丢影子。剩下的只有一个小问题:光线会立刻撞回它出发的那个面,所以起点要沿法线挪一丁点 2。这和 acne 同源,但量级小得多。
如果灯是一个点,这一条光线就给出精确答案,得到干净的硬阴影。这也是混合渲染里阴影最早被交给光追的原因之一:每个像素一条光线,相邻像素的光线都指向灯附近同一小片区域,走势一致,硬件求交和缓存都吃得消 7。
软阴影不是参数,是几何长出来的
灯有面积时,做法照样朴素:在表面点看得见的那片灯上取若干个点,各射一条光线,统计有多少条能通到灯。通一半,这个点就收到一半的光;一条都不通就是本影;全通就是全亮。
于是半影的宽度不是谁设的,而是几何自己算出来的。用相似三角形就能看出:设遮挡物到灯的距离为 ,接收点到灯的距离为 ,灯的宽度为 ,接收面上半影的宽度约为
接收点越靠近遮挡物,分子越小,半影越窄;贴合时半影缩到零,边缘锐利——接触硬化不是特效,就是这个比值的结果 58。灯越大、遮挡物离灯越近,同样的距离差给出的半影越宽。前后错落的遮挡物也各按自己的距离贡献一部分遮挡,不需要假设只有一层。
半影越宽,代价越高
代价转移到采样数上。只取一个点代表整片灯,答案是 0 或 1,逐帧还会跳——那是噪声,不是软阴影。要把“通了一半”估准,就得取足够多的点,随机采样的误差大约按 下降,正是上一篇讲噪点时的曲线。实测中,软阴影的计算时间大约是同场景硬阴影的 2 到 3 倍,而且高度取决于光源面积:半影越大,要的采样越多 7。
有个结构可以省力:完全照亮和完全遮挡的地方,可见性是确定的,方差为零,多采样纯属浪费,噪声几乎只集中在半影区。所以一个自然的做法是看每个像素的可见性随时间波动多大,只波动大的地方加光线,其他地方一条就够 7。这也解释了为什么实时软阴影离不开降噪——采样数被预算卡住,剩下的缺口只能靠时空滤波补。
从画面反推
- 影子边缘糊,但物体举高、贴地都不改变糊的程度:PCF 磨出来的假软边。
- 边缘有摩尔纹,或影子从脚底断开、物体像浮空:bias 没调好,后者是调过头了。
- 近处还行、远处块状并闪烁:分辨率的分配问题。
- 影子里有细颗粒、动起来像沸腾:软阴影采样不够,降噪压力大。
- 细窄物体(栏杆、树叶、电线)没有影子:阴影贴图的分辨率丢掉了它,光追下不该出现。