DirectX11 With Windows SDK–31 陰影映射

前言

陰影既暗示著光源相對於觀察者的位置關係,也從側面傳達了場景中各物體之間的相對位置。本章將起底最基礎的陰影映射演算法,而像複雜如級聯陰影映射這樣的技術,也是在陰影映射的基礎上發展而來的。

學習目標:

  1. 掌握基本的陰影映射演算法
  2. 熟悉投影紋理貼圖的工作原理
  3. 了解陰影圖走樣的問題並學習修正該問題的常用策略

DirectX11 With Windows SDK完整目錄

Github項目源碼

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

核心思想

陰影映射技術的核心思想其實不複雜。對於場景中的一點,如果該點能夠被攝像機觀察到,卻不能被光源定義的虛擬攝像機所觀察到,那麼場景中的這一點則可以被判定為光源所照射不到的陰影區域。

以下圖為例,眼睛觀察到地面上最左邊的一點,並且從光源處觀察也能看到該點。因此該點不會產生陰影。

再看下面的圖,眼睛可以觀察到地面上中間那一點,但是從光源處觀察不能看到該點。因此該點會產生陰影。

具體落實下來應該怎麼做呢?對於點光源來說,由於它的光是朝所有方向四射散開的,但為了方便,我們可以像攝像機那樣選取視錐體區域(使用一個觀察矩陣 + 透視投影矩陣來定義),然後經過正常的變換後就能計算出光源到區域內物體的深度值;而對於平行光(方向光)來說,我們可以採用正交投影的方式來選取一個矩形區域(使用一個觀察矩陣 + 正交投影矩陣定義)。和一般的渲染流程不同的是,我們只需要記錄深度值到深度緩衝區,而不需要將顏色繪製到後備緩衝區。

陰影貼圖

陰影貼圖技術也是一種變相的「渲染到紋理」技術。它以光源的視角來渲染場景深度資訊,即在光源處有一個虛擬攝像機,它將觀察到的物體的深度資訊保存到深度緩衝區中。這樣我們就可以知道那些離光源最近的像素片元資訊,同時這些點自然是不在陰影範圍之中。

通常該技術需要用到一個深度/模板緩衝區、一個與之對應的視口、針對該深度/模板緩衝區的著色器資源視圖(SRV)和深度/模板視圖(DSV),而用於陰影貼圖的那個深度/模板緩衝區也被稱為陰影貼圖

光源的投影

在考慮點光源的投影和方向光的投影時可能會有些困難,但這兩個問題其實可以轉化成虛擬攝像機的透視投影和正交投影。

對於透視投影來說,其實我們也已經非常熟悉了。在這種做法下我們只考慮虛擬攝像機的視錐體區域(即儘管點光源是朝任意方向照射的,但我們只看點光源往該視錐體範圍內照射的區域),然後對物體慣例進行世界變換、以光源為視角的觀察變換、光源的透視投影變換,這樣物體就被變換到了以光源為視角的NDC空間。

而對於正交投影而言,我們也是一樣的做法。正交投影的視景體是一個軸對齊於觀察坐標系的長方體。儘管我們不好描述一個方向光的光源,但為了方便,我們把光源定義在視景體xOy切面中心所處的那條直線上。這樣我們就只需要給出視景體的寬度、高度、近平面、遠平面資訊就可以構造出一個正交投影矩陣了。

我們可以看到,正交投影的投影線均平行於觀察空間的z軸。

正交投影矩陣在第四章變換已經講過,就不再贅述。

投影紋理坐標

投影紋理貼圖技術能夠將紋理投射到任意形狀的幾何體上,又因為其原理與投影機的工作方式比較相似,由此得名。例如下圖中,右邊的骷髏頭紋理被投射到左邊場景中的多個幾何體上。

投影紋理貼圖的關鍵在於為每個像素生成對應的投影紋理坐標,從視覺上給人一種紋理被投射到幾何體上的感覺。

下圖是光源觀察的視野,其中點p是待渲染的一點,而紋理坐標(u, v)則指定了應當被投射到3D點p上的紋素,並且坐標(u, v)與投影到螢幕上的NDC坐標有特定聯繫。我們可以將投影紋理坐標的生成過程分為如下步驟:

  1. 將3D空間中一點p投影到光源的投影窗口,並將其變換到NDC空間。
  2. 將投影坐標從NDC空間變換到紋理空間,以此將它們轉換為紋理坐標

而步驟2中的變換過程則取決於下面的坐標變換:

\[u=0.5x+0.5\\
v=-0.5y+0.5
\]

即從x, y∈[-1, 1]映射到u, v∈[0, 1]。(y軸和v軸是相反的)

這種線性變換可以用矩陣表示:

\[\mathbf{T}=\begin{bmatrix}
0.5 & 0 & 0 & 0 \\
0 & -0.5 & 0 & 0 \\
0 & 0 & 1 & 0 \\
0.5 & 0.5 & 0 & 1 \\
\end{bmatrix}\\
\begin{bmatrix} x & y & 0 & 1 \end{bmatrix}\mathbf{T}=\begin{bmatrix} u & v & 0 & 1 \end{bmatrix}
\]

那麼物體上的一點p從局部坐標繫到最終的紋理坐標點t的變換過程為:

\[\mathbf{p}\mathbf{W_{Obj}}\mathbf{V_{Light}}\mathbf{P_{Light}}\mathbf{T}=\mathbf{t}
\]

這裡補上了世界變換矩陣,是因為這一步容易在後面的程式碼實踐中被漏掉。但此時的t還需要經過透視除法,才是我們最終需要的紋理坐標。

程式碼實現

下面的HLSL程式碼展示了頂點著色器計算投影紋理坐標的過程:

// 頂點著色器
VertexPosHWNormalTexShadowPosH VS(VertexPosNormalTex vIn)
{
    VertexPosHWNormalTexShadowPosH vOut;
    
    matrix viewProj = mul(g_View, g_Proj);
    vector posW = mul(float4(vIn.PosL, 1.0f), g_World);

    vOut.PosW = posW.xyz;
    
    // ...
    
    // 把頂點變換到光源的投影空間
    vOut.ShadowPosH = mul(posW, g_ShadowTransform);
    return vOut;
}

// 像素著色器
float4 PS(VertexPosHWNormalTexShadowPosH pIn) : SV_Target
{
    // 透視除法
    pIn.ShadowPosH.xyz /= pIn.ShadowPosH.w;
    
    // NDC空間中的深度值
    float depth = pIn.ShadowPosH.z;
    
    // 通過投影紋理坐標來對紋理取樣
    // 取樣出的r分量即為光源觀察該點時的深度值
    float4 c = g_ShadowMap.Sample(g_Sam, pIn.ShadowPosH.xy);
    
    // ...
}

視錐體之外的點

在渲染管線中,位於視錐體之外的幾何體是要被裁剪掉的。但是,在我們以光源設置的視角投影幾何體而為之生成投影紋理坐標時,並不需要執行裁剪操作——只需要簡單投影頂點即可。因此,位於視錐體之外的幾何體頂點會得到[0, 1]區間之外的投影紋理坐標。然後具體的取樣行為則需要依賴於我們設置的取樣器。

一般來說,我們並不希望對位於視錐體外的幾何體頂點進行貼圖,因為這並沒有任何意義。考慮到可視深度在NDC空間的最大值為1.0f,我們可以採用邊界深度值為1.0f的邊框定址模式

另一種做法則是結合聚光燈的策略,使聚光燈照射範圍之外的部分不受光照,亦即不在陰影的計算範圍內。

透視除法與投影的其他問題

來到正交投影,因為我們依然是要計算出NDC坐標,對於NDC空間範圍外的點,我們依然可以採用上面的定址模式策略,但聚光燈的策略就不適用了。

此外,正交投影無需進行透視除法,因為正交投影后的坐標w值總是1.0f。但保留透視除法可以讓我們的這套著色器可以同時工作在正交投影和透視投影上。如果沒有透視除法,則只能在正交投影中工作。

演算法思路

  1. 從光源的視角將場景深度以「渲染到紋理」的形式繪製到名為陰影貼圖的深度緩衝區中
  2. 從玩家攝像機的視角渲染場景,計算出該點在光源視角下NDC坐標,其中z值為深度值,記為d(p)
  3. 上面算出的NDC坐標的xy分量變換為陰影貼圖的紋理坐標uv,然後進行深度值取樣,得到s(p)
  4. 當d(p) > s(p)時, 像素p位於陰影範圍之內;自然相反地,當d(p) <= s(p)時,像素p位於陰影範圍之外(至於為什麼還有<,後面會提到)

改進TextureRender

既然陰影貼圖和RTT有著許多相似的地方,那何不把它也放到TextureRender裡面共用呢?只要添加一個開關控制該RTT是否用作陰影貼圖即可。

class TextureRender
{
public:
    template<class T>
    using ComPtr = Microsoft::WRL::ComPtr<T>;

    TextureRender() = default;
    ~TextureRender() = default;
    // 不允許拷貝,允許移動
    TextureRender(const TextureRender&) = delete;
    TextureRender& operator=(const TextureRender&) = delete;
    TextureRender(TextureRender&&) = default;
    TextureRender& operator=(TextureRender&&) = default;


    HRESULT InitResource(ID3D11Device* device,
        int texWidth,
        int texHeight,
        bool shadowMap = false,
        bool generateMips = false);

    // 開始對當前紋理進行渲染
    // 陰影貼圖無需提供背景色
    void Begin(ID3D11DeviceContext* deviceContext, const FLOAT backgroundColor[4]);
    // 結束對當前紋理的渲染,還原狀態
    void End(ID3D11DeviceContext * deviceContext);
    // 獲取渲染好的紋理的著色器資源視圖
    // 陰影貼圖返回的是深度緩衝區
    // 引用數不增加,僅用於傳參
    ID3D11ShaderResourceView* GetOutputTexture();

    // 設置調試對象名
    void SetDebugObjectName(const std::string& name);

private:
    ComPtr<ID3D11ShaderResourceView>        m_pOutputTextureSRV;          // 輸出的紋理(或陰影貼圖)對應的著色器資源視圖
    ComPtr<ID3D11RenderTargetView>          m_pOutputTextureRTV;          // 輸出的紋理對應的渲染目標視圖
    ComPtr<ID3D11DepthStencilView>          m_pOutputTextureDSV;          // 輸出紋理所用的深度/模板視圖(或陰影貼圖)
    D3D11_VIEWPORT                          m_OutputViewPort = {};        // 輸出所用的視口

    ComPtr<ID3D11RenderTargetView>          m_pCacheRTV;                  // 臨時快取的後備緩衝區
    ComPtr<ID3D11DepthStencilView>          m_pCacheDSV;                  // 臨時快取的深度/模板緩衝區
    D3D11_VIEWPORT                          m_CacheViewPort = {};         // 臨時快取的視口

    bool                                    m_GenerateMips = false;       // 是否生成mipmap鏈
    bool                                    m_ShadowMap = false;          // 是否為陰影貼圖

};

在作為RTT時,需要創建紋理與它的SRV和RTV、深度/模板緩衝區和它的DSV、視口

而作為陰影貼圖時,需要創建深度緩衝區與它的SRV和DSV、視口

下面的程式碼只關注創建陰影貼圖的部分:

HRESULT TextureRender::InitResource(ID3D11Device* device, int texWidth, int texHeight, bool shadowMap, bool generateMips)
{
    // 防止重複初始化造成記憶體泄漏
    m_pOutputTextureSRV.Reset();
    m_pOutputTextureRTV.Reset();
    m_pOutputTextureDSV.Reset();
    m_pCacheRTV.Reset();
    m_pCacheDSV.Reset();

    m_ShadowMap = shadowMap;
    m_GenerateMips = false;
    HRESULT hr;
    
    // ...
    
    // ******************
    // 創建與紋理等寬高的深度/模板緩衝區或陰影貼圖,以及對應的視圖
    //
    CD3D11_TEXTURE2D_DESC texDesc((m_ShadowMap ? DXGI_FORMAT_R24G8_TYPELESS : DXGI_FORMAT_D24_UNORM_S8_UINT),
        texWidth, texHeight, 1, 1,
        D3D11_BIND_DEPTH_STENCIL | (m_ShadowMap ? D3D11_BIND_SHADER_RESOURCE : 0));

    ComPtr<ID3D11Texture2D> depthTex;
    hr = device->CreateTexture2D(&texDesc, nullptr, depthTex.GetAddressOf());
    if (FAILED(hr))
        return hr;

    CD3D11_DEPTH_STENCIL_VIEW_DESC dsvDesc(depthTex.Get(), D3D11_DSV_DIMENSION_TEXTURE2D, DXGI_FORMAT_D24_UNORM_S8_UINT);

    hr = device->CreateDepthStencilView(depthTex.Get(), &dsvDesc,
        m_pOutputTextureDSV.GetAddressOf());
    if (FAILED(hr))
        return hr;

    if (m_ShadowMap)
    {
        // 陰影貼圖的SRV
        CD3D11_SHADER_RESOURCE_VIEW_DESC srvDesc(depthTex.Get(), D3D11_SRV_DIMENSION_TEXTURE2D, DXGI_FORMAT_R24_UNORM_X8_TYPELESS);

        hr = device->CreateShaderResourceView(depthTex.Get(), &srvDesc,
            m_pOutputTextureSRV.GetAddressOf());
        if (FAILED(hr))
            return hr;
    }

    // ******************
    // 初始化視口
    //
    m_OutputViewPort.TopLeftX = 0.0f;
    m_OutputViewPort.TopLeftY = 0.0f;
    m_OutputViewPort.Width = static_cast<float>(texWidth);
    m_OutputViewPort.Height = static_cast<float>(texHeight);
    m_OutputViewPort.MinDepth = 0.0f;
    m_OutputViewPort.MaxDepth = 1.0f;

    return S_OK;
}

需要注意的是,在創建深度緩衝區時,如果還想為他創建SRV,就不能將DXGI格式定義成DXGI_FORMAT_D24_UNORM_S8_UINT這些帶D的類型,而應該是DXGI_FORMAT_R24G8_TYPELESS

然後在創建陰影貼圖的SRV時,則需要指定為DXGI_FORMAT_R24_UNORM_X8_TYPELESS

開始陰影貼圖的渲染前,不需要設置RTV,只需要綁定DSV。

void TextureRender::Begin(ID3D11DeviceContext* deviceContext, const FLOAT backgroundColor[4])
{
    // 快取渲染目標和深度模板視圖
    deviceContext->OMGetRenderTargets(1, m_pCacheRTV.GetAddressOf(), m_pCacheDSV.GetAddressOf());
    // 快取視口
    UINT num_Viewports = 1;
    deviceContext->RSGetViewports(&num_Viewports, &m_CacheViewPort);

    // 清空緩衝區
    // ... 
    deviceContext->ClearDepthStencilView(m_pOutputTextureDSV.Get(), D3D11_CLEAR_DEPTH | (m_ShadowMap ? 0 : D3D11_CLEAR_STENCIL), 1.0f, 0);
    
    // 設置渲染目標和深度模板視圖
    deviceContext->OMSetRenderTargets((m_ShadowMap ? 0 : 1), 
        (m_ShadowMap ? nullptr : m_pOutputTextureRTV.GetAddressOf()), 
        m_pOutputTextureDSV.Get());
    // 設置視口
    deviceContext->RSSetViewports(1, &m_OutputViewPort);
}

渲染完成後,和往常一樣還原即可。

偏移與走樣

陰影圖存儲的是距離光源最近的可視像素深度值,但是它的解析度有限,導致每一個陰影圖紋素都要表示場景中的一片區域。因此,陰影圖只是以光源視角針對場景深度進行的離散取樣,這將會導致所謂的陰影粉刺等影像走樣問題。如下圖所示(注意圖中地面上光影之間輪流交替的「階梯狀」條紋):

而下圖則簡單展示了為什麼會發生陰影粉刺這種現象。由於陰影圖的解析度有限,所以每個陰影圖紋素要對應於長江中的一塊區域(而不是點對點的關係,一個坡面代表陰影圖中一個紋素的對應範圍)。從觀察點E查看場景中的兩個點p1與p2,它們分別對應於兩個不同的螢幕像素。但是,從光源的觀察角度來看,它們卻都有著相同的陰影圖紋素(即s(p1)=s(p2)=s,由於解析度的原因)。當我們在執行陰影圖檢測時,會得到d(p1) > s 及 d(p2) <= s這兩個測試結果,這樣一來,p1將會被繪製為如同它在陰影中的顏色,p2將被渲染為好似它在陰影之外的顏色,從而導致陰影粉刺。

因此,我們可以通過偏移陰影圖中的深度值來防止出現錯誤的陰影效果。此時我們就可以保證d(p1) <= s 及 d(p2) <= s。但是尋找合適的深度偏移需要反覆嘗試。

偏移量過大會導致名為peter-panning(彼得·潘,即小飛俠,他曾在一次逃跑時弄丟了自己的影子)的失真效果,使得陰影看起來與物體相分離。

然而,並沒有哪一種固定的偏移量可以正確地運用於所有幾何體的陰影繪製。特別是下圖那種(從光源的角度來看)有著極大斜率的三角形,這時候就需要選取更大的偏移量。但是,如果試圖通過一個過大的深度偏移量來處理所有的斜邊,則又會造成peter-panning問題。

因此,我們繪製陰影的方式就是先以光源視角度量多邊形斜面的斜率,並為斜率較大的多邊形應用更大的偏移量。而圖形硬體內部對此有相關技術的支援,我們通過名為斜率縮放偏移的光柵化狀態屬性就能夠輕鬆實現。

typedef struct D3D11_RASTERIZER_DESC {
    // ...
    INT             DepthBias;
    FLOAT           DepthBiasClamp;
    FLOAT           SlopeScaledDepthBias;
    BOOL            DepthClipEnable;
    // ...
} D3D11_RASTERIZER_DESC;
  1. DepthBias:一個固定的應用偏移量。
  2. DepthBiasClamp:所允許的最大深度偏移量。以此來設置深度偏移量的上限。不難想像,及其陡峭的傾斜度會導致斜率縮放偏移量過大,從而造成peter-panning失真
  3. SlopeScaledDepthBias:根據多邊形的斜率來控制偏移程度的縮放因子。

注意,在將場景渲染至陰影貼圖時,便會應用該斜率縮放偏移量。這是由於我們希望以光源的視角基於多邊形的斜率而進行偏移操作,從而避免陰影失真。因此,我們就會對陰影圖中的數值進行偏移計算(即由硬體將像素的深度值與偏移值相加)。在本Demo中採用的具體數值如下:

// [出自MSDN]
// 如果當前的深度緩衝區採用UNORM格式並且綁定在輸出合併階段,或深度緩衝區還沒有被綁定
// 則偏移量的計算過程如下:
//
// Bias = (float)DepthBias * r + SlopeScaledDepthBias * MaxDepthSlope;
//
// 這裡的r是在深度緩衝區格式轉換為float32類型後,其深度值可取到大於0的最小可表示的值
// MaxDepthSlope則是像素在水平方向和豎直方向上的深度斜率的最大值
// [結束MSDN引用]
//
// 對於一個24位的深度緩衝區來說, r = 1 / 2^24
//
// 例如:DepthBias = 100000 ==> 實際的DepthBias = 100000/2^24 = .006
//
// 本Demo中的方向光始終與地面法線呈45度夾角,故取斜率為1.0f
// 以下數據極其依賴於實際場景,因此我們需要對特定場景反覆嘗試才能找到最合適
rsDesc.DepthBias = 100000;
rsDesc.DepthBiasClamp = 0.0f;
rsDesc.SlopeScaledDepthBias = 1.0f

注意:深度偏移發生在光柵化期間(裁剪之後),因此不會對幾何體裁剪造成影響。

RenderStates中我們添加了這樣一個光柵化狀態:

// 深度偏移模式
rasterizerDesc.FillMode = D3D11_FILL_SOLID;
rasterizerDesc.CullMode = D3D11_CULL_BACK;
rasterizerDesc.FrontCounterClockwise = false;
rasterizerDesc.DepthClipEnable = true;
rasterizerDesc.DepthBias = 100000;
rasterizerDesc.DepthBiasClamp = 0.0f;
rasterizerDesc.SlopeScaledDepthBias = 1.0f;
HR(device->CreateRasterizerState(&rasterizerDesc, RSDepth.GetAddressOf()));

MSDN文檔Depth Bias講述了該技術相關的全部規則,並且介紹了如何使用浮點深度緩衝區進行工作。

