欢迎您访问程序员文章站本站旨在为大家提供分享程序员计算机编程知识!
您现在的位置是: 首页

OpenGL学习笔记34-Shadow Mapping

程序员文章站 2022-05-23 13:29:05
...

Shadow Mapping

Advanced-Lighting/Shadows/Shadow-Mapping

阴影是由于遮挡而缺乏光的结果。当一个光源的光线因为被其他物体遮挡而没有击中物体时,该物体处于阴影中。阴影为被照亮的场景添加了大量的真实感,使观者更容易观察物体之间的空间关系。它们给我们的场景和物体更大的深度感。举个例子,看看下面有阴影和没有阴影的场景:

OpenGL学习笔记34-Shadow Mapping

你可以看到,有了阴影,物体之间的关系变得更加明显。例如,只有当我们有阴影的时候,其中一个立方体浮在其他立方体的上方才会很明显。

阴影的实现有点棘手,特别是在当前实时(光栅化图形)研究中,一个完美的阴影算法还没有被开发出来。有一些很好的阴影近似技术,但它们都有一些我们必须考虑的小怪癖和烦恼。

大多数电子游戏使用的一种技术是阴影映射,它可以获得不错的效果,并且相对容易实现。阴影映射并不难理解,不会在性能上花费太多,并且很容易扩展到更高级的算法(如全向阴影映射Omnidirectional Shadow Maps和级联阴影映射)。

Shadow mapping

阴影贴图背后的想法非常简单:我们从光的角度渲染场景,我们从光的角度看到的所有东西都被照亮了,而我们看不到的所有东西都必须在阴影中。想象一个地板部分,在它和光源之间有一个大盒子。因为光源会看到这个盒子而不是地板部分时,看它的方向,特定的地板部分应该是在阴影中。

OpenGL学习笔记34-Shadow Mapping

这里所有的蓝线代表了光源可以看到的碎片。被遮挡的片段显示为黑线:这些被渲染为阴影。如果我们要从光源画一条线或光线到最右边的盒子上的一个片段,我们可以看到光线首先击中浮动容器,然后再击中最右边的容器。结果,浮动容器的片段被点亮,而最右边的容器片段没有点亮,因此处于阴影中。

我们想要得到射线上第一次击中物体的点然后比较这条射线上最近的点和这条射线上的其他点。然后我们做一个基本的测试,看看一个测试点的射线位置是否比最近的点在光线下更远,如果是这样,那么这个测试点一定在阴影中。从这样一个光源中迭代可能上千条光线是一种非常低效的方法,并且不能很好地用于实时渲染。我们可以做类似的事情,但不投射光线。相反,我们使用我们非常熟悉的东西:深度缓冲区。

您可能还记得在深度测试一章中,深度缓冲区中的一个值对应于从摄像机的角度看夹紧到[0,1]的片段的深度。如果我们从光的角度渲染场景,并将产生的深度值存储在纹理中会怎么样呢?这样,我们就可以从光的角度来采样最近的深度值。毕竟,深度值显示的是从光的角度可见的第一个片段。我们将所有这些深度值存储在一个纹理中,我们称之为深度贴图或阴影贴图。

OpenGL学习笔记34-Shadow Mapping

左边的图像显示了一个方向光源(所有光线都是平行的)在立方体下面的表面上投下阴影。使用存储在深度映射中的深度值,我们找到最近的点,并使用它来确定片段是否在阴影中。我们通过渲染场景(从光的角度)来创建深度贴图,使用特定于光源的视图和投影矩阵。这个投影和视图矩阵一起构成了一个变换OpenGL学习笔记34-Shadow Mapping它可以将任何3D位置转换为光的(可见)坐标空间。

定向光没有位置,因为它被建模为无限远。然而,为了实现阴影映射,我们需要从光的角度渲染场景,这样就可以沿着光线方向的某个位置渲染场景。

