Chapter 7 – Shadows 阴影

7.4 Shadow Maps(阴影映射)

在1978年,William提出了一种适用于任意物体的给予z-buffer的绘制阴影的方法。其原理是基于光源的位置使用z-buffer来绘制场景。所有被光源“看到的”东西都不在阴影中,而剩余的物体则位于阴影中。当绘制该场景并生成图像时只需要z-buffer。其他对于back buffer或者color buffer的写入操作都可以禁用。

现在z-buffer中的每一个像素都包含了距离光源最近的物体的z-depth信息。我们可以将该z-buffer称为shadow map(阴影映射),有时其也被称为shadow depth map或者shadow buffer。为了使用shadow map,场景需要被绘制两次,而第二次则基于camera所在位置进行绘制。在我们绘制图元时,每一个像素的位置都会与shadow map进行比较。如果我们绘制的像素点,其到光源的距离大于shadow map中对应像素所包含的深度值,那么我们绘制的像素点就位于阴影中,否则就不在阴影中。这一技术通过使用贴图映射来实现。如下图所示。

上图左侧,shadow map存储了以光源为“视角的”场景中物体的depth值。上图右侧,我们在基于camera的位置绘制场景时,看到了位于球体上的点va,其在shadow map中对应的纹理为a。由于shadow map中纹理a的深度值并不小于点va基于光源的深度值,所以该点并不位于阴影中。此外,camera还看到了在矩形上的点vb,很明显基于光源的位置,该点的深度值要大于shadow map中纹理b的深度值,所以其该点位于阴影中。此外,左下角的图像就是给予光源“视角”的shadow map,白色的部分表示这些像素离开光源较远。右下角的图像则是使用shadow map绘制的场景。

由于shadow map算法的开销是可预估的,其是一种常用的生成阴影的算法。构建shadow map的开销几乎与我们需要绘制的图元的数量成线性关系,此外读取shadow map所消耗的时间是固定的。对于那些光源与物体不会移动的场景,我们还能够对shadow map反复利用。

当我们只使用一个z-buffer作为shadow map时,光源投射阴影的原理与camera类似,其有某个固定的朝向。对于那些距离较远的方向光,例如太阳光,光源的视野范围需要包含camera所能看见的所有物体。光源将使用正交投影,同时我们需要将视野范围设置的足够“高”足够“宽”以包含所有的物体。如果光源是聚集光,那么其本身就拥有一个类似camera的视锥体,所有在视锥体之外的物体都不会被照亮。

如果光源被多个投射阴影的物体所包围(例如,点光源),我们会使用一个六面立方体来构成shadow map,其类似于立方体环境映射贴图(cubic environment mapping)。其也被成为全方向阴影映射(omnidirectional shadow maps)。使用此类shadow map,我们需要注意两个独立的shadow map(全方向阴影映射由六个shadow map组成)的接缝处的显示问题。Crytek基于点光源的六个shadow map所覆盖的屏幕空间来设置这六个shadow map的分辨率,并将他们存储在texture atlas中。

需要注意的是,并不是场景中的所有物体都要被绘制在光源的“视野范围”内。首先,只有会投射阴影的物体需要被绘制在视野内。例如,如果我们游戏中的地面只会接受阴影并不会投射阴影,那么地面就不需要绘制在shadow map中。

投射阴影的物体指的是那些位于光源的“视锥体”中的物体。而我们可以使用多种方式来增大或者缩小该“视锥体”,这样我们就能剔除一些投射阴影的物体。假设接收阴影的物体对于camera来说是可见的。且它们都位于光源的“视锥体”之内。那么那些位于光源的视锥体之外的物体都不能在这些物体上投射阴影。如下图所示。

上图左侧部分,光源的视锥体包含了camera(eye)的视锥体。当中的部分,光源视锥体的远平面在被拉近了,之包含对于camera可见的物体,所以剔除了蓝色三角形;光源视锥体的近平面也做了调整以靠近camera视锥体。右侧部分,光源视锥体的两侧也进行了收窄以靠近camera视锥体,并且剔除了绿色胶囊体。

另一个例子,如果光源位于camera的视锥体之内,那么位于光源视锥体外部的物体就不能投影出阴影。减少shadow map中绘制物体的数量不但能够减少绘制所消耗的时间,还能够减少光源视锥体的尺寸,这样就能增加shadow map的有效分辨率并改善shadow map的质量。此外,我们需要保证光源视锥体的近平面与远平面之间的距离足够远,否则会造成z-buffer的depth精度问题。

