Elays'Blog

阴影的重要意义


阴影是光线被阻挡的结果,它能够使场景看起来真实很多,可以让观察者获得物体之间的空间位置关系。如下图所示:



图1


可以看到,有阴影的时候能够更容易的看出立方体是悬浮在地板上的。

当前实时渲染领域还没找到一种完美的阴影算法,目前有几种近似阴影技术,但他它们都有自己的弱点和不足。游戏中常用的技术是阴影贴图(shadow mapping),效果不错,而且相对容易实现,性能也挺高,比较容易扩展为更高级的算法,如 Omnidirectional Shadow MapsCascaded Shadow Maps

阴影映射原理


在绘制物体的某个片元时,要确定它是否在阴影中,就是要判断它是否被别的片元挡住了。而这个挡住其实是光线被挡住了,所以应该从光源位置看过去,看这个片元是否被其他片元挡住。如下图所示:



图2

判断是否被遮挡可以用深度贴图来实现:从光源处看过去(相当于把摄像机调整到光源的位置,即更改观察矩阵和投影矩阵,只是不渲染场景颜色而已),渲染一次场景(开启深度测试),将场景的深度值渲染到自定义帧缓冲的深度纹理附件中,此时深度纹理中存储的深度值就是离光源(或者说摄像机)最近的深度值,然后再渲染一次场景,这次渲染过程中判断当前片元的深度是否比对应位置上深度纹理中的深度更靠近光源(在屏幕空间里就是深度值更小),如果不是则说明该片元被挡住了,在阴影里。如下图所示:



图3

右图中,在光源看来C点和P点处在同一xy位置(以光源为原点的坐标系)上,但是深度z不同,P点的深度是0.9,C点的深度是0.4,存储在深度纹理中的应该是最靠近光源的0.4,在绘制点P时由于其深度值0.9比从深度纹理中取出的0.4大,所以判定点P被挡住了,应该位于阴影里。

综上,深度映射通过两个步骤完成:

  1. 渲染深度纹理。
  2. 正常渲染场景,同时采样深度纹理来判断片元是否在阴影中。

用代码表示如下:

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

渲染深度纹理
我们需要从光源的视角去渲染得到一张场景的深度纹理,最后需要用它来计算阴影。为了将场景的深度保存到纹理中,我们需要用到帧缓冲,并且为它添加深度纹理附件:

GLuint DepthMap;
    glGenTextures(1, &DepthMap);
    glBindTexture(GL_TEXTURE_2D, DepthMap);
    glTexImage2D(GL_TEXTURE_2D, 0, GL_DEPTH_COMPONENT, WIDTH, 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);
    glBindTexture(GL_TEXTURE_2D, 0);

    GLuint DepthMapFBO;
    glGenFramebuffers(1, &DepthMapFBO);
    glBindFramebuffer(GL_FRAMEBUFFER, DepthMapFBO);
    glFramebufferTexture2D(GL_FRAMEBUFFER, GL_DEPTH_ATTACHMENT, GL_TEXTURE_2D, DepthMap, 0);
    glDrawBuffer(GL_NONE);
    glReadBuffer(GL_NONE);
    if (glCheckFramebufferStatus(GL_FRAMEBUFFER) != GL_FRAMEBUFFER_COMPLETE)
        std::cout << "Framebuffer is not complete!" << std::endl;
    glBindFramebuffer(GL_FRAMEBUFFER, 0);

上面的代码首先创建了一张GL_DEPTH_COMPONENT格式的纹理,然后将它绑定到帧缓冲的深度附件上。

接下来我们需要从光源视角去渲染场景。先来看看着色器怎么写吧:

#version 330 core

layout (location = 0) in vec3 position;

uniform mat4 LightSpaceMVP;  //projection * view * model

void main()
{
    gl_Position = LightSpaceMVP * vec4(position,1.0f);
}
#version 330 core

void main()
{
}

可以看到渲染深度纹理的着色器相当简单,在顶点着色器里只是需要一个在光源视角下的MVP矩阵(投影矩阵、观察矩阵和物体模型矩阵的乘积),来计算在光源视角下的顶点坐标。片元着色器可以是空的,因为我们只想得到深度,所以没有必要在片元着色器里输出颜色。
【注】:

  • 直接使用MVP矩阵,是为了避免每一个顶点着色器都去执行模型矩阵、观察矩阵、投影矩阵的乘法运算,减少GPU的运算量,只需要每帧在应用程序里计算一次MVP矩阵,然后传给顶点着色器即可。这样还减少了传输带宽,毕竟只需要给GPU传一个MVP矩阵,而不是三个矩阵。

