DirectX11进阶7_着色器反射机制、Normal Mapping、Shadow Mapping

在这里插入图片描述

一、Shader Reflection

1.1 Effects11(FX11)

DirectX的特效是包含管线状态和着色器的集合,而Effects框架则正是用于管理这些特效的一套API。如果使用Effects11(FX11)框架的话,那么在HLSL中除了本身的语法外,还支持Effects特有的语法,这些语法大部分经过解析后会转化为在C++中使用Direct3D的API。

1.1.1 Pass、Technique11、Group

  • Pass:一个Pass由一组需要用到的着色器和一些渲染状态组成。通常情况下,我们至少需要一个顶点着色器和一个像素着色器。如果是要进行流输出,则至少需要一个顶点着色器和一个几何着色器。而通用计算则需要的是计算着色器。除此之外,它在HLSL还支持一些额外的函数,用以改变一些渲染状态。
  • Technique11:一个Technique由一个或多个Pass组成,用于创建一个渲染技术。有时候为了实现一种特效,需要历经多个Pass的处理才能实现,我们称之为多通道渲染。比如实现OIT(顺序无关透明度),第一趟Pass需要完成透明像素的收集,第二趟Pass则是将收集好的像素按深度排序,并将透明混合的结果渲染到目标。
  • Group:一个Group由一个或多个Technique组成。

1.2 着色器反射机制

编译好的着色器二进制数据中蕴含着丰富的信息,我们可以通过着色器反射机制来获取自己所需要的东西,然后构建一个属于自己的Effects类。

1.2.1 着色器反射对象

D3DReflect函数–获取着色器反射对象
在调用该函数之前需要使用D3DCompile或D3DCompileFromFile产生编译好的着色器二进制对象ID3DBlob:

HRESULT D3DReflect(
	LPCVOID pSrcData,		// [In]编译好的着色器二进制信息
	SIZE_T  SrcDataSize,	// [In]编译好的着色器二进制信息字节数
	REFIID  pInterface,		// [In]COM组件的GUID
	void    **ppReflector	// [Out]输出的着色器反射借口
);

其中pInterface为__uuidof(ID3D11ShaderReflection)时,返回的是ID3D11ShaderReflection接口对象;而pInterface为__uuidof(ID3D12ShaderReflection)时,返回的是ID3D12ShaderReflection接口对象。

ID3D11ShaderReflection提供了大量的方法给我们获取信息,其中我们比较感兴趣的主要信息有:

  • 着色器本身的信息
  • 常量缓冲区的信息
  • 采样器、资源的信息

1.2.2 着色器本身的信息

通过方法ID3D11ShaderReflection::GetDesc,我们可以获取到D3D11_SHADER_DESC对象。这里面包含了大量的基础信息:

typedef struct _D3D11_SHADER_DESC {
  UINT                             Version;						// 着色器版本、类型信息
  LPCSTR                           Creator;						// 是谁创建的着色器
  UINT                             Flags;						// 着色器编译/分析标签
  UINT                             ConstantBuffers;				// 实际使用到常量缓冲区数目
  UINT                             BoundResources;				// 实际用到绑定的资源数目
  UINT                             InputParameters;				// 输入参数数目(4x4矩阵为4个向量形参)
  UINT                             OutputParameters;			// 输出参数数目
  UINT                             InstructionCount;			// 指令数
  UINT                             TempRegisterCount;			// 实际使用到的临时寄存器数目
  UINT                             TempArrayCount;				// 实际用到的临时数组数目
  UINT                             DefCount;					// 常量定义数目
  UINT                             DclCount;					// 声明数目(输入+输出)
  UINT                             TextureNormalInstructions;	// 未分类的纹理指令数目
  UINT                             TextureLoadInstructions;		// 纹理读取指令数目
  UINT                             TextureCompInstructions;		// 纹理比较指令数目
  UINT                             TextureBiasInstructions;		// 纹理偏移指令数目
  UINT                             TextureGradientInstructions;	// 纹理梯度指令数目
  UINT                             FloatInstructionCount;		// 实际用到的浮点数指令数目
  UINT                             IntInstructionCount;			// 实际用到的有符号整数指令数目
  UINT                             UintInstructionCount;		// 实际用到的无符号整数指令数目
  UINT                             StaticFlowControlCount;		// 实际用到的静态流控制指令数目
  UINT                             DynamicFlowControlCount;		// 实际用到的动态流控制指令数目
  UINT                             MacroInstructionCount;		// 实际用到的宏指令数目
  UINT                             ArrayInstructionCount;		// 实际用到的数组指令数目
  UINT                             CutInstructionCount;			// 实际用到的cut指令数目
  UINT                             EmitInstructionCount;		// 实际用到的emit指令数目
  D3D_PRIMITIVE_TOPOLOGY           GSOutputTopology;			// 几何着色器的输出图元
  UINT                             GSMaxOutputVertexCount;		// 几何着色器的最大顶点输出数目
  D3D_PRIMITIVE                    InputPrimitive;				// 输入装配阶段的图元
  UINT                             PatchConstantParameters;		// 待填坑...
  UINT                             cGSInstanceCount;			// 几何着色器的实例数目
  UINT                             cControlPoints;				// 域着色器和外壳着色器的控制点数目
  D3D_TESSELLATOR_OUTPUT_PRIMITIVE HSOutputPrimitive;			// 镶嵌器输出的图元类型
  D3D_TESSELLATOR_PARTITIONING     HSPartitioning;				// 待填坑...
  D3D_TESSELLATOR_DOMAIN           TessellatorDomain;			// 待填坑...
  UINT                             cBarrierInstructions;		// 计算着色器内存屏障指令数目
  UINT                             cInterlockedInstructions;	// 计算着色器原子操作指令数目
  UINT                             cTextureStoreInstructions;	// 计算着色器纹理写入次数
} D3D11_SHADER_DESC;

