实验平台: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+鼠标左键拖拽改变视角,鼠标滚轮缩放)
参考文献
- OpenGL阴影,Shadow Mapping(附源程序),及其参考文献;
- http://en.wikipedia.org/wiki/Shadow_volume;
- C. Everitt and M. J. Kilgard, "Practical and robust stenciled shadow volumes for hardware-accelerated rendering," arXiv preprint cs/0301002, 2002(进入下载,PPT);
- GPU Gems 3, Chapter 11. Efficient and Robust Shadow Volumes Using Hierarchical Occlusion Culling and Geometry Shaders(网页版)。
*******************************************************************************
附录:VCGlib 库 使用说明
先来看看 VCGlib 能做什么:
- 最基本的,它提供 Mesh(triangular mesh,tetrahedral mesh,三角网格或四面体网格)数据结构的定义,该数据结构支持对 Mesh 数据的快速访问(拓扑信息、空间查询等)以及高效执行网格上算法;
- 在 Mesh 数据结构基础上,实现大量高效的网格算法,如网格修补、平滑、变形、曲率计算、细分、泊松盘采样、等值面计算等;
- IO 支持,读写 PLY、OBJ、STL、3DS、OFF、DXF 等格式网格文件;
- UI 支持,如 OpenGL 网格显示,Trackball 交互等。
VCGlib 的文档很简陋,在线文档并不是很全,可以自己用 Doxygen 从下载的源代码生成 html API 文档,为此只需要(Windows 用户):
- 安装 Doxygen(Doxywizard) 和 Graphviz,将 “Graphviz安装目录Graphviz2.36in” 添加到环境变量Path;
- 将本博客所附程序中的 “OpenGL Shadow\_Libsvcglibdocsdoxyfile-all” 文件拷贝到下载的 VCGlib 文件夹下的 “vcglibdocsDoxygen” 下,这个文件是我配置好的 Doxygen 配置文件;
- 用 Doxywizard 打开上一步拷贝的文件,点击 “Run” 选项卡下的 “Run doxygen” 按钮,生成的 API 文档将位于 “vcglibdocsDoxygenhtml-all” 下(html-all 文件夹将有 129M 大小)。
VCGlib 是纯头文件库,要安装只需将下载 VCGlib 库目录添加到程序的头文件包含路径(有些IO函数如读写PLY需要包含相应.cpp文件)。
后面按照如下步骤讲解:
- 定义 Mesh 类型;
- 访问及指定 Mesh 的顶点、三角形(对三角网格,如果是四面体网格则是四面体,这里默认只讲三角网格)等数据;
- IO,读写 PLY、OBJ 等网格文件;
- 构造网格的拓扑信息,如顶点或三角形面法向量、三角形相邻三角形、边连接的三角形等信息;
- 网格处理,如法向量平滑、网格修补等。
定义 Mesh 类型的典型代码如下(API 文档主页 Basic Concepts,在线版):
#include "vcg/complex/complex.h" // 类型声明 class MyVertex;class MyEdge; class MyFace; typedef vcg::UsedTypes< vcg::Use<MyVertex>::AsVertexType, vcg::Use<MyEdge> ::AsEdgeType, vcg::Use<MyFace> ::AsFaceType > MyUsedTypes; // 顶点类型 class MyVertex : public vcg::Vertex<MyUsedTypes, vcg::vertex::Coord3f, vcg::vertex::Normal3f, vcg::vertex::BitFlags > { }; // 边类型 class MyEdge : public vcg::Edge<MyUsedTypes, vcg::edge::VertexRef, vcg::edge::EFAdj, vcg::edge::BitFlags > { }; // 面类型,三角形 class MyFace : public vcg::Face<MyUsedTypes, vcg::face::VertexRef, vcg::face::Normal3f, vcg::face::FFAdj, vcg::face::BitFlags > { }; // 网格类型 typedef vcg::tri::TriMesh< std::vector<MyVertex>, std::vector<MyEdge>, std::vector<MyFace> > GLMesh;
抛开 MyUseTypes 不看,上面代码定义的网格类型为:
- 网格包含数据:顶点、边、三角形数组(std::vector<>);
- 每个顶点包含属性:空间坐标(3个float表示)、顶点法向量、标志位;
- 每个边包含属性:顶点指针(指向该边的两个顶点)、边-面邻接信息、标志位;
- 每个三角形面包含属性:顶点指针(指向该三角形的三个顶点)、面法向量、面-面邻接信息、标志位。
VCGlib 使用 Reference 数据结构,对每个边、面用指针记录其顶点、邻接面等信息,其他网格数据结构见 wikipedia Polygon Mesh 条目。
为了做到足够通用,VCGlib 使用了C++ template metaprogramming(模板元编程)方法。上面代码中的 MyVertex、MyEdge、MyFace、GLMesh 等类型包含哪些属性(模板参数)、属性的顺序(模板参数顺序)都是可以根据需要随意指定的(当然,必须包含足够的属性以执行相应网格算法),一般来说,最好使顶点、边、面包含标志位属性(BitFlags),BitFlags 指示该顶点、边、面是否可写、可读、已删除(为了效率,例如,删除顶点操作可能并不立即删除顶点数据,而仅仅打个标志位,待所有操作完成再更新顶点数据)等。不去深入讲解 VCGlib 元编程机理(说实话我还没弄清楚),可选个数模板参数是通过默认模板参数实现的,vcg::Vertex/Edge/Face<> 将继承其模板参数。
下面列举所有可选的模板参数:
- 网格 vcg::tri::TriMesh<> 最多可有四个参数:顶点容器、边容器、面容器、半边容器(vcg::HEdge<>);
- 顶点 vcg::Vertex<> 可以包含的属性有:坐标、法向量、颜色、纹理坐标、标志位、网格质量(网格在该点出优劣评价指标)、曲率、半径、顶点-边邻接信息、顶点-面邻接信息、顶点-半边邻接信息,等(API 文档 Modules 选项卡 Vertex Components,在线版);
- 边 vcg::Edge<> 可以包含的属性有:顶点指针、颜色、标志位、网格质量、边-顶点邻接信息、边-边邻接信息、边-面邻接信息、边-半边邻接信息,等(API 文档 Modules 选项卡 Edge Components,在线版);
- 面 vcg::Face<> 可以包含的属性有:顶点指针、法向量、颜色、标志位、网格质量、顶点-面邻接信息、面-边邻接信息、面-面邻接信息,等(API 文档 Modules 选项卡 Face Components,在线版)。
访问 Mesh 数据示例代码如下:
// load mesh ...
int i=0, j=0; // 见 vcg::tri::TriMesh<> ------------------------------------------------------------- mesh.VN(); mesh.EN(); mesh.FN(); // 顶点、边、面个数,可能小于 vs/es/fs.size() // 因为有些元素被删除时仅仅打了标志位而并未删除存储数据 std::vector<GLMesh::VertexType>& vs = mesh.vert; // 顶点数组 std::vector<GLMesh::EdgeType>& es = mesh.edge; // 边数组 std::vector<GLMesh::FaceType>& fs = mesh.face; // 面数组 // 见 vcg::Vertex<> 及其 模板参数 ------------------------------------------------------- GLMesh::VertexType& v = mesh.vert[i]; // 第 i 个顶点,假设 v.isD()==false,即未标志为已删除 v.P().Z(); v.P().V(j); // 顶点坐标,其xyz分量 v.N().X(); // 顶点法向,其x分量 // 见 vcg::Edge<> 及其 模板参数 --------------------------------------------------------- GLMesh::EdgeType& e = mesh.edge[i]; // 第 i 个边,假设 e.isD()==false GLMesh::VertexType* pve = e.V(j); // j=0,1,边的两个端点顶点的指针 GLMesh::FaceType* pfa = e.EFp(); // 边-面邻接信息,该边连接的第一个面 // 见 vcg::Face<> 及其 模板参数 --------------------------------------------------------- GLMesh::FaceType& f = mesh.face[i]; // 第 i 个面(三角形),假设 f.isD()==false GLMesh::VertexType* pvf = f.V(j); // j=0,1,2,三角形面的三个顶点的指针 f.N(); // 面的法向量 GLMesh::FaceType* pfb = f.FFp(j); // 面-面邻接信息,j=0,1,2,面 f 通过其第j个边连接的第一个面 // 可以通过返回的引用(左值)修改数据,但不要随便修改,见下文 ------------------------------------ v.P().Y() += 3.2f; e.V(j) = &v; f.V(j) = &v; // 遍历所有顶点、边、面需要跳过标记为已删除的元素 --------------------------------------------- for(size_t i=0; i<vs.size(); ++i){ if(vs[i].IsD()) continue; // do some thing for each vertex vs[i] ... } // 除非已经删除了所有标记为已删除元素的存储数据,比如: vcg::tri::Allocator<GLMesh>::CompactVertexVector(mesh); vcg::tri::Allocator<GLMesh>::CompactEdgeVector(mesh); vcg::tri::Allocator<GLMesh>::CompactFaceVector(mesh); for(size_t i=0; i<fs.size(); ++i){ // do some thing for each face fs[i] ... }
填充(Fill)Mesh 数据的示例代码如下(API 文档主页 Creating and destroying elements,在线版,代码摘自那里):
// VCGlib Reference 数据结构,依赖于指针,直接操作顶点、边、面数组 mesh.vert/edge/face 可能 // 产生 std::vector<> 存储重新分配,此时,相关指针将失效,vcg::tri::Allocator<> 处理这些问题 GLMesh m; GLMesh::VertexIterator vi = vcg::tri::Allocator<GLMesh>::AddVertices(m, 3); GLMesh::FaceIterator fi = vcg::tri::Allocator<GLMesh>::AddFaces(m, 1); GLMesh::VertexPointer ivp[4]; ivp[0]=&*vi; vi->P()=GLMesh::CoordType(0.0f,0.0f,0.0f); ++vi; ivp[1]=&*vi; vi->P()=GLMesh::CoordType(1.0f,0.0f,0.0f); ++vi; ivp[2]=&*vi; vi->P()=GLMesh::CoordType(0.0f,1.0f,0.0f); ++vi; fi->V(0)=ivp[0]; fi->V(1)=ivp[1]; fi->V(2)=ivp[2]; // Alternative, more compact, method for adding a single vertex ivp[3]= &*vcg::tri::Allocator<GLMesh>::AddVertex(m,GLMesh::CoordType(1.0f,1.0f,0.0f)); // Alternative, method for adding a single face (once you have the vertex pointers) vcg::tri::Allocator<GLMesh>::AddFace(m, ivp[1],ivp[0],ivp[3]); // 同理,如果自己保存了顶点等数据指针,需要在修改顶点、边、面数组后更新该指针 -------------------- // a potentially dangerous pointer to a mesh element GLMesh::FacePointer fp = &m.face[0]; vcg::tri::Allocator<GLMesh>::PointerUpdater<GLMesh::FacePointer> pu; // now the fp pointer could be no more valid due to eventual re-allocation of the m.face vcg::tri::Allocator<GLMesh>::AddVertices(m,3); vcg::tri::Allocator<GLMesh>::AddFaces(m,1,pu); // check if an update of the pointer is needed and do it. if(pu.NeedUpdate()) pu.Update(fp); // 可以想见,pu 保存了地址偏移信息,只需将 fp 偏移 // 删除元素的代码如下 -------------------------------------------------------------------- vcg::tri::Allocator<GLMesh>::DeleteFace(m,m.face[0]); // 拷贝网格(同样引起地址变化)的代码如下,GLMesh 没有拷贝构造函数,也没有 operator= ------------ GLMesh m2; vcg::tri::Append<GLMesh,GLMesh>::MeshCopy(m2, m, false, true); // m to m2
IO,读写网格文件示例代码如下(API 文档主页 Loading and saving meshes,在线版):
// Mesh 文件一般至少包含顶点数组信息,还可以包含连接信息(三角形)、顶点法向量、顶点颜色、面颜色、 // 面法向量、纹理坐标等等属性,用 mask 的二进制位来标记或控制读取或写入了 Mesh 文件的哪些属性 // 见 vcg::tri::io::Mask,读取 PLY 需要包含文件 "vcglib/wrap/ply/plylib.cpp"(见这里) // 头文件包含:#include "wrap/io_trimesh/import.h" #include "wrap/io_trimesh/export.h" GLMesh m; int mask; // 读取 PLY 文件,并检查返回值,参数 mask 为可选,mask 是返回参数:读入了哪些属性 if( vcg::tri::io::ImporterPLY<GLMesh>::Open(m, "file_to_open.ply", mask) != vcg::ply::E_NOERROR ) { std::cout << "Load PLY file ERROR "; } // some modification to m and mask ... // 保存 PLY 文件,mask 是输入参数,控制 m 的哪些属性被写入到文件 vcg::tri::io::ExporterPLY<GLMesh>::Save(m, "file_to_save.ply", mask); // 读取或写入 OBJ 文件的代码,mask 作用同上 if( vcg::tri::io::ImporterOBJ<GLMesh>::Open(m, "file_to_open.obj", mask) != vcg::tri::io::ImporterOBJ<GLMesh>::E_NOERROR ) { std::cout << "Load OBJ file ERROR "; } // some modification to m and mask ... vcg::tri::io::ExporterOBJ<GLMesh>::Save(m, "file_to_save.obj", mask); // 读取、写入网格文件,将根据文件扩展名自动匹配文件格式 --------------------------------------- int oerr = vcg::tri::io::Importer<GLMesh>::Open(m, "file_to_open.off", mask); if( oerr != 0 ){ std::cout << "Load mesh file ERROR: " << vcg::tri::io::Importer<GLMesh>::ErrorMsg(oerr) << ' '; } // some modification to m and mask ... int serr = vcg::tri::io::Exporter<GLMesh>::Save(m, "file_to_save.3ds", mask); if( serr != 0 ){ std::cout << "Save mesh file ERROR: " << vcg::tri::io::Exporter<GLMesh>::ErrorMsg(oerr) << ' '; }
构造网格拓扑信息示例代码如下(API 文档主页 Adjacency and Topology,在线版):
// load mesh ... vcg::tri::UpdateNormal<GLMesh>::PerFaceNormalized(mesh); // 计算顶点法向量,并单位化 vcg::tri::UpdateNormal<GLMesh>::PerVertexNormalized(mesh); // 计算面法向量,并单位化 vcg::tri::UpdateTopology<GLMesh>::FaceFace(mesh); // 计算面-面邻接信息 vcg::tri::UpdateTopology<GLMesh>::AllocateEdge(mesh); // 计算边-面邻接信息,需要面-面信息 vcg::Matrix44f mat(&glm::translate(glm::vec3(1,2,3))[0][0]); vcg::tri::UpdatePosition<GLMesh>::Matrix(mesh, mat, true); // 更新顶点位置,并更新法向量 // 在调用 UpdateTopology<>::FaceFace() 和 UpdateTopology<>::AllocateEdge() 后就构造了边到面 // 的信息,对于 manifold 网格,每个边必连接两个三角形面,下面代码对边 i 查找其连接的面 fa 和 fb int i=0; GLMesh::EdgeType& e = mesh.edge[i]; GLMesh::FaceType* fa = e.EFp(); GLMesh::FaceType* fb = fa->FFp(e.EFi());
在准备这篇博客之初,研究 VCGlib 时,发现了 VCGlib 的一个 BUG,已经报告给开发者并得到确认(见这里,看看时间,发现这篇博客因为一些原因拖了20多天...)。
网格处理示例代码如下:
vcg::tri::Clean<GLMesh>::RemoveDuplicateVertex(mesh); // 去除重合的顶点 vcg::tri::Smooth<GLMesh>::VertexNormalLaplacian(mesh, 5); // 平滑顶点法向量 float maxSizeHole = 2.0f; // fill 所有直径小于 maxSizeHole 的洞 vcg::tri::Hole<GLMesh>::EarCuttingIntersectionFill <vcg::tri::SelfIntersectionEar<GLMesh>>(mesh, maxSizeHole, false);
进一步学习的资源:
- 下载的 VCGlib 源代码 “vcglibappssample” 下的官方示例代码;
- 源代码,结合 API 文档;
- 基于 VCGlib 的软件 MeshLab,可以用于网格文件处理。