OpenGl L26阴影映射

一、阴影映射

阴影映射:以光的位置为视角进行渲染,我们能看到的东西都将被点亮,看不见的一定是在阴影之中了。
如图:
在这里插入图片描述
从光源的透视图来渲染场景,并把深度值的结果储存到纹理中。通过这种方式,对光源的透视图所见的最近的深度值进行采样。最终,便得到了深度贴图。在这里插入图片描述
左侧的图片展示了一个定向光源(所有光线都是平行的)在立方体下的表面投射的阴影。通过储存到深度贴图中的深度值,我们就能找到最近点,用以决定片段是否在阴影中。我们使用一个来自光源的视图和投影矩阵来渲染场景就能创建一个深度贴图。这个投影和视图矩阵结合在一起成为一个T变换,它可以将任何三维位置转变到光源的可见坐标空间。

阴影映射由两个步骤组成:

  • 首先,我们渲染深度贴图
  • 然后我们像往常一样渲染场景,使用生成的深度贴图来计算片段是否在阴影之中。

二、深度贴图

(一)第一步我们需要生成一张深度贴图。深度贴图是从光的透视图里渲染的深度纹理,用它计算阴影。因为我们需要将场景的渲染结果储存到一个纹理中,我们将再次需要帧缓冲。

  • 首先,我们要为渲染的深度贴图创建一个帧缓冲对象:
GLuint depthMapFBO;
glGenFramebuffers(1, &depthMapFBO);
  • 然后,创建一个2D纹理,提供给帧缓冲的深度缓冲使用:
const GLuint SHADOW_WIDTH = 1024, SHADOW_HEIGHT = 1024;

GLuint 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);
  • 把生成的深度纹理作为帧缓冲的深度缓冲:
glBindFramebuffer(GL_FRAMEBUFFER, depthMapFBO);
glFramebufferTexture2D(GL_FRAMEBUFFER, GL_DEPTH_ATTACHMENT, GL_TEXTURE_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();

(三)光源空间的变换

使用ConfigureShaderAndMatrices确保了每个物体设置了合适的投影和视图矩阵,以及相关的模型矩阵。
由于这里我们使用的是所有光线都平行的定向光。于是,我们将光源使用正交投影矩阵,使得透视图不变。

GLfloat near_plane = 1.0f, far_plane = 7.5f;
glm::mat4 lightProjection = glm::ortho(-10.0f, 10.0f, -10.0f, 10.0f, near_plane, far_plane);

因为投影矩阵间接决定可视区域的范围,以及哪些东西不会被裁切,你需要保证投影视锥(frustum)的大小,以包含打算在深度贴图中包含的物体。当物体和片段不在深度贴图中时,它们就不会产生阴影。
为了创建一个视图矩阵来变换每个物体,把它们变换到从光源视角可见的空间中,我们将使用glm::lookAt函数;这次从光源的位置看向场景中央。

glm::mat4 lightView = glm::lookAt(glm::vec(-2.0f, 4.0f, -1.0f), glm::vec3(0.0f, 0.0f, 0.0f), glm::vec3(0.0f, 1.0f, 0.0f));

二者相结合为我们提供了一个光空间的变换矩阵,它将每个世界空间坐标变换到光源处所见到的那个空间;

glm::mat4 lightSpaceMatrix = lightProjection * lightView;

这个lightSpaceMatrix正是前面我们称为T的那个变换矩阵。

(四)渲染至深度贴图

当我们以光的透视图进行场景渲染的时候,会使用一个深度采样着色器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);
}

这个顶点着色器将一个单独模型的一个顶点,使用lightSpaceMatrix变换到光空间中。
由于我们没有颜色缓冲,最后的片段不需要任何处理,所以我们可以简单地使用一个空片段着色器:

#version 330 core

void main()
{             
    // gl_FragDepth = gl_FragCoord.z;
}

运行完成后,深度缓存会被更新,然后我们可以取消注释,显示设置的深度。
最后,在光的透视视角下,每个可见片段的最近深度填充了深度缓存。将这个纹理投射到一个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);
}

三、渲染阴影

正确地生成深度贴图以后我们就可以开始生成阴影了。

#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);
}

我们用同一个lightSpaceMatrix,把世界空间顶点位置转换为光空间。顶点着色器传递一个普通的经变换的世界空间顶点位置vs_out.FragPos和一个光空间的vs_out.FragPosLightSpace给片段着色器。
片段着色器使用Blinn-Phong光照模型渲染场景。我们接着计算出一个shadow值,当fragment在阴影中时是1.0,在阴影外是0.0。然后,diffuse和specular颜色会乘以这个阴影元素。

