从0开始的OpenGL学习(三十)-Shadow Map

本文主要解决一个问题:

如何使用Shadow Map显示场景中的阴影

引言

是时候给场景中添加阴影了。阴影是非常消耗资源的一个东西,但是,有阴影的场景真实性比没阴影的场景高了无数倍。所以,虽然阴影会非常耗资源(也耗我们实现它的时间),我们也必须去实现这个东西,因为它实在是太赞了!

但是,笔者要泼一盆冷水。到目前为止,能进行完美的实时阴影计算的算法并不存在,我们只有一些近似模拟的算法不过它们都有各自的局限性和使用范围,这点我们必须要知道。

Shadow Map

Shadow Map是一种非常常见的算法。它的原理非常简单,就是假设有一个摄像机在灯光的位置,从灯光的位置往物体看,这时候会有一张光源空间的深度信息图,这就是Shadow Map。凡是物体的深度值大于Shadow Map上的深度值的都是被遮挡的部分,都要显示阴影。通过这种判断方式,我们就可以知道该在什么位置绘制阴影了。让我们来看一张图仔细理解一下:

从0开始的OpenGL学习(三十)-Shadow Map_第1张图片
原理图

看上面左边的图。想象一下在那个灯泡位置是一个摄像机,当我们从灯泡位置沿着黄色的箭头看过去时,如果我们采用正交投影的变换方式(模拟方向光),我们就会得到一张深度信息图,就是左图中黄色框框的深度信息。然后,当我们真的要去渲染场景时,情况又变成了右图。

我们观察的是点P。当我们要从眼睛的方向去渲染点P的时候,我们必须要做的一件事就是确定点P是否在阴影中。这个时候,我们将P转换到光源空间中,得到其深度信息Dz(假设其值为0.9)。然后,在光源空间中,该点是被盒子遮住的,它的整体深度信息是盒子的深度信息Dc(假设值为0.4)。比较一下Dz和Dc,明显Dz值较大,这就说明P点被遮住了,它在阴影中。然后,我们就可以将这点渲染成阴影了。

显示深度信息

我想你应该也已经想到方法了,没错,就是使用我们前面学过的帧缓存。我们要先将场景绘制到自建的帧缓存中(当然只需要深度值,这个可以设置),然后在正常渲染时通过比较深度值来确定是否该显示阴影。来,我们一步步操作:

首先生成一个帧缓存的ID:

unsigned int depthMapFBO;
glGenFramebuffers(1, &depthMapFBO); 

然后创建一个要附加给帧缓存的深度缓存纹理:

const unsigned int SHADOW_WIDTH = 1024, SHADOW_HEIGHT = 1024;

unsigned int depthMap;
glGenTextures(1, &depthMap);
glBindTexture(GL_TEXTURE_2D, depthMap);
glTexImage2D(GL_TEXTURE_2D, 0, GL_DEPTH_COMPONENT, 
             SHADOW_WIDTH, SHADOW_HEIGHT, 0, GL_DEPTH_COMPONENT, GL_FLOAT, NULL);
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_NEAREST);
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_NEAREST);
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_S, GL_REPEAT); 
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_T, GL_REPEAT); 

这里要注意,在生成纹理(调用glTexImage2D)时,将内部格式和格式都设置成深度值(GL_DEPTH_COMPONENT)。你肯定已经注意到,Shadow图的宽高和我们窗口的宽高值不一样。这是必须的,因为我们要为一个大场景保存深度信息,不单单只是一个窗口的大小。

纹理图生成之后,我们就将其附加到帧缓存上,还记的附加的方式吗:

glBindFramebuffer(GL_FRAMEBUFFER, depthMapFBO);
glFramebufferTexture2D(GL_FRAMEBUFFER, GL_DEPTH_ATTACHMENT, GL_TEXTURE_2D, depthMap, 0);
glDrawBuffer(GL_NONE);
glReadBuffer(GL_NONE);
glBindFramebuffer(GL_FRAMEBUFFER, 0);

这里和之前不一样的地方就是额外调用了glDrawBuffer和glReadBuffer函数,将它们设置成GL_NONE。因为我们只要获取深度信息,所以我们要明确告诉OpenGL不要绘制颜色,这样即使缺了颜色缓存使得帧缓存不完整,我们也能进行使用。

