OpenGL学习笔记35-Point Shadows

Point Shadows

Advanced-Lighting/Shadows/Point-Shadows

在上一章中,我们学习了使用阴影映射创建动态阴影。它工作得很好,但它最适合于定向(或聚光灯)光,因为阴影只在光源的方向产生。因此,它也被称为方向阴影映射,因为深度(或阴影)映射仅从光线所注视的方向生成。

这一章的重点是动态阴影的产生在所有周围的方向。我们使用的技术对于点光源来说是完美的,因为一个真正的点光源会在各个方向投下阴影。这种技术被称为点(光)阴影,或者更早的全向阴影贴图。

本章建立在上一章阴影映射的基础上,所以除非你熟悉传统的阴影映射,建议你先阅读阴影映射这一章。

该技术与方向阴影贴图非常相似:我们从光线的角度生成一个深度贴图,基于当前的片段位置对深度贴图采样,然后将每个片段与存储的深度值进行比较,看它是否在阴影中。方向阴影映射和全向阴影映射的主要区别是我们使用的深度映射。

我们需要的深度贴图需要从点光源周围的所有方向渲染一个场景,这样一个普通的2D深度贴图就不起作用了;如果我们使用cubemap呢?因为cubemap可以存储只有6个面的完整环境数据,所以可以将整个场景渲染到cubemap的每个面,并将这些作为点光源周围深度值的样本。

生成的depth cubemap然后被传递给lighting fragment shader,后者用一个方向向量对cubemap进行采样,以获得在那个fragment上最近的深度(从光的视角)。大部分复杂的东西我们已经在阴影贴图一章中讨论过了。使这项技术更加困难的是深度cubemap生成。

Generating the depth cubemap

为了创建一个光线周围深度值的立方体映射,我们必须渲染场景6次:每个面一次。一种(非常明显的)方法是使用6个不同的视图矩阵渲染场景6次,每次都将一个不同的cubemap面附加到framebuffer对象。大概是这样的:


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

这可能是相当昂贵的,尽管许多渲染调用是必要的,为这个单一的深度贴图。在本章中,我们将使用另一种(更有组织的)方法,使用几何着色器中的一个小技巧,它允许我们用一个渲染通道来构建depth cubemap。

首先,我们需要创建一个cubemap:


unsigned int depthCubemap;
glGenTextures(1, &depthCubemap);

并为每个cubemap面分配一个二维深度值纹理图像:


const unsigned int SHADOW_WIDTH = 1024, SHADOW_HEIGHT = 1024;
glBindTexture(GL_TEXTURE_CUBE_MAP, depthCubemap);
for (unsigned 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_MAG_FILTER, GL_NEAREST);
glTexParameteri(GL_TEXTURE_CUBE_MAP, GL_TEXTURE_MIN_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);  

通常我们会将cubemap纹理的单一面附加到framebuffer对象上,并渲染场景6次,每次切换framebuffer的深度缓冲目标到不同的cubemap面。因为我们将使用几何着色器,这允许我们在一次传递中渲染所有的面,我们可以直接附加cubemap作为framebuffer的深度附件与glFramebufferTexture:


glBindFramebuffer(GL_FRAMEBUFFER, depthMapFBO);
glFramebufferTexture(GL_FRAMEBUFFER, GL_DEPTH_ATTACHMENT, depthCubemap, 0);
glDrawBuffer(GL_NONE);
glReadBuffer(GL_NONE);
glBindFramebuffer(GL_FRAMEBUFFER, 0);  

同样,请注意对glDrawBuffer和glReadBuffer的调用:在生成depth cubemap时,我们只关心depth值,所以我们必须明确地告诉OpenGL这个framebuffer对象不会渲染到一个颜色缓冲区。

对于全向阴影贴图,我们有两个渲染通道:第一,我们生成depth cubemap,第二,我们使用depth cubemap在正常的渲染通道中添加阴影到场景中。这个过程看起来有点像这样:


// 1. first render to depth cubemap
glViewport(0, 0, SHADOW_WIDTH, SHADOW_HEIGHT);
glBindFramebuffer(GL_FRAMEBUFFER, depthMapFBO);
    glClear(GL_DEPTH_BUFFER_BIT);
    ConfigureShaderAndMatrices();
    RenderScene();
