游戏中的阴影(一):基础

微信扫一扫,分享到朋友圈

游戏中的阴影(一):基础

前言

这是一篇躺在我草稿箱里超过两年的文章。光和影作为渲染领域最核心的两个需求,伴随着实时图形学的发展数十年,学界和工业界的相关研究也层出不穷,要综述这个话题实在是个大活儿,每每提笔要写,最后都有点发怵。鉴于个人水平和记忆有限,要把这个话题详尽罗列显然不太现实,所以我还是从 Shadow MapRay-Traced Shadow 出发,围绕这两种时下最流行的方案对相关算法做一个梳理。

从最简单的阴影开始

阴影是表示空间位置关系的重要线索,抛开其视觉质量本身不谈,有和没有阴影能够提供的视觉信息量是完全不同的。哪怕只是在角色脚下渲染一个半透明圆盘,也能够很大程度上改善游戏的3D体验,很多早期的游戏里确实就是这么干的。

因为白嫖我连腿都没了

相比小圆盘更复杂一点的阴影技术叫做 平面阴影(Planar Shadow) ,它基于一个简单的观察:物体在平面上的投影还是能够清晰地保持自身的轮廓,就好像模型被压扁在平面上了。

为了投射模型上每个三角形的阴影,我们只需要在顶点着色器中把三角形的每个顶点从原来的位置变换到指定平面上即可。假设模型任一顶点的位置是 ,光源位置是 ,投影的位置是 ,那么 三个点必定共线,根据直线方程,可以得到:

同时,因为投影点在给定平面上,那么它必定也满足平面方程:

上面两个式子结合起来,求出 ,进而就能求出 的位置。

实际计算的时候,一般我们不会用上面的式子去求解,而是把整个投影计算合成一个平面投影矩阵:

这个矩阵在局部坐标到世界坐标的变换之后执行。

具体实现的时候,会有两个问题: 第一个是z-fighting 。因为我们是投影在指定平面上的,最后得出的投影点势必会因为精度问题和投影平面本身的三角面有穿插现象,解决这个问题有两个思路:

(1)先绘制平面,再关闭深度测试和写入进行平面阴影的绘制,最后再打开深度的测试和写入进行模型的正常绘制。

(2)在平面投影矩阵中,稍稍减小(或者增大,取决于法线的方向)一点d的值,让实际的投影平面位于模型平面上方。

第二个问题是半透明叠加 。早期游戏在绘制阴影的时候,为了模拟全局照明的贡献,通常不会把阴影绘制为纯黑,而是带一定半透明的黑色。于是当三角面被压扁在投影平面上时,会产生重叠,这时如果开启半透明混合,不论使用什么样的叠加模式最后都会出现错误的半透明效果,因为实际上我们只希望平面上最多覆盖一个像素的半透明阴影。解决方法是首先绘制下方的模型平面,并向Stencil Buffer写入一个指定值(比如1),然后开启模板测试,比较函数设为EQUAL,测试通过后模板值+1,这样如果某个点已经绘制过一次半透明阴影,则相同位置就不会再绘制第二次。

左图没有用stencil buffer,导致最终的阴影深浅不一;右图使用了stencil buffer,阴影颜色均匀且不会超出平面。

平面阴影技术至今也还活跃在很多手游中,对于机能有限的平台仍然是很好的选择。它不存在阴影走样的问题,绘制性能也很高,同时也有一些缺陷,最严重的当然是影子只能投射在平面上,对于曲面上的投影就无能为力了;其次,如果光源恰好位于被投影物体和平面之间,理论上是不产生投影的,但是实际上它会在平面上投射出错误的阴影来;此外,平面投影算法无法模拟软阴影。

Shadow Volume

阴影体(Shadow Volume)也是个比较古老的技术,虽然现在已经鲜有游戏使用,但是算法本身还是相当精巧。Shadow Volume名气最大的应用当属传奇大佬John Camack开发的《DOOM3》,算法思路见下图:

Shadow Volume用在DOOM3

