DirectX11 With Windows SDK--31 阴影映射 前言 核心思想 阴影贴图 光源的投影 投影纹理坐标 算法思路 偏移与走样 百分比渐近过滤(PCF) C++端代码实现 演示 透明物体的阴影绘制 练习题

阴影既暗示着光源相对于观察者的位置关系,也从侧面传达了场景中各物体之间的相对位置。本章将起底最基础的阴影映射算法,而像复杂如级联阴影映射这样的技术,也是在阴影映射的基础上发展而来的。

从本章开始我们将使用EffectHelper进一步简化所编写的特效,为此你可能还需要了解下面的章节:

章节
深入理解Effects11、使用着色器反射机制(Shader Reflection)实现一个复杂Effects框架

学习目标:

  1. 掌握基本的阴影映射算法
  2. 熟悉投影纹理贴图的工作原理
  3. 了解阴影图走样的问题并学习修正该问题的常用策略

DirectX11 With Windows SDK完整目录

Github项目源码

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

核心思想

阴影映射技术的核心思想其实不复杂。对于场景中的一点,如果该点能够被摄像机观察到,却不能被光源定义的虚拟摄像机所观察到,那么场景中的这一点则可以被判定为光源所照射不到的阴影区域。

以下图为例,眼睛观察到地面上最左边的一点,并且从光源处观察也能看到该点。因此该点不会产生阴影。

DirectX11 With Windows SDK--31 阴影映射
前言
核心思想
阴影贴图
光源的投影
投影纹理坐标
算法思路
偏移与走样
百分比渐近过滤(PCF)
C++端代码实现
演示
透明物体的阴影绘制
练习题

再看下面的图,眼睛可以观察到地面上中间那一点,但是从光源处观察不能看到该点。因此该点会产生阴影。

DirectX11 With Windows SDK--31 阴影映射
前言
核心思想
阴影贴图
光源的投影
投影纹理坐标
算法思路
偏移与走样
百分比渐近过滤(PCF)
C++端代码实现
演示
透明物体的阴影绘制
练习题

具体落实下来应该怎么做呢?对于点光源来说,由于它的光是朝所有方向四射散开的,但为了方便,我们可以像摄像机那样选取视锥体区域(使用一个观察矩阵 + 透视投影矩阵来定义),然后经过正常的变换后就能计算出光源到区域内物体的深度值;而对于平行光(方向光)来说,我们可以采用正交投影的方式来选取一个长方体区域(使用一个观察矩阵 + 正交投影矩阵定义)。和一般的渲染流程不同的是,我们只需要记录深度值到深度缓冲区,而不需要将颜色绘制到后备缓冲区。

阴影贴图

阴影贴图技术也是一种变相的“渲染到纹理”技术。它以光源的视角来渲染场景深度信息,即在光源处有一个虚拟摄像机,它将观察到的物体的深度信息保存到深度缓冲区中。这样我们就可以知道那些离光源最近的像素片元信息,同时这些点自然是不在阴影范围之中。

通常该技术需要用到一个深度/模板缓冲区、一个与之对应的视口、针对该深度/模板缓冲区的着色器资源视图(SRV)和深度/模板视图(DSV),而用于阴影贴图的那个深度/模板缓冲区也被称为阴影贴图

光源的投影

在考虑点光源的投影和方向光的投影时可能会有些困难,但这两个问题其实可以转化成虚拟摄像机的透视投影和正交投影。

对于透视投影来说,其实我们也已经非常熟悉了。在这种做法下我们只考虑虚拟摄像机的视锥体区域(即尽管点光源是朝任意方向照射的,但我们只看点光源往该视锥体范围内照射的区域),然后对物体惯例进行世界变换、以光源为视角的观察变换、光源的透视投影变换,这样物体就被变换到了以光源为视角的NDC空间。

而对于正交投影而言,我们也是一样的做法。正交投影的视景体是一个轴对齐于观察坐标系的长方体。尽管我们不好描述一个方向光的光源,但为了方便,我们把光源定义在视景体xOy切面中心所处的那条直线上。这样我们就只需要给出视景体的宽度、高度、*面、远平面信息就可以构造出一个正交投影矩阵了。

DirectX11 With Windows SDK--31 阴影映射
前言
核心思想
阴影贴图
光源的投影
投影纹理坐标
算法思路
偏移与走样
百分比渐近过滤(PCF)
C++端代码实现
演示
透明物体的阴影绘制
练习题

我们可以看到,正交投影的投影线均平行于观察空间的z轴。

DirectX11 With Windows SDK--31 阴影映射
前言
核心思想
阴影贴图
光源的投影
投影纹理坐标
算法思路
偏移与走样
百分比渐近过滤(PCF)
C++端代码实现
演示
透明物体的阴影绘制
练习题

正交投影矩阵在第四章变换已经讲过,就不再赘述。

投影纹理坐标

投影纹理贴图技术能够将纹理投射到任意形状的几何体上,又因为其原理与投影机的工作方式比较相似,由此得名。例如下图中,右边的骷髅头纹理被投射到左边场景中的多个几何体上。

DirectX11 With Windows SDK--31 阴影映射
前言
核心思想
阴影贴图
光源的投影
投影纹理坐标
算法思路
偏移与走样
百分比渐近过滤(PCF)
C++端代码实现
演示
透明物体的阴影绘制
练习题

投影纹理贴图的关键在于为每个像素生成对应的投影纹理坐标,从视觉上给人一种纹理被投射到几何体上的感觉。

下图是光源观察的视野,其中点p是待渲染的一点,而纹理坐标(u, v)则指定了应当被投射到3D点p上的纹素,并且坐标(u, v)与投影到屏幕上的NDC坐标有特定联系。我们可以将投影纹理坐标的生成过程分为如下步骤:

  1. 将3D空间中一点p投影到光源的投影窗口,并将其变换到NDC空间。
  2. 将投影坐标从NDC空间变换到纹理空间,以此将它们转换为纹理坐标