glBindFramebuffer(GL_FRAMEBUFFER, 0);
// 2. then render scene as normal with shadow mapping (using depth cubemap)
glViewport(0, 0, SCR_WIDTH, SCR_HEIGHT);
glClear(GL_COLOR_BUFFER_BIT | GL_DEPTH_BUFFER_BIT);
ConfigureShaderAndMatrices();
glBindTexture(GL_TEXTURE_CUBE_MAP, depthCubemap);
RenderScene();

这个过程和默认的阴影映射完全一样,尽管这次我们使用cubemap深度纹理来渲染和使用2D深度纹理。

Light space transform

在设置了framebuffer和cubemap之后,我们需要一些方法来将场景的所有几何图形转换到6个方向的相关光空间中。就像阴影映射章节一样,我们需要一个光空间变换矩阵TT,但是这次每个面都需要一个。

每个光空间变换矩阵都包含一个投影矩阵和一个视图矩阵。对于投影矩阵,我们将使用透视投影矩阵;光源代表空间中的一个点,因此透视投影最有意义。每个光空间变换矩阵使用相同的投影矩阵:


float aspect = (float)SHADOW_WIDTH/(float)SHADOW_HEIGHT;
float near = 1.0f;
float far = 25.0f;
glm::mat4 shadowProj = glm::perspective(glm::radians(90.0f), aspect, near, far); 

这里需要注意的是glm::透视图的视场参数,我们设置为90度。通过将其设置为90度,我们可以确保观察区域足够大,足以填充立方体图的单个面,这样所有面在边缘上都能正确对齐。

由于投影矩阵不会按方向改变,我们可以对6个变换矩阵中的每一个重复使用它。我们确实需要每个方向的不同视图矩阵。使用glm::lookAt,我们创建了6个视图方向,每个方向按照立方体图的一个面方向:右、左、上、下、近、远。


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

在这里,我们创建了6个视图矩阵,并将它们与投影矩阵相乘,得到了6个不同的光空间变换矩阵。glm的目标参数::lookAt,每个都向一个cubemap面方向看。

这些转换矩阵被发送到渲染立体图深度的着色器。

Depth shaders

为了渲染depth值到depth cubemap,我们需要三个着色器:顶点着色器和片段着色器,以及中间的几何着色器。

几何着色器将负责将所有世界空间的顶点转换到6个不同的光空间。因此,顶点着色器只是将顶点转换到世界空间,并将它们定向到几何着色器:


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

uniform mat4 model;

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

几何着色器将输入3个三角形顶点和一个均匀的光空间变换矩阵数组。几何着色器负责将顶点转换到光空间;这也是有趣的地方。

几何着色器有一个名为gl_Layer的内置变量,它指定向哪个cubemap面发出原语。当不受影响时,几何着色器就像往常一样把它的原语发送到管道的更深处,但是当我们更新这个变量时,我们可以控制为每个原语渲染到哪个cubemap面。当然,这只有当我们有一个cubemap纹理附加到活动的framebuffer时才有效。


#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 vertex
        {
            FragPos = gl_in[i].gl_Position;
            gl_Position = shadowMatrices[face] * FragPos;
            EmitVertex();
        }    
        EndPrimitive();
    }
}  

这个几何着色器是相对简单的。我们以一个三角形作为输入,总共输出6个三角形(6 * 3 = 18个顶点)。在main函数中,我们迭代了6个cubemap面,其中通过将面整数存储到gl_Layer中,将每个面指定为输出面。然后,通过将FragPos与人脸的光空间变换矩阵相乘,将每个世界空间输入顶点转换为相应的光空间,从而生成输出三角形。注意,我们还将产生的FragPos变量发送到fragment shader中,我们将需要它来计算一个深度值。

在上一章中,我们使用了一个空的碎片着色器,并让OpenGL计算出深度贴图的深度值。这一次,我们将计算我们自己的(线性)深度作为每个最近的片段位置和光源位置之间的线性距离。计算我们自己的深度值使得后面的阴影计算更加直观。


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

片段着色器将几何体着色器的片段、光的位置向量和锥台的远平面值作为输入。这里我们取片段和光源之间的距离,将其映射到[0,1]范围,并将其写入片段的深度值。

使用这些着色器和与cubemap相连接的framebuffer对象激活渲染场景会给你一个完全填充深度的cubemap,用于第二轮的阴影计算。

Omnidirectional shadow maps 全向阴影地图

一切都设置好了,是时候渲染实际的全向阴影了。这个过程类似于方向阴影映射章节,尽管这次我们绑定了cubemap纹理而不是2D纹理,同时也将光投影的远平面变量传递给着色器。