其中,成员Version不仅包含了着色器版本,还包含着色器类型。下面的枚举值定义了着色器的类型,并通过宏D3D11_SHVER_GET_TYPE来获取:

typedef enum D3D11_SHADER_VERSION_TYPE
{
    D3D11_SHVER_PIXEL_SHADER    = 0,
    D3D11_SHVER_VERTEX_SHADER   = 1,
    D3D11_SHVER_GEOMETRY_SHADER = 2,
    
    // D3D11 Shaders
    D3D11_SHVER_HULL_SHADER     = 3,
    D3D11_SHVER_DOMAIN_SHADER   = 4,
    D3D11_SHVER_COMPUTE_SHADER  = 5,

    D3D11_SHVER_RESERVED0       = 0xFFF0,
} D3D11_SHADER_VERSION_TYPE;

#define D3D11_SHVER_GET_TYPE(_Version) \
    (((_Version) >> 16) & 0xffff)

即:

auto shaderType = static_cast<D3D11_SHADER_VERSION_TYPE>(D3D11_SHVER_GET_TYPE(sd.Version));

1.2.3 描述着色器资源如何绑定到着色器输入

为了获取着色器程序内声明的一切给着色器使用的对象,从这个结构体入手是一种十分不错的选择。我们将使用ID3D11ShaderReflection::GetResourceBindingDesc方法,和枚举显示适配器那样从索引0开始枚举一样的做法,只要当前的索引值获取失败,说明已经获取完所有的输入对象:

for (UINT i = 0;; ++i)
{
	D3D11_SHADER_INPUT_BIND_DESC sibDesc;
	hr = pShaderReflection->GetResourceBindingDesc(i, &sibDesc);
	// 读取完变量后会失败,但这并不是失败的调用
	if (FAILED(hr))
		break;
    
    // 根据sibDesc继续分析...
}
注意:那些在着色器代码中从未被当前着色器使用过的资源将不会被枚举出来,并且在着色器调试和着色器反射的时候看不到它们,而反汇编中也许能够看到该变量被标记为unused。

现在先来看该结构体的成员:

typedef struct _D3D11_SHADER_INPUT_BIND_DESC {
	LPCSTR                   Name;			// 着色器资源名
	D3D_SHADER_INPUT_TYPE    Type;			// 资源类型
	UINT                     BindPoint;		// 指定的输入槽起始位置
	UINT                     BindCount;		// 对于数组而言,占用了多少个槽
	UINT                     uFlags;		// D3D_SHADER_INPUT_FLAGS枚举复合
	D3D_RESOURCE_RETURN_TYPE ReturnType;	// 
	D3D_SRV_DIMENSION        Dimension;		// 着色器资源类型
	UINT                     NumSamples;	// 若为纹理,则为MSAA采样数,否则为0xFFFFFFFF
} D3D11_SHADER_INPUT_BIND_DESC;

