从零开始的OpenGL光栅化渲染器构建5-阴影

前言

阴影是光线被阻挡的结果;当一个光源的光线由于其他物体的阻挡不能够达到一个物体的表面的时候,那么这个物体就在阴影中了。阴影能够使场景看起来真实得多,并且可以让观察者获得物体之间的空间位置关系。

直接阴影

阴影映射(Shadow Mapping)背后的思路非常简单:我们以光的位置为视角进行渲染,我们能看到的东西都将被点亮,看不见的一定是在阴影之中了。

阴影映射的过程是,首先从光的视角渲染出一张深度图(利用帧缓冲),然后再进行正常渲染,根据深度图判断当前片段是否在阴影中。阴影映射的伪代码如下:

// 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();

阴影映射的可视化结果如下:

从零开始的OpenGL光栅化渲染器构建5-阴影_第1张图片

如何检查一个片段是否在阴影中:

首先把变换到光源视角空间的片段位置转换为裁切空间的标准化设备坐标。然后将当前片段的深度值与深度图中存储的深度值做比较,决定此时是否处在阴影之中。

此时渲染出的效果图如下图所示:
从零开始的OpenGL光栅化渲染器构建5-阴影_第2张图片

对阴影进行改进

我们可以看到,此时在图片中可以看到明显的线条样式,这种阴影贴图的不真实感叫做阴影失真(Shadow Acne),下图解释了成因:

从零开始的OpenGL光栅化渲染器构建5-阴影_第3张图片

因为阴影贴图受限于分辨率,在距离光源比较远的情况下,多个片段可能从深度贴图的同一个值中去采样。图片每个斜坡代表深度贴图一个单独的纹理像素。你可以看到,多个片段从同一个深度值进行采样。

虽然很多时候没问题,但是当光源以一个角度朝向表面的时候就会出问题,这种情况下深度贴图也是从一个角度下进行渲染的。多个片段就会从同一个斜坡的深度纹理像素中采样,有些在地板上面,有些在地板下面;这样我们所得到的阴影就有了差异。因为这个,有些片段被认为是在阴影之中,有些不在,由此产生了图片中的条纹样式。

我们可以用一个叫做阴影偏移(shadow bias)的技巧来解决这个问题,我们简单的对表面的深度(或深度贴图)应用一个偏移量,这样片段就不会被错误地认为在表面之下了。

从零开始的OpenGL光栅化渲染器构建5-阴影_第4张图片

使用了偏移量后,所有采样点都获得了比表面深度更小的深度值,这样整个表面就正确地被照亮,没有任何阴影。实现的代码为:

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

这里我们有一个偏移量的最大值0.05,和一个最小值0.005,它们是基于表面法线和光照方向的。这样像地板这样的表面几乎与光源垂直,得到的偏移就很小,而比如立方体的侧面这种表面得到的偏移就更大。

除此之外,还有一个正面剔除的步骤,为了解决阴影偏移问题,不过这个步骤我还没搞清楚,暂时不贴出来了。

此时的阴影效果如下:

从零开始的OpenGL光栅化渲染器构建5-阴影_第5张图片

除此之外,还需要对采样进行一些额外的限制。目前的采样渲染过程,光的视锥不可见的区域一律被认为是处于阴影中,不管它真的处于阴影之中。出现这个状况是因为超出光的视锥的投影坐标比1.0大,这样采样的深度纹理就会超出他默认的0到1的范围。为了处理这个问题,我们让所有超出深度贴图的坐标的深度范围是1.0,这样超出的坐标将永远不在阴影之中。另外,还有一部分区域的坐标超出了光的正交视锥的远平面。对于这一点,只要投影向量的z坐标大于1.0,我们就把shadow的值强制设为0.0。

这样,此时的渲染结果如下图所示:

从零开始的OpenGL光栅化渲染器构建5-阴影_第6张图片

PCF

阴影中锯齿边很严重,需要处理一下这个问题。

可以通过增加深度贴图的分辨率的方式来降低锯齿块,也可以尝试尽可能的让光的视锥接近场景。