于是乎,我们就只要再走两个流程了:第一、绘制光照空间的场景到深度缓存中,取得深度图。第二、使用深度图,绘制观察空间的场景显示到窗口。翻译成伪代码就是:

// 1、绘制深度图
glViewport(0, 0, SHADOW_WIDTH, SHADOW_HEIGHT);
glBindFramebuffer(GL_FRAMEBUFFER, depthMapFBO);
glClear(GL_DEPTH_BUFFER_BIT);
// TODO,配置着色器和变换矩阵
...
RenderScene();
glBindFramebuffer(GL_FRAMEBUFFER, 0);
// 2、使用深度图绘制观察场景
glViewport(0, 0, SCR_WIDTH, SCR_HEIGHT);
glClear(GL_COLOR_BUFFER_BIT | GL_DEPTH_BUFFER_BIT);
// TODO,配置着色器和变换矩阵
...
glBindTexture(GL_TEXTURE_2D, depthMap);
RenderScene();

伪代码中隐藏了部分细节,但是足够看出一个完整的流程是如何了。需要再强调一遍的是,在绘制深度图之前一定要调用glViewport!因为shadow图的分辨率在绝大多数情况下都不会和窗口大小一样,所以一定要用glViewport设置分辨率。有兴趣的读者可以尝试一下不用glViewport或者用glViewport设置一个小分辨率看看效果。

光源空间变换

在第一个步骤中,我们需要让场景变换到光源空间中。这里我们耍个小聪明就是不进行透视变换而进行正交变换来模拟平行光(主要是方便)照射。代码如下:

//从光源位置的正交投影矩阵
float near_plane = 1.0f, far_plane = 7.5f;
glm::mat4 lightOrthoPoojection = glm::ortho(-10.0f, 10.0f, -10.0f, 10.0f, near_plane, far_plane);

投影矩阵会直接决定哪些物体的深度信息会被记录,哪些不会被记录,从而决定了哪些物体或片元能显示阴影。所以我们要谨慎的选择一个近裁剪面和远裁剪面。这里我们分别设置成1.0和7.5.

接着就是从光源角度的观察矩阵,非常简单,直接使用glm::lookAt函数:

//从光源位置的观察矩阵
glm::mat4 lightView = glm::lookAt(lightPos,glm::vec3(0.0f),glm::vec3(0.0f, 1.0f, 0.0f));

最后,将两个矩阵组合起来,方便传递给着色器:

//组合成光源空间的变换矩阵
glm::mat4 lightSpaceMatrix = lightOrthoPoojection * lightView;

这一步工作完成之后,我们还要写绘制用的着色器。方便起见,我们会用一个超级简单的着色器来绘制。

绘制深度图

新建两个着色器文件,命名为simpleDepthShader待用。

对于顶点着色器的要求是可以进行坐标变换,将坐标转换成光源空间的坐标,所以顶点着色器还比较正常:

#version 330 core
layout (location = 0) in vec3 aPos;

uniform mat4 lightSpaceMatrix;
uniform mat4 model;

void main()
{
    gl_Position = lightSpaceMatrix * model * vec4(aPos, 1.0);
}  

片元着色器就纯粹是个样子活了,一个空架子:

#version 330 core

void main()
{
} 

我们不需要显示什么颜色,所以片元着色器不需要任何输出。

最后,将计算好的矩阵传递给着色器使用:

//将矩阵传给着色器
simpleDepthShader.use();
simpleDepthShader.setMat4("lightSpaceMatrix", glm::value_ptr(lightSpaceMatrix));

于是,渲染深度图的完整代码就成型了:

//从光源位置的正交投影矩阵
float near_plane = 1.0f, far_plane = 7.5f;
glm::mat4 lightOrthoPoojection = glm::ortho(-10.0f, 10.0f, -10.0f, 10.0f, near_plane, far_plane);

//从光源位置的观察矩阵
glm::mat4 lightView = glm::lookAt(lightPos,glm::vec3(0.0f),glm::vec3(0.0f, 1.0f, 0.0f));

//组合成光源空间的变换矩阵
glm::mat4 lightSpaceMatrix = lightOrthoPoojection * lightView;