加下来需要我们在应用程序里算好这个LightSpaceMVP矩阵了:

mat4 View = lookAt(lightPos, lightPos + lightDirection, vec3(0, 1, 0));
mat4 Projection = ortho(-6.0, 6.0, -6.0, 6.0, 0.1, 20.0);
mat4 LightSpaceVP = Projection * View;
mat4 CubeModel;
CubeModel = translate(CubeModel, glm::vec3(-1.0f, 0.0f, -1.0f));
mat4 LightSpaceMVPCube = LightSpaceVP * CubeModel;
mat4 PlaneModel;
PlaneModel = mat4();
mat4 LightSpaceMVPPlane = LightSpaceVP * PlaneModel;

场景里面有两个物体:地面和箱子,它们都需要在上面的着色器下绘制一次,由于它们的模型矩阵不同,所以它们的MVP矩阵需要分开算。观察矩阵通过平行光源的位置和方向来计算,投影矩阵是一个正交投影矩阵(因为场景里用的是平行光源)。

然后我们绑定自定义帧缓冲,激活着色器渲染场景,就可以渲染出深度纹理了:

glBindFramebuffer(GL_FRAMEBUFFER, DepthMapFBO);
glClear(GL_DEPTH_BUFFER_BIT);

GenerateDepthMap_Shader.Use();
glUniformMatrix4fv(glGetUniformLocation(GenerateDepthMap_Shader.shaderProgram, "LightSpaceMVP"), 1, GL_FALSE, value_ptr(LightSpaceMVPCube));
glBindVertexArray(cubeVAO);
glDrawElements(GL_TRIANGLES, 36, GL_UNSIGNED_INT, 0);

glUniformMatrix4fv(glGetUniformLocation(GenerateDepthMap_Shader.shaderProgram, "LightSpaceMVP"), 1, GL_FALSE, value_ptr(LightSpaceMVPPlane));
glBindVertexArray(planeVAO);
glDrawArrays(GL_TRIANGLES, 0, 6);
glBindVertexArray(0);

我们可以用一张窗口四边形来渲染这张深度贴图:

glBindFramebuffer(GL_FRAMEBUFFER, 0);
glClearColor(0.3f, 0.4f, 0.5f, 1.0f);
glClear(GL_COLOR_BUFFER_BIT | GL_DEPTH_BUFFER_BIT);

RenderDepthMap_Shader.Use();
glBindVertexArray(windowQuadVAO);
glBindTexture(GL_TEXTURE_2D, DepthMap);
glDrawArrays(GL_TRIANGLES, 0, 6);
glBindVertexArray(0);

渲染结果如下:

所有源码在这里

渲染阴影


我们先来看看着色器怎么写。

顶点着色器和正常渲染场景时一样,只是多了计算顶点在光源视角下的裁剪坐标这一步:

#version 330 core

layout (location = 0) in vec3 position;
layout (location = 1) in vec2 texCoords;
layout (location = 2) in vec3 normal;

out vec2 VS_TexCoords;
out vec3 VS_Normal;
out vec3 VS_WorldPos;
out vec4 VS_LightSpacePos;

uniform mat4 u_LightSpaceMVP;  //projection * view * model
uniform mat4 u_Model;
uniform mat4 u_View;
uniform mat4 u_Projection;

void main()
{
    VS_TexCoords = texCoords;
    VS_Normal = transpose(inverse(mat3(u_Model))) * normal;
    VS_WorldPos = vec3(u_Model * vec4(position, 1.0f));
    gl_Position = u_Projection * u_View * vec4(VS_WorldPos, 1.0f);
    VS_LightSpacePos = u_LightSpaceMVP * vec4(position, 1.0f);
}

其中u_LightSpaceMVP是光源视角下的模型矩阵、观察矩阵和投影矩阵的乘积。

将计算得到的顶点在光源视角下的裁剪坐标VS_LightSpacePos,传递给片元着色器,来计算阴影:

#version 330 core

in vec2 VS_TexCoords;
in vec3 VS_Normal;
in vec3 VS_WorldPos;
in vec4 VS_LightSpacePos;

out vec4 Color;

