这一节主要讲的是动态阴影的技术。
阴影为照明场景增添了大量真实感,并使观看者更容易观察对象之间的空间关系。 它们为我们的场景和物体提供了更大的深度感。
在当前的实时(光栅化图形)研究中,还没有开发出完美的阴影算法。 有几种很好的阴影近似技术,但它们都有我们必须考虑的缺点。
基础知识:
1.shadow mapping阴影映射
大多数游戏使用的一种技术可以提供不错的结果并且相对容易实现,那就是shadow mapping阴影映射。 shadow mapping不太难理解,不会在性能上花费太多,并且很容易扩展到更高级的算法(如Omnidirectional Shadow Maps (全向)and Cascaded Shadow Maps(级联))。
我们从灯光的角度渲染场景并将生成的深度值存储在纹理中会怎样?通过这种方式,我们可以从灯光的角度对最接近的深度值进行采样。毕竟,深度值显示了从灯光的角度可见的第一个片段。我们将所有这些深度值存储在称为depth map深度贴图或shadow map阴影贴图的纹理中。
蓝色部分为亮光,黑色为阴影。
个人理解:
depth test流程:顶点生成fragment,重叠的fragment根据z值来进行depth记录
shadow mapping阴影映射:
如果要看某个点P是否在阴影里,先转换为光源视角,坐标P变成坐标T(坐标系变换),然后depth test 记录T点的depth值,是否看得见(检索此像素的depth map看能不能找到更小的depth),如果看不见,则把阴影信息记录在shadow mapping中。
注意:阴影深度图,是以灯光的视角坐标系来存储图像的。
Shadow mapping阴影映射由两个PASS通道组成:
第一个PASS通道,渲染深度图
第二个PASS通道中,我们正常渲染场景,并使用生成的深度图来计算片段是否在阴影中。
2.The depth map(第一个PASS,渲染深度图)
我们要生成深度图。 深度贴图是从灯光的角度渲染的深度纹理,我们将使用它来测试阴影。 因为我们需要将场景的渲染结果存储到纹理中,所以我们将再次需要framebuffer帧缓冲区。
unsigned int depthMapFBO;
glGenFramebuffers(1, &depthMapFBO);
接下来我们创建一个 2D 纹理,我们将使用它作为帧缓冲区的depth深度缓冲区:
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);
使用生成的深度纹理,我们可以将其附加为帧缓冲区的深度缓冲区:
glBindFramebuffer(GL_FRAMEBUFFER, depthMapFBO);
glFramebufferTexture2D(GL_FRAMEBUFFER, GL_DEPTH_ATTACHMENT, GL_TEXTURE_2D, depthMap, 0);
//没有颜色缓冲区的帧缓冲区对象是不完整的,因此我们需要明确告诉 OpenGL 我们不会渲染任何颜色数据
glDrawBuffer(GL_NONE);
glReadBuffer(GL_NONE);
//绑定framebuffer
glBindFramebuffer(GL_FRAMEBUFFER, 0);
3.Light space transform (光照空间转换)
我们模拟一个定向光源,它的所有光线都是平行的。所以我们使用正交投影矩阵:
float near_plane = 1.0f, far_plane = 7.5f;
//projection投射的意思,表示设定坐标系原点
glm::mat4 lightProjection = glm::ortho(-10.0f, 10.0f, -10.0f, 10.0f, near_plane, far_plane);
光源对准场景中心:
//设定坐标系正方向
glm::mat4 lightView = 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));
光空间转换矩阵:
//两者结合,生成光空间转换矩阵,它可将每个世界空间向量,转换为从光源可见的空间坐标系
glm::mat4 lightSpaceMatrix = lightProjection * lightView;
4.Render to depth map(渲染depth map)
当我们从灯光的角度渲染场景时,我们更愿意使用一个简单的着色器,它只将顶点转换到灯光空间,而不是更多。 对于这样一个名为 simpleDepthShader 的简单着色器,我们将使用以下顶点着色器:
vs:
#version 330 core
layout (location = 0) in vec3 aPos;
uniform mat4 lightSpaceMatrix;
uniform mat4 model;
void main()
{
gl_Position = lightSpaceMatrix * model * vec4(aPos, 1.0);
}
fs:
#version 330 core
void main()
{
// gl_FragDepth = gl_FragCoord.z;
}
渲染depth/shadow图:
simpleDepthShader.use();
glUniformMatrix4fv(lightSpaceMatrixLocation, 1, GL_FALSE, glm::value_ptr(lightSpaceMatrix));
glViewport(0, 0, SHADOW_WIDTH, SHADOW_HEIGHT);
glBindFramebuffer(GL_FRAMEBUFFER, depthMapFBO);
glClear(GL_DEPTH_BUFFER_BIT);
RenderScene(simpleDepthShader);
glBindFramebuffer(GL_FRAMEBUFFER, 0);
渲染效果如下:
为了将深度图渲染到立方体上,我们使用了以下fs片段着色器:
#version 330 core
out vec4 FragColor;
in vec2 TexCoords;
uniform sampler2D depthMap;
void main()
{
float depthValue = texture(depthMap, TexCoords).r;
//把深度用颜色显示出来,颜色rgb都等于depthValue,则显示灰色,数值越大越灰色
FragColor = vec4(vec3(depthValue), 1.0);
}
5.Rendering shadows(渲染阴影)
vs:
#version 330 core
//位置,法线向量,贴图坐标
layout (location = 0) in vec3 aPos;
layout (location = 1) in vec3 aNormal;
layout (location = 2) in vec2 aTexCoords;
//输出
out VS_OUT {
vec3 FragPos;
vec3 Normal;
vec2 TexCoords;
vec4 FragPosLightSpace;
} vs_out;
//投影
uniform mat4 projection;
//视图
uniform mat4 view;
//模型
uniform mat4 model;
//light视图转换矩阵
uniform mat4 lightSpaceMatrix;
void main()
{
//配置输出的信息
vs_out.FragPos = vec3(model * vec4(aPos, 1.0));
vs_out.Normal = transpose(inverse(mat3(model))) * aNormal;
vs_out.TexCoords = aTexCoords;
vs_out.FragPosLightSpace = lightSpaceMatrix * vec4(vs_out.FragPos, 1.0);
gl_Position = projection * view * vec4(vs_out.FragPos, 1.0);
}
当片段在阴影中时为 1.0,当不在阴影中时为 0.0。 产生的漫反射和镜面反射分量然后乘以这个阴影分量。 因为阴影很少是完全黑暗的(由于光散射),我们将环境分量排除在阴影乘法之外。
fs:
#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;
//获取这个frag位置pos的阴影状态
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 * lightColor;
// 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);
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.FragPosLightSpace);
vec3 lighting = (ambient + (1.0 - shadow) * (diffuse + specular)) * color;
FragColor = vec4(lighting, 1.0);
}
当我们在顶点着色器中将裁剪空间顶点位置输出到 gl_Position 时,OpenGL 会自动进行透视分割,例如通过将 x、y 和 z 分量除以向量的 w 分量,将 [-w,w] 范围内的剪辑空间坐标转换为 [-1,1]。由于剪辑空间 FragPosLightSpace 没有通过 gl_Position 传递给片段着色器,所以我们必须自己做这个透视分割:
//计算frag在pos中的阴影状态
float ShadowCalculation(vec4 fragPosLightSpace)
{
// perform perspective divide 透视分割
vec3 projCoords = fragPosLightSpace.xyz / fragPosLightSpace.w;
[...]
}
这个返回fragment's light-space position 光照视图坐标系 范围为 [-1
,1
].
因为深度图的深度在 [0,1] 范围内,而且我们还想使用 projCoords 从深度图中采样,所以我们将 NDC (Normalized Device Coordinates)坐标变换到 [0,1] 范围内:
projCoords = projCoords * 0.5 + 0.5;
要获得此片段的当前深度,我们只需检索投影向量的 z 坐标,该坐标等于从光的角度来看此片段的深度。
float currentDepth = projCoords.z;
实际的比较只是检查 currentDepth 是否高于 NearestDepth ,如果是,则片段处于阴影中:
float shadow = currentDepth > closestDepth ? 1.0 : 0.0;
总体的检测阴影代码:
float ShadowCalculation(vec4 fragPosLightSpace)
{
// perform perspective divide 正交分割 / w
vec3 projCoords = fragPosLightSpace.xyz / fragPosLightSpace.w;
// transform to [0,1] range 转换[0,1]
projCoords = projCoords * 0.5 + 0.5;
// get closest depth value from light's perspective (using [0,1] range fragPosLight as coords) 获取最潜的depth
float closestDepth = texture(shadowMap, projCoords.xy).r;
// get depth of current fragment from light's perspective 获取当前fragment的depth
float currentDepth = projCoords.z;
// check whether current frag pos is in shadow 检测是否在阴影中
float shadow = currentDepth > closestDepth ? 1.0 : 0.0;
return shadow;
}
为什么会有这种摩尔纹一样的东西?
6.Shadow acne (阴影失真)
近距离放大向我们展示了一个非常明显的莫尔状图案:
shadow acne形成原因:
多个片段对同一深度样本进行采样。
我们可以通过一个称为shadow bias阴影偏差的小技巧来解决这个问题,我们只需将表面(或阴影贴图)的深度偏移一个小的偏差量,这样碎片就不会被错误地考虑在表面上方。
把shadow map生成的黄色梯线,往下方偏移一些:
应用偏置后,所有fragment样本的深度都小于表面的深度,因此整个表面被正确照亮,没有任何阴影。
float bias = 0.005;
float shadow = currentDepth - bias > closestDepth ? 1.0 : 0.0;
这里我们有基于表面法线和光线方向的最大偏差为 0.05 和最小偏差为 0.005。 这样,几乎垂直于光源的地板等表面会获得很小的偏差,而像立方体侧面这样的表面会获得更大的偏差。 下图显示了相同的场景,但现在有了shadow bias阴影偏差:
显示正常了。
7.Peter panning(彼得平移?)
使用阴影偏差的一个缺点是对对象的实际深度进行了偏移。 因此,偏差可能变得足够大,让人看到与实际对象位置相比的可见阴影偏移,如下所示(具有很夸张的偏差):
使用正面剔除来生成深度贴图可以解决正面的阴影失真,也就是说只有物体背面才会产生深度信息,向地板这种只有一个面的物体不会产生深度信息,这也是合理的,但是这也只能在封闭的物体上这么做。
右边的图,把正面剔除了,地面空白地方就不会显示影子了,不会shadow acne阴影失真了。
即使这样物体的背面还是会出现阴影悬浮,这时候可以用glPolygonOffset来解决这个问题,将深度图中的值稍微减小一点而不是片段到光源的距离,两者结合就可以解决大部分问题
8.Over sampling (过度采样)
您可以在图像中看到,有某种虚构的光区域,而该区域之外的大部分区域都处于阴影中; 该区域表示投影到地板上的深度图的大小。
发生这种情况是因为光的视锥体之外的投影坐标高于 1.0,因此将对超出其默认范围 [0,1] 的深度纹理进行采样,因为我们之前将深度图的环绕选项设置为 GL_REPEAT。
我们更希望深度图范围之外的所有坐标的深度为 1.0,这样利用深度贴图的时候,不会获取到一个大于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);
float borderColor[] = { 1.0f, 1.0f, 1.0f, 1.0f };
glTexParameterfv(GL_TEXTURE_2D, GL_TEXTURE_BORDER_COLOR, borderColor);
现在,每当我们在深度图的 [0,1] 坐标范围之外进行采样时,纹理函数将始终返回 1.0 的深度,从而产生 0.0 的阴影值。 结果现在看起来更合理:
当 z 坐标大于 1.0 时,光空间投影fragment片段坐标比光的视锥体远平面更远。 在这种情况下,GL_CLAMP_TO_BORDER 包装方法不再起作用,因为我们将坐标的 z 分量与深度图值进行比较; 对于大于 1.0 的 z,这始终返回 true。
对此的修复也相对容易,因为只要投影向量的 z 坐标大于 1.0,我们只需将阴影值强制为 0.0:
float ShadowCalculation(vec4 fragPosLightSpace)
{
[...]
if(projCoords.z > 1.0)
shadow = 0.0;
return shadow;
}
结果就正常了:
9.PCF (percentage-closer filtering 百分比接近过滤)
如果您放大阴影,阴影贴图的分辨率依赖性很快就会变得明显。
深度贴图分辨率不够就会这样,增加分辨率或者使用PCF,或percentage-closer filtering 百分比接近过滤,该术语包含许多不同的过滤功能,可产生更柔和的阴影,使它们看起来不那么块状或硬。这个想法是从深度图中多次采样,每次都有略微不同的纹理坐标。对于每个单独的样本,我们检查它是否在阴影中。然后将所有子结果组合并平均,我们得到一个漂亮的柔和阴影。
周围的点,加起来平均一下:
0 0 0
0 1 0
0 0 0
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 返回给定采样器纹理在 mipmap 级别 0 的宽度和高度的 vec2。除 1 表示返回我们用来偏移纹理坐标的单个纹素的大小,确保每个新样本采样不同的深度值 . 在这里,我们围绕投影坐标的 x 和 y 值对 9 个值进行采样,测试阴影遮挡,最后将结果取总采样数的平均值。
10.Orthographic vs perspective(正交和透视)
透视投影最常用于聚光灯和点光源,而正交投影则用于平行光。
使用透视投影矩阵的另一个细微差别是,可视化深度缓冲区通常会给出几乎完全白色的结果。 发生这种情况是因为使用透视投影,深度被转换为非线性深度值,其大部分显着范围靠近近平面。 为了能够像使用正交投影那样正确查看深度值,您首先需要将非线性深度值转换为线性深度值
#version 330 core
out vec4 FragColor;
in vec2 TexCoords;
uniform sampler2D depthMap;
uniform float near_plane;
uniform float far_plane;
//转换线性深度值的核心代码
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));
}
void main()
{
float depthValue = texture(depthMap, TexCoords).r;
//使用线性深度值
FragColor = vec4(vec3(LinearizeDepth(depthValue) / far_plane), 1.0); // perspective
// FragColor = vec4(vec3(depthValue), 1.0); // orthographic
}