本文同时发布在我的个人博客上:https://dragon_boy.gitee.io
点阴影
上一章我们使用阴影贴图创建了动态阴影,但我们使用的方式只适合方向光源。这里我们将讨论全方位阴影的创建方式,即适用于点光源的阴影,也可以称为全方位阴影贴图。
这一技术和单方向阴影贴图很相似:我们从灯光的视角生成深度贴图,根据当前片段的位置采样深度贴图,并比较当前片段深度和存储在深度贴图中的深度来判断当前片段是否在阴影中。而单方位和全方位的区别在于我们使用的深度贴图不一样。
生成的深度立方体贴图接着送往片元着色器,通过一个方向向量采样立方体贴图来获取最近深度点。
生成深度立方体贴图
为了创建一个深度立方体贴图,我们需要渲染场景6次。其中一种方法时通过6个不同的view矩阵渲染场景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);
接着为立方体贴图的每个面生成一张深度纹理:
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_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);
我们将立方体贴图作为1个附加项附加到自定义的帧缓冲上:
glBindFramebuffer(GL_FRAMEBUFFER, depthMapFBO);
glFramebufferTexture(GL_FRAMEBUFFER, GL_DEPTH_ATTACHMENT, depthCubemap, 0);
glDrawBuffer(GL_NONE);
glReadBuffer(GL_NONE);
glBindFramebuffer(GL_FRAMEBUFFER,0);
注意我们要使用glDrawBuffer和glReadBuffer来保证不写入和使用颜色缓冲。
渲染全方位阴影贴图有两个步骤,一是先渲染出深度图,二是使用深度图渲染正常场景,这和但方位阴影贴图没区别:
// 1. first render to depth cubemap
glViewport(0, 0, SHADOW_WIDTH, SHADOW_HEIGHT);
glBindFramebuffer(GL_FRAMEBUFFER, depthMapFBO);
glClear(GL_DEPTH_BUFFER_BIT);
ConfigureShaderAndMatrices();
RenderScene();
glBindFramebuffer(GL_FRAMEBUFFER, 0);
// 2. then render scene as normal with shadow mapping (using depth cubemap)
glViewport(0, 0, SCR_WIDTH, SCR_HEIGHT);
glClear(GL_COLOR_BUFFER_BIT | GL_DEPTH_BUFFER_BIT);
ConfigureShaderAndMatrices();
glBindTexture(GL_TEXTURE_CUBE_MAP, depthCubemap);
RenderScene();
灯光空间转换
为了渲染深度图,我们需要将所有的场景的几何体转化到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), aspect, near, far);
注意我们将透视角设为了90度,这样可以保证覆盖范围足够大,以保证立方体贴图的每个面都在投影范围内。
接着我们需要创建6个不同的view矩阵,我们使用glm::lookAt针对6个不同方位创建:右、左、上、下、前、后。
std::vector 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));
着色器
这次我们要创建3个着色器来渲染深度图,顶点、几何和片元。
几何着色器的任务是将所有世界空间的顶点转化到6个不同的灯光空间,因此,我们只需要在顶点着色器中将顶点转化到世界空间即可:
#version 330 core
layout (location = 0) in vec3 aPos;
uniform mat4 model;
void main()
{
gl_Position = model * vec4(aPos, 1.0);
}
几何着色器将每个三角形的三个顶点作为输入,并定义一个包含6个灯光空间变换矩阵的数组。
几何着色器中的内建变量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();
}
}
这个几何着色器中,我们输入一个三角形,并在这个三角形的举出上输出6个三角形。在main方法中,我们遍历立方体贴图的6个面,并将每个面的序号存储在gl_Layer中。接着我们将每个三角形变换到立方体贴图6个面对应的灯光空间中,FragPos代表每个顶点的位置,并将这个变量与变换矩阵相乘得到灯光空间的位置。我们也将这个FragPos传入片元着色器来计算深度值。
最后,在片元着色器中,我们这样做:
#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;
}
这个片元着色器将几何着色器计算的的FragPos作为输入,同时uniform定义灯光位置和远平面。我们获取灯光和片段之间的距离,并将这个距离映射到[0,1]作为片段从某一面的光源方向观察的深度值。
全方位深度贴图
做好这些准备后,我们开始渲染这张深度立方体贴图。过程和单方向深度贴图很相似,只是要将绑定的纹理类型换成立方体贴图:
glViewport(0, 0, SCR_WIDTH, SCR_HEIGHT);
glClear(GL_COLOR_BUFFER_BIT | GL_DEPTH_BUFFER_BIT);
shader.use();
// ... send uniforms to shader (including light's far_plane value)
glActiveTexture(GL_TEXTURE0);
glBindTexture(GL_TEXTURE_CUBE_MAP, depthCubemap);
// ... bind other textures
RenderScene();
接下来使用渲染好的深度图渲染我们的带阴影的场景。顶点着色器和片元着色器没有太大的改变,只是在片元着色器中我们不需要使用灯光空间的片段位置了,我们通过一个方向向量来采样深度值。
在顶点着色器中我们不再需要将顶点转移到灯光空间:
#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);
}
片元着色器如下:
#version 330 core
out vec4 FragColor;
in VS_OUT {
vec3 FragPos;
vec3 Normal;
vec2 TexCoords;
} fs_in;
uniform sampler2D diffuseTexture;
uniform samplerCube depthMap;
uniform vec3 lightPos;
uniform vec3 viewPos;
uniform float far_plane;
float ShadowCalculation(vec3 fragPos)
{
[...]
}
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);
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;
// calculate shadow
float shadow = ShadowCalculation(fs_in.FragPos);
vec3 lighting = (ambient + (1.0 - shadow) * (diffuse + specular)) * color;
FragColor = vec4(lighting, 1.0);
}
片元着色器的区别在于我们定义了立方体贴图类型的深度图采样器,同时计算阴影的方法的参数传入当前片段位置,不使用灯光空间位置。
在ShadowCalculation中,我们首先检索立方体贴图的深度值,我们像之前存储深度值到深度图中一样操作,当前片段位置减去灯光位置的向量作为采样的方向向量进行深度值采样:
float ShadowCalculation(vec3 fragPos)
{
vec3 fragToLight = fragPos - lightPos;
float closestDepth = texture(depthMap, fragToLight).r;
}
通过方向向量采样立方体贴图的值不需要使用单位向量,所以这样就可以。
结果的closestDepth的范围位于[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;
完整的ShadowCalculation方法如下:
float ShadowCalculation(vec3 fragPos)
{
// get vector between fragment position and light position
vec3 fragToLight = fragPos - lightPos;
// use the light to fragment vector to sample from the depth map
float closestDepth = texture(depthMap, fragToLight).r;
// it is currently in linear range between [0,1]. Re-transform back to original value
closestDepth *= far_plane;
// now get current linear depth as the length between the fragment and light position
float currentDepth = length(fragToLight);
// now test for shadows
float bias = 0.05;
float shadow = currentDepth - bias > closestDepth ? 1.0 : 0.0;
return shadow;
}
最后,(这里使用了几个平面来构成室内的模型)运行结果如下:
这里给出原文代码参考:Code。
观察立方体贴图深度缓冲
我们可以使用上面计算的closest值来观察一下深度值的分布:
FragColor = vec4(vec3(closestDepth / far_plane), 1.0);
结果会是这样:
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);
最后结果如下:
然而,上述的代码中,我们对每一维度都采样了4次,总共就是64次,太多了!
这些采样中大多数都是多余的,它们的采样向量很靠近原来的方向向量,然而只有与原来的方向向量垂直才能发挥更大的作用。然而,识别出哪些方向向量是多余的会很困难,有一个方法是粗略的给出一系列偏移方向来采样,这些方向完全不在同一方向,下面是20个偏移方向向量:
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)
);
我们可以使用这些向量来更新方法:
float shadow = 0.0;
float bias = 0.15;
int samples = 20;
float viewDistance = length(viewPos - fragPos);
float diskRadius = 0.05;
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来缩放这些偏移,环绕着原来的fragToLight进行采样。
另一个技巧是我们可以基于观察者到片段的距离改变diskRadius,让阴影在远处更柔和,在近处更尖锐:
float diskRadius = (1.0 + (viewDistance / far_plane)) / 25.0;
结果如下:
这里给出原文代码参考:Code。
需要注意的是,我们这里使用几何着色器来帮助渲染立方体深度贴图的方式并不一定是最好的,这需要针对实际情况而定,有时候反而是单独渲染立方体贴图的每个面更好,所以说如果对性能有很大的要求,可以测试这两种方式再进行选择。
最后,贴图原文地址供参考:https://learnopengl.com/Advanced-Lighting/Shadows/Point-Shadows。