百分比漸近過濾(PCF)

在使用投影紋理坐標(u, v)對陰影圖進行取樣時,往往不會命中陰影圖中紋素的準確位置,而是通常位於陰影圖中的4個紋素之間。然而,我們不應該對深度值採用雙線性插值法,因為4個紋素之間的深度值不一定滿足線性過渡,插值出來的深度值跟實際的深度值有偏差,這樣可能會導致把像素錯誤標入陰影中這樣的錯誤結果(因此我們也不能為陰影圖生成mipmap)。

出於這樣的原因,我們應該對取樣的結果進行插值,而不是對深度值進行插值。這種做法稱為——百分比漸近過濾。即我們以點過濾(MIN_MAG_MIP_POINT)的方式在坐標(u, v)、(u+△x, v)、(u, v+△x)、(u+△x, v+△x)處對紋理進行取樣,其中△x=1/SHADOW_MAP_SIZE(除以的是引用貼圖的寬高)。由於是點取樣,這4個取樣點分別命中的是圍繞坐標(u, v)最近的4個陰影圖紋素s0、s1、s2、s3,如下圖所示。

接下來,我們會對這些採集的深度值進行陰影圖檢測,並對測試的結果展開雙線性插值。

static const float SMAP_SIZE = 2048.0f;
static const float SMAP_DX = 1.0f / SMAP_SIZE;

// ...

//
// 取樣操作
//

// 對陰影圖進行取樣以獲取離光源最近的深度值
float s0 = g_ShadowMap.Sample(g_SamShadow, tex.xy).r;
float s1 = g_ShadowMap.Sample(g_SamShadow, tex.xy + float2(SMAP_DX, 0)).r;
float s2 = g_ShadowMap.Sample(g_SamShadow, tex.xy + float2(0, SMAP_DX)).r;
float s3 = g_ShadowMap.Sample(g_SamShadow, tex.xy + float2(SMAP_DX, SMAP_DX)).r;

// 該像素的深度值是否小於等於陰影圖中的深度值
float r0 = (depth <= s0);
float r1 = (depth <= s1);
float r2 = (depth <= s2);
float r3 = (depth <= s3);

//
// 雙線性插值操作
//

// 變換到紋素空間
float2 texelPos = SMAP_SIZE * tex.xy;

// 確定插值係數(frac()返回浮點數的小數部分)
float2 t = frac(texelPos);

// 對比較結果進行雙線性插值
return lerp(lerp(r0, r1, t.x), lerp(r2, r3, t.x), t.y);

若採用這種計算方法,則一個像素就可能局部處於陰影之中,而不是非0即1.例如,若有4個樣本,三個在陰影中,一個在陰影外,那麼該像素有75%處於陰影之中。這就讓陰影內外的像素之間有了更加平滑的過渡,而不是稜角分明。

但這種過濾方法產生的陰影看起來仍然非常生硬,且鋸齒失真問題的最終處理效果還是不能令人十分滿意。PCF的主要缺點是需要4個紋理樣本,而紋理取樣本身就是現代GPU代價較高的操作之一,因為存儲器的頻寬與延遲並沒有隨著GPU計算能力的劇增而得到相近程度的巨大改良。幸運的是,Direct3D 11+版本的圖形硬體對PCF技術已經有了內部支援,上面的一大堆程式碼可以用SampleCmpLevelZero函數來替代。

float percentage = g_ShadowMap.SampleCmpLevelZero(g_SamShadow, shadowPosH.xy, depth).r;

方法中的LevelZero部分意味著它只能在最高的mipmap層級中進行取樣。另外,該方法使用的並非一般的取樣器對象,而是比較取樣器。這使得硬體能夠執行陰影圖的比較測試,並且需要在過濾取樣結果之前完成。對於PCF技術來說,我們需要使用的是D3D11_FILTER_COMPARISON_MIN_MAG_LINEAR_MIP_POINT過濾器,並將比較函數設置為LESS_EQUAL(由於對深度值進行了偏移,所以也要用到LESS比較函數)。

函數中傳入的depth將會出現在比較運算符的左邊,即:

depth <= sampleDepth

RenderStates中我們添加了這樣一個取樣器:

ComPtr<ID3D11SamplerState> RenderStates::SSShadow = nullptr;

// 取樣器狀態:深度比較與Border模式
ZeroMemory(&sampDesc, sizeof(sampDesc));
sampDesc.Filter = D3D11_FILTER_COMPARISON_MIN_MAG_LINEAR_MIP_POINT;
sampDesc.AddressU = D3D11_TEXTURE_ADDRESS_BORDER;
sampDesc.AddressV = D3D11_TEXTURE_ADDRESS_BORDER;
sampDesc.AddressW = D3D11_TEXTURE_ADDRESS_BORDER;
sampDesc.ComparisonFunc = D3D11_COMPARISON_LESS_EQUAL;
sampDesc.BorderColor[0] = { 1.0f };
sampDesc.MinLOD = 0;
sampDesc.MaxLOD = D3D11_FLOAT32_MAX;
HR(device->CreateSamplerState(&sampDesc, SSShadow.GetAddressOf()));

注意:根據SDK文檔所述,只有R32_FLOAT_X8X24_TYPELESSR32_FLOATR24_UNORM_X8_TYPELESSR16_UNORM格式才能用於比較過濾器。

在PCF的基礎上進行均值濾波

到目前為止,我們在本節中一直使用的是4-tap PCF核(輸入4個樣本來執行的PCF)。PCF核越大,陰影的邊緣輪廓也就越豐滿、越平滑,當然,花費在SampleCmpLevelZero函數上的開銷也就越大。在本Demo中,我們是按3×3正方形的均值濾波方式來執行PCF。由於每次調用SampleCmpLevelZero函數實際所執行的都是4-tap PCF,所以一共取樣了36次,其中有4×4個獨立取樣點。此外,採用過大的濾波核還會導致之前所述的陰影粉刺問題,但本章不打算講述,有興趣可以回到龍書閱讀(過大的PCF核)。

顯然,PCF技術一般來說只需在陰影的邊緣進行,因為陰影內外兩部分並不涉及混合操作(只有陰影邊緣才是漸變的)。基於此,只要能對陰影邊緣的PCF設計相應的處理方案就好了。但這種做法一般要求我們所用的PCF核足夠大(5×5及更大)時才划算(因為動態分支也有開銷)。不過最終是要效率還是要畫質還是取決於你自己。

注意:實際工程中所用的PCF核不一定是方形的過濾柵格。不少文獻也指出,隨機的拾取點也可以作為PCF核。

考慮到在做比較時,如果處於陰影外的值為1,在陰影內的值為0,在採用SampleCmpLevelZero和均值濾波後,我們用範圍值0~1來表示處於陰影外的程度。隨著值的增加,該點也變得越亮。我們可以使用下面的函數來計算3×3正方形的均值濾波下的陰影因子:

float CalcShadowFactor(SamplerComparisonState samShadow, Texture2D shadowMap, float4 shadowPosH)
{
	// 透視除法
    shadowPosH.xyz /= shadowPosH.w;
	
	// NDC空間的深度值
    float depth = shadowPosH.z;

	// 紋素在紋理坐標下的寬高
    const float dx = SMAP_DX;

    float percentLit = 0.0f;
    const float2 offsets[9] =
    {
        float2(-dx, -dx), float2(0.0f, -dx), float2(dx, -dx),
		float2(-dx, 0.0f), float2(0.0f, 0.0f), float2(dx, 0.0f),
		float2(-dx, +dx), float2(0.0f, +dx), float2(dx, +dx)
    };
                      
	[unroll]
    for (int i = 0; i < 9; ++i)
    {
        percentLit += shadowMap.SampleCmpLevelZero(samShadow,
			shadowPosH.xy + offsets[i], depth).r;
    }
    
    return percentLit /= 9.0f;
}

然後在我們的光照模型中,只有第一個方向光才參與到陰影的計算,並且陰影因子將與直接光照(漫反射和鏡面反射光)項相乘。

// ...
float shadow[5] = { 1.0f, 1.0f, 1.0f, 1.0f, 1.0f };
 
// 僅第一個方向光用於計算陰影
shadow[0] = CalcShadowFactor(g_SamShadow, g_ShadowMap, pIn.ShadowPosH);
    