#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函数,用它计算阴影。片段着色器的最后,我们我们把diffuse和specular乘以(1-阴影元素),这表示这个片段有多大成分不在阴影中。
这个片段着色器还需要两个额外输入,一个是光空间的片段位置和第一个渲染阶段得到的深度贴图。

1.片段的阴影检测

首先要检查一个片段是否在阴影中,把光空间片段位置转换为裁切空间的标准化设备坐标。当我们在顶点着色器输出一个裁切空间顶点位置到gl_Position时,OpenGL自动进行一个透视除法,将裁切空间坐标的范围-w到w转为-1到1,这要将x、y、z元素除以向量的w元素来实现。
由于裁切空间的FragPosLightSpace并不会通过gl_Position传到片段着色器里,我们必须自己做透视除法:

float ShadowCalculation(vec4 fragPosLightSpace)
{
    // 执行透视除法
    vec3 projCoords = fragPosLightSpace.xyz / fragPosLightSpace.w;
    [...]
}

返回了片段在光空间的-1到1的范围,因为来自深度贴图的深度在0到1的范围,我们也打算使用projCoords从深度贴图中去采样,所以我们将NDC坐标变换为0到1的范围。
而为了和深度贴图的深度相比较,z分量需要变换到[0,1];
为了作为从深度贴图中采样的坐标,xy分量也需要变换到[0,1]。

projCoords = projCoords * 0.5 + 0.5;

从第一个渲染阶段的projCoords坐标直接对应于变换过的NDC坐标。我们将得到光的位置视野下最近的深度:

float closestDepth = texture(shadowMap, projCoords.xy).r;

为了得到片段的当前深度,我们简单获取投影向量的z坐标,它等于来自光的透视视角的片段的深度。

float currentDepth = projCoords.z;

通过对比currentDepth是否高于closetDepth,如果是,那么片段就在阴影中。

float shadow = currentDepth > closestDepth  ? 1.0 : 0.0;

完整的shadowCalculation函数:

float ShadowCalculation(vec4 fragPosLightSpace)
{
    // 执行透视除法
    vec3 projCoords = fragPosLightSpace.xyz / fragPosLightSpace.w;
    // 变换到[0,1]的范围
    projCoords = projCoords * 0.5 + 0.5;
    // 取得最近点的深度(使用[0,1]范围下的fragPosLight当坐标)
    float closestDepth = texture(shadowMap, projCoords.xy).r; 
    // 取得当前片段在光源视角下的深度
    float currentDepth = projCoords.z;
    // 检查当前片段是否在阴影中
    float shadow = currentDepth > closestDepth  ? 1.0 : 0.0;

    return shadow;
}

激活这个着色器,绑定合适的纹理,激活第二个渲染阶段默认的投影以及视图矩阵,结果如下图所示:
在这里插入图片描述

六、改进阴影贴图

1.阴影失真

针对这种明显的线条样式:
在这里插入图片描述
可以看到地板四边形渲染出很大一块交替黑线。这种阴影贴图的不真实感叫做阴影失真。
成因如下:
在这里插入图片描述
因为阴影贴图受限于分辨率,在距离光源比较远的情况下,多个片段可能从深度贴图的同一个值中去采样。
当光源以一个角度朝向表面的时候,深度贴图是从一个角度下进行渲染的。多个片段从同一个斜坡的深度纹理像素中采样,于是就产生了图片中的条纹样式。

  • 办法:阴影偏移
    简单的对表面的深度(或深度贴图)应用一个偏移量,这样片段就不会被错误地认为在表面之下了。
    在这里插入图片描述
    使用了偏移量后,所有采样点都获得了比表面深度更小的深度值,这样整个表面就正确地被照亮,没有任何阴影。我们可以这样实现这个偏移:
float bias = 0.005;
float shadow = currentDepth - bias > closestDepth  ? 1.0 : 0.0;

或者根据表面朝向光线的角度更改偏移量(点乘)

float bias = max(0.05 * (1.0 - dot(normal, lightDir)), 0.005);

下图展示了同一个场景,但使用了阴影偏移,效果的确更好:
在这里插入图片描述

2.悬浮

