在Per-Fragment Operations & Tests阶段,有一个步骤是模版测试(Stencil Test)。依靠这一步骤,不仅可以实现渲染模型的包围框这样的实用功能,还能创造出一种渲染阴影的算法,即Shadow Volume算法。
用Shadow Mapping方法得到的阴影,在贴近观察时,会看到细微的锯齿。这是因为深度缓存受到分辨率的限制,不可能完全精确地描述贴近观察时的各个Fragment。但Shadow Volume方法得到的阴影是没有这样的锯齿问题的,如下图所示:
对比(左)Shadow Mapping的阴影有锯齿(右)Shadow Volume的阴影更平滑
如图所示,左侧的Shadow Mapping方法得到的阴影在犄角、舌头和下巴部分可以看到比较明显的锯齿,而右侧的Shadow Volume方法得到的阴影则十分平滑。这是由Shadow Volume的实现机制决定的。其机制概括起来就是,根据光源位置(或方向)和模型位置,动态地生成一个不规则的包围盒,将阴影部分包裹起来,保证包围盒内部的Fragment不参与光照计算。上图的包围盒如下图所示:
从6个视角观察包围盒
注意,这个包围盒是实时动态生成的,它会随着光源位置(或方向)的变化而变化。而且,这个包围盒是延伸到无限远的。这样才能正确地渲染出阴影。
那么有2个主要问题:首先,如何动态生成这个包围盒,而且能够覆盖无限远的范围;然后如何根据包围盒判断Fragment是否参与光照计算。
为便于理解问题,这里假设探讨的模型都是由三角形网格拼接组成的。例如对于中国龙模型和Cube模型,其三角形网格结构如下图所示:
三角形网格组成的模型
可以看到,中国龙模型是由非常多的三角形网格拼接而成的,这利于观察光照效果的真实感。Cube模型则仅仅由12个三角形拼接而成,这利于检测程序的正确性。请读者在随书代码中找到任意一个可以渲染中国龙模型的项目,为其添加PolygonModeSwitch开关,近距离观察中国龙模型的三角形网格。
要找到一个模型在光源照射下的包围盒,首先要找到在光源照射下的外围轮廓(Outline)。轮廓的一侧都是能被光源照射到的三角形,另一侧都是不能被光源照射到的三角形(即处于阴影中),例如下图所示:
(左)模型+轮廓线(中)模型(右)轮廓线
在此场景中,在中国龙模型的头部方向上有一个聚光灯光源(下方的Cube模型也是)。图左的白线勾勒出此时的轮廓线在模型上的位置,图中为模型本身,图右为隐藏了模型的轮廓线全貌。
轮廓是由一条条线段组成的。对于组成线段的每个顶点,沿着从光源位置到顶点的方向,无限地延伸出去,就是要找的包围盒的侧面。然后再把轮廓中朝向光源的一侧加上,再把无限远处封口,就得到了一个完整的包围盒。完整的包围盒如下图所示:
从远到近观察完整的包围盒
如图所示,完整的包围盒从模型的位置,无限地延伸到了场景的边缘。图左为地面遮挡了一部分包围盒的情形,图右为隐藏了地面后显示出的更完整的包围盒。可以看到包围盒在最远处仍然呈现出模型的轮廓的形状,且包围盒的近端和远端保持着对应关系。
注意,包围盒是一个完全封闭的盒子,其法线全部指向外侧。这是在构造包围盒时特意设计的。这样才能在后续的判断过程中找到阴影。
动态生成包围盒
第一个问题,如何判断哪个顶点是在轮廓线上的呢?观察下图:
光线L照射到2个三角形上
如图所示,两个三角形的交界处,是一条共享的线段AB。三角形ABC正面向光源L,能够被照射到,另一个三角形ABD则背面向光源L,即处于阴影中。那么这条共享的线段AB就应当成为轮廓线的一部分。如果两个三角形同时正面向光源或同时背面向光源,那么它们之间的共享线段就不需要算到轮廓线里。要判断一个三角形是否正面朝向光源,只需将光源的方向向量L与三角形面的法线向量N做dot乘法,如果结果为负数,说明是正面朝向光源;如果结果为正数,说明是反面朝向光源。
注意,这里使用的是三角形面的法线向量。Vertex Shader只能处理单个的顶点。要处理三角形面这样的对象,就要使用Geometry Shader。为了得知一个三角形与周围哪些三角形有共享边,这里需要使用GL_TRIANGLES_ADJACENCY模式渲染的模型。这样的模型有一个特点,即每个三角形都包含了其周边三角形的信息,如下图所示:
GL_TRIANGLES_ADJACENCY模式的图元
此图展示了一个三角形网格模型的一部分(由6个顶点组成的4个三角形)。其中三角形ACE是Geometry Shader要处理的一个三角形,而三角形ABC、CDE和EFA是与三角形ACE有共享边的三个三角形。这三个三角形被称为三角形ACE的邻接三角形。向Geometry Shader依次传入这6个顶点,即可找到哪些边构成了模型的轮廓线。一般的,模型数据中是不包含邻接信息的,这需要在加载模型后额外计算。
为实现Shadow Volume算法,找轮廓线的任务就是在Geometry Shader中完成的,其代码如下:
注意,代码中的lightDir变量指的是从顶点到光源位置的向量,而图示 7‑13中的光源方向向量L是从光源到顶点的向量。两者是相反的。因此在代码中正面朝向光源的三角形面的法向量N与lightDir的dot结果为正数。
第二个问题,有了轮廓线,将轮廓线的每一条线段都延伸出去,分别形成一个四边形,就构成了包围盒的侧面。所有正面朝向光源的三角形,就构成了包围盒的近顶。沿着包围盒侧面的方向,把各个近顶面分别推向无限远处,并且翻转朝向,就构成了包围盒的远底。只需在生成轮廓线的代码基础上稍作修改,即可得到动态生成包围盒的Geometry Shader,代码如下:
上文提到,包围盒的远底面是位于无限远处的。这是数学意义上的描述。具体到OpenGL,其实并不需要描述一个无限远的顶点,只需要找到此顶点投影到近裁剪面上的投影位置即可。简单来说,只需将从光源到轮廓线上的顶点的向量作为xyz坐标,以0为w坐标,即可得到此投影位置。
从数学上理解此问题需要一些晦涩的推导过程,这里从OpenGL Pipeline的角度来理解即可。一般的,OpenGL描述顶点位置都是用vec4(x, y, z, 1)。在Pipeline从Clip Space到NDC Space的变换过程中,会将所有顶点的xyz坐标都除以w,所以vec4(x, y, z, w)、vec4(x/w, y/w, z/w, 1)和vec4(nx, ny, nz, nw)描述的都是同一个位置。试想,如果保持xyz的值不变,而不断减小w的值,那么这个坐标描述的位置会越来越远;当w减小到0时,这个坐标描述的就是一个无限远的位置。那么,沿着光源L到顶点的方向,走到无限远的那个位置,只能是vec4(LightDir, 0)。
使用Stencil Buffer和Depth Buffer实现阴影的渲染的过程如下伪代码所示:
1 void ShadowVolume(Scene scene, ..) 2 { 3 // Render depth info into depth buffer. 4 RenderDepthInfo(scene, ..); 5 6 glEnable(GL_STENCIL_TEST); // enable stencil test. 7 glClear(GL_STENCIL_BUFFER_BIT); // Clear stencil buffer. 8 // Extrude shadow volume and save shadow info into stencil buffer. 9 { 10 glDepthMask(false); // Disable writing to depth buffer. 11 glColorMask(false, false, false, false); // Disable writing to color buffer. 12 glDisable(GL_CULL_FACE); // Disable culling face. 13 14 // Set up stencil function and operations. 15 glStencilFunc(GL_ALWAYS, 0, 0xFF); 16 glStencilOpSeparate(GL_BACK, GL_KEEP, GL_INCR_WRAP, GL_KEEP); 17 glStencilOpSeparate(GL_FRONT, GL_KEEP, GL_DECR_WRAP, GL_KEEP); 18 19 // Extrude shadow volume. 20 // Shadow info will be saved into stencil buffer automatically 21 // according to `glStencilOp...`. 22 Extrude(scene, ..); 23 24 // Reset OpenGL switches. 25 glEnable(GL_CULL_FACE); 26 glColorMask(true, true, true, true); 27 glDepthMask(true); 28 } 29 // Render the scene under the light with shadow. 30 { 31 // Set up stencil function and operations. 32 glStencilFunc(GL_EQUAL, 0x0, 0xFF); 33 glStencilOp(GL_KEEP, GL_KEEP, GL_KEEP); 34 35 // light the scene up. 36 RenderUnderLight(scene, ..); 37 } 38 glDisable (GL_STENCIL_TEST); // disable stencil test. 39 }
Shadow Volume由3遍渲染完成。
第一遍渲染时,在不考虑阴影的前提下正常渲染场景。此时,Depth Buffer填充了正常的深度信息。这一次渲染的目的是准备好这一深度缓存,渲染的颜色并不重要。因此可以用最简单的Fragment Shader,甚至不使用Fragment Shader。
第二遍渲染前,启用模板测试,并按如下方式设置模板测试的函数和操作:
1 // Always pass stencil test. 2 glStencilFunc(GL_ALWAYS, 0, 0xFF); 3 // If depth test fails for back face, increase value in stencil buffer. 4 glStencilOpSeparate(GL_BACK, GL_KEEP, GL_INCR_WRAP, GL_KEEP); 5 // If depth test fails for front face, decrease value in stencil buffer. 6 glStencilOpSeparate(GL_FRONT, GL_KEEP, GL_DECR_WRAP, GL_KEEP);
这里设置模版测试对于每个像素都是通过的,且通过后将对应像素位置的模板缓存值设置为0。当模板测试完成后,对于包围盒背面的Fragment,如果深度测试失败,那么模版缓存的值加1;对于包围盒正面的Fragment,如果深度测试失败,那么模板缓存的值减1。
这样设置的结果是,位于包围盒内部的模型(或其一部分),包围盒的背面的深度测试会失败,所以此处的模板缓存值加1;包围盒正面的深度测试会成功,所以对模板缓存无影响。比包围盒更靠近Camera的模型(或其一部分),包围盒的正面背面的深度测试都会失败,所以此处的模板缓存值加1又减1,保持为0。比包围盒更远离Camera的模型(或其一部分),包围盒的正面背面的深度测试都会成功,所以此处的模板缓存值保持不变,即为0。而在包围盒涉及不到的位置,模板缓存也保持不变,即为0。
也就是说,只有包围盒内部的模型(或其一部分)对应的模板缓存值是大于0的,其它位置的模板缓存值都保持为0。而包围盒内部的模型(或其一部分)就位于阴影中。所以第二遍渲染的只有包围盒,这样就能区分出阴影部分,如下图所示:
Shadow Volume判断阴影
如图所示,场景中有一个点光源L位于左上角,一个地板(Floor)上方漂浮着一个立方体(Cube)。光源L照射到Cube和Floor上,Cube投射出自己的阴影,这阴影由包围盒描述处出来。图中ABCD都代表Floor上的一点。A点位于包围盒内部,包围盒的背面的深度测试会失败,所以此处的模板缓存值加1;包围盒正面的深度测试会成功,所以对模板缓存无影响。B点比包围盒更靠近Camera,因此此位置上的包围盒的正面背面的深度测试都会失败,所以此处的模板缓存值加1又减1,保持为0。C点比包围盒更远离Camera,包围盒的正面背面的深度测试都会成功,所以此处的模板缓存值保持不变,即为0。D点与包围盒的任何一部分都没有交集,因此不受包围盒影响,模板缓存在此位置的值保持不变,即为0。
包围盒本身是一个模型,但并不存在于原本的场景中,所以在第二次渲染过程中要通过下述代码来避免将其渲染到最终的场景中:
1 glDepthMask(false); // Disable writing to depth buffer. 2 glColorMask(false, false, false, false); // Disable writing to color buffer.
这样就保证了包围盒不改变深度缓存,也不会写入颜色缓存。同时,其他功能仍然能够正常进行。
为了保证包围盒的正面背面都被渲染,需要禁用背面剔除功能:
1 glDisable(GL_CULL_FACE); // Disable culling face.
第三遍渲染前,重新设置模板测试的函数和操作:
1 // Draw only if the corresponding stencil value is zero. 2 glStencilFunc(GL_EQUAL, 0x0, 0xFF); 3 // prevent updating to the stencil buffer. 4 glStencilOp(GL_KEEP, GL_KEEP, GL_KEEP);
此时设置模板测试仅允许模板缓存值为0的位置通过。也就是说,只有在第二次渲染时位于包围盒外部的模型(或其一部分)才会被渲染并可能影响到最后的Framebuffer。此时已经无需(也不应)修改模板缓存的值,所以设置在任何情形下都让模板缓存的值保持不变。
这时,只需按通常的方式用光照模型渲染场景,即可产生即有光照又有阴影的最终效果。
多光源下的阴影
无论Shadow Mapping还是Shadow Volume都可以简单地应用到有多个光源的场景中。类似于多光源下的光照模型Blinn-Phong,只需分别对每个光源执行一遍Shadow Mapping或Shadow Volume,并且用混合功能将各个光源的照射效果叠加即可。其伪代码如下:
1 void MultipleLights(Scene scene, ..) 2 { 3 // render ambient color. 4 5 foreach (var light in scene.Lights) 6 { 7 // preparation. 8 9 glEnable(GL_BLEND); // enable blending. 10 glBlend(GL_ONE, GL_ONE); // add lighting info to previous lights. 11 12 // light the scene up with specified light. 13 RenderUnderLight(scene, light, ..); 14 15 glDisable(GL_BLEND); 16 } 17 }
下图展示了同时用红绿蓝三色光源照射模型的场景:
多光源照射的光和影(左)点光源(中)平行光(右)聚光灯
图中用三个小球描述了光源的位置。对于平行光,小球描述的是光源的方向。
本文介绍了Shadow Volume渲染阴影的方法。Shadow Volume的实现相对复杂,对模型的规范性有一定的要求,但是阴影的分辨率很高。如果将Shadow Mapping类比作位图,那么Shadow Volume可以类比作矢量图。
r Shadow Mapping的思路是什么?
两遍渲染:首先从光源位置渲染场景,得到深度缓存;然后依据深度缓存判断Fragment是否位于阴影中。
r Shadow Volume的思路是什么?
两遍渲染:首先动态生成阴影包围盒,并设置模版缓存;然后依据模版缓存的状态判断Fragment是否位于阴影中。
r 多光源的阴影如何实现?
依次对每个光源运用Shadow Mapping或Shadow Volume算法。
r Shadow Volume最可能的失败原因是什么?
创建OpenGL Render Context时没有指定创建模版缓存。
带着问题实践是学习OpenGL的最快方式。这里给读者提出几个问题,作为抛砖引玉之用。
- 请在Github代码中的Demos\LogicOperation项目中尝试使用各种类型逻辑操作,观察各自的效果。注意,需要将鼠标移动到Cube模型上才能看到逻辑操作的效果。
- 任意选择一个示例项目,或者新建一个项目,尝试使用剪切测试(Scissor Test),观察效果。思考剪切测试能够帮助实现什么功能?
- 关于模版测试的示例项目Demos\StencilTest中,Cube的包围框的宽度随Cube的原理而逐渐减小。请尝试使用Shader来保证包围框的宽度保持不变。