本文主要解决一个问题:
如何显示点光源的阴影?
引言
在上一章中,我们使用了阴影图来渲染方向光的阴影。很酷是不是?既然方向光已经显示出来了,我们自然要把目光集中到另一种光源:聚光灯?当然不是,聚光灯和方向光太像了,我们要显示的是点光源阴影。方向光的阴影图有一个专门的称呼叫定向阴影贴图(directional shadow mapping),点光源的阴影贴图也有一个专门的称呼叫全向阴影贴图(omnidirectional shadow maps)。在这一章中,我们就来讨论如何使用全向阴影贴图绘制点光源的阴影。
本章内容有很大一部分与上一章相关,如果你不熟悉上一章的内容,强烈建议你将上一章的内容消化完再阅读本章。
原理
在前一章中,我们通过一张深度图来保存方向光的深度信息,这方法不错,能不能用到全向阴影贴图中呢?好像有点困难,对点光源来说,要有多少张贴图才算合适,点光源的光线方向可是所有方向。所以简单的纹理图肯定不行,纹理盒呢?纹理盒可以保存6个面所有的深度信息,应该可以满足我们的要求。
有了这个想法后,我们再来整理一下实现思路。从光源位置出发,在前后左右上下6个方向上做投影,得到6个方向的深度信息,保存到纹理盒中。使用的时候,通过计算片元位置到光源位置的距离,和纹理中的深度信息比较,如果大于深度信息,那么就是阴影;反之,就是被照亮的部分。这样,我们就能从容地渲染点光源阴影了。
给出一张原理图更容易理解:
点光源阴影原理(图片来自www.learnopengl.com)
图中黑色的部分就是阴影区域,由于被盒子挡住,这些区域接收不到光照,它们的深度值会大于深度图中最近的深度值,故而会被渲染成阴影。好了,实现点光源阴影的流程基本和上一章一样,我们一步步来操作:
生成深度纹理盒
先来复习一下怎么生成纹理盒。和普通的2D纹理不同的地方有两个:第一、绑定和设置参数的时候需要指定成GL_TEXTURE_CUBE_MAP。第二、由于纹理盒有6个面,所以我们需要循环6次每次分配一个面的内存空间。将这些不同翻译成代码就是:
unsigned int depthCubemap;
glGenTextures(1, &depthCubemap);
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_MIN_FILTER, GL_NEAREST);
glTexParameteri(GL_TEXTURE_CUBE_MAP, GL_TEXTURE_MAG_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);
发现没?glBindTexture和glTexParameteri的第一个参数都变成了GL_TEXTURE_CUBE_MAP,glTexParameteri还设置了GL_TEXTURE_WRAP_R的环绕方式。这是第一个不同点。第二个不同点就是使用glTexImage2D函数分配空间的时候需要设置成GL_TEXTURE_CUBE_MAP_POSITIVE_X、GL_TEXTURE_CUBE_MAP_NEGATIVE_X、GL_TEXTURE_CUBE_MAP_POSITIVE_Y、GL_TEXTURE_CUBE_MAP_NEGATIVE_Y、GL_TEXTURE_CUBE_MAP_POSITIVE_Z、GL_TEXTURE_CUBE_MAP_NEGATIVE_Z。幸运的是这6个参数的值是递增的,所以我们可以用一个循环来分配这些面的内存空间。
有了纹理之后,接下来就要将纹理作为深度附件附加到帧缓存中去了。这个过程和上一章类似:
//将纹理图附加到帧缓存上
glBindFramebuffer(GL_FRAMEBUFFER, cubemapFBO);
glFramebufferTexture(GL_FRAMEBUFFER, GL_DEPTH_ATTACHMENT, depthCubemap, 0);
glDrawBuffer(GL_NONE); //告诉OpenGL不需要绘制颜色数据
glReadBuffer(GL_NONE); //告诉OpenGL不需要绘制颜色数据
glBindFramebuffer(GL_FRAMEBUFFER, 0);
就是绑定了自定义的帧缓存后将纹理附加到帧缓存上罢了,没什么花头。要注意的就是这里我们也需要告诉OpenGL不需要绘制颜色数据。这样就不用附加颜色缓存上去了。
光源空间变换矩阵
前一章中,我们采用了正交投影的方式将物体变换到光源空间中。这种方法在点光源阴影绘制中显然不合适。我们需要的是一个真实的效果,就是那种如果物体靠地近了,阴影会变大,离得远了,阴影会变小的效果。所以正交投影的方式就被抛弃了,改用透视投影:
//透视投影矩阵
float aspect = (float)SHADOW_WIDTH / (float)SHADOW_HEIGHT;
float near_plane = 1.0f;
float far_plane = 25.0f;
glm::mat4 shadowProj = glm::perspective(glm::radians(90.0f), aspect, near_plane, far_plane);
注意,我们要将远平面调整的远一点为了容纳更多的物体,同时将fov值设置成90度确保视野足够开阔可以获取一整个面的信息。
因为透视投影矩阵不会随着面的不同而改变,所以我们可以复用shadowProj变量,用它乘上观察变换矩阵得到最终物体在某个面方向上的信息。观察变换矩阵我们要用glm::lookAt函数来生成,调用6次生成6个方向的观察变换矩阵,这六个面分别是:右面、左面、上面、下面、近面、远面:
//观察矩阵
std::vector<glm::mat4> 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)));
这样,我们就有了6个不同方向的变换矩阵。这些矩阵将传递给着色器进行深度图的渲染。
深度着色器
为了渲染6个面的深度图,我们有两种方式:1、调用6次渲染函数绘制6次。这是最容易相当的方法,但是调用渲染函数的过程很耗时间,这点我们从之前的实例化文章中就了解了,所以我们可以用第二种方式,那就是使用几何着色器。2、使用几何着色器。利用几何着色器可以生成比输入顶点数更多顶点的这一优势,将输入的1个三角形图元变成6个三角形图元输出,这样,进行一次绘制流程就能生成6个面的深度图了。
好了,思路明确,就看怎么实现了。因为几何着色器中会输出顶点在6个光源空间中的位置,所以我们的顶点着色器只要把顶点的世界坐标传递过去就可以了:
//因为转换工作在几何着色器中做,所以顶点着色器只要把数值传递给几何着色器就可以
#version 330 core
layout (location = 0) in vec3 aPos;
uniform mat4 model;
void main() {
gl_Position = model * vec4(aPos, 1.0);
}
几何着色器收到顶点坐标后,针对每一个面都需要对三角形的各个顶点都做一次位置变换。这里就有趣了,我们怎么让OpenGL知道这些顶点是对应纹理盒的哪个面呢?
所幸几何着色器有一个内置变量成为gl_Layer,它可以指定产生顶点到哪个面上。当然,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(); // 一个三角形结束输出一次
}
}
代码还是相当直观的。我们将输入的3个顶点变成了18个顶点进行输出,通过设置gl_Layer的值来表示输出到哪个面。还好这个值和我们的变换矩阵顺序一致,不然就惨了。当然,生成的位置并不是世界空间中的坐标,而是光源空间中的坐标,因为我们需要在最后的片元着色器中对深度值进行比较。除了变换后的位置,片元的原位置也要传递给片元着色器为了计算片元与光源之间的距离。
最后,片元着色器要计算片元位置和光源位置之间的距离,将这个距离缩放到[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;
}
使用这些着色器和有纹理盒的帧缓存渲染出来的场景就是我们所需要的深度纹理盒了,有了这个东西,我们就可以进行下面的操作了。
全向阴影贴图
当所有的准备工作都完成,我们就可以来绘制点光源阴影了。绘制的流程与前一章的流程很类似,就是把绑定的2D纹理换成纹理盒,以及把透视变换的远平面传递给着色器:
//一些前置工作(包括设置远平面值)
glActiveTexture(GL_TEXTURE0);
glBindTexture(GL_TEXTURE_CUBE_MAP, depthCubemap);
//一些其他的纹理
RenderScene();
顶点着色器和片元着色器的代码也和定向阴影绘制时类似,不同的是:片元着色器不再需要片元在光源空间中的位置,因为我们可以通过方向向量来获取其深度值。
因此,顶点着色器就不需要输出片元在光源空间中的位置了,删除这部分内容之后,顶点着色器的代码就像这样:
#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;
}vs_out;
uniform mat4 model;
uniform mat4 view;
uniform mat4 projection;
uniform bool reverse_normals; //最外面的包围盒的法线需要翻转,不然光照上去看不出效果
void main() {
vs_out.FragPos = vec3 (model * vec4 (aPos, 1.0));
if (reverse_normals)
vs_out.Normal = transpose(inverse(mat3(model))) * (-1.0 * aNormal);
else
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 sampler2D diffuseTexture;
uniform samplerCube depthMap;
uniform vec3 lightPos;
uniform vec3 viewPos;
uniform float far_plane;
float ShadowCalculation (vec3 fragPos) {
//TODO,阴影计算
}
void main()
{
vec3 color = texture(diffuseTexture, fs_in.TexCoords).rgb;
vec3 normal = normalize(fs_in.Normal);
vec3 lightColor = vec3(1.0);
// 环境光
vec3 ambient = 0.3 * color;
// 漫反射光
vec3 lightDir = normalize(lightPos - fs_in.FragPos);
float diff = max(dot(lightDir, normal), 0.0);
vec3 diffuse = diff * lightColor;
// 镜面高光
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.FragPos);
vec3 lighting = (ambient + (1.0 - shadow) * (diffuse + specular)) * color;
FragColor = vec4(lighting, 1.0);
}
让我们把注意力集中到里面的细微差别上:光照部分代码一样,不过由于我们要用samplerCube采样,所以传递给ShadowCalculation的参数就变成了片元的世界坐标。我们也需要将光锥体的远平面值传递进来,这个在计算阴影的时候会用到。
最大的不同之处还是在ShadowCalculation函数中,因为用纹理盒代替2D纹理的缘故,我们需要对整个流程重新整理一下。
第一步,自然是要获取片元位置的深度信息。对纹理盒采样的方式是用一个方向向量,所以我们要计算光源到片元的方向,然后用texture函数采样:
//光源指向片元的向量
vec3 lightToFrag = fragPos - lightPos;
//从纹理盒上去最近点的深度
float closestDepth = texture(depthMap, lightToFrag).r;
这里我们不需要将这个向量转换成单位向量,不仅是因为我们采样时只关心方向不关心大小,更重要的是之后我们会用到这个向量的长度来和最近点深度进行比较。
由于采样到的深度值是规范化的[0,1],所以我们还要乘上far_plane将其还原:
//将[0,1]范围的值变换到原尺寸中
closestDepth *= far_plane;
接着,我们计算当前片元的深度信息,这一步操作非常简单,只需要使用length函数获取lightToFrag向量的长度就行了:
//当前深度值计算
float currentDepth = length (lightToFrag);
最后,加上一段偏移值bias,计算片元位置是否在阴影中:
float bias = 0.005;
float shadow = (currentDepth - bias) > closestDepth? 1.0 : 0.0;
好,完整的代码就是这个样子:
float ShadowCalculation (vec3 fragPos) {
//光源指向片元的向量
vec3 lightToFrag = fragPos - lightPos;
//从纹理盒上去最近点的深度
float closestDepth = texture(depthMap, lightToFrag).r;
//将[0,1]范围的值变换到原尺寸中
closestDepth *= far_plane;
//当前深度值计算
float currentDepth = length (lightToFrag);
float bias = 0.005;
float shadow = (currentDepth - bias) > closestDepth? 1.0 : 0.0;
return shadow;
}
好,编译运行,如果一切顺利,你看到的场景应该是这样:
运行结果
如果有问题,请下载这里的源码进行比对。
显示深度纹理
如果你像我这样不是在一开始就显示正确,那么你肯定就需要一些调试的手段。正好,将深度纹理显示出来是一个很好的调试手段。确定你的深度纹理是否渲染正确可以帮助你大大减少定位问题的时间。(这不是开玩笑,笔者也是调试之后发现自己用glTexImage2D时设置参数设置错了才能很快的显示成功。)不过我们没有2D纹理图,要显示深度似乎有些困难。
有个简单的方法就是将深度信息直接作为颜色值输出,不用再接着计算了。在ShadowCalculation的float closestDepth = texture(depthMap, lightToFrag).r;
代码后面加入这两行:
FragColor = vec4(vec3(closestDepth), 1.0);
return 1.0;
然后将主函数中给FragColor赋值的那行代码注释掉,运行程序就能看到这结果:
运行效果
是不是很赞?
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, lightToFrag + vec3(x, y, z)).r;
closestDepth *= far_plane; // 切换到原大小
if(currentDepth - bias > closestDepth)
shadow += 1.0;
}
}
}
shadow /= (samples * samples * samples);
在每个方向上都取一定的偏移量来采样,这里我们设置的偏移值是[-0.1,0.1],每次采样间隔为0.05,意味着每个维度上取4次值,这样总共的采样次数就是444=64次。
运行之后,效果明显好很多:
运行效果
但是,每个片元都要进行64次采样好像太多了,这会浪费很多的显存,我们有没有方法减少采样次数但又不降低显示质量呢?遗憾的是,并没有什么简单的方法来确定可以丢弃哪些位置的采样,我们能做的只有两点:1、采用已经验证的效果还不错的采样位置进行采样。2、自己尝试研究丢弃哪些位置的采样显示质量依旧不错。作为一篇入门文章显然不适合仔细研究哪些位置的采样可以丢弃,所以直接给出一组可以用来采样的位置向量:
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)
);
然后,修改一下采样的方法,只对这20个方向进行采样就可以了:
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, lightToFrag + sampleOffsetDirections[i] * diskRadius).r;
closestDepth *= far_plane; // 切换到原大小
if(currentDepth - bias > closestDepth)
shadow += 1.0;
}
shadow /= float(samples);
看看运行效果:
运行效果
看上去效果也不错,关键是这方法采样数量比之前减少了2/3的计算量,性价比贼高。
好了,上传所有代码以供参考。
总结
本章我们实现了点阴影的绘制。原理和定向阴影类似,只是用纹理盒代替2D纹理,捕捉所有方向的深度信息,再用来比较绘制。其中的难点就是如何使用几何着色器一次性将6个面的深度信息全部收集到纹理盒中。我们采用的方法是传入6个光源空间变换矩阵,将原本的物体转换到光源空间中再输出给片元着色器使用。最后,我们还用PCF方法平滑了阴影边缘的锯齿,方式和上一章中的方式类似,只是固定了一些采样的方向。
又是一个大功能,弄完洗洗睡觉~
参考资料
www.learnopengl.com(非常好的网站,建议学习)
网友评论