shadow map的一个缺点是其效果取决与shadow map的分辨率以及z-buffer的精度。由于我们在比较像素的深度值时需要对shadow map进行采样,因此该算法会受到aliasing的影响,尤其是两个物体相交的点。一个常见的问题是self-shadow aliasing,其也被称为“surface acne”或者“shadow acne”,其原理是一个三角形错误的在自己身上投射了阴影。造成该问题的原因有两个。其一是处理器的精度较低。另一个则由于我们在shadow map中采样的像素点的深度值用来表示了某一个范围内所有像素的深度值。也就是说,光源的shadow map的采样点并不能与屏幕像素的采样点相吻合(例如,采样点总是位于像素的中心点)。当光源shadow map中的深度值与camera所看到物体的深度值相比较时,shadow map中的深度值永远略小于物体的深度值,这就造成了self-shadow。其效果如下图所示。

一种常用的避免shadow map显示错误的方法是使用偏移因子(bias factor)。当我们比较屏幕中的某个像素对于光源的深度值与该像素在shadow map中对应像素的深度值时,我们需要将屏幕中像素的深度值减去一个偏移量。如下图所示。物体表面被绘制在shadow map中,下图中的竖线表示shadow map中的像素的中心。遮盖物(投射阴影的物体)的深度被记录在×的位置。我们想要知道,物体表面上三个采样点是否位于阴影中。这三个采样点对应的位于shadow map中的深度值为×(颜色一一对应)所在的位置。在左侧的图片中,如果我们没有使用偏移量,那么蓝色和橙色的采样点会被错误地认为位于阴影中,因为以光源的角度来看,这两个点的深度值大于其对应的像素在shadow map中的深度值。在中间的图片中,我们将每一个采样点的深度值减去了一个偏移量,使得这三个点更靠近光源。蓝色的采样点仍旧被位于阴影中,因为其shadow map中的深度值仍然小于经过偏移后的采样点的深度值。在右侧的图片中,我们在生成shadow map时基于多边形的坡度,对shadow map中所有的像素都进行了平移,使其略微远离光源。现在所有的采样点的深度值都小于shadow map中的深度值,所以它们都不位于阴影中。

由上图可知,如果偏移量是一个常量,那么当接受阴影的物体不免想光源时,仍然会造成self-shadowing。更为有效的方法是使用一个与像素所在多边形的坡度成正比的偏移量。而这一类偏移量被称为slope scale bias。上述两种偏移量,我们都能够通过api来运用,例如,在OpenGL中调用glPolygonOffset即可。需要注意的是,如果物体表面直接面向光源(例如,表面是水平面,而我们的方向光的光线垂直于该平面),那么slop scale bias将会是0。正因为这个原因,我们会同时使用恒定偏移与slop scale bia来避免self-shadowing。同时,slop scale bias通常会进行clamp,以避免其超过某个最大值,因为当物体表面几乎与光线平行时,slop scale bias将会是一个非常大的值。

Holbert提出了normal offset bias(法线偏移),其沿着物体表面的法线向量的方向进行偏移,且偏移量与光线方向与法线向量的夹角的正弦值成正比。该偏移量不但改变了深度值还改变了x轴与y轴坐标。随着光线与物体表面的夹角变小,偏移量会不断增加,以保证在于shadow map中的深度值进行比较时,我们所使用的接收阴影的物体的像素点高于shadow map中的像素点以避免self-shadowing。这个偏移量是基于世界空间的,所以Pettineo建议,shadow map的深度范围对偏移量进行缩放。Pesce则提出了沿着camera的视野方向进行偏移。我们将在7.5小节学习其他的偏移算法。

如果偏移量太大也会造成显示错误的问题,其被称为light leak或者Peter Panning,这将会导致投射阴影的物体与其阴影分离,看上去漂浮在地面上。

另一种避免self-shadowing的方法是只将物体的背面(back)绘制到shadow map中。该方法被成为second-depth shadow mapping,其尤其适合那些无法手动调整偏移因子的渲染系统。但该方法仍旧有一定的局限性,例如物体是两面可见的(frontface和backface都会进行渲染),物体非常薄,或者两个物体互相连接。如果一个物体的模型的正面与背面都是可见的,例如,树叶或者纸片,self-shadowing仍然会发生,因为网格模型的正面与背面位于同一位置。同理,如果不进行偏移,在物体的边缘处或者非常薄的物体都会造成self-shadowing,因为物体的背面与正面的位置非常接近。进行偏移之后能够避免self-shadowing,但是这也更容易造成light leaking(物体看上去与其阴影相脱离)。下图展示了一种解决办法。

