【Reference】:https://zhuanlan.zhihu.com/p/104687855
https://zhuanlan.zhihu.com/p/369710758
https://zhuanlan.zhihu.com/p/483674565
一:Shadow Map 是什么#
之前已经写过了,所以这里不再赘述
详情请见:https://jiat-t.github.io/posts/shadow-techniques/
二:锯齿与软阴影#
1.PCF(Percentage-Closer Filtering,百分比渐近滤波)#
Shadow Map 在进行深度比较时,所得结果非零即一,这无疑会导致非常严重的锯齿化问题
PCF 采用的抗锯齿方法是:对于目标像素,我们并不仅仅只对这一个像素的深度进行比较,而是在它周围 3x3 或是 5x5 等范围内的每一个像素都进行深度比较;最后将所有比较的值相加求平均,可以得到一个【0,1】范围内的阴影系数(0 表示完全遮挡,1 表示无遮挡)
同时,对周围点的采样方式也可以是多样的,比较常见的就是使用泊松分布来进行选择周围像素

2.PCSS(Percentage-Closer Soft Shadows, 百分比渐近软阴影)#
首先明确一个物理现象:
遮挡物离接收平面越近,阴影越硬
遮挡物离接收平面越远,阴影越软

而 PCF 对阴影的软化是相对于整体而言的,无法做到随距离而变化,PCSS 则能够通过距离控制半影宽度,从而实现软阴影的效果
具体公式(相似三角形):

计算出 W_Penumbra 之后,只需将其乘上一个缩放因子,转化为 Shadow Map 中的采样半径,即可对 PCF 的采样范围进行控制(距离越近,PCF 采样范围越小,阴影就越硬;反之,PCF 采样范围越大,阴影更软)
3.VSM(Variance Shadow Maps, 方差阴影贴图)#
首先说说“切比雪夫不等式”:
$ P(x > t) ≤ \frac{\sigma^2}{\sigma^2 + (t - \mu)^2} $
其中$ \mu $是平均值,$ \sigma^2 $是方差;这个不等式的含义是: 在一个均值为$ \mu $,方差为$ \sigma^2 $的分布中,任取一个随机变量 x,其值大于 t 的概率小于某一个上界

回到 PCF 中,如果直接使用 PCF 去做软阴影,需要使用一个 for 循环对 shadow map 进行多次采样与深度比较,这会极大地拖慢运行速度;那么,有没有一种方法能够避免多次的查询,在渲染物体时只进行一次采样呢?
最理想的情况是:直接模糊掉 shadow map,然后每次都只采样一次,但这很显然是错的,因为最终的深度比较结果仍然非零即一。既然如此,不妨转变一下思路:既然最后要求得的都是一个 0~1 范围内的阴影系数,那我们不妨将这个系数等效为一种“概率”—— 即当前片元没有被遮挡(光线能够穿透)的概率
如果将 shadow map 上的深度视作随机变量,那么计算阴影系数,就变成了求“遮挡物深度大于当前片元深度”的概率 P(x > t)。此时,切比雪夫不等式就派上用场了
因为基础的 shadow map 只存有 z 值,而切比雪夫不等式需要使用的是方差与均值,因此需要对其进行额外的存储,具体操作步骤如下:
增加存储内容:除了存储 Z 值本身外,还需要额外对 $ Z^2 $ 进行存储(可以使用两张 shadow map,或者是将其存储在一张 shadow map 的不同通道)
模糊操作:这是 VSM 最大的优势。我们可以像处理普通图片一样,直接对这张双通道 Shadow Map 进行高斯模糊或硬件 Mipmap 过滤。模糊后,我们单次采样得到的值,就直接是该区域内深度的期望 E(x) 和深度平方的期望 E(x^2)
计算方差与概率:根据概率论公式,方差可以通过 $ \sigma^2 = E(x^2) - (E(x))^2 $ 算出。拿到方差$ \sigma^2 $和均值 $ \mu $后,直接代入切比雪夫不等式,就能估算出一个上限概率
优点:性能极高,只需要进行一次采样操作即可得到效果不错的软阴影
缺点:漏光现象——当两个遮挡物的深度差异非常大时,方差会变得特别大。根据上面的公式,方差一旦变大,计算出来的光照概率 P 就不会是 0,而是一个比较大的值。这会导致原本应该处于死角、完全是黑影的地方,诡异地透出光来
4.VSSM(Variance Soft Shadow Maps,方差软阴影)#
如果说 VSM 是 PCF 的升级版,那么 VSSM 则是 PCSS 的升级版
在 PCSS 中,开销最大的一步就是“求得遮挡物的平均深度 $ Z_{occ} $”,因为需要通过 for 循环去暴力求解,而这一步也正是 VSSM 对其进行改进的点,即:只使用数学公式,一步直接算出平均深度
公式如下:$ P_{unocc} \cdot Z_{unocc} + (1 - P_{unocc}) \cdot Z_{occ} = Z_{Avg} $
其中:
未被遮挡像素的比例 $ \frac{N_1}{N} $,其实就是当前片元处于光照中的概率,我们记为 $ P_{unocc} $
被遮挡像素的比例 $ \frac{N_2}{N} $,就是处于阴影中的概率,记为 $ 1 - P_{unocc} $
移项可得:$ Z_{occ} = \frac{Z_{Avg} - P_{unocc} \cdot Z_{unocc}}{1 - P_{unocc}} $
观察右边的式子,其中:
$ Z_{Avg} $(整个区域的平均深度): 可以直接通过对生成好的 Shadow Map 进行 Mipmap 查询,或者使用 SAT(Summed Area Table)瞬间得到
$ P_{unocc} $(未被遮挡的概率):就是 VSM 里用切比雪夫不等式算出来的那个光照概率 $ P $ ,通过方差 $ \sigma^2 $ 可以直接得出
$ Z_{unocc} $(未遮挡部分的平均深度): 既然它没被遮挡,说明光线直接打到了接收面上。因此,可以采用了一个非常巧妙的近似假设——直接把当前正在着色的片元深度 $ t $作为 $ Z_{unocc} $ 的值
最终得到遮挡物的平均深度$ Z_{occ} \approx \frac{Z_{Avg} - P \cdot t}{1 - P} $
5.MSM(Moment Shadow Mapping,矩阴影贴图)#
MSM 解决的是 VSM 的漏光现象,可以理解为“VSM 是 MSM 的青春版”

