点阴影(point shadow)
上一章节我们了解使用阴影映射创建动态阴影,但是只适合用于定向光源产生的阴影,因此也称为定向阴影映射(directional shadow mapping)。本章我们讨论如何在所有方向上生成动态阴影,这项技术特别适合点光源,因此也称为点阴影(point shadow),或更正式的名称叫做全向阴影映射(omnidirectional shadow mapping)。
- 全向阴影映射与定向阴影映射相似,都是先生成基于光源视角的深度图,然后基于片元位置从深度图采样,最后通过比较每个片元当前存储的深度值来判断是否处于阴影中,两者的主要区别就是所使用的深度图。
- 全向阴影映射使用立方体贴图将整个场景渲染到立方体的各个面,并从这6个面中采样点光源周围环境的深度值。见下图:(图片取自书中)
1. 生成深度立方体贴图
- 创建一个环绕光源的深度立方体贴图的一种方式就是使用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();
}
- 使用上述方法需要很多渲染操作调用,太过繁琐,本章我们采用另外一种方式:在几何着色器中使用一个小技巧来让我们用一次渲染调用完成立方体贴图的构建。(注意:采用几何着色器的方式不一定性能更好,具体哪种方法性能更优需根据渲染的场景,显卡型号等进行测试)
- 首先生成立方体贴图。
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_MIN_FILTER, GL_NEAREST);
glTexParameteri(GL_TEXTURE_CUBE_MAP, GL_TEXTURE_MAG_FILTER, GL_NEAREST);
glTexParameteri(GL_TEXTURE_CUBE_MAP, GL_TEXTURE_WRAP_S, GL_CLAMP_TO_BORDER);
glTexParameteri(GL_TEXTURE_CUBE_MAP, GL_TEXTURE_WRAP_T, GL_CLAMP_TO_BORDER);
glTexParameteri(GL_TEXTURE_CUBE_MAP, GL_TEXTURE_WRAP_R, GL_CLAMP_TO_BORDER);
- 使用
glFramebufferTexture
函数将立方体贴图纹理附加为帧缓冲区的深度附件。
- 使用
glBindFramebuffer(GL_FRAMEBUFFER, depthMapFBO);
glFramebufferTexture(GL_FRAMEBUFFER, GL_DEPTH_ATTACHMENT, depthCubemap, 0);
glDrawBuffer(GL_NONE);
glReadBuffer(GL_NONE);
glBindFramebuffer(GL_FRAMEBUFFER, 0);
- 与上一章节相似,阴影映射的两个阶段的伪代码如下:
// 第一阶段:渲染深度立方体贴图
glViewport(0, 0, SHADOW_WIDTH, SHADOW_HEIGHT);
glBindFramebuffer(GL_FRAMEBUFFER, depthMapFBO);
glClear(GL_DEPTH_BUFFER_BIT);
ConfigureShaderAndMatrices();
RenderScene();
glBindFramebuffer(GL_FRAMEBUFFER, 0);
// 第二阶段:使用深度立方体贴图渲染场景
glViewport(0, 0, SCR_WIDTH, SCR_HEIGHT);
glClear(GL_COLOR_BUFFER_BIT | GL_DEPTH_BUFFER_BIT);
ConfigureShaderAndMatrices();
glBindTexture(GL_TEXTURE_CUBE_MAP, depthCubemap);
RenderScene();
1.1 基于光源视角转换
- 设置好帧缓冲区和立方体贴图后,我们需要一种方法将场景的所有几何基元转换到光源6个方向上的光源空间。与阴影映射章节一样,我们需要一个光源空间的转换矩阵,但是这一次立方体的每个面都需要一个。
- 光源空间转换矩阵包含一个投影矩阵和一个视矩阵,对于每个转换矩阵我们使用相同的投影矩阵。
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), apsect, near, far);
- 对于投影矩阵需要注意的一点是我们将视角角度设置为90.0f。这是为了保证视场正好大到足够填充立方体贴图的一个面,这样所有的面就能够沿着边缘对齐。
- 每个方向我们使用相同的投影矩阵,但是对于视矩阵,我们需要使用
glm::lookAt
函数创建面向立方体贴图6个面的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)));
1.2 深度着色器
- 要渲染深度值到立方体贴图,我们需要完整使用三种着色器。其中几何着色器负责将顶点坐标从世界空间转换到6个不同的光源空间。因此,顶点着色器只是将顶点坐标转换到世界空间并传递给几何着色器。顶点着色器如下:
#version 330 core
layout (location = 0) in vec3 aPos;
uniform mat4 model;
void main()
{
gl_Position = model * vec4(aPos, 1.0);
}
- 几何着色器使用内置变量
gl_Layer
来指定往立方体贴图的那个面输出基元。如果不管该变量,几何着色器像往常一样将数据传递到渲染管道的下一个阶段,但是如果我们更新该变量我们可以控制将每个基元渲染到立方体贴图的那个面。当然这需要有一个立方体贴图纹理附加到当前激活的帧缓冲区。几何着色器如下:
#version 330 core
layout (triangles) in;
layout (triangle_strip, max_vertices = 18) out;
uniform mat4 shadowMatrices[6];
out vec4 FragPos;
void main()
{
for (int face = 0; face < 6; ++face)
{
gl_Layer = face; // 指定渲染到那个面
for (int i = 0; i < 3; ++i) // 每个三角形顶点
{
FragPos = gl_in[i].gl_Position;
gl_Position = shadowMatrices[face] * FragPos;
EmitVertex();
}
EndPrimitive();
}
}
- 上一章我们使用一个空的片元着色器,让OpenGL自己决定深度图的深度值。这次我们自己计算最近片元位置与光源位置的线性距离作为深度值。片元着色器如下:
#version 330 core
in vec4 FragPos;
uniform vec3 lightPos;
uniform float far_plane;
void mian()
{
// 获取片元与光源的距离
float lightDiatance = length(FragPos.xyz, lightPos);
// 除以far_plane,映射到[0;1]范围
lightDiatance = lightDiatance / far_plane;
// 写入深度值
gl_FragDepth = lightDiatance;
}
2. 全向阴影映射
- 渲染全向阴影的过程与定向阴影映射相似,只是这次我们需要绑定立方体贴图纹理并且将光源投影的远平面变量传递给着色器。伪代码如下:
glViewport(0, 0, SCR_WIDTH, SCR_HEIGHT);
glClear(GL_COLOR_BUFFER_BIT | GL_DEPTH_BUFFER_BIT);
shader.use();
// ... 发送变量值到着色器
glActiveTexture(GL_TEXTURE0);
glBindTexture(GL_TEXTURE_CUBE_MAP, depthCubemap);
// 绑定其他纹理
RenderScene();
- 场景的顶点着色器和片元着色器与阴影映射章节的相似,差别在于我们现在使用方向矢量来采样深度值,因此不需要光源空间的片元位置。因此我们可以移除顶点着色器的
FragPosLightSpace
变量。
#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;
} 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;
gl_Position = projection * view * model * vec4(aPos, 1.0);
}
- 片元着色器主要改变在于阴影计算函数,因为现在我们需要从立方体贴图纹理而不是二维纹理采样深度值。下面我们逐步讨论函数的内容。首先我们需要从立方体贴图纹理检索深度值。
float ShadowCaculation(float fragPos)
{
vec3 fragToLight = fragPos - lightPos;
float closestDepth = texture(depthMap, fragToLight).r;
}
- 将深度值从[0, 1]转换到[0, far_plane]。
closestDepth *= far_plane;
- 检索当前片元的深度值,由前面我们计算深度值的方式,我们知道其实就是片元与光源之间的距离。
float currentDepth = length(fragToLight);
- 计算阴影值并应用偏移消除阴影粉刺。
float bias = 0.05;
float shadow = currentDepth - bias > closestDepth ? 1.0 : 0.0;
- 最后,完整的片元着色器如下:
#version 330 core
out vec4 FragColor;
in VS_OUT
{
vec3 FragPos;
vec3 Normal;
vec2 TexCoords;
} fs_in;
uniform sampler2D diffuseTexture;
uniform samplerCube shadowMap;
uniform vec3 lightPos;
uniform vec3 viewPos;
float ShadowCaculation(vec3 fragPos)
{
vec3 fragToLight = fragPos - lightPos;
float closestDepth = texture(shadowMap, fragToLight).r;
closestDepth *= far_plane;
float currentDepth = length(fragToLight);
float bias = 0.05;
float shadow = currentDepth - bias > closestDepth ? 1.0 : 0.0;
return shadow;
}
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);
float spec = 0.0;
vec3 halfwayDir = normalize(lightDir + viewDir);
spec = pow(max(dot(normal, halfwayDir), 0.0), 64.0);
vec3 specular = spec * lightColor;
// caculate shadow
float shadow = ShadowCalculation(fs_in.FragPos);
vec3 lighting = (ambient + (1.0 -shadow) * (diffuse + specular)) * color;
FragColor = vec4(lighting, 1.0);
}
-
渲染效果。
- 当程序渲染异常时,一般我们都会检查深度图是否正常构建。可视化深度缓冲区我们可以采用
ShadowCaculation
函数中的closestDepth
作为片元输出。
vec3 fragToLight = fs_in.FragPos - lightPos;
float closestDepth = texture(shadowMap, fragToLight).r;
FragColor = vec4(vec3(closestDepth / far_plane), 1.0);
-
深度立方体贴图。
3. 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;
if(currentDepth - bias > closestDepth)
shadow += 1.0;
}
}
}
shadow /= (samples * samples * samples);
-
渲染效果如下:
- 上述PCF使用四个采样点,这样每个片元需要进行64次采样,增加了很多计算。而且这些采样很多都是冗余的,因为这里面很多与原来采样的方向矢量十分接近。但是我们也很难区分哪些子采样是冗余的,有一个小技巧就是我们使用一个偏移数组来区分采样方向矢量,让不同子采样指向不同的方向。这样我们就可以降低子采样的数量。下面是一个20个元素的偏移数组:
vec3 samplesOffsetDirections[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算法,采用固定数量的子采样来对立方体贴图进行采样。
float shadow = 0.0;
float bias = 0.05;
int samples = 20.0;
float viewDistance = length(viewPos - fragPos);
float diskRadius = 0.05;
for(int i = 0;i < 20; ++i)
{
float closestDepth = texture(depthMap, fragToLight + samplesOffsetDirections[i] * diskRadius).r;
closestDepth *= far_plane;
if(currentDepth - bias > closestDepth)
shadow += 1.0;
}
shadow /= float(samples);
- 另外一个技巧是我们可以根据观察者与片元的距离调整
diskRadius
的大小,这样可以让视角拉远时阴影更柔和,拉近时则更锐化。
float diskRadius = (1.0 + (viewDistance / far_plane)) / 25.0;
-
渲染效果。