OpenGL阴影,Shadow Mapping(附源程序)

 

实验平台:Win7,VS2010

 

先上结果截图(文章最后下载程序,解压后直接运行BIN文件夹下的EXE程序):

 

本文描述图形学的两个最常用的阴影技术之一,Shadow Mapping方法(另一种是Shadow Volumes方法)。在讲解Shadow Mapping基本原理及其基本算法的OpenGL实现之后,将继续深入分析解决几个实际问题,包括如何处理全方向点光源、多个光源、平行光。最近还有可能写一篇Shadow Volumes的博文(目前已经将基本理论弄清楚了),在那里,将对Shadow Mapping和Shadow Volumes方法做简要的分析对比。

本文的程序实现用到了很多开源程序库,列举如下:

  1. GLEW,(.lib, .dll),用于处理OpenGL本地扩展;
  2. GLFW,(.lib, .dll),用于处理窗口,以及创建OpenGL Context;
  3. Freeglut,(.lib, .dll),处理窗口,但本文只用其绘制基本几何体,如茶壶;
  4. GLM,(纯头文件),OpenGL数学库,向量及矩阵代数计算;
  5. DevIL,(.lib, .dll),读写图片,支持很多格式,如JPG、PNG;
  6. FTGL作者网站),(.lib, .dll),在OpenGL中显示字体,支持TrueType字体文件读取,支持抗锯齿字体、拉伸实体字形等,需要FreeType,(.lib),库支持;
  7. Bullet,(.lib),物理引擎,可以进行刚体可变形体的模拟,本文暂未使用;
  8. VCG,(纯头文件,有些IO操作需要.cpp),读写.obj等网格数据,高效表示网格,并有大量如网格修复算法实现,本文暂未使用。

 

1. 数学原理

抛开复杂的现实世界中对“阴影”难以定义的问题(见文献[1]第1章),直接来看图形学实际采用的阴影的数学定义,如下图(摘自文献[1]):

lit(lighted)是直接接受光照的区域,umbra中文为“本影”,是某个光源完全被遮挡的局域,penumbra中文为“半影”,是仅能接受到有限大光源部分光照的区域。有限大光源产生半影,使得阴影的边沿柔和化,也称作Soft Shadow,理想点光源的半影将消失,也称为Hard Shadow。本文中,我将只考虑Hard Shadow,并主要讨论点光源,可以想见,有限大光源可以用无穷多个点光源逼近。

有了阴影的定义,用OpenGL实现阴影的问题就归结为:对摄像机看到的每个表面上的点,确定其和光源之间是否有遮挡,如果有则该点位于阴影中,如果没有则该点直接接受光照(不考虑半影)。Shadow Mapping方法将这个问题等价转换为:对于每个表面上的点P,过该点做一条从光源射出的射线(再次,我们主要说点光源),这条射线和场景中物体的表面有多个交点,设这些交点中离光源最近的为A,如果P点离光源距离大于A,则P点位于阴影中,否则接受光照;如果对从光源发出的每条射线,均找到这样的A,并将A到光源距离计算出来做成“表”,这样对于P点只需要“查表”找到其所在射线的那个表项就可以了;当然,计算机处理不了“每条射线”这种无穷问题,需要将光源照射的方向离散化,转化为有限问题,这将用到现代图形硬件的“光栅化”功能。

下图说明了Shadow Mapping的基本原理,先不用看图中文字,请看下面解释(摘自文献[1]):

左图中,黄色光源下面那个蓝线框矩形图即“类似”于上面说的,对于光源发出的每条射线,找一个最近距离,称为Shadow Map(阴影图),在实际渲染中,对于每个表面点P,只需找到和P在同一条光源发出射线上的Shadow Map中的表项,比较P点到光源距离和表项值的大小,即可判断阴影。这里之所以说“类似”是因为,Shadow Map中存储的并不是最近点A到光源的距离(设这个距离为d),而是d的函数,设为f(d),可以看到只要f严格单调递增,比较d的大小和比较f(d)的大小是等价的(好在只需要比较大小,而不需要知道具体大多少)。这个f即齐次坐标变换,f(d)即深度值,说的具体一点,就是模型视图变换和投影变换,模型变换将物体坐标变换到世界坐标,再经过视图变换到视觉坐标,再经过投影变换到裁剪坐标(视景体被变为xyz为±1的边长为2的正方体),详见我前两篇博文:文献[6][7]。这里来说明一下投影变换具有所需要的性质:将过光源点(摄像机位置)的射线变换为射线,且射线上的点顺序不发生变化(严格单调增加)。

Shadow Mapping方法概述如下:

  • 定义一个变换生成Shadow Map,记为表S,其中保存了最近点深度值,即视图矩阵V为摄像机在光源点对准物体,投影矩阵P为开口和聚光灯开角相等或足以包括场景物体的透视投影矩阵,记M=PV为视图矩阵和投影矩阵变换的叠加;
  • 在渲染场景时,对每个片断,设其世界坐标为p,则其到光源的深度值可如下计算,q=Mp=(xq,yq,zq,wq),d=zq/wq,用d和S的表项S(xq/wq,yq/wq)比较,若结果为等于则p接受光照,若大于则位于阴影中。