//将矩阵传给着色器
simpleDepthShader.use();
simpleDepthShader.setMat4("lightSpaceMatrix", glm::value_ptr(lightSpaceMatrix));

glViewport(0, 0, SHADOW_WIDTH, SHADOW_HEIGHT);
glBindFramebuffer(GL_FRAMEBUFFER, depthMapFBO);
glClear(GL_DEPTH_BUFFER_BIT);
glActiveTexture(GL_TEXTURE0);
glBindTexture(GL_TEXTURE_2D, woodTexture);
renderScene(simpleDepthShader);
glBindFramebuffer(GL_FRAMEBUFFER, 0);

将前面帧缓存文章中的着色器拷贝过来再次利用,对片元着色器做如下修改:

#version 330 core
out vec4 FragColor;
  
in vec2 TexCoords;

uniform sampler2D depthMap;

void main()
{             
    float depthValue = texture(depthMap, TexCoords).r;
    FragColor = vec4(vec3(depthValue), 1.0);
} 

大功告成,让我们来看看渲染好的深度信息。

从0开始的OpenGL学习(三十)-Shadow Map_第2张图片
深度信息图

就是这么漂亮!我知道你肯定没法显示这幅图,因为顶点坐标场景等东西都没有,所以这里的 源码就是给你作为参考的。限于篇幅,笔者只能挑重点的讲解,细节部分请读者自己完成。

渲染阴影

我们继续。有了这一张深度图,我们就可以着手绘制阴影了。先来理一下思路:对于每一个顶点,首先要将它变换到光源空间中计算其深度信息,然后要变换到观察空间中计算其真正渲染时的位置,这两个位置都需要在顶点着色器中计算完传递给片元着色器:

#version 330 core
layout (location = 0) in vec3 aPos;
layout (location = 1) in vec3 aNormal;
layout (location = 2) in vec2 aTexCoords;

out VS_OUT {
    vec3 FragPos;
    vec3 Normal;
    vec2 TexCoords;
    vec4 FragPosLightSpace; 
} vs_out;

uniform mat4 projection;
uniform mat4 view;
uniform mat4 model;
uniform mat4 lightSpaceMatrix;

void main()
{    
    vs_out.FragPos = vec3(model * vec4(aPos, 1.0));
    vs_out.Normal = transpose(inverse(mat3(model))) * aNormal;
    vs_out.TexCoords = aTexCoords;
    vs_out.FragPosLightSpace = lightSpaceMatrix * vec4(vs_out.FragPos, 1.0);   //在光源空间中的位置
    gl_Position = projection * view * model * vec4(aPos, 1.0);  //观察空间中的位置
}

光源空间的坐标通过光源空间变换矩阵来计算,然后放到输出块中一并传递给片元着色器。

继续整理思路:片元着色器要做的最重要的一件事就是判断片元位置是否接收到光照。通过传递过来的光源空间位置坐标,与深度图中的信息进行比较,如果大于深度图中的信息,表示该点被遮住,需要显示阴影。

#version 330 core
out vec4 FragColor;

in VS_OUT{
    vec3 FragPos;
    vec3 Normal;
    vec2 TexCoords;
    vec4 FragPosLightSpace;
} fs_in;

uniform sampler2D diffuseTexture;
uniform sampler2D shadowMap;

uniform vec3 lightPos;
uniform vec3 viewPos;

float ShadowCalculation (vec4 fragPosLightSpace) {
    //TODO,计算是否需要绘制阴影
}

void main()
{           
    vec3 color = texture(diffuseTexture, fs_in.TexCoords).rgb;
    vec3 normal = normalize(fs_in.Normal);
    vec3 lightColor = vec3(1.0);
    // 环境光
    vec3 ambient = 0.15 * color;
    // 漫反射光
    vec3 lightDir = normalize(lightPos - fs_in.FragPos);
    float diff = max(dot(lightDir, normal), 0.0);
    vec3 diffuse = diff * lightColor;
    // 镜面高光
    vec3 viewDir = normalize(viewPos - fs_in.FragPos);
    float spec = 0.0;
    vec3 halfwayDir = normalize(lightDir + viewDir);  
    spec = pow(max(dot(normal, halfwayDir), 0.0), 64.0);
    vec3 specular = spec * lightColor;    
    // 计算阴影
    float shadow = ShadowCalculation(fs_in.FragPosLightSpace);       
    vec3 lighting = (ambient + (1.0 - shadow) * (diffuse + specular)) * color;    
    
    FragColor = vec4(lighting, 1.0);
}