DirectX11 With Windows SDK--31 阴影映射
前言
核心思想
阴影贴图
光源的投影
投影纹理坐标
算法思路
偏移与走样
百分比渐近过滤(PCF)
C++端代码实现
演示
透明物体的阴影绘制
练习题

而步骤2中的变换过程则取决于下面的坐标变换:

[u=0.5x+0.5\ v=-0.5y+0.5 ]

即从x, y∈[-1, 1]映射到u, v∈[0, 1]。(y轴和v轴是相反的)

这种线性变换可以用矩阵表示:

[mathbf{T}=egin{bmatrix} 0.5 & 0 & 0 & 0 \ 0 & -0.5 & 0 & 0 \ 0 & 0 & 1 & 0 \ 0.5 & 0.5 & 0 & 1 \ end{bmatrix}\ egin{bmatrix} x & y & 0 & 1 end{bmatrix}mathbf{T}=egin{bmatrix} u & v & 0 & 1 end{bmatrix} ]

那么物体上的一点p从局部坐标系到最终的纹理坐标点t的变换过程为:

[mathbf{p}mathbf{W_{Obj}}mathbf{V_{Light}}mathbf{P_{Light}}mathbf{T}=mathbf{t} ]

这里补上了世界变换矩阵,是因为这一步容易在后面的代码实践中被漏掉。但此时的t还需要经过透视除法,才是我们最终需要的纹理坐标。

HLSL代码

下面的HLSL代码展示了顶点着色器计算投影纹理坐标的过程:

// 顶点着色器
VertexOutBasic VS(VertexPosNormalTex vIn)
{
    VertexOutBasic vOut;
    
    // ...
    
    // 把顶点变换到光源的投影空间
    vOut.ShadowPosH = mul(posW, g_ShadowTransform);
    return vOut;
}



// 像素着色器
float4 PS(VertexOutBasic pIn) : SV_Target
{
    // 透视除法
    pIn.ShadowPosH.xyz /= pIn.ShadowPosH.w;
    
    // NDC空间中的深度值
    float depth = pIn.ShadowPosH.z;
    
    // 通过投影纹理坐标来对纹理采样
    // 采样出的r分量即为光源观察该点时的深度值
    float4 c = g_ShadowMap.Sample(g_Sam, pIn.ShadowPosH.xy);
    
    // ...
}

视锥体之外的点

在渲染管线中,位于视锥体之外的几何体是要被裁剪掉的。但是,在我们以光源设置的视角投影几何体而为之生成投影纹理坐标时,并不需要执行裁剪操作——只需要简单投影顶点即可。因此,位于视锥体之外的几何体顶点会得到[0, 1]区间之外的投影纹理坐标。然后具体的采样行为则需要依赖于我们设置的采样器。

一般来说,我们并不希望对位于视锥体外的几何体顶点进行贴图,因为这并没有任何意义。考虑到可视深度在NDC空间的最大值为1.0f,我们可以采用边界深度值为1.0f的边框寻址模式

另一种做法则是结合聚光灯的策略,使聚光灯照射范围之外的部分不受光照,亦即不在阴影的计算范围内。

透视除法与投影的其他问题

来到正交投影,因为我们依然是要计算出NDC坐标,对于NDC空间范围外的点,我们依然可以采用上面的寻址模式策略,但聚光灯的策略就不适用了。

此外,正交投影无需进行透视除法,因为正交投影后的坐标w值总是1.0f。但保留透视除法可以让我们的这套着色器可以同时工作在正交投影和透视投影上。如果没有透视除法,则只能在正交投影中工作。

算法思路

  1. 从光源的视角将场景深度以“渲染到纹理”的形式绘制到名为阴影贴图的深度缓冲区中
  2. 从玩家摄像机的视角渲染场景,计算出该点在光源视角下NDC坐标,其中z值为深度值,记为d(p)
  3. 上面算出的NDC坐标的xy分量变换为阴影贴图的纹理坐标uv,然后进行深度值采样,得到s(p)
  4. 当d(p) > s(p)时, 像素p位于阴影范围之内;自然相反地,当d(p) <= s(p)时,像素p位于阴影范围之外(至于为什么还有<,后面会提到)

DirectX11 With Windows SDK--31 阴影映射
前言
核心思想
阴影贴图
光源的投影
投影纹理坐标
算法思路
偏移与走样
百分比渐近过滤(PCF)
C++端代码实现
演示
透明物体的阴影绘制
练习题

偏移与走样

阴影图存储的是距离光源最近的可视像素深度值,但是它的分辨率有限,导致每一个阴影图纹素都要表示场景中的一片区域。因此,阴影图只是以光源视角针对场景深度进行的离散采样,这将会导致所谓的阴影粉刺等图像走样问题。如下图所示(注意图中地面上光影之间轮流交替的“阶梯状”条纹):

DirectX11 With Windows SDK--31 阴影映射
前言
核心思想
阴影贴图
光源的投影
投影纹理坐标
算法思路
偏移与走样
百分比渐近过滤(PCF)
C++端代码实现
演示
透明物体的阴影绘制
练习题

而下图则简单展示了为什么会发生阴影粉刺这种现象。由于阴影图的分辨率有限,所以每个阴影图纹素要对应于长江中的一块区域(而不是点对点的关系,一个坡面代表阴影图中一个纹素的对应范围)。从观察点E查看场景中的两个点p1与p2,它们分别对应于两个不同的屏幕像素。但是,从光源的观察角度来看,它们却都有着相同的阴影图纹素(即s(p1)=s(p2)=s,由于分辨率的原因)。当我们在执行阴影图检测时,会得到d(p1) > s 及 d(p2) <= s这两个测试结果,这样一来,p1将会被绘制为如同它在阴影中的颜色,p2将被渲染为好似它在阴影之外的颜色,从而导致阴影粉刺。

