dx12 龙书第二十章学习笔记 -- 阴影贴图

24 篇文章 10 订阅

对于龙书这本入门级别的书籍来说,我们仅关注于基本的阴影贴图算法。而像级联阴影贴图(cascading shadow map)[Engel06]这种效果更佳却也更为复杂的阴影技术,实则都是由这基本的阴影贴图算法扩展而成的。

1.渲染场景深度

阴影贴图(shadow mapping,或阴影映射)算法依赖于以光源的视角渲染场景深度。本节将考察一个名为ShadowMap的工具类,它所存储的是以光源为视角而得到的场景深度数据。阴影贴图中那个深度/模板缓冲区就是阴影图(shadow map)。

class ShadowMap
{
public:
	ShadowMap(ID3D12Device* device,
		UINT width, UINT height);
		
	ShadowMap(const ShadowMap& rhs)=delete;
	ShadowMap& operator=(const ShadowMap& rhs)=delete;
	~ShadowMap()=default;

    UINT Width()const;
    UINT Height()const;
	ID3D12Resource* Resource();
	CD3DX12_GPU_DESCRIPTOR_HANDLE Srv()const;
	CD3DX12_CPU_DESCRIPTOR_HANDLE Dsv()const;

	D3D12_VIEWPORT Viewport()const;
	D3D12_RECT ScissorRect()const;
    
	void BuildDescriptors(
		CD3DX12_CPU_DESCRIPTOR_HANDLE hCpuSrv,
		CD3DX12_GPU_DESCRIPTOR_HANDLE hGpuSrv,
		CD3DX12_CPU_DESCRIPTOR_HANDLE hCpuDsv); 

	void OnResize(UINT newWidth, UINT newHeight);

private:
	void BuildDescriptors();
	void BuildResource(); // 创建Resource

private:

	ID3D12Device* md3dDevice = nullptr;

	D3D12_VIEWPORT mViewport;
	D3D12_RECT mScissorRect;

	UINT mWidth = 0;
	UINT mHeight = 0;
	DXGI_FORMAT mFormat = DXGI_FORMAT_R24G8_TYPELESS;
    
    // 句柄:视图在描述符堆的位置
	CD3DX12_CPU_DESCRIPTOR_HANDLE mhCpuSrv;
	CD3DX12_GPU_DESCRIPTOR_HANDLE mhGpuSrv;
	CD3DX12_CPU_DESCRIPTOR_HANDLE mhCpuDsv; 

	Microsoft::WRL::ComPtr<ID3D12Resource> mShadowMap = nullptr;
};

ShadowMap构造函数会根据指定的尺寸和视口创建纹理,阴影图的分辨率(resolution)会直接影响阴影效果的质量。

阴影贴图算法需要执行两次渲染过程(render pass):①第一次渲染,在光源视角将场景深度数据渲染至阴影图中(depth pass);②第二次渲染,以玩家摄像机视角渲染场景至后台缓冲区中,此时阴影图作为着色器的输入之一。

2.正交投影

正交投影只适用于模拟平行光生成的阴影。沿着观察空间z轴的正方向看去,正交投影的视景图(viewing volume)是宽度为w、高度为h、近平面为n、远平面为f的对齐于观察空间坐标轴的长方体。比如顶点(x,y,z)的2D投影为(x,y)。

与透视投影一样,我们既希望保留正交投影中的深度信息,又希望采用NDC(规格化设备坐标)。我们需要利用缩放和平移操作完成坐标映射:

[-w/2,w/2]\times [-h/2,h/2]\times [n,f]=>[-1,1]\times [-1,1]\times[0,1]

对于xy坐标,我们只需乘以缩放系数;对于z坐标,我们构造保序函数即可完成坐标映射。

所以正交投影矩阵