其中成员Name帮助我们使用着色器反射按名获取资源,而成员Type帮助我们确定资源类型。这两个成员一旦确定下来,对我们开展更详细的着色器反射和实现自己的特效框架提供了巨大的帮助。具体枚举如下:

typedef enum _D3D_SHADER_INPUT_TYPE {
  D3D_SIT_CBUFFER,
  D3D_SIT_TBUFFER,
  D3D_SIT_TEXTURE,
  D3D_SIT_SAMPLER,
  D3D_SIT_UAV_RWTYPED,
  D3D_SIT_STRUCTURED,
  D3D_SIT_UAV_RWSTRUCTURED,
  D3D_SIT_BYTEADDRESS,
  D3D_SIT_UAV_RWBYTEADDRESS,
  D3D_SIT_UAV_APPEND_STRUCTURED,
  D3D_SIT_UAV_CONSUME_STRUCTURED,
  D3D_SIT_UAV_RWSTRUCTURED_WITH_COUNTER,
  // ...
} D3D_SHADER_INPUT_TYPE;

根据上述枚举可以分为常量缓冲区、采样器、着色器资源、可读写资源四大类。对于采样器、着色器资源和可读写资源我们只需要知道它设置在哪个slot即可,但对于常量缓冲区,我们还需要知道其内部的成员和位于哪一段内存区域。

1.2.4 描述一个着色器的常量缓冲区

D3D11_SHADER_BUFFER_DESC结构体–描述一个着色器的常量缓冲区
在通过上面提到的枚举值判定出来是常量缓冲区后,我们就可以通过ID3D11ShaderReflection::GetConstantBufferByName迅速拿下常量缓冲区的反射,然后再获取D3D11_SHADER_BUFFER_DESC的信息:

ID3D11ShaderReflectionConstantBuffer* pSRCBuffer = pShaderReflection->GetConstantBufferByName(sibDesc.Name);
// 获取cbuffer内的变量信息并建立映射
D3D11_SHADER_BUFFER_DESC cbDesc{};
hr = pSRCBuffer->GetDesc(&cbDesc);
if (FAILED(hr))
	return hr;

该结构体定义如下:

typedef struct _D3D11_SHADER_BUFFER_DESC {
	LPCSTR           Name;		// 常量缓冲区名称
	D3D_CBUFFER_TYPE Type;		// D3D_CBUFFER_TYPE枚举值
	UINT             Variables;	// 内部变量数目
	UINT             Size;		// 缓冲区字节数
	UINT             uFlags;	// D3D_SHADER_CBUFFER_FLAGS枚举复合
} D3D11_SHADER_BUFFER_DESC;

根据成员Variables,我们就可以确定查询变量的次数。

1.2.5 描述一个着色器的变量

常量缓冲区内的数据是可以改变的,但是在着色器运行的时候,cbuffer内的任何变量就不可以被修改了。因此对C++来说,它是可变量,但对着色器来说,它是常量。

好了不扯那么多,现在我们用这样一个循环,通过ID3D11ShaderReflectionVariable::GetVariableByIndex来逐一枚举着色器变量的反射,然后获取D3D11_SHADER_VARIABLE_DESC的信息:

// 记录内部变量
for (UINT j = 0; j < cbDesc.Variables; ++j)
{
    ID3D11ShaderReflectionVariable* pSRVar = pSRCBuffer->GetVariableByIndex(j);
    D3D11_SHADER_VARIABLE_DESC svDesc;
    hr = pSRVar->GetDesc(&svDesc);
    if (FAILED(hr))
        return hr;
    // ...
}

那么D3D11_SHADER_VARIABLE_DESC的定义如下:

typedef struct _D3D11_SHADER_VARIABLE_DESC {
	LPCSTR Name;			// 变量名
	UINT   StartOffset;		// 起始偏移
	UINT   Size;			// 大小
	UINT   uFlags;			// D3D_SHADER_VARIABLE_FLAGS枚举复合
	LPVOID DefaultValue;	// 用于初始化变量的默认值
	UINT   StartTexture;	// 从变量开始到纹理开始的偏移量[看不懂]
	UINT   TextureSize;		// 纹理字节大小
	UINT   StartSampler;	// 从变量开始到采样器开始的偏移量[看不懂]
	UINT   SamplerSize;		// 采样器字节大小
} D3D11_SHADER_VARIABLE_DESC;

其中前三个参数是我们需要的,由此我们就可以构建出根据变量名来设置值和获取值的一套方案。

讲到这里其实已经满足了我们构建一个最小特效管理类的需求。但你如果想要获得更详细的变量信息,则可以继续往下读,这里只会粗略讲述。

1.2.6 描述着色器变量类型

现在我们已经获得了一个着色器变量的反射,那么可以通过ID3D11ShaderReflectionVariable::GetType获取着色器变量类型的反射,然后获取D3D11_SHADER_TYPE_DESC的信息:

ID3D11ShaderReflectionType* pSRType = pSRVar->GetType();
D3D11_SHADER_TYPE_DESC stDesc;
hr = pSRType->GetDesc(&stDesc);
if (FAILED(hr))
	return hr;

D3D11_SHADER_TYPE_DESC的定义如下:

typedef struct _D3D11_SHADER_TYPE_DESC {
	D3D_SHADER_VARIABLE_CLASS Class;		// 说明它是标量、矢量、矩阵、对象,还是类型
	D3D_SHADER_VARIABLE_TYPE  Type;			// 说明它是BOOL、INT、FLOAT,还是别的类型
	UINT                      Rows;			// 矩阵行数
	UINT                      Columns;		// 矩阵列数
	UINT                      Elements;		// 数组元素数目
	UINT                      Members;		// 结构体成员数目
	UINT                      Offset;		// 在结构体中的偏移,如果不是结构体则为0
	LPCSTR                    Name;			// 着色器变量类型名,如果变量未被使用则为NULL
} D3D11_SHADER_TYPE_DESC;

如果它是个结构体,就还能通过ID3D11ShaderReflectionType::GetMemberTypeByIndex方法继续获取子类别。。。

二、法线贴图

在这里插入图片描述

在很早之前的纹理映射中,纹理存放的元素是像素的颜色,通过纹理坐标映射到目标像素以获取其颜色。但是我们的法向量依然只是定义在顶点上,对于三角形面内一点的法向量,也只是通过比较简单的插值法计算出相应的法向量值。这对平整的表面比较有用,但无法表现出内部粗糙的表面。

2.1 原理

法线贴图是指纹理中实际存放的元素通常是经过压缩后的法向量,用于表现一个表面凹凸不平的特性,它是凹凸贴图的一种实现方式。

法线贴图中存放的法向量( x , y , z ) 分别对应原来的( r , g , b )。每个像素都存放了对应的一个法向量,经过压缩后使用24 bit即可表示。实际情况则是一张法线贴图里面的每个像素使用了32 bit来表示,剩余的8 bit(位于Alpha值)要么可以不使用,要么用来表示高度值或者镜面系数。而未经压缩的法线贴图通常为每个像素存放4个浮点数,即使用128 bit来表示。

下面展示了一张法线贴图,每个像素点位置存放了任意方向的法向量。可以看到这里为法线贴图建立了一个TBN坐标系(左手坐标系),其中T轴(Tangent Axis)对应原来的x轴,B轴(Binormal Axis)对应原来的y轴,N轴(Normal Axis)对应原来的z轴。建立坐标系的目的在后面再详细描述。观察这些法向量,它们都有一个共同的特点,就是都朝着N轴的正方向散射,这样使得大多数法向量的z分量是最大的。
在这里插入图片描述
由于压缩后的法线贴图通常是以R8G8B8A8的格式存储,我们也可以直接把它当做图片来打开观察。
在这里插入图片描述
前面说到大部分法向量的z分量会比x, y分量大,导致整个图看起来会偏蓝。

2.2 法线贴图的压缩与解压

经过初步压缩后的法线贴图的占用空间为原来的1/4(不考虑文件头),就算每个分量只有256种表示,也足够表示出16777216种不同的法向量了。假如现在我们已经有未经过压缩的法线贴图,那要怎么进行初步压缩呢?

对于一个单位法向量来说,其任意一个分量的取值也无非就是落在[-1, 1]的区间上。现在我们要将其映射到[0, 255]的区间上,可以用下面的公式来进行压缩:
在这里插入图片描述
而如果现在拿到的是24位法向量,要进行还原,则可以用下面的公式:
在这里插入图片描述
当然,经过还原后的法向量是有部分的精度损失了,至少能够映射回[-1, 1]的区间上。

