上一章我们介绍了级联阴影贴图。刚开始的时候我尝试了给CSM直接加上PCSS,但不管怎么调难以达到说得过去的效果。然后文章越翻越觉得阴影就是一个巨大的坑,考虑到时间关系,本章只实现了方差阴影贴图(VSM)和指数阴影贴图(ESM)作为引子,然后将相关扩展放在文末。
现在假定读者已经读过下面的内容:
章节 |
---|
38 级联阴影贴图 |
DirectX11 With Windows SDK完整目录
欢迎加入QQ群: 727623616 可以一起探讨DX11,以及有什么问题也可以在这里汇报。
关于Shadow Mapping,我们可以将比较深度的过程用这样一个函数表示:(H(d_o-d_r))。其中(d_r)是receiver的深度,(d_o)是occluder的深度。很明显,当(d_o-d_r<0)时,(H(d_o-d_r)=0);(d_o-d_rgeq0)时,(H(d_o-d_r)=1)。
将该函数拆分成occluder项和receiver项,有利于我们对occluder项使用图片空间的模糊或者硬件mipmap进行pre-filter处理以用于软阴影。并且由于我们将要改变阴影测试的方法,就不再需要为了缓解shadow acne(阴影粉刺)而使用Depth bias。
受到Deep Shadow Maps的启发,可以使用概率表示的方式。给定当前receiver的深度值,occluder的深度值现在表示为一个随机变量:
上式变成了一个概率分布函数,判断当前像素位于阴影中的概率。
假设occluder近似满足单峰分布,那么它可以由均值和方差表示。这两者可通过一阶矩(moment)和二阶矩所派生:
其中一阶矩和二阶矩由下面的公式计算:
本质上就是对shadow map做一个滤波(如盒型滤波或高斯滤波等):
在算出均值和方差后,紧接着我们就可以根据切比雪夫不等式来找出(P(d_ogeq d_r))的上界:
当(sigma^2=0, mu=d_r)时,上式未定义,为此可以在分子分母同时加上一个极小量(epsilon),或者是(sigma^2<epsilon)时直接让(sigma^2:=epsilon)。此时没有遮蔽的话值为1; 产生遮蔽的话值接近0。
看上图,黑点所属的区域完全被Occluder遮蔽,因此(sigma^2=0, mu < d_r, p_{max}(d_r)approx 0)
红点所属的区域部分被Occluder遮蔽,有(sigma^2>0, mu < d_r),且红点越往右靠,(p_{max}(d_r))越接近1
蓝点所属的区域没有遮蔽,因此(sigma^2=0, mu = d_r, p_{max}(d_r)=1)
根据上式我们可以写出如下HLSL代码:
float ChebyshevUpperBound(float2 moments, float receiverDepth, float minVariance, float lightBleedingReduction) { float variance = moments.y - (moments.x * moments.x); variance = max(variance, minVariance); // 防止0除 float d = receiverDepth - moments.x; float p_max = variance / (variance + d * d); // 单边切比雪夫 return (receiverDepth <= moments.x ? 1.0f : p_max); }
而为了能够获得(d_o)和(d_o^2),显然我们不能靠深度图来缓存,而需要额外的R32G32_FLOAT
纹理来记录。如果只是单纯为了记录(d_o)和(d_o^2),可以在绘制深度图的同时将(d_o)和(d_o^2)写入到RTV上。
而由于我们最终要使用的是(E(d_o))和(E(d_o^2)),我们可以对其进行一个pre-filter的处理,具体包括:
而采样的时候我们可以对方差阴影贴图使用各种方式,比如点采样、线性采样、各向异性采样。
下面的代码展示的是深度图开启或关闭MSAA时,可以在全屏绘制阶段进行一个Resolve来进行一个pre-filter的处理:
// Shadow.hlsl Texture2DMS<float, MSAA_SAMPLES> g_ShadowMap : register(t0); // 用于VSM生成 float2 VarianceShadowPS(float4 posH : SV_Position, float2 texCoord : TEXCOORD) : SV_Target { float sampleWeight = 1.0f / float(MSAA_SAMPLES); uint2 coords = uint2(posH.xy); float2 avg = float2(0.0f, 0.0f); [unroll] for (int i = 0; i < MSAA_SAMPLES; ++i) { float depth = g_ShadowMap.Load(coords, i); avg.x += depth * sampleWeight; avg.y += depth * depth * sampleWeight; } return avg; }
为了更近一步考虑周围像素的深度,可以使用屏幕空间滤波获得(E(d_o))和(E(d_o^2)),使用盒型滤波或高斯滤波都可以:
// Shadow.hlsl #ifndef BLUR_KERNEL_SIZE #define BLUR_KERNEL_SIZE 3 #endif static const int BLUR_KERNEL_BEGIN = BLUR_KERNEL_SIZE / -2; static const int BLUR_KERNEL_END = BLUR_KERNEL_SIZE / 2 + 1; static const float FLOAT_BLUR_KERNEL_SIZE = (float)BLUR_KERNEL_SIZE; Texture2D g_TextureShadow : register(t1); // 用于模糊 SamplerState g_SamplerPointClamp : register(s0); float2 VSMHorizontialBlurPS(float4 posH : SV_Position, float2 texcoord : TEXCOORD) : SV_Target { float2 depths = 0.0f; [unroll] for (int x = BLUR_KERNEL_BEGIN; x < BLUR_KERNEL_END; ++x) { depths += g_TextureShadow.Sample(g_SamplerPointClamp, texcoord, int2(x, 0)); } depths /= FLOAT_BLUR_KERNEL_SIZE; return depths; } float2 VSMVerticalBlurPS(float4 posH : SV_Position, float2 texcoord : TEXCOORD) : SV_Target { float2 depths = 0.0f; [unroll] for (int y = BLUR_KERNEL_BEGIN; y < BLUR_KERNEL_END; ++y) { depths += g_TextureShadow.Sample(g_SamplerPointClamp, texcoord, int2(0, y)); } depths /= FLOAT_BLUR_KERNEL_SIZE; return depths; }
其中Sample
的可选第三个参数offset用来控制采样行为,往x方向和y方向偏移多少个像素单位,其范围只能在[-8, 7],超过这个范围编译就会报错。你也可以不使用offset,改为额外提供宽高信息来求texel的uv offset。
最后在绘制完所有级联的方差阴影贴图后,我们可以选择是否使用GenerateMips
。
VSM最大的问题在于漏光现象,见下图(不得不说这漏光是真的严重)。
我们固定((mu-d_r)^2)的值(非0)来观察随着(sigma^2)变化,(p_{max}(d_r))的函数图像:
随着方差的增大,(p_{max}(d_r))逐渐变大,这是造成漏光现象的主要原因。方差较大的情况可以参考下图:
从shadow map的视角来看,中间的区域遮挡物深度值发生了很大的跳变,求得的平均值在两个遮挡物之间,而平均值与遮挡物都距离较远,导致方差很大,从而出现漏光现象。同理,如果遮挡物或者接受物的平面与光路接近平行,也会产生大的方差值,导致漏光现象的出现。因此,在简单的场景下应让光路与尽可能多的平面垂直。但对于复杂的场景来说,仅调整光线方向并不能解决问题,不得不吐槽发电厂这个模型简直就是各路算法的埋葬场。
但如果我们尝试增加更多采样来解决这个问题,那又会牺牲效率,那还不如使用PCF。因为使用VSM等基于概率的阴影算法是相比于传统PCF的效率较高,当然代价是在极端情况下带来的物理不准确性。
如果receiver的深度值为(z),且它被某个滤波区域完全阻挡,那么有(d_o-d_r<0, (z-d)^2>0, p_{max}<1),即该表面永远接受不到满光照的强度
我们可以修改(p_{max})的值,让其在低于某个(amountin[0, 1])值的时候直接归零,然后将([amount,1])重新映射到([0,1]):
float Linstep(float a, float b, float v) { return saturate((v - a) / (b - a)); } // 令[0, amount]的部分归零并将(amount, 1]重新映射到(0, 1] float ReduceLightBleeding(float pMax, float amount) { return Linstep(amount, 1.0f, pMax); }
当然,我们也可以向VarianceShadows11的例子中,对(p_{max})套上一个幂指数,然后通过这个幂指数来控制漏光。
现在求(p_{max})的方法变成了:
float ChebyshevUpperBound(float2 moments, float receiverDepth, float minVariance, float lightBleedingReduction) { float variance = moments.y - (moments.x * moments.x); variance = max(variance, minVariance); // 防止0除 float d = receiverDepth - moments.x; float p_max = variance / (variance + d * d); p_max = ReduceLightBleeding(p_max, lightBleedingReduction); // 单边切比雪夫 return (receiverDepth <= moments.x ? 1.0f : p_max); }
在使用梯度采样级联阴影时,可能会在两个级联的边界区域出现下图所示的问题。
使用各项异性滤波由于动态流控制导致在级联之间出现的接缝
采样指令使用像素之间的导数来计算mipmap等级,也被各项异性过滤所需。这可能会在各项异性过滤或mipmap选择的时候引发问题。当2x2像素块在像素着色器中使用不同的分支时,GPU硬件计算出的导数是不合理的。这会导致在级联边缘出现跳变。
该问题可以通过计算光照空间下位置的偏导来解决;光照空间的坐标并没有指定所选的级联。计算出的导数可以变换到对应级联所属的投影纹理空间,从而可以求出正确的mipmap等级或被各项异性过滤使用:
float CalculateVarianceShadow(float4 shadowTexCoord, float4 shadowTexCoordViewSpace, int currentCascadeIndex) { float percentLit = 0.0f; float2 moments = 0.0f; // 为了将求导从动态流控制中拉出来,我们计算观察空间坐标的偏导 // 从而得到投影纹理空间坐标的偏导 float3 shadowTexCoordDDX = ddx(shadowTexCoordViewSpace).xyz; float3 shadowTexCoordDDY = ddy(shadowTexCoordViewSpace).xyz; shadowTexCoordDDX *= g_CascadeScale[currentCascadeIndex].xyz; shadowTexCoordDDY *= g_CascadeScale[currentCascadeIndex].xyz; moments += g_TextureShadow.SampleGrad(g_SamplerShadow, float3(shadowTexCoord.xy, (float) currentCascadeIndex), shadowTexCoordDDX.xy, shadowTexCoordDDY.xy).xy; percentLit = ChebyshevUpperBound(moments, shadowTexCoord.z, 0.00001f, g_LightBleedingReduction); return percentLit; }
VSM具有如下优点:
但它也有如下缺点:
指数阴影贴图的核心公式如下:
在固定(c)的情况下,随着occluder逐渐远离receiver,(d-z)从0向负数变动,对应的函数图像如下:
为此我们可以将上式拆分成(e^{cd})和(e^{-cz})项。深度图负责前面一项,receiver可以得到后一项。
这种表示的好处在于简单,并且和VSM一样,可以对(e^{cd})项进行blur,并且没有shadow acne的问题。而相比于VSM,它只需要存一项就可以用。
上图中的(c=20),可以看出,如果(d)和(z)比较接近的话仍然会出现比较严重的漏光,为此需要让c的值变得更大。下图是(c=100)的效果:
但深度图直接保存(e^{cd})的话会面临一个严重的问题:浮点数的表示范围是有限的,到(e^{88})的时候就已经接近浮点表示的上界了,(c)值过大则无法表示左边部分的范围。而为了能够产生跟一开始那张函数图接近跳变的效果,需要让c能够表示得更大,否则在(d-z)逼近0的时候误差会很大。
前面提到如果(c)太大,(e^{cd})可能会超过float的表示上界,但(c(d-z))本身远小于(cd),不容易越界。在不需要blur的情况下只需要在shadow map生成的时候保存d或者cd即可。
但可以blur也是ESM的优点之一,为此我们需要在blur的部分进行改进。在Lighting Research at Bungie中,提到了一种指数空间滤波的方式。首先对N个样本的加权求和有:
即我们只需要在blur的时候求出即可:
指数阴影贴图相关的HLSL代码如下:
float ESMLogGaussianBlurPS(float4 posH : SV_Position, float2 texcoord : TEXCOORD) : SV_Target { float cd0 = g_TextureShadow.Sample(g_SamplerPointClamp, texcoord); float sum = g_BlurWeights[FLOAT_BLUR_KERNEL_SIZE / 2] * g_BlurWeights[FLOAT_BLUR_KERNEL_SIZE / 2]; [unroll] for (int i = BLUR_KERNEL_BEGIN; i < BLUR_KERNEL_END; ++i) { for (int j = BLUR_KERNEL_BEGIN; j < BLUR_KERNEL_END; ++j) { float cdk = g_TextureShadow.Sample(g_SamplerPointClamp, texcoord, int2(i, j)) * (float) (i != 0 || j != 0); sum += g_BlurWeights[i - BLUR_KERNEL_BEGIN] * g_BlurWeights[j - BLUR_KERNEL_BEGIN] * exp(cdk - cd0); } } sum = log(sum) + cd0; sum = isinf(sum) ? 84.0f : sum; // 防止溢出 return sum; }
//-------------------------------------------------------------------------------------- // ESM:采样深度图并返回着色百分比 //-------------------------------------------------------------------------------------- float CalculateExponentialShadow(float4 shadowTexCoord, float4 shadowTexCoordViewSpace, int currentCascadeIndex) { float percentLit = 0.0f; float occluder = 0.0f; float3 shadowTexCoordDDX = ddx(shadowTexCoordViewSpace).xyz; float3 shadowTexCoordDDY = ddy(shadowTexCoordViewSpace).xyz; shadowTexCoordDDX *= g_CascadeScale[currentCascadeIndex].xyz; shadowTexCoordDDY *= g_CascadeScale[currentCascadeIndex].xyz; occluder += g_TextureShadow.SampleGrad(g_SamplerShadow, float3(shadowTexCoord.xy, (float) currentCascadeIndex), shadowTexCoordDDX.xy, shadowTexCoordDDY.xy).x; percentLit = saturate(exp(occluder - g_MagicPower * shadowTexCoord.z)); return percentLit; }
这样就把receiver和occluder之间深度的矛盾,转移到了occluder与相邻occluder之间深度的矛盾了。但如果相邻occluder之间的深度差很大,依然开不了很大的c。由于级联的Near/Far与发电厂尽可能贴近,在发电厂中可能存在相邻occluder之间的深度差接近0.5,那么此时c开到180就会溢出了。而上面的代码虽然能够防止溢出,却会导致出现下图的锯齿现象(类似于回到没开模糊的情况):
由于深度值已经位于线性空间,那么c值一定会有一个随深度差最大值变化的上界。这时候更多需要依赖于手工调整。
ESM具有如下优点:
但它也有如下缺点:
阴影本身就是一个巨大的坑。实际上搞这两章阴影就已经搞掉我很长时间了,加上中间还要忙各种事情,再往后的阴影效果现在暂时也没有耐心去实现,也许以后还会回来添砖加瓦。总体来说,VSM和ESM这些尝试拟合最开头图像函数的方法都难以避免出现漏光的问题,对于具有复杂深度的场景表现不尽如人意。这些方法可以放在级联等级较大,即距离较远的地方,当然也有人在远距离尝试使用距离场,这些都是遥远的后话了。
建议读者直接打开项目进行尝试,这里只解释部分可调参数的含义:
VSM
[0, amount]
映射到0,将[amount, 1]
映射到[0, 1]
ESM
GPU Profile那边开Release来查看各个Pass下。至于EVSM和MSM等,可以尝试跑TheRealMJP/Shadows的项目,但需要一些动手修改的能力,它那边可以调的参数更多。
如果有兴趣的话可以了解下面这些内容,当然肯定是有我没注意到的。
[1]Cascade Shadow Maps--MSDN
[2]Playing with Real-Time Shadows(Siggraph 2013)
[3]Lighting Research at Bungie(Siggraph 2009)
[4]Advanced Soft Shadow Mapping Techniques(GDC 2008)
[5]Variance Shadow Maps(GDC 2006)
[6]A Sampling of Shadow Techniques
[7]论文:Layered Variance Shadow Maps
[8]KlayGE:切换到ESM
[9]Exponential Variance Shadow Maps
[10]知乎:方差阴影(Variance Shadow Map)实现
[11]知乎:Unreal Engine UE4 静态阴影实现 Static ShadowMap ESM,改进ESM(log space 下做模糊)
[12]Percentage-Closer Soft Shadows
[13]Integrating Realistic Soft Shadows Into Your Game Engine
[14]VSSM
[15]Moment Shadow Mapping (momentsingraphics.de)
[16]Sample Distribution Shadow Map(自动级联分层)
参考项目:
DirectX11 With Windows SDK完整目录
欢迎加入QQ群: 727623616 可以一起探讨DX11,以及有什么问题也可以在这里汇报。