OpenGL核心技术之Shadow Mapping改进版

时间:2022-09-10 16:45:11

笔者介绍:姜雪伟,IT公司技术合伙人,IT高级讲师,CSDN社区专家,特邀编辑,畅销书作者;已出版书籍:《手把手教你架构3D游戏引擎》电子工业出版社和《Unity3D实战核心技术详解》电子工业出版社等。

CSDN视频网址:http://edu.csdn.net/lecturer/144

在上一个博客中介绍了Shadow Mapping技术,但是正如你所看到的哦,Shadow Mapping还是有点不真实,本片博客开始介绍如何改进Shadow Mapping技术。在上篇博客中渲染的阴影有所失真,正如如下图片所示的:

OpenGL核心技术之Shadow Mapping改进版

我们可以看到地板四边形渲染出很大一块交替黑线。这种阴影贴图的不真实感叫做阴影失真(Shadow Acne),下图解释了成因:

OpenGL核心技术之Shadow Mapping改进版

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

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

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

OpenGL核心技术之Shadow Mapping改进版

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

float bias = 0.005;float shadow = currentDepth - bias > closestDepth  ? 1.0 : 0.0;
一个0.005的偏移就能帮到很大的忙,但是有些表面坡度很大,仍然会产生阴影失真。有一个更加可靠的办法能够根据表面朝向光线的角度更改偏移量:使用点乘:

float bias = max(0.05 * (1.0 - dot(normal, lightDir)), 0.005);
这里我们有一个偏移量的最大值0.05,和一个最小值0.005,它们是基于表面法线和光照方向的。这样像地板这样的表面几乎与光源垂直,得到的偏移就很小,而比如立方体的侧面这种表面得到的偏移就更大。下图展示了同一个场景,但使用了阴影偏移,效果的确更好:

OpenGL核心技术之Shadow Mapping改进版
选用正确的偏移数值,在不同的场景中需要一些像这样的轻微调校,但大多情况下,实际上就是增加偏移量直到所有失真都被移除的问题。

使用阴影偏移的一个缺点是你对物体的实际深度应用了平移。偏移有可能足够大,以至于可以看出阴影相对实际物体位置的偏移,你可以从下图看到这个现象(这是一个夸张的偏移值):

OpenGL核心技术之Shadow Mapping改进版

这个阴影失真叫做悬浮(Peter Panning),因为物体看起来轻轻悬浮在表面之上(译注Peter Pan就是童话彼得潘,而panning有平移、悬浮之意,而且彼得潘是个会飞的男孩…)。我们可以使用一个叫技巧解决大部分的Peter panning问题:当渲染深度贴图时候使用正面剔除(front face culling)你也许记得在面剔除博文中OpenGL默认是背面剔除。我们要告诉OpenGL我们要剔除正面。

OpenGL核心技术之Shadow Mapping改进版

为了修复peter游移,我们要进行正面剔除,先必须开启GL_CULL_FACE:

glCullFace(GL_FRONT);RenderSceneToDepthMap();glCullFace(GL_BACK); // 不要忘记设回原先的culling face

这十分有效地解决了peter panning的问题,但只针对实体物体,内部不会对外开口。我们的场景中,在立方体上工作的很好,但在地板上无效,因为正面剔除完全移除了地板。地面是一个单独的平面,不会被完全剔除。如果有人打算使用这个技巧解决peter panning必须考虑到只有剔除物体的正面才有意义。

另一个要考虑到的地方是接近阴影的物体仍然会出现不正确的效果。必须考虑到何时使用正面剔除对物体才有意义。不过使用普通的偏移值通常就能避免peter panning。

无论你喜不喜欢还有一个视觉差异,就是光的视锥不可见的区域一律被认为是处于阴影中,不管它真的处于阴影之中。出现这个状况是因为超出光的视锥的投影坐标比1.0大,这样采样的深度纹理就会超出他默认的0到1的范围。根据纹理环绕方式,我们将会得到不正确的深度结果,它不是基于真实的来自光源的深度值。

OpenGL核心技术之Shadow Mapping改进版

你可以在图中看到,光照有一个区域,超出该区域就成为了阴影;这个区域实际上代表着深度贴图的大小,这个贴图投影到了地板上。发生这种情况的原因是我们之前将深度贴图的环绕方式设置成了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);
现在如果我们采样深度贴图0到1坐标范围以外的区域,纹理函数总会返回一个1.0的深度值,阴影值为0.0。结果看起来会更真实:
OpenGL核心技术之Shadow Mapping改进版

