转自csdn 网络游戏客户端编程 免费试读
7.2 实时阴影技术
在游戏中为了实现更逼真的自然效果,就要体现游戏场景中光与影的效果,有时候光影效果会是游戏中的一个主要成分,能极大地改变玩家的游戏体验,如图7-5所示。
图7-5 游戏中的实时阴影
7.2.1 阴影体
在3D动作游戏中,GPU往往要面对绘制大量光影效果的场景,而游戏的光影效果越复杂,提供的视觉真实感就越好。但复杂的光影计算往往需要耗费大量的计算资源,导致游戏的运行速度慢如蜗牛。DOOM3就是一个典型的例子,它带来的逼近影视质量的效果令人折服,但过于复杂的光影变换极其耗费GPU的运算资源,导致现在没有几款显卡能够轻松应对。
先来看看3D游戏中阴影的生成原理。在现实世界中,阴影效应因光线被物体遮挡而产生,而在3D环境下同样通过模拟这一机制来创建阴影:将物体沿着光线的方向扩展成一个棱台,该棱台内的所有物体都处于阴影之中,它也被称为“阴影锥”或“阴影体”。那么,GPU如何判断某个物体是否在这个阴影锥内呢?这个时候,就必须用到模板缓冲。模板缓冲好比是蜡染中的蜡层,可以遮罩住3D画面的任意区域,这样,这些区域就暂时不被绘制,之后再对整个画面作光照计算。由于阴影锥内的物体被模板缓冲遮住,光照计算并不会涉及到其中的物体,体现在视觉上就是阴影锥内的物体无法被光线“照到”,阴影效果由此诞生。
实时阴影是一种相对高级的技术,在每一帧,场景中的几何体或灯光位置变动时,要计算一个叫做shadow volume 的物体。shadow volume实际是一个三维物体,是投影物体的轮廓,总是从光源方向投出。
例如飞机模型,投影的物体是一个两翼飞机,在每一帧中飞机的轮廓被计算(使用一个边检测算法,轮廓的每个边要被建立,因为相邻的多边形针对光矢量分别有相反的法线),结果边列表(轮廓的)被投影成一个沿光源方向的三维物体。这个三维物体就叫做shadow volume,而视点在volume里就是在阴影里。
接下来,shadow volume在模板缓冲区中被渲染两次,开始时仅仅正面面对的多边形被渲染,模板缓冲区每次数值被增加。然后背面面对的多边形被渲染,模板缓冲区值被减小。一般来说,所有增加的值和减少的值将互相抵消,但是,因为场景中已经具有被渲染的正常的图元(飞机和地形),当shadow volume被渲染时,一些像素将不能通过zbuffer测试,所有留在模板缓冲区中的值对应的像素处于阴影区。
最后,保留的模板缓冲区内容被用做一个模板,作为一个巨大的全包围的黑方块被alpha-blended到场景中,使用模板缓冲区作为模板,仅仅阴影中的像素是暗的。
前面所介绍的方法,即平面阴影(planar shadow),只适用于平面上。但是,除了少数的情形之外,绝大多数的情形下,根本无法预测阴影会被投射在什么样的表面上。所以需要自由度更高的方法。
在这里介绍一个较为灵活的方法,它可以将阴影投射在不规则的表面上。这个方法称为体积阴影(volumetric shadow)。这个方法的特点在于,它并不是利用“把物体投影到表面”的方式来产生阴影,而是去找出场景中,有哪些像素在阴影中。也就是说,想像一个物体挡住光时,在物体的后面会形成一个大的“阴影锥”。很明显,若一个像素在“阴影锥”之中,那它就是在阴影之中的,如图7-6所示。
图7-6 体积阴影
图7-6中的红色球体在接受光照后,在后方产生一个“阴影锥”,而这个“阴影锥”和灰色平面的交集,就是阴影出现的地方。
体积阴影的算法最早于1977年提出,其基本原理就是根据光源和遮蔽物的位置关系计算出场景中会产生阴影的区域,即阴影锥。然后对所有物体进行检测,以确定其会不会受阴影的影响。
假设有一个已经绘制完成的3D场景。因为使用zbuffer的关系,对每一个像素而言,都有一个相对的z值,即表示该像素和观察者的距离的值。假设现在有一个三角面,需要把阴影投射到这个3D场景中,并画出这个三角面的“阴影锥”。因为物体是一个三角形,所以它的“阴影锥”也是一个三角锥。这时,如何才能知道3D场景中,有哪些像素和这个三角锥有交集?
其实方法很简单。想像许多射线,由观察者射向每个像素。如果射线和“阴影锥”完全没有交集,那么它所对应的像素当然不会和“阴影锥”有交集。不过,即使是射线和“阴影锥”有交集,也并不一定表示该像素就一定和“阴影锥”有交集,因为射线可能会射入“阴影锥”后又射出。所以,只有在射线射入“阴影锥”之后,在离开“阴影锥”之前就遇到其对应的像素,才表示这个像素和“阴影锥”有交集。图7-7显示出像素和阴影锥相交的各种不同的情形。
(1) (2) (3)
图7-7 阴影锥
图7-7中的(1)和(2)都是面对观察者的面,所以它们所涵盖的像素,就是射线会射入阴影锥的像素。而(3)则是背对观察者的面,所以它所涵盖的像素是射线会离开阴影锥的像素。所以,会和阴影锥有交集的像素,就是(1)+(2)-(3)的那些像素,也就是阴影所在的位置。
不过,怎么才能在一般的3D绘图硬件中,得到(1)+(2)-(3)的结果呢?和平面阴影一样,这需要模板缓冲区(stencil buffer)。在OpenGL和Direct3D中的模板缓冲区都可以让它进行“加1”和“减1”的动作。所以,只要把模板缓冲区设定成:在绘制(1)和(2)的面时,让模板缓冲区加1;而在绘制(3)的面时,让模板缓冲区减1。这样一来,在画完(1)~(3)时,那些模板值不为0的像素就是阴影了。最后,把所有模板不为0的像素利用alpha blending的方式,使其亮度降低,就可以达到绘制阴影的效果。
上面的例子用的是一个三角面。对于比较复杂的物体,其原理也是一样的,如图7-8所示。当物体由许多三角面组成时,把所有面对光源的三角面都进行上面的动作,就可以产生阴影。不过,这样有个缺点:因为很多三角面的边是接在一起的,所以这样做十分浪费时间。要提高效率其实也很容易,在绘制阴影锥的时候,若有一个边被两个三角面共享,就表示这是一个内部的边,在绘制阴影锥的时候,可以不用去画这个边。这样就可以省下不少的时间。
这个方法适用于非常复杂的物体。不过,还是可能会遇上一些问题。例如,如果观察者在阴影锥的内部,可能会发生一些麻烦的情形。不过,对大部分情形来说,只要将模板缓冲区设定成“减到0就停止”,即0-1 = 0,就可以解决。当然这无法解决所有的问题,不过通常已经够好了。另外,如果物体不是convex(即“凸”的),就可能会出现射线重复进入阴影锥的情形。这种情形并不会有问题,不过模板缓冲区就需要有比较多的比特才不会出错。一般来说,4比特已经可以处理绝大多数的物体,如图7-9所示。
图7-8 模板阴影锥技术
图7-9 阴影锥效果图
这个方法比平面阴影更能适用于不同的场景。不过,它也有缺点。最主要的缺点在于它的复杂度。要做出有效率的阴影锥,需要对物体做相当麻烦的处理,基本上就是要找出物体在某个方向的外缘(即silhouette),这会耗费相当长的GPU时间。另外,为所有的物体绘制出其阴影锥,需要相当大量的填充率(fill rate)和内存频宽。若是延后渲染(deferred renderer),例如图素渲染(tile renderer),则影响不会这么大,特别是图素渲染可以支持一些特别的功能,来加速体积阴影的动作。
7.2.2 实时阴影的技术实现
阴影的实现方法有很多种,现在比较流行的主要是shadow mapping和shadow volume,前者实现起来相对简单,可以发挥现在GPU可编程流水线的能力,但是由于先天不足,shadow mapping在处理动态光源/物体的时候开销过大,经常作为一种静态场景中的廉价替代物。而Shadow volume的强项恰恰是shadow mapping的短处,像DOOM3这种大量运用动态光源,并且要对时刻都在运动中的物体投射阴影,shadow volume是现阶段唯一的选择。
1.shadow mapping算法
一个物体之所以会处在阴影当中,是由于在它和光源之间存在着遮蔽物,或者说遮蔽物离光源的距离比物体近,这就是shadow mapping算法的基本原理。
(1)以光源为视点,或者说在光源坐标系下面对整个场景进行渲染,目的是要得到一幅所有物体相对于光源的depth map(也就是常说的shadow map),也就是这幅图像中每个像素的值代表着场景里面离光源最近的像素的深度值。由于这个阶段感兴趣的只是像素的深度值,所以可以把所有的光照计算关掉,打开z-test和z-write的渲染状态。
(2)将视点恢复到原来的正常位置,渲染整个场景,对每个像素计算它和光源的距离,然后将这个值和depth map中相应的值比较,以确定这个像素点是否处在阴影当中。然后根据比较的结果,对shadowed fragment和lighted fragment分别进行不同的光照计算,这样就可以得到阴影的效果了。
从上面的分析可以看出来,depth map的渲染只和光源的位置及场景中物体的位置有关,无论视点怎么运动,只要光源和物体的相互位置关系不变,shadow map就可以被重复使用,因此对于没有动态光源的场景,shadow mapping是很明智的一种选择。
2.shadow volume算法
shadow volume这种算法第一次被提出是在Franklin C. Crow在1977年写的一篇论文“SHADOW ALGORITHMS FOR COMPUTER GRAPHICS”里。其基本原理是根据光源和遮蔽物的位置关系计算出场景中会产生阴影的区域,然后对所有物体进行检测,以确定其会不会受阴影的影响,如图7-10所示。
图7-10 shadow volume示意图
图中的绿色物体就是所谓的遮蔽物,而灰色的区域就是 shadow volume,如图7-11所示。
图7-11 只有处于 shadow volume里面的物体才会受阴影的影响
1)z-pass算法
z-pass是shadow volume一开始的标准算法,用来确定某一个像素是否处于阴影当中。其原理如下。
(1)获取depth map(所有物体的深度值)。
首先打开深度缓冲(enable z-buffer write),渲染整个场景,得到关于所有物体的depth map。注意这里的depth map和shadow mapping里面的区别是:shadow volume里面的depth map是以真实视点作为视点得到的,而shadow mapping里面的depth map是以光源为视点得到的。
(2)计算模板值。
接着,关闭深度缓冲,打开模板缓冲(disable z-buffer write 和enable stencil buffer write),渲染所有的shadow volume。对于shadow volume的front face(即面对视点的这一面),如果depth test的结果是pass,则那么和这个像素对应的stencil值加1。如果depth test的结果是fail,则stencil值不变。而对于shadow volume的back face(远离视点的一侧),如果depth test的结果是fail,则stencil值减1,否则保持不变。
用一句简单的话来概括:z-pass的算法就是从视点向物体引一条射线,当这条射线进入shadow volume的时候,stencil值加1,而当这条射线离开shadow volume的时候,stencil值减1,如果stencil值为0,则表示实现进入和离开shadow volume的次数相等,自然就表示物体不在shadow volume内了。
(3)第二步完成以后,根据每个像素的stencil值判断其是否处于阴影当中(如果stencil的值大于零,则这个像素在shadow volume内,否则在shadow volume 的外面),然后据此绘制阴影效果。
采用z-pass的算法时,对几种情况的处理如图7-12所示。
在图7-12里,视线三进三出shadow volume,最后的stencil值为零,表示物体在 shadow volume外,不受阴影的影响。
图7-12 物体在阴影区的后面
图7-13里视线三进一出,stencil值为2,表示物体在shadow volume内,有阴影产生。
图7-13 物体在阴影区中
图7-14里从视点到物体的视线中止于shadow volume前,也就是说所有的z-test都是fail,相应的stencil值为零,表示物体在阴影外面。
图7-14 物体在阴影区的前面
以上的讨论都是基于视点在shadow volume外面的情况。在这个条件可以得到满足的情况下,z-pass算法工作的很好,不过一旦视点进入到了shadow volume里面,z-pass算法就会立即失效。
图7-15里视线二进二出,按照z-pass的算法,最后的stencil值为0,表示物体在阴影外,可实际上物体是处于阴影内的。错误的原因就在于视点进入阴影内,使得视线失去了一次进入shadow volume的机会,让原本应该是1的stencil值变成了0。
图7-15 视线在阴影区时,算法失效
2)z-fail算法
z-fail算法是John Carmack、Bill Bilodeau和Mike Songy各自独立发明的,其目的就是解决视点进入shadow volume后z-pass算法失效的问题。
(1)打开深度缓冲,渲染整个场景,得到depth map(这一步和z-pass的完全一样)。
(2)关闭深度缓冲,打开深度测试和模板缓冲(disable z-write, enable z-test/stencil- write),渲染shadow volume。对于它的back face,如果z-test的结果是fail,则stencil值加1,如果z-test的结果是pass,则stencil值不变。对于front face,如果z-test的结果是fail,则stencil值减1,如果结果是pass,则stencil值不变。
图7-16中所有的shadow volume都处在z-pass的位置,因此stencil值不会改变。
图7-16 z-fail算法对视点在阴影区外的处理
视点在shadow volume内也没有问题,最后stencil的值是2,表示物体在阴影内,如图7-17所示。
图7-17 z-fail算法对视点在阴影区内的处理
图7-18那个z-pss无法处理的场景,用z-fail计算则可以得到正确的结果,如图7-19所示。
图7-18 阴影错误的效果
图7-19 阴影正确的效果
由于z-fail算法依靠计算shadow volume不能通过z-test的部分来确定stencil buffer的值,所以要求shadow volume是闭合的。图7-20里红色的实线表示capping,可以想像,假如不人为地添加capping,那么shadow object 1/2的stencil值都会是0,而实际上正确的stencil值应该是1,因为它们都在阴影内。
图7-20 人为增加的封闭区域
在z-pass算法中,当shadow volume和视图体(view frustum)发生剪切关系的时候,需要附加的capping才能保证最后的结果正确。因为经过view frustum的剪裁作用以后,shadow volume的一部分有可能变成敞开的,比如在图7-21中additional capping的位置,假如不人为地附加一部分多边形,在渲染shadow volume的时候stencil buffer就不会发生+1的操作(因为这里没有任何多边形,自然也就不会和原来的depth map比较),最后的结果显然是不对的。
图7-21 z-pass和近剪裁面的关系
3.shadow volume的建立
shadow volume的建立是阴影算法里面最重要的部分,在GPU出现以前,shadow volume的建立都是基于CPU的。随着GPU应用的逐渐开展,人们又将shadow volume运算移植到了GPU上,不过这种方法需要对物体的几何数据进行预处理,下面就对两种方法分别进行解释。
1)CPU based method(基于CPU建立方法)
silhouette edge表示从光源的角度看物体所得到的轮廓线。
shadow volume就是由silhouette edge扩展到一定距离以外或者无穷远处得到的。silhouette edge的确定方法有很多种,基本思想就是找出那些朝向相反(一个面向光源,另一个背向光源)的两个三角形(相对于光源来说)所共享的边,因为只有这样的边最终会成为silhouette edge,其他的边在光源看来都在物体投影的内部而不是边缘。
图7-22 模型的边缘
图7-22是一个由4个三角形组成的多边形,假设光源处在读者头部的位置,那么外围的一圈实线就是所谓的silhouette edge。所要做的就是从原始数据里面将内部多余的4条边(虚线)去掉。
具体实现过程如下。
遍历模型的所有三角形。
计算dot3(light_direction , triangle_normal)。用这个结果判断三角形是面向光源(dot3>0)还是背向光源(dot<0)。
对于面向光源的三角形,将所有的三条边压入一个栈,和里面的边进行比较,如果发现重复的(edge1和edge2),将这些边删除。
检测过所有三角形的所有边以后,栈里面剩下的边就是当前光源/物体位置下面的silhouette edge。
根据光源方向,利用CPU或者vertex shader将这些silhouette edge投射出去形成shadow volume。
值得一提的是,这种方法正是DOOM3所采用的方案,但是其中有一个问题,silhouette edge是由光源和物体的相互位置确定的,也就是说这二者之间有一个的位置发生了变化,silhouette edge就要重新计算,更新的数据也要传回显卡才能渲染shadow volume,这对CPU的计算能力及AGP的带宽不能不说是一个不小的考验。
2)GPU based method(基于GPU建立方法)
vertex shader一出现,人们就在思考能不能利用它来加速shadow volume的渲染速度。但即使是现在最先进的vertex shader 3.0,也不具备创建新的几何物体的能力。简单点说,vertex shader只能接受一个顶点,修改这个顶点的属性(位置,颜色,纹理坐标,等等),之后输出这个顶点到光栅化部分,继而进行pixel shader运算。碰到需要创建新顶点的地方,就只有依靠CPU直接操作vertex buffer了。
另外一个方法就是事先把shadow volume需要的空间留出来,然后再通过vertex shader的运算使外形达到需要的样子。这就好比要存储一串数据,但又不很确定具体的规模是多大,只好事先分配一块很大的区域,这样不免会造成很大浪费,但也是不得已而为之。这种处理方式如图7-23所示。
图7-23 基于GPU的边缘处理
由于物体上的每条边都有可能成为silhouette edge,因此需要事先插入degenerate quad(上图的红色三角形),这些quad的面积为零,不作任何变换的话是不可见的,不会造成视觉瑕疵。但是在需要的地方,可以把这些quad拉伸成为shadow volume的侧壁。