在右图中,我们看到相同方向的光和观察者。我们在点OpenGL学习笔记34-Shadow Mapping渲染一个片段,我们必须确定它是否在阴影中。为此,我们首先使用TT将点P¯P¯变换到光的坐标空间。因为点OpenGL学习笔记34-Shadow Mapping现在从光线的角度来看,它的z坐标对应于它的深度,在这个例子中是0.9。使用点OpenGL学习笔记34-Shadow Mapping,我们也可以对深度/阴影图进行索引,以获得从光的视角得到的最近的可见深度,即在点OpenGL学习笔记34-Shadow Mapping,采样的深度为0.4。由于对深度图的索引返回的深度小于点OpenGL学习笔记34-Shadow Mapping,我们可以得出点OpenGL学习笔记34-Shadow Mapping被遮挡,因此处于阴影中。

阴影贴图因此由两个步骤组成:首先我们渲染深度贴图,在第二个步骤中我们以正常方式渲染场景,并使用生成的深度贴图来计算片段是否在阴影中。这听起来可能有点复杂,但只要我们一步一步地了解了该技术,就可能开始有意义了。

The depth map

第一个步骤需要我们生成一个深度图。深度贴图是从光的角度渲染的深度纹理,我们将使用它来测试阴影。因为我们需要将渲染的场景结果存储到纹理中,所以我们又需要framebuffer。

首先,我们将创建一个framebuffer对象来呈现深度映射:


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

接下来我们创建一个2D纹理,我们将使用它作为framebuffer的深度缓冲区:


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的宽度和高度:这是深度贴图的分辨率。

有了生成的深度纹理,我们可以将它附加为framebuffer的深度缓冲区:


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);  

我们只需要从光的角度渲染场景时的深度信息,所以不需要颜色缓冲。然而,没有颜色缓冲区的framebuffer对象是不完整的,所以我们需要显式地告诉OpenGL我们不渲染任何颜色数据。我们通过使用glDrawBuffer和glReadbuffer将读取和绘制缓冲区都设置为GL_NONE来实现这一点。

有了一个正确配置的framebuffer,它可以为纹理呈现深度值,我们可以开始第一步:生成深度映射。当与第二步相结合时,完整的渲染阶段看起来有点像这样:


// 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的调用。因为阴影贴图的分辨率通常与我们最初渲染场景的分辨率不同(通常是窗口分辨率),所以我们需要改变viewport参数来适应阴影贴图的大小。如果我们忘记更新viewport参数,得到的深度映射要么不完整,要么太小。

Light space transform

前面代码片段中的一个未知函数是configuremderandmatrices函数。在第二个过程中,这和往常一样:确保设置了正确的投影和视图矩阵,并为每个对象设置相关的模型矩阵。然而,在第一轮中,我们需要使用不同的投影和视图矩阵来从光的角度渲染场景。

因为我们正在建模一个定向光源,它所有的光线都是平行的。基于这个原因,我们将对光源使用一个正投影矩阵,其中没有透视变形:


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; 

这个lightSpaceMatrix是我们前面表示为TT的变换矩阵。有了这个lightSpaceMatrix,我们可以像往常一样渲染场景,只要我们给每个着色器一个投影和视图矩阵的光空间等效物。然而,我们只关心深度值,而不是所有昂贵的片段(光照)计算。为了节省性能,我们将使用一个不同的,但更简单的着色器来渲染深度贴图。

Render to depth map

当我们从光的角度渲染场景时,我们宁愿使用一个简单的着色器,它只将顶点转换到光的空间,而不是更多。对于这样一个叫做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四轴上(类似于我们在framebuffers一章末尾的后处理部分所做的工作),我们得到如下内容:

OpenGL学习笔记34-Shadow Mapping

 

为了将深度贴图渲染到一个四边形上,我们使用了下面的片段着色器:


#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);
}  

请注意,使用透视投影矩阵而不是正投影矩阵显示深度时有一些细微的变化,因为在使用透视投影时深度是非线性的。在本章的末尾,我们将讨论其中一些细微的区别。

你可以在这里here. 找到渲染场景到深度贴图的源代码。

Rendering shadows

有了正确生成的深度贴图,我们就可以开始渲染实际的阴影了。检查片段是否在阴影中的代码(很明显)是在片段着色器中执行的,但是我们在顶点着色器中进行光-空间变换:


#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);
}

碎片着色器很大程度上是我们在高级照明章节中使用的复制,但是添加了阴影计算。我们声明了一个函数阴影计算来完成大部分的阴影工作。在碎片着色器的最后,我们将漫反射和高光部分乘以阴影部分的倒数,例如,有多少碎片没有在阴影中。这个片段着色器额外输入光-空间片段的位置和从第一个渲染通道生成的深度贴图。

要检查一个片段是否在阴影中,首先要做的是将剪贴空间中的光-空间片段位置转换为标准化的设备坐标。当我们在顶点着色器中输出一个剪贴空间的顶点位置到gl_Position时,OpenGL会自动进行透视划分,例如通过将x、y和z分量除以向量的w分量来转换范围[-w,w]到[-1,1]的剪贴空间坐标。由于剪贴空间的FragPosLightSpace没有通过gl_Position传递给fragment shader,我们必须自己做这个透视划分:


float ShadowCalculation(vec4 fragPosLightSpace)
{
    // perform perspective divide
    vec3 projCoords = fragPosLightSpace.xyz / fragPosLightSpace.w;
    [...]
}

这将返回片段在[-1,1]范围内的光空间位置。

当使用一个正交投影矩阵时,顶点的w分量保持不变,所以这一步实际上是相当没有意义的。但是,在使用透视投影时,这是必要的,因此保持这条线可以确保它与两个投影矩阵一起工作。

因为depth map的depth值在range[0,1]内,而且我们还想使用projCoords对depth map进行采样,所以我们将NDC坐标变换为range [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;  

完整的阴影计算函数变为:


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;
}  

**这个材质,结合适当的纹理,并**默认投影和第二渲染视图矩阵传递给你一个结果应该类似于下图:

OpenGL学习笔记34-Shadow Mapping

如果你做的事你确实应该看到(虽然不少工件)的阴影在地板上和多维数据集。您可以在这里here. 找到演示应用程序的源代码。

Improving shadow maps

我们设法让阴影映射的基础工作起来,但是正如你所能看到的那样,我们还没有做到这一点,因为我们需要修复一些与阴影映射相关的(明显可见的)工件。在接下来的部分中,我们将重点讨论如何修复这些构件。

Shadow acne

从之前的图像来看,很明显有些地方出了问题。近焦向我们展示了一个非常明显的莫伊尔模式:

OpenGL学习笔记34-Shadow Mapping

我们可以看到很大一部分的方形地板用明显的黑线交替呈现。这种阴影映射伪影称为痤疮阴影,可以用下图来解释:

OpenGL学习笔记34-Shadow Mapping

由于阴影贴图受到分辨率的限制,当多个碎片离光源相对较远时,它们可以从深度贴图中采样相同的值。该图像显示了地板,其中每个黄色倾斜面板代表深度图的单个texel。如您所见,多个片段对相同深度的样本进行了采样。

虽然这通常是可以的,但当光源从一个角度看表面时,这就成了一个问题,因为在这种情况下,深度贴图也是从一个角度来渲染的。一些碎片然后访问相同的倾斜深度texel,而一些在上面和下面的地板;我们得到一个阴影差异。因此,有些碎片被认为是在阴影中,而有些则不是,从图像中得到条纹图案。

我们可以用一个叫做阴影偏置的小技巧来解决这个问题,我们简单地用一个小的偏置来偏移表面(或者阴影贴图)的深度,这样碎片就不会被错误地认为在表面之下。

OpenGL学习笔记34-Shadow Mapping

应用了偏置后,所有的样本得到的深度都小于表面的深度,因此整个表面被正确地照亮,没有任何阴影。我们可以实现这样的偏差:


float bias = 0.005;
float shadow = currentDepth - bias > closestDepth  ? 1.0 : 0.0;  

0.005的阴影偏置在很大程度上解决了场景的问题,但是你可以想象阴影偏置的值高度依赖于光源和表面之间的角度。如果表面会有一个陡峭的角度到光源,阴影可能仍然显示暗疮阴影。一个更可靠的方法是根据面对光的角度来改变偏置的数量:我们可以通过点积来解决这个问题:


float bias = max(0.05 * (1.0 - dot(normal, lightDir)), 0.005);  

这里我们有一个最大的偏差0.05和最小的0.005基于表面的法线和光的方向。这样,像地板这样几乎垂直于光源的表面会有一个小的偏差,而像立方体的侧面这样的表面会有一个大得多的偏差。下面的图片显示了相同的场景,但现在有阴影偏差:

 

选择正确的偏置值(s)需要一些调整,因为这将是不同的场景,但大多数时候,这只是一个简单的问题,慢慢增加偏置,直到所有痤疮被删除。

Peter panning 彼得平移

使用阴影偏差的一个缺点是你对物体的实际深度进行了偏移。因此,偏差可能会变得足够大,可以看到与实际物体位置相比,阴影的明显偏移,如下图所示(带有夸大的偏差值):

OpenGL学习笔记34-Shadow Mapping

这种阴影人工制品被称为彼得平移,因为物体似乎有点脱离他们的阴影。我们可以使用一个小技巧来解决大部分的peter平移问题,即在渲染深度图时使用前脸剔除。你可能还记得在face culling一章中OpenGL默认会淘汰背面。通过告诉OpenGL我们想要在阴影贴图阶段剔除正面,我们改变了顺序。

因为我们只需要深度贴图的深度值对于实体对象来说,不管我们取它们的正面还是背面的深度。使用背面深度不会产生错误的结果,因为物体内部是否有阴影并不重要;我们怎么也看不见。

OpenGL学习笔记34-Shadow Mapping

为了修正peter平移,我们在阴影贴图生成过程中剔除了所有正面。注意,您需要首先启用GL_CULL_FACE。


glCullFace(GL_FRONT);
RenderSceneToDepthMap();
glCullFace(GL_BACK); // don't forget to reset original culling face

这有效地解决了peter平移的问题,但只适用于那些实际上有一个内部没有开口的固体物体。例如,在我们的场景中,这在立方体上工作得非常好。然而,在地板上,它不会工作的好,因为剔除前面的面完全删除地板从等式。地板是一个单一的平面,因此会被完全剔除。如果你想用这个技巧解决peter平移问题,就必须注意只在有意义的地方剔除对象的正面。

另一个需要考虑的问题是,靠近阴影接收器的物体(比如远处的立方体)可能仍然会给出错误的结果。但是,使用正常的偏置值,通常可以避免peter平移。

Over sampling

另一个你可能喜欢或不喜欢的视觉差异是,在光的可见锥体以外的区域被认为是在阴影中,而他们(通常)不是。这是因为在光的视锥外的投影坐标高于1.0,因此会在其默认范围[0,1]之外采样深度纹理。基于纹理的缠绕方法,我们将得到不正确的深度结果,而不是基于光源的真实深度值。

OpenGL学习笔记34-Shadow Mapping

你可以在图像中看到一些想象的光区,这个区域之外的很大一部分是在阴影中;这个区域表示投影到地板上的深度图的大小。发生这种情况的原因是我们之前将深度映射的包装选项设置为GL_REPEAT。

我们想要的是深度图范围之外的所有坐标的深度都为1.0,这意味着这些坐标永远不会在阴影中(因为没有对象的深度会大于1.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);
float borderColor[] = { 1.0f, 1.0f, 1.0f, 1.0f };
glTexParameterfv(GL_TEXTURE_2D, GL_TEXTURE_BORDER_COLOR, borderColor);  

现在,当我们在深度贴图的[0,1]坐标范围之外采样时,纹理函数总是会返回深度1.0,产生阴影值0.0。现在看来,结果更加可信:

OpenGL学习笔记34-Shadow Mapping

似乎仍有一部分显示出黑暗区域。这些是光的正截锥远平面外的坐标。你可以看到,这个黑暗的区域总是发生在光源的圆锥台的远端,通过观察阴影的方向。

当z坐标大于1.0时,光-空间投影的碎片坐标比光的远平面更远。在这种情况下,当我们比较坐标的z组件和深度映射值时,GL_CLAMP_TO_BORDER包装方法不再起作用;对于z大于1。0,它总是返回true。

