Shadow Mapping阴影贴图
基本思路
阴影贴图的思想较为简单:首先选择光源所在的位置为视角进行渲染,按照阴影产生的原理,我们所能看到的东西能被点亮,而反之看不到的部分则处在阴影之中。
容易想到的解决思路是:对光源发出的射线上的点进行遍历,并记录第一个与物体相交的点。如果在这条光射线上的点比这个交点距离光源的距离更远,那么较远的点处在阴影之中。
但是在渲染过程中逐一对不同方向上的射线、同一射线上的无数个点进行计算比较显然是不切实际的,所以考虑开启深度测试,使用深度测试的方法来简化实现的过程。
这里我们考虑从光源的透视图来渲染场景,并将深度值的结果存储在纹理之中——也就是说,对光源的透视图所见的最近的深度值进行采样,所得到的这个深度值就是我们在光源的角度下透视图能够见到的第一个片元。所有的这些深度值被称作深度贴图(depth map)。
有了深度贴图之后,我们可以在渲染原有基本场景的基础上直接使用深度贴图来计算片元是否需要调整成阴影即可。
STEP1深度贴图的获取
- 帧缓冲的概念
在前面的作业中,我们用到的屏幕缓冲有很多:用于写入颜色值的颜色缓冲、用于写入深度信息的深度缓冲和允许我们根据一些条件来丢弃特定片段的模板缓冲。所有的这些缓冲结合起来叫做帧缓冲(Frame Buffer)。
个人理解:帧缓冲可以看做是某一帧对应的所有信息的合集。而在我们前面实现的场景中,我们是在默认的帧缓冲上进行的,如果设置了自己的帧缓冲,那么我们可以直接对已经存在的场景进行处理(而不是需要重新建立符合新要求的场景)。
帧缓冲的具体实现方式参考教程。 - 准备工作
我们需要将深度贴图存储在一个纹理中来用于后续对于阴影的计算,所以首先我们为深度贴图建立一个帧缓冲对象(FBO):
GLuint depthMapFBO;
glGenFramebuffers(1, &depthMapFBO);
然后我们为这个帧缓冲创建一些附件,并将这些附件附加到帧缓冲上。
首先是纹理附件。按照作业四中的方法创建一个纹理,不同之处在于此次实验中我们只关心深度值,所以将纹理的格式指定为GL_DEPTH_COMPONENT
,然后将生成的深度纹理作为帧缓冲的深度缓冲附加到帧缓冲上:
glBindFramebuffer(GL_FRAMEBUFFER, depthMapFBO);
// 纹理附加
glFramebufferTexture2D(GL_FRAMEBUFFER, GL_DEPTH_ATTACHMENT, GL_TEXTURE_2D, depthMap, 0);
// 在这次作业中,我们需要的仅仅是深度缓冲,颜色缓冲是没有必要的
// 所以需要设置下面两个语句来告诉OpenGL我们不使用任何颜色数据进行渲染
glDrawBuffer(GL_NONE);
glReadBuffer(GL_NONE);
glBindFramebuffer(GL_FRAMEBUFFER, 0);
- 生成深度贴图(render loop中的处理)
使用深度贴图来渲染场景。与前面的处理结合,这里分两步进行渲染:首先渲染深度贴图,然后使用深度贴图渲染场景:
// 从光源的角度渲染深度缓冲
simpleDepthShader.use();
simpleDepthShader.setMat4("lightSpaceMatrix", lightSpaceMatrix);
// viewpoint设置!!
glViewport(0, 0, SHADOW_WIDTH, SHADOW_HEIGHT);
// 关于depthMap深度贴图的着色器内容设置,见STEP2
glBindFramebuffer(GL_FRAMEBUFFER, depthMapFBO);
glClear(GL_DEPTH_BUFFER_BIT);
glActiveTexture(GL_TEXTURE0);
glBindTexture(GL_TEXTURE_2D, woodTexture);
renderScene(simpleDepthShader);
glBindFramebuffer(GL_FRAMEBUFFER, 0);
// 重置视点
glViewport(0, 0, SCR_WIDTH, SCR_HEIGHT);
glClear(GL_COLOR_BUFFER_BIT | GL_DEPTH_BUFFER_BIT);
// 利用深度贴图渲染场景
// --------------------------------------------------------------
glViewport(0, 0, SCR_WIDTH, SCR_HEIGHT);
glClear(GL_COLOR_BUFFER_BIT | GL_DEPTH_BUFFER_BIT);
shader.use();
glm::mat4 projection = glm::perspective(camera.Zoom, (float)SCR_WIDTH / (float)SCR_HEIGHT, 0.1f, 100.0f);
glm::mat4 view = camera.GetViewMatrix();
...// 设置shader
glActiveTexture(GL_TEXTURE0);
glBindTexture(GL_TEXTURE_2D, woodTexture);
glActiveTexture(GL_TEXTURE1);
glBindTexture(GL_TEXTURE_2D, depthMap);
renderScene(shader);
- 光源设置
这里是对光源空间的变换。用来确保为物体选择了合适的投影(正交/透视)和视图矩阵(使用lookat获得)。
首先是对于光线的选择:(bonus1内容)
// 透视光线,通常用于点光源、舞台光线等。同时注意如果选择使用透视光线,光源对应的着色器也需要进行相应的处理
if (perspective) {
Shader debugDepthQuad("debug_quad.vs.txt", "debug_quad_perspective.fs.txt");
lightProjection = glm::perspective(glm::radians(45.0f)*10, (GLfloat)SHADOW_WIDTH / (GLfloat)SHADOW_HEIGHT, near_plane, far_plane);
ortho = false;
}
// 正交投影光线,用于平行光线的设置,如太阳光等
if (ortho) {
lightProjection = glm::ortho(-10.0f, 10.0f, -10.0f, 10.0f, near_plane, far_plane);
perspective = false;
}
接着,由于我们需要从光源的角度来观察场景,那么视图矩阵可以采用lookat函数获取:
glm::mat4 lightView = glm::lookAt(glm::vec(-2.0f, 4.0f, -1.0f), glm::vec3(0.0f), glm::vec3(1.0));
二者相乘就得到了我们在光源空间内变换需要的矩阵:glm::mat4 lightSpaceMatrix = lightProjection * lightView;
STEP2渲染至深度贴图(shader source的处理)
在STEP1中,我们已经知道了如何得到深度贴图,并且知道了利用光的透视图(深度贴图)渲染的大致过程。下面是我们在渲染过程中需要用到的着色器的设置。
- 将顶点变换到光源空间(对应深度贴图获取过程中的渲染深度缓冲)
即对cube的顶点进行lightSpaceMatrix
变换,使其变换到光源空间,以便进一步获取深度贴图。所以在顶点着色器中对顶点位置处理即可;而由于这里仅仅是对深度进行处理,所以片段着色器设置为空即可:
// vertex shader
#version 330 core
layout (location = 0) in vec3 position;
uniform mat4 lightSpaceMatrix;
uniform mat4 model;
void main()
{
gl_Position = lightSpaceMatrix * model * vec4(position, 1.0f);
}
- 深度贴图渲染到四边形上
对于正交投影的处理较为简单,
#version 330 core
out vec4 FragColor;
in vec2 TexCoords;
uniform sampler2D depthMap;
uniform float near_plane;
uniform float far_plane;
// 透视投影时用到,将非线性的深度值转换为线性的,从而得到易于观察的深度值
float LinearizeDepth(float depth)
{
float z = depth * 2.0 - 1.0; // Back to NDC
return (2.0 * near_plane * far_plane) / (far_plane + near_plane - z * (far_plane - near_plane));
}
void main()
{
float depthValue = texture(depthMap, TexCoords).r;
// FragColor = vec4(vec3(LinearizeDepth(depthValue) / far_plane), 1.0); // 透视
FragColor = vec4(vec3(depthValue), 1.0); // 正交
}
STEP3渲染阴影
STEP1、2已经得到了完整的深度贴图,接下来就是生成阴影的步骤,显然依旧是在着色器(shader mapping对应的着色器)中对场景进行处理。
首先是在顶点着色器中,我们需要对场景中的片元判断其是否在阴影之中,实现如下:
void main()
{
gl_Position = projection * view * model * vec4(position, 1.0f);
vs_out.FragPos = vec3(model * vec4(position, 1.0));
vs_out.Normal = transpose(inverse(mat3(model))) * normal;
vs_out.TexCoords = texCoords;
// 变换到光源空间的坐标
vs_out.FragPosLightSpace = lightSpaceMatrix * vec4(vs_out.FragPos, 1.0);
}
而对于片段着色器,我们选择Phong模型进行渲染。对于阴影部分,我们设置一个shadow值,如果片元在阴影内则shadow=1,反之则shadow=0。回忆上次作业中,Phong模型中主要的光照分量是环境光、漫反射光和镜面反射光,显然这里我们需要将阴影对应的系数shadow与漫反射分量和镜面反射分量相乘即可。
但是在实际实现的过程中,我们需要对shadow的值进行更细致的处理,这里放在函数ShadowCalculation
中,实现如下:
float ShadowCalculation(vec4 fragPosLightSpace)
{
// 透视除法,对于正交投影没有任何影响
vec3 projCoords = fragPosLightSpace.xyz / fragPosLightSpace.w;
// normalize
projCoords = projCoords * 0.5 + 0.5;
// 获取光源坐标下最近的深度值
float closestDepth = texture(shadowMap, projCoords.xy).r;
// 获取当前片元在光源坐标下的深度值
float currentDepth = projCoords.z;
// 越界处理
if(projCoords.z > 1.0)
shadow = 0.0;
return shadow;
}
然后将这个shadow系数添加到lighting结果的计算中去即可:
float shadow = ShadowCalculation(fs_in.FragPosLightSpace);
vec3 lighting = (ambient + (1.0 - shadow) * (diffuse + specular)) * color;
STEP4阴影优化
- 针对阴影中的线条
这种情况是由于多个片元从同一个深度值采样所造成的。比如当多个片元从同一个斜坡的深度纹理像素中采样时,有的在地板上有的在地板下,也就是说有的片元被认为在阴影中,有的不在,于是产生了条纹。可以通过添加偏移值处理:
float bias = max(0.05 * (1.0 - dot(normal, lightDir)), 0.005);
float shadow = currentDepth - bias > closestDepth ? 1.0 : 0.0;
- 针对场景一半明亮一半暗
这是由于采样过多和坐标超出光的正交视锥的结果。可以通过储存一个边框颜色,然后将深度贴图的纹理环绕选项设置为GL_CLAMP_TO_BORDER
来解决采样过多的问题:
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 borderColor[] = { 1.0, 1.0, 1.0, 1.0 };
glTexParameterfv(GL_TEXTURE_2D, GL_TEXTURE_BORDER_COLOR, borderColor);
而对于第二种情况,可以通过检查边界来优化。在上面我们已经处理过。
Bonus
1、分别实现正交、透视投影下的shadow mapping
在前面已经实现。具体需要修改的地方是render loop中添加对投影方式的选择,以及shadow mapping对应片段着色器中着色方式。
2、阴影优化
- 对阴影锯齿边的简单处理
PCF的思路是从深度贴图中多次取样,然后对不同的结果进行平均即可得到较柔和的阴影,实现如下:
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;