这里再注意一个细节,上面算法对每个片断进行,对每个片断的坐标进行齐次变换求得其到光源深度值,其实这是没有必要的,对于每个图元:点、线、多边形,其片断的到光源深度值可由其顶点到光源的深度值插值得到,毕竟,同一图元必定落于某平面上。这里的“插值”是在光栅化阶段进行的,就像我之前博文说的,其实它并不简单(文献[6]),但我们不用管,即使在着色器程序中,光栅化也由固定管线功能实现。

 

2.基本算法的OpenGL实现

我们先来看一个最简单的程序,从光源绘制一个深度图,将其拷贝到纹理,我们先手动计算纹理坐标,以直观表达计算过程。程序的全局变量,纹理初始化代码如下(请见文献[6]最后的OpenGL函数总结):

GLuint tex_shadow; // 纹理名字
glm::vec4 light_pos; // 光源在世界坐标中的位置
glm::mat4 shadow_mat_p; // 光源视角的投影矩阵
glm::mat4 shadow_mat_v; // 光源视角的视图矩阵
void tex_init() // 纹理初始化
{
    // 纹理如何影响颜色,和光照计算结果相乘
    glTexEnvi(GL_TEXTURE_ENV, GL_TEXTURE_ENV_MODE, GL_MODULATE);
    // 分配纹理对象,并绑定为当前纹理
    glGenTextures(1, &tex_shadow);
    glBindTexture(GL_TEXTURE_2D, tex_shadow);
    // 纹理坐标超出[0,1]时如何处理
    glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_S, GL_CLAMP_TO_EDGE);
    glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_T, GL_CLAMP_TO_EDGE);
    // 非整数纹理坐标处理方式,线性插值
    glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_LINEAR);
    glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_LINEAR);
    // 深度纹理,深度值对应亮度
    glTexParameteri(GL_TEXTURE_2D, GL_DEPTH_TEXTURE_MODE, GL_LUMINANCE);
} 

绘制函数里,先将摄像机放置在光源位置,渲染后将深度缓冲拷贝到纹理,代码如下:

//---------------------------------------第1次绘制,生成深度纹理--------
glClear(GL_COLOR_BUFFER_BIT | GL_DEPTH_BUFFER_BIT);
// 将摄像机放置在光源位置,投影矩阵和视图矩阵
shadow_mat_p = glm::perspective(glm::radians(90.0f), 1.0f, 1.0f, 1.0e10f);
shadow_mat_v = glm::lookAt(glm::vec3(light_pos), glm::vec3(0), glm::vec3(0,1,0));
glMatrixMode(GL_PROJECTION); glPushMatrix();
glLoadMatrixf(&shadow_mat_p[0][0]); // 加载投影矩阵
glMatrixMode(GL_MODELVIEW); glPushMatrix();
glLoadMatrixf(&shadow_mat_v[0][0]); // 加载视图矩阵
    draw_world();
glMultMatrixf(&mat_model[0][0]);
    draw_model();
glMatrixMode(GL_PROJECTION); glPopMatrix();
glMatrixMode(GL_MODELVIEW); glPopMatrix();
// 拷贝深度缓冲到纹理
glBindTexture(GL_TEXTURE_2D, tex_shadow);
glCopyTexImage2D(GL_TEXTURE_2D, 0, GL_DEPTH_COMPONENT,
    0, 0, glStaff::get_frame_width(), glStaff::get_frame_height(), 0);
glEnable(GL_TEXTURE_2D); // 使能纹理
void draw_model() // 绘制模型,一个茶壶
{    
    glMatrixMode(GL_MODELVIEW);
    glPushMatrix();
    glTranslatef(0, 1, 0);
        glutSolidTeapot(1);
    glPopMatrix();
}

我们的draw_world就绘制一个平面,draw_model就绘制一个茶壶,场景如下(黄色为光源位置):

可以用如下代码获取纹理像素,并用DevIL保存(il_saveImgDep是我写的函数,字符串前加L是wchar_t字符串):

GLfloat* data = new GLfloat[glStaff::get_frame_width()*glStaff::get_frame_height()];
glGetTexImage(GL_TEXTURE_2D, 0, GL_DEPTH_COMPONENT, GL_FLOAT, data); // 获取纹理数据
il_saveImgDep(L"d0.png", data, glStaff::get_frame_width(), glStaff::get_frame_height());
delete[] data;

深度图如下,距离摄像机近的点深度值小,所以颜色为黑色,距离越远颜色越白:

我们手动将这个纹理贴到那个正方形地板上:

void draw_world() // 绘制世界,一个地板
{
    glm::vec4 v1(-3, 0,-3, 1), v2(-3, 0, 3, 1), v3( 3, 0, 3, 1), v4( 3, 0,-3, 1);//四个顶点
    glm::mat4 m = glm::translate(glm::vec3(0.5f,0.5f,0.5f))
        * glm::scale(glm::vec3(0.5f,0.5f,0.5f)); // 需要将裁剪坐标的[-1,+1]缩放到[0,1]
    glm::vec4 t;
    glBegin(GL_POLYGON);
      glNormal3f(0, 1, 0);
      t = m*shadow_mat_p*shadow_mat_v*v1; // 按和生成纹理相同的变换计算纹理坐标
      glTexCoord4fv(&t[0]); glVertex3fv(&v1[0]);
      t = m*shadow_mat_p*shadow_mat_v*v2;
      glTexCoord4fv(&t[0]); glVertex3fv(&v2[0]);
      t = m*shadow_mat_p*shadow_mat_v*v3;
      glTexCoord4fv(&t[0]); glVertex3fv(&v3[0]);
      t = m*shadow_mat_p*shadow_mat_v*v4;
      glTexCoord4fv(&t[0]); glVertex3fv(&v4[0]);
    glEnd();
}
//-------------------------------------------第2次绘制,绘制场景------------
glClear(GL_COLOR_BUFFER_BIT | GL_DEPTH_BUFFER_BIT);
glMatrixMode(GL_MODELVIEW);
glLoadMatrixf(&mat_view[0][0]);
    draw_world();