什么是”矩“?
在统计学和数学中,“矩”是用来描述概率分布形状的数值:
一阶矩:均值 E(z),也就是 VSM 里的$ \mu $
二阶矩:平方的均值 E(z^2),VSM 利用它算出了方差$ \sigma^2 $
VSM 的本质,其实就是“二阶矩阴影贴图”。 它利用两个矩,通过切比雪夫不等式去猜(估算)深度的分布。但当遮挡物深度跨度极大时,仅靠两个矩提供的信息太少了,估算出的概率曲线会过于平缓,导致本该是死黑的阴影被错误地算出了大于 0 的光照概率——这就是漏光的数学根源
MSM 的标准做法是存储四个矩(4-Moments)。 在生成 Shadow Map 时,它不仅存 z 和 z^2,还要存 z^3 和 z^4。 之后依然可以像 VSM 那样,对这张包含了 4 个通道的贴图进行暴力的高斯模糊或硬件滤波
可以看到,四阶矩已经可以很好的近似 PCF 了:

至于之后 CDF 的复原,涉及到非常复杂的数学推导,这里不作展开
三:精度与分辨率#
1.PSM (Perspective Shadow Maps, 透视阴影贴图)#
对于一张 1024 x 1024 的 shadow map,我们常常会遇到这样的情况:明明远处的阴影看上去还行,但是近处的阴影却是一团马赛克。造成这种现象的原因是“投影面积不匹配”,相较于远处的阴影,近处的阴影明明在屏幕上占据更大的区域(换句话说是,占据的像素更多),但 shadow map 还是使用与远处阴影一样的纹素数量来表示它(比如 2 x 2 个纹素)
解决的方法也很简单,既然分配给近处的纹素太少,那为什么不增加近处的采样数量呢?
在标准的 shadow map 流程中,我们是在世界空间下,从光源的视角去拍摄场景并生成深度图;此时由于正交投影,拍摄出来的物体是不存在“近大远小”的关系的,因此只需要将近处的物体放大,将远处的物体缩小,就可以达到区分阴影精度的效果;所以,唯一需要做的就是将“正交投影”更改为“透视投影”
操作步骤:
空间变换:PSM 放弃在世界空间中直接生成 shadow map,而是使用观察矩阵与透视矩阵将坐标变换到 NDC 空间,在这个空间中,物体完美符合人眼中的景象——近大远小
光源采样:计算出光源在这个 NDC 空间中的位置与方向,并生成这个空间下的 shadow map
结果:近处的物体在 shadow map 上占据了更多纹素,远处的物体占据更少的纹素


