3 - 点光源阴影

上一篇文章提到了定向光的阴影是怎么实现的,接下来将说说点光源的阴影是怎么实现及优化的。

点光源阴影

点光阴影技术,也就是 万向阴影贴图 (Omnidirectional Shadow Maps) 技术,它大体跟前边的定向光阴影差不多,就是深度贴图的存储位置成了一个立方体贴图 (Cube Maps):

生成深度立方体贴图

有了立方体贴图,我们就能在每一个面上存储阴影数据了,它的代码可能是这样子的:

   
for(int i = 0; i < 6; i++) { GLuint face = GL_TEXTURE_CUBE_MAP_POSITIVE_X + i; glFramebufferTexture2D(GL_FRAMEBUFFER, GL_DEPTH_ATTACHMENT, face, depthCubemap, 0); BindViewMatrix(lightViewMatrices[i]); RenderScene(); }

可以发现我们要渲染一个场景 7 次(6 个面的深度贴图,1 次场景渲染),很是麻烦。但在几何着色器的帮助下,我们可以一次性渲染好一个深度立方体贴图。

使用几何着色器来生成深度贴图不会一定比每个面渲染场景 6 次更快。

unsigned int depthCubeMap;
glGenTextures(1, &depthCubeMap);

接下来给每个面安排一个 2D 深度纹理图像:

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

最后将这个深度立方体贴图作为纹理附件附加到对应的 FBO 上(像上篇文章那样):

   
// 绑定纹理附件至FBO glFramebufferTexture(GL_FRAMEBUFFER, GL_DEPTH_ATTACHMENT, depthCubeMap, 0); glDrawBuffer(GL_NONE); glReadBuffer(GL_NONE);

创建深度立方体贴图

类似于上篇文章,还是将整个渲染流程分为两部分:

   
// 1. 先得到深度立方体贴图 glViewport(0, 0, SHADOW_WIDTH, SHADOW_HEIGHT); glBindFramebuffer(GL_FRAMEBUFFER, depthMapFBO); glClear(GL_DEPTH_BUFFER_BIT); ConfigureShaderAndMatrices(); RenderScene(); glBindFramebuffer(GL_FRAMEBUFFER, 0); // 2. 再用深度立方体贴图进行常规场景渲染 glViewport(0, 0, SCR_WIDTH, SCR_HEIGHT); glClear(GL_COLOR_BUFFER_BIT | GL_DEPTH_BUFFER_BIT); ConfigureShaderAndMatrices(); glBindTexture(GL_TEXTURE_CUBE_MAP, depthCubemap); RenderScene();

准备转换矩阵

我们需要准备将物体转换为光源视角空间的转换矩阵,注意到有六个面,因此有六个不同的矩阵。首先是投影 P 矩阵,注意这里是点光源,得用透视矩阵:

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

其中,透视投影设置的角度为 90°,这是为了最终得到的视野可以大到覆盖立方体贴图的一个面,最后也就能对齐到六个面的边上了。

然后是 6 个 V 矩阵,分别以 “右、左、上、下、近、远” 的顺序看向六个面的中心,这里图省事直接进行 VP 变换了:

   
std::vector<glm::mat4> shadowTransforms; shadowTransforms.push_back(shadowProj * glm::lookAt(lightPos, lightPos + glm::vec3(1.0f, 0.0f, 0.0f), glm::vec3(0.0f, -1.0f, 0.0f))); shadowTransforms.push_back(shadowProj * glm::lookAt(lightPos, lightPos + glm::vec3(-1.0f, 0.0f, 0.0f), glm::vec3(0.0f, -1.0f, 0.0f))); shadowTransforms.push_back(shadowProj * glm::lookAt(lightPos, lightPos + glm::vec3(0.0f, 1.0f, 0.0f), glm::vec3(0.0f, 0.0f, 1.0f))); shadowTransforms.push_back(shadowProj * glm::lookAt(lightPos, lightPos + glm::vec3(0.0f, -1.0f, 0.0f), glm::vec3(0.0f, 0.0f, -1.0f))); shadowTransforms.push_back(shadowProj * glm::lookAt(lightPos, lightPos + glm::vec3(0.0f, 0.0f, 1.0f), glm::vec3(0.0f, -1.0f, 0.0f))); shadowTransforms.push_back(shadowProj * glm::lookAt(lightPos, lightPos + glm::vec3(0.0f, 0.0f, -1.0f), glm::vec3(0.0f, -1.0f, 0.0f)));