通常情况下我们能拿到的都是经过压缩后的法线贴图,但是还原工作还是需要由自己来完成。

float3 normalT = gNormalMap.Sample(sam, pin.Tex);

经过上面的采样后,normalT的每个分量会自动从[0, 255]映射到[0, 1],但还不是最终[-1, 1]的区间。因此我们还需要完成下面这一步:

normalT = 2.0f * normalT - 1.0f;

这里的1.0f会扩展成float3(1.0f, 1.0f, 1.0f)以完成减法运算。

注意:
如果你想要使用压缩纹理格式(对原来的R8G8B8A8进一步压缩)来存储法线贴图,可以使用BC7(DXGI_FORMAT_BC7_UNORM)来获得最佳性能。在DirectXTex中有大量从BC1到BC7的纹理压缩/解压函数。

2.3 纹理/切线空间

这里开始就会产生一个疑问了,为什么需要切线空间?

在只有2D的纹理坐标系仅包含了U轴和V轴,但现在我们的纹理中存放的是法向量,这些法向量要怎么变换到局部物体上某一个三角形对应位置呢?这就需要我们对当前法向量做一次矩阵变换(平移和旋转),使它能够来到局部坐标系下物体的某处表面。由于矩阵变换涉及到的是坐标系变换,我们需要先在原来的2D纹理坐标系加一条坐标轴(N轴),与T轴(原来的U轴)和B轴(原来的V轴)相互垂直,以此形成切线空间。

一开始法向量处在单位切线空间,而需要变换到目标3D三角形的位置也有一个对应的切线空间。对于一个立方体来说,一个面的两个三角形可以共用一个切线空间。
在这里插入图片描述

2.3.1 利用顶点位置和纹理坐标求TBN坐标系

现在假设我们的顶点只包含了位置和纹理坐标这两个信息,有这样一个三角形,它们的顶点为V0(x0, y0, z0), V1(x1, y1, z1), V2(x2, y2, z2),纹理坐标为(u0, v0), (u1, v1), (u2, v2)。
在这里插入图片描述
此方法存在问题:由于位置坐标和纹理坐标的不一致性,导致求出来的T向量和B向量很有可能不是单位向量。仅当位置坐标的变化率与纹理坐标的变化率相同时才会得到单位向量。这里我们将其进行标准化即可。

但如果对纹理坐标进行了变换,有可能导致T轴和B轴不相互垂直。比如尝试用球体网格模型某个三角形面内的一点映射到球面上一点。

2.4 顶点切线空间

上面的方法运算得到的切线空间是基于单个三角形的,其运算过程还是比较复杂,而且交给着色器来进行运算的话还会产生大量的指令。

我们可以为顶点添加法向量N和切线向量T用于构建基于顶点的切线空间。很早之前提到法向量是与该顶点共用的所有三角形的法向量取平均值所得到的。切线向量也一样,它是与该顶点共用的所有三角形的切线向量取平均值所得到的。

现在Vertex.h定义了我们的新顶点类型:

struct VertexPosNormalTangentTex
{
	DirectX::XMFLOAT3 pos;
	DirectX::XMFLOAT3 normal;
	DirectX::XMFLOAT4 tangent;
	DirectX::XMFLOAT2 tex;
	static const D3D11_INPUT_ELEMENT_DESC inputLayout[4];
};

这里的tangent是一个4D向量,考虑到要和微软DXTK定义的顶点类型保持一致,多出来的w分量可以留作他用,这里暂不讨论。

2.4.1 施密特向量正交化

通常顶点提供的NT通常是相互垂直的,并且都是单位向量,我们可以通过计算B = N × T 来得到副法线向量B,使得顶点可以不需要存放副法线向量B。但是经过插值计算后的NT可能会导致不是相互垂直,我们最好还是要通过施密特正交化来获得实际的切线空间。

现在已知互不垂直的N向量和T向量,我们希望求出与N向量垂直的T’向量,需要将T向量投影到N向量上。
在这里插入图片描述
从上面的图我们可以知道最终求得的T’
在这里插入图片描述

B’ 最终也可以确定下来
在这里插入图片描述

这样T’, B’, N相互垂直,可以构成TBN坐标系。在后面的着色器实现中我们也会用到这部分内容。

2.4.2 切线空间的变换