这个算法解释起来稍微有点复杂。首先,我们从光源位置出发,针对阴影遮挡体的每个三角面生成一系列半开的棱台,这些棱台被我们称之为阴影体(Shadow Volume),所有位于阴影体内部的点,都会被遮挡而看不到光源,也就是需要绘制阴影的位置。那么如何判断一个点是否位于阴影体内部呢?这正是算法的关键所在: 假设我们从摄像机向屏幕上任一点发射一条线段,当线段和阴影体正面相交时,则表示它进入了一个阴影体,当线段和阴影体背面相交时,则表示它离开了一个阴影体,于是,我们每进入一个阴影体,就让交点数+1,反之离开一个阴影体,就让交点数-1,如果最终所有交点数(Shadow Volume Count)为0,则表示当前点位于阴影体之外,若交点数大于0,则表示当前点位于阴影体内 。于是判断一个点是否位于阴影体内的问题就变成了判断Shadow Volume Count是否大于0。

具体实现:

(1)打开ZWrite/ZTest,关闭Stencil Write/Test,绘制一遍场景(只绘制非投影光源的贡献)

(2)关闭ZWrite,ZTest保持Less Equal,打开Stencil Write,将其设置为+1,Stencil Test设为总是通过,绘制投影光源的阴影体正面

(3)将Stencil Write设置为-1,绘制投影光源的阴影体背面

(4)将ZTest设置为Equal,关闭Stencil Write,Stencil Test设置为Equal,再次绘制场景中Stencil Value为0的区域(叠加模式为ADD,绘制非阴影区域,且本次只绘制投影光源的贡献)

上述算法基于一个假设: 摄像机本身是位于阴影体外的 ,但实际情况往往并非如此。于是John Carmark在原始算法的基础上进行了改进,提出了 ZFail算法 。不同于ZPass的算法,这次线段发射的起点位于距离摄像机无限远处(我们可以肯定这个点一定是位于阴影体外的),然后我们每次遇到阴影体背面,就让Stencil Value +1,遇到阴影体正面,就让Stencil Value-1,其他设定均不变。

针对ZPass改进后的ZFail示意图

Shadow Volume的算法能够生成精确的硬阴影,但也有一些明显的缺点,比如难以实现软阴影效果,此外场景的多遍绘制加上阴影体的生成和绘制,使得算法对场景几何复杂度非常敏感。

Shadow Map

Shadow Map是目前主流的阴影生成算法,这主要得益于它算法直观,并且能够充分利用现代硬件的光栅化能力。

标准的Shadow Map算法思路很简单,也是很多人入门图形学的基础算法之一:对 于指定光源来说,场景中某个点是否被其照亮,取决于从光源的视角看去,这个点是否可见 。假设该点可见,则表示没有遮挡,反之则表示该点处于阴影中。于是,算法被分成了四步:

(1)从光源的视角出发,绘制整个场景(平行光用正交投影,点光用透视投影),生成深度图(Shadow Map)

(2)从摄像机视角出发,重新绘制场景,并根据光源投影矩阵的逆矩阵,将世界坐标空间变换回光源的投影空间,找出对应投影空间UV坐标以及投影空间内的深度

(3)利用光源投影空间的UV坐标采样Shadow Map,得到深度

(4)比较 和 ,若 ,则当前位置被遮挡,处于阴影内,否则未被遮挡

从光源视角生成Shadow Map
将光源投影空间的深度映射到屏幕空间
深度比较,确定阴影区域

由于光栅化导致的精度误差,通常直接比较深度会产生些许误差,产生所谓的“Shadow Acne”,通常的解决方案是在比较前给 加上一个固定偏移,但是若偏移选取过大,又会产生所谓的“Peter-Panning”的问题,一个自适应偏移的方案,是基于斜率去计算当前深度要加的偏移(Slope Scale Depth Bias)。

Shadow Acne
Peter-Panning
基于斜率计算深度偏移

由于相机和光源视角不同,从光源视角光栅化后的每个像素投影到屏幕空间后,对应的区域大小也不相同,所以往往会出现距离相机较近位置的Shadow Map精度不够,而距离相机较远位置的Shadow Map精度又过高的问题,于是就会出现阴影边缘的明显锯齿。

缓解这个问题的方法是把视锥沿着Z轴切分成多段,每段单独计算出一个光源坐标空间内的紧凑AABB,然后基于这个AABB生成多张Shadow Map,也就是所谓的级联式阴影(Cascaded Shadow Map)。在进行深度查询时,首先根据当前像素在相机空间中的Z值确定其位于哪个分段中,然后找到对应分段的Shadow Map和投影矩阵。在实际操作中,通常会选择3~4级分段,划分位置通常是指数划分和均匀划分的结果进行插值后得到。鉴于划分是基于视锥的,所以较远处的Shadow Map可以预先计算好,或者每隔几帧才更新一次,以此提高渲染效率。

