DirectX11 With Windows SDK--39 陰影技術(VSM、ESM)

X_Jun發表於2022-05-14

前言

上一章我們介紹了級聯陰影貼圖。剛開始的時候我嘗試了給CSM直接加上PCSS,但不管怎麼調難以達到說得過去的效果。然後文章越翻越覺得陰影就是一個巨大的坑,考慮到時間關係,本章只實現了方差陰影貼圖(VSM)和指數陰影貼圖(ESM)作為引子,然後將相關擴充套件放在文末。

現在假定讀者已經讀過下面的內容:

章節
38 級聯陰影貼圖

DirectX11 With Windows SDK完整目錄

歡迎加入QQ群: 727623616 可以一起探討DX11,以及有什麼問題也可以在這裡彙報。

Variance Shadow Map

關於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\)

image

將該函式拆分成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。

image

看上圖,黑點所屬的區域完全被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);
}

對VSM濾波

而為了能夠獲得\(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記錄更多深度
  • 使用盒型濾波或高斯濾波處理方差陰影貼圖
  • 使用mipmap

而取樣的時候我們可以對方差陰影貼圖使用各種方式,比如點取樣、線性取樣、各向異性取樣。

下面的程式碼展示的是深度圖開啟或關閉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

漏光(Light Bleeding)

VSM最大的問題在於漏光現象,見下圖(不得不說這漏光是真的嚴重)。

image

我們固定\((\mu-d_r)^2\)的值(非0)來觀察隨著\(\sigma^2\)變化,\(p_{max}(d_r)\)的函式影像:

image

隨著方差的增大,\(p_{max}(d_r)\)逐漸變大,這是造成漏光現象的主要原因。方差較大的情況可以參考下圖:

image

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

image

當然,我們也可以向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);
}

使用梯度對級聯陰影取樣

在使用梯度取樣級聯陰影時,可能會在兩個級聯的邊界區域出現下圖所示的問題。

imageimage

使用各項異性濾波由於動態流控制導致在級聯之間出現的接縫

取樣指令使用畫素之間的導數來計算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具有如下優點:

  • 可以使用圖片空間blur或硬體filtering來產生軟陰影
  • 不需要處理shadow acne問題,因此也不需要引入depth bias

但它也有如下缺點:

  • 需要原來深度圖佔用視訊記憶體空間的兩倍來存放\(d_o\)\(d_o^2\)
  • 在具有高方差分佈的區域容易產生漏光(Light Bleeding)
  • 大卷積核濾波會使漏光現象更加嚴重(因為方差值變大了)

Exponential Shadow Map

指數陰影貼圖的核心公式如下:

\[f(z)=saturate(e^{c(d-z)}), d<z, c>0 \]

在固定\(c\)的情況下,隨著occluder逐漸遠離receiver,\(d-z\)從0向負數變動,對應的函式影像如下:

image

為此我們可以將上式拆分成\(e^{cd}\)\(e^{-cz}\)項。深度圖負責前面一項,receiver可以得到後一項。

這種表示的好處在於簡單,並且和VSM一樣,可以對\(e^{cd}\)項進行blur,並且沒有shadow acne的問題。而相比於VSM,它只需要存一項就可以用。

上圖中的\(c=20\),可以看出,如果\(d\)\(z\)比較接近的話仍然會出現比較嚴重的漏光,為此需要讓c的值變得更大。下圖是\(c=100\)的效果:

image

但深度圖直接儲存\(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程式碼

指數陰影貼圖相關的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就會溢位了。而上面的程式碼雖然能夠防止溢位,卻會導致出現下圖的鋸齒現象(類似於回到沒開模糊的情況):

imageimage
image

由於深度值已經位於線性空間,那麼c值一定會有一個隨深度差最大值變化的上界。這時候更多需要依賴於手工調整。

優缺點總結

ESM具有如下優點:

  • 可以使用圖片空間blur或硬體filtering來產生軟陰影,也不需要開很大的Blur
  • 不需要處理shadow acne問題,因此也不需要引入depth bias
  • 相比VSM只需要用1個float

但它也有如下缺點:

  • 為了提升精度需要用特定的Blur,並且沒法縮減成水平和豎直方向的Blur
  • 鄰近畫素深度變化較大的話c的大小會受限

後記

陰影本身就是一個巨大的坑。實際上搞這兩章陰影就已經搞掉我很長時間了,加上中間還要忙各種事情,再往後的陰影效果現在暫時也沒有耐心去實現,也許以後還會回來添磚加瓦。總體來說,VSM和ESM這些嘗試擬合最開頭影像函式的方法都難以避免出現漏光的問題,對於具有複雜深度的場景表現不盡如人意。這些方法可以放在級聯等級較大,即距離較遠的地方,當然也有人在遠距離嘗試使用距離場,這些都是遙遠的後話了。

建議讀者直接開啟專案進行嘗試,這裡只解釋部分可調引數的含義:

VSM

  • Shadow MSAA:記錄陰影圖時開啟MSAA,然後生成VSM的時候進行Resolve
  • Light Bleeding:將[0, amount]對映到0,將[amount, 1]對映到[0, 1]
  • Enable Mipmap:級聯陰影開啟mipmap
  • Sampler:取樣VSM使用的濾波

ESM

  • Blur Sigma:Log高斯濾波用於控制權重分散情況
  • Magic Power:控制\(e^{cd}\)\(e^{cz}\)的c項

GPU Profile那邊開Release來檢視各個Pass下。至於EVSM和MSM等,可以嘗試跑TheRealMJP/Shadows的專案,但需要一些動手修改的能力,它那邊可以調的引數更多。

參考與擴充套件閱讀材料

如果有興趣的話可以瞭解下面這些內容,當然肯定是有我沒注意到的。

Fixed-Size Penumbra

  • 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(Moment Shadow Maps, 2015)
  • Virtual Shadow Map(這個估計只能在DX12做)

Variable-Size Penumbra

  • PCSS(Percentage Closer Soft Shadows)
  • VSSM = PCSS + VSM(Variance Soft Shadow Maps)
  • SAVSM = VSM + SAT(Summed Area Table)

Others

  • 距離場陰影
  • Reflective Shadow Maps
  • 光線追蹤白給的陰影,但需要顯示卡支援

Cascade Optimization & Technique

  • Sample Distribution Shadow Map
  • GPU-Driven Cascade Setup and Scene Submission
  • Deferred Shadow

[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(自動級聯分層)

參考專案:

VarianceShadows11

TheRealMJP/Shadows



DirectX11 With Windows SDK完整目錄

歡迎加入QQ群: 727623616 可以一起探討DX11,以及有什麼問題也可以在這裡彙報。

相關文章