我们学习了使用阴影贴图创建动态阴影。但它主要适用于定向(或聚光)灯,因为阴影仅在光源方向上生成。 因此,它也被称为directional shadow mapping定向阴影贴图,因为深度(或阴影)贴图仅从光所看到的方向生成。
本章将重点关注的是在所有周围方向上动态阴影的生成。 我们使用的技术非常适合点光源,因为真正的点光源会向各个方向投射阴影。 这种技术被称为点(光)阴影或更早以前称为omnidirectional shadow maps全向阴影贴图。
directional shadow mapping定向阴影映射和omnidirectional shadow mapping全向阴影映射的主要区别在于我们使用的深度图。
cubemap立方体贴图可以存储只有 6 个面的完整环境数据,所以可以将整个场景渲染到立方体贴图的每个面,并将这些作为点光源的周围深度值进行采样。
基础知识:
1.Generating the depth cubemap 生成深度立方体贴图
我们必须渲染场景 6 次,每个面一次。使用 6 个不同的视图矩阵渲染场景 6 次,每次都将不同的立方体贴图面附加到帧缓冲区对象。 这看起来像这样:
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();
}
在本章中,我们将使用几何着色器中的一个小技巧使用另一种(更有组织的)方法,该方法允许我们仅通过单个渲染通道构建深度立方体贴图。
创建cubemap:
unsigned int depthCubemap;
glGenTextures(1, &depthCubemap);
并为每个单个立方体贴图面分配一个 2D 深度值纹理图像:
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);
通常我们会将立方体贴图纹理的单个面附加到帧缓冲区对象并渲染场景 6 次,每次将帧缓冲区的深度缓冲区目标切换到不同的立方体贴图面。 由于我们将使用几何着色器,它允许我们在一次传递中渲染所有面,我们可以使用 glFramebufferTexture 直接附加立方体贴图作为帧缓冲区的framebuffer's depth attachment深度附件:
glBindFramebuffer(GL_FRAMEBUFFER, depthMapFBO);
glFramebufferTexture(GL_FRAMEBUFFER, GL_DEPTH_ATTACHMENT, depthCubemap, 0);
glDrawBuffer(GL_NONE);
glReadBuffer(GL_NONE);
glBindFramebuffer(GL_FRAMEBUFFER, 0);
对于omnidirectional shadow maps全向阴影贴图,我们有两个渲染通道:首先,我们生成深度立方体贴图,其次,我们使用普通渲染通道中的深度立方体贴图为场景添加阴影。 这个过程看起来有点像这样:
// 1. first render to depth cubemap
//首先渲染cubemap
glViewport(0, 0, SHADOW_WIDTH, SHADOW_HEIGHT); //配置宽高
glBindFramebuffer(GL_FRAMEBUFFER, depthMapFBO); //绑定framebuffer
glClear(GL_DEPTH_BUFFER_BIT); //清缓存
ConfigureShaderAndMatrices(); //配置阴影和矩阵
RenderScene(); //渲染cubemap立方体,顺便把scene记录进buffer
glBindFramebuffer(GL_FRAMEBUFFER, 0); //绑定buffer
// 2. then render scene as normal with shadow mapping (using depth cubemap)
// 利用cubemap渲染场景
glViewport(0, 0, SCR_WIDTH, SCR_HEIGHT); //配置场景宽高
glClear(GL_COLOR_BUFFER_BIT | GL_DEPTH_BUFFER_BIT); //清除之前的信息
ConfigureShaderAndMatrices(); //配置场景中的shader和矩阵渲染
glBindTexture(GL_TEXTURE_CUBE_MAP, depthCubemap); //depthCubemap绑定texture资源
RenderScene();//渲染场景
该过程与默认阴影贴图完全相同,尽管与 2D 深度纹理相比,这次我们渲染并使用立方体贴图深度纹理。
2.Light space transform (光的世界坐标系转换)
设置了帧缓冲区和立方体贴图后,我们需要某种方法将所有场景的几何图形转换为所有 6 个光方向的相关光空间。 就像阴影映射一章一样,我们将需要一个光空间变换矩阵 T,但这次每个面一个。
光源代表空间中的一个点,因此透视投影最有意义。
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::perspective 的视野参数,我们设置为 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 的目标参数每个都查看单个立方体贴图面的方向。
这些变换矩阵被发送到将深度渲染到立方体贴图中的着色器。
3.Depth shaders (深度着色器)
要将深度值渲染到深度立方体贴图,我们总共需要三个着色器:顶点和片段着色器,以及介于两者之间的geometry shader几何着色器。几何着色器将是负责将所有世界空间顶点转换为 6 个不同光空间的着色器。 因此,顶点着色器只是将顶点转换到世界空间并将它们定向到几何着色器。
vs:
#version 330 core
layout (location = 0) in vec3 aPos;
uniform mat4 model;
void main()
{
gl_Position = model * vec4(aPos, 1.0);
}
几何着色器将输入 3 个三角形顶点和一个均匀的光空间变换矩阵阵列。 几何着色器负责将顶点转换为光照空间; 这也是它变得有趣的地方。
几何着色器有一个名为 gl_Layer 的内置变量,它指定要向哪个立方体贴图面发射primitive图元。 当单独放置时,几何着色器只是像往常一样将其primitive图元进一步向下发送到管道,但是当我们更新这个变量时,我们可以控制我们为每个图元渲染到哪个立方体贴图面。 这当然仅在我们将立方体贴图纹理附加到活动帧缓冲区时才有效。
gs:
#version 330 core
layout (triangles) in;
//输出 由于gs是处理三角形的,每个三角形三个点,我们想要在6个面都输出一遍,则共输出3*6=18个点
layout (triangle_strip, max_vertices=18) out;
//cubemap的6个面
uniform mat4 shadowMatrices[6];
//输出fragment的pos
out vec4 FragPos; // FragPos from GS (output per emitvertex)
void main()
{
//每个面都生成一遍,顶点的depth映射
for(int face = 0; face < 6; ++face)
{
//当前的face面
gl_Layer = face; // built-in variable that specifies to which face we render.
//每个面都发送当前处理的三角形的3个vertice
for(int i = 0; i < 3; ++i) // for each triangle vertex
{
//输入的三角形,的三个点,每个点i的fragment的pos
FragPos = gl_in[i].gl_Position;
//把这个fragment,进行某个面的光坐标系转换
gl_Position = shadowMatrices[face] * FragPos;
//发射一个vertex
EmitVertex();
}
EndPrimitive();
}
}
这个几何着色器相对简单。 我们以一个三角形作为输入,并输出总共 6 个三角形(6 * 3 等于 18 个顶点)。
fs:
#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;
}
fs片段着色器将来自gs几何着色器的 FragPos、灯光的位置向量和视锥体的远平面值作为输入。 这里我们取片段和光源之间的距离,将其映射到 [0,1] 范围并将其写入fs片段的深度值。
使用这些着色器和立方体贴图附加帧缓冲对象活动渲染场景应该会为您提供一个完全填充的深度立方体贴图,用于第二遍的阴影计算。
3.Omnidirectional shadow maps (全方位阴影贴图具体实施)
开始渲染:
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 变量:
vs:
#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);
}
片段着色器的 Blinn-Phong 光照代码与我们之前的代码完全相同,在末尾添加了阴影乘法:
fs:
#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);
}
ShadowCalculation 函数将当前片段的位置作为其参数,而不是光照空间中的片段位置。 我们现在还包括我们稍后需要的光截头体的 far_plane 值。
最大的区别在于 ShadowCalculation 函数的内容,它现在从立方体贴图而不是 2D 纹理中采样深度值。
float ShadowCalculation(vec3 fragPos)
{
vec3 fragToLight = fragPos - lightPos;
float closestDepth = texture(depthMap, fragToLight).r;
}
NearestDepth 值当前在 [0,1] 范围内,因此我们首先将其与 far_plane 相乘,将其转换回 [0,far_plane]。
closestDepth *= far_plane;
我们可以通过获取 fragToLight 的长度轻松获得:(前面有代码,fragToLight = fragPos-lightPos计算得出,光到fragment的Pos距离,就是frag的到光的深度)
float currentDepth = length(fragToLight);
这将返回与最接近深度相同(或更大)范围内的深度值。
float bias = 0.05;
float shadow = currentDepth - bias > closestDepth ? 1.0 : 0.0;
总体的ShadowCaculation函数:
float ShadowCalculation(vec3 fragPos)
{
// get vector between fragment position and light position
// 计算frag到光的距离
vec3 fragToLight = fragPos - lightPos;
// use the light to fragment vector to sample from the depth map
// 最小的depth距离,直接通过一个角度,打在cubemap上
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
// 计算frag到光的距离
float currentDepth = length(fragToLight);
// now test for shadows
float bias = 0.05;
float shadow = currentDepth - bias > closestDepth ? 1.0 : 0.0;
return shadow;
}
在一个简单场景的中心放置一个点光源,它看起来有点像这样:
4.Visualizing cubemap depth buffer(可视化立方体贴图深度缓冲区)
可视化深度缓冲区的一个简单技巧是在 ShadowCalculation 函数中使用最接近的 Depth 变量并将该变量显示为:
FragColor = vec4(vec3(closestDepth / far_plane), 1.0);
结果是一个变灰的场景,其中每种颜色代表场景的线性深度值:
5.PCF (Percentage-closer filtering)
由于全向阴影贴图基于与传统阴影贴图相同的原理,因此它也具有相同的分辨率相关伪影。 如果放大得足够近,您可以再次看到锯齿状边缘。 PCF(Percentage-closer filtering) 允许我们通过过滤片段位置周围的多个样本并平均结果来平滑这些锯齿状边缘。
如果我们采用与上一章相同的简单 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);
在这个代码中,samples * samples * samples = 4*4*4 = 64个采样
阴影现在看起来更柔和和平滑,并提供更合理的结果。
然而,在样本设置为 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 算法以从 sampleOffsetDirections 获取固定数量的样本,并使用这些样本对立方体贴图进行采样。 这里的优点是我们需要更少的样本来获得视觉上相似的结果。
float shadow = 0.0;
float bias = 0.15;
int samples = 20;
float viewDistance = length(viewPos - fragPos);
//采样的搜寻长度
float diskRadius = 0.05;
//对samples=20个采样点进行采样
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);
直接遍历数组的20个元素,采样,然后除以20。
我们可以在这里应用的另一个有趣的技巧是,我们可以根据观察者到片段的距离更改 diskRadius,使阴影在远处时更柔和,在靠近时更清晰。
//距离越远,采样的搜寻长度radius就越小,阴影越清晰,相当于不采样。距离越近,采样搜寻就越长,越模糊
float diskRadius = (1.0 + (viewDistance / far_plane)) / 25.0;
使用几何着色器生成深度图不一定比为每个面渲染场景 6 次快。 使用像这样的几何着色器有其自身的性能损失,这可能超过首先使用几何着色器的性能增益。 这当然取决于环境类型、特定的显卡驱动程序以及许多其他因素。 因此,如果您真的关心最大限度地利用系统,请确保对两种方法进行概要分析,并为您的场景选择更有效的一种。