阴影映射(Shadow Mapping)
阴影是遮挡导致光线缺失造成的。阴影能够让我们的场景变得更加真实,也能让我们更容易地观察物体在空间中的关系。
1. 阴影映射
-
阴影映射(shadow mapping) 的思想很简单:就是我们将光源位置作为视角来渲染场景,光线照射到的所有物体都是可见,其他则都处于阴影之中。我们看下图展示的场景:(图片取自书中)
- 上图中,所有蓝色线代表光源能照射到的片元,被遮挡的片元则用黑色线表示。对于阴影计算,一般,我们需要获取光线最先撞击物体时的点,并将这个最近点与光线上的其他点进行比较。如果测试点比最近点远则处于阴影中。在场景中迭代所有可能的光线进行计算对于实时渲染来说太过耗时,因此我们采取相似的方法,只是我们不投射光线而是使用深度缓冲区来做替代。
- 从深度测试章节我们知道深度缓冲区中的深度值代表片元的深度,并且被限制在[0, 1]之间。如果我们从光源的角度渲染场景并将深度值结果存储到一个纹理中,那么我们就能从光源的角度采样最近的深度值。因为深度缓冲区中的深度值就是第一个可见的片元的深度值。我们将存储这些深度值的纹理称为深度图(depth map) 或阴影图(shadow map)。(图片取自书中)
- 上图我们展示了一个定向光源在一个立方体下方投射阴影的场景。在右侧图中,假设我们要渲染点上的片元。那么要确定是否处于阴影中,我们首先使用矩阵将点转换到光源空间。当转换到光源空间后,点的值就是深度值(图中为0.9)。然后我们使用阴影图索引光源的最近点,图中为,深度值为0.4。因为最近点的深度值小于点深度值,我们可以确定点处于阴影中。
- 阴影映射的两个阶段:
- 渲染深度图。
- 渲染场景并使用深度图计算片元是否处于阴影中。
2. 深度图
深度图就是我们从光源的视角渲染场景所生成的纹理。因为我们需要将场景的渲染结果存储到纹理中,因此我们需要使用到帧缓冲区,下面是创建深度图的主要步骤:
- 创建用于生成深度图的帧缓冲区对象。
unsigned int depthMapFBO;
glGenFramebuffers(1, & depthMapFBO);
- 创建用作帧缓冲区的深度缓冲区的2D纹理。
// 深度图分辨率:1024x1024
const unsigned int SHADOW_WIDTH = 1024, SHADOW_HEIGHT = 1024;
unsigned int depthMap;
glGenTextures(1, & depthMap);
glBindTexture(GL_TEXTURE_2D, depthMap);
glTexImage2D(GL_TEXTURE_2D, 0, GL_DEPTH_COMPONENT, SHADOW_WIDTH, SHADOW_HEIGHT, 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);
- 将纹理对象附加为帧缓冲区的深度缓冲区。
glBindFramebuffer(GL_FRAMEBUFFER, depthMapFBO);
glFramebufferTexture2D(GL_FRAMEBUFFER, GL_DEPTH_ATTACHMENT, GL_TEXTURE_2D, depthMap, 0);
glDrawBuffer(GL_NONE);
glReadBuffer(GL_NONE);
glBindFramebuffer(GL_FRAMEBUFFER, 0);
- 因为帧缓冲区没有颜色缓冲区是不完整的,所以上面我们使用
GL_NONE
参数调用glDrawBuffer
和glReadBuffer
函数,显式告诉OpenGL我们不渲染任何颜色数据。 - 配置好帧缓冲区和纹理,我们就可以进行深度图生成。整个渲染过程的伪代码如下:
// 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_2D, depthMap);
RenderScene();
2.1 光源空间变换
- 在上面伪代码的第一阶段,我们还需完善函数
ConfigureShaderAndMatrices
。在这里我们需要设置合适的视矩阵和投影矩阵,将场景转换为以光源为视角。因为我们要对定向光源进行建模,所以我们采用正射投影:
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);
- 对于视矩阵,我们使用
glm::lookAt
函数:
glm::mat4 lightView = glm::lookAt(glm::vec3(-2.0f, 4.0f, -1.0f),
glm::vec3( 0.0f, 0.0f, 0.0f),
glm::vec3( 0.0f, 1.0f, 0.0f));
- 将视矩阵和投影矩阵结合,我们就可以将世界空间的矢量转换到光源空间,这个结合矩阵就是我们前面提到的矩阵。
glm::mat4 lightSpaceMatrix = lightProjection * lightView;
2.2 渲染深度图
- 对于深度图,我们只关心深度值,而不关心片元的颜色计算。为了提高性能,我们为深度图提供独立且更简单的着色器。顶点着色器如下:
#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;
}
- 深度图的渲染伪代码变为:
simpleDepthShader.use();
glUniformMatrix4fv(lightSpaceMatrixLocation, 1, GL_FALSE, glm::value_ptr(lightSpaceMatrix));
glViewport(0, 0, SHADOW_WIDTH, SHADOW_HEIGHT);
glBindFramebuffer(GL_FRAMEBUFFER, depthMapFBO);
glClear(GL_DEPTH_BUFFER_BIT);
RenderScene(simpleDepthShader);
glBindFramebuffer(GL_FRAMEBUFFER, 0);
- 我们使用如下片元着色器将深度图显示到屏幕。
#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);
}
-
深度图渲染效果。
3. 阴影渲染
- 当生成深度图后,我们就可以渲染实际的阴影。虽然检查片元是否处于阴影中是在片元着色器中,但是我们需要在顶点着色器中将场景转换为以光源为视角。在顶点着色器中我们增加一个输出
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;
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);
}
- 片元着色器中我们使用Blinn-Phong模型。对于阴影我们计算出一个值
shadow
,为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 diffuseTexture;
uniform sampler2D shadowMap;
uniform vec3 lightPos;
uniform vec3 viewPos;
float ShadowCalculation(vec4 fragPosLightSpace)
{
...
}
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.15 * 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.FragPosLightSpace);
vec3 lighting = (ambient + (1.0 -shadow) * (diffuse + specular)) * color;
FragColor = vec4(lighting, 1.0);
}
- 判断片元是否处于阴影要做的第一件事就是将光源视角下裁剪空间的片元坐标转换为标准设备坐标。当我们通过顶点着色器的
gl_Position
输出裁剪空间的顶点坐标,OpenGL自动执行透视除法——通过将坐标的x, y, z分量除以w分量把裁剪空间[-w, w]映射到[-1, 1]。但是我们光源视角的片元位置FragPosLightSpace
不是通过gl_Position
输出,因此我们需要自己完成透视除法。
float ShadowCalculation(vec4 fragPosLightSpace)
{
// 透视除法
vec3 projCoords = fragPosLightSpace.xyz / fragPosLightSpace.w;
...
}
- 注意:如果使用正射投影,那么执行透视除法没有任何意义,不过保留这行代码可以让我们的方法同时兼容正射投影和透视投影。
- 因为阴影图的范围为[0, 1],而我们使用
projCoords
坐标来对阴影图进行采样,因此我们还需要将其转换到范围为[0, 1]的NDC坐标。
projCoords = projCoords * 0.5 + 0.5;
- 使用上述投影坐标我们就可以从阴影图进行采样,获取以光源视角的最近深度值。
float closestDepth = texture(shadowMap, projCoords.xy).r;
- 获取片元的深度值我们直接取坐标的z分量,那么片元是否处于阴影的判断就是对片元深度值与最近深度值的进行比较。
float currentDepth = projCoords.z;
float shadow = currentDepth > closestDepth ? 1.0 : 0.0;
- 完整的阴影计算如下:
float ShadowCalculation(vec4 fragPosLightSpace)
{
// 透视除法
vec3 projCoords = fragPosLightSpace.xyz / fragPosLightSpace.w;
// 转换坐标到[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;
}
4. 优化阴影渲染
4.1 阴影粉刺
- 上图的渲染中我们可以在地板上看到明显相间的黑色条纹。由于阴影映射而产生的伪影我们称为阴影粉刺(shadow acne)。阴影粉刺可由下图进行解释:(图片取自书中)
- 阴影图因为分辨率的限制,当片元与光源距离相对较远时,多个片元可能从阴影图采样到相同的值。如上图所示,每个黄色倾斜面板代表阴影图中的一个纹理元(texel),多个片元会采样到相同的深度值。这种情形一般是可以接受的,但是当光源以一定角度照射地板表面,那么阴影图也是以相对于表面一定角度进行渲染的。这时多个片元从相同的倾斜面板进行采样,那些处于上方的形成阴影,处于下方则被照亮,从而在地板表面形成相间黑色条纹。
- 要解决上述问题,我们可以采用一个小技巧称为阴影偏移(shadow bias),我们简单对表面的深度进行小量偏移,这样片元就不会被错认在表面之下。如下图所示:(图片取自书中)
- 应用阴影偏移,所有表面的片元获得一个较小的深度值,相当于把阴影图整个往上移动,这样就能消除表面的阴影条纹。
float bias = 0.005;
float shadow = currentDepth - bias > closestDepth ? 1.0 : 0.0;
- 虽然应用简单的阴影偏移可以解决我们的大部分问题,但是我们设置的偏移值独立于光源和表面的角度,如果角度很陡峭,仍然可能产生阴影粉刺。一种更可靠的解决方法是我们基于光源与表面的角度来调整偏移量,而利用点积我们可以实现这一点:
float bias = max(0.05 * (1.0 - dot(normal, lightDir)), 0.005);
-
渲染效果。
4.2 彼得平移(peter panning)
-
阴影偏移的一个缺点就是我们将偏移应用到物体的实际深度值,当偏移值过大时会造成相对于物体的实际位置可见的阴影错位,如下图所示:
- 上图中的阴影伪影称为彼得平移(peter panning),因为物体看起来与阴影分离。我们可以通过在渲染深度图时使用面剔除来解决大部分彼得平移问题。因为在深度图渲染中我们只需要深度值,因此对于实心物体来说,我们取物体的前向表面深度值或背向表面深度图不会产生影响。见下图:(图片取自书中)
- 要解决彼得平移,我们在深度图生成时剔除所有前向面片。
glCullFace(GL_FRONT);
RenderSceneToDepthMap();
glCullFace(GL_BACK);
...
4.3 过采样
- 另外一个场景失真的情况是处于光源可见截锥之外的区域都会被当作处于阴影之中,而实际上可能不是。这是因为光源截锥之外的投影坐标大于1.0,因此对深度图采样其值将在[0, 1]范围之外。前面我们设置了深度图纹理时使用了纹理扭曲选项
GL_REPEAT
,因此截锥之外的不是基于真实深度值进行采样。 - 针对上述问题,我们一般将深度图范围外的的深度值都设置为1.0,我们可以通过将纹理扭曲选项设置为
GL_CLAMP_TO_BORDER
来完成。
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);
-
使用这样的纹理设置,当我们采样处于深度图[0, 1]范围外的值总是返回1.0,这样阴影值就为0.0。渲染结果如下:
- 上图的渲染结果还存在一个阴暗区域,这是因为有些坐标处于光源正射投影截锥远平面之外。当坐标的z分量值大于1.0时,以光源视角投射产生的坐标将比远平面还远。这时候纹理设置
GL_CLAMP_TO_BORDER
将不起作用,因为z分量大于1.0总是比深度值大。解决该问题的一种简单方法就是当z分量大于1.0时我们强制将阴影值设置为0.0。
float ShadowCaculation(vec4 fragPosLightSpace)
{
...
if(projCooord.z > 1.0)
shadow = 0.0;
return shadow;
}
-
最终渲染结果。
4. PCF
-
虽然目前得到的阴影渲染效果已经很不错,但当我们在阴影部分放大,我们还是可以明显地看到阴影边缘呈现锯齿块状。这是由于深度图固定的分辨率造成的。我们可以通过提高深度图的分辨率或尽可能拉近光源截锥与场景的距离来减少锯齿块状阴影。
- 针对阴影的锯齿边缘还有另外一种解决方法称为百分比渐进过滤(percentage-closer filtering, PCF)。PCF是一个术语,它包含了多种产生柔和阴影的过滤方法。PCF的主要思想就是对深度图进行不止一次的采样,每次使用稍微不同的坐标值,然后结合所有采样结果取均值。
- PCF一种简单的实现就是对深度图纹理元四周进行采样取均值。
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, prokCoord.xy + vec2(x, y) * texelSize).r;
shadow += currentDepth - bias > pcfDepth ? 1.0 : 0.0;
}
}
shadow /= 9.0;
1. textureSize函数返回指定纹理在mipmap第一层的宽和高。
-
渲染效果。
-
不过对阴影放大拉近,还是可以看到由于分辨率导致的伪影。
5. 正射投影vs透视投影
- 使用正射或透视投影矩阵这两种投影方法渲染深度图,阴影渲染效果存在差异。正射投影不会通过视角改变场景,因此视线/光线都是平行的。透视投影基于视角改变了所有的顶点,因此产生了不同的阴影效果。见下图:(图片取自书中)
- 一般正射投影用于定向光源,透视投影用于聚光灯或点光源。
- 透视投影与正射投影的另外一个细微差别是透视投影的深度图经常是大部分都是白色。这是因为透视投影对深度进行非线性转换,致使大部分都处于近片面附近。要想像正射投影那样查看深度图,我们需要像在深度测试章节那样将非线性深度值转换为线性。
#version 330 core
out vec4 FragColor;
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; // 转换到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;
FragColor = vec4(vec3(LinearizeDepth(depthValue) / far_plane), 1.0);
}