如何使用Windows SDK和DirectX11实现38级联阴影贴图(CSM)?

2026-05-19 19:501阅读0评论SEO教程
  • 内容介绍
  • 文章标签
  • 相关推荐

本文共计9585个文字,预计阅读时间需要39分钟。

如何使用Windows SDK和DirectX11实现38级联阴影贴图(CSM)?

前言:在31章中,我们曾实现过shadow mapping,但受限于阴影贴图精度,只能在场景中相对有限的范围内投射阴影。本章我们将以微软提供的示例和博客作为切入点,学习如何解决阴影问题。

前言

在31章我们曾经实现过shadow mapping,但是受到阴影贴图精度的限制,只能在场景中相当有限的范围内投射阴影。本章我们将以微软提供的例子和博客作为切入点,学习如何解决阴影中出现的Atrifacts:

  • 边缘闪烁&抖动
  • 阴影接缝
  • 阴影缺失
  • perspective aliasing
  • projective aliasing

并且我们将会学习到如何使用级联阴影贴图(CSMs)。具体包括:

  • 解释CSMs的复杂性
  • 给出CSM算法可能变化的细节
  • 识别并解决一些向CSMs添加滤波相关的常见陷阱

然后在下一章,我们可能会讨论更多提升阴影质量、提升效率的技术,如PCSS、VSM等。

现在假定读者已经读过下面的内容:

章节 31 阴影映射

DirectX11 With Windows SDK完整目录

欢迎加入QQ群: 727623616 可以一起探讨DX11,以及有什么问题也可以在这里汇报。

Shadow Map Artifacts Perspective Aliasing

阴影贴图的Perspective Aliasing(这个词我无法给出好的翻译)是其中一个最难解决的问题之一。具体指的是在摄像机的近处,场景中物体的分辨率比较高,即便是一个较小的三角形面片也会对应大量的像素,但阴影贴图的精度并不会发生改变,导致有大量的像素对应shadow map中的同一点。如下图所示。当观察空间中的texel与shadow map中的texel不是一比一比例时就会发生。这是因为靠近 近平面的像素需要更高的shadow map的分辨率。

虽然可以让shadow map的分辨率可以提高,从而降低Perspective Aliasing的影响,但可能会带来严重的性能问题。当摄像机移动到与几何体非常近的地方时,这些靠近摄像机的像素需要非常高的精度以至于连8192x8192的shadow map都会产生锯齿。

使用PCF、PCSS等滤波算法来产生模糊效果,从而减轻这种锯齿感。对于不同距离下的深度,可以采用CSM来适配不同距离的阴影。

Projective Aliasing

Projective Aliasing比前面的问题更难展示。下图左侧可以看到投影引发的错误。当表面法线与光线接近正交时,就会发生这种现象。在shadow map中它只能表示某个深度值,但实际场景几何有着大段的深度值变化,容易导致采样出错。

通常尝试缓解Perspective Aliasing的算法也能够减轻Projective Aliasing带来的影响。

级联阴影

CSMs的概念其实比较好理解,视锥体的不同区域需要不同分辨率的shadow map,靠近摄像机的对象需要的精度比远离摄像机的更高一些。但实际上,当摄像机移动到与几何体非常近的地方时,这些靠近摄像机的像素需要非常高的精度以至于连8192x8192的shadow map都不够充足。

CSMs的基本思想是将视锥体分割成多个子视锥体,然后为每个子视锥体渲染不同的shadow map,接着像素着色器从最接近匹配所需精度的shadow map采样。

如何使用Windows SDK和DirectX11实现38级联阴影贴图(CSM)?

上图代表shadow map的一系列栅格 与 视锥体 显示了不同分辨率的shadow map对像素的覆盖影响。当光照空间的像素与阴影贴图的texels的比例为1:1时,阴影的质量是最高的(白色像素)。当太多像素映射到同一个阴影texel时,就会出现Perspective aliasing,即大块的纹理映射(左图)。当shadow map过大时,就会出现欠采样现象,这时候texels会被略过,出现闪烁的artifacts,性能也会受到影响。

上图展示了前图中的每个shadow map中质量最高部分的切割。从技术上说,白色和灰色用来表示级联阴影的成功。白色区域是理想的,因为它显示了良好的覆盖率——观察空间像素和shadow map texels的比例为1:1

在每一帧中,CSM需要完成下面的步骤:

  1. 将视锥体划分成多个子视锥体

  2. 为每个子视锥体计算光照空间下的正交投影

  3. 为每个子视锥体渲染shadow map

  4. 渲染场景

    1. 绑定shadow maps并渲染
    2. 顶点着色器完成下述内容
      • 为每个子视锥体计算光照空间投影纹理坐标(或者在像素着色器完成投影纹理坐标的计算)
      • 顶点变换
    3. 像素着色器完成下述内容
      • 决定合适的级联阴影
      • 必要时变换纹理坐标
      • 采样级联
      • 照亮该像素
视锥体划分

划分视锥体实际上是创建多个子视锥体。一种划分视锥体的方式是沿着Z的方向计算0%到100%范围内表示的区间,然后百分比的下界和上界分别表示子视锥体的近平面/远平面

在实践中,每一帧重新计算这些视锥体会导致阴影边缘的闪烁。普遍接受的做法是,每个场景使用一组静态的级联区间。在这种情况下,沿Z轴的区间用于描述一个在划分视锥体时出现的子视锥体。为一个给定的场景确定合适的区间取决于下面几个因素。

场景几何体的方向

就常见几何体来说,摄像机的方向会影响级联间隔的选择。例如,非常靠近地面的摄像机,如足球比赛中的地面摄像机,与天空中的摄像机有不同的静态级联区间集。

下图展示了不同的摄像机和各自的划分。当场景的Z-range非常大,需要进行更多的视锥体划分。例如,当眼睛离地面很近的时候,远处的物体依然可以看到,这时候就需要多重级联。划分视锥体使得更多在摄像机附近的分区(perspective aliasing变化最快的地方)也是有价值的。当大部分几何体聚集在视锥体的一小部分(如俯瞰图或飞行模拟器)时,需要的级联较少

不同的配置需要不同的视锥体划分

(左图)几何体有着Z的高动态范围时需要用到很多级联。(中图)低动态范围的Z不需要用到多级联。(右图)一般动态范围的Z只需要中等数量的级联

光源与摄像机的方向

每个级联投影矩阵都紧紧地围绕着其相应的子视锥体进行拟合。在摄像机和光源方向正交的情况,级联的排布可以非常紧密,没有什么重叠。但当光源方向与摄像机观察方向越接近平行,重叠的区域随着也会变大。直到逐渐接近平行时,就会产生dueling frusta现象,对大多数算法来说这是一个非常难处理的情况。对光照和摄像机进行约束使得这种现象不会发生的情况是很常见的,而CSM在这种情况下的表现比其他许多算法好得多。

下图展示了级联重叠随着光路与摄像机的逐渐平行而增加

许多CSM的实现使用了固定大小的视锥。在视锥体被分割成固定大小数目的区间时,像素着色器可以使用深度值来索引级联数组。

子视锥体的创建

一旦选择了视锥体的分段,我们有两种方式创建子视锥体:一个贴合场景(fit to scene)、另一个贴合级联(fit to cascade)

Fit to Scene:所有视锥体使用相同的近平面来创建。这会使得级联强制重叠

Fit to Cascade:所有的视锥体可以通过近平面与远平面进行区间划分的方式来创建。这会导致紧密贴合,但在出现dueling frusta现象时会退化成Fit to Scene。

左 Fit to scene vs. 右 Fit to cascade

下面的代码确定了不同子视锥体的近平面和远平面,m_CascadePartitionsPercentage[i]对应的是当前级联远平面占视锥体远平面的百分比:

float frustumIntervalBegin, frustumIntervalEnd; float cameraNearFarRange = viewerCamera.GetFarZ() - viewerCamera.GetNearZ(); for (int cascadeIndex = 0; cascadeIndex < m_CascadeLevels; ++cascadeIndex) { // 计算当前级联覆盖的视锥体区间。我们以沿着Z轴最小/最大距离来衡量级联覆盖的区间 if (m_SelectedCascadesFit == FitProjection::FitProjection_ToCascade) { // 因为我们希望让正交投影矩阵在级联周围紧密贴合,我们将最小级联值 // 设置为上一级联的区间末端 if (cascadeIndex == 0) frustumIntervalBegin = 0.0f; else frustumIntervalBegin = m_CascadePartitionsPercentage[cascadeIndex - 1]; } else { // 在FIT_PROJECTION_TO_SCENE中,这些级联相互重叠 // 比如级联1-8覆盖了区间1 // 级联2-8覆盖了区间2 frustumIntervalBegin = 0.0f; } // 算出视锥体Z区间 frustumIntervalEnd = m_CascadePartitionsPercentage[cascadeIndex]; frustumIntervalBegin = frustumIntervalBegin * cameraNearFarRange; frustumIntervalEnd = frustumIntervalEnd * cameraNearFarRange; } 为每个子视锥体计算光照空间下的正交投影

所谓的光照空间,就是假定存在光源一点,以光源来建立观察空间,然后通过正交投影的方式向前方投射来产生shadow map。

通常一个投影正交立方体包括六个要素:TopLeftXTopLeftYWidthHeightNearFar。有了这六个要素,我们就可以在光照空间中定义一个任意轴对齐的立方体(AABB)了,并且可以不受名义上光源一点的限制(尤其对方向光来说,使用光源可以定义一个光照空间)

为了计算视锥体在光照空间下的正交投影,我们需要将子视锥体变换到光照空间中,然后根据子视锥体的八个角点计算出对应的AABB。下图展示了fit to cascade在平面下观察AABB的构建:

XMMATRIX ViewerProj = viewerCamera.GetProjMatrixXM(); XMMATRIX ViewerView = viewerCamera.GetViewMatrixXM(); XMMATRIX LightView = lightCamera.GetViewMatrixXM(); XMMATRIX ViewerInvView = XMMatrixInverse(nullptr, ViewerView); for (int cascadeIndex = 0; cascadeIndex < m_CascadeLevels; ++cascadeIndex) { XMFLOAT3 viewerFrustumPoints[8]; BoundingFrustum viewerFrustum(ViewerProj); viewerFrustum.Near = frustumIntervalBegin; viewerFrustum.Far = frustumIntervalEnd; // 将局部视锥体变换到世界空间后,再变换到光照空间 viewerFrustum.Transform(viewerFrustum, ViewerInvView * LightView); viewerFrustum.GetCorners(viewerFrustumPoints); // 计算视锥体在光照空间下的AABB和vMax, vMin BoundingBox viewerFrustumBox; BoundingBox::CreateFromPoints(viewerFrustumBox, 8, viewerFrustumPoints, sizeof(XMFLOAT3)); lightCameraOrthographicMaxVec = XMLoadFloat3(&viewerFrustumBox.Center) + XMLoadFloat3(&viewerFrustumBox.Extents); lightCameraOrthographicMinVec = XMLoadFloat3(&viewerFrustumBox.Center) - XMLoadFloat3(&viewerFrustumBox.Extents); // ... }

如果我们不考虑那么多的话,现在就已经足够构建出各自的投影矩阵了:

XMStoreFloat4x4(m_ShadowProj + cascadeIndex, XMMatrixOrthographicOffCenterLH( XMVectorGetX(lightCameraOrthographicMinVec), XMVectorGetX(lightCameraOrthographicMaxVec), XMVectorGetY(lightCameraOrthographicMinVec), XMVectorGetY(lightCameraOrthographicMaxVec), XMVectorGetZ(lightCameraOrthographicMinVec), XMVectorGetZ(lightCameraOrthographicMaxVec)); 渲染shadow map

在本样例中,我们使用纹理数组来创建多个shadow maps,这样的话每一个级联只需要共用一个视口。在渲染shadow map时,我们不需要绑定像素着色器。

这部分着色器代码基本上就是沿用31章的内容,故不再重复展示。

渲染场景 选择合适的级联

包含阴影的缓冲区现在可以绑到像素着色器。在本示例中实现了两种选择级联的方法。

基于区间的级联选择(Interval-Based Cascade Selection)

在该方法中,顶点着色器需要计算顶点在世界空间的深度,接着在像素着色器可以拿到插值后的深度。我们根据深度值所落在的区间范围来选择对应的级联。若是fit to scene对应的则是目标级联的远平面内和上一级联的远平面外;若是fit to cascade对应的则是目标级联的近平面和远平面之间。这里我们可以将循环查找的方式改写成使用向量比较和点乘的方式来决定正确的级联。CASCADE_COUNT_FLAG宏用于指定级联的数目。g_CascadeFrustumsEyeSpaceDepthsFloat则是不同级联的远平面Z值。

cbuffer CBCascadedShadow : register(b2) { float4 g_CascadeFrustumsEyeSpaceDepthsFloat[2]; // 不同子视锥体远平面的Z值,将级联分开 float4 g_CascadeOffset[8]; // ShadowPT矩阵的平移量 float4 g_CascadeScale[8]; // ShadowPT矩阵的缩放量 } // Interval-Based Selection currentCascadeIndex = 0; // Depth // /-+-------/----------------/----+-------/----------/ // 0 N F[0] ... F[i] F[i+1] ... F // Depth > F[i] to F[0] => index = i+1 if (CASCADE_COUNT_FLAG > 1) // 当前级联数 { float4 currentPixelDepthVec = depth; float4 cmpVec1 = (currentPixelDepthVec > g_CascadeFrustumsEyeSpaceDepthsFloat[0]); float4 cmpVec2 = (currentPixelDepthVec > g_CascadeFrustumsEyeSpaceDepthsFloat[1]); float index = dot(float4(CASCADE_COUNT_FLAG > 0, CASCADE_COUNT_FLAG > 1, CASCADE_COUNT_FLAG > 2, CASCADE_COUNT_FLAG > 3), cmpVec1) + dot(float4(CASCADE_COUNT_FLAG > 4, CASCADE_COUNT_FLAG > 5, CASCADE_COUNT_FLAG > 6, CASCADE_COUNT_FLAG > 7), cmpVec2); index = min(index, CASCADE_COUNT_FLAG - 1); currentCascadeIndex = (int)index; } shadowMapTexCoord = shadowMapTexCoordViewSpace * g_CascadeScale[currentCascadeIndex] + g_CascadeOffset[currentCascadeIndex];

基于映射的级联选择(Map-Based Cascade Selection)

这种方法将从级联0开始,计算当前级联的投影纹理坐标,然后对级联纹理采样时的4个边界进行测试,若不在边界范围内,则来到级联1继续尝试,直到找到投影纹理坐标位于边界范围内的级联。这样的话,顶点着色器需要为每个级联计算光照空间的位置。像素着色器对每个级联进行迭代,以便于对纹理坐标进行缩放和移动(正交投影),使得其对当前级联进行索引。然后将纹理坐标与纹理边界进行测试。当纹理坐标的X和Y值落在某一个级联内时,它将被用来对纹理进行采样。Z坐标用于最后的深度比较。

cbuffer CBCascadedShadow : register(b2) { float4 g_CascadeFrustumsEyeSpaceDepthsFloat[2]; // 不同子视锥体远平面的Z值,将级联分开 // 对Map-based Selection方案,这将保持有效范围内的像素。 // 当没有边界时,Min和Max分别为0和1 float g_MinBorderPadding; // (kernelSize / 2) / (float)shadowSize float g_MaxBorderPadding; // 1.0f - (kernelSize / 2) / (float)shadowSize } // Map-Based Selection currentCascadeIndex = 0; if (CASCADE_COUNT_FLAG == 1) { shadowMapTexCoord = shadowMapTexCoordViewSpace * g_CascadeScale[0] + g_CascadeOffset[0]; } if (CASCADE_COUNT_FLAG > 1) { // 寻找最近的,使得纹理坐标位于纹理边界内的级联 for (int cascadeIndex = 0; cascadeIndex < CASCADE_COUNT_FLAG && cascadeFound == 0; ++cascadeIndex) { shadowMapTexCoord = shadowMapTexCoordViewSpace * g_CascadeScale[cascadeIndex] + g_CascadeOffset[cascadeIndex]; if (min(shadowMapTexCoord.x, shadowMapTexCoord.y) > g_MinBorderPadding && max(shadowMapTexCoord.x, shadowMapTexCoord.y) < g_MaxBorderPadding) { currentCascadeIndex = cascadeIndex; cascadeFound = 1; } } }

两种方法的对比

基于区间的方法比基于映射的方法稍微快一些,因为前者可以很快完成,而后者的纹理坐标必须与级联的边界相交,但shadow map不完全对齐的时候能更有效地使用级联。

Shadow Map的滤波 PCF

对普通的shadow map进行滤波不会产生柔和、模糊的阴影。滤波硬件模糊了深度值,然后将这些模糊的值与光照空间的texels进行比较。由深度测试产生的硬边缘仍然存在。模糊shadow map只是错误地移动了硬边缘。PCF能够对shadow maps进行滤波。当然PCF采样所用的函数在前面的章节讲过,就不再赘述了。

PCF滤波后的图片,红色像素有25%的像素覆盖率

我们可以在没有硬件支持的情况下进行PCF,或者将PCF扩展到更大的核。有些技术甚至使用加权核进行采样。要做到这一点,需要创建一个NxN的核(如高斯),这些权重的和必须为1,然后对纹理进行N^2次采样。每个样本都被内核中响应的权重所缩放。

Depth Bias

当使用大型PCF核时,给深度加上偏移变得更加重要。它只对一个像素在光照空间的深度与shadow map中对当前像素位置采样的深度进行比较才有效。shadow map的texel的邻居指向的是一个不同的位置。这个深度很可能是相似的,但根据场景的情况可能会有很大的不同。下图展示了所出现的伪影。单一的深度值与shadow map中三个相邻的texel进行比较,其中一次深度测试错误地失败了,因为shadow map对应texel的邻居的深度 与 当前texel所处的光照空间深度 并没有关系,不应该直接比较。对这个问题的一个简易解决方案是使用一个更大的偏移。然而,过大的偏移会导致Peter panning。计算一个紧密的近平面和远平面有助于减少使用偏移的影响。

错误的阴影自遮挡是因为光照空间用于深度比较的像素 与 shadow map中的texels不相关 却直接拿来比较所产生的。光照空间的深度与shadow map中的texel 2相关,texel 1有更大的光照空间深度,texel 2相等,texel 3小于。只有texel 1没有通过测试。

使用DDX和DDY为大PCF核计算Per-Pexel Depth Bias

在使用这项技术有个大前提,就是在假定大部分几何体的较为平坦的情况下,可以计算出正确的depth bias。它使用偏导数信息将深度比较拟合到一个平面。因为这种技术的计算比较复杂,所以只有在GPU有计算周期的情况下才可以使用。当使用非常大的内核时,这可能是唯一可以在不引起Peter Panning的情况下消除自遮蔽伪影的技术。

下图显示了这个问题。对于正在比较的一个texel,其变换到光照空间的深度是已知的。而对于shadow map中,屏幕空间texel的邻居变换到光照空间的深度则是未知的。

左边是渲染的场景,右边是对shadow map采样一个texel及相邻texel的情况。观察空间的texel可以映射到shadow map中的像素D位置进行采样,从而能够与光照空间的深度值进行比较。我们可以对shadow map中,D周围的邻居texel进行采样,但这些texel反过来映射到观察空间的位置和深度则是未知的。只有当我们假设该相邻像素与D同属于一个三角形时,才有可能将相邻的像素映射回观察空间。

这项技术使用ddx和ddy的操作来寻找光照空间位置的偏导数,它返回光照空间深度相对于屏幕空间X、Y方向的梯度。为了将其转换为屏幕空间深度相对于光照空间X、Y方向的梯度,需要计算一个变换矩阵。

以下图为例,栅格表示的是shadow map的像素,黑色点代表观察空间的位置,两个黑色箭头分别对应光照空间深度相对于屏幕空间X、Y方向的梯度(LSP),而两个蓝色箭头对应的分别是屏幕空间深度相对于光照空间X、Y方向的梯度。

图13:屏幕空间与光照空间的相互变换

我们使用光照空间位置对X和Y的偏导数来构建这个矩阵。

cbuffer CBCascadedShadow : register(b2) { float4 g_CascadeOffset[8]; // ShadowPT矩阵的平移量 float4 g_CascadeScale[8]; // ShadowPT矩阵的缩放量 } //-------------------------------------------------------------------------------------- // 为阴影空间的texels计算屏幕空间深度 //-------------------------------------------------------------------------------------- void CalculateRightAndUpTexelDepthDeltas(float3 shadowTexDDX, float3 shadowTexDDY, out float upTextDepthWeight, out float rightTextDepthWeight) { // 这里使用X和Y中的偏导数来计算变换矩阵。我们需要逆矩阵从阴影空间变换到屏幕空间, // 因为这些导数能让我们从屏幕空间变换到阴影空间。新的矩阵允许我们从shadow map的texels // 映射到屏幕空间。这将允许我们寻找对应深度texel用于比较的屏幕空间深度。 // 这不是一个完美的解决方案,因为它假定场景中的几何体是一个平面。 // [TODO]一种更准确的寻找实际深度的方法为:采用延迟渲染并采样shadow map。 // 在大多数情况下,使用偏移或方差阴影贴图是一种更好的、能够减少伪影的方法 // 从屏幕空间变换到阴影空间的矩阵 float2x2 matScreenToShadow = float2x2(shadowTexDDX.xy, shadowTexDDY.xy); float det = determinant(matScreenToShadow); float invDet = 1.0f / det; float2x2 matShadowToScreen = float2x2( matScreenToShadow._22 * invDet, matScreenToShadow._12 * -invDet, matScreenToShadow._21 * -invDet, matScreenToShadow._11 * invDet); float2 rightShadowTexelLocation = float2(g_TexelSize, 0.0f); float2 upShadowTexelLocation = float2(0.0f, g_TexelSize); // 通过阴影空间到屏幕空间的矩阵变换右边的texel float2 rightTexelDepthRatio = mul(rightShadowTexelLocation, matShadowToScreen); float2 upTexelDepthRatio = mul(upShadowTexelLocation, matShadowToScreen); // 我们现在可以计算在shadow map向右和向上移动时,深度的变化值 // 我们使用x方向和y方向变换的比值乘上屏幕空间X和Y深度的导数来计算变化值 upTextDepthWeight = upTexelDepthRatio.x * shadowTexDDX.z + upTexelDepthRatio.y * shadowTexDDY.z; rightTextDepthWeight = rightTexelDepthRatio.x * shadowTexDDX.z + rightTexelDepthRatio.y * shadowTexDDY.z; } float upTextDepthWeight = 0; float rightTextDepthWeight = 0; float3 shadowMapTexCoordDDX; float3 shadowMapTexCoordDDY; // 这些导数用于寻找当前平面的斜率 // 导数的计算必须在循环内部,以阻止流控制分歧的影响 if (USE_DERIVATIVES_FOR_DEPTH_OFFSET_FLAG) { // 计算光照空间的偏导映射到投影纹理空间的变化率 shadowMapTexCoordDDX = ddx(shadowMapTexCoordViewSpace); shadowMapTexCoordDDY = ddy(shadowMapTexCoordViewSpace); shadowMapTexCoordDDX *= g_CascadeScale[currentCascadeIndex]; shadowMapTexCoordDDY *= g_CascadeScale[currentCascadeIndex]; CalculateRightAndUpTexelDepthDeltas(shadowMapTexCoordDDX, shadowMapTexCoordDDY, upTextDepthWeight, rightTextDepthWeight); }

然后我们就可以用这些信息进行PCF滤波了:

//-------------------------------------------------------------------------------------- // 使用PCF采样深度图并返回着色百分比 //-------------------------------------------------------------------------------------- float CalculatePCFPercentLit(int currentCascadeIndex, float4 shadowTexCoord, float rightTexelDepthDelta, float upTexelDepthDelta, float blurRowSize) { float percentLit = 0.0f; // 该循环可以展开,并且如果PCF大小是固定的话,可以使用纹理即时偏移从而改善性能 for (int x = g_PCFBlurForLoopStart; x < g_PCFBlurForLoopEnd; ++x) { for (int y = g_PCFBlurForLoopStart; y < g_PCFBlurForLoopEnd; ++y) { float depthCmp = shadowTexCoord.z; // 一个非常简单的解决PCF深度偏移问题的方案是使用一个偏移值 // 不幸的是,过大的偏移会导致Peter-panning(阴影跑出物体) // 过小的偏移又会导致阴影失真 depthCmp -= g_ShadowBiasFromGUI; if (USE_DERIVATIVES_FOR_DEPTH_OFFSET_FLAG) { depthCmp += rightTexelDepthDelta * (float)x + upTexelDepthDelta * (float)y; } // 将变换后的像素深度同阴影图中的深度进行比较 percentLit += g_TextureShadow.SampleCmpLevelZero(g_SamShadow, float3( shadowTexCoord.x + (float)x * g_TexelSize, shadowTexCoord.y + (float)y * g_TexelSize, (float)currentCascadeIndex ), depthCmp); } } percentLit /= blurRowSize; return percentLit; }

但实际上我们可以发现,在一个复杂场景中,由于有很多地形不平坦的区域都被偏导当成平坦区域来处理,导致计算出的结果有明显的错误:

关键问题出自这里:

if (USE_DERIVATIVES_FOR_DEPTH_OFFSET_FLAG) { depthCmp += rightTexelDepthDelta * (float)x + upTexelDepthDelta * (float)y; }

如果我们能使用深度图,再根据偏移进行采样,应该会比这种方式更为准确。这部分内容可以作为练习题供读者进行尝试。

PCF核的Padding

如果shadow buffer没有进行填充,PCF核就会越界访问。一种办法是,使用PCF核大小的一半来填充级联的外缘。而在当前map-based selection的实现中,仅当纹理的x、y坐标在设置的边界范围内时才能选择当前级联:

// Map-Based Selection currentCascadeIndex = 0; if (CASCADE_COUNT_FLAG == 1) { shadowMapTexCoord = shadowMapTexCoordViewSpace * g_CascadeScale[0] + g_CascadeOffset[0]; } if (CASCADE_COUNT_FLAG > 1) { // 寻找最近的级联,使得纹理坐标位于纹理边界内 // minBoard < tx, ty < maxBoard for (int cascadeIndex = 0; cascadeIndex < CASCADE_COUNT_FLAG && cascadeFound == 0; ++cascadeIndex) { shadowMapTexCoord = shadowMapTexCoordViewSpace * g_CascadeScale[cascadeIndex] + g_CascadeOffset[cascadeIndex]; if (min(shadowMapTexCoord.x, shadowMapTexCoord.y) > g_MinBorderPadding && max(shadowMapTexCoord.x, shadowMapTexCoord.y) < g_MaxBorderPadding) { currentCascadeIndex = cascadeIndex; cascadeFound = 1; } } }

float padding = ((int)kernelSize / 2) / (float)shadowSize; pImpl->m_pEffectHelper->GetConstantBufferVariable("g_MinBorderPadding")->SetFloat(padding); pImpl->m_pEffectHelper->GetConstantBufferVariable("g_MaxBorderPadding")->SetFloat(1.0f - padding);

但随着PCF的增大,padding也变大了,这么做会缩小级联的在世界中的范围。作为代偿,我们考虑可以适当扩大级联的正交立方体,从而抵消这种影响:

// 我们基于PCF核的大小再计算一个边界扩充值使得包围盒稍微放大一些。 float scaleDuetoBlur = m_PCFKernelSize / (float)m_ShadowSize; XMVECTORF32 scaleDuetoBlurVec = { {scaleDuetoBlur, scaleDuetoBlur, 0.0f, 0.0f} }; float normalizeByBufferSize = 1.0f / m_ShadowSize; XMVECTORF32 normalizeByBufferSizeVec = { {normalizeByBufferSize, normalizeByBufferSize, 0.0f, 0.0f} }; XMVECTOR borderOffsetVec = lightCameraOrthographicMaxVec - lightCameraOrthographicMinVec; borderOffsetVec *= g_XMOneHalf; borderOffsetVec *= scaleDuetoBlurVec; lightCameraOrthographicMaxVec += borderOffsetVec; lightCameraOrthographicMinVec -= borderOffsetVec; 阴影抖动&闪烁

现在我们关掉PCF。当我们移动摄像机视角的时候,可以发现会出现阴影闪烁:

导致这种问题出现原因之一在于:移动的时候会导致视锥体产生的AABB大小发生变化,导致shadow map的像素与屏幕空间像素的对应比例关系发生了变化,从而出现了抖动&闪烁。

为此,我们首先可以考虑如何固定视锥体产生的AABB,一种方式是采用视锥体斜对角线的长度作为AABB盒的宽高。然后我们就有了下面的代码:

// Near Far // 0----1 4----5 // | | | | // | | | | // 3----2 7----6 XMVECTOR diagVec = XMLoadFloat3(viewerFrustumPoints + 7) - XMLoadFloat3(viewerFrustumPoints + 1); // 子视锥体的斜对角线 // 找到视锥体对角线的长度作为AABB的宽高 XMVECTOR lengthVec = XMVector3Length(diagVec); // 计算出的偏移量会填充正交投影 XMVECTOR borderOffsetVec = (lengthVec - (lightCameraOrthographicMaxVec - lightCameraOrthographicMinVec)) * g_XMOneHalf; // 我们仅对XY方向进行填充 static const XMVECTORF32 xyzw1100Vec = { {1.0f, 1.0f, 0.0f, 0.0f} }; lightCameraOrthographicMaxVec += borderOffsetVec * xyzw1100Vec; lightCameraOrthographicMinVec -= borderOffsetVec * xyzw1100Vec;

结果跑起来一看,窗口两侧会有一片莫名其妙的阴影,即便是连微软给的程序示例,把分辨率调成1280x720都会有这样的问题:

仔细想想,就会发现如果级联0视锥体的远平面与远平面的距离过近,会导致斜对角线过短,继而生成的AABB盒不能包围整个视锥体。下面画个图感受一下:

为此我们需要把远平面对角线也考虑进来:

// Near Far // 0----1 4----5 // | | | | // | | | | // 3----2 7----6 XMVECTOR diagVec = XMLoadFloat3(viewerFrustumPoints + 7) - XMLoadFloat3(viewerFrustumPoints + 1); // 子视锥体的斜对角线 XMVECTOR diag2Vec = XMLoadFloat3(viewerFrustumPoints + 7) - XMLoadFloat3(viewerFrustumPoints + 5); // 远平面对角线 // 找到较长的对角线作为AABB的宽高 XMVECTOR lengthVec = XMVectorMax(XMVector3Length(diagVec), XMVector3Length(diag2Vec));

虽然CSM的内容都是千篇一律,但这里也算是一个很难让人注意到的问题。

然后当我们移动摄像机的时候,依然会出现抖动&闪烁现象:

仔细观察下图,黑色栅格代表世界空间,蓝色栅格代表光照在世界中产生的投影(宽高相等相当于光源方向竖直向下),一个texel对应一个区块。以粗黑色的区块的中心点进行投影纹理变换与采样,会采样到的shadow map对应蓝色圆点所属texel的深度。

如果我们尝试让摄像机移动一点点,此时阴影投射图在世界中也发生了位移。这时候以粗黑色的区块中心进行投影纹理变换与采样,会采样到下面那个蓝色圆点所属texel的深度。

可以看到两个时刻采样的深度位置不同,会导致采样到的深度值不同,从而出现阴影闪烁的问题。为此,我们可以考虑计算出阴影投射图的每个texel在世界中的宽度W和高度H,然后让阴影投射图的宽高分别为W和H的整数倍,并且只能以整数倍的W或H来进行移动,这样可以保证采样得到的深度值不会发生变动,从而避免了阴影抖动&闪烁的问题。

微软把这项技术称之为:Moving the Light in Texel-Sized Increments

下面的代码展示了这些技术的使用:

if (m_MoveLightTexelSize) { // 计算阴影图中每个texel对应世界空间的宽高,用于后续避免阴影边缘的闪烁 float normalizeByBufferSize = 1.0f / m_ShadowSize; XMVECTORF32 normalizeByBufferSizeVec = { {normalizeByBufferSize, normalizeByBufferSize, 0.0f, 0.0f} }; worldUnitsPerTexelVec = lightCameraOrthographicMaxVec - lightCameraOrthographicMinVec; worldUnitsPerTexelVec *= normalizeByBufferSize; // worldUnitsPerTexel // | | 光照空间 // [x][x][ ] [ ][x][x] x是阴影texel // [x][x][ ] => [ ][x][x] // [ ][ ][ ] [ ][ ][ ] // 在摄像机移动的时候,视锥体在光照空间下的AABB并不会立马跟着移动 // 而是累积到texel对应世界空间的宽高的变化时,AABB才会发生一次texel大小的跃动 // 所以移动摄像机的时候不会出现阴影的抖动 lightCameraOrthographicMinVec /= worldUnitsPerTexelVec; lightCameraOrthographicMinVec = XMVectorFloor(lightCameraOrthographicMinVec); lightCameraOrthographicMinVec *= worldUnitsPerTexelVec; lightCameraOrthographicMaxVec /= worldUnitsPerTexelVec; lightCameraOrthographicMaxVec = XMVectorFloor(lightCameraOrthographicMaxVec); lightCameraOrthographicMaxVec *= worldUnitsPerTexelVec; }

投影近平面与远平面的确定

在前面的代码中我们使用近平面与远平面是从视锥体计算AABB得到的,如果直接这样使用的话,会出现缺乏遮蔽的效果:

左上角的发电厂本应该能对下面的红色区域产生遮蔽,却因为没有在投影立方体的范围内而丢失了深度信息。

一种最简单粗暴的方式就是,让光源位于场景之外,然后近平面设置为接近0,远平面设为一个很大的值。

if (m_SelectedNearFarFit == FitNearFar::FitNearFar_ZeroOne) { nearPlane = 0.1f; farPlane = 10000.0f; }

但很明显整个场景的阴影效果立马就有问题了,原因在于大范围的深度值会使得深度的表示精度降低,为此我们需要想办法将近平面和远平面设置到合适的范围。

一种简单的方式是,将场景AABB的角点变换到光照空间,然后对这些点计算出vMin和vMax,相当于计算新的AABB:

// 将场景AABB的角点变换到光照空间 XMVECTOR sceneAABBPointsLightSpace[8]{}; { XMFLOAT3 corners[8]; sceneBoundingBox.GetCorners(corners); for (int i = 0; i < 8; ++i) { XMVECTOR v = XMLoadFloat3(corners + i); sceneAABBPointsLightSpace[i] = XMVector3Transform(v, LightView); } } if (m_SelectedNearFarFit == FitNearFar::FitNearFar_SceneAABB) { XMVECTOR lightSpaceSceneAABBminValueVec = g_XMFltMax; XMVECTOR lightSpaceSceneAABBmaxValueVec = -g_XMFltMax; // 我们计算光照空间下场景的min max向量 // 其中光照空间AABB的minZ和maxZ可以用于近平面和远平面 // 这比场景与AABB的相交测试简单,在某些情况下也能提供相似的结果 for (int i = 0; i < 8; ++i) { lightSpaceSceneAABBminValueVec = XMVectorMin(sceneAABBPointsLightSpace[i], lightSpaceSceneAABBminValueVec); lightSpaceSceneAABBmaxValueVec = XMVectorMax(sceneAABBPointsLightSpace[i], lightSpaceSceneAABBmaxValueVec); } nearPlane = XMVectorGetZ(lightSpaceSceneAABBminValueVec); farPlane = XMVectorGetZ(lightSpaceSceneAABBmaxValueVec); }

可以看到此时的效果已经比较理想了:

然而在最坏的情况下,可能会导致深度缓冲区的精度大幅下降。下图可以看到,由于光源的倾斜程度较大,导致近平面与远平面的范围比所需要的大了四倍。

如果想要更加精细的远平面和近平面,则需要对光照空间下的视锥体AABB 与 变换到光照空间的场景AABB 进行相交测试。为此我们需要用到三角形的剔除算法。

假设有一个待裁剪的三角形,我们需要对包围盒的四个边界以迭代的方式执行边界测试。现在对于左边界,有2个点位于边界外,此时可以找到与边界的2个交点,然后将三角形坍缩。接着对于上边界,发现有2个点在边界内部,此时可以找到与边界外的两个交点,以某种规则产生出一个新的三角形,然后这两个三角形参与后续边界的测试。对于右边界,1号三角形的三个点都在边界内,可以保留,而2号三角形有2个点在边界内,故继续坍缩并产生新的三角形。产生的3个三角形与下边界进行测试发现都在内部,不需要操作。最终得到的就是裁剪后的三角形集合。

// 通过光照空间下视锥体的AABB 与 变换到光照空间的场景AABB 的相交测试,我们可以得到一个更紧密的近平面和远平面 ComputeNearAndFar(nearPlane, farPlane, lightCameraOrthographicMinVec, lightCameraOrthographicMaxVec, sceneAABBPointsLightSpace); void XM_CALLCONV ComputeNearAndFar( float& outNearPlane, float& outFarPlane, FXMVECTOR lightCameraOrthographicMinVec, FXMVECTOR lightCameraOrthographicMaxVec, XMVECTOR pointsInCameraView[]) { // 核心思想 // 1. 对AABB的所有12个三角形进行迭代 // 2. 每个三角形分别对正交投影的4个侧面进行裁剪。裁剪过程中可能会出现这些情况: // - 0个点在该侧面的内部,该三角形可以剔除 // - 1个点在该侧面的内部,计算该点与另外两个点在侧面上的交点得到新三角形 // - 2个点在该侧面的内部,计算这两个点与另一个点在侧面上的交点,分裂得到2个新三角形 // - 3个点都在该侧面的内部 // 遍历中的三角形与新生产的三角形都要进行剩余侧面的裁剪 // 3. 在这些三角形中找到最小/最大的Z值作为近平面/远平面 outNearPlane = FLT_MAX; outFarPlane = -FLT_MAX; Triangle triangleList[16]{}; int numTriangles; // 4----5 // /| /| // 0-+--1 | // | 7--|-6 // |/ |/ // 3----2 static const int all_indices[][3] = { {4,7,6}, {6,5,4}, {5,6,2}, {2,1,5}, {1,2,3}, {3,0,1}, {0,3,7}, {7,4,0}, {7,3,2}, {2,6,7}, {0,4,5}, {5,1,0} }; bool triPointPassCollision[3]{}; const float minX = XMVectorGetX(lightCameraOrthographicMinVec); const float maxX = XMVectorGetX(lightCameraOrthographicMaxVec); const float minY = XMVectorGetY(lightCameraOrthographicMinVec); const float maxY = XMVectorGetY(lightCameraOrthographicMaxVec); for (auto& indices : all_indices) { triangleList[0].point[0] = pointsInCameraView[indices[0]]; triangleList[0].point[1] = pointsInCameraView[indices[1]]; triangleList[0].point[2] = pointsInCameraView[indices[2]]; numTriangles = 1; triangleList[0].isCulled = false; // 每个三角形都需要对4个视锥体侧面进行裁剪 for (int planeIdx = 0; planeIdx < 4; ++planeIdx) { float edge; int component; switch (planeIdx) { case 0: edge = minX; component = 0; break; case 1: edge = maxX; component = 0; break; case 2: edge = minY; component = 1; break; case 3: edge = maxY; component = 1; break; default: break; } for (int triIdx = 0; triIdx < numTriangles; ++triIdx) { // 跳过裁剪的三角形 if (triangleList[triIdx].isCulled) continue; int insideVertexCount = 0; for (int triVtxIdx = 0; triVtxIdx < 3; ++triVtxIdx) { switch (planeIdx) { case 0: triPointPassCollision[triVtxIdx] = (XMVectorGetX(triangleList[triIdx].point[triVtxIdx]) > minX); break; case 1: triPointPassCollision[triVtxIdx] = (XMVectorGetX(triangleList[triIdx].point[triVtxIdx]) < maxX); break; case 2: triPointPassCollision[triVtxIdx] = (XMVectorGetY(triangleList[triIdx].point[triVtxIdx]) > minY); break; case 3: triPointPassCollision[triVtxIdx] = (XMVectorGetY(triangleList[triIdx].point[triVtxIdx]) < maxY); break; default: break; } insideVertexCount += triPointPassCollision[triVtxIdx]; } // 将通过视锥体测试的点挪到数组前面 if (triPointPassCollision[1] && !triPointPassCollision[0]) { std::swap(triangleList[triIdx].point[0], triangleList[triIdx].point[1]); triPointPassCollision[0] = true; triPointPassCollision[1] = false; } if (triPointPassCollision[2] && !triPointPassCollision[1]) { std::swap(triangleList[triIdx].point[1], triangleList[triIdx].point[2]); triPointPassCollision[1] = true; triPointPassCollision[2] = false; } if (triPointPassCollision[1] && !triPointPassCollision[0]) { std::swap(triangleList[triIdx].point[0], triangleList[triIdx].point[1]); triPointPassCollision[0] = true; triPointPassCollision[1] = false; } // 裁剪测试 triangleList[triIdx].isCulled = (insideVertexCount == 0); if (insideVertexCount == 1) { // 找出三角形与当前平面相交的另外两个点 XMVECTOR v0v1Vec = triangleList[triIdx].point[1] - triangleList[triIdx].point[0]; XMVECTOR v0v2Vec = triangleList[triIdx].point[2] - triangleList[triIdx].point[0]; float hitPointRatio = edge - XMVectorGetByIndex(triangleList[triIdx].point[0], component); float distAlong_v0v1 = hitPointRatio / XMVectorGetByIndex(v0v1Vec, component); float distAlong_v0v2 = hitPointRatio / XMVectorGetByIndex(v0v2Vec, component); v0v1Vec = distAlong_v0v1 * v0v1Vec + triangleList[triIdx].point[0]; v0v2Vec = distAlong_v0v2 * v0v2Vec + triangleList[triIdx].point[0]; triangleList[triIdx].point[1] = v0v2Vec; triangleList[triIdx].point[2] = v0v1Vec; } else if (insideVertexCount == 2) { // 裁剪后需要分开成两个三角形 // 把当前三角形后面的三角形(如果存在的话)复制出来,这样 // 我们就可以用算出来的新三角形覆盖它 triangleList[numTriangles] = triangleList[triIdx + 1]; triangleList[triIdx + 1].isCulled = false; // 找出三角形与当前平面相交的另外两个点 XMVECTOR v2v0Vec = triangleList[triIdx].point[0] - triangleList[triIdx].point[2]; XMVECTOR v2v1Vec = triangleList[triIdx].point[1] - triangleList[triIdx].point[2]; float hitPointRatio = edge - XMVectorGetByIndex(triangleList[triIdx].point[2], component); float distAlong_v2v0 = hitPointRatio / XMVectorGetByIndex(v2v0Vec, component); float distAlong_v2v1 = hitPointRatio / XMVectorGetByIndex(v2v1Vec, component); v2v0Vec = distAlong_v2v0 * v2v0Vec + triangleList[triIdx].point[2]; v2v1Vec = distAlong_v2v1 * v2v1Vec + triangleList[triIdx].point[2]; // 添加三角形 triangleList[triIdx + 1].point[0] = triangleList[triIdx].point[0]; triangleList[triIdx + 1].point[1] = triangleList[triIdx].point[1]; triangleList[triIdx + 1].point[2] = v2v0Vec; triangleList[triIdx].point[0] = triangleList[triIdx + 1].point[1]; triangleList[triIdx].point[1] = triangleList[triIdx + 1].point[2]; triangleList[triIdx].point[2] = v2v1Vec; // 添加三角形数目,跳过我们刚插入的三角形 ++numTriangles; ++triIdx; } } } for (int triIdx = 0; triIdx < numTriangles; ++triIdx) { if (!triangleList[triIdx].isCulled) { for (int vtxIdx = 0; vtxIdx < 3; ++vtxIdx) { float z = XMVectorGetZ(triangleList[triIdx].point[vtxIdx]); outNearPlane = (std::min)(outNearPlane, z); outFarPlane = (std::max)(outFarPlane, z); } } } } }

现在我们可以看到比前面的方法产生的阴影又稍微细致了一点。

在级联之间混合

VSMs(方差阴影贴图,本章不会提及)和滤波技术(如PCF)可以用于低分辨率的CSMs产生软阴影。不幸的是,这会导致级联层之间出现明显的接缝,因为分辨率不匹配。解决的办法是:在两个级联之间确定一片边界区域,在这片区域对两个级联进行PCF计算。然后,着色器根据像素在边界区域中的位置,在这两个值之间进行线性插值。在本样例中提供了对应的操作UI,可以用来尝试增加和减少这个模糊带。

(左图)级联重叠的时候可以看到一个可接缝,(右图)当级联间进行混合时,接缝被模糊掉了。

除了阴影接缝,在开启大PCF核的时候,还会出现这样的问题,但我们依然可以用级联间混合的方式解决:

Interval-Based Blend

cbuffer CBCascadedShadow : register(b2) { float4 g_CascadeFrustumsEyeSpaceDepthsFloat4[8];// 以float4花费额外空间的形式使得可以数组遍历,yzw分量无用 float g_CascadeBlendArea; // 级联之间重叠量时的混合区域 } //-------------------------------------------------------------------------------------- // 计算两个级联之间的混合量 及 混合将会发生的区域 //-------------------------------------------------------------------------------------- void CalculateBlendAmountForInterval(int currentCascadeIndex, inout float pixelDepth, inout float currentPixelsBlendBandLocation, out float blendBetweenCascadesAmount) { // pixelDepth // |<- ->| // /-+-------/----------+------/-------- // 0 N F[0] F[i] // |<-blendInterval->| // blendBandLocation = 1 - depth/F[0] or // blendBandLocation = 1 - (depth-F[0]) / (F[i]-F[0]) // blendBandLocation位于[0, g_CascadeBlendArea]时,进行[0, 1]的过渡 // 我们需要计算当前shadow map的边缘地带,在那里将会淡化到下一个级联 // 然后我们就可以提前脱离开销昂贵的PCF for循环 float blendInterval = g_CascadeFrustumsEyeSpaceDepthsFloat4[currentCascadeIndex].x; // 对原项目中这部分代码进行了修正 if (currentCascadeIndex > 0) { int blendIntervalbelowIndex = currentCascadeIndex - 1; pixelDepth -= g_CascadeFrustumsEyeSpaceDepthsFloat4[blendIntervalbelowIndex].x; blendInterval -= g_CascadeFrustumsEyeSpaceDepthsFloat4[blendIntervalbelowIndex].x; } // 当前像素的混合地带的位置 currentPixelsBlendBandLocation = 1.0f - pixelDepth / blendInterval; // blendBetweenCascadesAmount用于最终的阴影色插值 blendBetweenCascadesAmount = currentPixelsBlendBandLocation / g_CascadeBlendArea; }

Map-Based Blend

//-------------------------------------------------------------------------------------- // 计算两个级联之间的混合量 及 混合将会发生的区域 //-------------------------------------------------------------------------------------- void CalculateBlendAmountForMap(float4 shadowMapTexCoord, inout float currentPixelsBlendBandLocation, inout float blendBetweenCascadesAmount) { // _____________________ // | map[i+1] | // | | // | 0_______0 | // |______| map[i]|______| // | 0.5 | // |_______| // 0 0 // blendBandLocation = min(tx, ty, 1-tx, 1-ty); // blendBandLocation位于[0, g_CascadeBlendArea]时,进行[0, 1]的过渡 float2 distanceToOne = float2(1.0f - shadowMapTexCoord.x, 1.0f - shadowMapTexCoord.y); currentPixelsBlendBandLocation = min(shadowMapTexCoord.x, shadowMapTexCoord.y); float currentPixelsBlendBandLocation2 = min(distanceToOne.x, distanceToOne.y); currentPixelsBlendBandLocation = min(currentPixelsBlendBandLocation, currentPixelsBlendBandLocation2); blendBetweenCascadesAmount = currentPixelsBlendBandLocation / g_CascadeBlendArea; }

实际混合代码

// // 在两个级联之间进行混合 // if (BLEND_BETWEEN_CASCADE_LAYERS_FLAG) { // 为下一个级联重复进行投影纹理坐标的计算 // 下一级联的索引用于在两个级联之间模糊 nextCascadeIndex = min(CASCADE_COUNT_FLAG - 1, currentCascadeIndex + 1); } blendBetweenCascadesAmount = 1.0f; float currentPixelsBlendBandLocation = 1.0f; if (SELECT_CASCADE_BY_INTERVAL_FLAG) { if (BLEND_BETWEEN_CASCADE_LAYERS_FLAG && CASCADE_COUNT_FLAG > 1) { CalculateBlendAmountForInterval(currentCascadeIndex, currentPixelDepth, currentPixelsBlendBandLocation, blendBetweenCascadesAmount); } } else { if (BLEND_BETWEEN_CASCADE_LAYERS_FLAG) { CalculateBlendAmountForMap(shadowMapTexCoord, currentPixelsBlendBandLocation, blendBetweenCascadesAmount); } } if (BLEND_BETWEEN_CASCADE_LAYERS_FLAG && CASCADE_COUNT_FLAG > 1) { if (currentPixelsBlendBandLocation < g_CascadeBlendArea) { // 计算下一级联的投影纹理坐标 shadowMapTexCoord_blend = shadowMapTexCoordViewSpace * g_CascadeScale[nextCascadeIndex] + g_CascadeOffset[nextCascadeIndex]; // 在级联之间混合时,为下一级联也进行计算 if (currentPixelsBlendBandLocation < g_CascadeBlendArea) { // 当前像素在混合地带内 if (USE_DERIVATIVES_FOR_DEPTH_OFFSET_FLAG) { CalculateRightAndUpTexelDepthDeltas(shadowMapTexCoordDDX, shadowMapTexCoordDDY, upTextDepthWeight_blend, rightTextDepthWeight_blend); } percentLit_blend = CalculatePCFPercentLit(nextCascadeIndex, shadowMapTexCoord_blend, rightTextDepthWeight_blend, upTextDepthWeight_blend, blurSize); // 对两个级联的PCF混合 percentLit = lerp(percentLit_blend, percentLit, blendBetweenCascadesAmount); } } } 演示

为了本演示,我把微软样例中的powerplant.sdkmesh模型想办法转成了obj文件。不得不吐槽虽然很多格式都能转成sdkmesh,但没有一个能从sdkmesh转成obj或别的格式的,网上的sdkmesh-to-obj就没有一个靠谱的,最后靠着在微软示例的渲染代码里强行输出一个obj文件才搞定。

然后是一些功能说明:

Debug Shadow:打开shadow map调试窗口

Depth Offset:PCF的深度偏移

Cascade Blur:混合区域的大小及开关

DDX, DDY offset:感觉没什么用的偏导深度偏移

Fixed Size Frustum AABB:固定视锥体AABB的宽高,避免摄像机旋转时阴影闪烁

Fit Light to Texels:整数倍texel world unit的AABB及移动,避免移动时阴影闪烁

Fit Projection:级联的投影模式

Camera:支持主摄像机、光照摄像机改变光照方向

Near Far:计算近平面、远平面的方式

Selection:级联选择算法

光照摄像机效果:

级联可视化:

可以说级联阴影要面临非常多的问题,这些坑也是不踩不知道,一踩就是一个接一个的来。下一章的内容还是阴影相关。

参考

最主要参考自下面两个文章:

Cascade Shadow Maps--MSDN

Common Techniques to Improve Shadow Depth Maps--MSDN

知乎、CSDN上关于CSM的文章,这里就不逐一列举了。

参考项目:

CascadedShadowMaps11

练习题
  1. 在PCF中,尝试使用深度图读取相邻shadow map texel对应位置的深度用作比较

DirectX11 With Windows SDK完整目录

欢迎加入QQ群: 727623616 可以一起探讨DX11,以及有什么问题也可以在这里汇报。

本文共计9585个文字,预计阅读时间需要39分钟。

如何使用Windows SDK和DirectX11实现38级联阴影贴图(CSM)?

前言:在31章中,我们曾实现过shadow mapping,但受限于阴影贴图精度,只能在场景中相对有限的范围内投射阴影。本章我们将以微软提供的示例和博客作为切入点,学习如何解决阴影问题。

前言

在31章我们曾经实现过shadow mapping,但是受到阴影贴图精度的限制,只能在场景中相当有限的范围内投射阴影。本章我们将以微软提供的例子和博客作为切入点,学习如何解决阴影中出现的Atrifacts:

  • 边缘闪烁&抖动
  • 阴影接缝
  • 阴影缺失
  • perspective aliasing
  • projective aliasing

并且我们将会学习到如何使用级联阴影贴图(CSMs)。具体包括:

  • 解释CSMs的复杂性
  • 给出CSM算法可能变化的细节
  • 识别并解决一些向CSMs添加滤波相关的常见陷阱

然后在下一章,我们可能会讨论更多提升阴影质量、提升效率的技术,如PCSS、VSM等。

现在假定读者已经读过下面的内容:

章节 31 阴影映射

DirectX11 With Windows SDK完整目录

欢迎加入QQ群: 727623616 可以一起探讨DX11,以及有什么问题也可以在这里汇报。

Shadow Map Artifacts Perspective Aliasing

阴影贴图的Perspective Aliasing(这个词我无法给出好的翻译)是其中一个最难解决的问题之一。具体指的是在摄像机的近处,场景中物体的分辨率比较高,即便是一个较小的三角形面片也会对应大量的像素,但阴影贴图的精度并不会发生改变,导致有大量的像素对应shadow map中的同一点。如下图所示。当观察空间中的texel与shadow map中的texel不是一比一比例时就会发生。这是因为靠近 近平面的像素需要更高的shadow map的分辨率。

虽然可以让shadow map的分辨率可以提高,从而降低Perspective Aliasing的影响,但可能会带来严重的性能问题。当摄像机移动到与几何体非常近的地方时,这些靠近摄像机的像素需要非常高的精度以至于连8192x8192的shadow map都会产生锯齿。

使用PCF、PCSS等滤波算法来产生模糊效果,从而减轻这种锯齿感。对于不同距离下的深度,可以采用CSM来适配不同距离的阴影。

Projective Aliasing

Projective Aliasing比前面的问题更难展示。下图左侧可以看到投影引发的错误。当表面法线与光线接近正交时,就会发生这种现象。在shadow map中它只能表示某个深度值,但实际场景几何有着大段的深度值变化,容易导致采样出错。

通常尝试缓解Perspective Aliasing的算法也能够减轻Projective Aliasing带来的影响。

级联阴影

CSMs的概念其实比较好理解,视锥体的不同区域需要不同分辨率的shadow map,靠近摄像机的对象需要的精度比远离摄像机的更高一些。但实际上,当摄像机移动到与几何体非常近的地方时,这些靠近摄像机的像素需要非常高的精度以至于连8192x8192的shadow map都不够充足。

CSMs的基本思想是将视锥体分割成多个子视锥体,然后为每个子视锥体渲染不同的shadow map,接着像素着色器从最接近匹配所需精度的shadow map采样。

如何使用Windows SDK和DirectX11实现38级联阴影贴图(CSM)?

上图代表shadow map的一系列栅格 与 视锥体 显示了不同分辨率的shadow map对像素的覆盖影响。当光照空间的像素与阴影贴图的texels的比例为1:1时,阴影的质量是最高的(白色像素)。当太多像素映射到同一个阴影texel时,就会出现Perspective aliasing,即大块的纹理映射(左图)。当shadow map过大时,就会出现欠采样现象,这时候texels会被略过,出现闪烁的artifacts,性能也会受到影响。

上图展示了前图中的每个shadow map中质量最高部分的切割。从技术上说,白色和灰色用来表示级联阴影的成功。白色区域是理想的,因为它显示了良好的覆盖率——观察空间像素和shadow map texels的比例为1:1

在每一帧中,CSM需要完成下面的步骤:

  1. 将视锥体划分成多个子视锥体

  2. 为每个子视锥体计算光照空间下的正交投影

  3. 为每个子视锥体渲染shadow map

  4. 渲染场景

    1. 绑定shadow maps并渲染
    2. 顶点着色器完成下述内容
      • 为每个子视锥体计算光照空间投影纹理坐标(或者在像素着色器完成投影纹理坐标的计算)
      • 顶点变换
    3. 像素着色器完成下述内容
      • 决定合适的级联阴影
      • 必要时变换纹理坐标
      • 采样级联
      • 照亮该像素
视锥体划分

划分视锥体实际上是创建多个子视锥体。一种划分视锥体的方式是沿着Z的方向计算0%到100%范围内表示的区间,然后百分比的下界和上界分别表示子视锥体的近平面/远平面

在实践中,每一帧重新计算这些视锥体会导致阴影边缘的闪烁。普遍接受的做法是,每个场景使用一组静态的级联区间。在这种情况下,沿Z轴的区间用于描述一个在划分视锥体时出现的子视锥体。为一个给定的场景确定合适的区间取决于下面几个因素。

场景几何体的方向

就常见几何体来说,摄像机的方向会影响级联间隔的选择。例如,非常靠近地面的摄像机,如足球比赛中的地面摄像机,与天空中的摄像机有不同的静态级联区间集。

下图展示了不同的摄像机和各自的划分。当场景的Z-range非常大,需要进行更多的视锥体划分。例如,当眼睛离地面很近的时候,远处的物体依然可以看到,这时候就需要多重级联。划分视锥体使得更多在摄像机附近的分区(perspective aliasing变化最快的地方)也是有价值的。当大部分几何体聚集在视锥体的一小部分(如俯瞰图或飞行模拟器)时,需要的级联较少

不同的配置需要不同的视锥体划分

(左图)几何体有着Z的高动态范围时需要用到很多级联。(中图)低动态范围的Z不需要用到多级联。(右图)一般动态范围的Z只需要中等数量的级联

光源与摄像机的方向

每个级联投影矩阵都紧紧地围绕着其相应的子视锥体进行拟合。在摄像机和光源方向正交的情况,级联的排布可以非常紧密,没有什么重叠。但当光源方向与摄像机观察方向越接近平行,重叠的区域随着也会变大。直到逐渐接近平行时,就会产生dueling frusta现象,对大多数算法来说这是一个非常难处理的情况。对光照和摄像机进行约束使得这种现象不会发生的情况是很常见的,而CSM在这种情况下的表现比其他许多算法好得多。

下图展示了级联重叠随着光路与摄像机的逐渐平行而增加

许多CSM的实现使用了固定大小的视锥。在视锥体被分割成固定大小数目的区间时,像素着色器可以使用深度值来索引级联数组。

子视锥体的创建

一旦选择了视锥体的分段,我们有两种方式创建子视锥体:一个贴合场景(fit to scene)、另一个贴合级联(fit to cascade)

Fit to Scene:所有视锥体使用相同的近平面来创建。这会使得级联强制重叠

Fit to Cascade:所有的视锥体可以通过近平面与远平面进行区间划分的方式来创建。这会导致紧密贴合,但在出现dueling frusta现象时会退化成Fit to Scene。

左 Fit to scene vs. 右 Fit to cascade

下面的代码确定了不同子视锥体的近平面和远平面,m_CascadePartitionsPercentage[i]对应的是当前级联远平面占视锥体远平面的百分比:

float frustumIntervalBegin, frustumIntervalEnd; float cameraNearFarRange = viewerCamera.GetFarZ() - viewerCamera.GetNearZ(); for (int cascadeIndex = 0; cascadeIndex < m_CascadeLevels; ++cascadeIndex) { // 计算当前级联覆盖的视锥体区间。我们以沿着Z轴最小/最大距离来衡量级联覆盖的区间 if (m_SelectedCascadesFit == FitProjection::FitProjection_ToCascade) { // 因为我们希望让正交投影矩阵在级联周围紧密贴合,我们将最小级联值 // 设置为上一级联的区间末端 if (cascadeIndex == 0) frustumIntervalBegin = 0.0f; else frustumIntervalBegin = m_CascadePartitionsPercentage[cascadeIndex - 1]; } else { // 在FIT_PROJECTION_TO_SCENE中,这些级联相互重叠 // 比如级联1-8覆盖了区间1 // 级联2-8覆盖了区间2 frustumIntervalBegin = 0.0f; } // 算出视锥体Z区间 frustumIntervalEnd = m_CascadePartitionsPercentage[cascadeIndex]; frustumIntervalBegin = frustumIntervalBegin * cameraNearFarRange; frustumIntervalEnd = frustumIntervalEnd * cameraNearFarRange; } 为每个子视锥体计算光照空间下的正交投影

所谓的光照空间,就是假定存在光源一点,以光源来建立观察空间,然后通过正交投影的方式向前方投射来产生shadow map。

通常一个投影正交立方体包括六个要素:TopLeftXTopLeftYWidthHeightNearFar。有了这六个要素,我们就可以在光照空间中定义一个任意轴对齐的立方体(AABB)了,并且可以不受名义上光源一点的限制(尤其对方向光来说,使用光源可以定义一个光照空间)

为了计算视锥体在光照空间下的正交投影,我们需要将子视锥体变换到光照空间中,然后根据子视锥体的八个角点计算出对应的AABB。下图展示了fit to cascade在平面下观察AABB的构建:

XMMATRIX ViewerProj = viewerCamera.GetProjMatrixXM(); XMMATRIX ViewerView = viewerCamera.GetViewMatrixXM(); XMMATRIX LightView = lightCamera.GetViewMatrixXM(); XMMATRIX ViewerInvView = XMMatrixInverse(nullptr, ViewerView); for (int cascadeIndex = 0; cascadeIndex < m_CascadeLevels; ++cascadeIndex) { XMFLOAT3 viewerFrustumPoints[8]; BoundingFrustum viewerFrustum(ViewerProj); viewerFrustum.Near = frustumIntervalBegin; viewerFrustum.Far = frustumIntervalEnd; // 将局部视锥体变换到世界空间后,再变换到光照空间 viewerFrustum.Transform(viewerFrustum, ViewerInvView * LightView); viewerFrustum.GetCorners(viewerFrustumPoints); // 计算视锥体在光照空间下的AABB和vMax, vMin BoundingBox viewerFrustumBox; BoundingBox::CreateFromPoints(viewerFrustumBox, 8, viewerFrustumPoints, sizeof(XMFLOAT3)); lightCameraOrthographicMaxVec = XMLoadFloat3(&viewerFrustumBox.Center) + XMLoadFloat3(&viewerFrustumBox.Extents); lightCameraOrthographicMinVec = XMLoadFloat3(&viewerFrustumBox.Center) - XMLoadFloat3(&viewerFrustumBox.Extents); // ... }

如果我们不考虑那么多的话,现在就已经足够构建出各自的投影矩阵了:

XMStoreFloat4x4(m_ShadowProj + cascadeIndex, XMMatrixOrthographicOffCenterLH( XMVectorGetX(lightCameraOrthographicMinVec), XMVectorGetX(lightCameraOrthographicMaxVec), XMVectorGetY(lightCameraOrthographicMinVec), XMVectorGetY(lightCameraOrthographicMaxVec), XMVectorGetZ(lightCameraOrthographicMinVec), XMVectorGetZ(lightCameraOrthographicMaxVec)); 渲染shadow map

在本样例中,我们使用纹理数组来创建多个shadow maps,这样的话每一个级联只需要共用一个视口。在渲染shadow map时,我们不需要绑定像素着色器。

这部分着色器代码基本上就是沿用31章的内容,故不再重复展示。

渲染场景 选择合适的级联

包含阴影的缓冲区现在可以绑到像素着色器。在本示例中实现了两种选择级联的方法。

基于区间的级联选择(Interval-Based Cascade Selection)

在该方法中,顶点着色器需要计算顶点在世界空间的深度,接着在像素着色器可以拿到插值后的深度。我们根据深度值所落在的区间范围来选择对应的级联。若是fit to scene对应的则是目标级联的远平面内和上一级联的远平面外;若是fit to cascade对应的则是目标级联的近平面和远平面之间。这里我们可以将循环查找的方式改写成使用向量比较和点乘的方式来决定正确的级联。CASCADE_COUNT_FLAG宏用于指定级联的数目。g_CascadeFrustumsEyeSpaceDepthsFloat则是不同级联的远平面Z值。

cbuffer CBCascadedShadow : register(b2) { float4 g_CascadeFrustumsEyeSpaceDepthsFloat[2]; // 不同子视锥体远平面的Z值,将级联分开 float4 g_CascadeOffset[8]; // ShadowPT矩阵的平移量 float4 g_CascadeScale[8]; // ShadowPT矩阵的缩放量 } // Interval-Based Selection currentCascadeIndex = 0; // Depth // /-+-------/----------------/----+-------/----------/ // 0 N F[0] ... F[i] F[i+1] ... F // Depth > F[i] to F[0] => index = i+1 if (CASCADE_COUNT_FLAG > 1) // 当前级联数 { float4 currentPixelDepthVec = depth; float4 cmpVec1 = (currentPixelDepthVec > g_CascadeFrustumsEyeSpaceDepthsFloat[0]); float4 cmpVec2 = (currentPixelDepthVec > g_CascadeFrustumsEyeSpaceDepthsFloat[1]); float index = dot(float4(CASCADE_COUNT_FLAG > 0, CASCADE_COUNT_FLAG > 1, CASCADE_COUNT_FLAG > 2, CASCADE_COUNT_FLAG > 3), cmpVec1) + dot(float4(CASCADE_COUNT_FLAG > 4, CASCADE_COUNT_FLAG > 5, CASCADE_COUNT_FLAG > 6, CASCADE_COUNT_FLAG > 7), cmpVec2); index = min(index, CASCADE_COUNT_FLAG - 1); currentCascadeIndex = (int)index; } shadowMapTexCoord = shadowMapTexCoordViewSpace * g_CascadeScale[currentCascadeIndex] + g_CascadeOffset[currentCascadeIndex];

基于映射的级联选择(Map-Based Cascade Selection)

这种方法将从级联0开始,计算当前级联的投影纹理坐标,然后对级联纹理采样时的4个边界进行测试,若不在边界范围内,则来到级联1继续尝试,直到找到投影纹理坐标位于边界范围内的级联。这样的话,顶点着色器需要为每个级联计算光照空间的位置。像素着色器对每个级联进行迭代,以便于对纹理坐标进行缩放和移动(正交投影),使得其对当前级联进行索引。然后将纹理坐标与纹理边界进行测试。当纹理坐标的X和Y值落在某一个级联内时,它将被用来对纹理进行采样。Z坐标用于最后的深度比较。

cbuffer CBCascadedShadow : register(b2) { float4 g_CascadeFrustumsEyeSpaceDepthsFloat[2]; // 不同子视锥体远平面的Z值,将级联分开 // 对Map-based Selection方案,这将保持有效范围内的像素。 // 当没有边界时,Min和Max分别为0和1 float g_MinBorderPadding; // (kernelSize / 2) / (float)shadowSize float g_MaxBorderPadding; // 1.0f - (kernelSize / 2) / (float)shadowSize } // Map-Based Selection currentCascadeIndex = 0; if (CASCADE_COUNT_FLAG == 1) { shadowMapTexCoord = shadowMapTexCoordViewSpace * g_CascadeScale[0] + g_CascadeOffset[0]; } if (CASCADE_COUNT_FLAG > 1) { // 寻找最近的,使得纹理坐标位于纹理边界内的级联 for (int cascadeIndex = 0; cascadeIndex < CASCADE_COUNT_FLAG && cascadeFound == 0; ++cascadeIndex) { shadowMapTexCoord = shadowMapTexCoordViewSpace * g_CascadeScale[cascadeIndex] + g_CascadeOffset[cascadeIndex]; if (min(shadowMapTexCoord.x, shadowMapTexCoord.y) > g_MinBorderPadding && max(shadowMapTexCoord.x, shadowMapTexCoord.y) < g_MaxBorderPadding) { currentCascadeIndex = cascadeIndex; cascadeFound = 1; } } }

两种方法的对比

基于区间的方法比基于映射的方法稍微快一些,因为前者可以很快完成,而后者的纹理坐标必须与级联的边界相交,但shadow map不完全对齐的时候能更有效地使用级联。

Shadow Map的滤波 PCF

对普通的shadow map进行滤波不会产生柔和、模糊的阴影。滤波硬件模糊了深度值,然后将这些模糊的值与光照空间的texels进行比较。由深度测试产生的硬边缘仍然存在。模糊shadow map只是错误地移动了硬边缘。PCF能够对shadow maps进行滤波。当然PCF采样所用的函数在前面的章节讲过,就不再赘述了。

PCF滤波后的图片,红色像素有25%的像素覆盖率

我们可以在没有硬件支持的情况下进行PCF,或者将PCF扩展到更大的核。有些技术甚至使用加权核进行采样。要做到这一点,需要创建一个NxN的核(如高斯),这些权重的和必须为1,然后对纹理进行N^2次采样。每个样本都被内核中响应的权重所缩放。

Depth Bias

当使用大型PCF核时,给深度加上偏移变得更加重要。它只对一个像素在光照空间的深度与shadow map中对当前像素位置采样的深度进行比较才有效。shadow map的texel的邻居指向的是一个不同的位置。这个深度很可能是相似的,但根据场景的情况可能会有很大的不同。下图展示了所出现的伪影。单一的深度值与shadow map中三个相邻的texel进行比较,其中一次深度测试错误地失败了,因为shadow map对应texel的邻居的深度 与 当前texel所处的光照空间深度 并没有关系,不应该直接比较。对这个问题的一个简易解决方案是使用一个更大的偏移。然而,过大的偏移会导致Peter panning。计算一个紧密的近平面和远平面有助于减少使用偏移的影响。

错误的阴影自遮挡是因为光照空间用于深度比较的像素 与 shadow map中的texels不相关 却直接拿来比较所产生的。光照空间的深度与shadow map中的texel 2相关,texel 1有更大的光照空间深度,texel 2相等,texel 3小于。只有texel 1没有通过测试。

使用DDX和DDY为大PCF核计算Per-Pexel Depth Bias

在使用这项技术有个大前提,就是在假定大部分几何体的较为平坦的情况下,可以计算出正确的depth bias。它使用偏导数信息将深度比较拟合到一个平面。因为这种技术的计算比较复杂,所以只有在GPU有计算周期的情况下才可以使用。当使用非常大的内核时,这可能是唯一可以在不引起Peter Panning的情况下消除自遮蔽伪影的技术。

下图显示了这个问题。对于正在比较的一个texel,其变换到光照空间的深度是已知的。而对于shadow map中,屏幕空间texel的邻居变换到光照空间的深度则是未知的。

左边是渲染的场景,右边是对shadow map采样一个texel及相邻texel的情况。观察空间的texel可以映射到shadow map中的像素D位置进行采样,从而能够与光照空间的深度值进行比较。我们可以对shadow map中,D周围的邻居texel进行采样,但这些texel反过来映射到观察空间的位置和深度则是未知的。只有当我们假设该相邻像素与D同属于一个三角形时,才有可能将相邻的像素映射回观察空间。

这项技术使用ddx和ddy的操作来寻找光照空间位置的偏导数,它返回光照空间深度相对于屏幕空间X、Y方向的梯度。为了将其转换为屏幕空间深度相对于光照空间X、Y方向的梯度,需要计算一个变换矩阵。

以下图为例,栅格表示的是shadow map的像素,黑色点代表观察空间的位置,两个黑色箭头分别对应光照空间深度相对于屏幕空间X、Y方向的梯度(LSP),而两个蓝色箭头对应的分别是屏幕空间深度相对于光照空间X、Y方向的梯度。

图13:屏幕空间与光照空间的相互变换

我们使用光照空间位置对X和Y的偏导数来构建这个矩阵。

cbuffer CBCascadedShadow : register(b2) { float4 g_CascadeOffset[8]; // ShadowPT矩阵的平移量 float4 g_CascadeScale[8]; // ShadowPT矩阵的缩放量 } //-------------------------------------------------------------------------------------- // 为阴影空间的texels计算屏幕空间深度 //-------------------------------------------------------------------------------------- void CalculateRightAndUpTexelDepthDeltas(float3 shadowTexDDX, float3 shadowTexDDY, out float upTextDepthWeight, out float rightTextDepthWeight) { // 这里使用X和Y中的偏导数来计算变换矩阵。我们需要逆矩阵从阴影空间变换到屏幕空间, // 因为这些导数能让我们从屏幕空间变换到阴影空间。新的矩阵允许我们从shadow map的texels // 映射到屏幕空间。这将允许我们寻找对应深度texel用于比较的屏幕空间深度。 // 这不是一个完美的解决方案,因为它假定场景中的几何体是一个平面。 // [TODO]一种更准确的寻找实际深度的方法为:采用延迟渲染并采样shadow map。 // 在大多数情况下,使用偏移或方差阴影贴图是一种更好的、能够减少伪影的方法 // 从屏幕空间变换到阴影空间的矩阵 float2x2 matScreenToShadow = float2x2(shadowTexDDX.xy, shadowTexDDY.xy); float det = determinant(matScreenToShadow); float invDet = 1.0f / det; float2x2 matShadowToScreen = float2x2( matScreenToShadow._22 * invDet, matScreenToShadow._12 * -invDet, matScreenToShadow._21 * -invDet, matScreenToShadow._11 * invDet); float2 rightShadowTexelLocation = float2(g_TexelSize, 0.0f); float2 upShadowTexelLocation = float2(0.0f, g_TexelSize); // 通过阴影空间到屏幕空间的矩阵变换右边的texel float2 rightTexelDepthRatio = mul(rightShadowTexelLocation, matShadowToScreen); float2 upTexelDepthRatio = mul(upShadowTexelLocation, matShadowToScreen); // 我们现在可以计算在shadow map向右和向上移动时,深度的变化值 // 我们使用x方向和y方向变换的比值乘上屏幕空间X和Y深度的导数来计算变化值 upTextDepthWeight = upTexelDepthRatio.x * shadowTexDDX.z + upTexelDepthRatio.y * shadowTexDDY.z; rightTextDepthWeight = rightTexelDepthRatio.x * shadowTexDDX.z + rightTexelDepthRatio.y * shadowTexDDY.z; } float upTextDepthWeight = 0; float rightTextDepthWeight = 0; float3 shadowMapTexCoordDDX; float3 shadowMapTexCoordDDY; // 这些导数用于寻找当前平面的斜率 // 导数的计算必须在循环内部,以阻止流控制分歧的影响 if (USE_DERIVATIVES_FOR_DEPTH_OFFSET_FLAG) { // 计算光照空间的偏导映射到投影纹理空间的变化率 shadowMapTexCoordDDX = ddx(shadowMapTexCoordViewSpace); shadowMapTexCoordDDY = ddy(shadowMapTexCoordViewSpace); shadowMapTexCoordDDX *= g_CascadeScale[currentCascadeIndex]; shadowMapTexCoordDDY *= g_CascadeScale[currentCascadeIndex]; CalculateRightAndUpTexelDepthDeltas(shadowMapTexCoordDDX, shadowMapTexCoordDDY, upTextDepthWeight, rightTextDepthWeight); }

然后我们就可以用这些信息进行PCF滤波了:

//-------------------------------------------------------------------------------------- // 使用PCF采样深度图并返回着色百分比 //-------------------------------------------------------------------------------------- float CalculatePCFPercentLit(int currentCascadeIndex, float4 shadowTexCoord, float rightTexelDepthDelta, float upTexelDepthDelta, float blurRowSize) { float percentLit = 0.0f; // 该循环可以展开,并且如果PCF大小是固定的话,可以使用纹理即时偏移从而改善性能 for (int x = g_PCFBlurForLoopStart; x < g_PCFBlurForLoopEnd; ++x) { for (int y = g_PCFBlurForLoopStart; y < g_PCFBlurForLoopEnd; ++y) { float depthCmp = shadowTexCoord.z; // 一个非常简单的解决PCF深度偏移问题的方案是使用一个偏移值 // 不幸的是,过大的偏移会导致Peter-panning(阴影跑出物体) // 过小的偏移又会导致阴影失真 depthCmp -= g_ShadowBiasFromGUI; if (USE_DERIVATIVES_FOR_DEPTH_OFFSET_FLAG) { depthCmp += rightTexelDepthDelta * (float)x + upTexelDepthDelta * (float)y; } // 将变换后的像素深度同阴影图中的深度进行比较 percentLit += g_TextureShadow.SampleCmpLevelZero(g_SamShadow, float3( shadowTexCoord.x + (float)x * g_TexelSize, shadowTexCoord.y + (float)y * g_TexelSize, (float)currentCascadeIndex ), depthCmp); } } percentLit /= blurRowSize; return percentLit; }

但实际上我们可以发现,在一个复杂场景中,由于有很多地形不平坦的区域都被偏导当成平坦区域来处理,导致计算出的结果有明显的错误:

关键问题出自这里:

if (USE_DERIVATIVES_FOR_DEPTH_OFFSET_FLAG) { depthCmp += rightTexelDepthDelta * (float)x + upTexelDepthDelta * (float)y; }

如果我们能使用深度图,再根据偏移进行采样,应该会比这种方式更为准确。这部分内容可以作为练习题供读者进行尝试。

PCF核的Padding

如果shadow buffer没有进行填充,PCF核就会越界访问。一种办法是,使用PCF核大小的一半来填充级联的外缘。而在当前map-based selection的实现中,仅当纹理的x、y坐标在设置的边界范围内时才能选择当前级联:

// Map-Based Selection currentCascadeIndex = 0; if (CASCADE_COUNT_FLAG == 1) { shadowMapTexCoord = shadowMapTexCoordViewSpace * g_CascadeScale[0] + g_CascadeOffset[0]; } if (CASCADE_COUNT_FLAG > 1) { // 寻找最近的级联,使得纹理坐标位于纹理边界内 // minBoard < tx, ty < maxBoard for (int cascadeIndex = 0; cascadeIndex < CASCADE_COUNT_FLAG && cascadeFound == 0; ++cascadeIndex) { shadowMapTexCoord = shadowMapTexCoordViewSpace * g_CascadeScale[cascadeIndex] + g_CascadeOffset[cascadeIndex]; if (min(shadowMapTexCoord.x, shadowMapTexCoord.y) > g_MinBorderPadding && max(shadowMapTexCoord.x, shadowMapTexCoord.y) < g_MaxBorderPadding) { currentCascadeIndex = cascadeIndex; cascadeFound = 1; } } }

float padding = ((int)kernelSize / 2) / (float)shadowSize; pImpl->m_pEffectHelper->GetConstantBufferVariable("g_MinBorderPadding")->SetFloat(padding); pImpl->m_pEffectHelper->GetConstantBufferVariable("g_MaxBorderPadding")->SetFloat(1.0f - padding);

但随着PCF的增大,padding也变大了,这么做会缩小级联的在世界中的范围。作为代偿,我们考虑可以适当扩大级联的正交立方体,从而抵消这种影响:

// 我们基于PCF核的大小再计算一个边界扩充值使得包围盒稍微放大一些。 float scaleDuetoBlur = m_PCFKernelSize / (float)m_ShadowSize; XMVECTORF32 scaleDuetoBlurVec = { {scaleDuetoBlur, scaleDuetoBlur, 0.0f, 0.0f} }; float normalizeByBufferSize = 1.0f / m_ShadowSize; XMVECTORF32 normalizeByBufferSizeVec = { {normalizeByBufferSize, normalizeByBufferSize, 0.0f, 0.0f} }; XMVECTOR borderOffsetVec = lightCameraOrthographicMaxVec - lightCameraOrthographicMinVec; borderOffsetVec *= g_XMOneHalf; borderOffsetVec *= scaleDuetoBlurVec; lightCameraOrthographicMaxVec += borderOffsetVec; lightCameraOrthographicMinVec -= borderOffsetVec; 阴影抖动&闪烁

现在我们关掉PCF。当我们移动摄像机视角的时候,可以发现会出现阴影闪烁:

导致这种问题出现原因之一在于:移动的时候会导致视锥体产生的AABB大小发生变化,导致shadow map的像素与屏幕空间像素的对应比例关系发生了变化,从而出现了抖动&闪烁。

为此,我们首先可以考虑如何固定视锥体产生的AABB,一种方式是采用视锥体斜对角线的长度作为AABB盒的宽高。然后我们就有了下面的代码:

// Near Far // 0----1 4----5 // | | | | // | | | | // 3----2 7----6 XMVECTOR diagVec = XMLoadFloat3(viewerFrustumPoints + 7) - XMLoadFloat3(viewerFrustumPoints + 1); // 子视锥体的斜对角线 // 找到视锥体对角线的长度作为AABB的宽高 XMVECTOR lengthVec = XMVector3Length(diagVec); // 计算出的偏移量会填充正交投影 XMVECTOR borderOffsetVec = (lengthVec - (lightCameraOrthographicMaxVec - lightCameraOrthographicMinVec)) * g_XMOneHalf; // 我们仅对XY方向进行填充 static const XMVECTORF32 xyzw1100Vec = { {1.0f, 1.0f, 0.0f, 0.0f} }; lightCameraOrthographicMaxVec += borderOffsetVec * xyzw1100Vec; lightCameraOrthographicMinVec -= borderOffsetVec * xyzw1100Vec;

结果跑起来一看,窗口两侧会有一片莫名其妙的阴影,即便是连微软给的程序示例,把分辨率调成1280x720都会有这样的问题:

仔细想想,就会发现如果级联0视锥体的远平面与远平面的距离过近,会导致斜对角线过短,继而生成的AABB盒不能包围整个视锥体。下面画个图感受一下:

为此我们需要把远平面对角线也考虑进来:

// Near Far // 0----1 4----5 // | | | | // | | | | // 3----2 7----6 XMVECTOR diagVec = XMLoadFloat3(viewerFrustumPoints + 7) - XMLoadFloat3(viewerFrustumPoints + 1); // 子视锥体的斜对角线 XMVECTOR diag2Vec = XMLoadFloat3(viewerFrustumPoints + 7) - XMLoadFloat3(viewerFrustumPoints + 5); // 远平面对角线 // 找到较长的对角线作为AABB的宽高 XMVECTOR lengthVec = XMVectorMax(XMVector3Length(diagVec), XMVector3Length(diag2Vec));

虽然CSM的内容都是千篇一律,但这里也算是一个很难让人注意到的问题。

然后当我们移动摄像机的时候,依然会出现抖动&闪烁现象:

仔细观察下图,黑色栅格代表世界空间,蓝色栅格代表光照在世界中产生的投影(宽高相等相当于光源方向竖直向下),一个texel对应一个区块。以粗黑色的区块的中心点进行投影纹理变换与采样,会采样到的shadow map对应蓝色圆点所属texel的深度。

如果我们尝试让摄像机移动一点点,此时阴影投射图在世界中也发生了位移。这时候以粗黑色的区块中心进行投影纹理变换与采样,会采样到下面那个蓝色圆点所属texel的深度。

可以看到两个时刻采样的深度位置不同,会导致采样到的深度值不同,从而出现阴影闪烁的问题。为此,我们可以考虑计算出阴影投射图的每个texel在世界中的宽度W和高度H,然后让阴影投射图的宽高分别为W和H的整数倍,并且只能以整数倍的W或H来进行移动,这样可以保证采样得到的深度值不会发生变动,从而避免了阴影抖动&闪烁的问题。

微软把这项技术称之为:Moving the Light in Texel-Sized Increments

下面的代码展示了这些技术的使用:

if (m_MoveLightTexelSize) { // 计算阴影图中每个texel对应世界空间的宽高,用于后续避免阴影边缘的闪烁 float normalizeByBufferSize = 1.0f / m_ShadowSize; XMVECTORF32 normalizeByBufferSizeVec = { {normalizeByBufferSize, normalizeByBufferSize, 0.0f, 0.0f} }; worldUnitsPerTexelVec = lightCameraOrthographicMaxVec - lightCameraOrthographicMinVec; worldUnitsPerTexelVec *= normalizeByBufferSize; // worldUnitsPerTexel // | | 光照空间 // [x][x][ ] [ ][x][x] x是阴影texel // [x][x][ ] => [ ][x][x] // [ ][ ][ ] [ ][ ][ ] // 在摄像机移动的时候,视锥体在光照空间下的AABB并不会立马跟着移动 // 而是累积到texel对应世界空间的宽高的变化时,AABB才会发生一次texel大小的跃动 // 所以移动摄像机的时候不会出现阴影的抖动 lightCameraOrthographicMinVec /= worldUnitsPerTexelVec; lightCameraOrthographicMinVec = XMVectorFloor(lightCameraOrthographicMinVec); lightCameraOrthographicMinVec *= worldUnitsPerTexelVec; lightCameraOrthographicMaxVec /= worldUnitsPerTexelVec; lightCameraOrthographicMaxVec = XMVectorFloor(lightCameraOrthographicMaxVec); lightCameraOrthographicMaxVec *= worldUnitsPerTexelVec; }

投影近平面与远平面的确定

在前面的代码中我们使用近平面与远平面是从视锥体计算AABB得到的,如果直接这样使用的话,会出现缺乏遮蔽的效果:

左上角的发电厂本应该能对下面的红色区域产生遮蔽,却因为没有在投影立方体的范围内而丢失了深度信息。

一种最简单粗暴的方式就是,让光源位于场景之外,然后近平面设置为接近0,远平面设为一个很大的值。

if (m_SelectedNearFarFit == FitNearFar::FitNearFar_ZeroOne) { nearPlane = 0.1f; farPlane = 10000.0f; }

但很明显整个场景的阴影效果立马就有问题了,原因在于大范围的深度值会使得深度的表示精度降低,为此我们需要想办法将近平面和远平面设置到合适的范围。

一种简单的方式是,将场景AABB的角点变换到光照空间,然后对这些点计算出vMin和vMax,相当于计算新的AABB:

// 将场景AABB的角点变换到光照空间 XMVECTOR sceneAABBPointsLightSpace[8]{}; { XMFLOAT3 corners[8]; sceneBoundingBox.GetCorners(corners); for (int i = 0; i < 8; ++i) { XMVECTOR v = XMLoadFloat3(corners + i); sceneAABBPointsLightSpace[i] = XMVector3Transform(v, LightView); } } if (m_SelectedNearFarFit == FitNearFar::FitNearFar_SceneAABB) { XMVECTOR lightSpaceSceneAABBminValueVec = g_XMFltMax; XMVECTOR lightSpaceSceneAABBmaxValueVec = -g_XMFltMax; // 我们计算光照空间下场景的min max向量 // 其中光照空间AABB的minZ和maxZ可以用于近平面和远平面 // 这比场景与AABB的相交测试简单,在某些情况下也能提供相似的结果 for (int i = 0; i < 8; ++i) { lightSpaceSceneAABBminValueVec = XMVectorMin(sceneAABBPointsLightSpace[i], lightSpaceSceneAABBminValueVec); lightSpaceSceneAABBmaxValueVec = XMVectorMax(sceneAABBPointsLightSpace[i], lightSpaceSceneAABBmaxValueVec); } nearPlane = XMVectorGetZ(lightSpaceSceneAABBminValueVec); farPlane = XMVectorGetZ(lightSpaceSceneAABBmaxValueVec); }

可以看到此时的效果已经比较理想了:

然而在最坏的情况下,可能会导致深度缓冲区的精度大幅下降。下图可以看到,由于光源的倾斜程度较大,导致近平面与远平面的范围比所需要的大了四倍。

如果想要更加精细的远平面和近平面,则需要对光照空间下的视锥体AABB 与 变换到光照空间的场景AABB 进行相交测试。为此我们需要用到三角形的剔除算法。

假设有一个待裁剪的三角形,我们需要对包围盒的四个边界以迭代的方式执行边界测试。现在对于左边界,有2个点位于边界外,此时可以找到与边界的2个交点,然后将三角形坍缩。接着对于上边界,发现有2个点在边界内部,此时可以找到与边界外的两个交点,以某种规则产生出一个新的三角形,然后这两个三角形参与后续边界的测试。对于右边界,1号三角形的三个点都在边界内,可以保留,而2号三角形有2个点在边界内,故继续坍缩并产生新的三角形。产生的3个三角形与下边界进行测试发现都在内部,不需要操作。最终得到的就是裁剪后的三角形集合。

// 通过光照空间下视锥体的AABB 与 变换到光照空间的场景AABB 的相交测试,我们可以得到一个更紧密的近平面和远平面 ComputeNearAndFar(nearPlane, farPlane, lightCameraOrthographicMinVec, lightCameraOrthographicMaxVec, sceneAABBPointsLightSpace); void XM_CALLCONV ComputeNearAndFar( float& outNearPlane, float& outFarPlane, FXMVECTOR lightCameraOrthographicMinVec, FXMVECTOR lightCameraOrthographicMaxVec, XMVECTOR pointsInCameraView[]) { // 核心思想 // 1. 对AABB的所有12个三角形进行迭代 // 2. 每个三角形分别对正交投影的4个侧面进行裁剪。裁剪过程中可能会出现这些情况: // - 0个点在该侧面的内部,该三角形可以剔除 // - 1个点在该侧面的内部,计算该点与另外两个点在侧面上的交点得到新三角形 // - 2个点在该侧面的内部,计算这两个点与另一个点在侧面上的交点,分裂得到2个新三角形 // - 3个点都在该侧面的内部 // 遍历中的三角形与新生产的三角形都要进行剩余侧面的裁剪 // 3. 在这些三角形中找到最小/最大的Z值作为近平面/远平面 outNearPlane = FLT_MAX; outFarPlane = -FLT_MAX; Triangle triangleList[16]{}; int numTriangles; // 4----5 // /| /| // 0-+--1 | // | 7--|-6 // |/ |/ // 3----2 static const int all_indices[][3] = { {4,7,6}, {6,5,4}, {5,6,2}, {2,1,5}, {1,2,3}, {3,0,1}, {0,3,7}, {7,4,0}, {7,3,2}, {2,6,7}, {0,4,5}, {5,1,0} }; bool triPointPassCollision[3]{}; const float minX = XMVectorGetX(lightCameraOrthographicMinVec); const float maxX = XMVectorGetX(lightCameraOrthographicMaxVec); const float minY = XMVectorGetY(lightCameraOrthographicMinVec); const float maxY = XMVectorGetY(lightCameraOrthographicMaxVec); for (auto& indices : all_indices) { triangleList[0].point[0] = pointsInCameraView[indices[0]]; triangleList[0].point[1] = pointsInCameraView[indices[1]]; triangleList[0].point[2] = pointsInCameraView[indices[2]]; numTriangles = 1; triangleList[0].isCulled = false; // 每个三角形都需要对4个视锥体侧面进行裁剪 for (int planeIdx = 0; planeIdx < 4; ++planeIdx) { float edge; int component; switch (planeIdx) { case 0: edge = minX; component = 0; break; case 1: edge = maxX; component = 0; break; case 2: edge = minY; component = 1; break; case 3: edge = maxY; component = 1; break; default: break; } for (int triIdx = 0; triIdx < numTriangles; ++triIdx) { // 跳过裁剪的三角形 if (triangleList[triIdx].isCulled) continue; int insideVertexCount = 0; for (int triVtxIdx = 0; triVtxIdx < 3; ++triVtxIdx) { switch (planeIdx) { case 0: triPointPassCollision[triVtxIdx] = (XMVectorGetX(triangleList[triIdx].point[triVtxIdx]) > minX); break; case 1: triPointPassCollision[triVtxIdx] = (XMVectorGetX(triangleList[triIdx].point[triVtxIdx]) < maxX); break; case 2: triPointPassCollision[triVtxIdx] = (XMVectorGetY(triangleList[triIdx].point[triVtxIdx]) > minY); break; case 3: triPointPassCollision[triVtxIdx] = (XMVectorGetY(triangleList[triIdx].point[triVtxIdx]) < maxY); break; default: break; } insideVertexCount += triPointPassCollision[triVtxIdx]; } // 将通过视锥体测试的点挪到数组前面 if (triPointPassCollision[1] && !triPointPassCollision[0]) { std::swap(triangleList[triIdx].point[0], triangleList[triIdx].point[1]); triPointPassCollision[0] = true; triPointPassCollision[1] = false; } if (triPointPassCollision[2] && !triPointPassCollision[1]) { std::swap(triangleList[triIdx].point[1], triangleList[triIdx].point[2]); triPointPassCollision[1] = true; triPointPassCollision[2] = false; } if (triPointPassCollision[1] && !triPointPassCollision[0]) { std::swap(triangleList[triIdx].point[0], triangleList[triIdx].point[1]); triPointPassCollision[0] = true; triPointPassCollision[1] = false; } // 裁剪测试 triangleList[triIdx].isCulled = (insideVertexCount == 0); if (insideVertexCount == 1) { // 找出三角形与当前平面相交的另外两个点 XMVECTOR v0v1Vec = triangleList[triIdx].point[1] - triangleList[triIdx].point[0]; XMVECTOR v0v2Vec = triangleList[triIdx].point[2] - triangleList[triIdx].point[0]; float hitPointRatio = edge - XMVectorGetByIndex(triangleList[triIdx].point[0], component); float distAlong_v0v1 = hitPointRatio / XMVectorGetByIndex(v0v1Vec, component); float distAlong_v0v2 = hitPointRatio / XMVectorGetByIndex(v0v2Vec, component); v0v1Vec = distAlong_v0v1 * v0v1Vec + triangleList[triIdx].point[0]; v0v2Vec = distAlong_v0v2 * v0v2Vec + triangleList[triIdx].point[0]; triangleList[triIdx].point[1] = v0v2Vec; triangleList[triIdx].point[2] = v0v1Vec; } else if (insideVertexCount == 2) { // 裁剪后需要分开成两个三角形 // 把当前三角形后面的三角形(如果存在的话)复制出来,这样 // 我们就可以用算出来的新三角形覆盖它 triangleList[numTriangles] = triangleList[triIdx + 1]; triangleList[triIdx + 1].isCulled = false; // 找出三角形与当前平面相交的另外两个点 XMVECTOR v2v0Vec = triangleList[triIdx].point[0] - triangleList[triIdx].point[2]; XMVECTOR v2v1Vec = triangleList[triIdx].point[1] - triangleList[triIdx].point[2]; float hitPointRatio = edge - XMVectorGetByIndex(triangleList[triIdx].point[2], component); float distAlong_v2v0 = hitPointRatio / XMVectorGetByIndex(v2v0Vec, component); float distAlong_v2v1 = hitPointRatio / XMVectorGetByIndex(v2v1Vec, component); v2v0Vec = distAlong_v2v0 * v2v0Vec + triangleList[triIdx].point[2]; v2v1Vec = distAlong_v2v1 * v2v1Vec + triangleList[triIdx].point[2]; // 添加三角形 triangleList[triIdx + 1].point[0] = triangleList[triIdx].point[0]; triangleList[triIdx + 1].point[1] = triangleList[triIdx].point[1]; triangleList[triIdx + 1].point[2] = v2v0Vec; triangleList[triIdx].point[0] = triangleList[triIdx + 1].point[1]; triangleList[triIdx].point[1] = triangleList[triIdx + 1].point[2]; triangleList[triIdx].point[2] = v2v1Vec; // 添加三角形数目,跳过我们刚插入的三角形 ++numTriangles; ++triIdx; } } } for (int triIdx = 0; triIdx < numTriangles; ++triIdx) { if (!triangleList[triIdx].isCulled) { for (int vtxIdx = 0; vtxIdx < 3; ++vtxIdx) { float z = XMVectorGetZ(triangleList[triIdx].point[vtxIdx]); outNearPlane = (std::min)(outNearPlane, z); outFarPlane = (std::max)(outFarPlane, z); } } } } }

现在我们可以看到比前面的方法产生的阴影又稍微细致了一点。

在级联之间混合

VSMs(方差阴影贴图,本章不会提及)和滤波技术(如PCF)可以用于低分辨率的CSMs产生软阴影。不幸的是,这会导致级联层之间出现明显的接缝,因为分辨率不匹配。解决的办法是:在两个级联之间确定一片边界区域,在这片区域对两个级联进行PCF计算。然后,着色器根据像素在边界区域中的位置,在这两个值之间进行线性插值。在本样例中提供了对应的操作UI,可以用来尝试增加和减少这个模糊带。

(左图)级联重叠的时候可以看到一个可接缝,(右图)当级联间进行混合时,接缝被模糊掉了。

除了阴影接缝,在开启大PCF核的时候,还会出现这样的问题,但我们依然可以用级联间混合的方式解决:

Interval-Based Blend

cbuffer CBCascadedShadow : register(b2) { float4 g_CascadeFrustumsEyeSpaceDepthsFloat4[8];// 以float4花费额外空间的形式使得可以数组遍历,yzw分量无用 float g_CascadeBlendArea; // 级联之间重叠量时的混合区域 } //-------------------------------------------------------------------------------------- // 计算两个级联之间的混合量 及 混合将会发生的区域 //-------------------------------------------------------------------------------------- void CalculateBlendAmountForInterval(int currentCascadeIndex, inout float pixelDepth, inout float currentPixelsBlendBandLocation, out float blendBetweenCascadesAmount) { // pixelDepth // |<- ->| // /-+-------/----------+------/-------- // 0 N F[0] F[i] // |<-blendInterval->| // blendBandLocation = 1 - depth/F[0] or // blendBandLocation = 1 - (depth-F[0]) / (F[i]-F[0]) // blendBandLocation位于[0, g_CascadeBlendArea]时,进行[0, 1]的过渡 // 我们需要计算当前shadow map的边缘地带,在那里将会淡化到下一个级联 // 然后我们就可以提前脱离开销昂贵的PCF for循环 float blendInterval = g_CascadeFrustumsEyeSpaceDepthsFloat4[currentCascadeIndex].x; // 对原项目中这部分代码进行了修正 if (currentCascadeIndex > 0) { int blendIntervalbelowIndex = currentCascadeIndex - 1; pixelDepth -= g_CascadeFrustumsEyeSpaceDepthsFloat4[blendIntervalbelowIndex].x; blendInterval -= g_CascadeFrustumsEyeSpaceDepthsFloat4[blendIntervalbelowIndex].x; } // 当前像素的混合地带的位置 currentPixelsBlendBandLocation = 1.0f - pixelDepth / blendInterval; // blendBetweenCascadesAmount用于最终的阴影色插值 blendBetweenCascadesAmount = currentPixelsBlendBandLocation / g_CascadeBlendArea; }

Map-Based Blend

//-------------------------------------------------------------------------------------- // 计算两个级联之间的混合量 及 混合将会发生的区域 //-------------------------------------------------------------------------------------- void CalculateBlendAmountForMap(float4 shadowMapTexCoord, inout float currentPixelsBlendBandLocation, inout float blendBetweenCascadesAmount) { // _____________________ // | map[i+1] | // | | // | 0_______0 | // |______| map[i]|______| // | 0.5 | // |_______| // 0 0 // blendBandLocation = min(tx, ty, 1-tx, 1-ty); // blendBandLocation位于[0, g_CascadeBlendArea]时,进行[0, 1]的过渡 float2 distanceToOne = float2(1.0f - shadowMapTexCoord.x, 1.0f - shadowMapTexCoord.y); currentPixelsBlendBandLocation = min(shadowMapTexCoord.x, shadowMapTexCoord.y); float currentPixelsBlendBandLocation2 = min(distanceToOne.x, distanceToOne.y); currentPixelsBlendBandLocation = min(currentPixelsBlendBandLocation, currentPixelsBlendBandLocation2); blendBetweenCascadesAmount = currentPixelsBlendBandLocation / g_CascadeBlendArea; }

实际混合代码

// // 在两个级联之间进行混合 // if (BLEND_BETWEEN_CASCADE_LAYERS_FLAG) { // 为下一个级联重复进行投影纹理坐标的计算 // 下一级联的索引用于在两个级联之间模糊 nextCascadeIndex = min(CASCADE_COUNT_FLAG - 1, currentCascadeIndex + 1); } blendBetweenCascadesAmount = 1.0f; float currentPixelsBlendBandLocation = 1.0f; if (SELECT_CASCADE_BY_INTERVAL_FLAG) { if (BLEND_BETWEEN_CASCADE_LAYERS_FLAG && CASCADE_COUNT_FLAG > 1) { CalculateBlendAmountForInterval(currentCascadeIndex, currentPixelDepth, currentPixelsBlendBandLocation, blendBetweenCascadesAmount); } } else { if (BLEND_BETWEEN_CASCADE_LAYERS_FLAG) { CalculateBlendAmountForMap(shadowMapTexCoord, currentPixelsBlendBandLocation, blendBetweenCascadesAmount); } } if (BLEND_BETWEEN_CASCADE_LAYERS_FLAG && CASCADE_COUNT_FLAG > 1) { if (currentPixelsBlendBandLocation < g_CascadeBlendArea) { // 计算下一级联的投影纹理坐标 shadowMapTexCoord_blend = shadowMapTexCoordViewSpace * g_CascadeScale[nextCascadeIndex] + g_CascadeOffset[nextCascadeIndex]; // 在级联之间混合时,为下一级联也进行计算 if (currentPixelsBlendBandLocation < g_CascadeBlendArea) { // 当前像素在混合地带内 if (USE_DERIVATIVES_FOR_DEPTH_OFFSET_FLAG) { CalculateRightAndUpTexelDepthDeltas(shadowMapTexCoordDDX, shadowMapTexCoordDDY, upTextDepthWeight_blend, rightTextDepthWeight_blend); } percentLit_blend = CalculatePCFPercentLit(nextCascadeIndex, shadowMapTexCoord_blend, rightTextDepthWeight_blend, upTextDepthWeight_blend, blurSize); // 对两个级联的PCF混合 percentLit = lerp(percentLit_blend, percentLit, blendBetweenCascadesAmount); } } } 演示

为了本演示,我把微软样例中的powerplant.sdkmesh模型想办法转成了obj文件。不得不吐槽虽然很多格式都能转成sdkmesh,但没有一个能从sdkmesh转成obj或别的格式的,网上的sdkmesh-to-obj就没有一个靠谱的,最后靠着在微软示例的渲染代码里强行输出一个obj文件才搞定。

然后是一些功能说明:

Debug Shadow:打开shadow map调试窗口

Depth Offset:PCF的深度偏移

Cascade Blur:混合区域的大小及开关

DDX, DDY offset:感觉没什么用的偏导深度偏移

Fixed Size Frustum AABB:固定视锥体AABB的宽高,避免摄像机旋转时阴影闪烁

Fit Light to Texels:整数倍texel world unit的AABB及移动,避免移动时阴影闪烁

Fit Projection:级联的投影模式

Camera:支持主摄像机、光照摄像机改变光照方向

Near Far:计算近平面、远平面的方式

Selection:级联选择算法

光照摄像机效果:

级联可视化:

可以说级联阴影要面临非常多的问题,这些坑也是不踩不知道,一踩就是一个接一个的来。下一章的内容还是阴影相关。

参考

最主要参考自下面两个文章:

Cascade Shadow Maps--MSDN

Common Techniques to Improve Shadow Depth Maps--MSDN

知乎、CSDN上关于CSM的文章,这里就不逐一列举了。

参考项目:

CascadedShadowMaps11

练习题
  1. 在PCF中,尝试使用深度图读取相邻shadow map texel对应位置的深度用作比较

DirectX11 With Windows SDK完整目录

欢迎加入QQ群: 727623616 可以一起探讨DX11,以及有什么问题也可以在这里汇报。