仍有一部分是黑暗区域。那里的坐标超出了光的正交视锥的远平面。你可以看到这片黑色区域总是出现在光源视锥的极远处。

当一个点比光的远平面还要远时,它的投影坐标的z坐标大于1.0。这种情况下,GL_CLAMP_TO_BORDER环绕方式不起作用,因为我们把坐标的z元素和深度贴图的值进行了对比;它总是为大于1.0的z返回true。

解决这个问题也很简单,我们简单的强制把shadow的值设为0.0,不管投影向量的z坐标是否大于1.0:

float ShadowCalculation(vec4 fragPosLightSpace){    [...]    if(projCoords.z > 1.0)        shadow = 0.0;    return shadow;}

检查远平面,并将深度贴图限制为一个手工指定的边界颜色,就能解决深度贴图采样超出的问题,我们最终会得到下面我们所追求的效果:

OpenGL核心技术之Shadow Mapping改进版

这些结果意味着,只有在深度贴图范围以内的被投影的fragment坐标才有阴影,所以任何超出范围的都将会没有阴影。由于在游戏中通常这只发生在远处,就会比我们之前的那个明显的黑色区域效果更真实。

阴影现在已经附着到场景中了,不过这仍不是我们想要的。如果你放大看阴影,阴影映射对解析度的依赖很快变得很明显。

OpenGL核心技术之Shadow Mapping改进版

因为深度贴图有一个固定的解析度,多个片元对应于一个纹理像素。结果就是多个片元会从深度贴图的同一个深度值进行采样,这几个片元便得到的是同一个阴影,这就会产生锯齿边。

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

另一个(并不完整的)解决方案叫做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;

这个textureSize返回一个给定采样器纹理的0级mipmap的vec2类型的宽和高。用1除以它返回一个单独纹理像素的大小,我们用以对纹理坐标进行偏移,确保每个新样本,来自不同的深度值。这里我们采样得到9个值,它们在投影坐标的x和y值的周围,为阴影阻挡进行测试,并最终通过样本的总数目将结果平均化。

使用更多的样本,更改texelSize变量,你就可以增加阴影的柔和程度。下面你可以看到应用了PCF的阴影:

OpenGL核心技术之Shadow Mapping改进版

从稍微远一点的距离看去,阴影效果好多了,也不那么生硬了。如果你放大,仍会看到阴影贴图解析度的不真实感,但通常对于大多数应用来说效果已经很好了。

下面将PCF的Shader代码展示如下所示,首先展示的是顶点着色器:

#version 330 corelayout (location = 0) in vec3 position;layout (location = 1) in vec3 normal;layout (location = 2) in 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);}

片段着色器代码如下所示:

#version 330 coreout 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;uniform bool shadows;float ShadowCalculation(vec4 fragPosLightSpace){    // perform perspective divide    vec3 projCoords = fragPosLightSpace.xyz / fragPosLightSpace.w;    // Transform to [0,1] range    projCoords = projCoords * 0.5 + 0.5;    // Get closest depth value from light's perspective (using [0,1] range fragPosLight as coords)    float closestDepth = texture(shadowMap, projCoords.xy).r;     // Get depth of current fragment from light's perspective    float currentDepth = projCoords.z;    // Calculate bias (based on depth map resolution and slope)    vec3 normal = normalize(fs_in.Normal);    vec3 lightDir = normalize(lightPos - fs_in.FragPos);    float bias = max(0.05 * (1.0 - dot(normal, lightDir)), 0.005);    // Check whether current frag pos is in shadow    // float shadow = currentDepth - bias > closestDepth  ? 1.0 : 0.0;    // 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;        // Keep the shadow at 0.0 when outside the far_plane region of the light's frustum.    if(projCoords.z > 1.0)        shadow = 0.0;            return shadow;}void main(){               vec3 color = texture(diffuseTexture, fs_in.TexCoords).rgb;    vec3 normal = normalize(fs_in.Normal);    vec3 lightColor = vec3(0.4);    // Ambient    vec3 ambient = 0.2 * 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);    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 = shadows ? ShadowCalculation(fs_in.FragPosLightSpace) : 0.0;                          shadow = min(shadow, 0.75); // reduce shadow strength a little: allow some diffuse/specular light in shadowed regions    vec3 lighting = (ambient + (1.0 - shadow) * (diffuse + specular)) * color;            FragColor = vec4(lighting, 1.0f);}
实际上PCF还有更多的内容,以及很多技术要点需要考虑以提升柔和阴影的效果,但处于本章内容长度考虑,我们将留在以后讨论。