片元着色器的大部分代码是从Blinn-Phong光照模型一章中复制过来的,最大的不同就是加了一个阴影计算函数。根据计算的返回值,我们要对片元位置的漫反射和镜面高光颜色进行处理,注意,不管如何环境光都不会处理,这是一个基本亮度。如果在阴影中,函数返回值是1.0,这样漫反射和镜面高光都不会计算在内,形成一个很明显的阴影区域。

下面我们一步步来执行片元是否在阴影的计算。首先要明确一点,我们通过gl_Position传递过来的数据,OpenGL已经自动进行过perspective divide处理,就是将裁剪空间坐标[-w,w]变换成[-1,1]。方式就是将x,y,z分量都除以w分量。但是FragPosLightSpace没有计算过,所以我们要先进行这一步计算:

//进行perspective divide
vec3 projCoords = fragPosLightSpace.xyz / fragPosLightSpace.w;

因为深度值信息范围是[0,1]。所以我们的片元坐标都要转换到这个范围:

//转换到[0,1]范围
projCoords = (projCoords + 1) * 0.5;

接下来从阴影图中采集当前位置的深度信息:

//采样最近点
float closestDepth = texture(shadowMap, projCoords.xy).r;

获取当前片元的深度信息,就是z值

//当前的深度
float currentDepth = projCoords.z;

最后一步比较当前的深度和shadowMap中的深度值大小,如果当前深度较大,则返回1.0表示阴影值:

//比较当前深度和最近采样点深度
float shadow = currentDepth > closestDepth? 1.0 : 0.0;

完整的ShadowCalculation函数变成:

float ShadowCalculation (vec4 fragPosLightSpace) {
    //归一化坐标
    vec3 projCoords = fragPosLightSpace.xyz / fragPosLightSpace.w;
    //转换到[0,1]范围
    projCoords = (projCoords + 1) * 0.5;
    //采样最近点
    float closestDepth = texture(shadowMap, projCoords.xy).r;
    //当前的深度
    float currentDepth = projCoords.z;
    //比较当前深度和最近采样点深度
    float shadow = currentDepth > closestDepth? 1.0 : 0.0;

    return shadow;
}

编译运行:

运行效果

Shit!看上去一塌糊涂!不过至少有阴影存在了,也是一个巨大的进步。完整的代码到 这里下载。下面,我们会在这基础上修改得到更好的效果。

提升阴影质量

虽然阴影显示出来了,但是腐朽的气息和阴影一样明显。我们显示的质量实在是太渣渣了,这种质量哪个不长眼的玩家还会来玩咱的游戏?所以,接下来,我们就要把注意力集中到提高阴影质量上了(优化工作)。

Shadow acne

最明显的就是条纹状的痕迹:


从0开始的OpenGL学习(三十)-Shadow Map_第3张图片
条纹痕迹

这个问题被称为Shadow acne(没啥好中文翻译,直接用英文,或许可以将其翻译成阴影疮),其原因可以用下面这图来解释:


从0开始的OpenGL学习(三十)-Shadow Map_第4张图片
Shadow ance原理

图中蓝色的线表示阴影图中的一个片元,黄色箭头表示光线方向,表面上黄色的部分表示亮点,黑色的部分表示暗点(图比较丑,大家将就着看:))。因为我们的阴影图分辨率有限,在从观察空间渲染场景时多个片元可能会取到同一个阴影图的信息。加上我们的阴影图绘制的时候是从某个角度来绘制的,这就导致了某些片元在比深度图近(黄色部分),某些片元比深度图远(黑色部分),从而造成了这种类似条纹的痕迹。

知道原理后,我们解决起来也就简单了。我们可以将整个平面往上移一定的距离,或者将深度图信息往下移一定的距离,这个偏移的距离成为shadow bias。这里我们选择把平面往上移一定的距离,比如说0.05,看看效果:

//比较当前深度和最近采样点深度
float shadow = (currentDepth - 0.05) > closestDepth? 1.0 : 0.0;