uniform sampler2D u_DiffuseMapSampler1;
uniform sampler2D u_DepthMapSampler2;
uniform vec3 u_LightPos;
uniform vec3 u_LightDirection;
uniform vec3 u_ViewPos;
uniform vec3 u_LightColor;

vec3 getDepthInLightSpace(vec4 vLightSpacePos)
{
    vec3 Temp = (vLightSpacePos / vLightSpacePos.w).xyz;
    Temp = Temp * 0.5 + 0.5;
    return Temp;
}

void main()
{
    vec3 ObjectColor = texture(u_DiffuseMapSampler1, VS_TexCoords).rgb;

    float AmbientStrength = 0.3f;
    vec3  AmbientColor = AmbientStrength * ObjectColor;

    vec3 LightClipSpacePos = getDepthInLightSpace(VS_LightSpacePos);
    if(LightClipSpacePos.z >= texture(u_DepthMapSampler2, LightClipSpacePos.xy).r + 0.01)
    {
        Color = vec4(AmbientColor * ObjectColor, 1.0);
        return;
    }

    vec3 Normal = normalize(VS_Normal);
    vec3 LightDir = normalize(-u_LightDirection);
    float DiffuseFactor = max(dot(Normal, LightDir), 0.0);
    vec3 DiffuseColor = DiffuseFactor * u_LightColor;

    vec3 ViewDir = normalize(u_ViewPos - VS_WorldPos);
    vec3 HalfDir = normalize(LightDir + ViewDir);
    float SpecularFactor = pow(max(dot(HalfDir, Normal), 0.0f), 32);
    vec3 SpecularColor = SpecularFactor * u_LightColor;

    Color = vec4((AmbientColor + DiffuseColor + SpecularColor) * ObjectColor, 1.0);
}

其中在片元着色器里,我们需要计算插值后的片元在光源视角下的深度:

vec3 getDepthInLightSpace(vec4 vLightSpacePos)
{
    vec3 Temp = (vLightSpacePos / vLightSpacePos.w).xyz;
    Temp = Temp * 0.5 + 0.5;
    return Temp;
}

原理跟简单,就是模拟了一下透视除法,让xyz分量分别除以w分量(其实不除也可以,因为在我们的demo里用的是平行光,光源视角下的投影矩阵是正交投影,所以w分量其实是1,但是如果不是平行光,这一步还是必须要做的)。但是透视除法之后的坐标范围还是-1到1,而之后我们需要用这个坐标去查找之前的深度纹理,而且其z分量应该代表片元在光源视角下的深度,所以不应该有负数,我们需要把-1到1的范围映射到0到1,所以才有了:

    Temp = Temp * 0.5 + 0.5;

然后我们就可以根据这个坐标(片元在光源视角下的裁剪坐标并且映射到了0到1的范围)的xy值,去之前保存下来的深度纹理里查找场景在这个xy位置上距离光源最近的深度值,如果当前片元在光源视角下的深度值大于从纹理中查找到的深度值,则说明这个片元被挡住了,应该在阴影里:

if(LightClipSpacePos.z >= texture(u_DepthMapSampler2, LightClipSpacePos.xy).r + 0.01)
{
    Color = vec4(AmbientColor * ObjectColor, 1.0);
    return;
}

这里对阴影的处理方式是让片元的颜色等于环境光颜色,不再对它做漫反射和镜面反射光照了。

对于不满足这个条件,即不在阴影里的片元,照常执行blinn-phong光照即可。

剩下的就是在应用程序里把着色器需要的顶点数据和uniform变量传进来就可以了,由于这些内容和之前的文章里几乎是一样的,所以不再赘述了,所有源码都在这里

运行结果如下图所示:

改进阴影贴图


阴影fighting
可以看到上面的阴影并不好,有很多条纹,这是由于深度贴图所能保存的精度有限,相邻的很多片元可能用的是同一个深度,如下图所示:

可能表示的最大深度只有6位,那么图中0.9276355到0.9276364的部分都只能用0.927636来表示了,但是getDepthInLightSpace函数计算出来的片元深度精度通常更大,导致在比较时,0.9276355到0.927636的部分,比深度纹理中存储的0.927636小,不处于阴影中,而0.927636到0.9276364的部分比深度纹理中存储的0.927636更大,处于阴影中,所以会出现一半不在阴影中,而另一半在阴影中,而这种精度情况在每一个类似的精度范围内都会出现,所以造成了上图里的条纹状。
【注:】

  • 这里只是举了个例子,最大精度不一定是6位小数,也不一定是四舍五入,要视具体运行环境和硬件决定。

