- 注意!本文是在下几年前入门期间所写(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 / 2 24 1/2^{24} 1/224,这里设置成100000那么实际的bias就是 100000 / 2 24 = 0.006 100000/2^{24}=0.006 100000/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