上图中,我们假设光源位于模型的正上方。左侧的图片中,模型表面面向光源的部分被标记为红色,并被绘入shadow map中。由于可能会发生self-shadowing,我们需要对shadow map进行偏移。在中间的图片中,只有背面的三角形被绘制到shadow map中。在右侧的图片中,我们在最近的正面与背面三角形之间形成了一个中间层,并将其绘入shadow map。

对于shadow mapping算法,我们必须保证物体是“防水的”(闭合的,或者说,固体),或者说,我们必须将物体的正面三角形与背面三角形都绘制到shadow map中,否则就无法绘制出完整的阴影。Woo提出了一种绘制阴影的方法,其原理是将闭合的物体绘制到shadow map后记录两个基于光源距离最近的平面。这两个平面的平均深度将构成一个中间层,而shadow map将记录中间层的深度,有时我们也将这一类shadow map称为dual shadow map。如果物体的厚度足够,那么我们就能尽可能地减少self-shadowing和light-leak。

随着camera的移动,光源的视野空间通常会不断改变,而投射阴影的物体也会不断改变。这就会造成每一帧阴影都会不断移动。这是因为我们以不同的方向对shadow map进行采样。对于方向光,我们的解决办法是强制每一个连续的shadow map都保持相同的纹理位置。也就是说,你可以认为shadow map组成了整个场景的二维网格平面,每一个单元格代表了场景中的像素采样。随着camera的移动,shadow map只是从众多单元网格中选取若干组。换句话说,以光源为基准的投影运算在同一个单元网格内是相同的。

7.4.1 分辨率增强

shadow map本质上是一张贴图,所以我们也希望其中的每一个纹理能够覆盖一个像素。如果场景中的光源与camera位于同一个位置,shadow map能够与屏幕像素一一对应(也就是说场景中没有可见的阴影,因为光源照亮了camera视锥体内的每一个物体)。只要光源的方向发生改变,每一个纹理的像素覆盖率就会变化,并造成显示错误(锯齿),如下图所示。很明显阴影的效果将会变差,这是因为屏幕中大量的像素与shadow map中的每一个纹理相关联。这也被称为perspective aliasing

如果物体的表面几乎位于光线的边缘且面向camera,单一的shadow map中的纹理也可能覆盖大量的屏幕像素。这一问题被称为projective aliasing;如下图所示。我们可以通过增加shadow map的分辨率来减少阴影的锯齿,但是这会增加shadow map所占的内存空间与处理开销。

上图左侧部分,光源几乎位于物体的正上方。阴影的边缘看上去仍旧有一些锯齿感,这是因为相较于屏幕(back buffer)的分辨率,shadow map的分辨率较低。右侧部分,光源几乎与物体保持水平,所以shadow map的每一个纹理将覆盖多个屏幕像素,所以阴影的边缘看上去锯齿严重。

有一种方法可以使得光源的采样模式类似于camera的采样模式。这需要改变光源投影物体的方式。一般来说,我们认为视野空间是对称的,也就是说视野向量位于视锥体的中心。但是,视野的方向仅仅定义了视野平面,并没有定义哪些像素会被采样。定义视锥体的窗口(应用的窗口,或者说游戏的窗口)能够被平移,修剪或者旋转,之后我们的场景将被映射到完全不同的视野空间中。而四边形上的采样率并不会改变。因此我们可以基于不同的光源视野方向和视野窗口的边界来修改采样率。如下图所示。

上图中光源位于物体的正上方,左侧的部分,阴影贴图的采样率并不与camera的采样率相匹配。在右侧的图片中,我们改变了光源的“视野方向”,使得靠近camera的部分采样率较高。

在我们将光源的视野映射到camera的视野时,其光源的方向可以调整22°。在这个空间内,有多种算法使得光线的采样率与camera的采样率相匹配。其中包括perspective shadow maps(PSM,透视阴影映射)trapezoidal shadow map(TSM,梯形阴影映射)以及light space perspective shadow map(LiSPSM,光线空间透视阴影映射)。这一系列技术参考了透视投影转换。