glViewport(0, 0, SCR_WIDTH, SCR_HEIGHT);
glClear(GL_COLOR_BUFFER_BIT | GL_DEPTH_BUFFER_BIT);
shader.use();  
// ... send uniforms to shader (including light's far_plane value)
glActiveTexture(GL_TEXTURE0);
glBindTexture(GL_TEXTURE_CUBE_MAP, depthCubemap);
// ... bind other textures
RenderScene();

在这里,renderScene函数在一个大的立方体房间中渲染几个立方体,这些立方体散布在场景中心的光源周围。

顶点和碎片着色器与原始的阴影映射着色器非常相似:区别在于碎片着色器不再需要在光空间中的碎片位置,因为我们现在可以用一个方向向量来采样深度值。

正因为如此,顶点着色器不需要将其位置向量转换为光空间,所以我们可以移除FragPosLightSpace变量:


#version 330 core
layout (location = 0) in vec3 aPos;
layout (location = 1) in vec3 aNormal;
layout (location = 2) in vec2 aTexCoords;

out vec2 TexCoords;

out VS_OUT {
    vec3 FragPos;
    vec3 Normal;
    vec2 TexCoords;
} vs_out;

uniform mat4 projection;
uniform mat4 view;
uniform mat4 model;

void main()
{
    vs_out.FragPos = vec3(model * vec4(aPos, 1.0));
    vs_out.Normal = transpose(inverse(mat3(model))) * aNormal;
    vs_out.TexCoords = aTexCoords;
    gl_Position = projection * view * model * vec4(aPos, 1.0);
}  

fragment shader's Blinn-Phong lighting code是完全相同的,就像我们之前在阴影乘法的最后:


#version 330 core
out vec4 FragColor;

in VS_OUT {
    vec3 FragPos;
    vec3 Normal;
    vec2 TexCoords;
} fs_in;

uniform sampler2D diffuseTexture;
uniform samplerCube depthMap;

uniform vec3 lightPos;
uniform vec3 viewPos;

uniform float far_plane;

float ShadowCalculation(vec3 fragPos)
{
    [...]
}

void main()
{           
    vec3 color = texture(diffuseTexture, fs_in.TexCoords).rgb;
    vec3 normal = normalize(fs_in.Normal);
    vec3 lightColor = vec3(0.3);
    // ambient
    vec3 ambient = 0.3 * 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;    
    // calculate shadow
    float shadow = ShadowCalculation(fs_in.FragPos);                      
    vec3 lighting = (ambient + (1.0 - shadow) * (diffuse + specular)) * color;    
    
    FragColor = vec4(lighting, 1.0);
}  

这里有一些细微的区别:光照代码是相同的,但是我们现在有一个samplerCube uniform,阴影计算函数使用当前片段的位置作为参数,而不是片段在光照空间中的位置。我们现在还包括了我们稍后需要的光截锥的far_plane值。

最大的不同是在阴影计算函数的内容,现在采样的深度值从cubemap而不是2D纹理。让我们一步一步来讨论它的内容。

我们要做的第一件事是检索cubemap的深度。你可能还记得在本章的cubemap部分中,我们将深度存储为片段和光线位置之间的线性距离;我们在这里采用了类似的方法:


float ShadowCalculation(vec3 fragPos)
{
    vec3 fragToLight = fragPos - lightPos; 
    float closestDepth = texture(depthMap, fragToLight).r;
}  

在这里,我们取片段位置和光线位置之间的差向量,并使用这个向量作为方向向量来采样cubemap。从cubemap中采样的方向向量不需要是单位向量,所以没有必要对它进行标准化。所得到的closestDepth值是光源与其最接近的可见片段之间的归一化深度值

closestDepth值当前在[0,1]范围内,因此我们首先通过将其与far_plane相乘将其转换回[0,far_plane]。


closestDepth *= far_plane;  

接下来我们检索当前片段和光源之间的深度值,根据cubemap中深度值的计算方法,我们可以很容易地通过取fragToLight的长度来获得:


float currentDepth = length(fragToLight);  

这将返回与closestDepth相同(或更大)范围的深度值。

现在我们可以比较这两个深度值,看看哪个更接近另一个,并确定当前的片段是否在阴影中。我们还包括阴影偏倚,所以我们不会得到阴影痤疮,正如在前一章讨论。