[x,y,z,1]\begin{bmatrix} \frac{2}{w} & 0 & 0 & 0\\ 0 & \frac{2}{h} &0 &0 \\ 0 & 0& \frac{1}{f-n} &0 \\ 0 & 0 & \frac{-n}{f-n} & 1 \end{bmatrix}=[x',y',z',1]

正交投影变换相比于透视投影变换来说,只有线性变换,不需要除以w分量来完成非线性变换。

3.投影纹理坐标

投影纹理贴图(projective texturing, 投影纹理映射)技术能够将纹理映射到任意形状的几何体上。投影纹理贴图模拟了投影机投影光线的过程。投影纹理贴图的关键在于为每个像素生成对应的纹理坐标,从视觉上给人一种纹理投射到几何体上的感觉。投影纹理贴图在阴影贴图中充当了一个关键的作用。

生成投影纹理坐标的步骤(透视投影):

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

我们在光源装置处设置观察矩阵V和投影矩阵P,这两个矩阵定义了光源位置朝向,以及视锥体相关信息。通过这两个矩阵进行步骤1的变换,另外转换为NDC空间需要实现透视除法。步骤2将NDC空间转换为纹理空间,NDC空间中:x\in[-1,1],y\in[-1,1],而纹理空间中:u\in[0,1],v\in[0,1],所以需要一个保序函数进行变换,即:

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

y坐标需要取反,因为NDC空间中y正方向和纹理空间中v正方向是相反的,也就是将[-1,1]=>[1,0]

①代码实现:

// shader中生成投影纹理坐标
VertexOut VS(VertexIn vin)
{
    VertexOut vout;
    ...
    
    // 物体局部空间=>光源的齐次裁剪空间: world view proj
    vout.ProjTex = mul(float4(vin.PosL, 1.f), gLightWorldViewProjTexture);

    ...
}

float4 PS(VertexOut pin) : SV_Target
{
    pin.ProjTex.xyz /= pin.ProjTex.w; // 透视除法:齐次裁剪空间=>NDC空间

    float depth = pin.ProjTex.z;
    
    float4 c = gTextureMap.Sample(sampler, pin.ProjTex.xy); // 根据投影坐标进行采样
    ...
}

②视锥体之外的点:

在渲染流水线中,位于视锥体之外的几何体是要被裁剪掉的。但是,我们以光源装置的视角投影几何体而为之生成投影纹理坐标时,并不执行剪裁操作,只需简单地投影顶点即可。投影窗口之外的几何体也不会获得[0,1]区间以内的投影纹理坐标。在采样时,超出范围的采样会采用设定的寻址模式,如果我们不希望视锥体外的几何体进行贴图,我们可以将寻址模式设置为0的边框颜色寻址模式。

另一种策略是将投影机和聚光灯结合在一起,因为它考虑了光的衰减,所以超出范围的光强会衰减消失。

③正交投影:

为了投影处理的需要,我们实际应该使用正交投影而不是透视投影。正交投影与透视投影基本一致,正交投影只是不需要进行透视除法,效率更快。然后对于视锥体之外的点来说,不能用聚光灯原理,但是还是可以使用更改寻址模式的方法。

4.什么是阴影贴图

①算法描述:

从光源的视角将场景深度以"渲染至纹理"的方式绘制到名为阴影图的深度缓冲区中。建立光源空间的光源观察矩阵,以及光源投影矩阵。光源照射的体积范围可能是平截头体(透视投影)或长方体(正交投影)。若采用长方体,考虑长方体只能照射一部分场景,我们应该扩大长方体的面积,让它覆盖整个场景。

阴影图中存储的深度值为d(p),当我们以玩家视角渲染场景时,我们计算光源到像素的深度值,两个深度值相比较,如果d(p)<=s(p),则像素p在阴影范围内。

 ②偏移与走样:

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

一种简单的解决办法是:通过恒定偏移(bias)量对阴影图深度进行调整。

偏移量过大会导致名为peter-panning(小飞侠--逃跑时丢掉了自己的影子)的失真效果,使阴影看起来与物体相分离。

然而没有哪一种固定的偏移量可以正确地运用于所有几何体的阴影绘制。对于有着极大斜率(从光源角度观察)的三角形,就需要更大的偏移量,但不能过大。

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

typedef struct D3D12_RASTERIZER_DESC {
[...]
INT DepthBias;
FLOAT DepthBiasClamp;
FLOAT SlopeScaledDepthBias;
[...]
} D3D12_RASTERIZER_DESC;

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

深度偏移的具体细节,参考SDK文档中文章Depth Bias。

③百分比渐近过滤 PCF:

在用投影纹理坐标(u,v)对阴影图进行采样时,往往不会命中阴影图中纹素的准确位置,通常位于阴影图中4个纹素之间,若执行的是颜色贴图,那么使用双线性过滤是可以的。[Kilgard01]却指出,我们不应对深度值采用平均值法。我们应该对采样的结果进行插值,而不是对深度值进行插值,这种方法叫做--百分比渐近过滤(Percentage Closer Filtering,PCF)。即我们以点过滤的方式在坐标(u,v),(u+Δx,v),(u,v+Δx),(u+Δx,v+Δx)处进行采样,其中Δx=1/SHADOW_MAP_SIZE,除以的是阴影贴图的大小,采样的4个点分别是围绕(u,v)的最近4个阴影图纹素。接下来,我们会对采集的深度值进行阴影图检测,并对测试结果进行双线性插值。结果是,一个像素可能局部处于阴影中,使得阴影更加平滑。

static const float SMAP_SIZE = 2048.0f;
static const float SMAP_DX = 1.f/SMAP_SIZE;

...

float s0 = gShadowMap.Sample(gShadowSam, projTexC.xy).r;
float s1 = gShadowMap.Sample(gShadowSam, projTexC.xy+float2(SMAP_DX,0)).r;
float s2 = gShadowMap.Sample(gShadowSam, projTexC.xy+float2(0,SMAP_DX)).r;
float s3 = gShadowMap.Sample(gShadowSam, projTexC.xy+float2(SMAP_DX,SMAP_DX)).r;

float result0 = depth <= s0;
float result1 = depth <= s1;
float result2 = depth <= s2;
float result3 = depth <= s3;

float2 texelPos = SMAP_SIZE*projTexC.xy; // 变换到纹素空间
float2 t = frac(texelPos); // 取小数部分

return lerp(lerp(result0, result1, t.x), lerp(result2, result3, t.x), t.y);

PCF过滤的主要缺点:需要对纹理采样4次。采样是GPU代价较高的操作之一,因为存储器的带宽和延迟并没有随着GPU计算能力的剧增而得到相近程度的巨大改良。

幸运的是,我们已经能够通过调用SampleCmpLevelZero方法来调用兼容Direct3D 11+版本的图形硬件对PCF技术的内部支持

Texture2D gShadowMap : register(t1);
SamplerComparisonState gsamShadow : register(s6);

shadowPosH.xyz /= shadowPosH.w;

float depth = shadowPosH.z;

// 自动执行4-tap PCF
gShadowMap.SampleCmpLevelZero(gsamShadow, shadowPosH.xy, depth).r;

④构建阴影图:

实现阴影贴图的第一步就是构建阴影图,首先需要创建一个ShadowMap实例,接着定义光源观察矩阵以及一个投影矩阵。接着将场景渲染至阴影图时,资源状态设置为DEPTH_WRITE,且渲染目标设置为空(PSO中渲染目标也设置为空),渲染完成再变回GENERIC_READ状态。

着色器代码中,VS只需要正常完成坐标转换即可,而PS不需要返回任何数据(也可以在其中进行一些额外的操作,比如alpha裁剪)。

因为渲染目标设置为0,相当于只将深度数据渲染至资源中,而不考虑颜色数据,所以效率较高。

⑤阴影因子:

阴影因子(shadow factor)是我们为光照方程新添加的一种标量系数,范围在[0,1]。值为0表示位于阴影中,值为1表示点位于阴影之外。进行PCF时,可能会得到0~1之间的值。

float CalcShadowFactor(float4 shadowPosH)
{
    // Complete projection by doing division by w.
    shadowPosH.xyz /= shadowPosH.w;

    // Depth in NDC space.
    float depth = shadowPosH.z;

    uint width, height, numMips;
    gShadowMap.GetDimensions(0, width, height, numMips); // 获取纹理基本信息的函数

    // Texel size.
    float dx = 1.0f / (float)width;

    float percentLit = 0.0f;
    // 使用3×3的过滤模式
    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 += gShadowMap.SampleCmpLevelZero(gsamShadow,
            shadowPosH.xy + offsets[i], depth).r;
    }
    
    return percentLit / 9.0f;
}

