原帖地址:http://ogldev.atspace.co.uk/www/tutorial23/tutorial23.html
当光投射到物体上时,会在地面或者墙壁等物体上产生阴影。在计算机图形学中,有很多种技术可以产生阴影,本篇教程中,我们学习一种最常用的阴影技术—shadow mapping。
对于OpenGL程序中的阴影问题,可以归结为:如何判定一个像素是否在阴影区域。简单的说,我们可以把像素的位置和光源的位置连接起来(如下图所示),如果连接线通过物体(假设该物体不透明),则该像素可能在阴影内,否则不在阴影内,如下图中A像素和物体交于C点,所以它应该在阴影内,而B像素和物体没有交点,所以不在阴影内。我们可以把摄像机放在光源的位置,则在阴影区域的点深度测试会失败,而不在阴影区域的点则不会,shadow mapping就是基于这种思路。
上面我们得到结论,深度测试可以帮我们判定一个像素点是否在阴影区域,但前提条件是光源和摄像机位置相同,在大多数情况下,光源和摄像机都不在一个位置,此时该怎么做呢?
解决方法就是我们渲染场景两次: 第一次渲染时候,我们把摄像机放在光源的位置,此时我们并不写颜色缓冲,只是输出深度缓冲,通常是输出到一个纹理缓冲中。第二次渲染时候,摄像机在其原始的位置,在第二次渲染的片元shader中,我们会读入第一次渲染的深度缓冲(通常是一个纹理)。对于第二次渲染的每个像素,我们会把这个像素的深度值转化到光源作为视点的空间坐标中[就是通过公式计算得到光源到这点个像素点的距离],然后和第一次渲染保存的深度值进行比较,如果这两个深度是一样的,则表示该像素不在阴影之内,我们输入其正常的颜色即可,如果深度缓冲不同,则表示从光源位置看时,其它的像素遮盖住了它,该像素应该在阴影之内,此时,我们可以输入一个阴影的颜色,比如灰色,作为该像素的输出颜色。
注意深度比较判断等于和不等于时候,要考虑到精度的问题,如果两个浮点表示的深度值足够接近,就认为它们相等。
我们的场景由两个物体组成,立方体和地面,光源位于左上角,指向立方体。第一次渲染中,摄像机在光源的位置,此时B被渲染,它的深度值进入深度缓冲,而A点和C点在同一条线上,此时C的深度更小,所以C的深度被写入深度缓冲。在第二次渲染中,摄像机在其原始的位置,此时对于B点来说,它的光源视点深度和第一次渲染的深度是一样的(注意,可能不是完全一致,有浮点精度问题存在),所以它不在阴影内,而对于A点,现在的光源视点深度值和第一次渲染的深度不一样,所以A在阴影内。
我们第一次渲染生成的深度图就称作: shadow map,对于基于shadow map的阴影算法,我们分两篇教程来学习,本篇教程中,我们只学习如何生成shadow map,就是通过纹理映射技术,把第一次渲染的深度图输出到一张纹理中去,最后我们会在屏幕上显示生成的shadow map图,这也是一个很好的调试方法,可以观察到shadow map是否正确,有时候阴影不正确,就是因为shadow map图不对。
在源代码中,包括一个简单的四边形,该四边形用来显示shadow map。该四边形由2个三角形组成,纹理坐标设置成(0,0),(1,0),(0,1)(1,1),以便使得它覆盖整个纹理空间。
主要源代码:
shadow_map_fbo.h
class ShadowMapFBO
{
public:
ShadowMapFBO();
~ShadowMapFBO();
bool Init(unsigned int WindowWidth, unsigned int WindowHeight);
void BindForWriting();
void BindForReading(GLenum TextureUnit);
private:
GLuint m_fbo;
GLuint m_shadowMap;
};
在OpengGL中,3D管线最终的输出缓冲称作framebuffer对象或者说FBO,FBO的概念涉及颜色缓冲,深度缓冲以及其它的一些缓冲,比如模板缓冲等等。当函数glutInitDisplayMode()被调用时候,会创建一个缺省的framebuffer对象,这个framebuffer对象由窗口系统管理,OpenGL不能删除它,除了缺省的framebuffer,每个应用程序还能创建自己的FBO,这些对象由应用程序控制,可以用来实现一些特效。
本篇教程中的ShadowMapFBO类提供一种很方便管理FBO的方法,该类包含2个OpenGL句柄,句柄m_fbo表示真实的FBO(输出到屏幕上的FBO),句柄m_shadowMap表示深度缓冲。注意:只有缺省的framebuffer才能显示在屏幕上,应用程序创建的FBO只能用来离线渲染,比如把该FBO保存在文件中。
framebuffer本身只是一个句柄,我们需要把它和纹理关联起来,纹理中的数据才是framebuffer中真正的内容。
下面是OpenGL中纹理和FBO关联的一些设置:
- COLOR_ATTACHMENTi - 片元shader的输出图像将放到该纹理中。后缀i意味着可能有多个纹理和颜色缓冲相关联,在片元shader中,我们可以同时渲染多个颜色缓冲。
- DEPTH_ATTACHMENT - 纹理和深度缓冲相关联。
- STENCIL_ATTACHMENT - 纹理和模板缓冲相关联。
- DEPTH_STENCIL_ATTACHMENT - 纹理和深度模板缓冲相关联。
在shadow mapping阴影实现中,我们只需要深度缓冲,m_shadowMap就是和深度缓冲相关联的纹理句柄。ShadowMapFBO类也提供了一些在main cpp中调用的函数,比如渲染shadow map前要调用BindForWriting(),在第二次渲染前要调用BindForReading()。
shadow_map_fbo.cpp
glGenFramebuffers(1, &m_fbo);
我们开始创建FBO,创建方法和纹理类似,首先我们指定一个GLuints数组地址和大小,数组中为fbo句柄。
glGenTextures(1, &m_shadowMap);
glBindTexture(GL_TEXTURE_2D, m_shadowMap);
glTexImage2D(GL_TEXTURE_2D, 0, GL_DEPTH_COMPONENT, WindowWidth, WindowHeight, 0, GL_DEPTH_COMPONENT, GL_FLOAT, NULL);
glTexParameterf(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_LINEAR);
glTexParameterf(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_LINEAR);
glTexParameterf(GL_TEXTURE_2D, GL_TEXTURE_WRAP_S, GL_CLAMP);
glTexParameterf(GL_TEXTURE_2D, GL_TEXTURE_WRAP_T, GL_CLAMP);
接下来,我们创建shadow map纹理,它是一个标准的2D纹理。
- 纹理的内部格式是GL_DEPTH_COMPONENT,这和普通的纹理设置不同,通常的纹理一般是GL_RGB,GL_DEPTH_COMPONENT表示纹理数据是一个单浮点格式,该浮点数表示归一化的深度值。
- 最后一个参数glTexImage2D是空的,这意味着我们不提供任何数据来初始化该缓冲。
- GL_CLAMP使得纹理坐标限制在[0,1]内。
glBindFramebuffer(GL_DRAW_FRAMEBUFFER, m_fbo);
上面的代码把纹理对象和FBO关联起来。GL_DRAW_FRAMEBUFFER表示写framebuffer,而GL_READ_FRAMEBUFFER表示读framebuffer,此时我们可以用glReadPixels得到framebuffer的内容。
glFramebufferTexture2D(GL_DRAW_FRAMEBUFFER, GL_DEPTH_ATTACHMENT, GL_TEXTURE_2D, m_shadowMap, 0);
我们把shadow map纹理和深度FBO关联起来,最后一个参数是mipmap层,因为我们没有使用mipmap层,所以这儿为0,第四个参数为纹理句柄,如果为0的话,则会取消深度FBO的纹理关联。
glDrawBuffer(GL_NONE);
由于第一次渲染并不输出color缓冲,所以我们使用GL_NONE参数。缺省情况下,颜色缓冲target是 GL_COLOR_ATTACHMENT0。有效的参数包括:GL_NONE以及GL_COLOR_ATTACHMENT0到GL_COLOR_ATTACHMENTm,这儿m是GL_MAX_COLOR_ATTACHMENTS - 1,注意,这些参数仅对FBO有效。
GLenum Status = glCheckFramebufferStatus(GL_FRAMEBUFFER);
if (Status != GL_FRAMEBUFFER_COMPLETE) {
printf("FB error, status: 0x%x\n", Status);
return false;
}
FBO配置完成,我们需要验证它是否有效,保证程序不会出错。
void ShadowMapFBO::BindForWriting()
{
glBindFramebuffer(GL_DRAW_FRAMEBUFFER, m_fbo);
}
void ShadowMapFBO::BindForReading(GLenum TextureUnit)
{
glActiveTexture(TextureUnit);
glBindTexture(GL_TEXTURE_2D, m_shadowMap);
}
我们会在shadow map和缺省的framebuffer之间进行切换,第一次输出到shadow map,第二次输出到framebuffer,上面的两个函数就是执行该功能。
下面是第一趟渲染时候,也就是渲染shadow map时的vs和fs(ps)的代码:
shadowmap.vs
#version 400
layout (location = 0) in vec3 Position;
layout (location = 1) in vec2 TexCoord;
layout (location = 2) in vec3 Normal;
uniform mat4 gWVP;
out vec2 TexCoordOut;
void main()
{
gl_Position = gWVP * vec4(Position, 1.0);
TexCoordOut = TexCoord;
}
。
shadowmap.ps
#version 400
in vec2 TexCoordOut;
uniform sampler2D gShadowMap;
out vec4 FragColor;
void main()
{
float Depth = texture(gShadowMap, TexCoordOut).x;
Depth = 1.0 - (1.0 - Depth) * 25.0;
FragColor = vec4(Depth);
}
在第二次执行渲染中,我们会执行片元shader,输出shadow map纹理。由于shadow map创建时候使用了GL_DEPTH_COMPONENTU做为格式,它是单浮点数,并不是颜色,所以我们用纹理坐标的x分量,来采样纹理值。透视投影有个问题,它把一个顶点向量z值归一化时候,它会保留更多接近视点的位置,靠近视点位置的的深度比较小,用图像显示出来,可能不太清晰,我们用一个变化显示深度,并把深度扩展为vec4表示的颜色。
tutorial23.cpp
virtual void RenderSceneCB()
{
m_pGameCamera->OnRender();
m_scale += 0.05f;
ShadowMapPass();
RenderPass();
glutSwapBuffers();
}
主函数的渲染很简单,它调用两个渲染函数,先渲染shadow map,第二趟渲染把shadow map输出到屏幕上。
virtual void ShadowMapPass()
{
m_shadowMapFBO.BindForWriting();
glClear(GL_DEPTH_BUFFER_BIT);
Pipeline p;
p.Scale(0.1f, 0.1f, 0.1f);
p.Rotate(0.0f, m_scale, 0.0f);
p.WorldPos(0.0f, 0.0f, 5.0f);
p.SetCamera(m_spotLight.Position, m_spotLight.Direction, Vector3f(0.0f, 1.0f, 0.0f));
p.SetPerspectiveProj(20.0f, WINDOW_WIDTH, WINDOW_HEIGHT, 1.0f, 50.0f);
m_pShadowMapTech->SetWVP(p.GetWVPTrans());
m_pMesh->Render();
glBindFramebuffer(GL_FRAMEBUFFER, 0);
}
第一次渲染中,会把摄像机放在光源的位置,用来得到shadow map。
virtual void RenderPass()
{
glClear(GL_COLOR_BUFFER_BIT | GL_DEPTH_BUFFER_BIT);
m_pShadowMapTech->SetTextureUnit(0);
m_shadowMapFBO.BindForReading(GL_TEXTURE0);
Pipeline p;
p.Scale(5.0f, 5.0f, 5.0f);
p.WorldPos(0.0f, 0.0f, 10.0f);
p.SetCamera(m_pGameCamera->GetPos(), m_pGameCamera->GetTarget(), m_pGameCamera->GetUp());
p.SetPerspectiveProj(30.0f, WINDOW_WIDTH, WINDOW_HEIGHT, 1.0f, 50.0f);
m_pShadowMapTech->SetWVP(p.GetWVPTrans());
m_pQuad->Render();
}
第二次渲染中,把输入的纹理shadow map,渲染在一个quad中,注意:需要装入quad模型quad.obj。
程序执行后效果如下: