DirectX11 With Windows SDK--38 級聯陰影對映(CSM)

X_Jun發表於2022-04-07

前言

在31章我們曾經實現過陰影對映,但是受到陰影貼圖精度的限制,只能在場景中相當有限的範圍內投射陰影。本章我們將以微軟提供的例子和部落格作為切入點,學習如何解決陰影中出現的Atrifacts:

  • 邊緣閃爍&抖動
  • 陰影接縫
  • 陰影缺失
  • perspective aliasing
  • projective aliasing

並且我們將會學習到如何使用級聯陰影貼圖(CSMs)。具體包括:

  • 解釋CSMs的複雜性
  • 給出CSM演算法可能變化的細節
  • 識別並解決一些向CSMs新增濾波相關的常見陷阱

然後在下一章,我們可能會討論更多提升陰影質量、提升效率的技術,如PCSS、VSM等。

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

章節
31 陰影對映

DirectX11 With Windows SDK完整目錄

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

Shadow Map Artifacts

Perspective Aliasing

陰影貼圖的Perspective Aliasing(這個詞我無法給出好的翻譯)是其中一個最難解決的問題之一。具體指的是在攝像機的近處,場景中物體的解析度比較高,即便是一個較小的三角形面片也會對應大量的畫素,但陰影貼圖的精度並不會發生改變,導致有大量的畫素對應shadow map中的同一點。如下圖所示。當觀察空間中的texel與shadow map中的texel不是一比一比例時就會發生。這是因為靠近 近平面的畫素需要更高的shadow map的解析度。

雖然可以讓shadow map的解析度可以提高,從而降低Perspective Aliasing的影響,但可能會帶來嚴重的效能問題。當攝像機移動到與幾何體非常近的地方時,這些靠近攝像機的畫素需要非常高的精度以至於連8192x8192的shadow map都會產生鋸齒。

使用PCF、PCSS等濾波演算法來產生模糊效果,從而減輕這種鋸齒感。對於不同距離下的深度,可以採用CSM來適配不同距離的陰影。

Projective Aliasing

Projective Aliasing比前面的問題更難展示。下圖左側可以看到投影引發的錯誤。當表面法線與光線接近正交時,就會發生這種現象。在shadow map中它只能表示某個深度值,但實際場景幾何有著大段的深度值變化,容易導致取樣出錯。

通常嘗試緩解Perspective Aliasing的演算法也能夠減輕Projective Aliasing帶來的影響。

級聯陰影

CSMs的概念其實比較好理解,視錐體的不同區域需要不同解析度的shadow map,靠近攝像機的物件需要的精度比遠離攝像機的更高一些。但實際上,當攝像機移動到與幾何體非常近的地方時,這些靠近攝像機的畫素需要非常高的精度以至於連8192x8192的shadow map都不夠充足。

CSMs的基本思想是將視錐體分割成多個子視錐體,然後為每個子視錐體渲染不同的shadow map,接著畫素著色器從最接近匹配所需精度的shadow map取樣。

上圖代表shadow map的一系列柵格 與 視錐體 顯示了不同解析度的shadow map對畫素的覆蓋影響。當光照空間的畫素與陰影貼圖的texels的比例為1:1時,陰影的質量是最高的(白色畫素)。當太多畫素對映到同一個陰影texel時,就會出現Perspective aliasing,即大塊的紋理對映(左圖)。當shadow map過大時,就會出現欠取樣現象,這時候texels會被略過,出現閃爍的artifacts,效能也會受到影響。

上圖展示了前圖中的每個shadow map中質量最高部分的切割。從技術上說,白色和灰色用來表示級聯陰影的成功。白色區域是理想的,因為它顯示了良好的覆蓋率——觀察空間畫素和shadow map texels的比例為1:1

在每一幀中,CSM需要完成下面的步驟:

  1. 將視錐體劃分成多個子視錐體

  2. 為每個子視錐體計算光照空間下的正交投影

  3. 為每個子視錐體渲染shadow map

  4. 渲染場景

    1. 繫結shadow maps並渲染
    2. 頂點著色器完成下述內容
      • 為每個子視錐體計算光照空間投影紋理座標(或者在畫素著色器完成投影紋理座標的計算)
      • 頂點變換
    3. 畫素著色器完成下述內容
      • 決定合適的級聯陰影
      • 必要時變換紋理座標
      • 取樣級聯
      • 照亮該畫素

視錐體劃分

劃分視錐體實際上是建立多個子視錐體。一種劃分視錐體的方式是沿著Z的方向計算0%到100%範圍內表示的區間,然後百分比的下界和上界分別表示子視錐體的近平面/遠平面

在實踐中,每一幀重新計算這些視錐體會導致陰影邊緣的閃爍。普遍接受的做法是,每個場景使用一組靜態的級聯區間。在這種情況下,沿Z軸的區間用於描述一個在劃分視錐體時出現的子視錐體。為一個給定的場景確定合適的區間取決於下面幾個因素。

場景幾何體的方向

就常見幾何體來說,攝像機的方向會影響級聯間隔的選擇。例如,非常靠近地面的攝像機,如足球比賽中的地面攝像機,與天空中的攝像機有不同的靜態級聯區間集。

下圖展示了不同的攝像機和各自的劃分。當場景的Z-range非常大,需要進行更多的視錐體劃分。例如,當眼睛離地面很近的時候,遠處的物體依然可以看到,這時候就需要多重級聯。劃分視錐體使得更多在攝像機附近的分割槽(perspective aliasing變化最快的地方)也是有價值的。當大部分幾何體聚集在視錐體的一小部分(如俯瞰圖或飛行模擬器)時,需要的級聯較少

不同的配置需要不同的視錐體劃分

(左圖)幾何體有著Z的高動態範圍時需要用到很多級聯。(中圖)低動態範圍的Z不需要用到多級聯。(右圖)一般動態範圍的Z只需要中等數量的級聯

光源與攝像機的方向

每個級聯投影矩陣都緊緊地圍繞著其相應的子視錐體進行擬合。在攝像機和光源方向正交的情況,級聯的排布可以非常緊密,沒有什麼重疊。但當光源方向與攝像機觀察方向越接近平行,重疊的區域隨著也會變大。直到逐漸接近平行時,就會產生dueling frusta現象,對大多數演算法來說這是一個非常難處理的情況。對光照和攝像機進行約束使得這種現象不會發生的情況是很常見的,而CSM在這種情況下的表現比其他許多演算法好得多。

下圖展示了級聯重疊隨著光路與攝像機的逐漸平行而增加

許多CSM的實現使用了固定大小的視錐。在視錐體被分割成固定大小數目的區間時,畫素著色器可以使用深度值來索引級聯陣列。

子視錐體的建立

一旦選擇了視錐體的分段,我們有兩種方式建立子視錐體:一個貼合場景(fit to scene)、另一個貼合級聯(fit to cascade)

Fit to Scene:所有視錐體使用相同的近平面來建立。這會使得級聯強制重疊

