上一章我们介绍了级联阴影贴图。刚开始的时候我尝试了给CSM直接加上PCSS,但不管怎么调难以达到说得过去的效果。然后文章越翻越觉得阴影就是一个巨大的坑,考虑到时间关系,本章只实现了方差阴影贴图(VSM)和指数阴影贴图(ESM)作为引子,然后将相关扩展放在文末。
现在假定读者已经读过下面的内容:
章节
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_r\geq0\)时,\(H(d_o-d_r)=1\)。
将该函数拆分成occluder项和receiver项,有利于我们对occluder项使用图片空间的模糊或者硬件mipmap进行pre-filter处理以用于软阴影。并且由于我们将要改变阴影测试的方法,就不再需要为了缓解shadow acne(阴影粉刺)而使用Depth bias。
受到Deep Shadow Maps的启发,可以使用概率表示的方式。给定当前receiver的深度值,occluder的深度值现在表示为一个随机变量:
\[f(d_r)=P(d_o\geq d_r)
\]
上式变成了一个概率分布函数,判断当前像素位于阴影中的概率。
假设occluder近似满足单峰分布,那么它可以由均值和方差表示。这两者可通过一阶矩(moment)和二阶矩所派生:
\[\mu=E(d_o)\\
\sigma^2=E(d_o^2)-E(d_o)^2
\]
其中一阶矩和二阶矩由下面的公式计算:
\[E(x)=\int xp(x)dx\\
E(x^2)=\int x^2p(x)dx
\]
本质上就是对shadow map做一个滤波(如盒型滤波或高斯滤波等):
\[E(d_o)\approx\sum w_i d_i\\
E(d_o^2)\approx\sum w_i d_i^2
\]
在算出均值和方差后,紧接着我们就可以根据切比雪夫不等式来找出\(P(d_o\geq d_r)\)的上界:
\[P(d_o\geq d_r)\leq p_{max}(d_r)\equiv \frac{\sigma^2}{\sigma^2+(\mu-d_r)^2}
\]
当\(\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}\)的值,让其在低于某个\(amount\in[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具有如下优点:
但它也有如下缺点:
指数阴影贴图的核心公式如下:
\[f(z)=saturate(e^{c(d-z)}), d
\]
在固定\(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个样本的加权求和有:
\[\begin{aligned}\sum_{i=0}^N w_i e^{cd_{o_i}}&=
e^{cd_{o_0}}(w_0+\sum_{i=1}^Nw_i e^{c(d_{o_i}-d_{o_0})})\\
&=e^{cd_{o_0}}\cdot e^{ln(w_0+\sum_{i=1}^Nw_i e^{c(d_{o_i}-d_{o_0})})}\\
&=e^{cd_{o_0} + ln(w_0+\sum_{i=1}^Nw_i e^{c(d_{o_i}-d_{o_0})})}
\end{aligned}
\]
即我们只需要在blur的时候求出即可:
\[cd_{o_0} + ln(w_0+\sum_{i=1}^Nw_i e^{c(d_{o_i}-d_{o_0})})
\]
指数阴影贴图相关的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的项目,但需要一些动手修改的能力,它那边可以调的参数更多。
如果有兴趣的话可以了解下面这些内容,当然肯定是有我没注意到的。
PCF(Percentage Closer Filtering)
VSM(Variance Shadow Maps, 2006)
LVSM(Layered Variance Shadow Maps)
ESM(Exponential Shadow Maps, 2008)
EVSM(Exponential Variance Shadow Maps)
MSM(Movement Shadow Maps, 2015)
Virtual Shadow Map(这个估计只能在DX12做)
PCSS(Percentage Closer Soft Shadows)
VSSM = PCSS + VSM(Variance Soft Shadow Maps)
SAVSM = VSM + SAT(Summed Area Table)
距离场阴影
Reflective Shadow Maps
光线追踪白给的阴影,但需要显卡支持
Sample Distribution Shadow Map
GPU-Driven Cascade Setup and Scene Submission
Deferred Shadow
[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
[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,以及有什么问题也可以在这里汇报。
手机扫一扫
移动阅读更方便
你可能感兴趣的文章