一开始的切线空间可以用一个单位矩阵来表示,切线向量正是处在这个空间中。紧接着就是需要对其进行一次到局部对象(具体到某个三角形)切线空间的变换:
在这里插入图片描述
然后切线向量随同世界矩阵一同进行变换来到世界坐标系,因此我们可以把它写成:
在这里插入图片描述

注意:
对切线向量进行矩阵变换,我们只需要使用3x3的矩阵即可。
法线向量变换到世界矩阵需要用世界矩阵求逆的转置进行校正,而对切线向量只需要用世界矩阵变换即可。下图演示了将宽度拉伸为原来2倍后,法线和切线向量的变化:

在这里插入图片描述

1.5 顶点切线空间

为了使用法线贴图,我们需要完成下列步骤:

  1. 获取该纹理所需要用到的法线贴图,在C++端为其创建一个ID3D11Texture2D。这里不考虑如何制作一张法线贴图。
  2. 对于一个网格模型来说,顶点数据需要包含位置、法向量、切线向量、纹理坐标四个元素。同样这里不讨论模型的制作,在本教程使用的是Geometry所生成的网格模型
  3. 在顶点着色器中,将顶点法向量和切线向量从局部坐标系变换到世界坐标系
  4. 在像素着色器中,使用经过插值的法向量和切线向量来为每个三角形表面的像素点构建TBN坐标系,然后将切线空间的法向量变换到世界坐标系中,这样最终求得的法向量用于光照计算。

在Basic.hlsli中新增切线向量,变化如下:

Texture2D g_DiffuseMap : register(t0);
//法线纹理
Texture2D g_NormalMap : register(t1);
TextureCube g_TexCube : register(t2);
SamplerState g_Sam : register(s0);

// 省略和之前一样的结构体...

struct VertexPosNormalTangentTex
{
    float3 PosL : POSITION;
    float3 NormalL : NORMAL;
    float4 TangentL : TANGENT;
    float2 Tex : TEXCOORD;
};

struct InstancePosNormalTangentTex
{
    float3 PosL : POSITION;
    float3 NormalL : NORMAL;
    float4 TangentL : TANGENT;
    float2 Tex : TEXCOORD;
    matrix World : World;
    matrix WorldInvTranspose : WorldInvTranspose;
};

struct VertexPosHWNormalTangentTex
{
    float4 PosH : SV_POSITION;
    float3 PosW : POSITION; // 在世界中的位置
    float3 NormalW : NORMAL; // 法向量在世界中的方向
    float4 TangentW : TANGENT; // 切线在世界中的方向
    float2 Tex : TEXCOORD;
};

float3 NormalSampleToWorldSpace(float3 normalMapSample,
    float3 unitNormalW,
    float4 tangentW)
{
    // 将读取到法向量中的每个分量从[0, 1]还原到[-1, 1]
    float3 normalT = 2.0f * normalMapSample - 1.0f;

    // 构建位于世界坐标系的切线空间
    float3 N = unitNormalW;
    float3 T = normalize(tangentW.xyz - dot(tangentW.xyz, N) * N); // 施密特正交化
    float3 B = cross(N, T);

    float3x3 TBN = float3x3(T, B, N);

    // 将凹凸法向量从切线空间变换到世界坐标系
    float3 bumpedNormalW = mul(normalT, TBN);

    return bumpedNormalW;
}

上面的NormalSampleToWorldSpace函数用于将法向量从切线空间变换到世界空间,位于Basic.hlsli。它接受了3个参数:从法线贴图采样得到的向量,变换到世界坐标系的法向量和切线向量。

然后是顶点着色器:

// NormalMapObject_VS.hlsl
#include "Basic.hlsli"

// 顶点着色器
VertexPosHWNormalTangentTex VS(VertexPosNormalTangentTex vIn)
{
    VertexPosHWNormalTangentTex vOut;
    
    matrix viewProj = mul(g_View, g_Proj);
    vector posW = mul(float4(vIn.PosL, 1.0f), g_World);

    vOut.PosW = posW.xyz;
    vOut.PosH = mul(posW, viewProj);
    vOut.NormalW = mul(vIn.NormalL, (float3x3) g_WorldInvTranspose);
    vOut.TangentW = mul(vIn.TangentL, g_World);
    vOut.Tex = vIn.Tex;
    return vOut;
}

相比之前的像素着色器,现在它多了对法线映射的处理:

...
// 法线映射
float3 normalMapSample = g_NormalMap.Sample(g_Sam, pIn.Tex).rgb;
float3 bumpedNormalW = NormalSampleToWorldSpace(normalMapSample, pIn.NormalW, pIn.TangentW);
...

求得的法向量bumpedNormalW将用于光照计算。

现在完整的像素着色器代码如下:

// NormalMap_PS.hlsl
#include "Basic.hlsli"

// 像素着色器(3D)
float4 PS(VertexPosHWNormalTangentTex pIn) : SV_Target
{
    // 若不使用纹理,则使用默认白色
    float4 texColor = float4(1.0f, 1.0f, 1.0f, 1.0f);

    if (g_TextureUsed)
    {
        texColor = g_DiffuseMap.Sample(g_Sam, pIn.Tex);
        // 提前进行裁剪,对不符合要求的像素可以避免后续运算
        clip(texColor.a - 0.1f);
    }
    
    // 标准化法向量
    pIn.NormalW = normalize(pIn.NormalW);

    // 求出顶点指向眼睛的向量,以及顶点与眼睛的距离
    float3 toEyeW = normalize(g_EyePosW - pIn.PosW);
    float distToEye = distance(g_EyePosW, pIn.PosW);

    // 法线映射
    float3 normalMapSample = g_NormalMap.Sample(g_Sam, pIn.Tex).rgb;
    float3 bumpedNormalW = NormalSampleToWorldSpace(normalMapSample, pIn.NormalW, pIn.TangentW);

    // 初始化为0 
    float4 ambient = float4(0.0f, 0.0f, 0.0f, 0.0f);
    float4 diffuse = float4(0.0f, 0.0f, 0.0f, 0.0f);
    float4 spec = float4(0.0f, 0.0f, 0.0f, 0.0f);
    float4 A = float4(0.0f, 0.0f, 0.0f, 0.0f);
    float4 D = float4(0.0f, 0.0f, 0.0f, 0.0f);
    float4 S = float4(0.0f, 0.0f, 0.0f, 0.0f);
    int i;

    [unroll]
    for (i = 0; i < 5; ++i)
    {
        ComputeDirectionalLight(g_Material, g_DirLight[i], bumpedNormalW, toEyeW, A, D, S);
        ambient += A;
        diffuse += D;
        spec += S;
    }
        
    [unroll]
    for (i = 0; i < 5; ++i)
    {
        ComputePointLight(g_Material, g_PointLight[i], pIn.PosW, bumpedNormalW, toEyeW, A, D, S);
        ambient += A;
        diffuse += D;
        spec += S;
    }

    [unroll]
    for (i = 0; i < 5; ++i)
    {
        ComputeSpotLight(g_Material, g_SpotLight[i], pIn.PosW, bumpedNormalW, toEyeW, A, D, S);
        ambient += A;
        diffuse += D;
        spec += S;
    }
  
    float4 litColor = texColor * (ambient + diffuse) + spec;

    // 反射
    if (g_ReflectionEnabled)
    {
        float3 incident = -toEyeW;
        float3 reflectionVector = reflect(incident, pIn.NormalW);
        float4 reflectionColor = g_TexCube.Sample(g_Sam, reflectionVector);

        litColor += g_Material.Reflect * reflectionColor;
    }
    // 折射
    if (g_RefractionEnabled)
    {
        float3 incident = -toEyeW;
        float3 refractionVector = refract(incident, pIn.NormalW, g_Eta);
        float4 refractionColor = g_TexCube.Sample(g_Sam, refractionVector);

        litColor += g_Material.Reflect * refractionColor;
    }

    litColor.a = texColor.a * g_Material.Diffuse.a;
    return litColor;
}


三、阴影映射

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

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

3.1 阴影贴图

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

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

3.2 光源的投影

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

  • 透视投影

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

  • 正交投影

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

3.3 投影纹理坐标

投影纹理贴图技术能够将纹理投射到任意形状的几何体上,又因为其原理与投影机的工作方式比较相似,由此得名。

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

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

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

在这里插入图片描述
而步骤2中的变换过程则取决于下面的坐标变换:
在这里插入图片描述
即从x, y∈[-1, 1]映射到u, v∈[0, 1]。(y轴和v轴是相反的)
这种线性变换可以用矩阵表示:
在这里插入图片描述
那么物体上的一点p从局部坐标系到最终的纹理坐标点t的变换过程为:
在这里插入图片描述
这里补上了世界变换矩阵,是因为这一步容易在后面的代码实践中被漏掉。但此时的t还需要经过透视除法,才是我们最终需要的纹理坐标。