float bias = 0.05; 
float shadow = currentDepth -  bias > closestDepth ? 1.0 : 0.0; 

完整的阴影计算变成:


float ShadowCalculation(vec3 fragPos)
{
    // get vector between fragment position and light position
    vec3 fragToLight = fragPos - lightPos;
    // use the light to fragment vector to sample from the depth map    
    float closestDepth = texture(depthMap, fragToLight).r;
    // it is currently in linear range between [0,1]. Re-transform back to original value
    closestDepth *= far_plane;
    // now get current linear depth as the length between the fragment and light position
    float currentDepth = length(fragToLight);
    // now test for shadows
    float bias = 0.05; 
    float shadow = currentDepth -  bias > closestDepth ? 1.0 : 0.0;

    return shadow;
}  

有了这些着色器,我们已经得到了很好的阴影,这一次从点光源得到了周围所有方向的阴影。在一个简单场景的中心放置一个点光源,它看起来就像这样:

你可以在这里here. 找到这个演示的源代码。

Visualizing cubemap depth buffer

如果您和我一样,可能在第一次尝试时没有得到正确的结果,那么进行一些调试是有意义的,其中一个明显的检查就是验证深度映射是否构建正确。可视化深度缓冲区的一个简单技巧是在ShadowCalculation函数中使用closestDepth变量,并显示为:


FragColor = vec4(vec3(closestDepth / far_plane), 1.0);  

结果是一个灰色的场景,其中每一种颜色代表了场景的线性深度值:

你也可以在外墙上看到将要被阴影的区域。如果它看起来有点相似,那么您就知道depth cubemap是正确生成的。

PCF

由于全向阴影映射基于与传统阴影映射相同的原理,所以它也有相同的分辨率依赖工件。如果你放大到足够近,你可以再次看到锯齿状的边缘。通过对片段位置周围的多个样本进行过滤并对结果进行平均,PCF允许我们平滑这些锯齿状的边缘。

如果我们采用上一章同样简单的PCF滤波器,并添加第三维,我们得到:


float shadow  = 0.0;
float bias    = 0.05; 
float samples = 4.0;
float offset  = 0.1;
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);

代码与传统的阴影映射代码没有什么不同。我们根据固定数目的样本动态地计算和添加每个轴的纹理偏移量。对于每个样本,我们在偏移样本的方向上重复原始的阴影过程,并在最后对结果进行平均。

阴影现在看起来更加柔软和光滑,并给出更可信的结果。

然而,当样本设置为4.0时,我们总共抽取64个样本,这是很多的!

由于这些样本大多是冗余的,因为它们的样本接近于原始的方向向量,因此只在样本方向向量的垂直方向进行样本可能更有意义。然而,由于没有(简单的)方法来确定哪些子方向是多余的,这就变得很困难。我们可以使用的一个技巧是取一个大致可分离的偏移方向数组,例如,它们中的每一个都指向完全不同的方向。这将大大减少靠近的子方向的数量。下面我们有一个最大偏移量为20的数组


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

由此,我们可以调整PCF算法,从sampleoffsetdirection取固定数量的样本,并使用这些样本对cubemap进行采样。这样做的好处是,我们需要的样本要少得多,才能得到视觉上相似的结果。


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

在这里,我们添加多个偏移量,按磁盘半径缩放,围绕着原始的fragToLight方向向量,从cubemap采样。

我们可以在这里应用的另一个有趣的技巧是,我们可以根据观看者到碎片的距离来改变磁盘半径,使阴影在远处更柔和,在近处更清晰。


float diskRadius = (1.0 + (viewDistance / far_plane)) / 25.0;  

更新后的PCF算法得到的结果和阴影效果一样好,如果不是更好的话:

当然,我们添加到每个样本中的偏差是高度基于上下文的,并且总是需要根据你正在处理的场景进行调整。使用所有的值,看看它们是如何影响场景的。

你可以在这里找到最终的代码:这里here. 。

我应该提到的是,使用几何着色器生成深度贴图并不一定比为每张脸渲染6次更快。使用一个像这样的几何体着色器有它自己的性能惩罚,可能会超过在第一个地方使用一个性能增益。当然,这取决于环境的类型、特定的显卡驱动程序和许多其他因素。所以,如果你真的想要最大限度地发挥你的系统的功能,确保对这两种方法进行配置,并为你的场景选择更有效的一种。

Additional resources

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

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值