[DirectX12学习笔记] 阴影

本文介绍了DirectX12中阴影贴图的基本概念和实现方式,包括阴影贴图简介、正射投影、纹理矩阵、PCF(Percentage Closer Filtering)等。通过设置适当的bias解决shadow acne和peter panning问题,并展示了3x3 PCF采样的应用。文章以一个阴影demo为例,详细讲解了阴影贴图的创建、渲染过程及关键代码,帮助读者理解阴影效果的渲染技术。

摘要生成于 C知道 ,由 DeepSeek-R1 满血版支持, 前往体验 >

  • 注意!本文是在下几年前入门期间所写(young and naive),其中许多表述可能不正确,为防止误导,请各位读者仔细鉴别。

阴影贴图


阴影贴图简介

要渲染场景的阴影,我们可以在渲染场景之前,先从每个光源出发渲染一遍深度缓冲,记录下被光照射的深度,再正常渲染,渲染的时候计算到光源的深度,深度大于记录下来的深度的话,就说明没有被照到,就是在阴影范围内。

下面介绍基础知识,首先是正射投影。正射投影的视锥是一个长方体,所以不会有透视效果,投影矩阵如下
在这里插入图片描述
在c++里可以这样获取正射投影矩阵

    // Ortho frustum in light space encloses scene.
    float l = sphereCenterLS.x - mSceneBounds.Radius;
    float b = sphereCenterLS.y - mSceneBounds.Radius;
    float n = sphereCenterLS.z - mSceneBounds.Radius;
    float r = sphereCenterLS.x + mSceneBounds.Radius;
    float t = sphereCenterLS.y + mSceneBounds.Radius;
    float f = sphereCenterLS.z + mSceneBounds.Radius;

    mLightNearZ = n;
    mLightFarZ = f;
    XMMATRIX lightProj = XMMatrixOrthographicOffCenterLH(l, r, b, t, n, f);

这个变换是线性的,也不需要再作齐次除法,不过除一下也不影响,反正w除的是1。

以往我们通过view和proj矩阵把坐标变换到ndc,现在介绍怎么把NDC变换到贴图的坐标系,贴图坐标系的xy∈[0,1],而ndc则是[-1,1],而且y是反的,所以这里我们要乘以下矩阵来变换。
在这里插入图片描述
这个矩阵我们称为纹理矩阵。

接下来介绍贴图投影技术,类似于cs里的喷图,如果我们要将一张贴图投射到一个平面上绘制出来,我们该怎么做呢?首先我们需要知道出发点的view矩阵和投影矩阵,从世界坐标出发,经过view和投影矩阵,就可以变换成ndc里的坐标,然后再乘一个贴图矩阵,就可以把当前渲染的坐标变换到贴图的uv坐标系,然后用这个uv去采样,就可以得到颜色了。
然后有这么一个小问题,用这种方法渲染的时候,我们是没有作裁剪的,我们做了三次投影,到了uv坐标系里面采样,但是如果点在视锥外,也会被采样到,所以我们应该在采样的时候把address mode设置成border,让超出范围的采样结果都返回0。

渲染阴影的原理也和这个差不多,我们渲染一个点的时候,想知道这个点在不在阴影范围内,首先投影到光的坐标系里,用前两个维度去采样这个光源的阴影贴图,得到深度记录的s(p)s(p)s(p),再取第三个维度,也就是渲染的这个点的深度d(p)d(p)d(p),比较s(p)s(p)s(p)d(p)d(p)d(p)就可以知道这个点在不在阴影范围内。
然而这样做还是有问题,我们会在地上看到一条一条的黑斑,这个问题被称为shadow acne,效果以及出现原因如下图。
在这里插入图片描述
在这里插入图片描述
一个解决办法是我们设置一个bias。
在这里插入图片描述
这样就不会有条纹了,注意过大的bias可能导致影子与物体分离,这种问题叫做peter panning。而且固定的bias可能达不到要求,因为在相对摄像头坡度大的地方,需要的bias会更多,如图。
在这里插入图片描述
幸运的是dx里面已经支持了带斜率缩放的bias,这个设置在PSO的D3D12_RASTERIZER_DESC里

    D3D12_GRAPHICS_PIPELINE_STATE_DESC smapPsoDesc = opaquePsoDesc;
    smapPsoDesc.RasterizerState.DepthBias = 100000;
    smapPsoDesc.RasterizerState.DepthBiasClamp = 0.0f;
    smapPsoDesc.RasterizerState.SlopeScaledDepthBias = 1.0f;