上述基于矩阵的算法只需要改变光源的矩阵就能实现。而其中的每一个算法都有其优势与劣势,Lloyd详细分析其中的优缺点,Warping and Partitioning For Low Error Shadow Maps。当光线的方向与camera的方向垂直时(例如,光源位于物体的正上方),这些算法能提供最好的效果,因为在这种情况下,透视转换能够对于在靠近camera的区域进行更多的采样。

当光源位于camera的前方且指向camera时,上述基于矩阵的技术无法缓解采样率不匹配的问题。这种情况被称为duel frusta,或者“deer in the headlights(狭路相逢)”。在靠近camera的区域shadow map需要更多的采样,但是线性转换只会使得情况变得更差。这种种问题都使得上述方法逐渐淡出“历史舞台”。

在camera所处的位置增加更多的采样点是一个很好的理念,以此为基准,出现了生成多个shadow map的算法。Carmack大神在2004年的Quakecon中提出了这一技术,而Blow在之后实现了这一系统。其原理非常简单:生成一组固定的shadow map(其中的每一个shadow map可能有着不同的分辨率),而每一个shadow map都覆盖场景中的不同区域。在Blow的代码中,camera周围有四个shadow map。那些靠近camera的物体将会使用高分辨率的shadow map,而距离较远的物体则使用分辨率较低的shadow map。Forsyth也提出了相关的理论,为不同的物体生成不同的分辨率的shadow map。其理论避免了处理那些位于不同shadow map边界的物体(例如,一个立方体其位置正好处于分辨率为480p的shadow map和分辨率为320p的shadow map之间),并规定每一个物体只与一个shadow map相对应。Flagship工作室(暴雪的前生)进一步改进了此系统,camera附近的动态物体对应第一个shadow map,camera附近的静态物体则对应第二个shadow map,此外第三个shadow map对应场景中的所有静态物体。我们每一帧更新第一个shadow map。另两种shadow map则只需生成一次,因为光源和几何体是相对静止的。但是现代的游戏或者渲染系统几乎不会使用上述系统,对于不同物体以及不同的场景生成多组shadow map的理念也在不断的改进。

2006年,Engel,Lloyd以及Zhang分别独立研究了同一个理论,将camera的视锥体空间沿着视野方向划分为多个区域。如下图所示。左侧部分表示,视锥体被划分为四个区域。而右侧的部分中,我们为每一个区域都创建了不同的包围盒,每一个包围盒都定义了方向光的四个shadow map所绘制的区域。

随着深度的增加,视锥体的每一个区域都所包含的深度值都是上一个区域的两倍或者三倍。对于视锥体空间的每一个区域,光源会生成一个包围盒(或者说光源的视锥体)贴合该区域,并以此生成shadow map。通过使用贴图数组或者texture atlas,我们将不同的shadow map打包为一个贴图对象,以此减少读取不同shadow map的开销。这一算法被称为cascaded shadow map(CSM),也被称为parallel-split shadow maps

这一类算法的实现较为简单,而且能够覆盖大型的场景,并提供较好的效果。而之前提到的duel frusta(光源位于camera正前方)的问题也迎刃而解(靠近camera的区域的shadow map分辨率较高)。因此,大多数游戏都在使用这一算法。

虽然我们可以通过透视的方法在一个shadow map中的某个区域增加采样率,但常规做法仍然是使用独立的shadow map来表示视锥体空间中的各个区域。视锥体中靠近camera的部分,其范围较小,但是需要更多的采样点。如何基于深度值对视锥体进行分割则被称为z-partitioning。其中一种方法被称为对数划分(logarithmic partitioning),每一个cascade shadow map在深度值上的比率是相同的:

其中,n和f分别表示场景的近平面和远平面,c表示shadow map的数量,r则是比率。例如,如果场景中距离最近的物体为1米远,最远的距离为1000米,同时我们拥有三个cascade shadow map,那么r=(1000/1)1/3=10。也就是对于距离camera最近的空间,其近平面与远平面的距离分别为1和10;下一个区域则是10和100;最后一个区域则是100和1000;很明显每个区域的近平面与远平面的距离比例为10。使用这种方法对深度值进行分割则意味着camera的近平面对分割比率有着很大的影响。如果近平面为0.1,且其他参数不变,那么我们的r将变为21.54,也就是说视锥体将被分割为,0.1到2.154,2.154到46.42,46.42到1000。这也意味着每一个cascade shadow map将覆盖较大的范围,因此每个shadow map的精度将会降低。实践中,这种方法将是的靠近camera的区域的shadow map分辨率非常高,但是如果该区域内没有物体,那我们无疑浪费了这部分shadow map。