准备着色器

前面说到要用几何着色器来解决 6 次重复渲染问题,接下来看看着色器该怎么写。

首先是顶点着色器:

   
#version 330 core layout (location = 0) in vec3 aPos; uniform mat4 model; void main() { gl_Position = model * vec4(aPos, 1.0); }

顶点着色器的工作很简单,就是将顶点坐标转换为世界空间坐标,然后传给几何着色器。

然后是几何着色器,它接收 1 个三角形的顶点,并且接收一个 uniform 变量 shadowMatrices(就是前面得到的变换矩阵),将接受进来的图元进行从世界空间到光视角空间的变换。这里用到了几何着色器的内建变量 gl_Layer,它可以指定将当前图元发送到立方体贴图的哪个面上,当然,只有在一个立方体贴图纹理附加到一个帧缓冲上时,该内建变量才有效:

   
#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's vertices { FragPos = gl_in[i].gl_Position; gl_Position = shadowMatrices[face] * FragPos; EmitVertex(); } EndPrimitive(); } }

可以发现,它输入一个三角形,输出 6 个面的三角形。在 main() 中,利用内建变量 gl_Layer 遍历立方体贴图 6 个面,顺便把输入的三角形用对应面的变换矩阵变换到对应面上。别忘了设置 FragPos 输出变量,我们需要用它在片段着色器中进行深度值的比较。

最后是片段着色器,在上一篇文章中,平行光的片段着色器是空的,因为 OpenGL 可以自动计算阴影的深度值。但是在这里,由于用到透视投影矩阵,得到的深度值不是线性的,因此得先将它们映射成 [0,1] 内的线性值,再进行深度值计算:

   
#version 330 core in vec4 FragPos; uniform vec3 lightPos; uniform float far_plane; void main() { // 获得着色点跟光源的距离 float lightDistance = length(FragPos.xyz - lightPos); // 映射到线性(0,1) lightDistance = lightDistance / far_plane; // 手动指定深度值 gl_FragDepth = lightDistance; }

渲染深度立方体贴图

准备好深度立方体贴图后,就能渲染万向阴影 (omnidirectional shadows) 了。这里将点光源位置设定为 (0, 0, 0)。

准备着色器

首先是顶点着色器,和之前平行光的差不多,就是少了个输出变量 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); }

然后是片段着色器,它使用 Blinn-Phong 着色模型:

   
#version 330 core out vec4 FragColor; in VS_OUT { vec3 FragPos; vec3 Normal; vec2 TexCoords; } fs_in; uniform samplerCube depthCubeMap; uniform sampler2D diffuseTexture; uniform sampler2D texture_diffuse1; uniform sampler2D texture_specular1; uniform sampler2D texture_normal1; uniform int useModel; uniform vec3 lightPos; uniform vec3 viewPos; uniform float far_plane; float ShadowCalculation(vec3 fragPos) { // ...... } void main() { vec3 lightDir = normalize(lightPos - fs_in.FragPos); vec3 viewDir = normalize(viewPos - fs_in.FragPos); vec3 halfWay = normalize(viewDir + lightDir); vec3 normal = normalize(fs_in.Normal); vec3 lightColor = vec3(1.0); // calculate shadow float shadow = ShadowCalculation(fs_in.FragPos); if (useModel == 1) { vec3 ambient = lightColor * texture(texture_diffuse1, fs_in.TexCoords).rgb; vec3 diffuse = lightColor * texture(texture_diffuse1, fs_in.TexCoords).rgb * max(0.0, dot(normal, lightDir)); vec3 specular = lightColor * texture(texture_specular1, fs_in.TexCoords).rgb * pow(max(0.0, dot(normal, halfWay)), 32.0); FragColor = vec4(ambient + (1.0 - shadow) * (diffuse + specular), 1.0); } else { vec3 ambient = 0.3 * texture(diffuseTexture, fs_in.TexCoords).rgb; vec3 diffuse = lightColor * texture(diffuseTexture, fs_in.TexCoords).rgb * max(0.0, dot(normal, lightDir)); vec3 specular = lightColor * texture(diffuseTexture, fs_in.TexCoords).rgb * pow(max(0.0, dot(normal, halfWay)), 32.0); FragColor = vec4(ambient + (1.0 - shadow) * (diffuse + specular), 1.0); } }