解决这个问题也相对简单,只要投影向量的z坐标大于1.0,我们就强制阴影值为0.0:


float ShadowCalculation(vec4 fragPosLightSpace)
{
    [...]
    if(projCoords.z > 1.0)
        shadow = 0.0;
    
    return shadow;
}  

检查远平面并将深度贴图夹在手动指定的边框颜色上解决了深度贴图的过度采样问题。这就得到了我们想要的结果:

OpenGL学习笔记34-Shadow Mapping

所有这一切的结果意味着我们只有投影片段坐标在深度贴图范围内的阴影,所以任何在光锥体之外的东西将没有可见的阴影。因为游戏通常要确保这只发生在远处,这是一个比我们之前看到的明显的黑色区域更可信的效果。

PCF

现在的阴影是对风景的一个很好的补充,但它仍然不是我们想要的。如果你放大阴影,阴影映射的分辨率依赖性很快就变得很明显。OpenGL学习笔记34-Shadow Mapping

 

因为深度贴图有一个固定的分辨率,深度通常跨越每个texel的多个片段。因此,多个碎片从深度图中采样相同的深度值,得到相同的阴影结论,从而产生锯齿状的块状边缘。

你可以通过增加深度贴图的分辨率来减少这些块状的阴影,或者通过尝试让光视锥体尽可能的贴近场景。

另一种(部分)解决这些锯齿状边缘的方法叫做PCF,或接近百分比过滤,这是一个包含许多不同过滤功能的术语,可以产生更柔和的阴影,使它们看起来不那么块或坚硬。这个想法是从深度图中多次取样,每次都有略微不同的纹理坐标。对于每个个体样本,我们检查它是否处于阴影中。所有的子结果然后合并和平均,我们得到一个漂亮的柔软的外观阴影。

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;

在这里textureSize返回一个在mipmap级别0的采样器纹理的宽度和高度的vec2。1除以它会返回我们用来偏移纹理坐标的单个纹理的大小,确保每个新样本都采样了不同的深度值。这里我们在投影坐标的x和y值周围采样9个值,测试阴影遮挡,最后用样本总数对结果进行平均。

通过使用更多的样本和/或改变texelSize变量,你可以提高软阴影的质量。下面你可以看到应用了简单PCF的阴影:

OpenGL学习笔记34-Shadow Mapping

从远处看,阴影看起来好多了,也没那么硬。如果你放大,你仍然可以看到阴影映射的分辨率工件,但一般来说,这对大多数应用来说都是很好的结果。

您可以在这里here找到示例的完整源代码。

实际上,关于PCF还有很多内容,还有很多技术可以显著提高软阴影的质量,但是为了这一章的篇幅,我们将把它留到以后讨论。

Orthographic vs projection

用正投影矩阵渲染深度图和用正投影矩阵渲染深度图是有区别的。正射影矩阵不会使透视场景变形,所以所有的视图/光线都是平行的。这使它成为一个伟大的投影矩阵的方向光。然而,透视投影矩阵在透视的基础上对所有的顶点进行变形,得到不同的结果。下图展示了两种投影方法的不同阴影区域:

OpenGL学习笔记34-Shadow Mapping

与方向灯不同,透视投影对于有实际位置的光源最有意义。透视投影最常与聚光灯和点光源一起使用,而正投影用于定向灯。

与使用透视投影矩阵的另一个微妙的区别是,可视化的深度缓冲通常会给一个几乎完全白色的结果。这是因为透视投影将深度转换为非线性深度值,其大部分可察觉范围接近*面。为了能够像我们在正投影中那样正确地查看深度值,首先要将非线性深度值转换为线性深度值,正如我们在深度测试章节中所讨论的那样:


#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); // perspective
    // FragColor = vec4(vec3(depthValue), 1.0); // orthographic
}  

这显示的深度值类似于我们在正投影中看到的。注意,这只对调试有用;深度检查与正投影或正投影矩阵相同,因为相对深度不会改变。

Additional resources

相关标签: OpenGL