那么我们怎么避免这种深度精度问题呢?

我们可以在判断条件上加一个很小的偏移量:

if(LightClipSpacePos.z >= texture(u_DepthMapSampler2, LightClipSpacePos.xy).r + 0.0009)
{
    Color = vec4(AmbientColor * ObjectColor, 1.0);
    return; 
}

运行结果如下图所示:

可以看到虽然很大程度上解决了条纹状问题,但是由于偏移量加得太小,在箱子的垂直表面上,坡度很大,导致上面还是有一些黑点,有两种方法可以解决:一种是加大偏移量,但是有可能会产生彼得潘效应(后文会介绍),另一种就是利用表面法线和光线的夹角来计算出一个偏移值,这样对于坡度大的地方偏移就大、对坡度小的地方偏移就很小:

float Offset = max(0.0009, 0.0025 * (1.0 - dot(Normal, LightDir)));
if(LightClipSpacePos.z >= texture(u_DepthMapSampler2, LightClipSpacePos.xy).r + Offset)
{
    Color = vec4(AmbientColor * ObjectColor, 1.0);
    return; 
}

运行结果如下:

但是,偏移量加多少合适需要多次微调,加少了会有黑点,加多了会有彼得潘效应,其实即使是上面的代码,运行程序后拉近看依然有彼得潘效应。想要调出合适的偏移量很难,很容易出现彼得潘效应。下面来看看什么是彼得潘效应。

彼得潘效应
当偏移加的偏大时,可以看到阴影相对实际物体的偏移,如下图所示(这个偏移值加得很大0.01):

看起来像箱子漂浮在地面之上,但是实际上从顶点数据来看箱子是紧贴着地面的,这种错觉就是彼得潘效应(童话里彼得潘是个会飞的男孩……)。

经过代码实现,渲染阴影贴图时开启正面剔除依然不能消除彼得潘效应,只能用更精确的偏移值来让彼得潘效应更小,直到看不出来。

光视锥外的阴影
在之前的片元着色器里,对于不在光源视角下的正交投影视锥里的片元,经过getDepthInLightSpace函数算出来的裁剪坐标绝对值将大于1,用这个坐标去索引深度纹理,当然得不到正确的深度值。因为默认深度纹理的环绕方式是repeat,所以导致在视锥之外的片元都处于阴影里,如下图所示:

有两个解决方案:

  1. 把正交投影矩阵的参数加大,让正交视锥能包含更大的区域。
  2. 如果当前正交视锥之外没有物体(或者没有需要投射阴影的物体),可以让视锥外的片元索引深度纹理得到的深度值总是1.0,这样这些片元就不会处在阴影里了。其实也就是想用绝对值大于1的坐标去索引深度纹理,总是得到1.0这个值,所以我们可以把深度纹理的环绕方式设为GL_CLAMP_TO_BORDER,让超出1.0的坐标永远得到的都是边界上的值,同时要设置边界颜色的r分量为1.0:
    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,0.0,0.0,1.0 };
    glTexParameterfv(GL_TEXTURE_2D, GL_TEXTURE_BORDER_COLOR, BorderColor);
    
    结果如下图所示:

可以发现在视锥横截面之外的片元都不再处于阴影里了,远处还有片元处于阴影里,是因为那块区域超过视锥的远平面,计算出来的深度值是大于1.0的,会永远比从深度纹理中取出来的值要大,所以会处于阴影里。有两种解决方案:

  1. 在正交投影矩阵里加大远平面的距离。
  2. 在片元着色器里计算裁剪坐标的时候,如果最后发现裁剪坐标的z值大于1.0,则把其z值强制更改为0:
    vec3 getDepthInLightSpace(vec4 vLightSpacePos)
    {
     vec3 Temp = (vLightSpacePos / vLightSpacePos.w).xyz;
     Temp = Temp * 0.5 + 0.5;
     if(Temp.z > 1.0)
         Temp.z = 0.0;
     return Temp;
    }
    

运行结果如下图:

到此的所有源码都在这里。其中解开一些注释代码就能看到这一小节说过的各种结果。

PCF
拉近了看,会发现阴影边缘走样很严重,有明显的锯齿,如下图所示:

这是因为深度纹理的分辨率有限,多个片元可能对应同一个阴影,这样采样计算阴影时就会产生锯齿边。当然可以通过增加深度纹理分辨率的方式来降低锯齿块。但是这样会增加很多内存开销。

我们可以用一种叫做PCF(percentage-closer filtering)的技术来得到更柔和一点的阴影:对片元裁剪坐标的四周多次采样,对采样的结果(在或者不在阴影里)求均值。实现代码如下:

#version 330 core

in vec2 VS_TexCoords;
in vec3 VS_Normal;
in vec3 VS_WorldPos;
in vec4 VS_LightSpacePos;

out vec4 Color;

uniform sampler2D u_DiffuseMapSampler1;
uniform sampler2D u_DepthMapSampler2;
uniform vec3 u_LightPos;
uniform vec3 u_LightDirection;
uniform vec3 u_ViewPos;
uniform vec3 u_LightColor;

vec3 getDepthInLightSpace(vec4 vLightSpacePos)
{
    vec3 Temp = (vLightSpacePos / vLightSpacePos.w).xyz;
    Temp = Temp * 0.5 + 0.5;
    //远平面外的深度值更改为0.0
    if(Temp.z > 1.0)
        Temp.z = 0.0;
    return Temp;
}

void main()
{
    vec3 ObjectColor = texture(u_DiffuseMapSampler1, VS_TexCoords).rgb;

    //Ambient Lighting
    float AmbientStrength = 0.3f;
    vec3  AmbientColor = AmbientStrength * ObjectColor;

    vec3 LightClipSpacePos = getDepthInLightSpace(VS_LightSpacePos);

    vec3 Normal = normalize(VS_Normal);
    vec3 LightDir = normalize(-u_LightDirection);

    //PCF阴影测试
    float Offset = max(0.002, 0.0025 * (1.0 - dot(Normal, LightDir)));
    float Shadow = 0.0;
    vec2 texelSize = 1.0 / textureSize(u_DepthMapSampler2, 0);

    if(LightClipSpacePos.z != 0.0)
    {
        for(int x = -1; x <= 1; ++x)
        {
            for(int y = -1; y <= 1; ++y)
            {
                if(LightClipSpacePos.z >= texture(u_DepthMapSampler2, LightClipSpacePos.xy + vec2(x, y) * texelSize).r + Offset)
                    Shadow += 1.0;
            }
        }
    }
    Shadow /= 9.0;

    //Diffuse Lighting
    float DiffuseFactor = max(dot(Normal, LightDir), 0.0);
    vec3 DiffuseColor = DiffuseFactor * u_LightColor;

    //Specular Lighting
    vec3 ViewDir = normalize(u_ViewPos - VS_WorldPos);
    vec3 HalfDir = normalize(LightDir + ViewDir);
    float SpecularFactor = pow(max(dot(HalfDir, Normal), 0.0f), 32);
    vec3 SpecularColor = SpecularFactor * u_LightColor;

    Color = vec4((AmbientColor + (1.0 - Shadow) * (DiffuseColor + SpecularColor)) * ObjectColor, 1.0);
}

其中texelSize 是深度纹理中每个纹素的大小。

运行结果如下图所示:

可以发现,阴影边缘确实较之前柔和了一些。

所有源码请看这里

透视投影渲染深度贴图


之前用的是正交投影来渲染深度纹理,这对于平行光比较适用,但是对于点光源和聚光灯,透视投影更适合。只是透视投影渲染得到的深度纹理里的深度值是非线性的,有两种就解决方案:

  1. 在片元着色器里,计算片元在光源视角下的裁剪坐标时,手动做一次透视除法,这样也就变为非线性的深度。
  2. 不在片元着色器里计算片元的裁剪坐标,计算到它在观察空间里的深度就好了,然后把从深度纹理中的非线性深度转变为观察空间里的线性深度。怎么转换呢?可以参考《OpenGL15:深度测试》里说过的非线性深度公式:
    $$
    begin{equation}
    F_{depth} = frac{1/z - 1/near}{1/far - 1/near}
    end{equation}
    $$
    float LinearizeDepth(float depth)
    {
     float z = depth * 2.0 - 1.0; // Back to NDC 
     return (2.0 * near_plane * far_plane) / (far_plane + near_plane - z * (far_plane - near_plane));
    }
    
     

参考文献:LearnOpenGL

原文地址:https://www.cnblogs.com/lijianming180/p/12037838.html