Shadow Mapping (1)
openGL高级光照部分目录 见 openGL高级光照部分目录
这一节实在太长了,我分成两小节
阴影是由于遮挡而缺少灯光的结果。当一个光源的光线没有击中一个物体,因为它被其他物体挡住了,这个物体就处于阴影中。阴影为照亮的场景添加了大量的真实感,使观看者更容易观察对象之间的空间关系。它们给我们的场景和物体以更大的深度感。例如,请查看具有和不包含阴影的场景的以下图像:
你可以看到,有了阴影,物体之间的关系变得更加明显。例如,一个立方体漂浮在其他立方体之上的事实,只有当我们有阴影时才真正明显。
阴影的实现有点棘手,特别是在当前的实时(光栅化图形)研究中,还没有一个完美的阴影算法被开发出来。有几种很好的阴影近似技术,但它们都有一些我们必须考虑到的小怪癖和烦恼。
大多数视频游戏使用的一种技术是阴影映射,这种技术可以产生不错的效果,并且相对容易实现。阴影映射不太难理解,在性能上不需要太多成本,而且很容易扩展到更高级的算法中(如全向阴影贴图和级联阴影贴图)。
阴影映射
阴影贴图背后的想法非常简单:我们从灯光的角度渲染场景,我们从灯光透视看到的所有东西都被照亮了,我们看不到的东西必须在阴影中。想象一个地板部分,在它和光源之间有一个大盒子。因为光源会看到这个盒子,而不是地板部分,当朝它的方向看时,特定的地板部分应该在阴影中。
里所有的蓝线代表光源可以看到的碎片。被遮挡的碎片显示为黑线:这些线被渲染为阴影。如果我们能在光线击中容器之前,先从容器上画出一条光线。结果,浮动容器的碎片被照亮,最右边的容器碎片没有被照亮,因此处于阴影中。
我们要得到光线上第一次击中物体的点,并将这个最近的点与光线上的其他点进行比较。然后我们做一个基本测试,看看测试点的光线位置是否比最近的点更靠近光线,如果是,测试点必须在阴影中。迭代来自这样一个光源的数千条光线是一种非常低效的方法,并且不适合实时渲染。我们可以做类似的事情,但不需要投射光线。相反,我们使用我们非常熟悉的东西:深度缓冲。
您可能记得在深度测试一章中,深度缓冲区中的一个值对应于从相机角度钳制到[0,1]的碎片的深度。如果我们要从灯光的角度渲染场景并将结果深度值存储在纹理中会怎么样?这样,我们可以从灯光的透视图上采样最近的深度值。毕竟,深度值显示从灯光透视图可见的第一个片段。我们将所有这些深度值存储在一个称为深度贴图或阴影贴图的纹理中。
左图显示了一个平行光源(所有光线平行),在立方体下方的曲面上投射阴影。使用存储在深度图中的深度值,我们找到最近的点,并用它来确定碎片是否在阴影中。我们通过使用特定于该光源的视图和投影矩阵渲染场景(从灯光的透视图)来创建深度贴图。这个投影和视图矩阵一起形成了一个变换T,它将任何3D位置转换为灯光的(可见)坐标空间。
平行光没有一个位置,因为它的模型是无限远的。但是,为了进行阴影贴图,我们需要从灯光的角度渲染场景,从而从沿着灯光方向的某个位置渲染场景。
在右图中,我们看到了相同的平行光和观察者。我们在点渲染一个片段,我们必须确定它是否在阴影中。为此,我们首先使用T将点转换为灯光的坐标空间。由于点现在可以从灯光的角度看到,因此其z坐标对应于其深度,在本例中为0.9。使用点我们还可以索引深度/阴影贴图,以从灯光的透视图获得最近的可见深度,即在采样深度为0.4的点C。由于索引深度贴图返回的深度小于点处的深度,我们可以得出结论:点被遮挡,因此处于阴影中。
因此,阴影贴图由两个过程组成:首先我们渲染深度贴图,在第二个过程中,我们将场景渲染为法线,并使用生成的深度贴图来计算碎片是否处于阴影中。这听起来有点复杂,但只要我们一步一步地走完这项技术,它就可能开始有意义了。
深度图
第一个过程需要我们生成一个深度图。深度贴图是从灯光透视图渲染的深度纹理,我们将使用它来测试阴影。因为我们需要将场景的渲染结果存储到纹理中,所以我们将再次需要帧缓冲区。
首先,我们将创建用于渲染深度贴图的帧缓冲区对象:
unsigned int depthMapFBO;
glGenFramebuffers(1, &depthMapFBO);
接下来,我们将创建一个2D纹理,用作帧缓冲区的深度缓冲区:
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);
生成深度图看起来不应该太复杂。因为我们只关心深度值,所以我们将纹理的格式指定为GL_depth_COMPONENT。我们还为纹理指定1024的宽度和高度:这是深度贴图的分辨率。
使用生成的深度纹理,我们可以将其附加为帧缓冲区的深度缓冲区:
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);
我们只需要深度信息时,从灯光的角度渲染场景,因此不需要颜色缓冲区。但是,没有颜色缓冲区的帧缓冲区对象是不完整的,因此我们需要明确地告诉OpenGL我们不会渲染任何颜色数据。我们通过使用glDrawBuffer和glReadbuffer将read和draw buffer设置为GL_NONE。
使用正确配置的帧缓冲区将深度值渲染到纹理,我们可以开始第一个过程:生成深度贴图。当与第二个过程结合时,完整的渲染阶段看起来像这样:
// 1. first render to depth map
glViewport(0, 0, SHADOW_WIDTH, SHADOW_HEIGHT);
glBindFramebuffer(GL_FRAMEBUFFER, depthMapFBO);
glClear(GL_DEPTH_BUFFER_BIT);
ConfigureShaderAndMatrices();
RenderScene();
glBindFramebuffer(GL_FRAMEBUFFER, 0);
// 2. then render scene as normal with shadow mapping (using depth map)
glViewport(0, 0, SCR_WIDTH, SCR_HEIGHT);
glClear(GL_COLOR_BUFFER_BIT | GL_DEPTH_BUFFER_BIT);
ConfigureShaderAndMatrices();
glBindTexture(GL_TEXTURE_2D, depthMap);
RenderScene();
这段代码省略了一些细节,但是它将提供阴影映射的一般概念。这里需要注意的是对glViewport的调用。因为阴影贴图的分辨率通常与我们最初渲染场景的分辨率(通常是窗口分辨率)不同,所以我们需要更改视口参数以适应阴影贴图的大小。如果我们忘记更新视口参数,生成的深度贴图将不完整或太小。
光空间变换
前面代码片段中的一个未知项是ConfigureShaderAndMatrices函数。在第二个步骤中,一切照旧:确保设置了正确的投影和视图矩阵,并为每个对象设置了相关的模型矩阵。但是,在第一个过程中,我们需要使用不同的投影和视图矩阵从灯光的角度渲染场景。
因为我们要模拟一个定向光源,它所有的光线都是平行的。因此,我们将对没有透视变形的光源使用正交投影矩阵:
float near_plane = 1.0f, far_plane = 7.5f;
glm::mat4 lightProjection = glm::ortho(-10.0f, 10.0f, -10.0f, 10.0f, near_plane, far_plane);
这是本章演示场景中使用的正交投影矩阵示例。因为投影矩阵间接地决定了可见对象的范围(例如,未剪裁的对象),所以您需要确保投影视锥的大小正确地包含您希望在深度贴图中的对象。当对象或碎片不在深度贴图中时,它们不会产生阴影。
要创建一个视图矩阵来变换每个对象,使它们从灯光的角度可见,我们将使用著名的glm::lookAt函数;这一次,光源的位置指向场景的中心。
glm::mat4 lightView = glm::lookAt(glm::vec3(-2.0f, 4.0f, -1.0f),
glm::vec3( 0.0f, 0.0f, 0.0f),
glm::vec3( 0.0f, 1.0f, 0.0f));
将这两者结合起来,我们就得到了一个光空间变换矩阵,它将每个世界空间向量转换成从光源可见的空间;这正是我们渲染深度贴图所需的。
glm::mat4 lightSpaceMatrix = lightProjection * lightView;
glm::mat4 lightSpaceMatrix = lightProjection * lightView;
这个光空间矩阵是我们之前用T表示的变换矩阵。有了这个光空间矩阵,只要我们给每个着色器投影矩阵和视图矩阵的光空间等价物,我们就可以照常渲染场景。然而,我们只关心深度值,而不是所有昂贵的碎片(光照)计算。为了节省性能,我们将使用另一种更简单的着色器来渲染深度贴图。
渲染到深度贴图
当我们从灯光的角度渲染场景时,我们更希望使用一个简单的着色器,它只将顶点变换到灯光空间,而不是其他元素。对于这样一个名为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);
}
该顶点着色器采用每对象模型、一个顶点,并使用lightSpaceMatrix将所有顶点变换为灯光空间。
由于我们没有颜色缓冲区并禁用了绘制和读取缓冲区,因此生成的片段不需要任何处理,因此我们可以简单地使用空片段着色器:
#version 330 core
void main()
{
// gl_FragDepth = gl_FragCoord.z;
}
此空片段着色器不进行任何处理,并且在其运行结束时更新深度缓冲区。我们可以通过取消注释它的一行来显式地设置深度,但无论如何,这实际上就是场景后面发生的事情。
渲染深度/阴影贴图现在有效地变成:
simpleDepthShader.use();
glUniformMatrix4fv(lightSpaceMatrixLocation, 1, GL_FALSE, glm::value_ptr(lightSpaceMatrix));
glViewport(0, 0, SHADOW_WIDTH, SHADOW_HEIGHT);
glBindFramebuffer(GL_FRAMEBUFFER, depthMapFBO);
glClear(GL_DEPTH_BUFFER_BIT);
RenderScene(simpleDepthShader);
glBindFramebuffer(GL_FRAMEBUFFER, 0);
这里RenderScene函数接受一个着色器程序,调用所有相关的绘图函数,并在必要时设置相应的模型矩阵。
结果是一个很好填充的深度缓冲区,从灯光的角度保存每个可见片段的最近深度。通过将该纹理渲染到填充屏幕的2D四边形(类似于我们在帧缓冲区一章末尾的“后处理”部分中所做的操作),我们得到如下结果:
为了将深度贴图渲染到四边形上,我们使用了以下片段着色器:
#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);
}
请注意,使用透视投影矩阵而不是正交投影矩阵显示深度时会有一些细微的变化,因为使用透视投影时深度是非线性的。在本章的最后,我们将讨论这些细微的差异。
您可以在这里找到将场景渲染为深度贴图的源代码。
渲染阴影
使用正确生成的深度贴图,我们可以开始渲染实际的阴影。检查片段是否处于阴影中的代码(很明显)在片段着色器中执行,但我们在顶点着色器中执行灯光空间变换:
#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 * vec4(vs_out.FragPos, 1.0);
}
这里新增的是额外的输出向量FragPosLightSpace。我们使用相同的lightSpaceMatrix(用于在深度贴图阶段将顶点转换为灯光空间)并将世界空间顶点位置转换为灯光空间,以便在片段着色器中使用。
我们将用于渲染场景的主片段着色器使用Blinn Phong照明模型。然后在片段着色器中计算阴影值,该值在片段处于阴影时为1.0,而在不处于阴影中时为0.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)
{
[...]
}
void main()
{
vec3 color = texture(diffuseTexture, fs_in.TexCoords).rgb;
vec3 normal = normalize(fs_in.Normal);
vec3 lightColor = vec3(1.0);
// ambient
vec3 ambient = 0.15 * color;
// diffuse
vec3 lightDir = normalize(lightPos - fs_in.FragPos);
float diff = max(dot(lightDir, normal), 0.0);
vec3 diffuse = diff * lightColor;
// specular
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;
// calculate shadow
float shadow = ShadowCalculation(fs_in.FragPosLightSpace);
vec3 lighting = (ambient + (1.0 - shadow) * (diffuse + specular)) * color;
FragColor = vec4(lighting, 1.0);
}
碎片着色器很大程度上是我们在“高级照明”一章中使用的副本,但添加了阴影计算。我们声明了一个函数ShadowCalculation来完成大部分的阴影工作。在片段着色器的末尾,我们将漫反射和高光贡献乘以阴影组件的倒数,例如碎片不在阴影中的程度。此片段着色器将灯光空间片段位置和从第一个渲染过程生成的深度贴图作为额外输入。
要检查片段是否处于阴影中,首先要将片段空间中的灯光空间片段位置转换为规范化的设备坐标。当我们在顶点着色器中将剪辑空间的顶点位置输出到gl_Position时,OpenGL会自动进行透视分割,例如通过将x、y和z分量除以向量的w分量,来转换[-w,w]到[-1,1]范围内的剪辑空间坐标。由于片段空间FragPosLightSpace没有通过gl_Position传递给片段着色器,因此我们必须执行以下透视划分:
float ShadowCalculation(vec4 fragPosLightSpace)
{
// perform perspective divide
vec3 projCoords = fragPosLightSpace.xyz / fragPosLightSpace.w;
[...]
}
这将返回片段在[-1,1]范围内的光空间位置。
当使用正交投影矩阵时,顶点的w分量保持不变,所以这一步实际上是毫无意义的。然而,当使用透视投影时,它是必要的,所以保持这条线可以确保它与两个投影矩阵一起工作。
因为深度图的深度在[0,1]范围内,而且我们还希望使用projCoords从深度图中采样,所以我们将NDC坐标转换为范围[0,1]:
projCoords = projCoords * 0.5 + 0.5;
使用这些投影坐标,我们可以采样深度贴图,因为projCoords生成的[0,1]坐标直接对应于第一次渲染过程中转换的NDC坐标。这给我们提供了从灯光角度看最接近的深度:
float closestDepth = texture(shadowMap, projCoords.xy).r;
为了得到这个碎片的当前深度,我们只需检索投影向量的z坐标,从灯光的角度来看,这个坐标等于碎片的深度。
float currentDepth = projCoords.z;
实际的比较只是检查currentDepth是否高于closestDepth,如果是,则片段处于阴影中:
float shadow = currentDepth > closestDepth ? 1.0 : 0.0;
然后,完整的ShadowCalculation 函数变为:
float ShadowCalculation(vec4 fragPosLightSpace)
{
// perform perspective divide
vec3 projCoords = fragPosLightSpace.xyz / fragPosLightSpace.w;
// transform to [0,1] range
projCoords = projCoords * 0.5 + 0.5;
// get closest depth value from light's perspective (using [0,1] range fragPosLight as coords)
float closestDepth = texture(shadowMap, projCoords.xy).r;
// get depth of current fragment from light's perspective
float currentDepth = projCoords.z;
// check whether current frag pos is in shadow
float shadow = currentDepth > closestDepth ? 1.0 : 0.0;
return shadow;
}
在第二个渲染过程中**此着色器、绑定正确的纹理以及**默认投影和视图矩阵,将得到与下图类似的结果:
如果你做得对的话,你确实应该看到地板和立方体上的阴影(尽管有很多人工痕迹)。您可以在这里找到演示应用程序的源代码。