使用阴影偏移的一个缺点是你对物体的实际深度应用了平移。偏移有可能足够大,以至于可以看出阴影相对实际物体位置的偏移,你可以从下图看到这个现象:
在这里插入图片描述
这个阴影失真叫做悬浮,是由于阴影偏移所带来的。

  • 办法:当渲染深度贴图时候使用正面剔除
    因为我们只需要深度贴图的深度值,对于实体物体无论我们用它们的正面还是背面都没问题。使用背面深度不会有错误,因为阴影在物体内部有错误我们也看不见。
    在这里插入图片描述
    为了修复peter游移,我们要进行正面剔除,先必须开启GL_CULL_FACE:
glCullFace(GL_FRONT);
RenderSceneToDepthMap();
glCullFace(GL_BACK); // 不要忘记设回原先的culling face

我们的场景中,在立方体上工作的很好,但在地板上无效,因为正面剔除完全移除了地板。
地面是一个单独的平面,不会被完全剔除。
另一个要考虑到的地方是接近阴影的物体仍然会出现不正确的效果。
必须考虑到何时使用正面剔除对物体才有意义。不过使用普通的偏移值通常就能避免peter panning。

3.采样过多

光的视锥不可见的区域一律被认为是处于阴影中,不管它真的处于阴影之中。
出现这个状况是因为超出光的视锥的投影坐标比1.0大,这样采样的深度纹理就会超出他默认的0到1的范围。
根据纹理环绕方式,我们将会得到不正确的深度结果,它不是基于真实的来自光源的深度值。
在这里插入图片描述
可以在图中看到,光照有一个区域,超出该区域就成为了阴影;左边区域造成阴影的原因在于深度贴图的环绕方式设置成了GL_REPEAT

  • 办法:设置边框颜色并改变深度贴图的纹理环绕方式
    我们让所有超出深度贴图的坐标的深度范围是1.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。
这种情况下,GL_CLAMP_TO_BORDER环绕方式不起作用,因为我们把坐标的z元素和深度贴图的值进行了对比;它总是为大于1.0的z返回true。

  • 办法:设置shadow值
    要投影向量的z坐标大于1.0,我们就把shadow的值强制设为0.0:
float ShadowCalculation(vec4 fragPosLightSpace)
{
    [...]
    if(projCoords.z > 1.0)
        shadow = 0.0;

    return shadow;
}

检查远平面,并将深度贴图限制为一个手工指定的边界颜色,就能解决深度贴图采样超出的问题,我们最终会得到下面我们所追求的效果:
在这里插入图片描述
这意味着,只有在深度贴图范围以内的被投影的fragment坐标才有阴影,所以任何超出范围的都将会没有阴影。

4.PCF

但是现在还有问题就是阴影,如果放大看阴影,阴影映射对分辨率的依赖很明显。
在这里插入图片描述
因为深度贴图有一个固定的分辨率,多个片段对应于一个纹理像素。
结果就是多个片段会从深度贴图的同一个深度值进行采样,这几个片段便得到的是同一个阴影,这就会产生锯齿边。也就是最后得到的阴影并不平滑。

  • 方法
    这时可以通过增加深度贴图的分辨率的方式来降低锯齿块,也可以尝试尽可能的让光的视锥接近场景。
    另一个解决方案叫做PCF,这是一种多个不同过滤方式的组合,它产生柔和阴影,使它们出现更少的锯齿块和硬边。

核心思想是从深度贴图中多次采样,每一次采样的纹理坐标都稍有不同。每个独立的样本可能在也可能不再阴影中。所有的次生结果接着结合在一起,进行平均化,我们就得到了柔和阴影。

一个简单的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值的周围,为阴影阻挡进行测试,并最终通过样本的总数目将结果平均化。
使用更多的样本,更改texelSize变量,你就可以增加阴影的柔和程度。下面你可以看到应用了PCF的阴影:
在这里插入图片描述
从稍微远一点的距离看去,阴影效果好多了,也不那么生硬了。如果你放大,仍会看到阴影贴图分辨率的不真实感,但通常对于大多数应用来说效果已经很好了。

5.正交 vs 投影

正交投影矩阵并不会将场景用透视图进行变形,所有视线/光线都是平行的,这使它对于定向光来说是个很好的投影矩阵。下图展示了两种投影方式所产生的不同阴影区域:
在这里插入图片描述
透视投影对于光源来说更合理,不像定向光,它是有自己的位置的。透视投影因此更经常用在点光源和聚光灯上,而正交投影经常用在定向光上。

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值