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;  

提示
其实用几何着色器去渲染立方体深度贴图,并不一定能提高效率,具体还得看硬件和使用的环境。其实两种做法理论上都可以:

  • 渲染六次场景,每一次场景得到一张立方体的深度贴图
  • 利用几何着色器,渲染一次场景,一次得到整个立方体的深度贴图
  • 0
    点赞
  • 1
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值