阴影因子会与直接光直接相乘。

⑥阴影图检测:

在主渲染过程中,我们需要为每个像素p计算其d(p)和s(p),把点变换到光源的NDC空间便可以求出其对应的d(p)的值。

⑦渲染阴影图:

阴影图其实是一种深度缓冲区纹理,我们可以为它创建SRV,以便在着色器程序中对它进行采样,且因为阴影图存储的是一维数据,所以我们可以将它渲染为一幅灰度图。

5.过大的PCF核

过大的PCF核可能会导致本不在阴影中的物体,变成了一部分在阴影内。对小的PCF核进行深度值偏移操作是合理的,但对大的PCF核不太合理,因为这些顶点可能都不在一个三角形上。

①HLSL函数:ddx & ddy

ddx函数和ddy函数分别近似地计算\partial p/\partial x,\partial p/\partial y,其中x和y分别是屏幕空间中的xy轴。有这两个函数,我们便可以确定相邻像素之间某属性值p的变化量。比如,这两个导函数可以用于:

  • 估算相邻像素的颜色变化值
  • 估算相邻像素的深度变化值
  • 估算相邻像素的法线变化值

硬件估算这些偏导数的过程并不复杂,按照2×2像素规模的四边形进行并行处理,以前向差分方程q_{x+1,y}-q_{x,y}来估算x方向上的偏导数,y方向同理。