DirectX11 With Windows SDK--31 阴影映射
前言
核心思想
阴影贴图
光源的投影
投影纹理坐标
算法思路
偏移与走样
百分比渐近过滤(PCF)
C++端代码实现
演示
透明物体的阴影绘制
练习题

因此,我们可以通过偏移阴影图中的深度值来防止出现错误的阴影效果。此时我们就可以保证d(p1) <= s 及 d(p2) <= s。但是寻找合适的深度偏移需要反复尝试。

DirectX11 With Windows SDK--31 阴影映射
前言
核心思想
阴影贴图
光源的投影
投影纹理坐标
算法思路
偏移与走样
百分比渐近过滤(PCF)
C++端代码实现
演示
透明物体的阴影绘制
练习题

偏移量过大会导致名为peter-panning(彼得·潘,即小飞侠,他曾在一次逃跑时弄丢了自己的影子)的失真效果,使得阴影看起来与物体相分离。

DirectX11 With Windows SDK--31 阴影映射
前言
核心思想
阴影贴图
光源的投影
投影纹理坐标
算法思路
偏移与走样
百分比渐近过滤(PCF)
C++端代码实现
演示
透明物体的阴影绘制
练习题

然而,并没有哪一种固定的偏移量可以正确地运用于所有几何体的阴影绘制。特别是下图那种(从光源的角度来看)有着极大斜率的三角形,这时候就需要选取更大的偏移量。但是,如果试图通过一个过大的深度偏移量来处理所有的斜边,则又会造成peter-panning问题。

DirectX11 With Windows SDK--31 阴影映射
前言
核心思想
阴影贴图
光源的投影
投影纹理坐标
算法思路
偏移与走样
百分比渐近过滤(PCF)
C++端代码实现
演示
透明物体的阴影绘制
练习题

因此,我们绘制阴影的方式就是先以光源视角度量多边形斜面的斜率,并为斜率较大的多边形应用更大的偏移量。而图形硬件内部对此有相关技术的支持,我们通过名为斜率缩放偏移的光栅化状态属性就能够轻松实现。

typedef struct D3D11_RASTERIZER_DESC {
    // ...
    INT             DepthBias;
    FLOAT           DepthBiasClamp;
    FLOAT           SlopeScaledDepthBias;
    BOOL            DepthClipEnable;
    // ...
} D3D11_RASTERIZER_DESC;
  1. DepthBias:一个固定的应用偏移量。
  2. DepthBiasClamp:所允许的最大深度偏移量。以此来设置深度偏移量的上限。不难想象,及其陡峭的倾斜度会导致斜率缩放偏移量过大,从而造成peter-panning失真
  3. SlopeScaledDepthBias:根据多边形的斜率来控制偏移程度的缩放因子。

注意,在将场景渲染至阴影贴图时,便会应用该斜率缩放偏移量。这是由于我们希望以光源的视角基于多边形的斜率而进行偏移操作,从而避免阴影失真。因此,我们就会对阴影图中的数值进行偏移计算(即由硬件将像素的深度值与偏移值相加)。在本Demo中采用的具体数值如下:

// [出自MSDN]
// 如果当前的深度缓冲区采用UNORM格式并且绑定在输出合并阶段,或深度缓冲区还没有被绑定
// 则偏移量的计算过程如下:
//
// Bias = (float)DepthBias * r + SlopeScaledDepthBias * MaxDepthSlope;
//
// 这里的r是在深度缓冲区格式转换为float32类型后,其深度值可取到大于0的最小可表示的值
// MaxDepthSlope则是像素在水平方向和竖直方向上的深度斜率的最大值
// [结束MSDN引用]
//
// 对于一个24位的深度缓冲区来说, r = 1 / 2^24
//
// 例如:DepthBias = 100000 ==> 实际的DepthBias = 100000/2^24 = .006
//
// 本Demo中的方向光始终与地面法线呈45度夹角,故取斜率为1.0f
// 以下数据极其依赖于实际场景,因此我们需要对特定场景反复尝试才能找到最合适
rsDesc.DepthBias = 100000;
rsDesc.DepthBiasClamp = 0.0f;
rsDesc.SlopeScaledDepthBias = 1.0f

注意:深度偏移发生在光栅化期间(裁剪之后),因此不会对几何体裁剪造成影响。

RenderStates中我们添加了这样一个光栅化状态:

// 深度偏移模式
rasterizerDesc.FillMode = D3D11_FILL_SOLID;
rasterizerDesc.CullMode = D3D11_CULL_BACK;
rasterizerDesc.FrontCounterClockwise = false;
rasterizerDesc.DepthClipEnable = true;
rasterizerDesc.DepthBias = 100000;
rasterizerDesc.DepthBiasClamp = 0.0f;
rasterizerDesc.SlopeScaledDepthBias = 1.0f;
HR(device->CreateRasterizerState(&rasterizerDesc, RSDepth.GetAddressOf()));

MSDN文档Depth Bias讲述了该技术相关的全部规则,并且介绍了如何使用浮点深度缓冲区进行工作。

百分比渐近过滤(PCF)

在使用投影纹理坐标(u, v)对阴影图进行采样时,往往不会命中阴影图中纹素的准确位置,而是通常位于阴影图中的4个纹素之间。然而,我们不应该对深度值采用双线性插值法,因为4个纹素之间的深度值不一定满足线性过渡,插值出来的深度值跟实际的深度值有偏差,这样可能会导致把像素错误标入阴影中这样的错误结果(因此我们也不能为阴影图生成mipmap)。

