转载自:http://www.cnblogs.com/liangliangh/p/4165228.html
实验平台:Win7,VS2010
先上结果截图:
本文是我前一篇博客:OpenGL阴影,Shadow Mapping(附源程序)的下篇,描述两个最常用的阴影技术中的第二个,Shadow Volumes 方法。将从基本原理出发,首先讲解 Zpass 方法,然后是 Zfail 方法(比较实际的方法),最后对 Shadow Mapping 和 Shadow Volumes 方法做简要分析对比。
Shadow Volumes 需要网格的连接信息,本文使用 VCGlib 库 构造拓扑信息及读写网格文件,为了清晰,将 VCGlib 使用的简单总结作为附录,附于文章的最后。
1. 数学原理
关于阴影的定义,请见我的前一篇博客(文献[1])。Shadow Mapping 将空间各个方向上离光源最近点的距离编码成深度纹理。Shadow Volumes 采用一种不同的方法,它直接构造光源被物体(投射阴影的物体,Shadow caster)遮挡的空间的边界,即落在这个边界内的任何点都处于阴影中,反之被光源照亮,如下图所示(使用Blender制作,另见文献[3]PPT第10页):
遮挡空间边界所包围的空间即为 Shadow Volume (阴影体积),构造 Shadow Volume 并不困难,对上图中的三角形(设顶点为 A,B,C)只需要从光源点到三角形顶点做连线并延伸出去到足够远(设 A,B,C 延伸到点 D,E,F),并用这些多边形构成封闭体积:面ABC、面ADEB、面BEFC、面CFDA、面EDF,共5个面,注意顶点字母的顺序已经考虑了顶点环绕方向向外(右手法则)。
那如何判断一个点是否位于 Shadow Volume 内部呢? Shadow Volumes 采用一种间接方法:从一个位于所有 Shadow Volume 外的点出发作射线,从 0 开始计数,每穿入一个 Shadow Volume +1,每穿出一个 Shadow Volume -1,这样到达点 P 时,如果计数为 0 说明位于阴影体积外,大于 0 说明在一层或多层 Shadow Volume 内部。原理是,每个 Shadow Volume 都是封闭的,如果点 P 位于所有 Shadow Volume 外,则穿入和穿出必成对出现,有一种极端情况:射线与一个 Shadow Volume 相切于棱边上,这时射线与 Shadow Volume 表面只有 1 个交点而不是通常的 2 个交点(Shadow Volume 为凸时),好在,这里说的几何原理的实际实现使用光栅化进行离散化,在离散化空间中,这种极端情况并不存在(这和光栅化特性有关,如 "watertight" rasterization 见文献[3])。这个原理如下图所示(摘自文献[3]PPT第18页,二维示意):
这个计数的起点其实就是摄像机所在点,计数的任务可以由图形硬件的 Stencil Buffer (模板缓冲)机制提供,可以看到,这里要求摄像机位于阴影之外。
2. Zpass 方法
直接实现第1节的数学原理的方法即为 Zpass 方法。实现 Zpass 需要完成两方面工作:构造 Shadow Volume 、利用 Stencil Buffer 的功能实现计数。我们先来看最简单的情况,场景中只有两个三角形和一个地板,如下图(看到阴影对判断空间位置的重要性):
场景代码如下:
// 世界,四边形地板 void draw_world() { glStaff::xyz_frame(2, 2, 2, false); glBegin(GL_POLYGON); glNormal3f(0, 1, 0); glVertex3f(-5, 0,-5); glVertex3f(-5, 0, 5); glVertex3f(5, 0, 5); glVertex3f(5, 0,-5); glEnd(); } glm::vec3 tri1[3] = { glm::vec3(0, 3, 0), glm::vec3( 0, 3, 2), glm::vec3(2, 3, 0) }; glm::vec3 tri2[3] = { glm::vec3(1, 2,-1), glm::vec3(-1, 2,-1), glm::vec3(1, 2, 1) }; // 模型,两个三角形 void draw_model() { GLfloat _ca[4], _cd[4]; glGetMaterialfv(GL_FRONT, GL_AMBIENT, _ca); glGetMaterialfv(GL_FRONT, GL_DIFFUSE, _cd); GLfloat c[4]; glBegin(GL_TRIANGLES); c[0]=1; c[1]=0; c[2]=0; c[3]=1; glMaterialfv(GL_FRONT, GL_AMBIENT_AND_DIFFUSE, c); glNormal3fv(&glm::normalize(glm::cross(tri1[1]-tri1[0], tri1[2]-tri1[0]))[0]); for(int i=0; i<3; ++i) glVertex3fv(&tri1[i][0]); // tri1,红色 c[0]=0; c[1]=1; c[2]=0; c[3]=1; glMaterialfv(GL_FRONT, GL_AMBIENT_AND_DIFFUSE, c); glNormal3fv(&glm::normalize(glm::cross(tri2[1]-tri2[0], tri2[2]-tri2[0]))[0]); for(int i=0; i<3; ++i) glVertex3fv(&tri2[i][0]); // tri2,绿色 glEnd(); glMaterialfv(GL_FRONT, GL_AMBIENT, _ca); glMaterialfv(GL_FRONT, GL_DIFFUSE, _cd); }
构造 Shadow Volume 代码如下(light_pos 为光源位置,位置式光源):
static float d_far = 10; // 构造、绘制 Shadow Volume,仅考虑位置光源 void draw_model_volumes() {for(int t=0; t<2; ++t){ glm::vec3* tri = t==0 ? tri1 : tri2; // tri1 or tri2 glm::vec3 tri_far[3]; for(int i=0; i<3; ++i){ tri_far[i] = tri[i] + glm::normalize(tri[i]-glm::vec3(light_pos))*d_far; } for(int i=0; i<3; ++i){ glBegin(GL_POLYGON); // 三个边挤出(extrude)的四边形 glVertex3fv(&tri[i][0]); glVertex3fv(&tri_far[i][0]); glVertex3fv(&tri_far[(i+1)%3][0]); glVertex3fv(&tri[(i+1)%3][0]); glEnd(); } glBegin(GL_TRIANGLES); // 顶部(near cap),原三角形,对 Zpass 来说可选 for(int i=0; i<3; ++i) glVertex3fv(&tri[i][0]); glEnd(); glBegin(GL_TRIANGLES); // 底部(far cap),挤出三角形,对 Zpass 来说可选 for(int i=0; i<3; ++i) glVertex3fv(&tri_far[2-i][0]); glEnd(); } }
构造的 Shadow Volume 如下图所示:
Stencil Buffer 实现计数代码:
// ------------------------------------------ 清除缓冲区,包括模板缓冲 glClear(GL_COLOR_BUFFER_BIT | GL_DEPTH_BUFFER_BIT | GL_STENCIL_BUFFER_BIT); // ------------------------------------------ 第1遍,渲染环境光,深度值 // 关闭光源,打开环境光 GLboolean _li0 = glIsEnabled(GL_LIGHT0); if(_li0) glDisable(GL_LIGHT0); glMatrixMode(GL_MODELVIEW); glLoadMatrixf(&mat_view[0][0]); draw_world(); glMultMatrixf(&mat_model[0][0]); draw_model(); if(_li0) glEnable(GL_LIGHT0); // ------------------------------------------ 第2遍,渲染模板值 // 不需要光照,不更新颜色和深度缓冲 GLboolean _li = glIsEnabled(GL_LIGHTING); if(_li) glDisable(GL_LIGHTING); glColorMask(GL_FALSE, GL_FALSE, GL_FALSE, GL_FALSE); glDepthMask(GL_FALSE); glStencilMask(~0); glEnable(GL_CULL_FACE); glEnable(GL_STENCIL_TEST); glStencilFunc(GL_ALWAYS, 0, ~0); // 剔除背面留下正面,穿入,模板值 加 1 glCullFace(GL_BACK); glStencilOp(GL_KEEP, GL_KEEP, GL_INCR); glMatrixMode(GL_MODELVIEW);glLoadMatrixf(&mat_view[0][0]);glMultMatrixf(&mat_model[0][0]); draw_model_volumes(); // 剔除正面留下背面,穿出,模板值 减 1 glCullFace(GL_FRONT); glStencilOp(GL_KEEP, GL_KEEP, GL_DECR); glMatrixMode(GL_MODELVIEW);glLoadMatrixf(&mat_view[0][0]);glMultMatrixf(&mat_model[0][0]); draw_model_volumes(); // 恢复状态 if(_li) glEnable(GL_LIGHTING); glColorMask(GL_TRUE, GL_TRUE, GL_TRUE, GL_TRUE); glDepthMask(GL_TRUE); glStencilMask(~0); glDisable(GL_CULL_FACE); glDisable(GL_STENCIL_TEST); glStencilOp(GL_KEEP,GL_KEEP,GL_KEEP); // ------------------------------------------ 第3遍,渲染光源光照,依据模板值判断阴影 // 关闭环境光,打开光源 GLfloat _lia[4]; glGetFloatv(GL_LIGHT_MODEL_AMBIENT, _lia); GLfloat ca[4]={0}; glLightModelfv(GL_LIGHT_MODEL_AMBIENT, ca); // 模板测试为,等于0通过, 深度测试为,相等通过,颜色混合为直接累加 glEnable(GL_STENCIL_TEST); glStencilFunc(GL_EQUAL, 0, ~0); glDepthFunc(GL_EQUAL); glBlendFunc(GL_ONE, GL_ONE); glMatrixMode(GL_MODELVIEW); glLoadMatrixf(&mat_view[0][0]); glLightfv(GL_LIGHT0, GL_POSITION, &light_pos[0]); // 位置式光源 draw_world(); glMultMatrixf(&mat_model[0][0]); draw_model(); // 恢复状态 glLightModelfv(GL_LIGHT_MODEL_AMBIENT, _lia); glDisable(GL_STENCIL_TEST); glStencilFunc(GL_ALWAYS, 0, ~0); glDepthFunc(GL_LESS); glBlendFunc(GL_SRC_ALPHA, GL_ONE_MINUS_SRC_ALPHA); // 在光源处绘制一个黄色的球 glMatrixMode(GL_MODELVIEW); glLoadMatrixf(&mat_view[0][0]); dlight(0.05f);
这里要用到 Stencil Buffer,要在创建窗口时(即创建 OpenGL Context)启用 Stencil Buffer,GLFW 默认就启用了(8-bit)。第1遍渲染时,仅开启环境光,渲染场景后,颜色缓冲是环境光贡献,深度缓冲是离摄像机最近的片断的深度。第2遍渲染,只更新 Stencil Buffer,因为深度缓冲已经保存了最近片断深度,深度测试 GL_LESS 通过的片断都是未经遮挡的 Shadow Volume 部分,如果看到了正面,模板值+1,背面-1,注意正背面是依据顶点环绕方向确定的(光栅化的任务),因为是深度测试通过后计数故称作 Zpass 。第3遍渲染,因为模板值为0的点为光照,否则为阴影,设置模板测试为和0比较相等时通过,并设置混合函数为直接累加(和 Shadow Mapping 类似)。
模板缓冲区的值(全黑为模板值为0,每个颜色梯度模板值变化1),以及最终渲染结果如下图所示:
读取模板缓冲区使用 glReadPixels(ox,oy, width,height, GL_STENCIL_INDEX, GL_UNSIGNED_BYTE, data),上面所有代码见所附程序中的 volumes_basic0.cpp。
在讲轮廓边之前,先看下上面代码几个需要改进的地方:
- 在渲染模板值时,不需要渲染两遍(一遍正面,一遍背面),OpenGL 支持在一遍渲染中对正背面使用不同的模板更新操作,使用 glStencilOpSeparate() 函数;
- 为防止模板缓冲区溢出或减小为负数(默认模板缓冲为8-bit),可以使用绕回模式(wrap,255加1变成0,0减1变成255);
- 可以利用齐次坐标特性将 Shadow Volume 延伸到无穷远,对于 Zpass 来说,不需要对 Shadow Volume 进行封口(cap),底部不需要封口因为 Zpass 只关心未被遮挡(Zpass)部分,顶部不需要封口因为它正好被原三角形遮挡(不能通过 Z 测试)。
- 上面代码没有考虑光源为平行光源的情况(光源位置坐标w分量为0),也没有考虑三角形背对光源的情况,背对时 Shadow Volume 的顶点环绕方向将向内部(如果所有 Shadow Volume 都向内部也没关系,问题是向内向外不一致将导致计数错误),是面对还是背对光源可以用光源到三角形上任意一点的连线向量和三角形法向量的内积的正负号判断;
- 上面代码未考虑模型变换矩阵的变换(鼠标左键拖动物体,阴影将不再正确),因为模型变换同样施加到 Shadow Volume 上,只需对光源位置进行反变换。
上面代码的 “第2遍,渲染模板值” 的绘制部分等价代码如下:
// 不需要光照,不更新颜色和深度缓冲 // ... // 正面加1,背面减1 glStencilOpSeparate(GL_FRONT, GL_KEEP, GL_KEEP, GL_INCR_WRAP); // 改进后 glStencilOpSeparate(GL_BACK, GL_KEEP, GL_KEEP, GL_DECR_WRAP); glMatrixMode(GL_MODELVIEW); glLoadMatrixf(&mat_view[0][0]); glMultMatrixf(&mat_model[0][0]); draw_model_volumes(glm::affineInverse(mat_model)*light_pos); // 恢复状态 // ...
将三角形边挤出到无穷远的代码如下(考虑三角形是否背对光源):
// 构造、绘制 Shadow Volume,挤出(extrude)到无穷远 void draw_model_volumes(glm::vec4& lpos) { for(int t=0; t<2; ++t){ glm::vec3* tri = t==0 ? tri1 : tri2; // tri1 or tri2 glm::vec4 tri_far[3]; for(int i=0; i<3; ++i){ tri_far[i] = glm::vec4( tri[i].x*lpos.w-lpos.x, tri[i].y*lpos.w-lpos.y, tri[i].z*lpos.w-lpos.z, 0); } glm::vec3 n = glm::cross(tri[1]-tri[0], tri[2]-tri[0]); glm::vec3 l0 = lpos.w==0 ? glm::vec3(lpos) : glm::vec3(lpos)/lpos.w-tri[0]; int m = glm::dot(n,l0)>=0 ? 1 : -1; // 是否反转四边形环绕方向 for(int i=0; i<3; ++i){ glBegin(GL_POLYGON); // 三个边挤出(extrude)的四边形 glVertex3fv(&tri[i][0]); glVertex4fv(&tri_far[i][0]); glVertex4fv(&tri_far[(i+m+3)%3][0]); glVertex3fv(&tri[(i+m+3)%3][0]); glEnd(); } } }
位置光源和平行光源的对比如下:
这部分代码见所附程序中的 volumes_basic1.cpp。
到目前为止,我们的场景过于简单,现在考虑复杂的网格,这里仅考虑质量好的三角网格(封闭,任意点为二维流形,manifold,即每个边接两个面,面之间无交叉)。我们使用 VCGlib,关于用 VCGlib 读写网格文件、构造顶点边面连接信息、法向量计算、平滑等处理请见本文最后的附录。最简单的将上述方法扩展到复杂网格的方法是:对每个三角形都构造 Shadow Volume ,对一个 mesh 的每个三角形构造 Shadow Volume 的代码如下(读入的 PLY 网格文件已经预先用 Blender 和 MeshLab 处理为 manifold 三角网格,关于 VCGlib 的使用见最后的附录):
// 构造、绘制 Shadow Volume void draw_model_volumes(GLMesh& mesh, glm::vec4& lpos) { assert(mesh.FN()==mesh.face.size()); // vcg::tri::Allocator<>::CompactFace/Edge/VertexVector() for(int i=0; i<mesh.FN(); ++i){ // for each face (i.e. triangle) GLMesh::FaceType& f = mesh.face[i]; glm::vec4 tri_far[3]; // 挤出的3个点,到无穷远 for(int i=0; i<3; ++i){ tri_far[i] = glm::vec4( f.V(i)->P().X()*lpos.w-lpos.x, f.V(i)->P().Y()*lpos.w-lpos.y, f.V(i)->P().Z()*lpos.w-lpos.z, 0 ); } glm::vec3 n( vcg_to_glm(f.N()) ); glm::vec3 l0 = lpos.w==0 ? glm::vec3(lpos) : glm::vec3(lpos)/lpos.w - vcg_to_glm(f.V(0)->P()); int m = glm::dot(n,l0)>=0 ? 1 : -1; // 是否反转四边形环绕方向 for(int i=0; i<3; ++i){ glBegin(GL_POLYGON); // 三个边挤出(extrude)的四边形 glVertex3fv(&f.V(i)->P()[0]); glVertex4fv(&tri_far[i][0]); glVertex4fv(&tri_far[(i+m+3)%3][0]); glVertex3fv(&f.V((i+m+3)%3)->P()[0]); glEnd(); } } }
程序结果如下:左上为最终结果;右上为对应 Stencil 值(颜色梯度表示变化 1,可以想见 Stencil 的更新非常频繁,但因为都是+1和-1操作,所以累积值并不一定很大);下面是 Shadow Volume 的显示,可以看到,因为每个三角形都构造 Shadow Volume,Shadow Volume 的线条非常密。渲染时间约 180ms:
并不需要对所有边都进行挤出(extrude),只需要对某些被称作 “轮廓边” 的边(准确的说是 “可能轮廓边”)进行挤出就可以构造出合格的 Shadow Volume,“可能轮廓边” 是指其所连接的两个面(对 manifold 网格每个边必连接两个面)一个面对光源另一个背对光源。面对还是背对光源可以用三角形面法向量和光源到三角形上任一点连线向量的内积的正负号判断,优化后的,只对 “可能轮廓边” 进行挤出的代码如下,注意和上面不同,此时对边进行遍历,而不再是三角形,注意要保证四边形环绕方向为向外:
// 构造、绘制 Shadow Volume void draw_model_volumes(GLMesh& mesh, glm::vec4& lpos) { assert(mesh.EN()==mesh.edge.size()); for(int i=0; i<mesh.EN(); ++i){ GLMesh::EdgeType& e = mesh.edge[i]; GLMesh::FaceType* fa = e.EFp(); // fa,fb 为边 e 邻接的两个面 GLMesh::FaceType* fb = fa->FFp(e.EFi()); glm::vec3 l0 = lpos.w==0 ? glm::vec3(lpos) : glm::vec3(lpos)/lpos.w-vcg_to_glm(e.V(0)->P()); int sa = glm::dot(l0, vcg_to_glm(fa->N()))>=0 ? 1 : -1; // 面对还是背对光源 int sb = glm::dot(l0, vcg_to_glm(fb->N()))>=0 ? 1 : -1; if( sa*sb < 0 ){ // 一个面面对,一个面背对光源,“可能轮廓边” GLMesh::VertexType* va = fa->V(e.EFi()); GLMesh::VertexType* vb = fa->V((e.EFi()+1)%3); if(sa<0) std::swap(va, vb); // 确定顶点顺序,是最终四边形环绕方向向外 glm::vec4 e_far[2]; // 挤出的2个点,到无穷远 e_far[0] = glm::vec4( va->P().X()*lpos.w-lpos.x, va->P().Y()*lpos.w-lpos.y, va->P().Z()*lpos.w-lpos.z, 0 ); e_far[1] = glm::vec4( vb->P().X()*lpos.w-lpos.x, vb->P().Y()*lpos.w-lpos.y, vb->P().Z()*lpos.w-lpos.z, 0 ); glBegin(GL_POLYGON); // 边挤出(extrude)的四边形 glVertex3fv(&va->P()[0]); glVertex4fv(&e_far[0][0]); glVertex4fv(&e_far[1][0]); glVertex3fv(&vb->P()[0]); glEnd(); } } }
再看结果,对比上面的图,现在 Shadow Volume 的边稀疏多了,且渲染时间减少到了 45ms:
Zpass 方法的第一个问题是:当摄像机位于阴影中时,光照处 Stencil 值将不再为0,见下面的例子:
这个问题可以通过检测摄像机是否位于阴影中,并在摄像机位于阴影中时对 Stencil 值进行偏移进行解决,但这需要额外开销,后面用 Zfail 方法避免这一问题。和想象中的不同,摄像机并不是 “要么在阴影中,要么在阴影外” ,它有可能 “一半位于阴影中,一半位于阴影外”,这其实是近裁剪面的作用:
近裁剪面问题是 Zpass 方法的第二个问题,详见文献[3]。这小节代码见所附程序中的 volumes_zpass.cpp。
3. Zfail 方法,实际方法
Zpass 失败的原因,以及 Zfail 方法的原理如下图所示(摘自文献[4],a. Zpass 原理,b. Zpass 失败例子,c. Zfail 方法原理):
Zpass 从摄像机发出射线到无穷远并计数,而 Zfail 正好相反,它从摄像机射线的穷远处到摄像机计数,当 Shadow Volume 封闭时且摄像机位于阴影外时,Zpass 和 Zfail 是等价的,因为:一条射线和封闭的 Shadow Volume 总是交于两个点(凸时,非凸时总是偶数个交点,前面已经分析了,极端情况在离散空间并不存在),若点 P 在某 Shadow Volume 中,Zpass 和 Zfail 对该 Shadow Volume 计数结果都为+1,若 P 在该 Shadow Volume 外,则 Zpass 和 Zfail 计数结果为 “0 和 +1-1” 或者 “+1-1 和 0”,此两种情况都是等价的说明了 Zfail 的正确性。
Zfail 较 Zpass 有更好的特性:
- 在摄像机位于阴影中时也能产生正确结果;
- 不受近裁剪面影响,因为它只关心被物体遮挡的部分。
但其也有缺点需要克服:
- 受远裁剪面影响,可以按照文献[3]将摄像机远裁剪面设置于无穷远处(精度损失并不大),也可以使用 glEnable(GL_DEPTH_CLAMP);
- Zpass 不需要对Shadow Volume 封口(cap),而 Zfail 需要,并且需要对近端和远端都进行 cap,对远端进行 cap 是因为 Zfail 需要Shadow Volume 被遮挡的部分(很可能是远端),对近端进行 cap 是因为 Shadow Volume 被遮挡的部分可能是近端(摄像机从 P 点背后看物体);
- Zfail 较 Zpass 通常产生更多的 Shadow Volume 片断,即需要更多的像素填充,这是因为 Shadow Volume 被遮挡的部分通常比未被遮挡的部分面积大。
实现 Zfail 计数是直接的:
- 将上面代码中通过 Depth Test 更新 Stencil Buffer 改为未通过时更新(故名 Zfail)。
对网格构造 Shadow Volume 的代码和之前稍有区别,需要 cap:
- 对每个 “可能轮廓边” 进行挤出(extrude)到无穷远,需要四边形顶点环绕方向向外;
- 对所有面对光源的三角形面,直接绘制,对所有背对光源的三角形面,将其顶点挤出到无穷远并绘制。
Zfail 代码见所附程序中的 Volumes_zfail.cpp。程序结果如下图所示,现在摄像机位于阴影中也不会有问题了,但渲染帧率也从 23fps 降到了 18 fps:
“实际方法” 一词出自文献[3],这篇 2002 年的文章通过使用 Zfail 并将摄像机远裁剪面设置于无穷远处,改进了 Shadow Volumes 方法,更值得一提的是,它提到的 wrap 方式 Stencil 值更新、Depth Clamping、Two-Sided Stencil Testing 后来都已经是 OpenGL 标准了,这使得我们可以以更简洁的方式实现 Shadow Volumes。
多个光源的处理和 Shadow Mapping 类似,下面是结果,代码见所附程序中的 volumes_multi_lights.cpp,关于平行光,因为已经利用齐次坐标特性考虑了光源 w 坐标,只需将光源坐标 w 分量设为 0 即可实现平行光:
4. 进一步研究
低质量网格(non-manifold 网格) Shadow Volume 构造见文献[4],另外文献[4]给出了用几何着色器构造 Shadow Volume 的代码,通过裁剪 Shadow Volume 或交替使用 Zpass/Zfail 减小对像素填充率(需要光栅化的多边形面积)消耗的性能优化方法见文献[1]的文献[1]及文献[4],基于 Shadow Volumes 的 Soft Shadow 方法见文献[1]的文献[1]。
5. Shadow Volumes VS. Shadow Mapping
先来看同一个场景用 Shadow Volumes 和 Shadow Mapping 两种方法渲染的对比图(我的机器配置:Pentium Dual-Core 2.6 GHz,4 GB DDR2,GT240 1GB GDDR5 OpenGL 3.3),代码见所附程序中的 comparison_volumes_mapping.cpp。
第一个场景,2000 个正方体,每个正方体有 8 个顶点、12 个三角形,下面依次是无阴影、Shadow Volumes、Shadow Mapping 渲染结果,渲染时间和帧率在图中左上角和左下角(帧率结果包含全部CPU时间和GPU时间,更具综合性),Shadow Volumes 使用本文最后的 Zfail 方法,Shadow Mapping 使用 2048x2048 阴影图:
第二个场景,50 个猴头模型,每个猴头模型有 28.9K 个顶点、57.8K 个三角形,程序结果如下:
对上图作放大观察,Shadow Volumes 和 Shadow Mapping 方法的结果如下,可以看到 Shadow Volumes 放大后毫无锯齿,而 Shadow Mapping 方法已经有轻微锯齿:
需要指出的是,这里实现的 Shadow Volumes 和 Shadow Mapping 可以进一步优化,如使用顶点列表、使用显示列表、优化几何数据结构、如果可能重用阴影图或阴影体积等等,所以上面的性能比较结果并不很准确,这里只想给出一个参考。
对 Shadow Volumes 和 Shadow Mapping 作如下分析对比:
- 运行速度方面,基本的 Shadow Volumes 需要三遍渲染:环境光和深度值、Shadow Volume 和 Stencil 值、光源光,基本的 Shadow Mapping 需要三遍渲染:摄像机视角深度图、环境光和深度值、光源光,一般而言,Shadow Mapping 更快,这是因为构造和光栅化 Shadow Volume 比较耗时,粗略估算下 Shadow Mapping 比直接渲染(没有阴影)慢三倍左右(如果环境光不单独渲染则是二倍);
- 渲染效果方面,Shadow Volumes 实现的是几何上精确的阴影,不存在锯齿问题,Shadow Mapping 存在锯齿问题,这可以通过增大深度图尺寸缓解,但并不能根本解决,Shadow Mapping 锯齿问题的根本原因是需要两个不同视角:光源视角和摄像机视角,两个视角下多边形的斜率以及多边形投影后的大小差异是产生锯齿的原因(无限放大去观察阴影的边沿,需要无限大的阴影图),而这并没有好的解决方法,相比之下 Shadow Volumes 的 Stencil 值渲染是在摄像机视角进行的,另外一般来说,从 Shadow Mapping 产生 Soft Shadow 相对容易;
- 鲁棒性或通用性方面,Shadow Volumes 需要良好的几何数据结构,即使算法能够处理非封闭网格,也要求网格的拓扑信息,从而优化 Shadow Volume 的构造,这使得使用 glutTeaport() 不再可能,因为几何数据被封装在了函数内部,几何计算发生很小错误时,Shadow Volumes 可能产生很明显的错误结果,相比之下 Shadow Mapping 对几何数据的要求则小的多,但 Shadow Mapping 也存在问题:需要剔除正面或使用深度偏移值以避免斑纹,而偏移值大小不好确定,需要特殊处理大视角光源,尤其全方向点光源,也需要特殊处理平行光(简单);
- 研究和工业使用方面,Shadow Mapping 的研究和基于其的阴影的研究相对较多,Shadow Mapping 在工业中的使用也相对较多,这可能是因为其算法实现较为简单。
下载链接:程序集成了上一博客 Shadow Mapping 的源代码,并支持64位,好多库是纯头文件,为了加快编译速度,使用了预编译头,请见代码中注释,工程的配置见程序文件夹下 “说明.txt”。
链接: http://pan.baidu.com/s/1i3oXHSL 密码: agx5
(左Ctrl+鼠标左键拖拽改变视角,鼠标滚轮缩放)