【RTR4笔记】 第七章 阴影

平面阴影、阴影贴图(PCSS,VSM)、其他阴影

Posted Kongouuu's Blog on January 29, 2022

7.1 平面阴影

平面阴影是思路上最直接的一个阴影绘制方式,就是把模型拍成一个平面后当作阴影绘制的手法。

7.1.1 投影阴影

在这个手法里,每个拥有阴影的物体都要被绘制两次,一次为本体,一次为自己的阴影。

由于阴影的产生是光被顶点遮挡,所以每个顶点阴影的位置都可以通过简单的相似三角形计算。这个计算是假设py0来计算的,理解起来比较简单。

\[\frac{p_x-l_x}{v_x-l_x}=\frac{l_y}{l_y-v_y} \iff p_x=\frac{l_yv_x-l_xv_y}{l_y-v_y}\\ p_z同理\]

那么我们可以把这个操作写入一个转换矩阵:

\[M=\left[ \matrix{ l_y & -l_x & 0 & 0\\ 0 & 0 & 0 & 0 \\ 0 & -l_z & l_y & 0 \\ 0 & -1 & 0 & l_y \\ } \right]\]

一般来说我们并不会把阴影投影到一个y=0这样的平面上,所以会使用更加复杂的变换矩阵去进行操作,不过个人认为这部分并没有很重要。

这个操作的思路就是指定一个平面,然后把我们的物体通过变换矩阵投影到我们指定的平面上面当作阴影。这样的操作的好处在于我们可以特别方便的为点光源这样的光源去定制投影在平面上的阴影。不过这个方法的局限性也是很多的。物体只能被投影到单个平面上,并且阴影大小也可能超过投影到到的平面。再加上我们需要为所有的应该有阴影的物体都去进行一个投影的设置,这样是比较繁琐的。

为了让这样的阴影能形成软阴影,我们有很多的手段,例如使用模糊操作。

7.2 曲面阴影(阴影纹理)

上面的手法没有办法在曲面上,或是其他形状的物体投影阴影。这时候被提出的解决办法就是使用纹理技术(不是Shadow Mapping)。曲面阴影的思路是:

  1. 指定一个物体以及投影到的面
  2. 用光源当摄像机渲染物体到一个白色的纹理上,并且物体在这个纹理中颜色为黑色。
  3. 让投影到的面使用这个含有黑色物体的纹理当作阴影。

实际上这里并不一定是要投影在曲面,主要讲的是使用一个贴图当作阴影的思路。整个流程跟Shadow Mapping可以说是思路十分的接近,不过还是有些差别的。从设置来看,这样的操作是很麻烦的,我们需要为每个拥有阴影的物体去生成一个贴图,并且要确保物体确实挡住了放阴影的面。

不过这不代表这个手法是可以被Shadow Mapping替代的。为每个物体提供单独的阴影纹理,可以很有效的在我们需要定制阴影的环节表现出效果,例如说我们想要一个固定的聚光灯的效果打到墙壁,那么我们就可以提前渲染出一个有聚光灯阴影的纹理。

7.3 阴影锥 Shadow Volume

Shadow Volume 是一个比较早期被提出的阴影解决方案。 它的主要思路是我们为一个物体进行一个实体的阴影几何体积的绘制。

这个阴影锥体的意义就是所有在锥体内部的物体,都应该是得不到光的,也就是都会是阴影。要注意的是,是在阴影内部的都会绘制成阴影,而不是体积本身全黑。

这个效果可以通过我们使用模板缓冲来达到,这里讲一下具体思路:

  1. 先根据光源和我们要绘制阴影的物体去定义一个阴影锥体(通过光源视角的物体轮廓)。
  2. 首先我们先绘制所有阴影锥体以外的东西(指的是场景内所有的物体,而不是几何位置是否在阴影锥体内部)。
  3. 然后我们关闭颜色写入,绘制阴影体正面的三角形,这时候是考虑上阶段的深度信息和遮挡的;这时候每次绘制都让模板缓冲对应的像素+1.
  4. 绘制阴影1体背面的三角形,这时候也考虑上上阶段的深度信息和遮挡;每次绘制都让模板缓冲对应的像素-1
  5. 重新绘制场景,在模板缓冲为0的部分正常绘制,不为0的地方为阴影。

实际上模板缓冲的使用思路是很简单的,这么操作的意思就是,如果物体在阴影锥体内部;那么物体会挡住阴影锥体的背面,因此模板缓冲不为0

如果没有任何物体挡住阴影体积,那么该像素的模板缓冲为0,可以照常绘制。

7.4 阴影贴图

阴影贴图是现在最主流的阴影渲染法。前面提到的几种方法都有一些比较不能泛用的缺点,就是我们需要指定哪些遮挡物会生成阴影,并且很难去预测它们的开销。