出于这样的原因,我们应该对采样的结果进行插值,而不是对深度值进行插值。这种做法称为——百分比渐近过滤。即我们以点过滤(MIN_MAG_MIP_POINT)的方式在坐标(u, v)、(u+△x, v)、(u, v+△x)、(u+△x, v+△x)处对纹理进行采样,其中△x=1/SHADOW_MAP_SIZE(除以的是引用贴图的宽高)。由于是点采样,这4个采样点分别命中的是围绕坐标(u, v)最近的4个阴影图纹素s0、s1、s2、s3,如下图所示。

DirectX11 With Windows SDK--31 阴影映射
前言
核心思想
阴影贴图
光源的投影
投影纹理坐标
算法思路
偏移与走样
百分比渐近过滤(PCF)
C++端代码实现
演示
透明物体的阴影绘制
练习题

接下来,我们会对这些采集的深度值进行阴影图检测,并对测试的结果展开双线性插值。

static const float SMAP_SIZE = 2048.0f;
static const float SMAP_DX = 1.0f / SMAP_SIZE;

// ...

//
// 采样操作
//

// 对阴影图进行采样以获取离光源最近的深度值
float s0 = g_ShadowMap.Sample(g_SamShadow, tex.xy).r;
float s1 = g_ShadowMap.Sample(g_SamShadow, tex.xy + float2(SMAP_DX, 0)).r;
float s2 = g_ShadowMap.Sample(g_SamShadow, tex.xy + float2(0, SMAP_DX)).r;
float s3 = g_ShadowMap.Sample(g_SamShadow, tex.xy + float2(SMAP_DX, SMAP_DX)).r;

// 该像素的深度值是否小于等于阴影图中的深度值
float r0 = (depth <= s0);
float r1 = (depth <= s1);
float r2 = (depth <= s2);
float r3 = (depth <= s3);

//
// 双线性插值操作
//

// 变换到纹素空间
float2 texelPos = SMAP_SIZE * tex.xy;

// 确定插值系数(frac()返回浮点数的小数部分)
float2 t = frac(texelPos);

// 对比较结果进行双线性插值
return lerp(lerp(r0, r1, t.x), lerp(r2, r3, t.x), t.y);

若采用这种计算方法,则一个像素就可能局部处于阴影之中,而不是非0即1.例如,若有4个样本,三个在阴影中,一个在阴影外,那么该像素有75%处于阴影之中。这就让阴影内外的像素之间有了更加平滑的过渡,而不是棱角分明。

DirectX11 With Windows SDK--31 阴影映射
前言
核心思想
阴影贴图
光源的投影
投影纹理坐标
算法思路
偏移与走样
百分比渐近过滤(PCF)
C++端代码实现
演示
透明物体的阴影绘制
练习题

但这种过滤方法产生的阴影看起来仍然非常生硬,且锯齿失真问题的最终处理效果还是不能令人十分满意。PCF的主要缺点是需要4个纹理样本,而纹理采样本身就是现代GPU代价较高的操作之一,因为存储器的带宽与延迟并没有随着GPU计算能力的剧增而得到相近程度的巨大改良。幸运的是,Direct3D 11+版本的图形硬件对PCF技术已经有了内部支持,上面的一大堆代码可以用SampleCmpLevelZero函数来替代。

float percentage = g_ShadowMap.SampleCmpLevelZero(g_SamShadow, shadowPosH.xy, depth).r;

方法中的LevelZero部分意味着它只能在最高的mipmap层级中进行采样。另外,该方法使用的并非一般的采样器对象,而是比较采样器。这使得硬件能够执行阴影图的比较测试,并且需要在过滤采样结果之前完成。对于PCF技术来说,我们需要使用的是D3D11_FILTER_COMPARISON_MIN_MAG_LINEAR_MIP_POINT过滤器,并将比较函数设置为LESS_EQUAL(由于对深度值进行了偏移,所以也要用到LESS比较函数)。

函数中传入的depth将会出现在比较运算符的左边,即:

depth <= sampleDepth

RenderStates中我们添加了这样一个采样器:

ComPtr<ID3D11SamplerState> RenderStates::SSShadow = nullptr;

// 采样器状态:深度比较与Border模式
ZeroMemory(&sampDesc, sizeof(sampDesc));
sampDesc.Filter = D3D11_FILTER_COMPARISON_MIN_MAG_LINEAR_MIP_POINT;
sampDesc.AddressU = D3D11_TEXTURE_ADDRESS_BORDER;
sampDesc.AddressV = D3D11_TEXTURE_ADDRESS_BORDER;
sampDesc.AddressW = D3D11_TEXTURE_ADDRESS_BORDER;
sampDesc.ComparisonFunc = D3D11_COMPARISON_LESS_EQUAL;
sampDesc.BorderColor[0] = { 1.0f };
sampDesc.MinLOD = 0;
sampDesc.MaxLOD = D3D11_FLOAT32_MAX;
HR(device->CreateSamplerState(&sampDesc, SSShadow.GetAddressOf()));

注意:根据SDK文档所述,只有R32_FLOAT_X8X24_TYPELESSR32_FLOATR24_UNORM_X8_TYPELESSR16_UNORM格式才能用于比较过滤器。

在PCF的基础上进行均值滤波

到目前为止,我们在本节中一直使用的是4-tap PCF核(输入4个样本来执行的PCF)。PCF核越大,阴影的边缘轮廓也就越丰满、越平滑,当然,花费在SampleCmpLevelZero函数上的开销也就越大。在本Demo中,我们是按3x3正方形的均值滤波方式来执行PCF。由于每次调用SampleCmpLevelZero函数实际所执行的都是4-tap PCF,所以一共采样了36次,其中有4x4个独立采样点。此外,采用过大的滤波核还会导致之前所述的阴影粉刺问题,但本章不打算讲述,有兴趣可以回到龙书阅读(过大的PCF核)。

