DOOM3的秘密——阴影锥原理与展望
id software向来都不吝惜为了达到最好的图像效果而使用最先进的渲染技术,这曾经使得玩家为了玩它开发的游戏而不得不掏光口袋里面的钱来升级电脑,不知道这次我们可以幸免吗?
自DX9发布以来,大家的注意力似乎都被shader吸引住了,BBS里面谈论的话题也总是离不开shader based rendering,前一段时间关于GPU内部精度的讨论大有遮天蔽日之感,但其实和闪闪发光的金属小球以及波光鳞鳞的水面比较,几个简简单单的影子常常能带给场景更多的真实感。也许这就是为什么DOOM3能够在多如牛毛的FPS游戏中脱颖而出的原因之一。
阴影的实现方法有很多种,现在比较流行的主要是shadow mapping和shadow volume。前者实现起来相对简单,可以发挥现在 GPU 可编程流水线的能力,但是由于先天不足,shadow mapping在处理动态光源/物体的时候开销过大,经常作为一种静态场景中的廉价替代物。而Shadow volume的强项恰恰是shadow mapping的短处,像DOOM3这种大量运用动态光源,并且要对时刻都在运动中的物体投射阴影,shadow volume是现阶段唯一的选择。
Shadow mapping的原理:
一个物体之所以会处在阴影当中,是由于在它和光源之间存在着遮蔽物,或者说遮蔽物离光源的距离比物体要近,这就是shadow mapping算法的基本原理。
Pass1:以光源为视点,或者说在光源坐标系下面对整个场景进行渲染,目的是要得到一副所有物体相对于光源的depth map(也就是我们所说的shadow map),也就是这副图像中每个象素的值代表着场景里面离光源最近的fragment的深度值。由于这个pass中我们感兴趣的只是象素的深度值,所以可以把所有的光照计算关掉,打开z-test和z-write的render state。
Pass2:将视点恢复到原来的正常位置,渲染整个场景,对每个象素计算它和光源的距离,然后将这个值和depth map中相应的值比较,以确定这个象素点是否处在阴影当中。然后根据比较的结果,对shadowed fragment和lighted fragment分别进行不同的光照计算,这样就可以得到阴影的效果了。
从上面的分析可以看出来,depth map的渲染只和光源的位置以及场景中物体的位置有关,无论视点怎么运动,只要光源和物体的相互位置关系不变,shadow map就可以被重复使用,因此对于没有动态光源的场景,shadow mapping是很明智的一种选择。
除了上面提到的不能很好应付动态光源场景的限制之外,shadow mapping还存在着所有使用texture的场景面临的共同问题-锯齿。根据采样定理,只有纹理分辨率小于或者等于物体的实际分辨率时才不会失真,而当一副很大的纹理被贴到尺寸比它小的物体上时,会出现一个 fragment覆盖多个texel的情况,这时要准确的再现这个fragment的颜色信息,就要综合考虑所有被它覆盖的texel产生的影响,这就是各种纹理滤波方法最基本的原理。
但是由于depth map是在不断的变化当中,所以不能像一般的纹理那样把各个mip-map事先计算好放到显存里面。有一种利用pixel shader的方法对depth map做bilinear filtering,但是开销很大,在现阶段不具备实用意义。同样的问题在纹理分辨率小于屏幕分辨率的时候仍然存在,这时多个ragment会被投射到同一个texel上面,虽然从再现纹理的角度来说并不存在失真,但是由于多个 fragment 共用同一个纹理值,锯齿问题还是存在。
更糟糕的是,没有一种滤波技术可以从根本上解决这样的锯齿,因为从数学上讲,人们不可能通过运算来创造出比原始量更多的信息。近年来,为了解决shadow mapping的锯齿问题,人们做了很多努力,比较有前景的是adaptive shadow map(ASM)和perspective shadow map(PSM)。两者的基本原理都是在可能产生锯齿的地方人为增加采样率,使得一个fragment至少对应一个texel, 区别是ASM增加采样率的地方是在shadow边缘,而PSM是在靠近视点的地方。
修补一个本身存在缺陷的方法从数学上来说是缺乏美感的,正像John Carmack在2002年8月的一封email中所说:“Shadow buffers make good looking demos with controlled circumstances,but when you start using them for a “real” application,you find that you need absolutely massive resolution to get acceptable results for omni - directional lights,and a lot of the artifacts need to be tweaked on a per-light basis。While it is possible to do shadow buffers on GF1/radeon class hardware,without percentage closer filtering they look wretched。If we were targeting only the newest hardware,shadow buffers would have a better shot,but even then,they have more drawbacks than are commonly appreciated。”
看起来似乎John Carmack找到了实现阴影更好的方法?让我们来看看它究竟是什么。
用一句简单的话来概括z-pass的算法就是从视点向物体引一条视线,当这条射线进入shadow volume的时候,stencil值加一,而当这条射线离开shadow volume的时候,stencil值减一。如果stencil值为零,则表示实现进入和离开shadow volume的次数相等,自然就表示物体不在shadow volume内了。
Pass3:第二步完成以后,根据每个象素的stencil值判断其是否处于阴影当中(如果stencil的值大于零,则这个象素在shadow volume内,否则在 shadow volume的外面),然后据此绘制阴影效果。
z-pass算法缺点及补救办法
以上的讨论都是基于视点在shadow volume外面的情况。在这个条件可以得到满足的情况下,z-pass算法工作的很好,不过一旦视点进入到了shadow volume里面,z-pass算法就会立即失效。
Pass2:disable z-write,enable z-test/stencil-write。渲染shadow volume,对于它的back face,如果z-test 的结果是 fail,stencil值加一,如果z-test的结果是pass,stencil值不变。对于front face,如果z-test的结果是fail,stencil值减一,如果结果是pass,stencil值不变。
使用z-Fail算法的条件
Capping For Z-Fail
由于Z-Fail算法依靠计算shadow volume不能通过Z-test的部分来确定stencil buffer的值,所以要求shadow volume是闭合的。下面的那张图里面红色的实线表示capping,可以想象,假如不人为的添加capping,那么shadow object 1/2的stencil值都会是0,而实际上正确的stencil值应该是1,因为它们都在阴影内。
CPU based method(基于CPU建立方法):
显然,插入冗余的顶点会造成极大的浪费。因为大部分的边最终并不会成为silhouette edge,也就是说插入的degenerate quad是无用的。不过这样做的好处是几何数据只需要传输到显卡一次,之后无论光源的位置在哪里,预处理过后的几何体都可以用来生成shadow volume,不像刚才解释过的方法那样一旦光源和物体的相对位置发生变化,就需要重新用CPU计算silhouette edge,之后再把结果传送给显卡。 实际编程的时候,可以做一下改进,由于平坦的表面是不会产生阴影的,所以在这些表面所包含的边上就没必要插入degenerate quad。而且所有的预处理应该在软件开发过程中完成,用户启动程序以后直接调用的就是插入过quad的模型,不需要CPU再进行计算。
Shadow volume的算法优化 Shadow volume的基本算法讲到这里就基本完成了,下面说一下现在比较常用的一些优化算法。 |
实现阴影效果的好方法
Shadow volume的原理:
Shadow volume这种算法第一次被提出是在Franklin C. Crow在1977年写的一篇论文“SHADOW ALGORITHMS FOR COMPUTER GRAPHICS”里。其基本原理是根据光源和遮蔽物的位置关系计算出场景中会产生阴影的区域(shadow volume),然后对所有物体进行检测,以确定其会不会受阴影的影响。
图中的绿色物体就是所谓的遮蔽物,而灰色的区域就是shadow volume
只有处于shadow volume里面的物体才会受阴影的影响
shadow volume的算法
现在清楚了shadow volume的基本原理,那么如何确定一个物体或者一个物体的某一部分处于shadow volume中呢?这就要用到stencil buffer的帮助了。
z-pass算法:
z-pass是shadow volume一开始的标准算法,用来确定某一个象素是否处于阴影当中。其原理是:
Pass1:enable z-buffer write,渲染整个场景,得到关于所有物体的depth map。注意这里的depth map和shadow mapping里面的区别是 shadow volume里面的depth map是以真实视点作为视点得到的,而shadow mapping里面的depth map是以光源为视点得到的。
Pass2:disable z-buffer write,enable stencil buffer write,然后渲染所有的shadow volume。对于shadow volume的front face(既面对视点的这一面),如果depth test的结果是pass,那么和这个象素对应的stencil值加一。如果depth test的结果是fail,stencil值不变。而对于shadow volume的back face(远离视点的一侧),如果depth test的结果是fail,stencil值减一,否则保持不变。
在这副图里面,视线三进三出shadow volume,最后的stencil值为零,表示物体在shadow volume外,不受阴影的影响。
这副图里面视线三进一出,stencil值为2,表示物体在shadow volume内,有阴影产生。
这副图里面从视点到物体的视线中止于shadow volume前,也就是说所有的z-test都是fail,相应的stencil值为零,表示物体在阴影外面。
这副图里面的视线二进二出,按照z-pass的算法,最后的stencil值为0,表示物体在阴影外,可实际上物体是处于阴影内的。错误的原因就在于视点进入到阴影内,使得视线失去了一次进入shadow volume的机会,让原本应该是1的stencil值变成了0。
Z-Pass这种错误的行为可以从下图中看出:
注意地下的影子
Z-Fail算法:
Z-Fail算法是John Carmack,Bill Bilodeau和Mike Songy各自独立发明的,其目的就是解决视点进入shadow volume后z-pass算法失效的问题。
Pass1:enable z-write/z-test,渲染整个场景,得到depth map。(这一步和z-pass的完全一样)
图中所有的shadow volume都处在z-pass的位置,因此stencil值不会改变。
视点在shadow volume内也没有问题,最后stencil的值是2,表示物体在阴影内。
上面那个Z-Pass无法处理的场景,用Z-Fail计算则可以得到正确的结果。
Z-Pass和近剪裁面的关系:
在Z-PASS算法中,当shadow volume和视图体(view frustum)发生剪切关系的时候,需要附加的capping才能保证最后的结果正确。因为经过view frustum 的剪裁作用以后,shadow volume的一部分有可能变成敞开的,比如在图中additional capping的位置,假如不人为的附加一部分多边形,在渲染shadow volume 的时候stencil buffer就不会发生+1的操作(因为这里没有任何多边形,自然也就不会和原来的depth map比较),最后的结果显然是不对的。
如何建立shadow volume?
shadow volume的建立是整个算法里面最重要的部分,在GPU出现以前,shadow volume的建立都是基于CPU的。随着GPU应用的逐渐开展,人们又将shadow volume运算移植到了GPU上,不过后面一种方法需要对物体的几何数据进行预处理,下面就对两种方法分别进行解释。
想必熟悉shadow volume的朋友对silhouette edge这个词会很熟悉。它表示从光源的角度看物体所得到的轮廓线。Shadow volume就是由silhouette edge扩展到一定距离以外或者无穷远处得到的。silhouette edge的确定方法有很多种,基本思想就是找出那些被朝向相反(一个面向光源,另一个背向光源)的两个三角形(相对于光源来说)所共享的边,因为只有这样的边会最终成为silhouette edge,其他的边在光源看来都在物体投影的内部而不是边缘。
这副图是一个由4个三角形组成的多边形,假设光源处在读者头部的位置,那么外围的一圈实线就是所谓的silhouette edge。我们所要做的就是从原始数据里面将内部多余的4条边(虚线)去掉。具体实现是这样:
值得一提的是,这种方法正是DOOM3所采用的方案,但是其中有一个问题就是silhouette edge是由光源和物体的相互位置确定的,也就是说这二者之间有一个的位置发生了变化,silhouette edge就要重新计算,更新的数据也要传回显卡才能渲染shadow volume,这对CPU的计算能力以及AGP的带宽不能不说是一个不小的考验。
GPU based method(基于GPU建立方法):
Vertex shader一出现人们就在思考能不能利用它来加速shadow volume的渲染速度。但即使是现在最先进的vertex shader 3.0也不具备创建新的几何物体的能力。简单点说vertex shader只能接受一个顶点,修改这个顶点的属性(位置,颜色,纹理坐标,etc),之后输出这个顶点到光栅化部分,继而进行pixel shader运算。碰到需要创建新顶点的地方,就只有依靠CPU直接操作vertex buffer了。
另外一个方法就是事先把shadow volume需要的空间留出来,然后再通过vertex shader的运算使之外形达到我们需要的样子。这就好比我要存储一串数据,但又不很确定具体的规模是多大,只好事先分配一块很大的区域,这样不免会造成很大浪费,但也是不得以而为之。
(一)Z-Pass .VS. Z-Fail
前面提到过,Z-Pass比Z-Fail速度要快,因此我们可以在不会产生问题的场合下适当使用Z-Pass来提高性能,但是如何确定何时Z-Pass不会带来问题呢?Z-Pass失效主要是由于两种原因 :
原因一:视点进入shadow volume内,比如下图:
只要能探测出这两种情况,就能在需要的时候切换到Z-Fail算法。条件A的判定可以参照下图,在视点和光源之间做一条连线,如果这条线和遮蔽物相交,那么可以肯定视点在shadow volume内,将切换到Z-Fail算法。
原因二:shadow volume与近剪裁面相交
至于情况B的判定可以利用光源和近剪裁面形成的light-pyramid(红色阴影部分)与遮蔽物的交汇关系。如果遮蔽物完全在light-pyramid之外,则由它生成的shadow volume不会和近剪裁面相交,可以使用Z-Pass算法,否则将只能使用Z-Fail算法。
(二)tricks to save fillrate: 前面提到过,shadow volume算法里面两个最耗时的步骤就是silhouette edge determination和 shadow volume rendering。其中shadow volume rendering是完全考验 GPU 填充率的步骤,虽然现在的显卡动辄就有几十G fragment/s的填充率能力,但是遇到复杂的场景,流水线也不免不堪重负。 此外,频繁的stencil buffer操作也会占据一部分显存带宽,如果能够找出一些办法尽量减小shadow volume的尺寸,将会是效果很明显的一种优化方法。 限定光照的范围(Attenuated Light Bounds): 如果所用的光源有衰减效应,则可以利用scissor test将渲染的范围限定在光源的作用范围之内,因为超出了这个范围就不会有阴影存在,自然用不着去渲染那部分的shadow volume了。所谓scissor test就是人为地在屏幕坐标系下面定义一个矩形,只有坐标处在这个矩形范围内的fragment才能够通过测试,其内容才能被写入帧缓存。 ultra shadow这项技术是随着NV35的发布而浮出水面的,进而在NV36/38中得到了继承,我们基本上可以在NVIDIA今后的产品中,这项技术会得到持续的应用。 id software的当家程序员John Carmack曾经说过NV35是为DOOM3量身打造的GPU,我们在这里有理由怀疑Carmack说这番话的原因很有可能就是由于NV35中集成了ultra shadow阴影加速技术(近日GeForce FX系列已经成为DOOM3的推荐GPU),那么ultra shadow究竟是什么,它如何加速阴影的渲染速度呢? 其实ultra shadow技术仅仅利用了一个NVIDIA新近提交的OpenGL扩展—EXT_depth_bounds_test,我们先来看一下NVIDIA官方在GDC2003上对这个扩展的介绍: Depth bounds test的作用是比较由当前fragment的屏幕坐标(xw,yw)指定的depth buffer中的z值与用户通过glDepthBoundsNV(GLclampd zmin ,GLclampd zmax)所指定的[zmin,zmax]。 如果z值在次范围之外,则将当前的fragment从流水线中剔除掉,不进行此处的stencil buffer操作。注意这里比较的并不是fragment(shadow volume)的z值,而是前一个path中已经渲染过的shadow receiver的z值。具体情况请看下图: 阴影渲染实现技术的展望 shadow volume是近阶段实现统一光照模型比较好的一种技术,现在主要的问题是基于CPU的方法对处理器依赖比较重,在AI/物理运算较多的场景中CPU的运算能力可能不足,而基于GPU的方法效率太低,会产生大量的冗余顶点,其原因还是由于现在的GPU(包括即将发布的 NV40/R420)都不具备在芯片内部产生新顶点的能力。Microsoft意识到了这一点,在DirectX Next的发展规划中将这种能力列为了要实现的目标之一: 当然,所有这些设想都要基于半导体生产技术的支持才行,我们在近期(5-10年)将不会看到它们在硬件上的实现。 |