LearnOpenGL——阴影映射
阴影映射 Shadow Mapping
阴影是光线被遮挡而缺失的效果,当光源的光线由其他物体遮挡不能够到达物体表面的时候,这个物体就在阴影中。
一、阴影映射
基本思路:我们从光源视角渲染场景,看得见的就是可以被点亮的,看不到的就是在阴影里面。
如果实时进行光线追踪运算的话,遍历所有光线相交情况会是一个非常消耗性能的事情,我们就使用深度缓冲来代替。我们将光源视角的深度值渲染到一张纹理上,这就是深度贴图(阴影贴图)
阴影映射由两个步骤组成:
- 首先,我们渲染深度贴图
- 然后我们像往常一样渲染场景,使用生成的深度贴图来计算片段是否在阴影之中。
二、深度贴图 Shadow map
1. 生成阴影贴图
第一步我们需要生成一张深度贴图(Depth Map)。深度贴图是从光的透视图里渲染的深度纹理,用它计算阴影。因为我们需要将场景的渲染结果储存到一个纹理中,我们将再次需要帧缓冲。
GLuint depthMapFBO;
glGenFramebuffer(1,&depthMapFBO);
我们创建一个深度缓冲纹理
const GLuint SHADOW_WIDTH = 1024, SHADOW_HEIGHT = 1024;
GLuint depthMap;
glGenTextures(1,&depthMap);
glBindTexture(GL_TEXTURE_2D, depthMap);
glTexImage2D(GL_TEXTURE_2D, 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);
然后创建一个纹理附件,将深度缓冲设置为纹理附件并绑定到缓冲对象上。因为我们渲染的是深度贴图(黑白灰),所以颜色缓冲没啥用,所以我们将调用glDrawBuffer和glReadBuffer把读和绘制缓冲设置为GL_NONE,告诉OpenGL我们不适用任何颜色数据进行渲染。
glBindFramebuffer(GL_FRAMEBUFFER,depthMapFBO);
glFramebufferTexture2D(GL_FRAMEBUFFER, GL_DEPTH_COMPONENT, GL_TXETURE_2D, depthMap, 0);
glDrawBuffer(GL_NONE);
glReadBuffer(GL_NONE);
glBindFramebuffer(GL_FRAMEBUFFER,0);
然后就可以开始渲染。两个步骤的完整渲染类似于
// 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();
一定要调用glViewport,因为阴影贴图与窗口分辨率一般有着不同的分辨率,我们需要改变视口参数来适应阴影贴图的尺寸。如果我们忘了更新视口参数,最后的深度贴图要么太小要么就不完整。
2. 光源空间变换
在第一个pass中,我们需要使用不同的投影和观察矩阵,将场景渲染为光源视角的。
因为我们模拟的是平行定向光,所以我们将会使用正交投影矩阵去描述没有透视变换的光源。
GLfloat near_plane = 1.0f, far_plane = 7.5f;
mat4 lightProjection = ortho(-10.0f, 10.0f, -10.0f, 10.0f,
near_plane, far_plane);
创建一个光源LookAt观察矩阵,可以将物体的坐标转换到光源观察空间中。我们需要知道光源位置、目标位置和表示世界空间中的向上向量(计算右向量的向上方向),就可以使用glm::LookAt来创建矩阵。这次从光源的位置看向场景中央。
LookAt矩阵详细见 OpenGL入门章节学习
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));
二者相结合为我们提供了一个光空间的变换矩阵,它将每个世界空间坐标变换到光源处所见到的那个空间;
mat4 lightSpaceMatrix = lightProjection * lightView;
3. 渲染至深度贴图
当我们以光的透视图进行场景渲染的时候,我们会用一个比较简单的着色器,这个着色器除了把顶点变换到光空间以外,不会做得更多了。这个简单的着色器叫做simpleDepthShader,就是使用下面的这个着色器:
#version 330 core
layout (location = 0) in vec3 position;
uniform mat4 lightSpaceMatrix;
uniform mat4 model;
void main()
{
gl_Position = lightSpaceMatrix * model * vec4(position, 1.0f);
}
由于我们没有颜色缓冲,最后的片段不需要任何处理,所以我们可以简单地使用一个空片段着色器:
#version 330 core
void main()
{
// gl_FragDepth = gl_FragCoord.z;
}
现在第一个Pass渲染深度缓冲可以写为
mat4 lightProj, lightView, lightSpaceMatrix;
lightProj = ortho(-10.0f, 10.0f, -10.0f, 10.0f, near_plane, far_plane);
lightView = lookAt(lightPos, vec3(0.0f), vec3(0.0, 1.0, 0.0));
lightSpaceMatrix = lightProj * lightView;
depthShader.use();
depthShader.setMat4("lightSpaceMatrix", lightSpaceMatrix);
glViewport(0, 0, SHADOW_WIDTH, SHADOW_HEIGHT);
glBindFramebuffer(GL_FRAMEBUFFER, depthMapFBO);
glClear(GL_DEPTH_BUFFER_BIT);
glActiveTexture(GL_TEXTURE0);
glBindTexture(GL_TEXTURE_2D, containerTexture);
renderScene(depthShader);
glBindFramebuffer(GL_FRAMEBUFFER, 0);
通过将这个纹理投射到一个2D四边形上(和我们在帧缓冲一节做的后处理过程类似),就能在屏幕上显示出来。将深度贴图渲染到四边形上的片段着色器:
#version 330 core
out vec4 color;
in vec2 TexCoords;
uniform sampler2D depthMap;
void main()
{
float depthValue = texture(depthMap, TexCoords).r;
color = vec4(vec3(depthValue), 1.0);
}
三、渲染阴影
这段代码在片段着色器中执行,用来检验一个片段是否在阴影之中,不过我们先在顶点着色器中进行光空间的变换
在顶点着色器中,我们声明了一个VS_OUT结构体,里面有FragPosLightSpace变量,我们用同一个lightSpaceMatrix,把世界空间顶点位置转换为光空间。顶点着色器传递一个普通的经变换的世界空间顶点位置vs_out.FragPos和一个光空间的vs_out.FragPosLightSpace给片段着色器。
#version 330 core
layout (location = 0) in vec3 position;
layout (location = 1) in vec3 normal;
layout (location = 2) in vec2 texCoords;
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()
{
gl_Position = projection * view * model * vec4(position, 1.0f);
vs_out.FragPos = vec3(model * vec4(position, 1.0));
vs_out.Normal = transpose(inverse(mat3(model))) * normal;
vs_out.TexCoords = texCoords;
vs_out.FragPosLightSpace = lightSpaceMatrix * vec4(vs_out.FragPos, 1.0);
}
在片元着色器中,我们使用Blinn-Phong光照模型,我们将计算每个像素的shadow值,然后使用(1.0 - shadow)
× 漫反射和高光反射值,限制其颜色:当fragment在阴影中时是0.0,在阴影外是1.0.又因为散射,所以阴影不会是全黑,我们对于ambient不进行阴影颜色计算。
#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(1.0);
// 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);
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;
// 计算阴影
float shadow = ShadowCalculation(fs_in.FragPosLightSpace);
vec3 lighting = (ambient + (1.0 - shadow) * (diffuse + specular)) * color;
FragColor = vec4(lighting, 1.0f);
}
对于阴影计算函数ShadowCalculation
- 我们首先得把光空间的坐标转换到裁剪空间的标准化设备坐标,我们需要手动进行透视除法(在顶点着色器输出一个裁切空间顶点位置到gl_Position时,OpenGL自动进行一个透视除法,将裁切空间坐标的范围-w到w转为-1到1,这要将x、y、z元素除以向量的w元素来实现。由于裁切空间的FragPosLightSpace并不会通过gl_Position传到片段着色器里,我们必须自己做透视除法)
vec3 projCoords = fragPosLightSpace.xyz / fragPosLightSpace.w;
- 此时z坐标分量在-1到1的范围,但又由于我们计算是希望深度值是在0-1的范围中,所以我们需要进行一个变换。
projCoords = projCoords * 0.5 + 0.5;
- 然后我们需要对片元深度进行判断,如果片元的深度值 > 深度贴图位置的深度值,那么就在阴影中,返回阴影值1,反之返回0
// 取得最近点的深度(使用[0,1]范围下的fragPosLight当坐标)
float closestDepth = texture(shadowMap, projCoords.xy).r;
// 取得当前片段在光源视角下的深度
float currentDepth = projCoords.z;
// 检查当前片段是否在阴影中
float shadow = currentDepth > closestDepth ? 1.0 : 0.0;
完整ShadowCalculation
float ShadowCalculation(vec4 fragPosLightSpace)
{
vec3 projCoords = fragPosLightSpace.xyz / fragPosLightSpace.w;
projCoords = projCoords * 0.5 + 0.5;
float closesDepth = texture(shadowMap, projCoords.xy).r;
float currentDepth = projCoords,z;
float shadow = currentDepth > closesDepth ? 1.0 : 0.0;
return shadow
}
激活这个着色器,绑定合适的纹理,激活第二个渲染阶段默认的投影以及视图矩阵
四、改进阴影贴图
1. 阴影失真 Shadow Acne
在目前的渲染图来看,阴影会有明显的黑线。
因为阴影贴图也受分辨率影响,在距离光源较远的情况下,可能多个片段从深度贴图的同一个值去采样。图片每个斜坡代表深度贴图一个单独的纹理像素,有些在地板上面,有些在地板下面,这样我们所得到的阴影就有了差异。
为了解决这个问题,我们可以引入一个bias,叫做 阴影偏移 shadow bias ,我们简单的对表面的深度(或深度贴图)应用一个偏移量,这样片段就不会被错误地认为在表面之下了。
根据表面朝向光线的角度更改偏移量:使用点乘:
float bias = max(0.05 * (1.0 - dot(normal, lightDir)), 0.005);
2. 悬浮
如果对物体使用的阴影偏移足够大,就会导致阴影会位移而导致一种类似悬浮的现象。
一般的偏移是不会产生悬浮,不过要解决悬浮问题的办法就是对阴影贴图进行正面剔除
glCullFace(GL_FRONT);
RenderSceneToDepthMap();
glCullFace(GL_BACK); // 不要忘记设回原先的culling face
3. 采样过多
光的视锥不可见的区域一律被认为是处于阴影中,不管它真的处于阴影之中,发生这种情况的原因是我们之前将深度贴图的环绕方式设置成了GL_REPEAT。
我们可以将超出深度贴图坐标的深度范围设为1.0(阴影值为0.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);
GLfloat borderColor[] = { 1.0, 1.0, 1.0, 1.0 };
glTexParameterfv(GL_TEXTURE_2D, GL_TEXTURE_BORDER_COLOR, borderColor);
现在还有一片黑色区域,那里的坐标超出了光的正交视锥的远平面。你可以看到这片黑色区域总是出现在光源视锥的极远处。
当一个点比光的远平面还要远时,它的投影坐标的z坐标大于1.0。解决这个问题也很简单,只要投影向量的z坐标大于1.0,我们就把shadow的值强制设为0.0:
float ShadowCalculation(vec4 fragPosLightSpace)
{
[...]
if(projCoords.z > 1.0)
shadow = 0.0;
return shadow;
}
五、PCF
此处是不完全的pcf
此时放大阴影,会发现非常明显的锯齿,因为阴影贴图对分辨率依赖很快变的很明显。我们可以增加深度贴图的分辨率来降低锯齿块,也可以尽可能让光的视锥接近场景。
还有一个办法就是PCF(Percentage-closer filtering),这是一个多种不同过滤方式的组合,会产生柔和的阴影。
核心思想:从深度贴图中多次采样,每一次采样的纹理坐标都稍有不同。每个独立的样本可能在也可能不再阴影中。所有的次生结果接着结合在一起,进行平均化,我们就得到了柔和阴影。
一个简单的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, projCoords.xy + vec2(x, y) * texelSize).r;
shadow += currentDepth - bias > pcfDepth ? 1.0 : 0.0;
}
}
shadow /= 9.0;
- 这个textureSize返回一个给定采样器纹理的0级mipmap的vec2类型的宽和高
- 用1除以它返回一个单独纹理像素的大小,我们用以对纹理坐标进行偏移,确保每个新样本,来自不同的深度值。
- 这里我们采样得到9个值,它们在投影坐标的x和y值的周围,为阴影阻挡进行测试,并最终通过样本的总数目将结果平均化。
六、透视阴影
在渲染深度贴图的时候,正交和透视矩阵之间有所不同,透视投影矩阵,会将所有顶点根据透视关系进行变形
透视投影矩阵,将深度缓冲视觉化经常会得到一个几乎全白的结果。发生这个是因为透视投影下,深度变成了非线性的深度值,它的大多数可辨范围都位于近平面附近。为了可以像使用正交投影一样合适地观察深度值,必须先将非线性深度值转变为线性的。
#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
}
(这个只适用于调试)