相比标准Shadow Map,级联式Shadow Map的像素利用率提高了
视锥划分算法

提高Shadow Map像素利用率的另一个方案是 设法获得更加紧凑的视锥包围盒 。由于相机在场景中始终处于变化状态,因此整个屏幕空间中可见像素的包围盒也在变化,且这个包围盒往往要比相机默认的视椎体紧凑许多,假设我们能够通过场景的ZBuffer去统计得到这个包围盒,再结合CSM去做场景划分,就可以最大限度的避免Shadow Map中像素的浪费,这就是 Sample Distribution Shadow Map(SDSM) 的核心思想。

基于PSSM划分的场景层级分布
基于SDSM划分的场景层级分布
PSSM和SDSM视锥划分投影到光源空间后的面积

基于Shadow Map方法产生锯齿的另一个原因在于, 坐标变换是连续的,但光栅化后的像素位置则是离散的,于是不论Shadow Map的精度有多高,都无法保证从屏幕空间的像素映射到光源空间时,能够找到一个位置完全匹配的像素。 这意味着当计算一个屏幕空间的像素是否处于阴影当中时,需要考虑它投影到的Shadow Map位置附近的多个像素。例如我们可以考虑投影位置附近 个像素的贡献:

其中,

这就是所谓的 百分比渐进滤波(Percentage-Closer Filtering,PCF) 。标准PCF算法使用的采样点位置比较规则,最终呈现出来的阴影过度还是会看出一块一块的Pattern,为此我们可以采用一些随机的样本位置,比如Poisson Disk来改善PCF的效果。

通过控制PCF的Kernel Size,就可以改变阴影的模糊半径,进而模拟出软阴影的效果,这就是 PCSS(Percentage-Closer Soft Shadows) 算法的思路。我们知道,之所以有软阴影,是因为某些区域处于光源的 半影区(Penumbra) ,所以PCSS算法会设法估算出当前位置的半影区大小,这个大小决定了PCF算法的Kernel Size,最终呈现出一个视觉上的软阴影效果。

首先在2D坐标系下分析这个问题,我们假设光源长度为 ,半影区长度为 ,遮挡物距离光源 ,距离被遮挡物 ,且光源、遮挡物、被遮挡物三者平行,根据相似形容易得出:

PCF方法看起来是美好的,但也存在一个致命的问题: 它需要大量的采样 ,例如一个3×3的Kernel需要9次采样,5×5的Kernel则需要25次采样,随着采样半径增大,这个数字会迅速增加。这时候我们会自然地想到: 不是可以把一个2D滤波拆分成两个独立的1D滤波再利用硬件的双线性采样来减少采样次数吗 ?这个思路确实是对的,然而实际却不可行,因为我们的可见性判定函数是一个二值函数。进一步来说,

上面式子的左边是PCF,右边则是我们期望的: 预先对生成的Shadow Map进行滤波,然后在判定可见性时只采样一次 。于是,如何设计出一个连续的可见性判定函数,使得提前滤波成为可能,就是能否使用大采样半径的关键。

VSM和ESM

VSM(Variance Shadow Map)ESM(Exponential Shadow Map) 提出的可见性判定函数,就满足了我们需要的条件。

VSM算法基于统计规律,我们假设光源视角下的深度值 是一个随机变量,服从分布的均值为 ,方差为 。于是对于一个固定值 , 就是我们利用PCF想要算出的当前点处于阴影中的概率,而根据切比雪夫不等式,我们可以得到这个概率的一个上界:

这个上界在绝大部分时候能够较好地去近似 ,计算这个值来代替PCF的过程就是整个算法的核心思路。为了求出上式,我们需要得到 和 ,所以不同于传统的Shadow Map仅存储深度值,VSM需存储两张Shadow Map贴图,一张是 ,一张是 。在Shadow Map生成完毕后,我们对两张图分别进行均值滤波(由于均值滤波可拆分,这里可以使用较大的Kernel半径),求出 和 ,于是:

在计算阴影可见性时,类似于Shadow Map的方法,只是我们需要采样 和 两张图,代入上式算出 和 ,进一步和 一起代入切比雪夫不等式,即可估算出最终的可见性。

VSM的可见性判定函数
VSM效果示意,1为标准SM,2为5×5的PCF,3为5×5的PCF+双线性插值,4为基于5×5分列高斯模糊的VSM