glMultMatrixf(&mat_model[0][0]);
    draw_model();

注意一个细节,经过模型视图和投影变换得到的裁剪坐标xyz坐标位于[-1,+1],而纹理坐标以及纹理像素值也就是深度值位于[0,1]需要将[-1,+1]缩放到[0,1],也即先缩放0.5倍,再平移0.5(OpenGL管线中这一变换在视口变换时进行,见文献[6])。绘制结果如下:

请对照深度图,因为计算纹理坐标的变换和生成纹理的变换相同,所以,深度纹理中的地板的四个角正好被贴图到了场景地板的四个角。由于茶壶函数是 glut 内置,其内部可能指定了纹理坐标,所以纹理也被贴到了茶壶上。上述所有代码请见所附程序中的 mapping_basic0.cpp。

上面程序的结果,地板上看着挺像阴影的,因为恰好在遮挡的地方纹理的颜色又偏黑(深度值小)。现在还需将计算出的纹理坐标的z值和纹理像素值也即深度值进行比较,并根据结果选择进行光照还是没有光照,因为纹理的影响模式为乘积,完全的光照也就是纹理值为1,完全没有光照也就是纹理值为0,OpenGL提供了纹理比较机制:用纹理坐标的r(纹理坐标四个分量为strq)值和纹理像素值比较,比较的结果是0和1(相等时为1),用比较的结果替换原来纹理值。只需在上面代码的初始化纹理函数 tex_init 中加入如下两行代码便启用此机制:

// 纹理比较模式,用纹理坐标r和纹理值(深度值)比较,若小于等于纹理值改为1,否则改为0
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_COMPARE_MODE, GL_COMPARE_R_TO_TEXTURE);
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_COMPARE_FUNC, GL_LEQUAL);

你可能已经想到了,对两种计算方式下计算出的浮点数进行相等比较(程序中用小于等于,理论上用等于就可以)结果是不确定的,如下图的斑纹:

可以对计算的纹理坐标r坐标进行少许偏移,让其偏小:

glm::mat4 m = glm::translate(glm::vec3(0.5f,0.5f,0.49f))
        * glm::scale(glm::vec3(0.5f,0.5f,0.5f)); // 需要将裁剪坐标的[-1,+1]缩放到[0,1]

直接对r坐标进行偏移或者直接对深度纹理的深度值进行偏移并不是一个好方法,因为透视投影下深度值和裁剪坐标的z值之间并不是线性关系:在离摄像机很远的地方,两个z值差别很大的点其深度值可能差别非常小(都接近1)。合理的做法是:1.多边形偏移,2.在生成深度纹理时剔除正面,更多方法请见文献[1]。

除了手动计算纹理坐标,我们可以将变换放到纹理变换矩阵中,上面绘制世界函数的等价版本如下:

void draw_world() // 绘制世界,一个地板
{
    glm::vec4 v1(-3, 0,-3, 1), v2(-3, 0, 3, 1), v3( 3, 0, 3, 1), v4( 3, 0,-3, 1);
    glm::mat4 m = glm::translate(glm::vec3(0.5f,0.5f,0.5f))
        * glm::scale(glm::vec3(0.5f,0.5f,0.5f)); // 需要将裁剪坐标的[-1,+1]缩放到[0,1]
    m = m*shadow_mat_p*shadow_mat_v;
    glMatrixMode(GL_TEXTURE); glLoadMatrixf(&m[0][0]); glMatrixMode(GL_MODELVIEW);
    glBegin(GL_POLYGON);
      glNormal3f(0, 1, 0);
      glTexCoord4fv(&v1[0]); glVertex3fv(&v1[0]);
      glTexCoord4fv(&v2[0]); glVertex3fv(&v2[0]);
      glTexCoord4fv(&v3[0]); glVertex3fv(&v3[0]);
      glTexCoord4fv(&v4[0]); glVertex3fv(&v4[0]);
    glEnd();
}

其实目前还有一个问题,就是我们的影子只投到了地板上,茶壶上并没有,这是因为茶壶函数是封装好的,我们不能到茶壶函数内部去指定纹理坐标。OpenGL提供了纹理坐标自动生成机制,可以从顶点物体坐标或顶点视觉坐标自动生成纹理坐标,我们先来看看从顶点物体坐标自动生成纹理坐标。需要在纹理初始化时加入如下代码:

// 纹理坐标自动生成,从顶点物体坐标生成
glTexGeni(GL_S, GL_TEXTURE_GEN_MODE, GL_OBJECT_LINEAR);
glTexGeni(GL_T, GL_TEXTURE_GEN_MODE, GL_OBJECT_LINEAR);
glTexGeni(GL_R, GL_TEXTURE_GEN_MODE, GL_OBJECT_LINEAR);
glTexGeni(GL_Q, GL_TEXTURE_GEN_MODE, GL_OBJECT_LINEAR);
glEnable(GL_TEXTURE_GEN_S);
glEnable(GL_TEXTURE_GEN_T);
glEnable(GL_TEXTURE_GEN_R);
glEnable(GL_TEXTURE_GEN_Q);

并在使用纹理前,也就是渲染深度纹理后将纹理坐标变换矩阵分行传递到纹理坐标自动生成的参数,如下:

// 将纹理坐标变换矩阵分行传递到纹理坐标自动生成的参数
glm::mat4 mat =
    glm::translate(glm::vec3(0.5f,0.5f,0.5f))*glm::scale(glm::vec3(0.5f,0.5f,0.5f))
    * shadow_mat_p * shadow_mat_v;
mat = glm::transpose(mat);
glTexGenfv(GL_S, GL_OBJECT_PLANE, &mat[0][0]);
glTexGenfv(GL_T, GL_OBJECT_PLANE, &mat[1][0]);
glTexGenfv(GL_R, GL_OBJECT_PLANE, &mat[2][0]);
glTexGenfv(GL_Q, GL_OBJECT_PLANE, &mat[3][0]);

还记得 OpenGL 和 GLM 的矩阵都是列优先,所以按行加载前要转置。其实,所谓纹理坐标自动生成就是,管线在遇到一个顶点时自动计算一个纹理坐标,这和之前手动计算或加载到纹理矩阵的计算方式是完全相同的,只不过现在自动计算而已,这里看到这些 GL_OBJECT_PLANE 参数合起来就是纹理矩阵,但 OpenGL 支持对 strq 坐标指定不同变换的行。看看结果,有点惊讶:

可以看到,地板上的阴影是正确的,但茶壶上不正确,原因是我们使用顶点的物体坐标,也就是直接传递给 glVertex3f() 等函数的值,这样茶壶函数指定的顶点物体坐标可能经过模型视图矩阵的变换,而我们没有跟踪到这些变换,毕竟那是封装的函数。其实我们想用的是顶点的世界坐标,不过OpenGL纹理坐标自动生成除了用顶点物体坐标外,另只支持从顶点视觉坐标生成纹理坐标,因为OpenGL将视图和模型变换矩阵合二为一了,不过,视觉坐标到世界坐标的转换可以通过摄像机定义的视图变换矩阵的逆做到。下面是从顶点视觉坐标自动生成纹理坐标的代码,请对照前面的物体坐标代码:

// 纹理坐标自动生成,从顶点视觉坐标
glTexGeni(GL_S, GL_TEXTURE_GEN_MODE, GL_EYE_LINEAR);
glTexGeni(GL_T, GL_TEXTURE_GEN_MODE, GL_EYE_LINEAR);
glTexGeni(GL_R, GL_TEXTURE_GEN_MODE, GL_EYE_LINEAR);
glTexGeni(GL_Q, GL_TEXTURE_GEN_MODE, GL_EYE_LINEAR);
glEnable(GL_TEXTURE_GEN_S);
glEnable(GL_TEXTURE_GEN_T);
glEnable(GL_TEXTURE_GEN_R);
glEnable(GL_TEXTURE_GEN_Q);
// When the eye planes are specified, the GL will automatically post-multiply them
// with the inverse of the current modelview matrix.
glMatrixMode(GL_MODELVIEW);
glLoadIdentity();
glm::mat4 mat =
    glm::translate(glm::vec3(0.5f,0.5f,0.5f)) * glm::scale(glm::vec3(0.5f,0.5f,0.5f))
    * shadow_mat_p * shadow_mat_v * glm::affineInverse(mat_view);
mat = glm::transpose(mat);
glTexGenfv(GL_S, GL_EYE_PLANE, &mat[0][0]);
glTexGenfv(GL_T, GL_EYE_PLANE, &mat[1][0]);
glTexGenfv(GL_R, GL_EYE_PLANE, &mat[2][0]);
glTexGenfv(GL_Q, GL_EYE_PLANE, &mat[3][0]);

指定从顶点视觉坐标自动生成纹理坐标的参数时,OpenGL会自动将参数代表的矩阵和当前模型视图矩阵的逆相乘,这本来是要给我们带来方便的,但很多时候这种额外的耦合会被忽略从而得到莫名其妙的结果。上面代码等价于:

// When the eye planes are specified, the GL will automatically post-multiply them
// with the inverse of the current modelview matrix.
glMatrixMode(GL_MODELVIEW);
glLoadMatrixf(&mat_view[0][0]); // glLoadIdentity();
glm::mat4 mat =
    glm::translate(glm::vec3(0.5f,0.5f,0.5f)) * glm::scale(glm::vec3(0.5f,0.5f,0.5f))
    * shadow_mat_p * shadow_mat_v/* * glm::affineInverse(mat_view)*/;