显然,PCF技术一般来说只需在阴影的边缘进行,因为阴影内外两部分并不涉及混合操作(只有阴影边缘才是渐变的)。基于此,只要能对阴影边缘的PCF设计相应的处理方案就好了。但这种做法一般要求我们所用的PCF核足够大(5x5及更大)时才划算(因为动态分支也有开销)。不过最终是要效率还是要画质还是取决于你自己。

注意:实际工程中所用的PCF核不一定是方形的过滤栅格。不少文献也指出,随机的拾取点也可以作为PCF核。

考虑到在做比较时,如果处于阴影外的值为1,在阴影内的值为0,在采用SampleCmpLevelZero和均值滤波后,我们用范围值0~1来表示处于阴影外的程度。随着值的增加,该点也变得越亮。我们可以使用下面的函数来计算3x3正方形的均值滤波下的阴影因子:

float CalcShadowFactor(SamplerComparisonState samShadow, Texture2D shadowMap, float4 shadowPosH)
{
    // 透视除法
    shadowPosH.xyz /= shadowPosH.w;
    
    // NDC空间的深度值
    float depth = shadowPosH.z;

    // 纹素在纹理坐标下的宽高
    const float dx = SMAP_DX;

    float percentLit = 0.0f;
    const float2 offsets[9] =
    {
        float2(-dx, -dx), float2(0.0f, -dx), float2(dx, -dx),
        float2(-dx, 0.0f), float2(0.0f, 0.0f), float2(dx, 0.0f),
        float2(-dx, +dx), float2(0.0f, +dx), float2(dx, +dx)
    };
                      
    [unroll]
    for (int i = 0; i < 9; ++i)
    {
        percentLit += shadowMap.SampleCmpLevelZero(samShadow,
            shadowPosH.xy + offsets[i], depth).r;
    }
    
    return percentLit /= 9.0f;
}

然后在我们的光照模型中,只有第一个方向光才参与到阴影的计算,并且阴影因子将与直接光照(漫反射和镜面反射光)项相乘。

// ...
float shadow[5] = { 1.0f, 1.0f, 1.0f, 1.0f, 1.0f };
 
// 仅第一个方向光用于计算阴影
shadow[0] = CalcShadowFactor(g_SamShadow, g_ShadowMap, pIn.ShadowPosH);
    
[unroll]
for (i = 0; i < 5; ++i)
{
    ComputeDirectionalLight(g_Material, g_DirLight[i], pIn.NormalW, toEyeW, A, D, S);
    ambient += A;
    diffuse += shadow[i] * D;
    spec += shadow[i] * S;
}

// ...

由于环境光是间接光,所以阴影因子不受影响。并且,阴影因子也不会对来自环境映射的反射光构成影响。

C++端代码实现

在本章中,与阴影特效直接相关的类有BasicEffectShadowEffect,然后TextureRender类在本章还作为阴影图使用。GameApp类承担了实现过程。

改进TextureRender

既然阴影贴图和RTT有着许多相似的地方,那何不把它也放到TextureRender里面共用呢?只要添加一个开关控制该RTT是否用作阴影贴图即可。

class TextureRender
{
public:
    template<class T>
    using ComPtr = Microsoft::WRL::ComPtr<T>;

    TextureRender() = default;
    ~TextureRender() = default;
    // 不允许拷贝,允许移动
    TextureRender(const TextureRender&) = delete;
    TextureRender& operator=(const TextureRender&) = delete;
    TextureRender(TextureRender&&) = default;
    TextureRender& operator=(TextureRender&&) = default;


    HRESULT InitResource(ID3D11Device* device,
        int texWidth,
        int texHeight,
        bool shadowMap = false,
        bool generateMips = false);

    // 开始对当前纹理进行渲染
    // 阴影贴图无需提供背景色
    void Begin(ID3D11DeviceContext* deviceContext, const FLOAT backgroundColor[4]);
    // 结束对当前纹理的渲染,还原状态
    void End(ID3D11DeviceContext * deviceContext);
    // 获取渲染好的纹理的着色器资源视图
    // 阴影贴图返回的是深度缓冲区
    // 引用数不增加,仅用于传参
    ID3D11ShaderResourceView* GetOutputTexture();

    // 设置调试对象名
    void SetDebugObjectName(const std::string& name);

private:
    ComPtr<ID3D11ShaderResourceView>        m_pOutputTextureSRV;          // 输出的纹理(或阴影贴图)对应的着色器资源视图
    ComPtr<ID3D11RenderTargetView>          m_pOutputTextureRTV;          // 输出的纹理对应的渲染目标视图
    ComPtr<ID3D11DepthStencilView>          m_pOutputTextureDSV;          // 输出纹理所用的深度/模板视图(或阴影贴图)
    D3D11_VIEWPORT                          m_OutputViewPort = {};        // 输出所用的视口

    ComPtr<ID3D11RenderTargetView>          m_pCacheRTV;                  // 临时缓存的后备缓冲区
    ComPtr<ID3D11DepthStencilView>          m_pCacheDSV;                  // 临时缓存的深度/模板缓冲区
    D3D11_VIEWPORT                          m_CacheViewPort = {};         // 临时缓存的视口

    bool                                    m_GenerateMips = false;       // 是否生成mipmap链
    bool                                    m_ShadowMap = false;          // 是否为阴影贴图

};

在作为RTT时,需要创建纹理与它的SRV和RTV、深度/模板缓冲区和它的DSV、视口

而作为阴影贴图时,需要创建深度缓冲区与它的SRV和DSV、视口

下面的代码只关注创建阴影贴图的部分:

HRESULT TextureRender::InitResource(ID3D11Device* device, int texWidth, int texHeight, bool shadowMap, bool generateMips)
{
    // 防止重复初始化造成内存泄漏
    m_pOutputTextureSRV.Reset();
    m_pOutputTextureRTV.Reset();
    m_pOutputTextureDSV.Reset();
    m_pCacheRTV.Reset();
    m_pCacheDSV.Reset();

    m_ShadowMap = shadowMap;
    m_GenerateMips = false;
    HRESULT hr;
    
    // ...
    
    // ******************
    // 创建与纹理等宽高的深度/模板缓冲区或阴影贴图,以及对应的视图
    //
    CD3D11_TEXTURE2D_DESC texDesc((m_ShadowMap ? DXGI_FORMAT_R24G8_TYPELESS : DXGI_FORMAT_D24_UNORM_S8_UINT),
        texWidth, texHeight, 1, 1,
        D3D11_BIND_DEPTH_STENCIL | (m_ShadowMap ? D3D11_BIND_SHADER_RESOURCE : 0));

    ComPtr<ID3D11Texture2D> depthTex;
    hr = device->CreateTexture2D(&texDesc, nullptr, depthTex.GetAddressOf());
    if (FAILED(hr))
        return hr;

    CD3D11_DEPTH_STENCIL_VIEW_DESC dsvDesc(depthTex.Get(), D3D11_DSV_DIMENSION_TEXTURE2D, DXGI_FORMAT_D24_UNORM_S8_UINT);

    hr = device->CreateDepthStencilView(depthTex.Get(), &dsvDesc,
        m_pOutputTextureDSV.GetAddressOf());
    if (FAILED(hr))
        return hr;

    if (m_ShadowMap)
    {
        // 阴影贴图的SRV
        CD3D11_SHADER_RESOURCE_VIEW_DESC srvDesc(depthTex.Get(), D3D11_SRV_DIMENSION_TEXTURE2D, DXGI_FORMAT_R24_UNORM_X8_TYPELESS);

        hr = device->CreateShaderResourceView(depthTex.Get(), &srvDesc,
            m_pOutputTextureSRV.GetAddressOf());
        if (FAILED(hr))
            return hr;
    }

    // ******************
    // 初始化视口
    //
    m_OutputViewPort.TopLeftX = 0.0f;
    m_OutputViewPort.TopLeftY = 0.0f;
    m_OutputViewPort.Width = static_cast<float>(texWidth);
    m_OutputViewPort.Height = static_cast<float>(texHeight);
    m_OutputViewPort.MinDepth = 0.0f;
    m_OutputViewPort.MaxDepth = 1.0f;

    return S_OK;
}

需要注意的是,在创建深度缓冲区时,如果还想为他创建SRV,就不能将DXGI格式定义成DXGI_FORMAT_D24_UNORM_S8_UINT这些带D的类型,而应该是DXGI_FORMAT_R24G8_TYPELESS

然后在创建阴影贴图的SRV时,则需要指定为DXGI_FORMAT_R24_UNORM_X8_TYPELESS

开始阴影贴图的渲染前,不需要设置RTV,只需要绑定DSV。

void TextureRender::Begin(ID3D11DeviceContext* deviceContext, const FLOAT backgroundColor[4])
{
    // 缓存渲染目标和深度模板视图
    deviceContext->OMGetRenderTargets(1, m_pCacheRTV.GetAddressOf(), m_pCacheDSV.GetAddressOf());
    // 缓存视口
    UINT num_Viewports = 1;
    deviceContext->RSGetViewports(&num_Viewports, &m_CacheViewPort);

    // 清空缓冲区
    // ... 
    deviceContext->ClearDepthStencilView(m_pOutputTextureDSV.Get(), D3D11_CLEAR_DEPTH | (m_ShadowMap ? 0 : D3D11_CLEAR_STENCIL), 1.0f, 0);
    
    // 设置渲染目标和深度模板视图
    deviceContext->OMSetRenderTargets((m_ShadowMap ? 0 : 1), 
        (m_ShadowMap ? nullptr : m_pOutputTextureRTV.GetAddressOf()), 
        m_pOutputTextureDSV.Get());
    // 设置视口
    deviceContext->RSSetViewports(1, &m_OutputViewPort);
}

渲染完成后,和往常一样还原即可。

EffectHelper的引入

本章开始的代码引入了EffectHelper来管理着色器所需的资源(我们可以无需手动创建并交给它来托管),并应用在了所有的Effect类当中。除了IEffect接口类,目前还引入了IEffectTransform接口类来统一变换的设置。

class IEffectTransform
{
public:
    virtual void XM_CALLCONV SetWorldMatrix(DirectX::FXMMATRIX W) = 0;
    virtual void XM_CALLCONV SetViewMatrix(DirectX::FXMMATRIX V) = 0;
    virtual void XM_CALLCONV SetProjMatrix(DirectX::FXMMATRIX P) = 0;
};

class IEffectTextureDiffuse
{
public:
    virtual void SetTextureDiffuse(ID3D11ShaderResourceView* textureDiffuse) = 0;
};

然后特效类再根据自己的需要选择接口并实现:

class BasicEffect : public IEffect, public IEffectTransform, public IEffectTextureDiffuse
{
    // ...
};

随着抽象类的增加,像GameObject这样的类就可以对IEffect接口类对象查询是否有某一特定接口类或具体类来执行额外的复杂操作。