阴影贴图本身的原理非常的简单:

  1. 深度Pass: 从光源当摄像机跑一个Pass,记录所有物体的深度
  2. 正常Pass: 为每个像素按照光源的MVP转换矩阵计算与光源的距离。如果和前面一部的深度结果不一样,说明被遮挡了,则渲染阴影。

一般来说我们会为平行光建立一个阴影贴图,然后为光源搭建一个可以覆盖我们场景的正交投影的长方体,来进行阴影深度的投影计算。所以基本上大部分时候我们都是用平行光去生成一个阴影贴图,不过当然,点光源也是可以的。我们可以为一个点光源生成6面阴影贴图,每个使用透视投影的方式去把物体的深度记录下来,不过开销算是比较大的,因为整个流程要跑6次。

阴影贴图虽然十分的泛用,可以为整个场景渲染所有的阴影,不过同时也有许多的问题,也就是走样的问题。由于我们是把场景内的所有的物体投影到光源的视角,那么我们也需要为此准备一个比较大的纹理来使用,不过大的纹理可能会导致开销过高。如果我们的纹理分辨率不够,那么阴影的精度就会不够,从而边缘会出现锯齿等现象。

分辨率问题除了锯齿之外,还有自遮挡的问题。理论上来说我们如果没有遮挡物,就不会有阴影,但是如果阴影贴图本身分辨率太低,很可能造成物体成为自己的遮挡物这样的现象。如下图所示,我们摄像机的画面的多个像素在深度图里只占有一个纹理元素。假设阴影贴图里记录的是深度N,那么我们摄像机记录的象素里,会有好几个深度大于N的部分要采样同一个纹理元素;并且会在没有遮挡物的情况下绘制阴影。

为了解决自遮挡问题,比较常用的手法是设置一个Bias,也就是说如果采样的深度跟计算的深度差距不大,那么我们就不画阴影。一般现在的图形API都可以自己去计算面的斜率,来决定Bias的值。

7.4.1 分辨率提高

如上面提到的,阴影贴图的分辨率一直都是一个问题。很直观的解决方式就是提高阴影贴图的分辨率,但很显然这么做是不对的,因为这样会有大量的浪费。一般来说靠近摄像机的阴影需要更多的分辨率,而远的物体则不需要,所以如果我们直接拉高阴影贴图的分辨率,那么渲染远景的阴影会有大量的空间被浪费。因此一个很聪明的解决方案被提出,级联阴影贴图(Cascaded Shadow Maps)

既然视锥体不同距离的物体要的阴影分辨率不一样,那么我们干脆直接把视锥体切割成多份,并且为每一份切割准备一个阴影贴图。如图所示,每一份切割的深度范围可能都是上一份的两到三倍,但我们可以准备四份分辨率一样的阴影贴图为每个切割计算阴影。

果是使用原始的办法,也就是用一张高分辨率的图,来达到图中CSM的近距离物体的高分辨率阴影;那么我们需要用到的内存开销将会是这个办法的十几倍起步。

7.5 PCF 软阴影

在上面的几个方法中生成的阴影都是非常锐利的。就是说在阴影范围内就是纯黑,外面就是正常颜色,中间没有一个过渡;但生活中一半阴影都会在边缘有一个过度,也就是软阴影。因此一个较为基础的手法被提出:Percentage-Closer Filtering

PCF的核心思路就是,一个像素采样阴影贴图的多个元素,把光源当作Area-Light来处理。我们拿像素的深度跟阴影贴图的多个深度对比,并且计算出:

\[ShadowFactor =\frac{没遮挡顶点的采样点}{总采样点}\]

来定义我们的阴影的值。假设一个物体在阴影的中间,那么就会呈现纯黑,如果一个物体在边缘,那么就会软化。

具体来说,我们程序输出的颜色将会是下面这样。如果所有点都挡住了我们的顶点,那么我们的阴影系数将会是0,则渲染黑色:

\[c_{output} = c*\frac{没遮挡顶点的采样点}{总采样点}\\\]

当然,这么简单的处理也会出现很多问题;我们在采样周围的时候我们的自遮挡问题很难用较小的Bias被解决,导致一个物体会让自己变暗。也就是说不该被遮挡的地方很多时候会因为采样问题反而变暗。

7.6 PCSS (Percentage-Closer Soft Shadows)

PCF可以做到一个很基础的软阴影,但效果并没有特别好。实际上阴影的软硬程度应该是要跟遮挡者与被遮挡者之间的距离有关系的。如果我们的物体离阴影平面很近,那么阴影应该是很锐利的,如果物体离得远,那么应该有软阴影。PCF没有办法达到这个较为真实的效果,所以有了PCSS这个算法。

想要软的阴影,我们在阴影贴图上采样的范围就要比较大,想要硬就范围小,因此PCSS使用了下面的计算:

\[w_{sample}: 采样范围(宽度)\\ w_{light}: 面积光源宽度\\ d_r: 像素距离光源的深度\\ d_o: 遮挡物平均离光源的深度\\ w_{sample}=w_{light}\frac{d_r-d_o}{d_r}\]

具体计算流程如下:

  1. 找到遮挡物平均深度(在一定范围内采样阴影贴图)
  2. 根据平均深度计算出采样的范围(采样点和采样点的距离), 实际上是可以不考虑光源的面积的
  3. 进行PCF计算

不过PCSS有一个最大的问题,就是平均遮挡物深度不好找。平均遮挡物的采样数量跟PCF计算的采样是分开来的,这里要单独去选不同的点采样。如果选的少了可能效果不好,多了时间开销又太大。

7.7 Filtered Shadow Maps (参考GAMES202)

7.7.1 VSM

PCF本身是通过一个滤波的行为来对阴影进行模糊化,不过采样多个点的开销还是比较大的,我们尽量希望减少采样的次数。因此Variance Shadow Map (VSM) 被提出来为滤波的这个阶段进行加速,并且允许通过单次采样来达到类似于PCF的效果。

先回到PCF,PCF要计算的数字实际上是我们的点在一定范围内被遮挡了多少,前面有多少物体。那么意思就是我们要计算出,在光的一个小范围行径内,有多少百分比的物体挡在我们前面。

\[\frac{没遮挡顶点的采样点}{总采样点}=\frac{排在顶点后面的点}{总采样点}=多少成物体排在顶点后面\\\]

这时候被提出的一个想法是,用正太分布去模拟多少百分比的物体深度比顶点高/低。然后我们要计算出的值就是, 深度大于顶点的概率

为了计算出 深度大于顶点的概率,我们需要用切比雪夫不等式:

\[P(x>t)=\frac{\sigma ^2}{\sigma ^2 + (t -\mu) ^2}\\ t: 顶点深度\\ \mu:平均深度\\ \sigma:深度方差\\ P(x>t):深度大于顶点深度的概率\]

为了写出这个不等式,我们需要计算平均深度以及深度方差:

  1. 平均深度:平均深度的计算可以依靠生成阴影贴图的Mipmap,或者使用Summed-Area Table来储存我们的阴影数据,就可以非常直接的得到我们的平均深度值。

  2. 深度方差:众所周知Var(x)=E( x2)+E2(x), 我们可以通过上一个步骤很简单的计算出E2(x),不过为了计算出E(x2), 我们则需要提前在生成阴影贴图的时候生成一个储存深度的方的阴影贴图, Depth-squared Shadow Map,并且同时生成一个Mipmap/SAT

7.7.1.1 VSM 流程

  1. 生成阴影贴图的时候同时生成储存 深度2 的图。 可以使用一个渲染目标储存深度和深度方。‘
  2. 为阴影图生成Mipmap, 这样可以直接通过单次采样得到 深度的平均值, 以及 深度方的平均值
  3. 通过采样两种阴影的值[O(1)] 去计算切比雪夫不等式,得到我们的阴影系数
  4. 把阴影系数套到颜色上面得到最后的结果。

7.7.1.2 VSM 总结

VSM 总的来说就是通过在生成阴影使用的深度图的时候,同时生成深度的方的图,并且都生成Mipmap来比较好的查找平均值。通过多生成这些信息,就可以在原本需要计算PCF的计算通过单次采样来达到差不多的效果。

对效率的提升从原本的PCF的多次采样降低到了单次采样。

7.7.2 VSSM (参考GAMES202)

我们上面的VSM实际上只解决了PCF的问题,也就是能过通过它来生成普通的软阴影。但是这样的做法是没有办法还原 遮挡物离被遮挡物越近,阴影越硬 的这个效果,也就是PCSS 达到的效果。

更简单的说,就是虽然我们解决了PCF, 但是如果要使用PCSS的话,Blocker Search 部分的效率并没有得到任何的改善。不过通过上面进行VSM操作的思路,实际上我们也可以同样的把Blocker Search部分加速到O(1), 而这个加速法和VSM一起应该就是Variance Soft Shadow Map (VSSM) 了。

首先,我们看一下下面的不等式:

\[z_{avg} =\frac{N1}{N}z_{unocc}+\frac{N2}{N}z_{occ}\]