这个bias的大小设置是根据设备支持的最小单位缩放到32位里的,比如24位的深度缓冲,那么1对应的就是1/2241/2^{24}1/224,这里设置成100000那么实际的bias就是100000/224=0.006100000/2^{24}=0.006100000/224=0.006。这里因为是在PSO里设置的,所以应该是深度缓冲在写入的时候就加上这个值再写进去,我们读的时候就不用再去加bias了。

PCF(Percentage Closer Filtering)
直接采样比大小的话,阴影的边缘会非常的硬,所以我们希望多采样一个范围内的一些点,然后取平均,然后点不是只能在或者不在阴影范围内,可以有中间状态,比如0.5表示一半的状态,这样的话阴影边缘就会平滑很多。为了达到这个效果,我们可以采样四个点,x和y的增量都取Δx=1/SHADOW_MAP_SIZE,分别检查是否在阴影范围内,然后结果取平均,如下图所示
在这里插入图片描述
代码如下

static const float SMAP_SIZE = 2048.0f;
static const float SMAP_DX = 1.0f / SMAP_SIZE;// Sample shadow map to get nearest depth to light.
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;
// Is the pixel depth <= shadow map value?
float result0 = depth <= s0;
float result1 = depth <= s1;
float result2 = depth <= s2;
float result3 = depth <= s3;
// Transform to texel space.
float2 texelPos = SMAP_SIZE*projTexC.xy;
// Determine the interpolation amounts.
float2 t = frac( texelPos );
// Interpolate results.
return lerp( lerp(result0, result1, t.x),
lerp(result2, result3, t.x), t.y);

但是这样的话采样点就是原来的四倍,效率就低了很多,好在dx支持硬件的4点PCF采样,代码如下

Texture2D gShadowMap : register(t1);
SamplerComparisonState gsamShadow : register(s6);
// Complete projection by doing division by w.
shadowPosH.xyz /= shadowPosH.w;
// Depth in NDC space.
float depth = shadowPosH.z;
// Automatically does a 4-tap PCF.
gShadowMap.SampleCmpLevelZero(gsamShadow, shadowPosH.xy, depth).r;

这里我们用的采样函数不再是Sample,而是SampleCmpLevelZero,cmp表示要拿来和cmp比较,level zero表示采样mipmap的第0级,实际上阴影贴图我们是不要生成一系列mipmap的,depth就是拿来比较的值,采样结果大于这个就返回1,小于就返回0,然后返回的结果是四个采样结果的平均,其中1是指不在阴影里,0是指在阴影范围里。
一般来说我们不会只用2x2的PCF核,例如下面实现的demo里就用了3x3次采样,(每次都是2x2,有重叠的部分)。

还有一种方法是先判断好阴影和被照亮区域的边界,在这个边界上用开销更大的PCF核,不在边缘范围上的就用0和1。

较大的PCF核
较大的PCF核会导致acne问题重新出现,原因如图
在这里插入图片描述
可以看到,p点不应该被阴影遮蔽,但是采样的三个点中,有两个点是没有遮蔽的,还有一个是在阴影范围内的,均值就是0.33,这样就出问题了,因为我们的p的深度是不变的,但是采样的时候采样的点不在同一个texel上,还拿p点的深度去比,当然会小于采样结果。这里可行的一种解决方案是用一个大一点的bias,但是如果PCF核更大点的话,就不适用了,就需要新的解决方法。
这里我们用不到这种方法,因为我们不会用特别大的PCF核,这里简单地提一下,HLSL里有个DDX函数和DDY函数,可以用来求一些量对x和y的偏导,这里的x和y指的是屏幕空间,假如(u,v,z)是光源坐标系下的点,根据链式法则
在这里插入图片描述
所以可以算出
在这里插入图片描述
现在我们知道了在光源坐标系下坐标变化(Δu,Δv)的话,屏幕坐标会变化(Δx,Δy),在pcf采样的时候,我们知道我们的采样间距(Δu,Δv),可以算出Δx和Δy,然后根据下式求得深度变化。
在这里插入图片描述
还有一种方法就是,根据链式法则有
在这里插入图片描述
我们直接求出z对u和v的偏导然后按下式计算深度偏移
在这里插入图片描述

阴影demo

接下来实现一个阴影demo,并且给出关键的代码

先封装了一个ShadowMap类,有点类似于之前的CubeMapping里的CubeRenderTarget类

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();

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

void ShadowMap::BuildDescriptors(CD3DX12_CPU_DESCRIPTOR_HANDLE hCpuSrv,
	                             CD3DX12_GPU_DESCRIPTOR_HANDLE hGpuSrv,
	                             CD3DX12_CPU_DESCRIPTOR_HANDLE hCpuDsv)
{
   
   
	// Save references to the descriptors. 
	mhCpuSrv 
评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值