②较大PCF核问题的解决方案:

方案来自[Tuft10],该策略的前提是假设p与相邻像素位于同一平面之内

假设p=(u,v,z)是光源空间中点的坐标,坐标(u,v)用于索引阴影图,我们可以用ddx和ddy分别计算位于多边形所在切平面内的向量。

如果我们在屏幕空间中移动(Δx,Δy)个单位,那么在光源空间中应该按照切方向对应移动\Delta x(\frac{\partial u}{\partial x},\frac{\partial v}{\partial x},\frac{\partial w}{\partial x} )+\Delta y(\frac{\partial u}{\partial y},\frac{\partial v}{\partial y},\frac{\partial w}{\partial y} ),所以用矩阵表示即为:

[\Delta x, \Delta y]\begin{bmatrix} \frac{\partial u }{\partial x} & \frac{\partial v}{\partial x}\\ \frac{\partial u }{\partial y} & \frac{\partial v}{\partial y} \end{bmatrix}=[\Delta u, \Delta v]

所以根据这个矩阵的逆矩阵,我们可以知道在纹理空间中移动Δu,Δv后,在光源空间会移动Δx,Δy。再来考虑深度方向的移动,满足\Delta z = \Delta x \frac{\partial z}{\partial x}+\Delta y \frac{\partial z}{\partial y}​。​​​​​

因此这个方法的优势在于,计算Δz:通过Δu,Δv计算得到Δx,Δy,再通过Δx,Δy,计算得到Δz。

③较大PCF核问题的另一种解决办法: 


拓展阅读:级联阴影贴图:[使用多张阴影贴图,]将光源视锥体按深度值划分为多个区域,为每个区域创建不同的阴影贴图,距离近的区域阴影贴图分辨率低,距离远的区域阴影贴图分辨率高。最后再在PS中根据距离选择对应的阴影贴图。


课后练习题:

11.正交投影可用于方向光光源生成阴影图;透视投影可用于聚光灯光源生成阴影图;而可以用CubeMap以及6次透视投影生成点光源阴影图。


ShadowMap在dx12实现总体思路:①利用辅助类ShadowMap创建实例,管理分辨率、视口等②为阴影图设置DSV并放入对应堆中③为阴影图设置SRV并放入对应堆中④渲染两次,第一次写入深度数据,第二次作为着色器资源参与shader

  • 0
    点赞
  • 2
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值