void GameObject::Draw(ID3D11DeviceContext * deviceContext, IEffect * effect)
{
    UINT strides = m_Model.vertexStride;
    UINT offsets = 0;

    for (auto& part : m_Model.modelParts)
    {
        // 设置顶点/索引缓冲区
        deviceContext->IASetVertexBuffers(0, 1, part.vertexBuffer.GetAddressOf(), &strides, &offsets);
        deviceContext->IASetIndexBuffer(part.indexBuffer.Get(), part.indexFormat, 0);

        /// 更新数据并应用
        IEffectTransform* pEffectTransform = dynamic_cast<IEffectTransform*>(effect);
        if (pEffectTransform)
        {
            pEffectTransform->SetWorldMatrix(m_Transform.GetLocalToWorldMatrixXM());
        }

        IEffectTextureDiffuse* pEffectTextureDiffuse = dynamic_cast<IEffectTextureDiffuse*>(effect);
        if (pEffectTextureDiffuse)
        {
            pEffectTextureDiffuse->SetTextureDiffuse(part.texDiffuse.Get());
        }

        BasicEffect* pBasicEffect = dynamic_cast<BasicEffect*>(effect);
        if (pBasicEffect)
        {
            pBasicEffect->SetTextureNormalMap(part.texNormalMap.Get());
            pBasicEffect->SetMaterial(part.material);
        }
        
        effect->Apply(deviceContext);

        deviceContext->DrawIndexed(part.indexCount, 0, 0);
    }
}

void GameObject::DrawInstanced(ID3D11DeviceContext* deviceContext, IEffect * effect, const std::vector<Transform> & data)
{
    // ...

    UINT strides[2] = { m_Model.vertexStride, sizeof(InstancedData) };
    UINT offsets[2] = { 0, 0 };
    ID3D11Buffer* buffers[2] = { nullptr, m_pInstancedBuffer.Get() };
    for (auto& part : m_Model.modelParts)
    {
        buffers[0] = part.vertexBuffer.Get();

        // 设置顶点/索引缓冲区
        deviceContext->IASetVertexBuffers(0, 2, buffers, strides, offsets);
        deviceContext->IASetIndexBuffer(part.indexBuffer.Get(), part.indexFormat, 0);

        // 更新数据并应用
        IEffectTextureDiffuse* pEffectTextureDiffuse = dynamic_cast<IEffectTextureDiffuse*>(effect);
        if (pEffectTextureDiffuse)
        {
            pEffectTextureDiffuse->SetTextureDiffuse(part.texDiffuse.Get());
        }

        BasicEffect* pBasicEffect = dynamic_cast<BasicEffect*>(effect);
        if (pBasicEffect)
        {
            pBasicEffect->SetTextureNormalMap(part.texNormalMap.Get());
            pBasicEffect->SetMaterial(part.material);
        }

        effect->Apply(deviceContext);

        deviceContext->DrawIndexedInstanced(part.indexCount, numInsts, 0, 0, 0);
    }
}

构建阴影贴图与更新

首先我们要在GameApp::InitResource中创建一副2048x2048的阴影贴图:

m_pShadowMap = std::make_unique<TextureRender>();
HR(m_pShadowMap->InitResource(m_pd3dDevice.Get(), 2048, 2048, true));

在本Demo中,光照方向每帧都在变动,我们希望让投影立方体与光照所属的变换轴对齐,并且中心能够坐落在原点。因此在GameApp::UpdateScene可以这么做:

//
// 投影区域为正方体,以原点为中心,以方向光为+Z朝向
//
XMMATRIX LightView = XMMatrixLookAtLH(dirVec * 20.0f * (-2.0f), g_XMZero, g_XMIdentityR1);
m_pShadowEffect->SetViewMatrix(LightView);

// 将NDC空间 [-1, +1]^2 变换到纹理坐标空间 [0, 1]^2
static XMMATRIX T(
    0.5f, 0.0f, 0.0f, 0.0f,
    0.0f, -0.5f, 0.0f, 0.0f,
    0.0f, 0.0f, 1.0f, 0.0f,
    0.5f, 0.5f, 0.0f, 1.0f);
// S = V * P * T
m_pBasicEffect->SetShadowTransformMatrix(LightView * XMMatrixOrthographicLH(40.0f, 40.0f, 20.0f, 60.0f) * T);

绘制过程

在最终的绘制过程如下:

void GameApp::DrawScene()
{
    assert(m_pd3dImmediateContext);
    assert(m_pSwapChain);

    m_pd3dImmediateContext->ClearRenderTargetView(m_pRenderTargetView.Get(), reinterpret_cast<const float*>(&Colors::Silver));
    m_pd3dImmediateContext->ClearDepthStencilView(m_pDepthStencilView.Get(), D3D11_CLEAR_DEPTH | D3D11_CLEAR_STENCIL, 1.0f, 0);
    
    // ******************
    // 绘制到阴影贴图
    //
    m_pShadowMap->Begin(m_pd3dImmediateContext.Get(), nullptr);
    {
        DrawScene(m_pShadowEffect.get());
    }
    m_pShadowMap->End(m_pd3dImmediateContext.Get());

    // ******************
    // 正常绘制场景
    //
    m_pBasicEffect->SetTextureShadowMap(m_pShadowMap->GetOutputTexture());
    DrawScene(m_pBasicEffect.get(), m_EnableNormalMap);

    // 绘制天空盒
    m_pDesert->Draw(m_pd3dImmediateContext.Get(), *m_pSkyEffect, *m_pCamera);

    // 解除深度缓冲区绑定
    m_pBasicEffect->SetTextureShadowMap(nullptr);
    m_pBasicEffect->Apply(m_pd3dImmediateContext.Get());

    // ******************
    // 调试绘制阴影贴图
    //
    if (m_EnableDebug)
    {
        if (m_GrayMode)
        {
            m_pDebugEffect->SetRenderOneComponentGray(m_pd3dImmediateContext.Get(), 0);
        }
        else
        {
            m_pDebugEffect->SetRenderOneComponent(m_pd3dImmediateContext.Get(), 0);
        }
        
        m_DebugQuad.Draw(m_pd3dImmediateContext.Get(), m_pDebugEffect.get());
        // 解除绑定
        m_pDebugEffect->SetTextureDiffuse(nullptr);
        m_pDebugEffect->Apply(m_pd3dImmediateContext.Get());
    }

    // Direct2D 部分...

    HR(m_pSwapChain->Present(0, 0));
}

