OpenGL笔记(九)
参考链接 https://learnopengl.com/Advanced-Lighting/Shadows
一. 阴影贴图
目前并没有一个完全成熟的实现阴影的算法,先介绍一个比较简单实用的方法:阴影贴图
- shadow mapping:is quite easily extended into more advanced algorithms (like Omnidirectional Shadow Maps and Cascaded Shadow Maps).
阴影贴图的原理很简单,之前的depth buffer中,我们根据摄像头的位置和朝向,确定了摄像头空间,然后根据物体离摄像头的远近创建了depth buffer,可以过滤掉离摄像头更远的片元。
而光线是一个道理,如果以光源位置作为摄像头,把光源点当作我们的眼睛(如果是平行光,可以在上方任意一点进行),那么看得到的片元对应的位置就没有阴影,看不到的片元处就有阴影,如图所示。
注意: 阴影贴图又称为平行光阴影贴图,因为这种情况只能朝一个方向看,不能模拟点光源的情况。
可以类比为depth buffer,离光源更近的点,depth值越小,depth最小的点则在光源下,大于这个值就在阴影中。基于此原理,可以在光源的坐标系中,设定光源方向为z轴方向,将世界坐标系的点转换为光源坐标系,在光源坐标系下,生成对应的纹理贴图:
如何判断点P在不在阴影中
在生成了深度贴图的基础上,计算出世界坐标系下点P在光源坐标系下的坐标,将其z值与阴影贴图上该点对应的z进行对比,若比贴图值大,说明在阴影中,否则在光源照射下
创建深度贴图
为了得到深度贴图,相当于有一个以光源为视点的场景,需要把该点看到的画面,渲染成一张depth贴图,这于FrameBuffer的原理是一样的。
//创建深度buffer
unsigned int depthMapFBO;
glGenFramebuffers(1, &depthMapFBO);
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);
用阴影贴图实现阴影效果的流程大致如下:
// 1. 先渲染出一张深度贴图
glViewport(0, 0, SHADOW_WIDTH, SHADOW_HEIGHT);//阴影贴图的分辨率通常与window窗口不同
glBindFramebuffer(GL_FRAMEBUFFER, depthMapFBO);//绑定到fbo上
glClear(GL_DEPTH_BUFFER_BIT);
ConfigureShaderAndMatrices();//转换到灯的坐标系,渲染贴图
RenderScene();
// 2. 利用深度贴图实现阴影效果
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();
得到depth map的过程需要在光源的坐标系下进行,把光源当作摄像头,所以对其坐标系的转换也与正常的mvp矩阵转换差不多。
model矩阵是不会变的,view矩阵换成了以光源为中心,朝向朝着物体方向(姑且认为物体的位置在世界坐标系的远点),则:
glm::mat4 view = 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));
需要额外注意的几点
- 由于第一个FBO是为了生成ShadowMap,这是一张Depth贴图,并没有什么color attachment,所以在第一个FBO的shader里,片元着色器的main函数里没有任何内容。
- 生成ShadowMap后,第二个贴图只需要用glBindTexture,再用glUniform1i函数传入shader就可以了
- 这里第一个FBO使用的是平行投影矩阵的生成函数,不是perspective函数
- 提前挖取VBO的数据到VAO之中,那么在渲染绘图时,只需要用glBindVertexArray和glDrawArray函数就可以了
- 没有颜色输出,只是生产depth数据,渲染场景的API一样用的glDrawArray等DrawCall函数
全场最重要的几行函数:
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);
...//设置环绕方式
glBindFramebuffer(GL_FRAMEBUFFER, depthMapFBO);
glFramebufferTexture2D(GL_FRAMEBUFFER, GL_DEPTH_ATTACHMENT, GL_TEXTURE_2D, depthMap, 0);
//绑定后可以开始渲染场景
...
关于projection矩阵的选择
如果上点光源或者手电筒光源,那么用perspective的投影矩阵,如果是平行光,则用orthagraphic的投影矩阵,值得一提的上用perspective的投影矩阵计算得到的深度值不是线性的,大部分距离都趋近于1,要想提高分辨率,需要将其转换为线性空间,具体转换方法在之前的深度测试课程中有提到。
Shadow Acne(阴影疮)
Shadow Acne长这样,仔细看会有黑白相间的条纹
由于阴影贴图是按照一个个像素为单位大,也就是说在该像素对应的小正方体对应的一整个正方体柱里,所有的点在ShadowMap上读取的值都是一样大的。
如下图所示,从C处的摄像头看过去,理论上阴影贴图上,左边红点记录的深度值应该是左边红色箭头的长度, 右边红点记录的深度值应该是右边红色箭头的长度,明显右边的箭头长度更长,代表离深度更深,
但由于像素这么大的区域里,纹理贴图记载的高度是一样的,这样进行判断,则左边红点会处于光照中,右边的红点会处于阴影中:
解决方法,使用Shadow Bias,具体可以有两种加法:
- 加到ShadowMap上,即所有的Map上存储的深度会变小一点,如下图所示(相当于黄色线上移):
- 加到实际计算的深度上,即所有的实际深度会变大一点,跟上图相同(相当于黑色物体下移)
具体在片元着色器的代码如下:
float ShadowCalculation(vec4 fragPosLightSpace)
{
// perform perspective divide
vec3 projCoords = fragPosLightSpace.xyz / fragPosLightSpace.w;
// transform to [0,1] range
projCoords = projCoords * 0.5 + 0.5;
// get closest depth value from light's perspective (using [0,1] range fragPosLight as coords)
float closestDepth = texture(depthMap, projCoords.xy).r;
// get depth of current fragment from light's perspective
float currentDepth = projCoords.z;
// check whether current frag pos is in shadow
// 1.0代表存在阴影
float shadow = currentDepth - shadowAcneOffset > closestDepth ? 1.0 : 0.0;
return shadow;
}
三. 阴影失真
计算得到的阴影贴图可能锯齿化现象更严重,毕竟它本身就是由一个个像素的值算出来的
解决阴影贴图失真有几种办法
- 增大ShadowMap的分辨率
- 调整光的投影矩阵的frustum,使其更好的对准场景
- 使用PCF技术
PCF技术
全称percentage-closing filtering,里面有很多函数,能够用于柔化阴影
技术的核心:从ShadowMap上多次采样,每次的uv值都有略小的偏差,感觉跟之前的颜色失真的处理方式很像
//举一个非常简单的例子,每次采样采九个点,取平均值,再与像素计算得到的离摄像头的距离进行比较
float shadow = 0.0;
vec2 texelSize = 1.0 / textureSize(shadowMap, 0);//算出贴图每一个像素对应uv值的大小
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;
四.点光源生成ShadowMap
点光源生成ShadowMap的过程与平行光类似,唯一的区别就是点光源的光的照射方向是全空间的,为了覆盖到它所有的照射范围,此时的ShadowMap从1张变成了6张,为了方便存储,把这六张贴图利用cubeMap来存放。
另外需要注意,点光源用的投影矩阵是perspective矩阵,平行光是othorgraphic矩阵
按照之前生成的ShadowMap方式,每生成一张ShadowMap,就需要进行一次FBO和场景的渲染,如果是六张贴图,渲染六个FBO,六次场景,如下代码所示,这样会过于损耗性能:
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();
}
下面介绍一个解决上述问题的窍门:
利用GeometryShader在一个FBO内完成上述工作
由于只用一个FBO,所以将FBO输出的texture的格式
glBindFramebuffer(GL_FRAMEBUFFER, depthMapFBO);
//specify 输出的贴图的格式
glFramebufferTexture(GL_FRAMEBUFFER, GL_DEPTH_ATTACHMENT, depthCubemap, 0);
glDrawBuffer(GL_NONE);
glReadBuffer(GL_NONE);
之前提到过,Geometry Shader不是用来改变基础图元的吗,这里的操作好像不是改变基础图元啊?
答: 这里的Geometry Shader的作用,确实也是改变基础图元,不过改变的不是图元的类型,而是改变了图元的数量和位置
在这里Geometry Shader的作用如下:
负责将空间上的点转换到每一个面对应的light space
#version 330 core
layout (triangles) in; //输入图元为三角形, 因为DrawCall里绘制的基本图元是GL_TRIANGLES
layout (triangle_strip, max_vertices=18) out;//输出图元为三角形带,最多18个顶点
uniform mat4 shadowMatrices[6];//用uniform传入6个面分别对应的转换矩阵
//这里的FragPos传给片元着色器,是为了将depth转换为线性空间
out vec4 FragPos; // FragPos from GS (output per emitvertex)
void main()
{
for(int face = 0; face < 6; ++face)
{
gl_Layer = face; // built-in variable that specifies to which face we render.
for(int i = 0; i < 3; ++i) // for each triangle's vertices
{
FragPos = gl_in[i].gl_Position;//保留原来的世界坐标系的坐标
//对同一个三角形进行六次不同的变换
gl_Position = shadowMatrices[face] * FragPos;
EmitVertex();
}
//虽然上面是输出的三角带,但是这里每三个点就EndPrimitive
//所以实际输出的是三角形图元,图元的类型并没有变
EndPrimitive();
}
}
关于gl_Layer
gl_Layer
是几何着色器中的built-in variavble, 仅在当前的framebuffer绑定了cubemap texture的时候,通过改变gl_Layer
的值可以设定当前的texture是哪一个面
这里顺便提一下给uniform数组传值的写法,比如要传入uniform mat4 shadowMatrices[6];
唯一的区别就是uniform名字不一样,写法如下:
for(int i = 0; i < 6; i++)
{
//shadpwTransforms是实际的mat4的数组
glUniformMatrix4fv(glGetUniformLocation("shadowMatrices[" + std::to_string(i) + "]", shadowTransforms[i]);
}
计算shadowMatrices
六个面对应不同的projection * view矩阵,但是,这六个矩阵的projection矩阵是一模一样的
//fructum是一模一样的
glm::mat4 shadowProj = glm::perspective(glm::radians(90.0f), aspect, near, far);
但是view矩阵就不一样了,因为摄像头(也就是光源)看向了六个不同的方向
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)));
注意到这里的每个lookAt函数对应的up的向量有的是-1
,这里不做深究,可以去stackoverflow上查到。
创建cubemap的代码如下
unsigned int depthMapFBO;
glGenFramebuffers(1, &depthMapFBO);
// create depth texture
unsigned int depthCubeMap;
glGenTextures(1, &depthCubeMap);
glBindTexture(GL_TEXTURE_CUBE_MAP, depthCubeMap);
//设置每一个面的格式
for (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);
// attach depth texture as FBO's depth buffer
glBindFramebuffer(GL_FRAMEBUFFER, depthMapFBO);
glFramebufferTexture(GL_FRAMEBUFFER, GL_DEPTH_ATTACHMENT, depthCubeMap, 0);
glDrawBuffer(GL_NONE);
glReadBuffer(GL_NONE);
在之前的平行光的阴影贴图教程中,负责生成阴影贴图的fragment shader里面的main函数里面是空的,这时输出的depth贴图的值都在[0, 1]之间,但是这个值不是线性的,如果使用该贴图,对于需要计算的点A,需要把A再次换算成光源坐标系的点A‘,再与贴图值进行比较。
而在这次生成cubeDepthMap的片元着色器中,把所有点的深度值变成了线性的。
#version 330 core
in vec4 FragPos;//这是从几何着色器传过来的点的真实世界坐标值
uniform vec3 lightPos;
uniform float far_plane;
void main()
{
float dist = length(FragPos.xyz - lightPos);
dist = dist/far_plane;//按照最大值的far_plane,转换成线性的
gl_FragDepth = dist;
}
转换成线性的有何用呢?可以看看绘制过程中,FragmentShader中的阴影计算函数:
float ShadowCalculation(vec3 fragPos)
{
// get vector between fragment position and light position
vec3 fragToLight = fragPos - lightPos;
// ise the fragment to light vector to sample from the depth map
float closestDepth = texture(depthMap, fragToLight).r;
// 由于这个值是线性的,直接乘以far_plane,可以得到计算阴影贴图时的真实距离
// 这样就不需要再将点转换到光影的坐标系,再去与贴图得到的值进行对比了
closestDepth *= far_plane;
// now get current linear depth as the length between the fragment and light position
float currentDepth = length(fragToLight);
// test for shadows
float bias = 0.05; // we use a much larger bias since depth is now in [near_plane, far_plane] range
float shadow = currentDepth - bias > closestDepth ? 1.0 : 0.0;
// display closestDepth as debug (to visualize depth cubemap)
// FragColor = vec4(vec3(closestDepth / far_plane), 1.0);
return shadow;
}
最后的阴影,由于没有做反走样的处理,还是会显示锯齿状:
PCF处理阴影
之前也提到了,PCF是Percentage-closer filtering,举个例子:
float shadow = 0.0;
float bias = 0.05;
float samples = 4.0;
float offset = 0.1;
//每个片元取周围一定距离的64个点进行采样,采到就加一,最后除以64取平均值
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);
效果会好很多:
上面效果是很好,但是每个片采样了64个点,挺消耗性能的,可以用一个偏移量尽可能大的数组:
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, fragToLight + sampleOffsetDirections[i] * diskRadius).r;
closestDepth *= far_plane; // Undo mapping [0;1]
if(currentDepth - bias > closestDepth)
shadow += 1.0;
}
shadow /= float(samples);
值得一提的是
上面的代码有一个参数diskRadius
,当观察者距离阴影很远时,适当加大sample的采样距离,有助于提高阴影效果,这样远看是soft shadow
,近看是hard shadow
float diskRadius = (1.0 + (viewDistance / far_plane)) / 25.0;
提示
其实用几何着色器去渲染立方体深度贴图,并不一定能提高效率,具体还得看硬件和使用的环境。其实两种做法理论上都可以:
- 渲染六次场景,每一次场景得到一张立方体的深度贴图
- 利用几何着色器,渲染一次场景,一次得到整个立方体的深度贴图