Point Shadows
Advanced-Lighting/Shadows/Point-Shadows
在上一章中,我们学习了使用阴影映射创建动态阴影。它工作得很好,但它最适合于定向(或聚光灯)光,因为阴影只在光源的方向产生。因此,它也被称为方向阴影映射,因为深度(或阴影)映射仅从光线所注视的方向生成。
这一章的重点是动态阴影的产生在所有周围的方向。我们使用的技术对于点光源来说是完美的,因为一个真正的点光源会在各个方向投下阴影。这种技术被称为点(光)阴影,或者更早的全向阴影贴图。
本章建立在上一章阴影映射的基础上,所以除非你熟悉传统的阴影映射,建议你先阅读阴影映射这一章。
该技术与方向阴影贴图非常相似:我们从光线的角度生成一个深度贴图,基于当前的片段位置对深度贴图采样,然后将每个片段与存储的深度值进行比较,看它是否在阴影中。方向阴影映射和全向阴影映射的主要区别是我们使用的深度映射。
我们需要的深度贴图需要从点光源周围的所有方向渲染一个场景,这样一个普通的2D深度贴图就不起作用了;如果我们使用cubemap呢?因为cubemap可以存储只有6个面的完整环境数据,所以可以将整个场景渲染到cubemap的每个面,并将这些作为点光源周围深度值的样本。
生成的depth cubemap然后被传递给lighting fragment shader,后者用一个方向向量对cubemap进行采样,以获得在那个fragment上最近的深度(从光的视角)。大部分复杂的东西我们已经在阴影贴图一章中讨论过了。使这项技术更加困难的是深度cubemap生成。
Generating the depth cubemap
为了创建一个光线周围深度值的立方体映射,我们必须渲染场景6次:每个面一次。一种(非常明显的)方法是使用6个不同的视图矩阵渲染场景6次,每次都将一个不同的cubemap面附加到framebuffer对象。大概是这样的:
for(unsigned int i = 0; i < 6; i++)
{
GLenum face = GL_TEXTURE_CUBE_MAP_POSITIVE_X + i;
glFramebufferTexture2D(GL_FRAMEBUFFER, GL_DEPTH_ATTACHMENT, face, depthCubemap, 0);
BindViewMatrix(lightViewMatrices[i]);
RenderScene();
}
这可能是相当昂贵的,尽管许多渲染调用是必要的,为这个单一的深度贴图。在本章中,我们将使用另一种(更有组织的)方法,使用几何着色器中的一个小技巧,它允许我们用一个渲染通道来构建depth cubemap。
首先,我们需要创建一个cubemap:
unsigned int depthCubemap;
glGenTextures(1, &depthCubemap);
并为每个cubemap面分配一个二维深度值纹理图像:
const unsigned int SHADOW_WIDTH = 1024, SHADOW_HEIGHT = 1024;
glBindTexture(GL_TEXTURE_CUBE_MAP, depthCubemap);
for (unsigned int i = 0; i < 6; ++i)
glTexImage2D(GL_TEXTURE_CUBE_MAP_POSITIVE_X + i, 0, GL_DEPTH_COMPONENT,
SHADOW_WIDTH, SHADOW_HEIGHT, 0, GL_DEPTH_COMPONENT, GL_FLOAT, NULL);
别忘了设置纹理参数:
glTexParameteri(GL_TEXTURE_CUBE_MAP, GL_TEXTURE_MAG_FILTER, GL_NEAREST);
glTexParameteri(GL_TEXTURE_CUBE_MAP, GL_TEXTURE_MIN_FILTER, GL_NEAREST);
glTexParameteri(GL_TEXTURE_CUBE_MAP, GL_TEXTURE_WRAP_S, GL_CLAMP_TO_EDGE);
glTexParameteri(GL_TEXTURE_CUBE_MAP, GL_TEXTURE_WRAP_T, GL_CLAMP_TO_EDGE);
glTexParameteri(GL_TEXTURE_CUBE_MAP, GL_TEXTURE_WRAP_R, GL_CLAMP_TO_EDGE);
通常我们会将cubemap纹理的单一面附加到framebuffer对象上,并渲染场景6次,每次切换framebuffer的深度缓冲目标到不同的cubemap面。因为我们将使用几何着色器,这允许我们在一次传递中渲染所有的面,我们可以直接附加cubemap作为framebuffer的深度附件与glFramebufferTexture:
glBindFramebuffer(GL_FRAMEBUFFER, depthMapFBO);
glFramebufferTexture(GL_FRAMEBUFFER, GL_DEPTH_ATTACHMENT, depthCubemap, 0);
glDrawBuffer(GL_NONE);
glReadBuffer(GL_NONE);
glBindFramebuffer(GL_FRAMEBUFFER, 0);
同样,请注意对glDrawBuffer和glReadBuffer的调用:在生成depth cubemap时,我们只关心depth值,所以我们必须明确地告诉OpenGL这个framebuffer对象不会渲染到一个颜色缓冲区。
对于全向阴影贴图,我们有两个渲染通道:第一,我们生成depth cubemap,第二,我们使用depth cubemap在正常的渲染通道中添加阴影到场景中。这个过程看起来有点像这样:
// 1. first render to depth cubemap
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 cubemap)
glViewport(0, 0, SCR_WIDTH, SCR_HEIGHT);
glClear(GL_COLOR_BUFFER_BIT | GL_DEPTH_BUFFER_BIT);
ConfigureShaderAndMatrices();
glBindTexture(GL_TEXTURE_CUBE_MAP, depthCubemap);
RenderScene();
这个过程和默认的阴影映射完全一样,尽管这次我们使用cubemap深度纹理来渲染和使用2D深度纹理。
Light space transform
在设置了framebuffer和cubemap之后,我们需要一些方法来将场景的所有几何图形转换到6个方向的相关光空间中。就像阴影映射章节一样,我们需要一个光空间变换矩阵TT,但是这次每个面都需要一个。
每个光空间变换矩阵都包含一个投影矩阵和一个视图矩阵。对于投影矩阵,我们将使用透视投影矩阵;光源代表空间中的一个点,因此透视投影最有意义。每个光空间变换矩阵使用相同的投影矩阵:
float aspect = (float)SHADOW_WIDTH/(float)SHADOW_HEIGHT;
float near = 1.0f;
float far = 25.0f;
glm::mat4 shadowProj = glm::perspective(glm::radians(90.0f), aspect, near, far);
这里需要注意的是glm::透视图的视场参数,我们设置为90度。通过将其设置为90度,我们可以确保观察区域足够大,足以填充立方体图的单个面,这样所有面在边缘上都能正确对齐。
由于投影矩阵不会按方向改变,我们可以对6个变换矩阵中的每一个重复使用它。我们确实需要每个方向的不同视图矩阵。使用glm::lookAt,我们创建了6个视图方向,每个方向按照立方体图的一个面方向:右、左、上、下、近、远。
std::vector<glm::mat4> shadowTransforms;
shadowTransforms.push_back(shadowProj *
glm::lookAt(lightPos, lightPos + glm::vec3( 1.0, 0.0, 0.0), glm::vec3(0.0,-1.0, 0.0));
shadowTransforms.push_back(shadowProj *
glm::lookAt(lightPos, lightPos + glm::vec3(-1.0, 0.0, 0.0), glm::vec3(0.0,-1.0, 0.0));
shadowTransforms.push_back(shadowProj *
glm::lookAt(lightPos, lightPos + glm::vec3( 0.0, 1.0, 0.0), glm::vec3(0.0, 0.0, 1.0));
shadowTransforms.push_back(shadowProj *
glm::lookAt(lightPos, lightPos + glm::vec3( 0.0,-1.0, 0.0), glm::vec3(0.0, 0.0,-1.0));
shadowTransforms.push_back(shadowProj *
glm::lookAt(lightPos, lightPos + glm::vec3( 0.0, 0.0, 1.0), glm::vec3(0.0,-1.0, 0.0));
shadowTransforms.push_back(shadowProj *
glm::lookAt(lightPos, lightPos + glm::vec3( 0.0, 0.0,-1.0), glm::vec3(0.0,-1.0, 0.0));
在这里,我们创建了6个视图矩阵,并将它们与投影矩阵相乘,得到了6个不同的光空间变换矩阵。glm的目标参数::lookAt,每个都向一个cubemap面方向看。
这些转换矩阵被发送到渲染立体图深度的着色器。
Depth shaders
为了渲染depth值到depth cubemap,我们需要三个着色器:顶点着色器和片段着色器,以及中间的几何着色器。
几何着色器将负责将所有世界空间的顶点转换到6个不同的光空间。因此,顶点着色器只是将顶点转换到世界空间,并将它们定向到几何着色器:
#version 330 core
layout (location = 0) in vec3 aPos;
uniform mat4 model;
void main()
{
gl_Position = model * vec4(aPos, 1.0);
}
几何着色器将输入3个三角形顶点和一个均匀的光空间变换矩阵数组。几何着色器负责将顶点转换到光空间;这也是有趣的地方。
几何着色器有一个名为gl_Layer的内置变量,它指定向哪个cubemap面发出原语。当不受影响时,几何着色器就像往常一样把它的原语发送到管道的更深处,但是当我们更新这个变量时,我们可以控制为每个原语渲染到哪个cubemap面。当然,这只有当我们有一个cubemap纹理附加到活动的framebuffer时才有效。
#version 330 core
layout (triangles) in;
layout (triangle_strip, max_vertices=18) out;
uniform mat4 shadowMatrices[6];
out vec4 FragPos; // FragPos from GS (output per emitvertex)
void main()
{
for(int face = 0; face < 6; ++face)
{
gl_Layer = face; // built-in variable that specifies to which face we render.
for(int i = 0; i < 3; ++i) // for each triangle vertex
{
FragPos = gl_in[i].gl_Position;
gl_Position = shadowMatrices[face] * FragPos;
EmitVertex();
}
EndPrimitive();
}
}
这个几何着色器是相对简单的。我们以一个三角形作为输入,总共输出6个三角形(6 * 3 = 18个顶点)。在main函数中,我们迭代了6个cubemap面,其中通过将面整数存储到gl_Layer中,将每个面指定为输出面。然后,通过将FragPos与人脸的光空间变换矩阵相乘,将每个世界空间输入顶点转换为相应的光空间,从而生成输出三角形。注意,我们还将产生的FragPos变量发送到fragment shader中,我们将需要它来计算一个深度值。
在上一章中,我们使用了一个空的碎片着色器,并让OpenGL计算出深度贴图的深度值。这一次,我们将计算我们自己的(线性)深度作为每个最近的片段位置和光源位置之间的线性距离。计算我们自己的深度值使得后面的阴影计算更加直观。
#version 330 core
in vec4 FragPos;
uniform vec3 lightPos;
uniform float far_plane;
void main()
{
// get distance between fragment and light source
float lightDistance = length(FragPos.xyz - lightPos);
// map to [0;1] range by dividing by far_plane
lightDistance = lightDistance / far_plane;
// write this as modified depth
gl_FragDepth = lightDistance;
}
片段着色器将几何体着色器的片段、光的位置向量和锥台的远平面值作为输入。这里我们取片段和光源之间的距离,将其映射到[0,1]范围,并将其写入片段的深度值。
使用这些着色器和与cubemap相连接的framebuffer对象激活渲染场景会给你一个完全填充深度的cubemap,用于第二轮的阴影计算。
Omnidirectional shadow maps 全向阴影地图
一切都设置好了,是时候渲染实际的全向阴影了。这个过程类似于方向阴影映射章节,尽管这次我们绑定了cubemap纹理而不是2D纹理,同时也将光投影的远平面变量传递给着色器。
glViewport(0, 0, SCR_WIDTH, SCR_HEIGHT);
glClear(GL_COLOR_BUFFER_BIT | GL_DEPTH_BUFFER_BIT);
shader.use();
// ... send uniforms to shader (including light's far_plane value)
glActiveTexture(GL_TEXTURE0);
glBindTexture(GL_TEXTURE_CUBE_MAP, depthCubemap);
// ... bind other textures
RenderScene();
在这里,renderScene函数在一个大的立方体房间中渲染几个立方体,这些立方体散布在场景中心的光源周围。
顶点和碎片着色器与原始的阴影映射着色器非常相似:区别在于碎片着色器不再需要在光空间中的碎片位置,因为我们现在可以用一个方向向量来采样深度值。
正因为如此,顶点着色器不需要将其位置向量转换为光空间,所以我们可以移除FragPosLightSpace变量:
#version 330 core
layout (location = 0) in vec3 aPos;
layout (location = 1) in vec3 aNormal;
layout (location = 2) in vec2 aTexCoords;
out vec2 TexCoords;
out VS_OUT {
vec3 FragPos;
vec3 Normal;
vec2 TexCoords;
} vs_out;
uniform mat4 projection;
uniform mat4 view;
uniform mat4 model;
void main()
{
vs_out.FragPos = vec3(model * vec4(aPos, 1.0));
vs_out.Normal = transpose(inverse(mat3(model))) * aNormal;
vs_out.TexCoords = aTexCoords;
gl_Position = projection * view * model * vec4(aPos, 1.0);
}
fragment shader's Blinn-Phong lighting code是完全相同的,就像我们之前在阴影乘法的最后:
#version 330 core
out vec4 FragColor;
in VS_OUT {
vec3 FragPos;
vec3 Normal;
vec2 TexCoords;
} fs_in;
uniform sampler2D diffuseTexture;
uniform samplerCube depthMap;
uniform vec3 lightPos;
uniform vec3 viewPos;
uniform float far_plane;
float ShadowCalculation(vec3 fragPos)
{
[...]
}
void main()
{
vec3 color = texture(diffuseTexture, fs_in.TexCoords).rgb;
vec3 normal = normalize(fs_in.Normal);
vec3 lightColor = vec3(0.3);
// ambient
vec3 ambient = 0.3 * 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);
vec3 reflectDir = reflect(-lightDir, normal);
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.FragPos);
vec3 lighting = (ambient + (1.0 - shadow) * (diffuse + specular)) * color;
FragColor = vec4(lighting, 1.0);
}
这里有一些细微的区别:光照代码是相同的,但是我们现在有一个samplerCube uniform,阴影计算函数使用当前片段的位置作为参数,而不是片段在光照空间中的位置。我们现在还包括了我们稍后需要的光截锥的far_plane值。
最大的不同是在阴影计算函数的内容,现在采样的深度值从cubemap而不是2D纹理。让我们一步一步来讨论它的内容。
我们要做的第一件事是检索cubemap的深度。你可能还记得在本章的cubemap部分中,我们将深度存储为片段和光线位置之间的线性距离;我们在这里采用了类似的方法:
float ShadowCalculation(vec3 fragPos)
{
vec3 fragToLight = fragPos - lightPos;
float closestDepth = texture(depthMap, fragToLight).r;
}
在这里,我们取片段位置和光线位置之间的差向量,并使用这个向量作为方向向量来采样cubemap。从cubemap中采样的方向向量不需要是单位向量,所以没有必要对它进行标准化。所得到的closestDepth值是光源与其最接近的可见片段之间的归一化深度值
closestDepth值当前在[0,1]范围内,因此我们首先通过将其与far_plane相乘将其转换回[0,far_plane]。
closestDepth *= far_plane;
接下来我们检索当前片段和光源之间的深度值,根据cubemap中深度值的计算方法,我们可以很容易地通过取fragToLight的长度来获得:
float currentDepth = length(fragToLight);
这将返回与closestDepth相同(或更大)范围的深度值。
现在我们可以比较这两个深度值,看看哪个更接近另一个,并确定当前的片段是否在阴影中。我们还包括阴影偏倚,所以我们不会得到阴影痤疮,正如在前一章讨论。
float bias = 0.05;
float shadow = currentDepth - bias > closestDepth ? 1.0 : 0.0;
完整的阴影计算变成:
float ShadowCalculation(vec3 fragPos)
{
// get vector between fragment position and light position
vec3 fragToLight = fragPos - lightPos;
// use the light to fragment vector to sample from the depth map
float closestDepth = texture(depthMap, fragToLight).r;
// it is currently in linear range between [0,1]. Re-transform back to original value
closestDepth *= far_plane;
// now get current linear depth as the length between the fragment and light position
float currentDepth = length(fragToLight);
// now test for shadows
float bias = 0.05;
float shadow = currentDepth - bias > closestDepth ? 1.0 : 0.0;
return shadow;
}
有了这些着色器,我们已经得到了很好的阴影,这一次从点光源得到了周围所有方向的阴影。在一个简单场景的中心放置一个点光源,它看起来就像这样:
你可以在这里here. 找到这个演示的源代码。
Visualizing cubemap depth buffer
如果您和我一样,可能在第一次尝试时没有得到正确的结果,那么进行一些调试是有意义的,其中一个明显的检查就是验证深度映射是否构建正确。可视化深度缓冲区的一个简单技巧是在ShadowCalculation函数中使用closestDepth变量,并显示为:
FragColor = vec4(vec3(closestDepth / far_plane), 1.0);
结果是一个灰色的场景,其中每一种颜色代表了场景的线性深度值:
你也可以在外墙上看到将要被阴影的区域。如果它看起来有点相似,那么您就知道depth cubemap是正确生成的。
PCF
由于全向阴影映射基于与传统阴影映射相同的原理,所以它也有相同的分辨率依赖工件。如果你放大到足够近,你可以再次看到锯齿状的边缘。通过对片段位置周围的多个样本进行过滤并对结果进行平均,PCF允许我们平滑这些锯齿状的边缘。
如果我们采用上一章同样简单的PCF滤波器,并添加第三维,我们得到:
float shadow = 0.0;
float bias = 0.05;
float samples = 4.0;
float offset = 0.1;
for(float x = -offset; x < offset; x += offset / (samples * 0.5))
{
for(float y = -offset; y < offset; y += offset / (samples * 0.5))
{
for(float z = -offset; z < offset; z += offset / (samples * 0.5))
{
float closestDepth = texture(depthMap, fragToLight + vec3(x, y, z)).r;
closestDepth *= far_plane; // undo mapping [0;1]
if(currentDepth - bias > closestDepth)
shadow += 1.0;
}
}
}
shadow /= (samples * samples * samples);
代码与传统的阴影映射代码没有什么不同。我们根据固定数目的样本动态地计算和添加每个轴的纹理偏移量。对于每个样本,我们在偏移样本的方向上重复原始的阴影过程,并在最后对结果进行平均。
阴影现在看起来更加柔软和光滑,并给出更可信的结果。
然而,当样本设置为4.0时,我们总共抽取64个样本,这是很多的!
由于这些样本大多是冗余的,因为它们的样本接近于原始的方向向量,因此只在样本方向向量的垂直方向进行样本可能更有意义。然而,由于没有(简单的)方法来确定哪些子方向是多余的,这就变得很困难。我们可以使用的一个技巧是取一个大致可分离的偏移方向数组,例如,它们中的每一个都指向完全不同的方向。这将大大减少靠近的子方向的数量。下面我们有一个最大偏移量为20的数组
vec3 sampleOffsetDirections[20] = vec3[]
(
vec3( 1, 1, 1), vec3( 1, -1, 1), vec3(-1, -1, 1), vec3(-1, 1, 1),
vec3( 1, 1, -1), vec3( 1, -1, -1), vec3(-1, -1, -1), vec3(-1, 1, -1),
vec3( 1, 1, 0), vec3( 1, -1, 0), vec3(-1, -1, 0), vec3(-1, 1, 0),
vec3( 1, 0, 1), vec3(-1, 0, 1), vec3( 1, 0, -1), vec3(-1, 0, -1),
vec3( 0, 1, 1), vec3( 0, -1, 1), vec3( 0, -1, -1), vec3( 0, 1, -1)
);
由此,我们可以调整PCF算法,从sampleoffsetdirection取固定数量的样本,并使用这些样本对cubemap进行采样。这样做的好处是,我们需要的样本要少得多,才能得到视觉上相似的结果。
float shadow = 0.0;
float bias = 0.15;
int samples = 20;
float viewDistance = length(viewPos - fragPos);
float diskRadius = 0.05;
for(int i = 0; i < samples; ++i)
{
float closestDepth = texture(depthMap, fragToLight + sampleOffsetDirections[i] * diskRadius).r;
closestDepth *= far_plane; // undo mapping [0;1]
if(currentDepth - bias > closestDepth)
shadow += 1.0;
}
shadow /= float(samples);
在这里,我们添加多个偏移量,按磁盘半径缩放,围绕着原始的fragToLight方向向量,从cubemap采样。
我们可以在这里应用的另一个有趣的技巧是,我们可以根据观看者到碎片的距离来改变磁盘半径,使阴影在远处更柔和,在近处更清晰。
float diskRadius = (1.0 + (viewDistance / far_plane)) / 25.0;
更新后的PCF算法得到的结果和阴影效果一样好,如果不是更好的话:
当然,我们添加到每个样本中的偏差是高度基于上下文的,并且总是需要根据你正在处理的场景进行调整。使用所有的值,看看它们是如何影响场景的。
你可以在这里找到最终的代码:这里here. 。
我应该提到的是,使用几何着色器生成深度贴图并不一定比为每张脸渲染6次更快。使用一个像这样的几何体着色器有它自己的性能惩罚,可能会超过在第一个地方使用一个性能增益。当然,这取决于环境的类型、特定的显卡驱动程序和许多其他因素。所以,如果你真的想要最大限度地发挥你的系统的功能,确保对这两种方法进行配置,并为你的场景选择更有效的一种。
Additional resources
- Shadow Mapping for point light sources in OpenGL: omnidirectional shadow mapping tutorial by sunandblackcat.
- Multipass Shadow Mapping With Point Lights: omnidirectional shadow mapping tutorial by ogldev.
- Omni-directional Shadows: a nice set of slides about omnidirectional shadow mapping by Peter Houska.