2 - ShadowMapping
为了让渲染结果变得更真实,得添加阴影效果。本文将简要介绍一下OpenGL的Shadow Map是怎么做的,并尝试改善一下它。
Shadow Map
直到现在,在实时渲染领域,渲染物体阴影的高效算法还未被研发出来。目前有几种可以近似计算出阴影的技术,但各自有各自的偷鸡技巧跟不足,我们都得好好考虑它。
目前游戏里常用的技术就是 阴影贴图(Shadow Map),效果不错且容易实现。并且在它的基础上也有一些高级算法。
有关Shadow Map的原理,详见GAMES101文章。总的来说它有两个步骤:
- 以光线为摄像机,渲染一个深度贴图
- 向往常那样渲染场景,并根据深度贴图结果来计算该着色点/片段是否在阴影中
深度贴图
创建
先创建一个存储深度贴图的帧缓冲对象:
unsigned int depthMapFBO;
glGenFramebuffers(1, &depthMapFBO);
glBindFramebuffer(GL_FRAMEBUFFER, depthMapFBO);
然后为他生成一个2D纹理附件,用于存储深度内容:
unsigned int depthMap;
glGenTextures(1, &depthMap);
glBindTexture(GL_TEXTURE_2D, depthMap);
glTexImage2D(GL_TEXTURE_2D, 0, GL_DEPTH_COMPONENT, 1024, 1024, 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。
别忘了把这个纹理附件附加到帧缓冲对象上,然后验证一下FBO是否完整:
// 绑定纹理附件至FBO
glFramebufferTexture2D(GL_FRAMEBUFFER, GL_DEPTH_ATTACHMENT, GL_TEXTURE_2D, depthMap, 0);
glDrawBuffer(GL_NONE);
glReadBuffer(GL_NONE);
// 验证FBO是否完整
if (glCheckFramebufferStatus(GL_FRAMEBUFFER) != GL_FRAMEBUFFER_COMPLETE)
std::cout << "ERROR::FRAMEBUFFER:: DepthMap framebuffer is not complete!" << std::endl;
glBindFramebuffer(GL_FRAMEBUFFER, 0);
其中,由于只需记录深度信息,可以将此纹理 显式声明 为不影响最终颜色输出的纹理,即ReadBuffer跟DrawBuffer都为GL_NONE
。
生成和使用
接下来就能生成并使用深度贴图了,大致流程如下:
// 将场景渲染到深度贴图上
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_2D, depthMap);
RenderScene();
其中,记得先进行视口变换,因为深度贴图的分辨率跟窗口的分辨率通常是不同的,不这样做就会导致深度贴图太小/不完整。
然后是ConfigureShaderAmdMatrices()
函数,该函数确保为每个物体设置正确的MVP矩阵。在本步骤中,将以光源视角进行渲染,因此要提前设置正确的VP矩阵。
由于光源用的是平行光,使用的是正交投影,P矩阵如下:
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);
PS: 有关透视投影的情况待会儿会说明。
接下来利用glm::lookAt()
来创建一个V矩阵,别忘了它有三个参数:光源位置,看向的目标,和垂直向上的方向向量:
glm::mat4 lightView = glm::lookAt(lightPos,
glm::vec3( 0.0f, 0.0f, 0.0f),
glm::vec3( 0.0f, 1.0f, 0.0f));
这样就完成了光源视角下的VP变换,最好将它存起来,避免重复计算:
glm::mat4 lightSpaceMatrix = lightProjection * lightView;
然后为深度贴图的渲染准备着色器:
// 顶点着色器
#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);
}
// 片段着色器
#version 330 core
void main()
{
// gl_FragDepth = gl_FragCoord.z;
}
由于我们不需要任何的颜色值,所以片段着色器内可以不写任何代码,但也能显式地写出上边那行代码(但不建议,因为底层无论如何都会去设置深度贴图)。
接下来就能按照上边的流程,进行深度贴图的渲染了。
可以用这个着色器来可视化查看深度贴图:
// vert
#version 330 core
layout (location = 0) in vec3 aPos;
layout (location = 1) in vec2 aTexCoords;
out vec2 TexCoords;
void main()
{
TexCoords = aTexCoords;
gl_Position = vec4(aPos, 1.0);
}
// frag
#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);
}
Ps: 这里是正交投影,透视投影还有些不同,一会儿会说到它们的区别。
可视化深度贴图的结果如下:
绘制阴影
有了正确的深度贴图,就能开始绘制阴影了。首先先看一下顶点着色器:
#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;
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 * model * vec4(aPos, 1.0);
}
这里把输出变量都放到都放到一个接口块中,注意在FragPosLightSpace
中,让物体坐标从世界空间转化成光照空间。
然后是片段着色器,我们使用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 shadowMap;
uniform sampler2D diffuseTexture;
uniform sampler2D texture_diffuse1;
uniform sampler2D texture_specular1;
uniform sampler2D texture_normal1;
uniform sampler2D texture_height1;
uniform int useModel;
uniform vec3 lightPos;
uniform vec3 viewPos;
float ShadowCalculation(vec4 fragPosLightSpace, vec3 normal, vec3 lightDir)
{
// ...
}
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.FragPosLightSpace, normal, lightDir);
if (useModel == 1)
{
vec3 ambient = lightColor * 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);
}
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()
这个函数的工作原理,它会决定该着色点是否有影子:
首先对光线空间的坐标转化为裁剪空间的标准化坐标,因为这里通过接口块传过来,而不是通过gl_Position
传过来,OpenGL没有对接口块里的坐标进行标准化:
// (wx, wy, wz, w) -> (x, y, z, 1 or -1)
vec3 projCoords = fragPosLightSpace.xyz / fragPosLightSpace.w;
这一步主要是针对使用透视投影矩阵的点进行的,我们得到了一个在\([-1, 1]\)范围内的点的坐标。
接下来,为了从深度贴图里采样,要将xy坐标映射到\([0, 1]\)范围中;为了比较采样出来的深度值,要将z坐标映射到\([0, 1]\)范围中:
projCoords = projCoords * 0.5 + 0.5;
然后就能从深度贴图里采样了,最后比较深度值,如果摄像机视角的深度值比光源视角的深度值大,就说明物体被遮挡了,有阴影:
float closestDepth = texture(shadowMap, projCoords.xy).r;
float currentDepth = projCoords.z;
float shadow = currentDepth > closestDepth ? 1.0 : 0.0;
return shadow;
最终效果如下:
问题处理
失真问题(偏移)
从上图中可以发现,在影子的周围有许多摩尔纹,说明产生了走样现象,这种走样现象叫做 阴影失真(Shadow Acne):
受限于shadow map的分辨率,许多离光源很远的着色点都会在深度贴图中采样到同一个值。如上图,每个黄色斜坡代表深度贴图上的一个纹素,可以发现有许多着色点(黑线与黄色斜坡的交点)采样到的值是相同的,导致摩尔纹(黑线)的产生。
可以通过一个叫做 阴影偏移(Shadow Bias) 的技巧来解决这个问题,只需对表面深度/深度贴图应用一个偏移量,这样就不会产生上边的黑线:
我们定义一个范围在\([0.005, 0.05]\)的偏移值bias,它会根据光照角度来进行适当调整(用到点乘):
float bias = max(0.05 * (1.0 - dot(normal, lightDir)), 0.005);
float shadow = currentDepth - bias > closestDepth ? 1.0 : 0.0;
修正后的结果如下:
悬浮问题(面剔除)
有时候对物体平移后,它阴影的偏移可能会很大,这里借用教程的图:
这被称为 悬浮(Peter Panning) 现象,因为物体看起来轻轻悬浮在表面之上。可以通过 面剔除 这个技巧来解决问题,我们只用物体的背向面进行阴影的渲染即可:
代码如下:
glCullFace(GL_FRONT);
// 生成深度贴图
glCullFace(GL_BACK); // 别忘了恢复面剔除设定
大部分情况下,通过偏移值就能避免阴影悬浮问题,所以偏移值和面剔除这两种方法可以不同时使用。
超采样问题(修改纹理&丢弃远处)
如上图修正后的结果,模型阴影的上半身直接无了,并且前面也有一大片区域位于阴影中,这是因为那些区域在光的视锥体以外(光看不见那些区域)。这样对它们进行强制采样就会得到不正确的深度结果,例如使用GL_REPEAT
会造成阴影重复等现象。
可以使用GL_CLAMP_TO_BORDER
来手动指定纹理边缘外的颜色,这里统一指定为1.0,让黑暗区域消失:
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);
但做完这个操作还不够,因为还有一部分黑暗区域,那里的坐标超出了光视锥体的远平面,导致投影坐标z始终大于1.0,产生阴影。可以简单偷鸡,直接让z值大于1的shadow值为0,因为离光太远的物体基本没有影子。
最后的结果如下:
柔和边缘问题(PCF)
从上图中可以发现,阴影周围不是平滑的。这是因为深度贴图有一个固定的分辨率,如果有多个着色点对应一个贴图纹素(它们都会采样这个深度值),就会产生锯齿边效果。解决方法可以是增加深度贴图分辨率;也可以是尽可能让光的视锥体尽可能适应场景。
但有一种叫做 PCF(percentage-closer filtering)的解决方案,它通过多种过滤来产生阴影,使产生的阴影出现更小的锯齿和硬边。核心思想是从深度贴图中进行多次采样,每一次采样的纹理坐标都稍有区别,并平均采样结果。
一种简单做法就是从纹理像素四周对深度贴图采样,然后平均结果:
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()
函数会返回给定采样器x级mipmap的vec2类型的宽和高,用1除以它就会返回一个单独纹理的大小。如果可以使用更多样本,就能增加阴影的柔和程度。
改善后的结果如下:
正交与透视
在上边的例子中,由于光源是平行光,使用正交投影合理;而对于点光源和聚光灯,使用透视投影合理。两种投影产生的阴影如下:
需要注意的是,用透视投影得到的深度贴图,如果直接将它可视化,就会得到一个几乎全白的结果。因为透视投影下,深度是非线性的。因此要想像正交投影那样可视化深度值,还得先把非线性的深度值转换为线性的:
#version 330 core
out vec4 color;
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;
color = vec4(vec3(LinearizeDepth(depthValue) / far_plane), 1.0); // perspective
// color = vec4(vec3(depthValue), 1.0); // orthographic
}
这样就能可视化正交/透视两种深度贴图了。
参考资料
- LearnOpenGL - Shadow Mapping
- 阴影映射 - LearnOpenGL CN (learnopengl-cn.github.io)