mat = glm::transpose(mat);
glTexGenfv(GL_S, GL_EYE_PLANE, &mat[0][0]);
glTexGenfv(GL_T, GL_EYE_PLANE, &mat[1][0]);
glTexGenfv(GL_R, GL_EYE_PLANE, &mat[2][0]);
glTexGenfv(GL_Q, GL_EYE_PLANE, &mat[3][0]);

来看结果,抛开浮点数相等比较带来的斑纹问题,都是正确的,茶壶把手和壶盖那里也有了阴影:

下面来看用多边形偏移和剔除正面方法解决斑纹问题的代码:

//---------------------------------------第1次绘制,生成深度纹理--------
// ...
glEnable(GL_POLYGON_OFFSET_FILL); // 多边形偏移
glPolygonOffset(0, 20000);
// draw_world() ...
glPolygonOffset(0, 0); // 别忘了恢复原来的值
glDisable(GL_POLYGON_OFFSET_FILL);
//---------------------------------------第1次绘制,生成深度纹理--------
// ...
glEnable(GL_CULL_FACE); // 剔除正面
glCullFace(GL_FRONT);
// draw_world() ...
glCullFace(GL_BACK); // 别忘了恢复原来的值
glDisable(GL_CULL_FACE);

就像我前一篇博文文献[6]说的,多边形顶点的环绕方向(右手法则)要和多边形的法向量一直,glut茶壶函数就是个反例,这使得我们不得不在绘制茶壶前临时剔除背面。

多边形偏移结果如下:

剔除正面的结果以及剔除前后的深度图如下:

结果差不多,注意茶壶相对光照的背面还有斑纹,那无关紧要,因为那里是不受光源照射(法向量和到光源向量乘积小于0)的地方,后面将对环境光和光源光分开两遍渲染,背面斑纹将自然消失。这里再次强调上图结果之所以对每个片断都正确,得益于光栅化对纹理坐标进行了正确插值(文献[6])。后面将采用剔除正面的做法,因为:多边形偏移方法的偏移值不好确定,剔除正面可以减少片断数量提高效率。但剔除正面方法也有问题:几何体(除了只接收阴影的物体)必须是封闭的,几何体的多边形顶点环绕方向必须和多边形法向量一致。这部分所有代码请见所附程序中的 mapping_basic1.cpp。

在进入下节之前,我们先来看一个 Shadow Mapping 方法给我们带来的附加产品:投影贴图,将上面深度纹理改成普通纹理,继续使用纹理坐标自动生成,代码如下:

void tex_init() // 纹理初始化
{
    // 纹理如何影响颜色,和光照计算结果相乘
    glTexEnvi(GL_TEXTURE_ENV, GL_TEXTURE_ENV_MODE, GL_MODULATE);
    // 分配纹理对象,并绑定为当前纹理
    glGenTextures(1, &tex_lena);
    glBindTexture(GL_TEXTURE_2D, tex_lena);
    // 纹理坐标超出[0,1]时如何处理
    glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_S, GL_CLAMP_TO_BORDER);
    glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_T, GL_CLAMP_TO_BORDER);
    // 边框颜色
    GLfloat c[4] = {1,1,1, 1};
    glTexParameterfv(GL_TEXTURE_2D, GL_TEXTURE_BORDER_COLOR, c);
    // 非整数纹理坐标处理方式,线性插值
    glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_LINEAR);
    glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_LINEAR);
    // 纹理坐标自动生成
    glTexGeni(GL_S, GL_TEXTURE_GEN_MODE, GL_EYE_LINEAR);
    glTexGeni(GL_T, GL_TEXTURE_GEN_MODE, GL_EYE_LINEAR);
    glTexGeni(GL_R, GL_TEXTURE_GEN_MODE, GL_EYE_LINEAR);
    glTexGeni(GL_Q, GL_TEXTURE_GEN_MODE, GL_EYE_LINEAR);
    glEnable(GL_TEXTURE_GEN_S);
    glEnable(GL_TEXTURE_GEN_T);
    glEnable(GL_TEXTURE_GEN_R);
    glEnable(GL_TEXTURE_GEN_Q);
    // 纹理数据
    void* data; int w, h;
    il_readImg(L"Lena Soderberg.jpg", &data, &w, &h);
    glTexImage2D(GL_TEXTURE_2D, 0, GL_RGBA, w, h, 0, GL_RGBA, GL_UNSIGNED_BYTE, data);
    delete data;
}
// 将摄像机放置在光源位置,投影矩阵和视图矩阵
shadow_mat_p = glm::perspective(glm::radians(45.0f), 1.0f, 1.0f, 1.0e10f);
shadow_mat_v = glm::lookAt(glm::vec3(light_pos), glm::vec3(0), glm::vec3(0,1,0));
// When the eye planes are specified, the GL will automatically post-multiply them
// with the inverse of the current modelview matrix.
glMatrixMode(GL_MODELVIEW);
glLoadMatrixf(&mat_view[0][0]); // glLoadIdentity();
glm::mat4 mat =
    glm::translate(glm::vec3(0.5f,0.5f,0.5f)) * glm::scale(glm::vec3(0.5f,0.5f,0.5f))
    * shadow_mat_p * shadow_mat_v/* * glm::affineInverse(mat_view)*/;
