前言
上一章我們介紹了級聯陰影貼圖。剛開始的時候我嘗試了給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\)。
將該函式拆分成occluder項和receiver項,有利於我們對occluder項使用圖片空間的模糊或者硬體mipmap進行pre-filter處理以用於軟陰影。並且由於我們將要改變陰影測試的方法,就不再需要為了緩解shadow acne(陰影粉刺)而使用Depth bias。
受到Deep Shadow Maps的啟發,可以使用概率表示的方式。給定當前receiver的深度值,occluder的深度值現在表示為一個隨機變數:
上式變成了一個概率分佈函式,判斷當前畫素位於陰影中的概率。
假設occluder近似滿足單峰分佈,那麼它可以由均值和方差表示。這兩者可通過一階矩(moment)和二階矩所派生:
其中一階矩和二階矩由下面的公式計算:
本質上就是對shadow map做一個濾波(如盒型濾波或高斯濾波等):
在算出均值和方差後,緊接著我們就可以根據切比雪夫不等式來找出\(P(d_o\geq 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);
}
對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最大的問題在於漏光現象,見下圖(不得不說這漏光是真的嚴重)。
我們固定\((\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具有如下優點:
- 可以使用圖片空間blur或硬體filtering來產生軟陰影
- 不需要處理shadow acne問題,因此也不需要引入depth bias
但它也有如下缺點:
- 需要原來深度圖佔用視訊記憶體空間的兩倍來存放\(d_o\)和\(d_o^2\)
- 在具有高方差分佈的區域容易產生漏光(Light Bleeding)
- 大卷積核濾波會使漏光現象更加嚴重(因為方差值變大了)
Exponential Shadow Map
指數陰影貼圖的核心公式如下:
在固定\(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程式碼
指數陰影貼圖相關的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具有如下優點:
- 可以使用圖片空間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(自動級聯分層)
參考專案:
DirectX11 With Windows SDK完整目錄
歡迎加入QQ群: 727623616 可以一起探討DX11,以及有什麼問題也可以在這裡彙報。