而主要需要解决的问题就是设置camera的近平面。如果其距离玩家的“眼睛”太原,那么许多物体将被近平面剪裁,这无疑会造成更严重的问题。如果游戏正在演示一个cut scene(游戏中的各种剧情脚本演出),那么美术可以预先设置一个精确的值,但是对于一个可交互的场景,这么做无疑是不行的。Lauritzen提出了sample distribution shadow maps(SDSM,采样分布阴影映射),其使用上一帧的深度值来求出一个更好的分割比率,而该方法有两个选择。

第一种找到最小与最大的z深度值,以此来决定近平面与远平面。我们使用GPU的reduce操作进行实现,computer shader或者其他类型的着色器将会分析一组非常小的buffer,而输出的buffer不断被作为输入buffer进行处理,知道只留下1×1个buffer。

第二种方法是分析depth buffer中的深度值,并制作一个被称为histogram的图表,其记录了一段范围内z-depth的分布。在该图标中我们能找到合适的近平面与远平面,此外如果某些深度值范围内没有物体,那么图标在对应范围内会产生空缺。因此,在此范围内的分割空间都能被掠过,这样cascade shadow map的精确度将会更高。

在实践中,第一种方法更常用,且速度也更快(1ms每帧)。

当我们只使用单一shadow map时,物体或者光源的不断移动可能会造成阴影闪烁,在我们使用cascade shadow map时,这个现象可能会更严重,特别是当物体位于两个shadow map之间。如果物体横跨两个shadow map,那么其阴影质量将会有明显的不同。一个解决办法是使光源的视野空间略微重叠。那么在重叠区域的采样点,其结果将会是两个shadow map进行融合后的值。或者,在该区域内的采样点使用dither进行采样。

由于大多数游戏都使用了cascade shadow map技术,其不断地被优化,提高其效率与效果。如果某个shadow map所对应的视野范围内,没有物体发生移动或者改变,那么该shadow map并不需要重新生成。对于每一个光源,通过计算哪些物体对于该光源是“可见的”,我们可以找到该光源的阴影投射物。由于我们凭借肉眼很难判断一个阴影是否是正确的,所以可以使用某些便捷的方法运用于cascade shadow map。其中一个是使用一个低精度的模型来投射阴影。另一个方法则是去除那些微小的遮挡物。那些距离camera较远的shadow map并不需要每一帧更新。但是如果场景中的物体较大,且会移动,那么我们需要谨慎使用该技术。Day提出了“滚动”距离较远的shadow map的理念。2016年推出的DOOM,其使用的shadow map atlas非常大,包含多个shadow map,只有shadow map所对应的范围内的物体发生了移动,游戏才会重新生成shadow map。距离较远的cascade shadow map可能完全忽略动态的物体,因为这一类的shadow map对于场景的影响非常小。在某些情况下,使用高分辨率的静态shadow map可以用来作为距离较远的cascade shadow map,其能够大大降低开销。cascade shadow map可以与light map贴图相结合,我们将在第十一章中学习预计算的光照与阴影算法。

创建一些独立的shadow map意味着每一组几何体都有各自对应的shadow map。针对这一技术,有多种放他来改善效率,其主要原理是在一个渲染通道中将投射阴影的物体绘制到一组shadow map中。我们可以使用几何着色器复制物体的数据并将其传输至多个shadow map。instanced几何着色器允许一个物体被输出至32个深度贴图中。

在现实世界中,我们本身就处于许许多多的光源之中。但是在实时渲染的游戏中,如果场景较大且拥有多个光源,且所有的光源始终是激活状态,那么会产生大量的计算并耗费大量的时间。如果某一空间位于视锥体内,但是对于camera是不可见的,那么该空间内的投射阴影的物体并不需要被绘制到shadow map中。Bittner使用了camera的遮挡剔除(我们将在第十九章中学习)来找到所有的可见的阴影接收物体,并将所有这些物体绘制到stencil buffer中。该stencil buffer将用来决定阴影接收物对于场景中的光源是不是可见的。为了生成shadow map,其使用了光源角度的遮挡剔除以及之前的stencil buffer来进一步进行筛选。此外,还有许多的剔除技术能够适用于光源。由于光源的强度随着距离的平方而衰减,通常在一定距离下,我们就认为光源对物体没有影响。我们将在第十九章学习的portal culling,其也适用于光源的剔除。

留下评论

此站点使用Akismet来减少垃圾评论。了解我们如何处理您的评论数据