Fit to Cascade:所有的視錐體可以通過近平面與遠平面進行區間劃分的方式來建立。這會導致緊密貼合,但在出現dueling frusta現象時會退化成Fit to Scene。

左 Fit to scene vs. 右 Fit to cascade

下面的程式碼確定了不同子視錐體的近平面和遠平面,m_CascadePartitionsPercentage[i]對應的是當前級聯遠平面佔視錐體遠平面的百分比:

float frustumIntervalBegin, frustumIntervalEnd;
float cameraNearFarRange = viewerCamera.GetFarZ() - viewerCamera.GetNearZ();
for (int cascadeIndex = 0; cascadeIndex < m_CascadeLevels; ++cascadeIndex)
{
    // 計算當前級聯覆蓋的視錐體區間。我們以沿著Z軸最小/最大距離來衡量級聯覆蓋的區間
    if (m_SelectedCascadesFit == FitProjection::FitProjection_ToCascade)
    {
        // 因為我們希望讓正交投影矩陣在級聯周圍緊密貼合,我們將最小級聯值
        // 設定為上一級聯的區間末端
        if (cascadeIndex == 0)
            frustumIntervalBegin = 0.0f;
        else
            frustumIntervalBegin = m_CascadePartitionsPercentage[cascadeIndex - 1];
    }
    else
    {
        // 在FIT_PROJECTION_TO_SCENE中,這些級聯相互重疊
        // 比如級聯1-8覆蓋了區間1
        // 級聯2-8覆蓋了區間2
        frustumIntervalBegin = 0.0f;
    }

    // 算出視錐體Z區間
    frustumIntervalEnd = m_CascadePartitionsPercentage[cascadeIndex];
    frustumIntervalBegin = frustumIntervalBegin * cameraNearFarRange;
    frustumIntervalEnd = frustumIntervalEnd * cameraNearFarRange;

}

為每個子視錐體計算光照空間下的正交投影

所謂的光照空間,就是假定存在光源一點,以光源來建立觀察空間,然後通過正交投影的方式向前方投射來產生shadow map。

通常一個投影正交立方體包括六個要素:TopLeftXTopLeftYWidthHeightNearFar。有了這六個要素,我們就可以在光照空間中定義一個任意軸對齊的立方體(AABB)了,並且可以不受名義上光源一點的限制(尤其對方向光來說,使用光源可以定義一個光照空間)

為了計算視錐體在光照空間下的正交投影,我們需要將子視錐體變換到光照空間中,然後根據子視錐體的八個角點計算出對應的AABB。下圖展示了fit to cascade在平面下觀察AABB的構建:

XMMATRIX ViewerProj = viewerCamera.GetProjMatrixXM();
XMMATRIX ViewerView = viewerCamera.GetViewMatrixXM();
XMMATRIX LightView = lightCamera.GetViewMatrixXM();
XMMATRIX ViewerInvView = XMMatrixInverse(nullptr, ViewerView);

for (int cascadeIndex = 0; cascadeIndex < m_CascadeLevels; ++cascadeIndex)
{
    XMFLOAT3 viewerFrustumPoints[8];
    BoundingFrustum viewerFrustum(ViewerProj);
    viewerFrustum.Near = frustumIntervalBegin;
    viewerFrustum.Far = frustumIntervalEnd;
    // 將區域性視錐體變換到世界空間後,再變換到光照空間
    viewerFrustum.Transform(viewerFrustum, ViewerInvView * LightView);
    viewerFrustum.GetCorners(viewerFrustumPoints);
    // 計算視錐體在光照空間下的AABB和vMax, vMin
    BoundingBox viewerFrustumBox;
    BoundingBox::CreateFromPoints(viewerFrustumBox, 8, viewerFrustumPoints, sizeof(XMFLOAT3));
    lightCameraOrthographicMaxVec = XMLoadFloat3(&viewerFrustumBox.Center) + XMLoadFloat3(&viewerFrustumBox.Extents);
    lightCameraOrthographicMinVec = XMLoadFloat3(&viewerFrustumBox.Center) - XMLoadFloat3(&viewerFrustumBox.Extents);
    
    // ...
}

如果我們不考慮那麼多的話,現在就已經足夠構建出各自的投影矩陣了:

XMStoreFloat4x4(m_ShadowProj + cascadeIndex,
	XMMatrixOrthographicOffCenterLH(
        XMVectorGetX(lightCameraOrthographicMinVec),
        XMVectorGetX(lightCameraOrthographicMaxVec),
        XMVectorGetY(lightCameraOrthographicMinVec), 
        XMVectorGetY(lightCameraOrthographicMaxVec),
        XMVectorGetZ(lightCameraOrthographicMinVec), 
        XMVectorGetZ(lightCameraOrthographicMaxVec));

渲染shadow map

在本樣例中,我們使用紋理陣列來建立多個shadow maps,這樣的話每一個級聯只需要共用一個視口。在渲染shadow map時,我們不需要繫結畫素著色器。

這部分著色器程式碼基本上就是沿用31章的內容,故不再重複展示。

渲染場景

選擇合適的級聯

包含陰影的緩衝區現在可以綁到畫素著色器。在本示例中實現了兩種選擇級聯的方法。

基於區間的級聯選擇(Interval-Based Cascade Selection)

在該方法中,頂點著色器需要計算頂點在世界空間的深度,接著在畫素著色器可以拿到插值後的深度。我們根據深度值所落在的區間範圍來選擇對應的級聯。若是fit to scene對應的則是目標級聯的遠平面內和上一級聯的遠平面外;若是fit to cascade對應的則是目標級聯的近平面和遠平面之間。這裡我們可以將迴圈查詢的方式改寫成使用向量比較和點乘的方式來決定正確的級聯。CASCADE_COUNT_FLAG巨集用於指定級聯的數目。g_CascadeFrustumsEyeSpaceDepthsFloat則是不同級聯的遠平面Z值。

cbuffer CBCascadedShadow : register(b2)
{
    float4 g_CascadeFrustumsEyeSpaceDepthsFloat[2]; // 不同子視錐體遠平面的Z值,將級聯分開
    float4 g_CascadeOffset[8];      // ShadowPT矩陣的平移量
    float4 g_CascadeScale[8];       // ShadowPT矩陣的縮放量
}


// Interval-Based Selection
currentCascadeIndex = 0;
//                               Depth
// /-+-------/----------------/----+-------/----------/
// 0 N     F[0]     ...      F[i]        F[i+1] ...   F
// Depth > F[i] to F[0] => index = i+1
if (CASCADE_COUNT_FLAG > 1) // 當前級聯數
{
    float4 currentPixelDepthVec = depth;
    float4 cmpVec1 = (currentPixelDepthVec > g_CascadeFrustumsEyeSpaceDepthsFloat[0]);
    float4 cmpVec2 = (currentPixelDepthVec > g_CascadeFrustumsEyeSpaceDepthsFloat[1]);
    float index = dot(float4(CASCADE_COUNT_FLAG > 0,
                             CASCADE_COUNT_FLAG > 1,
                             CASCADE_COUNT_FLAG > 2,
                             CASCADE_COUNT_FLAG > 3),
                      cmpVec1) +
        dot(float4(CASCADE_COUNT_FLAG > 4,
                   CASCADE_COUNT_FLAG > 5,
                   CASCADE_COUNT_FLAG > 6,
                   CASCADE_COUNT_FLAG > 7),
            cmpVec2);
    index = min(index, CASCADE_COUNT_FLAG - 1);
    currentCascadeIndex = (int)index;
}

shadowMapTexCoord = shadowMapTexCoordViewSpace * g_CascadeScale[currentCascadeIndex] + g_CascadeOffset[currentCascadeIndex];

基於對映的級聯選擇(Map-Based Cascade Selection)

這種方法將從級聯0開始,計算當前級聯的投影紋理座標,然後對級聯紋理取樣時的4個邊界進行測試,若不在邊界範圍內,則來到級聯1繼續嘗試,直到找到投影紋理座標位於邊界範圍內的級聯。這樣的話,頂點著色器需要為每個級聯計算光照空間的位置。畫素著色器對每個級聯進行迭代,以便於對紋理座標進行縮放和移動(正交投影),使得其對當前級聯進行索引。然後將紋理座標與紋理邊界進行測試。當紋理座標的X和Y值落在某一個級聯內時,它將被用來對紋理進行取樣。Z座標用於最後的深度比較。

cbuffer CBCascadedShadow : register(b2)
{
    float4 g_CascadeFrustumsEyeSpaceDepthsFloat[2]; // 不同子視錐體遠平面的Z值,將級聯分開
    // 對Map-based Selection方案,這將保持有效範圍內的畫素。
    // 當沒有邊界時,Min和Max分別為0和1
    float  g_MinBorderPadding;      // (kernelSize / 2) / (float)shadowSize
    float  g_MaxBorderPadding;      // 1.0f - (kernelSize / 2) / (float)shadowSize
}

// Map-Based Selection
currentCascadeIndex = 0;
if (CASCADE_COUNT_FLAG == 1)
{
    shadowMapTexCoord = shadowMapTexCoordViewSpace * g_CascadeScale[0] + g_CascadeOffset[0];
}
if (CASCADE_COUNT_FLAG > 1)
{
    // 尋找最近的,使得紋理座標位於紋理邊界內的級聯
    for (int cascadeIndex = 0; cascadeIndex < CASCADE_COUNT_FLAG && cascadeFound == 0; ++cascadeIndex)
    {
        shadowMapTexCoord = shadowMapTexCoordViewSpace * g_CascadeScale[cascadeIndex] + g_CascadeOffset[cascadeIndex];
        if (min(shadowMapTexCoord.x, shadowMapTexCoord.y) > g_MinBorderPadding
            && max(shadowMapTexCoord.x, shadowMapTexCoord.y) < g_MaxBorderPadding)
        {
            currentCascadeIndex = cascadeIndex;
            cascadeFound = 1;
        }
    }
}

兩種方法的對比

基於區間的方法比基於對映的方法稍微快一些,因為前者可以很快完成,而後者的紋理座標必須與級聯的邊界相交,但shadow map不完全對齊的時候能更有效地使用級聯。

Shadow Map的濾波

PCF

對普通的shadow map進行濾波不會產生柔和、模糊的陰影。濾波硬體模糊了深度值,然後將這些模糊的值與光照空間的texels進行比較。由深度測試產生的硬邊緣仍然存在。模糊shadow map只是錯誤地移動了硬邊緣。PCF能夠對shadow maps進行濾波。當然PCF取樣所用的函式在前面的章節講過,就不再贅述了。

PCF濾波後的圖片,紅色畫素有25%的畫素覆蓋率

我們可以在沒有硬體支援的情況下進行PCF,或者將PCF擴充套件到更大的核。有些技術甚至使用加權核進行取樣。要做到這一點,需要建立一個NxN的核(如高斯),這些權重的和必須為1,然後對紋理進行N^2次取樣。每個樣本都被核心中響應的權重所縮放。

Depth Bias

當使用大型PCF核時,給深度加上偏移變得更加重要。它只對一個畫素在光照空間的深度與shadow map中對當前畫素位置取樣的深度進行比較才有效。shadow map的texel的鄰居指向的是一個不同的位置。這個深度很可能是相似的,但根據場景的情況可能會有很大的不同。下圖展示了所出現的偽影。單一的深度值與shadow map中三個相鄰的texel進行比較,其中一次深度測試錯誤地失敗了,因為shadow map對應texel的鄰居的深度 與 當前texel所處的光照空間深度 並沒有關係,不應該直接比較。對這個問題的一個簡易解決方案是使用一個更大的偏移。然而,過大的偏移會導致Peter panning。計算一個緊密的近平面和遠平面有助於減少使用偏移的影響。

錯誤的陰影自遮擋是因為光照空間用於深度比較的畫素 與 shadow map中的texels不相關 卻直接拿來比較所產生的。光照空間的深度與shadow map中的texel 2相關,texel 1有更大的光照空間深度,texel 2相等,texel 3小於。只有texel 1沒有通過測試。

使用DDX和DDY為大PCF核計算Per-Pexel Depth Bias

在使用這項技術有個大前提,就是在假定大部分幾何體的較為平坦的情況下,可以計算出正確的depth bias。它使用偏導數資訊將深度比較擬合到一個平面。因為這種技術的計算比較複雜,所以只有在GPU有計算週期的情況下才可以使用。當使用非常大的核心時,這可能是唯一可以在不引起Peter Panning的情況下消除自遮蔽偽影的技術。

下圖顯示了這個問題。對於正在比較的一個texel,其變換到光照空間的深度是已知的。而對於shadow map中,螢幕空間texel的鄰居變換到光照空間的深度則是未知的。

左邊是渲染的場景,右邊是對shadow map取樣一個texel及相鄰texel的情況。觀察空間的texel可以對映到shadow map中的畫素D位置進行取樣,從而能夠與光照空間的深度值進行比較。我們可以對shadow map中,D周圍的鄰居texel進行取樣,但這些texel反過來對映到觀察空間的位置和深度則是未知的。只有當我們假設該相鄰畫素與D同屬於一個三角形時,才有可能將相鄰的畫素對映回觀察空間。

這項技術使用ddx和ddy的操作來尋找光照空間位置的偏導數,它返回光照空間深度相對於螢幕空間X、Y方向的梯度。為了將其轉換為螢幕空間深度相對於光照空間X、Y方向的梯度,需要計算一個變換矩陣。

以下圖為例,柵格表示的是shadow map的畫素,黑色點代表觀察空間的位置,兩個黑色箭頭分別對應光照空間深度相對於螢幕空間X、Y方向的梯度(LSP),而兩個藍色箭頭對應的分別是螢幕空間深度相對於光照空間X、Y方向的梯度。

圖13:螢幕空間與光照空間的相互變換

我們使用光照空間位置對X和Y的偏導數來構建這個矩陣。

cbuffer CBCascadedShadow : register(b2)
{
    float4 g_CascadeOffset[8];      // ShadowPT矩陣的平移量
    float4 g_CascadeScale[8];       // ShadowPT矩陣的縮放量
}

//--------------------------------------------------------------------------------------
// 為陰影空間的texels計算螢幕空間深度
//--------------------------------------------------------------------------------------
void CalculateRightAndUpTexelDepthDeltas(float3 shadowTexDDX, float3 shadowTexDDY,
                                         out float upTextDepthWeight,
                                         out float rightTextDepthWeight)
{
    // 這裡使用X和Y中的偏導數來計算變換矩陣。我們需要逆矩陣從陰影空間變換到螢幕空間,
    // 因為這些導數能讓我們從螢幕空間變換到陰影空間。新的矩陣允許我們從shadow map的texels
    // 對映到螢幕空間。這將允許我們尋找對應深度texel用於比較的螢幕空間深度。
    // 這不是一個完美的解決方案,因為它假定場景中的幾何體是一個平面。
    // [TODO]一種更準確的尋找實際深度的方法為:採用延遲渲染並取樣shadow map。
    
    // 在大多數情況下,使用偏移或方差陰影貼圖是一種更好的、能夠減少偽影的方法
    
    // 從螢幕空間變換到陰影空間的矩陣
    float2x2 matScreenToShadow = float2x2(shadowTexDDX.xy, shadowTexDDY.xy);
    float det = determinant(matScreenToShadow);
    float invDet = 1.0f / det;
    float2x2 matShadowToScreen = float2x2(
        matScreenToShadow._22 * invDet, matScreenToShadow._12 * -invDet,
        matScreenToShadow._21 * -invDet, matScreenToShadow._11 * invDet);
    
    float2 rightShadowTexelLocation = float2(g_TexelSize, 0.0f);
    float2 upShadowTexelLocation = float2(0.0f, g_TexelSize);
    
    // 通過陰影空間到螢幕空間的矩陣變換右邊的texel
    float2 rightTexelDepthRatio = mul(rightShadowTexelLocation, matShadowToScreen);
    float2 upTexelDepthRatio = mul(upShadowTexelLocation, matShadowToScreen);
    
    // 我們現在可以計算在shadow map向右和向上移動時,深度的變化值
    // 我們使用x方向和y方向變換的比值乘上螢幕空間X和Y深度的導數來計算變化值
    upTextDepthWeight =
        upTexelDepthRatio.x * shadowTexDDX.z 
        + upTexelDepthRatio.y * shadowTexDDY.z;
    rightTextDepthWeight =
        rightTexelDepthRatio.x * shadowTexDDX.z 
        + rightTexelDepthRatio.y * shadowTexDDY.z;
}

float upTextDepthWeight = 0;
float rightTextDepthWeight = 0;

float3 shadowMapTexCoordDDX;
float3 shadowMapTexCoordDDY;
// 這些導數用於尋找當前平面的斜率
// 導數的計算必須在迴圈內部,以阻止流控制分歧的影響
if (USE_DERIVATIVES_FOR_DEPTH_OFFSET_FLAG)
{
    // 計算光照空間的偏導對映到投影紋理空間的變化率
    shadowMapTexCoordDDX = ddx(shadowMapTexCoordViewSpace);
    shadowMapTexCoordDDY = ddy(shadowMapTexCoordViewSpace);

    shadowMapTexCoordDDX *= g_CascadeScale[currentCascadeIndex];
    shadowMapTexCoordDDY *= g_CascadeScale[currentCascadeIndex];

    CalculateRightAndUpTexelDepthDeltas(shadowMapTexCoordDDX, shadowMapTexCoordDDY,
                                        upTextDepthWeight, rightTextDepthWeight);
}

然後我們就可以用這些資訊進行PCF濾波了:

//--------------------------------------------------------------------------------------
// 使用PCF取樣深度圖並返回著色百分比
//--------------------------------------------------------------------------------------
float CalculatePCFPercentLit(int currentCascadeIndex,
                             float4 shadowTexCoord, 
                             float rightTexelDepthDelta, 
                             float upTexelDepthDelta,
                             float blurRowSize)
{
    float percentLit = 0.0f;
    // 該迴圈可以展開,並且如果PCF大小是固定的話,可以使用紋理即時偏移從而改善效能
    for (int x = g_PCFBlurForLoopStart; x < g_PCFBlurForLoopEnd; ++x)
    {
        for (int y = g_PCFBlurForLoopStart; y < g_PCFBlurForLoopEnd; ++y)
        {
            float depthCmp = shadowTexCoord.z;
            // 一個非常簡單的解決PCF深度偏移問題的方案是使用一個偏移值
            // 不幸的是,過大的偏移會導致Peter-panning(陰影跑出物體)
            // 過小的偏移又會導致陰影失真
            depthCmp -= g_ShadowBiasFromGUI;
            if (USE_DERIVATIVES_FOR_DEPTH_OFFSET_FLAG)
            {
                depthCmp += rightTexelDepthDelta * (float)x + upTexelDepthDelta * (float)y;
            }
            // 將變換後的畫素深度同陰影圖中的深度進行比較
            percentLit += g_TextureShadow.SampleCmpLevelZero(g_SamShadow,
                float3(
                    shadowTexCoord.x + (float)x * g_TexelSize,
                    shadowTexCoord.y + (float)y * g_TexelSize,
                    (float)currentCascadeIndex
                ),
                depthCmp);
        }
    }
    percentLit /= blurRowSize;
    return percentLit;
}

但實際上我們可以發現,在一個複雜場景中,由於有很多地形不平坦的區域都被偏導當成平坦區域來處理,導致計算出的結果有明顯的錯誤:

關鍵問題出自這裡:

if (USE_DERIVATIVES_FOR_DEPTH_OFFSET_FLAG)
{
    depthCmp += rightTexelDepthDelta * (float)x + upTexelDepthDelta * (float)y;
}

如果我們能使用深度圖,再根據偏移進行取樣,應該會比這種方式更為準確。這部分內容可以作為練習題供讀者進行嘗試。

PCF核的Padding

如果shadow buffer沒有進行填充,PCF核就會越界訪問。一種辦法是,使用PCF核大小的一半來填充級聯的外緣。而在當前map-based selection的實現中,僅當紋理的x、y座標在設定的邊界範圍內時才能選擇當前級聯:

// Map-Based Selection
currentCascadeIndex = 0;
if (CASCADE_COUNT_FLAG == 1)
{
    shadowMapTexCoord = shadowMapTexCoordViewSpace * g_CascadeScale[0] + g_CascadeOffset[0];
}
if (CASCADE_COUNT_FLAG > 1)
{
    // 尋找最近的級聯,使得紋理座標位於紋理邊界內
    // minBoard < tx, ty < maxBoard
    for (int cascadeIndex = 0; cascadeIndex < CASCADE_COUNT_FLAG && cascadeFound == 0; ++cascadeIndex)
    {
        shadowMapTexCoord = shadowMapTexCoordViewSpace * g_CascadeScale[cascadeIndex] + g_CascadeOffset[cascadeIndex];
        if (min(shadowMapTexCoord.x, shadowMapTexCoord.y) > g_MinBorderPadding
            && max(shadowMapTexCoord.x, shadowMapTexCoord.y) < g_MaxBorderPadding)
        {
            currentCascadeIndex = cascadeIndex;
            cascadeFound = 1;
        }
    }
}
float padding = ((int)kernelSize / 2) / (float)shadowSize;
pImpl->m_pEffectHelper->GetConstantBufferVariable("g_MinBorderPadding")->SetFloat(padding);
pImpl->m_pEffectHelper->GetConstantBufferVariable("g_MaxBorderPadding")->SetFloat(1.0f - padding);

但隨著PCF的增大,padding也變大了,這麼做會縮小級聯的在世界中的範圍。作為代償,我們考慮可以適當擴大級聯的正交立方體,從而抵消這種影響:

// 我們基於PCF核的大小再計算一個邊界擴充值使得包圍盒稍微放大一些。
float scaleDuetoBlur = m_PCFKernelSize / (float)m_ShadowSize;
XMVECTORF32 scaleDuetoBlurVec = { {scaleDuetoBlur, scaleDuetoBlur, 0.0f, 0.0f} };

float normalizeByBufferSize = 1.0f / m_ShadowSize;
XMVECTORF32 normalizeByBufferSizeVec = { {normalizeByBufferSize, normalizeByBufferSize, 0.0f, 0.0f} };

XMVECTOR borderOffsetVec = lightCameraOrthographicMaxVec - lightCameraOrthographicMinVec;
borderOffsetVec *= g_XMOneHalf;
borderOffsetVec *= scaleDuetoBlurVec;
lightCameraOrthographicMaxVec += borderOffsetVec;
lightCameraOrthographicMinVec -= borderOffsetVec;

陰影抖動&閃爍

現在我們關掉PCF。當我們移動攝像機視角的時候,可以發現會出現陰影閃爍:

導致這種問題出現原因之一在於:移動的時候會導致視錐體產生的AABB大小發生變化,導致shadow map的畫素與螢幕空間畫素的對應比例關係發生了變化,從而出現了抖動&閃爍。

為此,我們首先可以考慮如何固定視錐體產生的AABB,一種方式是採用視錐體斜對角線的長度作為AABB盒的寬高。然後我們就有了下面的程式碼:

//     Near    Far
//    0----1  4----5
//    |    |  |    |
//    |    |  |    |
//    3----2  7----6
XMVECTOR diagVec = XMLoadFloat3(viewerFrustumPoints + 7) - XMLoadFloat3(viewerFrustumPoints + 1);   // 子視錐體的斜對角線
// 找到視錐體對角線的長度作為AABB的寬高
XMVECTOR lengthVec = XMVector3Length(diagVec);

// 計算出的偏移量會填充正交投影
XMVECTOR borderOffsetVec = (lengthVec - (lightCameraOrthographicMaxVec - lightCameraOrthographicMinVec)) * g_XMOneHalf;
// 我們僅對XY方向進行填充
static const XMVECTORF32 xyzw1100Vec = { {1.0f, 1.0f, 0.0f, 0.0f} };
lightCameraOrthographicMaxVec += borderOffsetVec * xyzw1100Vec;
lightCameraOrthographicMinVec -= borderOffsetVec * xyzw1100Vec;

結果跑起來一看,視窗兩側會有一片莫名其妙的陰影,即便是連微軟給的程式示例,把解析度調成1280x720都會有這樣的問題:

仔細想想,就會發現如果級聯0視錐體的遠平面與遠平面的距離過近,會導致斜對角線過短,繼而生成的AABB盒不能包圍整個視錐體。下面畫個圖感受一下:

為此我們需要把遠平面對角線也考慮進來:

//     Near    Far
//    0----1  4----5
//    |    |  |    |
//    |    |  |    |
//    3----2  7----6
XMVECTOR diagVec = XMLoadFloat3(viewerFrustumPoints + 7) - XMLoadFloat3(viewerFrustumPoints + 1); // 子視錐體的斜對角線
XMVECTOR diag2Vec = XMLoadFloat3(viewerFrustumPoints + 7) - XMLoadFloat3(viewerFrustumPoints + 5); // 遠平面對角線
// 找到較長的對角線作為AABB的寬高
XMVECTOR lengthVec = XMVectorMax(XMVector3Length(diagVec), XMVector3Length(diag2Vec));

雖然CSM的內容都是千篇一律,但這裡也算是一個很難讓人注意到的問題。

然後當我們移動攝像機的時候,依然會出現抖動&閃爍現象:

仔細觀察下圖,黑色柵格代表世界空間,藍色柵格代表光照在世界中產生的投影(寬高相等相當於光源方向豎直向下),一個texel對應一個區塊。以粗黑色的區塊的中心點進行投影紋理變換與取樣,會取樣到的shadow map對應藍色圓點所屬texel的深度。

如果我們嘗試讓攝像機移動一點點,此時陰影投射圖在世界中也發生了位移。這時候以粗黑色的區塊中心進行投影紋理變換與取樣,會取樣到下面那個藍色圓點所屬texel的深度。

可以看到兩個時刻取樣的深度位置不同,會導致取樣到的深度值不同,從而出現陰影閃爍的問題。為此,我們可以考慮計算出陰影投射圖的每個texel在世界中的寬度W和高度H,然後讓陰影投射圖的寬高分別為W和H的整數倍,並且只能以整數倍的W或H來進行移動,這樣可以保證取樣得到的深度值不會發生變動,從而避免了陰影抖動&閃爍的問題。

微軟把這項技術稱之為:Moving the Light in Texel-Sized Increments

下面的程式碼展示了這些技術的使用:

if (m_MoveLightTexelSize)
{
    // 計算陰影圖中每個texel對應世界空間的寬高,用於後續避免陰影邊緣的閃爍
    float normalizeByBufferSize = 1.0f / m_ShadowSize;
    XMVECTORF32 normalizeByBufferSizeVec = { {normalizeByBufferSize, normalizeByBufferSize, 0.0f, 0.0f} };
    worldUnitsPerTexelVec = lightCameraOrthographicMaxVec - lightCameraOrthographicMinVec;
    worldUnitsPerTexelVec *= normalizeByBufferSize;

    // worldUnitsPerTexel
    // | |                     光照空間
    // [x][x][ ]    [ ][x][x]  x是陰影texel
    // [x][x][ ] => [ ][x][x]
    // [ ][ ][ ]    [ ][ ][ ]
    // 在攝像機移動的時候,視錐體在光照空間下的AABB並不會立馬跟著移動
    // 而是累積到texel對應世界空間的寬高的變化時,AABB才會發生一次texel大小的躍動
    // 所以移動攝像機的時候不會出現陰影的抖動
    lightCameraOrthographicMinVec /= worldUnitsPerTexelVec;
    lightCameraOrthographicMinVec = XMVectorFloor(lightCameraOrthographicMinVec);
    lightCameraOrthographicMinVec *= worldUnitsPerTexelVec;

    lightCameraOrthographicMaxVec /= worldUnitsPerTexelVec;
    lightCameraOrthographicMaxVec = XMVectorFloor(lightCameraOrthographicMaxVec);
    lightCameraOrthographicMaxVec *= worldUnitsPerTexelVec;
}

投影近平面與遠平面的確定

在前面的程式碼中我們使用近平面與遠平面是從視錐體計算AABB得到的,如果直接這樣使用的話,會出現缺乏遮蔽的效果:

左上角的發電廠本應該能對下面的紅色區域產生遮蔽,卻因為沒有在投影立方體的範圍內而丟失了深度資訊。

一種最簡單粗暴的方式就是,讓光源位於場景之外,然後近平面設定為接近0,遠平面設為一個很大的值。

if (m_SelectedNearFarFit == FitNearFar::FitNearFar_ZeroOne)
{
    nearPlane = 0.1f;
    farPlane = 10000.0f;
}

但很明顯整個場景的陰影效果立馬就有問題了,原因在於大範圍的深度值會使得深度的表示精度降低,為此我們需要想辦法將近平面和遠平面設定到合適的範圍。

一種簡單的方式是,將場景AABB的角點變換到光照空間,然後對這些點計算出vMin和vMax,相當於計算新的AABB:

// 將場景AABB的角點變換到光照空間
XMVECTOR sceneAABBPointsLightSpace[8]{};
{
    XMFLOAT3 corners[8];
    sceneBoundingBox.GetCorners(corners);
    for (int i = 0; i < 8; ++i)
    {
        XMVECTOR v = XMLoadFloat3(corners + i);
        sceneAABBPointsLightSpace[i] = XMVector3Transform(v, LightView);
    }
}

if (m_SelectedNearFarFit == FitNearFar::FitNearFar_SceneAABB)
{
    XMVECTOR lightSpaceSceneAABBminValueVec = g_XMFltMax;
    XMVECTOR lightSpaceSceneAABBmaxValueVec = -g_XMFltMax;
    // 我們計算光照空間下場景的min max向量
    // 其中光照空間AABB的minZ和maxZ可以用於近平面和遠平面
    // 這比場景與AABB的相交測試簡單,在某些情況下也能提供相似的結果
    for (int i = 0; i < 8; ++i)
    {
        lightSpaceSceneAABBminValueVec = XMVectorMin(sceneAABBPointsLightSpace[i], lightSpaceSceneAABBminValueVec);
        lightSpaceSceneAABBmaxValueVec = XMVectorMax(sceneAABBPointsLightSpace[i], lightSpaceSceneAABBmaxValueVec);
    }
    nearPlane = XMVectorGetZ(lightSpaceSceneAABBminValueVec);
    farPlane = XMVectorGetZ(lightSpaceSceneAABBmaxValueVec);
}

可以看到此時的效果已經比較理想了:

然而在最壞的情況下,可能會導致深度緩衝區的精度大幅下降。下圖可以看到,由於光源的傾斜程度較大,導致近平面與遠平面的範圍比所需要的大了四倍。

如果想要更加精細的遠平面和近平面,則需要對光照空間下的視錐體AABB 與 變換到光照空間的場景AABB 進行相交測試。為此我們需要用到三角形的剔除演算法。

假設有一個待裁剪的三角形,我們需要對包圍盒的四個邊界以迭代的方式執行邊界測試。現在對於左邊界,有2個點位於邊界外,此時可以找到與邊界的2個交點,然後將三角形坍縮。接著對於上邊界,發現有2個點在邊界內部,此時可以找到與邊界外的兩個交點,以某種規則產生出一個新的三角形,然後這兩個三角形參與後續邊界的測試。對於右邊界,1號三角形的三個點都在邊界內,可以保留,而2號三角形有2個點在邊界內,故繼續坍縮併產生新的三角形。產生的3個三角形與下邊界進行測試發現都在內部,不需要操作。最終得到的就是裁剪後的三角形集合。

// 通過光照空間下視錐體的AABB 與 變換到光照空間的場景AABB 的相交測試,我們可以得到一個更緊密的近平面和遠平面
ComputeNearAndFar(nearPlane, farPlane, lightCameraOrthographicMinVec, lightCameraOrthographicMaxVec, 
                  sceneAABBPointsLightSpace);

void XM_CALLCONV ComputeNearAndFar(
    float& outNearPlane, 
    float& outFarPlane, 
    FXMVECTOR lightCameraOrthographicMinVec, 
    FXMVECTOR lightCameraOrthographicMaxVec, 
    XMVECTOR pointsInCameraView[])
{
    // 核心思想
    // 1. 對AABB的所有12個三角形進行迭代
    // 2. 每個三角形分別對正交投影的4個側面進行裁剪。裁剪過程中可能會出現這些情況:
    //    - 0個點在該側面的內部,該三角形可以剔除
    //    - 1個點在該側面的內部,計算該點與另外兩個點在側面上的交點得到新三角形
    //    - 2個點在該側面的內部,計算這兩個點與另一個點在側面上的交點,分裂得到2個新三角形
    //    - 3個點都在該側面的內部
    //    遍歷中的三角形與新生產的三角形都要進行剩餘側面的裁剪
    // 3. 在這些三角形中找到最小/最大的Z值作為近平面/遠平面

    outNearPlane = FLT_MAX;
    outFarPlane = -FLT_MAX;
    Triangle triangleList[16]{};
    int numTriangles;

    //      4----5
    //     /|   /| 
    //    0-+--1 | 
    //    | 7--|-6
    //    |/   |/  
    //    3----2
    static const int all_indices[][3] = {
        {4,7,6}, {6,5,4},
        {5,6,2}, {2,1,5},
        {1,2,3}, {3,0,1},
        {0,3,7}, {7,4,0},
        {7,3,2}, {2,6,7},
        {0,4,5}, {5,1,0}
    };
    bool triPointPassCollision[3]{};
    const float minX = XMVectorGetX(lightCameraOrthographicMinVec);
    const float maxX = XMVectorGetX(lightCameraOrthographicMaxVec);
    const float minY = XMVectorGetY(lightCameraOrthographicMinVec);
    const float maxY = XMVectorGetY(lightCameraOrthographicMaxVec);

    for (auto& indices : all_indices)
    {
        triangleList[0].point[0] = pointsInCameraView[indices[0]];
        triangleList[0].point[1] = pointsInCameraView[indices[1]];
        triangleList[0].point[2] = pointsInCameraView[indices[2]];
        numTriangles = 1;
        triangleList[0].isCulled = false;

        // 每個三角形都需要對4個視錐體側面進行裁剪
        for (int planeIdx = 0; planeIdx < 4; ++planeIdx)
        {
            float edge;
            int component;
            switch (planeIdx)
            {
            case 0: edge = minX; component = 0; break;
            case 1: edge = maxX; component = 0; break;
            case 2: edge = minY; component = 1; break;
            case 3: edge = maxY; component = 1; break;
            default: break;
            }

            for (int triIdx = 0; triIdx < numTriangles; ++triIdx)
            {
                // 跳過裁剪的三角形
                if (triangleList[triIdx].isCulled)
                    continue;

                int insideVertexCount = 0;
                
                for (int triVtxIdx = 0; triVtxIdx < 3; ++triVtxIdx)
                {
                    switch (planeIdx)
                    {
                    case 0: triPointPassCollision[triVtxIdx] = (XMVectorGetX(triangleList[triIdx].point[triVtxIdx]) > minX); break;
                    case 1: triPointPassCollision[triVtxIdx] = (XMVectorGetX(triangleList[triIdx].point[triVtxIdx]) < maxX); break;
                    case 2: triPointPassCollision[triVtxIdx] = (XMVectorGetY(triangleList[triIdx].point[triVtxIdx]) > minY); break;
                    case 3: triPointPassCollision[triVtxIdx] = (XMVectorGetY(triangleList[triIdx].point[triVtxIdx]) < maxY); break;
                    default: break;
                    }
                    insideVertexCount += triPointPassCollision[triVtxIdx];
                }

                // 將通過視錐體測試的點挪到陣列前面
                if (triPointPassCollision[1] && !triPointPassCollision[0])
                {
                    std::swap(triangleList[triIdx].point[0], triangleList[triIdx].point[1]);
                    triPointPassCollision[0] = true;
                    triPointPassCollision[1] = false;
                }
                if (triPointPassCollision[2] && !triPointPassCollision[1])
                {
                    std::swap(triangleList[triIdx].point[1], triangleList[triIdx].point[2]);
                    triPointPassCollision[1] = true;
                    triPointPassCollision[2] = false;
                }
                if (triPointPassCollision[1] && !triPointPassCollision[0])
                {
                    std::swap(triangleList[triIdx].point[0], triangleList[triIdx].point[1]);
                    triPointPassCollision[0] = true;
                    triPointPassCollision[1] = false;
                }

                // 裁剪測試
                triangleList[triIdx].isCulled = (insideVertexCount == 0);
                if (insideVertexCount == 1)
                {
                    // 找出三角形與當前平面相交的另外兩個點
                    XMVECTOR v0v1Vec = triangleList[triIdx].point[1] - triangleList[triIdx].point[0];
                    XMVECTOR v0v2Vec = triangleList[triIdx].point[2] - triangleList[triIdx].point[0];
                    
                    float hitPointRatio = edge - XMVectorGetByIndex(triangleList[triIdx].point[0], component);
                    float distAlong_v0v1 = hitPointRatio / XMVectorGetByIndex(v0v1Vec, component);
                    float distAlong_v0v2 = hitPointRatio / XMVectorGetByIndex(v0v2Vec, component);
                    v0v1Vec = distAlong_v0v1 * v0v1Vec + triangleList[triIdx].point[0];
                    v0v2Vec = distAlong_v0v2 * v0v2Vec + triangleList[triIdx].point[0];

                    triangleList[triIdx].point[1] = v0v2Vec;
                    triangleList[triIdx].point[2] = v0v1Vec;
                }
                else if (insideVertexCount == 2)
                {
                    // 裁剪後需要分開成兩個三角形

                    // 把當前三角形後面的三角形(如果存在的話)複製出來,這樣
                    // 我們就可以用算出來的新三角形覆蓋它
                    triangleList[numTriangles] = triangleList[triIdx + 1];
                    triangleList[triIdx + 1].isCulled = false;

                    // 找出三角形與當前平面相交的另外兩個點
                    XMVECTOR v2v0Vec = triangleList[triIdx].point[0] - triangleList[triIdx].point[2];
                    XMVECTOR v2v1Vec = triangleList[triIdx].point[1] - triangleList[triIdx].point[2];

                    float hitPointRatio = edge - XMVectorGetByIndex(triangleList[triIdx].point[2], component);
                    float distAlong_v2v0 = hitPointRatio / XMVectorGetByIndex(v2v0Vec, component);
                    float distAlong_v2v1 = hitPointRatio / XMVectorGetByIndex(v2v1Vec, component);
                    v2v0Vec = distAlong_v2v0 * v2v0Vec + triangleList[triIdx].point[2];
                    v2v1Vec = distAlong_v2v1 * v2v1Vec + triangleList[triIdx].point[2];

                    // 新增三角形
                    triangleList[triIdx + 1].point[0] = triangleList[triIdx].point[0];
                    triangleList[triIdx + 1].point[1] = triangleList[triIdx].point[1];
                    triangleList[triIdx + 1].point[2] = v2v0Vec;

                    triangleList[triIdx].point[0] = triangleList[triIdx + 1].point[1];
                    triangleList[triIdx].point[1] = triangleList[triIdx + 1].point[2];
                    triangleList[triIdx].point[2] = v2v1Vec;

                    // 新增三角形數目,跳過我們剛插入的三角形
                    ++numTriangles;
                    ++triIdx;
                }
            }
        }

        for (int triIdx = 0; triIdx < numTriangles; ++triIdx)
        {
            if (!triangleList[triIdx].isCulled)
            {
                for (int vtxIdx = 0; vtxIdx < 3; ++vtxIdx)
                {
                    float z = XMVectorGetZ(triangleList[triIdx].point[vtxIdx]);

                    outNearPlane = (std::min)(outNearPlane, z);
                    outFarPlane = (std::max)(outFarPlane, z);
                }
            }
        }
    }
}

現在我們可以看到比前面的方法產生的陰影又稍微細緻了一點。

在級聯之間混合

VSMs(方差陰影貼圖,本章不會提及)和濾波技術(如PCF)可以用於低解析度的CSMs產生軟陰影。不幸的是,這會導致級聯層之間出現明顯的接縫,因為解析度不匹配。解決的辦法是:在兩個級聯之間確定一片邊界區域,在這片區域對兩個級聯進行PCF計算。然後,著色器根據畫素在邊界區域中的位置,在這兩個值之間進行線性插值。在本樣例中提供了對應的操作UI,可以用來嘗試增加和減少這個模糊帶。

(左圖)級聯重疊的時候可以看到一個可接縫,(右圖)當級聯間進行混合時,接縫被模糊掉了。

除了陰影接縫,在開啟大PCF核的時候,還會出現這樣的問題,但我們依然可以用級聯間混合的方式解決:

Interval-Based Blend

cbuffer CBCascadedShadow : register(b2)
{
	float4 g_CascadeFrustumsEyeSpaceDepthsFloat4[8];// 以float4花費額外空間的形式使得可以陣列遍歷,yzw分量無用
	float  g_CascadeBlendArea;      // 級聯之間重疊量時的混合區域
}

//--------------------------------------------------------------------------------------
// 計算兩個級聯之間的混合量 及 混合將會發生的區域
//--------------------------------------------------------------------------------------
void CalculateBlendAmountForInterval(int currentCascadeIndex,
                                     inout float pixelDepth,
                                     inout float currentPixelsBlendBandLocation,
                                     out float blendBetweenCascadesAmount)
{
    
    //                  pixelDepth
    //           |<-      ->|
    // /-+-------/----------+------/--------
    // 0 N     F[0]               F[i]
    //           |<-blendInterval->|
    // blendBandLocation = 1 - depth/F[0] or
    // blendBandLocation = 1 - (depth-F[0]) / (F[i]-F[0])
    // blendBandLocation位於[0, g_CascadeBlendArea]時,進行[0, 1]的過渡
    
    // 我們需要計算當前shadow map的邊緣地帶,在那裡將會淡化到下一個級聯
    // 然後我們就可以提前脫離開銷昂貴的PCF for迴圈
    float blendInterval = g_CascadeFrustumsEyeSpaceDepthsFloat4[currentCascadeIndex].x;
    
    // 對原專案中這部分程式碼進行了修正
    if (currentCascadeIndex > 0)
    {
        int blendIntervalbelowIndex = currentCascadeIndex - 1;
        pixelDepth -= g_CascadeFrustumsEyeSpaceDepthsFloat4[blendIntervalbelowIndex].x;
        blendInterval -= g_CascadeFrustumsEyeSpaceDepthsFloat4[blendIntervalbelowIndex].x;
    }
    
    // 當前畫素的混合地帶的位置
    currentPixelsBlendBandLocation = 1.0f - pixelDepth / blendInterval;
    // blendBetweenCascadesAmount用於最終的陰影色插值
    blendBetweenCascadesAmount = currentPixelsBlendBandLocation / g_CascadeBlendArea;
}

Map-Based Blend

//--------------------------------------------------------------------------------------
// 計算兩個級聯之間的混合量 及 混合將會發生的區域
//--------------------------------------------------------------------------------------
void CalculateBlendAmountForMap(float4 shadowMapTexCoord,
                                inout float currentPixelsBlendBandLocation,
                                inout float blendBetweenCascadesAmount)
{

    //   _____________________
    //  |       map[i+1]      |
    //  |                     |
    //  |      0_______0      |
    //  |______| map[i]|______|
    //         |  0.5  |
    //         |_______|
    //         0       0
    // blendBandLocation = min(tx, ty, 1-tx, 1-ty);
    // blendBandLocation位於[0, g_CascadeBlendArea]時,進行[0, 1]的過渡
    float2 distanceToOne = float2(1.0f - shadowMapTexCoord.x, 1.0f - shadowMapTexCoord.y);
    currentPixelsBlendBandLocation = min(shadowMapTexCoord.x, shadowMapTexCoord.y);
    float currentPixelsBlendBandLocation2 = min(distanceToOne.x, distanceToOne.y);
    currentPixelsBlendBandLocation =
        min(currentPixelsBlendBandLocation, currentPixelsBlendBandLocation2);
    
    
    blendBetweenCascadesAmount = currentPixelsBlendBandLocation / g_CascadeBlendArea;
}

實際混合程式碼

//
// 在兩個級聯之間進行混合
//
if (BLEND_BETWEEN_CASCADE_LAYERS_FLAG)
{
    // 為下一個級聯重複進行投影紋理座標的計算
    // 下一級聯的索引用於在兩個級聯之間模糊
    nextCascadeIndex = min(CASCADE_COUNT_FLAG - 1, currentCascadeIndex + 1);
}

blendBetweenCascadesAmount = 1.0f;
float currentPixelsBlendBandLocation = 1.0f;
if (SELECT_CASCADE_BY_INTERVAL_FLAG)
{
    if (BLEND_BETWEEN_CASCADE_LAYERS_FLAG && CASCADE_COUNT_FLAG > 1)
    {
        CalculateBlendAmountForInterval(currentCascadeIndex, currentPixelDepth,
                                        currentPixelsBlendBandLocation, blendBetweenCascadesAmount);
    }
}
else
{
    if (BLEND_BETWEEN_CASCADE_LAYERS_FLAG)
    {
        CalculateBlendAmountForMap(shadowMapTexCoord,
                                   currentPixelsBlendBandLocation, blendBetweenCascadesAmount);
    }
}

if (BLEND_BETWEEN_CASCADE_LAYERS_FLAG && CASCADE_COUNT_FLAG > 1)
{
    if (currentPixelsBlendBandLocation < g_CascadeBlendArea)
    {
        // 計算下一級聯的投影紋理座標
        shadowMapTexCoord_blend = shadowMapTexCoordViewSpace * g_CascadeScale[nextCascadeIndex] + g_CascadeOffset[nextCascadeIndex];

        // 在級聯之間混合時,為下一級聯也進行計算
        if (currentPixelsBlendBandLocation < g_CascadeBlendArea)
        {
            // 當前畫素在混合地帶內
            if (USE_DERIVATIVES_FOR_DEPTH_OFFSET_FLAG)
            {
                CalculateRightAndUpTexelDepthDeltas(shadowMapTexCoordDDX, shadowMapTexCoordDDY,
                                                    upTextDepthWeight_blend, rightTextDepthWeight_blend);
            }
            percentLit_blend = CalculatePCFPercentLit(nextCascadeIndex, shadowMapTexCoord_blend,
                                                      rightTextDepthWeight_blend, upTextDepthWeight_blend, blurSize);
            // 對兩個級聯的PCF混合
            percentLit = lerp(percentLit_blend, percentLit, blendBetweenCascadesAmount);
        }
    }
}

演示

為了本演示,我把微軟樣例中的powerplant.sdkmesh模型想辦法轉成了obj檔案。不得不吐槽雖然很多格式都能轉成sdkmesh,但沒有一個能從sdkmesh轉成obj或別的格式的,網上的sdkmesh-to-obj就沒有一個靠譜的,最後靠著在微軟示例的渲染程式碼裡強行輸出一個obj檔案才搞定。

然後是一些功能說明:

Debug Shadow:開啟shadow map除錯視窗

Depth Offset:PCF的深度偏移

Cascade Blur:混合區域的大小及開關

DDX, DDY offset:感覺沒什麼用的偏導深度偏移

Fixed Size Frustum AABB:固定視錐體AABB的寬高,避免攝像機旋轉時陰影閃爍

Fit Light to Texels:整數倍texel world unit的AABB及移動,避免移動時陰影閃爍

Fit Projection:級聯的投影模式

Camera:支援主攝像機、光照攝像機改變光照方向

Near Far:計算近平面、遠平面的方式

Selection:級聯選擇演算法

光照攝像機效果:

級聯視覺化:

可以說級聯陰影要面臨非常多的問題,這些坑也是不踩不知道,一踩就是一個接一個的來。下一章的內容還是陰影相關。

參考

最主要參考自下面兩個文章:

Cascade Shadow Maps--MSDN

Common Techniques to Improve Shadow Depth Maps--MSDN

知乎、CSDN上關於CSM的文章,這裡就不逐一列舉了。

參考專案:

CascadedShadowMaps11

練習題

  1. 在PCF中,嘗試使用深度圖讀取相鄰shadow map texel對應位置的深度用作比較

DirectX11 With Windows SDK完整目錄

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

相關文章