mat = glm::transpose(mat);
glTexGenfv(GL_S, GL_EYE_PLANE, &mat[0][0]);
glTexGenfv(GL_T, GL_EYE_PLANE, &mat[1][0]);
glTexGenfv(GL_R, GL_EYE_PLANE, &mat[2][0]);
glTexGenfv(GL_Q, GL_EYE_PLANE, &mat[3][0]);
glEnable(GL_TEXTURE_2D);
//-------------------------------------------绘制场景------------
glClear(GL_COLOR_BUFFER_BIT | GL_DEPTH_BUFFER_BIT);
// ...

结果如下,左上角为纹理原图(已打马赛克):

结果就好像在光源处有一个投影仪(没处理遮挡问题,可以用下一节方法)将图片投影下来,如果将上图中著名的 Lena 换成窗户,结果像光透过窗户洒在地上:

这部分代码请见所附程序中的 mapping_tex_map.cpp。

后面我们将在基础 Shadow Mapping 方法上进行改进以解决如下问题:

  • 目前深度图渲染使用默认帧缓冲区(Default Frame Buffer,请见文献[6]),这个缓冲区的宽和高跟随窗口,另外从默认帧缓冲中将深度值拷贝到纹理效率也不高,为了提高效率,也为了渲染大尺寸深度纹理来减轻阴影锯齿,将使用帧缓冲对象(Framebuffer Objects),并将纹理绑定到帧缓冲对象的深度缓冲,这样将能够直接将深度值渲染到纹理;
  • 在渲染深度图时,由于只需要深度值,把光照、纹理关闭以及屏蔽颜色缓冲写操作可以提高效率;
  • Shadow Mapping方法占用纹理通道,如果还想用普通的纹理贴图,需要使用多重纹理;
  • 目前阴影部分是纯黑色的,我们希望阴影部分不接受对应光源的照射,但接受环境光和其他光源的照射,这需要在渲染场景时进行多遍渲染,并将结果累加,这时后续渲染不需要清除深度缓冲和颜色缓冲,并需要修改深度测试函数和混合函数;
  • 目前渲染深度图时只有一个视角,如果点光源的四周都有物体将不能正确处理,最简单的方法是用6个视角为90度的光源视角将光源的全方向都渲染到深度纹理(想象光源位于某正方体中心),并在应用时将结果累加;
  • 多个光源的处理也需要多遍渲染,这和环境光光源光分离以及全方向点光源的处理类似;
  • 另外还有平行光问题,将光源视角的投影矩阵从透视投影换成平行投影即可,另外需要合理设置视景体以将场景全部包括进来,这时不存在全方向的问题。

下一节将逐个解决这些问题。 

 

3.解决实际问题

3.1 多重纹理,渲染到纹理,环境光

OpenGL多重纹理很简单,用 glActiveTexture(GL_TEXTURE0[1,2,...]) 函数指定当前纹理单元(纹理单元是个术语,就是一个纹理组,不同纹理组可以同时应用纹理功能),这里要分清纹理单元和纹理的参数,纹理单元的参数包括 glTexEnvi[f]() 指定的纹理影响模式以及 glTexGeni[f]() 指定的纹理坐标自动生成参数,纹理的参数包括纹理像素和 glTexParameteri[f]() 指定的参数。如下例子:

// 纹理单元0为当前纹理单元
glActiveTexture(GL_TEXTURE0);
    // 纹理单元0的影响模式
    glTexEnvi(GL_TEXTURE_ENV, GL_TEXTURE_ENV_MODE, GL_REPLACE);
    glGenTextures(1, &tex1);
    glBindTexture(GL_TEXTURE_2D, tex1);
    // 纹理单元0中的一个纹理tex1,其像素和参数
    glTexImage2D(GL_TEXTURE_2D, 0, GL_RGBA, w, h, 0, GL_RGBA, GL_UNSIGNED_BYTE, data);
    glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_S, GL_REPEAT);
    glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_LINEAR);
    // 纹理单元0中的一个纹理tex2,其参数
    glGenTextures(1, &tex2);
    glBindTexture(GL_TEXTURE_2D, tex2);
    glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_S, GL_CLAMP_TO_BORDER);

// 纹理单元1为当前纹理单元
glActiveTexture(GL_TEXTURE1); // shadow texture
    // 纹理单元1的环境函数,以及纹理坐标自动生成
    glTexEnvi(GL_TEXTURE_ENV, GL_TEXTURE_ENV_MODE, GL_MODULATE);
    glTexGeni(GL_S, GL_TEXTURE_GEN_MODE, GL_EYE_LINEAR);
    glEnable(GL_TEXTURE_GEN_S);
    glTexGenfv(GL_S, GL_EYE_PLANE, v1);
    // 纹理单元1的一个纹理,其参数
    glGenTextures(1, &tex3);
    glBindTexture(GL_TEXTURE_2D, tex3);
    glTexParameteri(GL_TEXTURE_2D, GL_DEPTH_TEXTURE_MODE, GL_LUMINANCE);
    glTexImage2D(GL_TEXTURE_2D, 0, GL_DEPTH_COMPONENT, //指定像素数据且传入0指针,预分配存储
        shadow_w, shadow_h, 0, GL_DEPTH_COMPONENT, GL_FLOAT, 0);

// 纹理单元1,禁用纹理
glActiveTexture(GL_TEXTURE1);
    glDisable(GL_TEXTURE_2D);