另一个(并不完整的)解决方案叫做PCF(percentage-closer filtering),这是一种多个不同过滤方式的组合,它产生柔和阴影,使它们出现更少的锯齿块和硬边。核心思想是从深度贴图中多次采样,每一次采样的纹理坐标都稍有不同。每个独立的样本可能在也可能不再阴影中。所有的次生结果接着结合在一起,进行平均化,我们就得到了柔和阴影。

一个简单的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;

运用了PCF进行边缘软化后,此时的渲染结果如下:

从零开始的OpenGL光栅化渲染器构建5-阴影_第7张图片

点阴影

点阴影与定向阴影映射类似,只不过点阴影需要将深度信息渲染到立方体贴图上,这个阴影贴图也可以叫万向阴影贴图。下图是点阴影的示意图:

从零开始的OpenGL光栅化渲染器构建5-阴影_第8张图片

点阴影的渲染有两个渲染阶段:首先我们生成深度贴图,然后我们正常使用深度贴图渲染,在场景中创建阴影。帧缓冲对象和立方体贴图的处理看起是这样的:

// 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_CUBE_MAP, depthCubemap);
RenderScene();

深度着色器的构建

为了避免在渲染立方体贴图时,将场景渲染6次,我们可以采用一个几何着色器。在几何着色器中,我们可以把一个三角形变换到上下左后前后六个方向的光源观察空间,绘制出六个三角形。因此在处理时,顶点着色器简单地将顶点变换到世界空间,然后直接发送到几何着色器中。

#version 330 core
layout (location = 0) in vec3 position;

uniform mat4 model;

void main()
{
    gl_Position = model * vec4(position, 1.0);
}

紧接着几何着色器以3个三角形的顶点作为输入,它还有一个光空间变换矩阵的uniform数组。几何着色器接下来会负责将顶点变换到光空间;这里它开始变得有趣了。

几何着色器有一个内建变量叫做gl_Layer,它指定发散出基本图形送到立方体贴图的哪个面。当不管它时,几何着色器就会像往常一样把它的基本图形发送到输送管道的下一阶段,但当我们更新这个变量就能控制每个基本图形将渲染到立方体贴图的哪一个面。当然这只有当我们有了一个附加到激活的帧缓冲的立方体贴图纹理才有效:

#version 330 core
layout (triangles) in;
layout (triangle_strip, max_vertices=18) out;

uniform mat4 shadowMatrices[6];

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

几何着色器相对简单。我们输入一个三角形,输出总共6个三角形(6*3顶点,所以总共18个顶点)。在main函数中,我们遍历立方体贴图的6个面,我们每个面指定为一个输出面,把这个面的interger(整数)存到gl_Layer。然后,我们通过把面的光空间变换矩阵乘以FragPos,将每个世界空间顶点变换到相关的光空间,生成每个三角形。注意,我们还要将最后的FragPos变量发送给像素着色器,我们需要计算一个深度值。

最后是片元着色器,我们将在这里计算每一个fragment的深度,这个深度就是每个fragment位置和光源位置之间的线性距离。

#version 330 core
in vec4 FragPos;

uniform vec3 lightPos;
uniform float far_plane;

void main()
{
    // get distance between fragment and light source
    float lightDistance = length(FragPos.xyz - lightPos);

    // map to [0;1] range by dividing by far_plane
    lightDistance = lightDistance / far_plane;

    // write this as modified depth
    gl_FragDepth = lightDistance;
}

之后,我们便可以利用深度图,来渲染万向阴影了。点阴影的渲染结果如下所示:

从零开始的OpenGL光栅化渲染器构建5-阴影_第9张图片

如果把立方体贴图深度缓冲渲染出来,结果如下:

从零开始的OpenGL光栅化渲染器构建5-阴影_第10张图片

PCF

与定向阴影映射类似,我们可以用PCF来对阴影的边缘进行软化,得出更自然的阴影渲染效果:

从零开始的OpenGL光栅化渲染器构建5-阴影_第11张图片

参考

https://learnopengl-cn.github.io/05%20Advanced%20Lighting/03%20Shadows/01%20Shadow%20Mapping/

https://learnopengl-cn.github.io/05%20Advanced%20Lighting/03%20Shadows/02%20Point%20Shadows/

你可能感兴趣的:(图形学,图形渲染,算法,c++,贴图)