这里用一段 Gemini 的例子来形象地理解一下:
假设你正在玩一个第一人称射击游戏,阳光从你头顶正上方垂直照下来。 在你脚边有一只小猫(离相机极近),在远处的山头有一只大象(离相机极远)。
- 在世界空间下(标准正交阴影贴图): 阳光垂直往下拍。因为是正交投影,小猫在 Shadow Map 上就占猫那么大的像素,大象就占大象那么大的像素。但是,因为小猫离玩家太近了,走上前去一看,猫身上的影子全是马赛克。
- 在 PSM 的流程下: 第一步,我们不让阳光拍。我们先让相机的透视矩阵把这个世界扭曲一下。 在相机的透视镜头里,由于“近大远小”的视觉原理,脚边的小猫因为离相机太近了,在屏幕上变得巨大无比;而远处的山头和大象因为离相机太远了,在屏幕上缩得只有指甲盖那么大。
第二步,重点来了!阳光在这个时候砸下来。 此时,阳光看到的不再是原本的世界,而是一个被相机镜头扭曲后的世界。在这个扭曲的世界里,小猫变成了“巨猫”,大象变成了“微雕大象”。 当阳光(依然用正交投影)去拍摄这个扭曲的世界生成 Shadow Map 时,这张贴图上大半张纸都被这只“巨猫”给占满了,而远处的“微雕大象”只分到了几个像素。
现在 PSM 由于巨大包围盒的计算难题、阴影质量随相机旋转剧烈波动等问题,以及显存与算力的提升,已经完全被淘汰了
2.CSM (Cascaded Shadow Maps, 级联阴影贴图)#
CSM 的核心思想其实于 PSM 差不多:增加近处阴影的分辨率,降低远处阴影的分辨率,而 CSM 也是许多引擎(如:UE、Unity…)在处理太阳光之类覆盖大场景的方向光时的首选
操作步骤:
- 首先沿着玩家视角,根据深度值将视锥体划分为不同层级(比如 3 层、4 层),这里以 3 层为例:
- Cascade 0 (近景): 离相机极近,虽然深度范围很窄(比如 0~10米),但我们给它分配一张完整的 1024x1024 贴图。这里的像素密度极高,能看清睫毛的阴影
- Cascade 1 (中景): 比如 10~50 米,也分配一张 1024x1024 的贴图
- Cascade 2 (远景): 比如 50~200 米,同样是 1024x1024。由于覆盖的物理面积太大了,所以这层阴影会很糊,但是又因为离相机很远,因此玩家根本注意不到
- 不过这种直接写死层级范围的方法可能会导致 shadow map 内存的浪费(e.g. 近处的物体太少),所以实际使用时一般都会结合指数或其他手段进行划分
- 计算包围盒与正交矩阵:
- 对于切出来的每一块子视锥体:
- 算出这个子视锥体在世界空间下的 8 个顶点坐标
- 将这 8 个点转换到光源的观察空间
- 计算这 8 个点的 AABB,从而推导出当前这一层的正交投影矩阵
- 对于切出来的每一块子视锥体:

- 渲染 Shadow Map:
- 光源依次套用上面算出的 3 个正交矩阵,把场景渲染 3 遍(可以配合视锥体剔除,没在当前 Cascade 里的物体就不画了)
- 像素着色器采样:
- 进入 FS,得到每个像素点的深度之后,先用几个 if/else 判断其位于哪一层级的视锥体/ Cascade,再通过索引(如果使用数组存储 Shadow Map 的话)和光源矩阵,将 Shadow Map 中的深度值提取出来进行比较
可能遇到的问题:
层级混合:由于相接的两张 Shadow Map 精度不同,清晰程度自然也就不一样,这种差异在两个层级的接缝处会显得尤其强烈——这一边是锐利的阴影,而另一边却直接糊成了一团马赛克
解决方法是使用 Cascade Blending(级联混合) —— 在两个层级之间定义一个缓冲带,Shader 会在缓冲带区域进行两次采样,并根据距离对两张 Shadow Map 进行混合。这种做法虽然可以实现有效的过渡,但代价也很明显:位于缓冲带的点,其 Shadow Map 的采样开销相对于缓冲区外的点的会翻倍
阴影抖动:当视角进行转动时,子视锥体的包围盒也会发生微小的变化,这意味着光源的正交矩阵在视角移动过程中的每一帧都在发生变化,反映在图像上就是:阴影的位置总是在变化,这种现象在硬阴影中尤为明显
解决方法是使用 Stabilize Cascades(稳定级联) —— 简单来说就是将原本的 AABB 替换为一个 “恒定包围球”,以这个包围球为内切球的立方体就是新的 AABB;至于包围球怎么寻找:先找出视锥体切片的中心点,并以中心点到最远顶点的距离(对角线)作为半径,画一个球,这个球就是包围球