// 纹理单元0,启用纹理
glActiveTexture(GL_TEXTURE0);
    glEnable(GL_TEXTURE_2D);

// -------------------------------------- 绘制函数 -------------------------------------
// 因为设置纹理单元0为当前纹理单元,且绑定tex1,纹理坐标t1,t2,t3将索引纹理tex1
// 另可用glMultiTexCoord指定多重纹理中特定纹理单元的纹理坐标,将索引那个纹理单元中最后绑定的纹理 glActiveTexture(GL_TEXTURE0); glBindTexture(GL_TEXTURE_2D, tex1); glBegin(GL_POLYGON); glNormal3f(0, 1, 0); glTexCoord4fv(&t1[0]); glVertex3fv(&v1[0]); glTexCoord4fv(&t2[0]); glVertex3fv(&v2[0]); glTexCoord4fv(&t3[0]); glVertex3fv(&v3[0]); glEnd();

帧缓冲对象的使用例子如下:

// 分配一个帧缓冲对象,并绑定为当前写缓冲对象
glGenFramebuffers(1, &frame_buffer_s);
glBindFramebuffer(GL_DRAW_FRAMEBUFFER, frame_buffer_s);
// 分配一个渲染缓冲,绑定,分配存储
glGenRenderbuffers(1, &render_buff_rgba);
glBindRenderbuffer(GL_RENDERBUFFER, render_buff_rgba);
glRenderbufferStorage(GL_RENDERBUFFER, GL_RGBA, shadow_w, shadow_h);
// 将渲染缓冲设定为帧缓冲对象的颜色缓冲,帧缓冲可以有颜色、深度、模板缓冲
glFramebufferRenderbuffer(GL_DRAW_FRAMEBUFFER, GL_COLOR_ATTACHMENT0,
    GL_RENDERBUFFER, render_buff_rgba);
// 将深度纹理设定为帧缓冲对象的深度缓冲
glFramebufferTexture2D(GL_DRAW_FRAMEBUFFER, GL_DEPTH_ATTACHMENT,
    GL_TEXTURE_2D, tex_shadow, 0);

// -------------------------------------- 绘制函数 -------------------------------------
glBindFramebuffer(GL_DRAW_FRAMEBUFFER, frame_buffer_s);
// 以下绘制将绘制到帧缓冲对象frame_buffer_s,即render_buff_rgba和tex_shadow
glViewport(0, 0, shadow_w, shadow_h); // 将视口设置为和frame_buffer_s相同
glClear(GL_DEPTH_BUFFER_BIT); // 清除tex_shadow
// ...

glBindFramebuffer(GL_FRAMEBUFFER, 0);
// 以下绘制将绘制到帧默认缓冲对象,即窗口的附属帧缓冲
glViewport(0, 0, get_frame_width(), get_frame_height()); // 将视口设置为和窗口大小相同
glClear(GL_COLOR_BUFFER_BIT | GL_DEPTH_BUFFER_BIT); // 清除屏幕
// ....

上面代码中,帧缓冲对象用于存放渲染目标,并用纹理或渲染对象作为帧缓冲对象这个“壳子”的具体存储。除此之外帧缓冲对象还可以用于指定 glReadPixels() 函数的读目标。

为减轻 Shadow Mapping 阴影的锯齿问题,需要增加纹理的分辨率,现在,应用绘制到纹理之后纹理的大小将可以自由设置,可以用 glGetIntegerv(GL_MAX_TEXTURE_SIZE, GLint*) 获取系统支持的最大纹理,我的机器(GT240 1GB GDDR5 OpenGL 3.3)最大为 8192x8192,下面是128x128 和 8192x8192 分辨率深度纹理的对比:

可以看到,现在阴影不再是全黑色了,这用到了多遍渲染,并将结果累加,代码如下:

//-------------------------------- 第2次绘制,绘制场景 ----------------------------
glBindFramebuffer(GL_FRAMEBUFFER, 0);
glClear(GL_COLOR_BUFFER_BIT | GL_DEPTH_BUFFER_BIT);
// 1 环境光
glDisable(GL_LIGHT0);
glActiveTexture(GL_TEXTURE1); glDisable(GL_TEXTURE_2D);
glActiveTexture(GL_TEXTURE0); glEnable(GL_TEXTURE_2D);
//float gac2[4]={0,0,0,1}; glLightModelfv(GL_LIGHT_MODEL_AMBIENT, gac2); // black
glMatrixMode(GL_MODELVIEW);
glLoadMatrixf(&mat_view[0][0]);
    draw_world();
glMultMatrixf(&mat_model[0][0]);
    draw_model();
// 2 点光源
GLfloat la[4]; glGetFloatv(GL_LIGHT_MODEL_AMBIENT, la);
float gac[4]={0,0,0,1}; glLightModelfv(GL_LIGHT_MODEL_AMBIENT, gac); // black
glEnable(GL_LIGHT0);
glActiveTexture(GL_TEXTURE1); glEnable(GL_TEXTURE_2D);
glActiveTexture(GL_TEXTURE0); glEnable(GL_TEXTURE_2D);
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, la); // 恢复环境光
glDepthFunc(GL_LESS); glBlendFunc(GL_SRC_ALPHA, GL_ONE_MINUS_SRC_ALPHA);