void GameApp::DrawScene(BasicEffect* pBasicEffect, bool enableNormalMap)
{
    // 地面和石柱
    if (enableNormalMap)
    {
        pBasicEffect->SetRenderWithNormalMap(m_pd3dImmediateContext.Get(), IEffect::RenderObject);
        m_GroundT.Draw(m_pd3dImmediateContext.Get(), pBasicEffect);

        pBasicEffect->SetRenderWithNormalMap(m_pd3dImmediateContext.Get(), IEffect::RenderInstance);
        m_CylinderT.DrawInstanced(m_pd3dImmediateContext.Get(), pBasicEffect, m_CylinderTransforms);
    }
    else
    {
        pBasicEffect->SetRenderDefault(m_pd3dImmediateContext.Get(), IEffect::RenderObject);
        m_Ground.Draw(m_pd3dImmediateContext.Get(), pBasicEffect);

        pBasicEffect->SetRenderDefault(m_pd3dImmediateContext.Get(), IEffect::RenderInstance);
        m_Cylinder.DrawInstanced(m_pd3dImmediateContext.Get(), pBasicEffect, m_CylinderTransforms);
    }

    // 石球
    pBasicEffect->SetRenderDefault(m_pd3dImmediateContext.Get(), IEffect::RenderInstance);
    m_Sphere.DrawInstanced(m_pd3dImmediateContext.Get(), pBasicEffect, m_SphereTransforms);

    // 房屋
    pBasicEffect->SetRenderDefault(m_pd3dImmediateContext.Get(), IEffect::RenderObject);
    m_House.Draw(m_pd3dImmediateContext.Get(), pBasicEffect);
}

void GameApp::DrawScene(ShadowEffect* pShadowEffect)
{
    // 地面
    pShadowEffect->SetRenderDefault(m_pd3dImmediateContext.Get(), IEffect::RenderObject);
    m_Ground.Draw(m_pd3dImmediateContext.Get(), pShadowEffect);

    // 石柱
    pShadowEffect->SetRenderDefault(m_pd3dImmediateContext.Get(), IEffect::RenderInstance);
    m_Cylinder.DrawInstanced(m_pd3dImmediateContext.Get(), pShadowEffect, m_CylinderTransforms);

    // 石球
    pShadowEffect->SetRenderDefault(m_pd3dImmediateContext.Get(), IEffect::RenderInstance);
    m_Sphere.DrawInstanced(m_pd3dImmediateContext.Get(), pShadowEffect, m_SphereTransforms);

    // 房屋
    pShadowEffect->SetRenderDefault(m_pd3dImmediateContext.Get(), IEffect::RenderObject);
    m_House.Draw(m_pd3dImmediateContext.Get(), pShadowEffect);
}

演示

本Demo提供了5种斜率下的方向光,对应主键盘数字键1-5,Q键开关法线贴图,E键开关阴影贴图的显示,G键切换阴影贴图的显示模式。

DirectX11 With Windows SDK--31 阴影映射
前言
核心思想
阴影贴图
光源的投影
投影纹理坐标
算法思路
偏移与走样
百分比渐近过滤(PCF)
C++端代码实现
演示
透明物体的阴影绘制
练习题

透明物体的阴影绘制

但我们的例程还没有处理透明物体的阴影绘制。如果我们直接在场景中绘制一颗树(贴图存在Alpha值为0的部分),可以看到下图的阴影并不正确:

DirectX11 With Windows SDK--31 阴影映射
前言
核心思想
阴影贴图
光源的投影
投影纹理坐标
算法思路
偏移与走样
百分比渐近过滤(PCF)
C++端代码实现
演示
透明物体的阴影绘制
练习题

因此,我们需要在绘制阴影贴图的时候增加一个像素着色器用以进行Alpha裁剪,把Alpha值低于0.1的纹素给剔除掉,不要让其写入到阴影贴图:

Texture2D g_DiffuseMap : register(t0);
SamplerState g_Sam : register(s0);

struct VertexPosHTex
{
    float4 PosH : SV_POSITION;
    float2 Tex : TEXCOORD;
};

// 这仅仅用于Alpha几何裁剪,以保证阴影的显示正确。
// 对于不需要进行纹理采样操作的几何体可以直接将像素
// 着色器设为nullptr
void PS(VertexPosHTex pIn)
{
    float4 diffuse = g_DiffuseMap.Sample(g_Sam, pIn.Tex);
    
    // 不要将透明像素写入深度贴图
    clip(diffuse.a - 0.1f);
}

我们只在绘制树的时候使用带有像素着色器的版本,其余物体照常绘制。并且因为我们的BasicEffect默认绘制就带有Alpha裁剪,无需做这部分改动。最终效果如下:

DirectX11 With Windows SDK--31 阴影映射
前言
核心思想
阴影贴图
光源的投影
投影纹理坐标
算法思路
偏移与走样
百分比渐近过滤(PCF)
C++端代码实现
演示
透明物体的阴影绘制
练习题

练习题

  1. 尝试4096x4096、1024x1024、512x512、256x256这几种不同分辨率的阴影贴图
  2. 尝试以单次点采样阴影检测来修改本演示程序(即不采用PCF)。我们将欣赏到硬阴影与锯齿状的阴影边缘
  3. 关闭斜率缩放偏移来观察阴影粉刺
  4. 将斜率缩放偏移值放大10倍,观察peter panning失真的效果
  5. 实现单点光源下的阴影(必要时可以考虑像CubeMap那样使用6个正方形贴图)
  6. 修改项目代码,把绘制房屋改成绘制上图中的树(模型已给出),要求阴影显示正确

DirectX11 With Windows SDK完整目录

Github项目源码

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