[unroll]
for (i = 0; i < 5; ++i)
{
    ComputeDirectionalLight(g_Material, g_DirLight[i], pIn.NormalW, toEyeW, A, D, S);
    ambient += A;
    diffuse += shadow[i] * D;
    spec += shadow[i] * S;
}

// ...

由於環境光是間接光,所以陰影因子不受影響。並且,陰影因子也不會對來自環境映射的反射光構成影響。

C++端程式碼實現

EffectHelper的引入

本章開始的程式碼引入了EffectHelper來管理著色器所需的資源(我們可以無需手動創建並交給它來託管),並應用在了所有的Effect類當中。除了IEffect介面類,目前還引入了IEffectTransform介面類來統一變換的設置。隨著抽象類的增加,像GameObject這樣的類就可以對IEffect介面類對象查詢是否有某一特定介面類或具體類來執行額外的複雜操作。

此外,SkyRender類也因此有了輕微的變動。具體想了解還是去源碼翻閱,這裡不展開。

構建陰影貼圖與更新

首先我們要在GameApp::InitResource中創建一副2048×2048的陰影貼圖:

m_pShadowMap = std::make_unique<TextureRender>();
HR(m_pShadowMap->InitResource(m_pd3dDevice.Get(), 2048, 2048, true));

在本Demo中,光照方向每幀都在變動,我們希望讓投影立方體與光照所屬的變換軸對齊,並且中心能夠坐落在原點。因此在GameApp::UpdateScene可以這麼做:

//
// 投影區域為正方體,以原點為中心,以方向光為+Z朝向
//
XMMATRIX LightView = XMMatrixLookAtLH(dirVec * 20.0f * (-2.0f), g_XMZero, g_XMIdentityR1);
m_pShadowEffect->SetViewMatrix(LightView);

// 將NDC空間 [-1, +1]^2 變換到紋理坐標空間 [0, 1]^2
static XMMATRIX T(
    0.5f, 0.0f, 0.0f, 0.0f,
    0.0f, -0.5f, 0.0f, 0.0f,
    0.0f, 0.0f, 1.0f, 0.0f,
    0.5f, 0.5f, 0.0f, 1.0f);
// S = V * P * T
m_pBasicEffect->SetShadowTransformMatrix(LightView * XMMatrixOrthographicLH(40.0f, 40.0f, 20.0f, 60.0f) * T);

至於繪製部分,本Demo將和陰影有聯繫的場景對象放入了另一個重載函數DrawScene中(具體實現不在這給出),總體情況如下:

void GameApp::DrawScene()
{
    // ...

    // ******************
    // 繪製到陰影貼圖

    m_pShadowMap->Begin(m_pd3dImmediateContext.Get(), nullptr);
    {
        DrawScene(true);
    }
    m_pShadowMap->End(m_pd3dImmediateContext.Get());

    // ******************
    // 正常繪製場景
    m_pBasicEffect->SetTextureShadowMap(m_pShadowMap->GetOutputTexture());
    DrawScene(false, m_EnableNormalMap);

    // 繪製天空盒
    m_pDesert->Draw(m_pd3dImmediateContext.Get(), *m_pSkyEffect, *m_pCamera);

    // 解除深度緩衝區綁定
    m_pBasicEffect->SetTextureShadowMap(nullptr);
    m_pBasicEffect->Apply(m_pd3dImmediateContext.Get());

    // ...

}

演示

本Demo提供了5種斜率下的方向光,對應主鍵盤數字鍵1-5,Q鍵開關法線貼圖,E鍵開關陰影貼圖的顯示,G鍵切換陰影貼圖的顯示模式。

練習題

  1. 嘗試4096×4096、1024×1024、512×512、256×256這幾種不同解析度的陰影貼圖
  2. 嘗試以單次點取樣陰影檢測來修改本演示程式(即不採用PCF)。我們將欣賞到硬陰影與鋸齒狀的陰影邊緣
  3. 關閉斜率縮放偏移來觀察陰影粉刺
  4. 將斜率縮放偏移值放大10倍,觀察peter panning失真的效果
  5. 實現單點光源下的陰影(必要時可以考慮像CubeMap那樣使用6個正方形貼圖)

DirectX11 With Windows SDK完整目錄

Github項目源碼

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