当然VSM也有一个显著的缺陷,观察切比雪夫不等式就可以发现,当Shadow Map某个区域内深度变化剧烈,导致 很大的时候,这个可见性估计就不再准确了,表现在视觉上,就是原本应该完全处于阴影的区域出现了 漏光

可以看到,原本应该全黑的阴影处出现了白边

ESM方案思路更加简洁,它的可见性判定函数如下:

于是,类似于PCF的思路,

只要我们在Shadow Map中选取合适的常数 存储 ,随后对Shadow Map进行滤波,再带入上式即可算出经过反走样的阴影效果。相比于VSM,ESM的优势是只需要存储一张经过指数运算的深度图,缺点是 的值越大越好,然而受限于贴图的精度,这个值通常只能在80左右。

ESM的可见性判定函数,可以看出c的选取对可见性判定的影响
ESM由于贴图数值精度不足导致的错误

鉴于VSM和ESM各自的优缺点,也有人提出了两者结合的方案,即 EVSM(Exponential Variance Shadow Maps) ,这里不再详细展开。

以上都是通过设计一个能够预先滤波的可见性判定函数去改进Shadow Map的算法思路,类似的方案还有 CSM(Convolution Shadow Maps) ,也有一些综述类文档对比了它们各自的特性,这里我们不再赘述。

总结

这篇文章我们介绍了一些基础的阴影算法框架,基于时下(仍然是)最流行的实时阴影解决方案Shadow Map介绍了一些改进方案。下一篇文章我们继续围绕Shadow Map在实践中的性能优化展开讨论,同时我们也会介绍近年来流行起来的基于光线追踪的阴影渲染技术。

参考

  1. Two Shadow Rendering Algorithms   https://web.cs.wpi.edu/~matt/courses/cs563/talks/shadow/shadow.html
  2. Improving Shadows and Reflection via the Stencil Buffer   https://www.researchgate.net/publication/238248138_Improving_Shadows_and_Reflections_via_the_Stencil_Buffer
  3. Cg Shadow Volumes   http://www.sonic.net/~surdules/articles/cg_shadowvolumes/index.html
  4. Shadow mapping   https://en.wikipedia.org/wiki/Shadow_mapping
  5. GPU Gems 3, Chapter 10. Parallel-Split Shadow Maps on Programmable GPUs   https://developer.nvidia.com/gpugems/gpugems3/part-ii-light-and-shadows/chapter-10-parallel-split-shadow-maps-programmable-gpus
  6. RENDERING TECHNIQUES IN RYSE: SON OF ROME   https://advances.realtimerendering.com/s2014/crytek/Sigg14_Schulz_Mader_Ryse_Rendering_Techniques.pptx
  7. Sample Distribution Shadow Maps   https://software.intel.com/content/dam/develop/external/us/en/documents/sampledistributionshadowmaps-siggraph2010-notes-181237.pdf
  8. GPU Gems, Chapter 11. Shadow Map Antialiasing   https://developer.nvidia.com/gpugems/gpugems/part-ii-lighting-and-shadows/chapter-11-shadow-map-antialiasing
  9. Tutorial 16 : Shadow mapping   http://www.opengl-tutorial.org/cn/intermediate-tutorials/tutorial-16-shadow-mapping/
  10. PCF   https://wiki.epfl.ch/bachelorshadows/week3-4
  11. Percentage-Closer Soft Shadows   https://developer.download.nvidia.cn/shaderlibrary/docs/shadow_PCSS.pdf
  12. Efficient Gaussian blur with linear sampling   http://rastergrid.com/blog/2010/09/efficient-gaussian-blur-with-linear-sampling/
  13. Variance Shadow Maps   https://www.punkuser.net/vsm/vsm_paper.pdf
  14. Exponential Shadow Maps   http://jankautz.com/publications/esm_gi08.pdf
  15. Exponential Variance Shadow Maps   https://www.martincap.io/project_detail.php?project_id=9
  16. Convolution Shadow Maps   http://jankautz.com/publications/csmEGSR07.pdf
  17. Advanced Soft Shadow Mapping Techniques   https://developer.download.nvidia.cn/presentations/2008/GDC/GDC08_SoftShadowMapping.pdf

长期蝉联商家直播榜首,余秋雨取名,这个茶行业“黑马”靠什么突围?

上一篇

好游戏平台“彩虹计划“启动,招募优质游戏内容创作者

下一篇

你也可能喜欢

游戏中的阴影(一):基础

长按储存图像,分享给朋友