在教程15中,我们学习了如何创建光照贴图,这包括了静态光照。虽然它能够产生非常漂亮的阴影,但它无法处理动画模型。
阴影贴图是当前(截至2016年)用于生成动态阴影的方法。它们的优点是相对容易实现。缺点是很难做到完美。
在本教程中,我们将首先介绍基本算法,指出它的不足之处,然后实现一些技术以获得更好的效果。由于在撰写本文时(2012年),阴影贴图仍然是一个被广泛研究的课题,我们将根据你的需求,为你提供一些进一步改进自己阴影贴图的方向。
基本阴影贴图
基本的阴影贴图算法分为两个步骤。首先,场景从光源的角度进行渲染。只计算每个片元的深度。接下来,场景像往常一样渲染,但增加一个测试,以判断当前片元是否处于阴影中。
“是否处于阴影中”的测试实际上非常简单。如果当前样本比阴影贴图中同一点的距离光源更远,这意味着场景中包含一个更靠近光源的物体。换句话说,当前片元处于阴影中。
以下图像可能有助于你理解这一原理:
渲染阴影贴图
在本教程中,我们只考虑平行光源(光源距离非常远,以至于所有光线都可以被视为平行光)。因此,渲染阴影贴图时使用正交投影矩阵。正交矩阵与通常的透视投影矩阵类似,只是不考虑透视效果——无论物体离相机远还是近,看起来都是一样的。
设置渲染目标和MVP矩阵
从教程14开始,你已经知道如何将场景渲染到纹理中,以便稍后在着色器中访问它。
这里我们使用一个1024x1024的16位深度纹理来存储阴影贴图。16位通常足以满足阴影贴图的需求。你可以随意尝试这些值。注意,我们使用的是深度纹理,而不是深度渲染缓冲区,因为稍后我们需要对其进行采样。
// 帧缓冲区,它可以包含0个、1个或多个纹理,以及0个或1个深度缓冲区。
GLuint FramebufferName = 0;
glGenFramebuffers(1, &FramebufferName); // 生成一个帧缓冲区对象
glBindFramebuffer(GL_FRAMEBUFFER, FramebufferName); // 绑定帧缓冲区
// 深度纹理。虽然比深度缓冲区慢,但你可以在着色器中对它进行采样。
GLuint depthTexture;
glGenTextures(1, &depthTexture); // 生成一个纹理对象
glBindTexture(GL_TEXTURE_2D, depthTexture); // 绑定纹理
glTexImage2D(GL_TEXTURE_2D, 0, GL_DEPTH_COMPONENT16, 1024, 1024, 0, GL_DEPTH_COMPONENT, GL_FLOAT, 0); // 分配纹理内存,格式为16位深度
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_NEAREST); // 设置纹理放大过滤器为最近邻
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_NEAREST); // 设置纹理缩小过滤器为最近邻
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_S, GL_CLAMP_TO_EDGE); // 设置纹理S轴(水平方向)的环绕模式为边缘截断
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_T, GL_CLAMP_TO_EDGE); // 设置纹理T轴(垂直方向)的环绕模式为边缘截断
glFramebufferTexture(GL_FRAMEBUFFER, GL_DEPTH_ATTACHMENT, depthTexture, 0); // 将深度纹理附加到帧缓冲区的深度附件
glDrawBuffer(GL_NONE); // 不绘制颜色缓冲区
// 始终检查帧缓冲区是否配置正确
if (glCheckFramebufferStatus(GL_FRAMEBUFFER) != GL_FRAMEBUFFER_COMPLETE)
return false; // 如果帧缓冲区不完整,返回false
用于从光源视角渲染场景的MVP矩阵计算如下:
- 投影矩阵(Projection Matrix):
- 投影矩阵是一个正交投影矩阵,它将包含X、Y和Z轴上轴对齐盒子(-10,10)、(-10,10)、(-10,20)内的所有内容。
- 这些值是为了确保我们整个可见场景始终可见;更多相关内容将在“进一步优化”部分讨论。
- 视图矩阵(View Matrix):
- 视图矩阵用于旋转世界,使得在相机空间中,光源方向为-Z轴(是否需要重新阅读教程3?)。
- 模型矩阵(Model Matrix):
- 模型矩阵可以是任意你需要的变换矩阵,用于调整场景中物体的位置、旋转和缩放。
glm::vec3 lightInvDir = glm::vec3(0.5f, 2, 2); // 定义光源的方向向量
// 从光源的视角计算MVP矩阵
// 创建正交投影矩阵,范围分别为X轴(-10到10)、Y轴(-10到10)、Z轴(-10到20)
glm::mat4 depthProjectionMatrix = glm::ortho<float>(-10, 10, -10, 10, -10, 20);
// 创建视图矩阵,使光源看向原点 (0,0,0),上方向为Y轴 (0,1,0)
glm::mat4 depthViewMatrix = glm::lookAt(lightInvDir, glm::vec3(0, 0, 0), glm::vec3(0, 1, 0));
// 创建模型矩阵,初始化为单位矩阵(无变换)
glm::mat4 depthModelMatrix = glm::mat4(1.0);
// 计算MVP矩阵:投影矩阵 * 视图矩阵 * 模型矩阵
glm::mat4 depthMVP = depthProjectionMatrix * depthViewMatrix * depthModelMatrix;
// 将MVP矩阵上传到着色器的 uniform 变量
// 将计算出的变换矩阵传递给当前绑定的着色器,存储在 "MVP" uniform 中
glUniformMatrix4fv(depthMatrixID, 1, GL_FALSE, &depthMVP[0][0]);
着色器
在此阶段使用的着色器非常简单。顶点着色器中的顶点坐标直接乘上MVP矩阵(pass-through shader),它仅计算顶点在齐次坐标中的位置:
#version 330 core
// Input vertex data, different for all executions of this shader.
layout(location = 0) in vec3 vertexPosition_modelspace;
// Values that stay constant for the whole mesh.
uniform mat4 depthMVP;
void main(){
gl_Position = depthMVP * vec4(vertexPosition_modelspace,1);
}
片段着色器同样非常简单:它只需将片段的深度写入位置0(即我们的深度纹理中)。
#version 330 core
// 输出数据
// 声明输出变量 fragmentdepth,用于写入深度值
layout(location = 0) out float fragmentdepth;
void main() {
// 这行代码并不是必需的,因为OpenGL默认会处理深度值的写入
// 将当前片段的深度值赋值给 fragmentdepth
fragmentdepth = gl_FragCoord.z;
}
生成阴影贴图(shadow map)通常比普通渲染快两倍以上,因为阴影贴图渲染时只写入低精度的深度信息,而不像普通渲染那样同时写入深度和颜色信息。在图形处理器(GPU)上,内存带宽常常是性能问题的最大瓶颈。
结果
生成的纹理看起来像这样:
在阴影贴图或深度贴图中,颜色的深浅通常表示场景中物体距离摄像机或光源的远近。具体来说:
- 深色(接近黑色) 表示较小的𝑧值,意味着该点靠近摄像机或光源。例如,墙壁的右上角离摄像机较近,因此它的深度值会较低,表现为较暗的颜色。
- 白色 表示𝑧=1(在齐次坐标系中),这意味着该点位于视锥体的最远端,或者无穷远处。
使用阴影贴图
基础着色器
现在回到我们通常的着色器。对于每个计算的片元,我们必须测试它是否“位于”阴影贴图之后。
为此,我们需要将当前片元的位置转换到与创建阴影贴图时相同的空间中。因此,我们需要用通常的MVP矩阵和深度MVP矩阵分别对其进行变换。
不过,这里有一个小技巧。将顶点的位置乘以深度MVP矩阵会得到齐次坐标,范围是[-1, 1];但纹理采样的坐标范围必须是[0, 1]。
例如,屏幕中间的片段在齐次坐标中会是(0, 0);但由于它需要采样纹理的中间部分,UV坐标必须是(0.5, 0.5)。
这个问题可以通过在片段着色器中直接调整采样坐标来解决,但更高效的方法是使用以下矩阵乘以齐次坐标。这个矩阵将坐标除以2(对角线:[-1, 1] -> [-0.5, 0.5]),然后进行平移(最后一行:[-0.5, 0.5] -> [0, 1])。
glm::mat4 biasMatrix(
0.5, 0.0, 0.0, 0.0,
0.0, 0.5, 0.0, 0.0,
0.0, 0.0, 0.5, 0.0,
0.5, 0.5, 0.5, 1.0
);
glm::mat4 depthBiasMVP = biasMatrix*depthMVP;
现在我们可以编写顶点着色器了。它和之前的一样,但输出了两个位置而不是之前只输出一个:
- gl_Position 是从当前摄像机视角看到的顶点位置。
- ShadowCoord 是从上一个摄像机(即光源)视角看到的顶点位置。
// 输出顶点在裁剪空间中的位置:MVP * 位置
gl_Position = MVP * vec4(vertexPosition_modelspace, 1);
// 同样的计算,但使用光源的视图矩阵
ShadowCoord = DepthBiasMVP * vec4(vertexPosition_modelspace, 1);
片段着色器的实现非常简单:
- texture(shadowMap, ShadowCoord.xy).z 是光源与最近遮挡物之间的距离。
- ShadowCoord.z 是光源与当前片段之间的距离。
…… 因此,如果当前片段的距离比最近遮挡物的距离更远,这意味着我们处于该最近遮挡物的阴影中。
float visibility = 1.0;
if ( texture( shadowMap, ShadowCoord.xy ).z < ShadowCoord.z){
visibility = 0.5;
}
我们只需要利用这一知识来修改我们的着色处理。当然,环境光颜色不会被修改,因为它的作用是在我们处于阴影中时仍然模拟一些入射光照(否则所有东西都会是纯黑色的)。
color =
// 环境光:模拟间接光照
MaterialAmbientColor +
// 漫反射:物体的“颜色”
visibility * MaterialDiffuseColor * LightColor * LightPower * cosTheta +
// 高光反射:像镜子一样的反射亮点
visibility * MaterialSpecularColor * LightColor * LightPower * pow(cosAlpha, 5);
摩尔纹
当前代码的结果如下。显然,整体思路是正确的,但质量无法接受。
让我们来看这张图片中的问题。代码仓库中有2个项目:shadowmaps和shadowmaps_simple;简单版本的阴影效果就是上图的输出结果。
问题
摩尔纹
最明显的问题被称为摩尔纹:
这种现象可以通过一个简单的图像轻松解释:
对此现象常用的“修复”方法是添加一个误差范围:只有当当前片段的深度(再次强调,是在光源空间中的深度)与阴影贴图的值相差较大时,我们才会对其进行着色。我们通过添加一个偏移量(bias)来实现这一点:
float bias = 0.005;
float visibility = 1.0;
if ( texture( shadowMap, ShadowCoord.xy ).z < ShadowCoord.z-bias){
visibility = 0.5;
}
结果已经好很多了:
然而,你可以注意到,由于我们添加了 偏移量(bias)
,地面与墙壁之间的 伪影(artifact)
变得更加严重。此外,0.005 的偏移量对地面来说似乎太大了,但对曲面来说又不够:圆柱体和球体上仍然存在一些伪影。
一种常见的解决方法是根据 斜率(slope)
来调整 偏移量(bias)
:
float bias = 0.005*tan(acos(cosTheta)); // cosTheta is dot( n,l ), clamped between 0 and 1
bias = clamp(bias, 0,0.01);
现在,即使是曲面上的摩尔纹也已经消失了。
另一个技巧是仅渲染背面到阴影贴图中,这个技巧的效果取决于你的几何体。这种方法要求我们使用特殊的几何体(见下一节——Peter Panning问题),例如厚墙,但至少可以将阴影伪影(acne)限制在阴影区域内的表面上。
渲染阴影贴图时,剔除正面三角形:
// 我们在着色器中不使用偏移(bias),而是绘制背面多边形,
// 而这些背面多边形本身已经与正面多边形相隔了一小段距离
// (如果您的几何形状是这样设计的)
glCullFace(GL_FRONT); // 剔除正面朝向的三角形 -> 仅绘制背面朝向的三角形
在渲染场景时,正常渲染(背景剔除)
glCullFace(GL_BACK); // Cull back-facing triangles -> draw only front-facing triangles
这种方法在代码中被使用,除此之外还使用了偏移(bias)。
Peter Panning
彼得潘效应(Peter Panning)是一个在实时渲染中常见的问题,通常发生在阴影贴图技术中。尽管通过 偏移(bias)
解决了阴影 自遮挡(shadow acne)
的问题,但彼得潘效应却导致了另一种视觉错误:物体与地面之间的阴影不自然,看起来像是物体悬浮在地面上,而不是真实接触。
这个问题非常容易修复:只需避免使用薄几何体。这样做有两个优势:
- 首先,它解决了彼得潘效应(Peter Panning):如果几何体的厚度大于你设置的偏移量(bias),那么问题就解决了。
- 其次,在渲染阴影贴图时可以启用背面剔除(backface culling),因为现在墙壁有一个面向光源的多边形,它可以遮挡另一侧,而这一侧在启用背面剔除时本就不会被渲染。
缺点是需要渲染更多的三角形(每帧两次!)。
走样问题(Aliasing)
即使使用了上述两种技巧,你仍然会注意到阴影边缘存在走样问题。换句话说,一个像素是白色,而相邻的像素是黑色,中间没有平滑的过渡。
百分比渐近滤波(PCF)
最简单的改进方法是将阴影贴图的采样器类型更改为 sampler2DShadow
。这样做的结果是,当你对阴影贴图进行一次采样时,硬件实际上还会采样相邻的纹素,并对所有采样点进行比较,然后返回一个 [0, 1] 范围内的浮点数,该值是对比较结果的双线性滤波。
例如,0.5 表示有 2 个采样点在阴影中,2 个采样点在光照中。
需要注意的是,这与对过滤后的深度贴图进行单次采样不同!每次比较只会返回 true 或 false;PCF 则是对 4 个“true 或 false”结果进行插值。
正如你所看到的,阴影边界是平滑的,但是阴影贴图的纹理仍然是可见的。
泊松采样(Poisson Sampling)
一种简单的解决方法是多次采样阴影贴图,而不是只采样一次。与百分比渐近滤波(PCF)结合使用,即使采样次数较少(例如4次),也能得到非常好的效果。以下是4次采样的代码示例:
for (int i=0;i<4;i++){
if ( texture( shadowMap, ShadowCoord.xy + poissonDisk[i]/700.0 ).z < ShadowCoord.z-bias ){
visibility-=0.2;
}
}
poissonDisk
是一个常量数组,可以按以下方式定义:
vec2 poissonDisk[4] = vec2[](
vec2( -0.94201624, -0.39906216 ),
vec2( 0.94558609, -0.76890725 ),
vec2( -0.094184101, -0.92938870 ),
vec2( 0.34495938, 0.29387760 )
);
通过这种方式,根据有多少阴影贴图采样点通过测试,生成的片元颜色会变得更暗或更亮。
700.0
这个常量定义了采样点的“扩散”程度。如果扩散得太小,会导致走样问题再次出现;如果扩散得太大,则会出现条带现象(这张截图没有使用PCF以增强效果,但使用了16次采样)。
分层泊松采样 (Stratified Poisson Sampling)
我们可以通过为每个像素选择不同的样本点来消除这种带状伪影(banding)。主要有两种方法:分层泊松采样 (Stratified Poisson)
和 旋转泊松采样 (Rotated Poisson)
。分层泊松采样会选择不同的样本点,而旋转泊松采样则始终使用相同的样本点,但通过随机旋转使它们看起来不同。在本教程中,我将只解释分层泊松采样的版本。
for (int i = 0; i < 4; i++) {
int index = // 一个随机数,范围在 0 到 15 之间,对每个像素(以及每个 i!)都不同
visibility -= 0.2 * (1.0 - texture(shadowMap, vec3(ShadowCoord.xy + poissonDisk[index] / 700.0, (ShadowCoord.z - bias) / ShadowCoord.w)));
}
我们可以通过这样的代码生成一个随机数,它返回一个范围在 [0, 1] 内的随机数。
float dot_product = dot(seed4, vec4(12.9898,78.233,45.164,94.673));
return fract(sin(dot_product) * 43758.5453);
在我们的例子中,seed4 将是 i 的组合(这样我们可以采样四个不同的位置),以及……其他某些内容。我们可以使用 gl_FragCoord(像素在屏幕上的位置)或 Position_worldspace:
// - 基于像素屏幕位置的随机采样。
// 不存在带状伪影,但阴影会随着摄像机移动,看起来有些奇怪。
int index = int(16.0 * random(gl_FragCoord.xyy, i)) % 16;
// - 基于像素世界空间位置的随机采样。
// 位置被四舍五入到毫米级,以避免过多的锯齿效应(aliasing)。
// int index = int(16.0 * random(floor(Position_worldspace.xyz * 1000.0), i)) % 16;
这将以引入视觉噪点为代价,使如上图所示的图案消失。然而,精心处理过的噪点通常比这些图案更可接受。
见教程16/ShadowMapping.fragmentshader的三个例子实现。
进一步优化
即使使用了上述所有技巧,我们的阴影仍然有许多可以改进的地方。以下是一些常见的优化方法:
提前退出(Early Bailing)
不要对每个片段都进行16次采样(这非常耗费性能),可以先进行4次远距离采样。如果这4次采样结果全部在光照中或全部在阴影中,那么可以认为16次采样的结果也会相同,从而提前退出。如果结果不一致,则说明可能处于阴影边界,此时再进行16次采样。
聚光灯(Spot Lights)
处理聚光灯只需要很少的改动。最明显的改动是将正交投影矩阵改为透视投影矩阵:
glm::vec3 lightPos(5, 20, 20);
glm::mat4 depthProjectionMatrix = glm::perspective<float>(glm::radians(45.0f), 1.0f, 2.0f, 50.0f);
glm::mat4 depthViewMatrix = glm::lookAt(lightPos, lightPos-lightInvDir, glm::vec3(0,1,0));
与正交投影不同,透视投影使用透视视锥体。在着色器中使用 texture2Dproj
来处理透视除法(详见教程3 - 矩阵)。
第二步是在着色器中考虑透视效果。透视投影矩阵实际上并不直接处理透视,而是由硬件通过将投影坐标除以 w
来实现。在着色器中,我们需要手动进行透视除法。以下是两种在GLSL中实现的方法,第二种使用了内置的 textureProj
函数,但两种方法的结果完全相同:
if (texture(shadowMap, (ShadowCoord.xy/ShadowCoord.w)).z < (ShadowCoord.z-bias)/ShadowCoord.w)
if (textureProj(shadowMap, ShadowCoord.xyw)).z < (ShadowCoord.z-bias)/ShadowCoord.w)
点光源(Point Lights)
点光源的处理方式类似,但需要使用 深度立方体贴图(depth cubemap)
。立方体贴图是一组6个纹理,分别对应立方体的每个面。它不使用标准的UV坐标,而是使用一个表示方向的3D向量来访问。
深度值存储在所有方向上,使得点光源可以在所有方向上投射阴影。
多光源组合(Combination of Several Lights)
算法可以处理多个光源,但需要注意,每个光源都需要额外的场景渲染来生成阴影贴图。这会导致内存占用急剧增加,并可能很快达到带宽限制。
自动光源视锥体(Automatic Light Frustum)
在本教程中,光源视锥体是手动设置的,以确保包含整个场景。虽然在这个简单的示例中有效,但在实际应用中应避免这种做法。例如,如果你的地图是 1公里 x 1公里
,那么1024x1024的阴影贴图中每个纹素将覆盖1平方米,这显然不够精细。光源的投影矩阵应尽可能紧密地包围场景
。
对于聚光灯,可以通过调整其范围来优化。对于像太阳这样的平行光源,情况更复杂,因为它们确实会照亮整个场景。以下是计算光源视锥体的一种方法:
- 潜在阴影接收者(Potential Shadow Receivers, PSRs:这些物体同时位于光源视锥体、相机视锥体和场景边界框内。它们可能被阴影覆盖。
- 潜在阴影投射者(Potential Shadow Casters, PCFs):包括所有潜在阴影接收者,以及位于它们和光源之间的物体(即使这些物体不可见,也可能投射可见的阴影)。
通过计算这些物体的包围盒,可以生成更紧密的光源投影矩阵。虽然精确计算这些集合涉及凸包交集,但这种方法更容易实现。
指数阴影贴图(Exponential Shadow Maps)
指数阴影贴图通过假设阴影中的片段(但靠近光照表面)实际上是“介于两者之间”来减少走样。这与偏移值(bias)类似,但测试不再是二进制的:片段随着其与光照表面距离的增加而逐渐变暗。
这种方法显然是一种“作弊”,当两个物体重叠时可能会出现伪影。
光空间透视阴影贴图(Light-space Perspective Shadow Maps, LiSPSM)
LiSPSM通过调整光源投影矩阵,在相机附近获得更高的精度。这在“对视视锥体”情况下尤为重要:当你看向一个方向,而聚光灯看向相反方向时,阴影贴图的精度在光源附近(即远离相机的地方)较高,而在相机附近(你最需要精度的地方)较低。
然而,LiSPSM的实现较为复杂。详见参考文献中的实现细节。
级联阴影贴图(Cascaded Shadow Maps, CSM)
CSM以不同的方式解决了与LiSPSM相同的问题。它简单地为视锥体的不同部分使用多个(2-4个)标准阴影贴图。第一个阴影贴图处理最近的几米,因此在小范围内可以获得高分辨率。接下来的阴影贴图处理更远的物体。最后一个阴影贴图处理场景的大部分区域,但由于透视关系,它的视觉重要性不如近处区域。
截至2012年,级联阴影贴图在复杂性和质量之间具有最佳平衡,是许多情况下的首选解决方案。
结论
正如你所见,阴影贴图是一个复杂的主题。每年都有新的改进方法发布,但至今仍没有完美的解决方案。
幸运的是,大多数方法可以混合使用:例如,可以在光空间透视中使用级联阴影贴图,并通过PCF进行平滑处理。尝试实验这些技术,找到最适合你的方案。
最后,我建议尽可能使用预计算的光照贴图,并仅对动态物体使用阴影贴图。同时,确保两者的视觉质量一致:静态环境完美而动态阴影粗糙是不可取的。