接下来看一下 ShadowCalculation() 是怎么工作的,它会根据传入的位置顶点,在深度立方体贴图上进行采样,最后决定该着色点 / 片段是否有阴影存在。

首先,先根据传入的位置顶点在深度立方体贴图上采样:

   
// 根据fragPos获取在深度立方体贴图上的深度值 vec3 fragToLight = fragPos - lightPos; float closestDepth = texture(depthCubeMap, fragToLight).r;

其中,fragToLight 没有需要被标准化,因为这个方向向量不必须是一个单位向量。最终得到的 closestDepth 是一个在光源和其最接近可见片段之间的标准化的深度值。

然后,要将位于 [0,1] 之间的 closetDepth 映射到透视投影空间中,需要将其乘以 far_plane,即映射到 [0,far_plane] 之间的值:

closestDepth *= far_plane;  

最后就能进行比较了,顺便用偏移值去解决阴影失真问题:

   
// 进行比较 float currentDepth = length(fragToLight); float bias = 0.05; float shadow = currentDepth - bias > closestDepth ? 1.0 : 0.0;

debug

如果想要进行 debug,可以直接渲染出标准化的深度值:

FragColor = vec4(vec3(closestDepth / far_plane), 1.0);

然后就会得到以下渲染结果:

可以发现结果居然在盒子的外表面上,而不是内表面,这时候就需要用到一个小技巧,可以通过 “反转法线” 的方式让显示在外表面的纹理显示在内表面:

   
// 顶点着色器 uniform bool reverse_normals; void main() { // ... if(reverse_normals) vs_out.Normal = transpose(inverse(mat3(model))) * (-1.0 * aNormal); else vs_out.Normal = transpose(inverse(mat3(model))) * aNormal; // ... }

在第二次渲染包围盒时,将此项设置为 1 就能看到影子被渲染在内表面上了:

   
// 渲染包围盒 model = glm::mat4(1.0f); model = glm::scale(model, glm::vec3(5.0f)); shadowMapShader.setMat4("model", model); shadowMapShader.setInt("useModel", 0); glDisable(GL_CULL_FACE); shadowMapShader.setInt("reverse_normals", 1); renderCube(cubeTexture); shadowMapShader.setInt("reverse_normals", 0); glEnable(GL_CULL_FACE);

其中,关闭面剔除的原因是要得到内表面的结果,最终效果如下:

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

但这样做我们就会采样 64 次,这太消耗性能了,且大多数采样都是重复的。因此我们得准备一个偏移量方向数组,只在坐标轴方向上采样:

   
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 算法中,只需采样 20 次就能完成工作:

   
float shadow = 0.0; float bias = 0.15; int samples = 20; float viewDistance = length(viewPos - fragPos); float diskRadius = (1.0 + (viewDistance / far_plane)) / 25.0; 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);

其中,diskRadius 控制阴影的采样程度,当距离更远的时候阴影更柔和,更近了就更锐利。

参考资料