3.4 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);
    
    // ...
}

3.5 算法思路

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

在这里插入图片描述

3.6 阴影优化

3.6.1 偏移与走样

阴影图存储的是距离光源最近的可视像素深度值,但是它的分辨率有限,导致每一个阴影图纹素都要表示场景中的一片区域。因此,阴影图只是以光源视角针对场景深度进行的离散采样,这将会导致所谓的阴影粉刺等图像走样问题。

而下图则简单展示了为什么会发生阴影粉刺这种现象。由于阴影图的分辨率有限,所以每个阴影图纹素要对应于长江中的一块区域(而不是点对点的关系,一个坡面代表阴影图中一个纹素的对应范围)。从观察点E查看场景中的两个点p1与p2,它们分别对应于两个不同的屏幕像素。但是,从光源的观察角度来看,它们却都有着相同的阴影图纹素(即s(p1)=s(p2)=s,由于分辨率的原因)。当我们在执行阴影图检测时,会得到d(p1) > s 及 d(p2) <= s这两个测试结果,这样一来,p1将会被绘制为如同它在阴影中的颜色,p2将被渲染为好似它在阴影之外的颜色,从而导致阴影粉刺。
在这里插入图片描述
因此,我们可以通过偏移阴影图中的深度值来防止出现错误的阴影效果。此时我们就可以保证d(p1) <= s 及 d(p2) <= s。但是寻找合适的深度偏移需要反复尝试。
在这里插入图片描述
然而,并没有哪一种固定的偏移量可以正确地运用于所有几何体的阴影绘制。特别是下图那种(从光源的角度来看)有着极大斜率的三角形,这时候就需要选取更大的偏移量。但是,如果试图通过一个过大的深度偏移量来处理所有的斜边,则又会造成peter-panning问题。
在这里插入图片描述
因此,我们绘制阴影的方式就是先以光源视角度量多边形斜面的斜率,并为斜率较大的多边形应用更大的偏移量。而图形硬件内部对此有相关技术的支持,我们通过名为斜率缩放偏移的光栅化状态属性就能够轻松实现。

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

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

// 如果当前的深度缓冲区采用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

注意:深度偏移发生在光栅化期间(裁剪之后),因此不会对几何体裁剪造成影响
添加了这样一个光栅化状态:

// 深度偏移模式
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()));

3.6.2 百分比渐近过滤(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,如下图所示。
在这里插入图片描述
接下来,我们会对这些采集的深度值进行阴影图检测,并对测试的结果展开双线性插值。

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%处于阴影之中。这就让阴影内外的像素之间有了更加平滑的过渡,而不是棱角分明。

但这种过滤方法产生的阴影看起来仍然非常生硬,且锯齿失真问题的最终处理效果还是不能令人十分满意。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

添加了这样一个采样器:

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_TYPELESS、R32_FLOAT,R24_UNORM_X8_TYPELESS、R16_UNORM格式才能用于比较过滤器。

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

到目前为止,我们在本节中一直使用的是4-tap PCF核(输入4个样本来执行的PCF)。PCF核越大,阴影的边缘轮廓也就越丰满、越平滑,当然,花费在SampleCmpLevelZero函数上的开销也就越大。我们是按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;
}
// ...

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

3.7 C++端代码实现

3.7.1 阴影贴图初始化

在作为RTT时,需要创建纹理与它的SRV和RTV、深度/模板缓冲区和它的DSV、视口
而作为阴影贴图时,需要创建深度缓冲区与它的SRV和DSV、视口
下面的代码只关注创建阴影贴图的部分:

HRESULT 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 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);
}

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

3.7.2 构建阴影贴图与更新

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

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

光照方向每帧都在变动,我们希望让投影立方体与光照所属的变换轴对齐,并且中心能够坐落在原点。因此在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);

3.7.3 绘制过程

最终的绘制过程伪代码如下:

void 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);
    {
        //绘制场景生成阴影贴图;
        ...
    }
    m_pShadowMap->End(m_pd3dImmediateContext.Get());

    // 正常绘制场景
	...

    // 绘制天空盒
    ...

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

    // Direct2D 部分...

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

©️2020 CSDN 皮肤主题: 技术工厂 设计师:CSDN官方博客 返回首页