我们从阴影贴图的Mipmap得到的深度可以被拆分为两个部分。一个是 没遮挡物体深度平均值*没遮挡物体比例 , 另一个是 遮挡物体深度平均值*遮挡物体比例,而我们想要得到的正是遮挡物深度平均值,因此这个手法进行了下面的假设:

  1. 被遮挡物体深度平均值:我们进行了下面的假设,把所有被遮挡物的深度都设置成跟顶点深度一样。这在物理上很显然是不正确的,不过这样的假设可以为我们后面的计算提供大量的加速,并且某种层面上也算正确。

    \[t:顶点深度\\ z_{unocc}= t\]
  2. 没遮挡物体比例以及遮挡物体比例:遮挡物比例和被遮挡物比例,这个我们很熟悉,就是上面在VSM里计算的值。也就是物体深度大于顶点的概率,以及小于顶点的概率。

    \[P(x>t)=\frac{\sigma ^2}{\sigma ^2 + (t -\mu) ^2}\\ \frac{N1}{N}=P(x>t)\\ \frac{N2}{N}=P(x<t)=1-P(x>t)\]

通过上面的假设,我们可以很直观的得到:

\[z_{avg} =\frac{N1}{N}z_{unocc}+\frac{N2}{N}z_{occ}\\ z_{avg} =P(x>t)t+(1-P(x>t))z_{occ}\\ (1-P(x>t))z_{occ}=z_{avg} -P(x>t)t\\ z_{occ}=\frac{z_{avg} -P(x>t)t}{1-P(x>t)}\\\]

并且需要的数据全部都可以在O(1)的时间得到。

7.7.2.1 VSSM 总结

VSSM实际上是把VSM的思路套用到PCSS,并且同时加速了Blocker SearchPCF 两个阶段。

要特别注意的是因为我们PCSS的思路是根据Blocker Search算出的平均遮挡物深度,来计算我们PCF采样的范围。所以说这里VSSM在第一阶段用切比雪夫加速计算完遮挡物平均深度的时候。大概率会在PCF/VSM阶段采用不同的深度采样范围,进行第二次切比雪夫计算。

7.7.2 其他Filtered Shadow Maps

VSM的计算本身在很多的应用上可能会缺乏真实性,造成光泄露。所以后续有很多的算法依次被推出来增加整个模型的准确性,比如 Exponential Shadow Map(ESM), Exponential Variance Shadow Map(EVSM)

最近期被提出的方案是Moment Shadow Map, 如果说VSSM是使用了z,z2 两个值的算法,那么这个算法就是使用了 z,z2,z3,z4 的一个算法,并且可以得到更真实的效果。

7.8 Volumetric Shadow Map

上面提到的阴影生成技术都没有办法应对细小或是半透明的物体,没有办法达到让光透过一半的那种效果。为了解决例如云、头发等略微半透明的物体的阴影生成,有很多体积阴影技术被提出。这类技术跟前面的Shadow Volume可以说是毫无关系;叫Volumetric的主要原因还是它想要把整个阴影的数据整的更三维化,而不是直接生成一个三维的阴影锥。

这一类技术的核心在于记录光在不同深度的衰减程度。我们常规的阴影贴图的大致思路是看光在什么时候消失,因为这样的数据比较好记录;因为没有光的地方可以直接渲染黑色。但是当我们要考虑光的衰减,需要考虑到在多层深度下的值。因此在这一类的阴影贴图,我们主要是想办法储存多个深度的光照值,也就是三维的带衰减的光强。

简单地说就是,用阴影贴图的一个纹理元素来记录好几个深度的光强。如果遇到不透明物体则光强为0,遇到半透明则跟着Alpha和颜色去考虑。

书中提到的比较新的技术是Adaptive Volumetric Shadow Map, 它的主要思路是先跑一个Pass,为每个像素储存光能看到的所有物体的链表,有点类似于A-Buffer(?)。然后在第二个Pass,把这些数据整理成我们需要的Adaptive Volumetric Shadow Map 的格式 (每个纹理元素可以展开成沿着深度的光强曲线)。有了这个技术就可以在实时渲染中更好的去渲染云、头发这样的物体。

7.9 Irregular Z-Buffer Shadows

这一部分我们回到了阴影走样的话题。由于阴影走样的发生是因为我们光源生成的阴影贴图本身并不是和我们摄像机的画面像素一一对应的。上面也有想办法去尝试让像素和阴影贴图更加契合,例如CSM这种手法。

Irregular Z-Buffer 采取了不一样的手段去尝试让我们所谓的阴影信息和摄像机像素对齐,那就是不直接生成一个阴影纹理,而是直接的使用几何信息。就是说我们并不考虑把光源看到的东西存到一个纹理里面,因为这样会失真,而是直接把遮挡信息传递到摄像机的像素内;这样就不会有纹理采样上的走样问题,不过具体操作还是没那么简单的。

首先被提出的办法是使用Ray Tracing,随着硬件的发展我们的光线追踪越来越普及;并且这里的思路就是使用光线追踪来判断物体是否被遮挡,这样就免去了生成阴影贴图导致的走样问题。

然后书中提到的第二个办法是依旧生成阴影贴图,不过贴图内部储存的会是遮挡物更详细的几何信息,这样在采样的时候可以更完整的复现遮挡的比例和情况。