运行我们的程序(连编译都不用,多省事),明显就能看到效果好很多:


运行效果

Peter panning

当我们对深度值进行了偏移之后,这就会导致另一个问题,请看:


从0开始的OpenGL学习(三十)-Shadow Map_第5张图片
Peter panning

看到没,我们的阴影脱离了物体的边缘,往后飘了一段。这种现象被称为:peter panning。出现这问题就说明我们的偏移值调整地太大了,要缩小点。之前我们设置成0.05,我们把它设置成0.005试试。


从0开始的OpenGL学习(三十)-Shadow Map_第6张图片
运行效果

嗯,看上去好很多了。那么,你肯定会有疑问,shadow bias该是多少合适呢?很遗憾,笔者也不知道,这就取决于经验了。或许下面这行代码可以解决大部分的问题:
float bias = max(0.05 * (1.0 - dot(normal, lightDir)), 0.005);  

不过这都是经验的东西,多多实践才能调整出合适的bias值。

超出深度图区域

还有一个严重的问题就是前面那一大片阴影区域:


从0开始的OpenGL学习(三十)-Shadow Map_第7张图片
超出深度图区域

这一大片阴影部分是超出了深度图区域的部分(我们的深度图也只有1024x1024)。因为现实原因,我们不可能将深度图放的非常大,所以这种超出深度图区域的问题肯定会存在,我们要想个办法解决。

当然,解决的方法也很简单,既然超出了区域,那就不产生阴影了。所有深度值大于1.0的片元shadow值都设置成0.0就可以:

//超出深度图区域的修正
if (projCoords.z > 1.0)
    shadow = 0.0;

运行一下,效果完美。


从0开始的OpenGL学习(三十)-Shadow Map_第8张图片
运行效果

阴影锯齿

最后一个问题,就是下图的问题:


从0开始的OpenGL学习(三十)-Shadow Map_第9张图片
阴影锯齿

从近处看,可以很明显地看到阴影的锯齿状边缘。因为我们对阴影的处理是要么显示阴影,要么不显示阴影,没有第三种选择,所以就出现了这么生硬的边缘。在现实生活中,由于光子的波动性,我们看到的阴影边缘是非常柔和的,有一个渐变的过程。

解决这个问题的方法叫做百分比渐进过滤(PCF,percentage-closer filtering)。具体的操作是对某个位置相邻的8个片元也进行采样,将这些值加起来之后除以9来决定当前片元的阴影值。翻译成代码就是:

float shadow = 0.0;
vec2 texelSize = 1.0 / textureSize(shadowMap, 0);
for(int x = -1; x <= 1; ++x)
{
    for(int y = -1; y <= 1; ++y)
    {
        float pcfDepth = texture(shadowMap, projCoords.xy + vec2(x, y) * texelSize).r; 
        shadow += currentDepth - bias > pcfDepth ? 1.0 : 0.0;        
    }    
}
shadow /= 9.0;

运行代码,你会看到,我们的阴影边缘成了这个样子:


运行效果

并没有什么难度,这里再将代码上传给大家参考。

细心的你可能已经发现,场景中还存在一个问题,就是在其他地方还有阴影区域:


从0开始的OpenGL学习(三十)-Shadow Map_第10张图片
遗留问题

这是怎么回事呢?笔者在这里卖个关子,希望读者可以思考一下原因,如果想到了,欢迎给笔者发私信(别在下面回复,避免影响其他读者的思考)。

总结

在本章中,我们理解了阴影图(shadow map)的实现原理并用其渲染了场景,显示出阴影。阴影图的原理十分简单,就是从光源位置的深度图而已。在渲染的过程中,我们遇到了shadow acne,peter panning,超出采样区以及阴影锯齿的问题,我们也都分别想出了解决方法。最后,笔者还遗留了一个小问题,欢迎大家把思考的结果(过程)私信给笔者,能和大家交流进步是笔者最大乐趣~

下一篇
目录
上一篇

参考资料

www.learnopengl.com(非常好的网站,建议学习)
http://blog.csdn.net/ronintao/article/details/51649664
http://www.opengl-tutorial.org/intermediate-tutorials/tutorial-16-shadow-mapping/

你可能感兴趣的:(从0开始的OpenGL学习(三十)-Shadow Map)