要点是,第二次不清除颜色和深度缓冲,并将深度测试函数设为相等(这里怎么又可以对浮点数进行相等比较了呢,因为第二遍渲染和第一遍的深度值计算过程完全相同),将混合设为直接相加(源,即片断,和目标,即之前颜色缓冲的值,的因子均为1)。第一遍打开环境光,关闭点光源,第二遍关闭环境光,打开点光源。

叠加示意图如下:

注意一个细节,OpenGL光照为逐顶点光照,上图中底板的明暗变化是用多个小方块才产生的,如果简单的将底板用四个顶点绘制,底板内部的颜色将是从顶点光照颜色插值而来(光栅化的结果),这样就不会有明暗变化,对比下图的左右边:

本小节代码见所附程序中的 mapping_render_to_tex.cpp。

 

3.2 全方向点光源

可以渲染6个深度纹理,每个代表点光源全方向的6分之1,如下图所示:

全方向点光源的实现和上一小节的环境和点光源分离类似,都是采用“1+1”叠加的混合实现的,具体实现代码见所附程序中的 mapping_omni_directional.cpp。下面是程序结果:

下面是这幅图的6个深度图,以及环境、点光源6个方向的贡献图,1、2行为光源视角深度图(剔除正面),3、4行为对应点光源贡献,5行为环境光贡献、最后结果、摄像机视角深度图:

一个细节,为了让点光源每个方向的贡献,在超出纹理坐标[0,1]之后全是黑色,把深度纹理的边框设为黑色,将纹理坐标环绕模式(纹理坐标超出[0,1]时处理方式)设置为 GL_CLAMP_TO_BORDER,并将纹理比较函数从 GL_LEQUAL 改为 GL_LESS(影响可以忽略不计,浮点数比较),代码如下:

glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_S, GL_CLAMP_TO_BORDER);
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_T, GL_CLAMP_TO_BORDER);
GLfloat c[4]={0,0,0,1}; glTexParameterfv(GL_TEXTURE_2D, GL_TEXTURE_BORDER_COLOR, c);
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_COMPARE_FUNC, GL_LESS);

同理,当使用单个视角的 Shadow Mapping 时,为防止超出纹理坐标范围[0,1]的部分变为黑色,可以将纹理的边框颜色设置为白色,并将纹理坐标环绕模式设置为GL_CLAMP_TO_BORDER。

 

3.2 多个光源

多个光源的处理和全方向光源非常类似,也是进行多遍渲染,请见所附程序中的 mapping_multi_lights.cpp,程序利用了这样的性质 GL_LIGHTi=GL_LIGHT0+i,程序结果见最前面彩色图(gif图片颜色有损失,小黑点是光源位置)。

再看各个光源以及环境光的贡献:

上图中第1行从左到右依次为光源1、2、3的深度图,第2行从左到右依次为光源1、2、3的贡献,第3行为环境光贡献,下面是最后结果:

 

3.3 平行光 

平行光的处理非常简单,只需将前面的从光源视角的透视投影矩阵改为平行投影矩阵,并设置视景体使得场景全部落在裁剪体内,另外,平行投影的深度值和视觉坐标的z值是线性关系(有平移),所以深度比较的精度也会高些,具体代码加所附程序中的 mapping_parallel.cpp。下面是结果截图:

深度图如下(剔除正面,光源视角摄像机沿y轴向上):

 

4.进一步研究

Shadow Mapping 方法虽然提出很早,但直到现在仍有许多前沿研究,这可能是因为 Shadow Mapping 方法的简洁性(不需要几何信息,只需要将场景额外的从光源渲染),研究内容主要位于从阴影图过滤产生柔和阴影,详见文献[1]。

 

下载链接,因为程序将所有的库都打包了,这样的好处是程序不依赖系统,另外将微软雅黑字体也拷贝了进去,还有几张贴图,所以程序压缩后仍有25MB大小,见谅。

链接: http://pan.baidu.com/s/1qWPWC7i 密码: nwdo

该程序已过时,请下载我后一篇博客所附支持64bit的程序:OpenGL阴影,Shadow Volumes(附源程序,使用 VCGlib )

 

参考文献

  1. Eisemann, E., Assarsson, U., Schwarz, M. and Wimmer, M., Shadow algorithms for real-time rendering. in Eurographics 2010-Tutorials, (2010), The Eurographics Association(进入作者给的下载链接,另该作者在ACM SIGGRAPH 2012,2013 Course “Efficient real-time shadows”,ACM SIGGRAPH Asia 2009 Course “Casting Shadows in Real Time”,2011 Book “Real-Time Shadows”);
  2. 《OpenGL Specification Version 3.3 (Compatibility Profile) 2010》, 2.12.3 Generating Texture Coordinates(到官网下载);
  3. http://en.wikipedia.org/wiki/Shadow_mapping
  4. C. Everitt, "Projective texture mapping," White paper, NVidia Corporation, vol. 4, 2001(进入下载);
  5. Paul's Projects, Shadow Mapping (这里进入网页);
  6. OpenGL管线(用经典管线代说着色器内部)
  7. OpenGL坐标变换及其数学原理,两种摄像机交互模型(附源程序)

 

posted on 2014-11-30 22:08  liangliangh  阅读(19105)  评论(18编辑  收藏  举报

导航