对作者心血表示感谢,先附上原文: http://www.86vr.com/teach/cursor/200410/3841.html
实时3D绘图的阴影效果
目 录
1 平面阴影 2 体积阴影
附带的源程序(91KB)
在目前的实时3D绘图中,要做出真实的阴影效果,是很不容易的。因为阴影是因物体遮住光源所产生的,因此,要做出正确的阴影效果,就需要对整个场景做处理,这样才能判断出哪些物体被哪些物体遮住了。
不过,目前的3D硬件,并不容易进行这类的测试,因为资料量和工作量都太大了。不过,这并不表示使用现在的3D硬件就无法做出阴影。现在已经有很多方法是适合用在目前的3D硬件上面,可以产生效果不错的阴影。本文会就一些常用的方法,做简单的介绍。
1、平面阴影
目前常用的方法,几乎都是把阴影看成是“物体投射到其它表面”来处理。在光源是平行光的时候(例如,太阳光),可以看成是物体把阴影“投射”到另一个表面上,如下图所示:
如果场景中只有一个重要的光源(即最强的光源),那可以假设只有这个光源会产生明显的阴影。以平行光源来说,要把阴影“投射”到一个平面上,就是一件相当容易的事。
设空间有一点V,平行光源的方向是L,要投射阴影的平面是P,那么,存在一常数k使
成立,如下图(V、L、和 P均为四维向量,表示一个三维的齐次坐标:homogeneous coordinate):
解上式得
空间中的点V投影到平面P的位置是V+kP。对一个3D模型(model)的每个顶点都代入这个式子,就可以得到投影的结果了。不过,因为目前的3D硬件都是以4×4的矩阵来做变换,所以如果能把投影的动作写成一个矩阵,就会方便很多。
设V = <Vx, Vy, Vz, 1>,L = <Lx, Ly, Lz, 0>,P = <a, b, c, d>;展开前面的式子,会发现k有一个分母:
因此这个式子会有点复杂。不过,因为在OpenGL中的向量都是齐次坐标(homogeneous coordinate),所以可以先把分母提出来,放到w中。对Vx展开得到:
整理一下得到
这样就变成向量内积的形式,可以放到矩阵中。对Vy和Vz做同样的动作,再加上放到w的分母部分,就可以得到下面的矩阵:
因此,理论上,要画阴影时,把MODELVIEW矩阵设成上面的矩阵,画出物体,就会是阴影的样子。
上面讨论的是以平行光源为主。如果光源是点光源,也可以用类似的方法来做。
这个方法可以对任何平面做出阴影的效果。如果有多个平面,可以分别对每个平面都做一次。不过,这个方法显然是没办法把阴影投影到曲面上。所以,这个方法通常称为平面阴影(planar shadow)。
理论的部分已经讨论完了。不过,实作的时候,还有一些细节部分是需要特别注意的。
首先,如果真的直接用上面的方法来做,那做出来的结果可能会像这样:
这是因为画了图中的地板之后,再画黑色的阴影时,会和地板产生所谓的Z fighting现象,也就是阴影的一部分的Z值较地板的Z值小,所以会画出来,但是有些部分的Z值则可能比地板的Z值大,所以就没画出来了。这种现象会使得阴影变得破碎不完整。
现在的3D硬件通常提供一个叫多边形偏移(polygon offset)的功能,用来解决Z fighting的问题。多边形偏移的原理,是在画一个物体时,要求把它的Z值进行一个小的调整。例如,在上面的例子中,我们可以把阴影的Z值减一个小的数字,这样就可以避免Z fighting的现象。
处理掉Z fighting后,再来是第二个问题:
上图中,阴影跑到地板的外面去了。这里需要一个方法,把阴影切到地板的范围内。这个例子中,地板的边界是直线,所以可以用user defined clip planes来做。不过,如果地板是奇怪的形状,就需要别的方法。
而且,光是把阴影切到地板的范围内是不够的。通常阴影是用blending的方式画上去的。如果一个物体的形状比较复杂,那有些地方可能会blend两次或更多次。这样会让阴影看起来不是同一个颜色,即有些地方颜色较深而有些地方较浅。
现在的3D硬件多半支持模板缓冲区(stencil buffer)。模板缓冲区是一个用来“做记号”的缓冲区。通常模板缓冲区可以存放1 bits到8 bits不等的数字,不同的硬件支持的大小会不一样。目前的3D硬件通常把模板缓冲区和Z buffer放在一起。例如,它可能支持15 bits的Z buffer加上1 bit的模板缓冲区;或是支持24 bits的Z buffer加上8 bit的模板缓冲区。因此,通常模板缓冲区的测试是和Z测试一起做的,也就是在使用Z buffer时,模板缓冲区可说是“免费”的。
在我们的例子中,可以先把模板缓冲区(stencil buffer)全清为0。在画地板之前,把模板缓冲区设定为“当Z测试通过时,把模板的值设为1”。这样一来,画完地板之后,地板所占有的那些像素的模板值就都会是1,其它的地方还是0。现在,把模板缓冲区设定成“当Z测试通过时,若模板的值为1才画,且将模板的值设为0”。然后开始画阴影。这样画出来的阴影,一定会在地板的范围内,而且每个像素只会被画一次,不会出现blend两次的情形。
如果有多个平面要投影,可以为每个平面指定不同的模板(stencil)值。不过,如果模板缓冲区(stencil buffer)只有1 bit,那就没办法了。这时,可能就需要在画下一个平面之前,先把模板缓冲区清掉,这样会花很多时间。
下图是一个完整的例子:
参考程序你可以去试验。这个程序需要至少2 bits的模板缓冲区(stencil buffer)支持。
平面阴影(Planar shadow)就差不多是这个样子了。平面阴影的好处是简单、容易做,而且在投影面不多的时候,速度很快。但是,当投影面变多时,或是物体很复杂时,速度很快就会变得很慢,因为对每个平面都需要把投影的物体再画一次。而且,它只能投影在平面上,对于不规则的表面则完全没办法。
还有一些别的方法可以产生阴影的效果。后面还会再讨论一些产生阴影的方式,都可以投影在任何不规则的表面上。
2、体积阴影
前面所介绍的方法,即平面阴影(Planar shadow),只适用于平面上。但是,除了少数的情形之外,绝大多数的情形下,根本无法预测阴影会被投射在什么样的表面上。所以,我们需要自由度更高的方法。
在这里介绍一个较为灵活的方法,它可以将阴影投射在不规则的表面上。这个方法称为体积阴影(Volumetric Shadow)。这个方法的动点在于,它并不是利用“把物体投影到表面”的方式来产生阴影,而是去找出场景中,有哪些像素是在阴影中。也就是说,想象一个物体挡住光时,在物体的后面会形成一个大的“阴影锥”。很明显的,若一个像素在“阴影锥”之中,那它就是在阴影之中。如下图所示:
上图中的红色球体,在受光照后,在后方产生一个“阴影锥”,即Shadow Volume,而这个“阴影锥”和灰色平面的交集,就是阴影会出现的地方。
所以,基本上体积阴影(Volumetric Shadow)的原理是很简单的。不过,要真正实作又是另一回事。为了简单起见,这里先以一个简单的三角形开始。目前的3D绘图几乎都是以三角形为基础,所以从三角形开始,应该是很适当的。
现在,假设有一个已经绘制完成的3D场景。因为使用Z buffer的关系,对每一个像素而言,都有一个相对的Z值,即表示该像素和观察者的距离的值。如果现在有一个三角面,把阴影投射到这个3D场景中,并画出这个三角面的“阴影锥”。因为物体是一个三角形,所以它的“阴影锥”也是一个三角锥。这时,要如何知道3D场景中,有哪些像素是和这个三角锥有交集?
其实方法很简单。想象许多射线,由观察者射向每个像素。如果射线和“阴影锥”完全没有交集,它所对应的像素当然就不会和“阴影锥”有交集。不过,即使是射线和“阴影锥”有交集,并不一定表示该像素就一定和“阴影锥”有交集,因为射线可能会射入“阴影锥”后又射出。所以,只有在射线射入“阴影锥”之后,在离开“阴影锥”之前就遇到其对应的像素时,才表示这个像素和“阴影锥”有交集。下图显示出各种不同的情形:
上图中的(1)和(2)都是面对观察者的面,所以它们所涵盖的像素,就是“射线会射入阴影锥”的像素。而(3)则是背对观察者的面,所以它所涵盖的像素是“射线会离开阴影锥”的像素。所以,会和阴影锥有交集的像素,就是(1)+(2)-(3)的那些像素,也就是阴影所在的位置。
不过,要怎么在一般的3D绘图硬件中,得到(1)+(2)-(3)的结果呢?和平面阴影(Planar shadow)一样,这需要模板缓冲区(stencil buffer)。在OpenGL和Direct3D中的模板缓冲区都可以让它进行“加一”和“减一”的动作。所以,只要把模板缓冲区设定成:在绘制(1)和(2)的面时,让模板缓冲区加一;而在绘制(3)的面时,让 模板缓冲区减一。这样一来,在画完(1)~(3)时,那些模板(stencil)值不为0的像素就是阴影了。最后,把所有模板不为0的像素利用alpha blending的方式,使其亮度降低,就可以达到绘制阴影的效果。
上面的例子是用一个三角面。对于比较复杂的物体,其原理还是一样的。当物体是由许多三角面组成时,可以把所有面对光源的三角面都进行上面的动作,就可以产生阴影。不过,这样有个缺点:因为很多三角面的边是接在一起的,所以这样做会十分浪费时间。要提高效率其实也很容易。在绘制“阴影锥”的时候,若有一个边是被两个三角面所共享,那就表示这是一个“内部”的边,在绘制“阴影锥”的时候,就可以不用去画这个边。这样就可以省下不少的时间。
这个方法适用于非常复杂的物体。不过,它还是可能会遇上一些问题。一个情形是,如果观察者在“阴影锥”的内部,会发生一些麻烦的情形。不过,对大部分的情形来说,只要将模板缓冲区(stencil buffer)设定成“减到0就停止”,即0 - 1 = 0,就可以解决。当然这无法解决所有的问题,不过通常已经够好了。另外,如果物体不是 convex(即“凸”的),那可能会出现“射线重复进入阴影锥”的情形。这种情形并不会有问题,不过模板缓冲区就需要比较多bit才不会出错。一般来说,4 bits就已经可以处理绝大多数的物体的。
下面的画面是体积阴影(Volumetric Shadow)的结果,是由DirectX 8 SDK中的一个示范程序所产生的。这个程序的结构并不复杂,所以有兴趣的话,可以自行参考它的原始码。
这个方法比平面阴影(Planar shadow)更能适用于不同的场景。不过,它当然也有缺点。最主要的缺点是在于它的复杂度。要做出有效率的“阴影锥”,需要对物体做相当麻烦的处理,基本上就是要找出物体在某个方向的“外缘”(即silhouette)。虽然这并不太难做,但是还是需要花费相当的CPU时间去处理。另外,为所有的物体绘制出其“阴影锥”,需要相当大量的填充率(fill rate)和内存频宽。若是延后渲染(deferred renderer),例如图素渲染(tile renderer)则影响不会这么大,特别是图素渲染可以支持一些特别的功能,来加速体积阴影(Volumetric Shadow)的动作。
基本上,体积阴影(Volumetric Shadow)的效果,一般来说都不错。最主要的缺点则是在效率方面,特别是当物体的复杂度和数量增加时,CPU需要的工作量会大增,是较为不理想的。后面会再介绍一些速度更快的方法。
|