DirectX11 With Windows SDK--32 SSAO(屏幕空間環境光遮蔽)

前言

由於性能的限制,實時光照模型往往會忽略間接光因素(即場景中其他物體所反彈的光線)。但在現實生活中,大部分光照其實是間接光。在第7章裏面的光照方程裏面引入了環境光項:

\[C_a = \mathbf{A_L}\otimes\mathbf{m_d} \]

其中顏色\(\mathbf{A_L}\)表示的是從某光源發出,經過環境光反射而照射到物體表面的間接光總量。漫反射\(\mathbf{m_d}\)則是物體表面根據漫反射率將入射光反射回的總量。這種方式的計算只是一種簡化,並非真正的物理計算,它直接假定物體表面任意一點接收到的光照都是相同的,並且都能以相同的反射係數最終反射到我們眼睛。下圖展示瞭如果僅採用環境光項來繪製模型的情況,物體將會被同一種單色所渲染:

當然,這種環境光項是不真實的,我們對其還有一些改良的餘地。

學習目標:

  1. 瞭解環境光遮蔽技術背後的基本原理,並知道如何通過投射光線來實現環境光遮蔽(見龍書d3d11CodeSet3/AmbientOcclusion項目)
  2. 熟悉屏幕空間環境光遮蔽這種近似於實時的環境光遮蔽技術(本章項目)。

DirectX11 With Windows SDK完整目錄

Github項目源碼

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

投射光線實現環境光遮蔽

環境光遮蔽技術的主體思路如下圖所示,表面上一點p所接收到的間接光總量,與照射到p爲中心的半球的入射光量成正比。

一種估算點p受遮蔽程度的方法是採用光線投射法。我們隨機投射出一些光線,使得它們傳過以點p爲中心的半球,並檢測這些光線與網格相交的情況。或者說我們以點p作爲射線的起點,隨機地在半球範圍選擇一個方向進行投射。

如果投射了N條光線,有h條與網格相交,那麼點p的遮蔽率大致爲:

\[occlusion=\frac{h}{N} \in [0, 1] \]

並且僅當光線與網格的交點q與點p之間的距離小於某個閾值d時纔會認爲該光線產生遮蔽。這是因爲若交點p與點p距離過遠時就說明這個方向上照射到點p的光不會受到物體的遮擋。

遮蔽因子用來測量該點受到遮蔽的程度(有多少光線不能到達該點)。計算該值出來,是爲了知道該點能夠接受光照的程度,即我們需要的是它的相反值,通常叫它爲可及率

\[accessibility = 1 - occlusion \in [0, 1] \]

在龍書11的項目AmbientOcclusion中,我們可以找到AmbientOcclusionApp::BuildVertexAmbientOcclusion函數,它負責爲物體的每個頂點計算出間接光的可及率。由於與本章主旨不同,故不在這裏貼出源碼。它是在程序運行之初先對所有物體預先計算出頂點的遮蔽情況,物體每個頂點都會投射出固定數目的隨機方向射線,然後與場景中的所有網格三角形做相交檢測。這一切都是在CPU完成的。

如果你之前寫過CPU光線追蹤的程序的話,能明顯感覺到產生一幅圖所需要的時間非常的長。因爲從物體表面一點可能會投射非常多的射線,並且這些射線還需要跟場景中的所有網格三角形做相交檢測,如果不採用加速結構的話就是數以萬計的射線要與數以萬計的三角形同時做相交檢測。在龍書11的所示例程中採用了八叉樹這種特別的數據解來進行物體的空間劃分以進行加速,這樣一條射線就可能只需要做不到10次的逐漸精細的檢測就可以快速判斷出是否有三角形相交。

在經過幾秒的漫長等待後,程序完成了物體的遮蔽預計算並開始渲染,下圖跟前面的圖相比起來可以說得到了極大的改善。該樣例程序並沒有使用任何光照,而是直接基於物體頂點的遮蔽屬性進行着色。可以看到那些顏色比較深的地方通常都是模型的縫隙間,因爲從它們投射出的光線更容易與其它幾何體相交。

投射光線實現環境光遮蔽的方法適用於那些靜態物體,即我們可以先給模型本身預先計算遮蔽值並保存到頂點上,又或者是通過一些工具直接生成環境光遮蔽圖,即存有環境光遮蔽數據的紋理。然而,對於動態物體來說就不適用了,每次物體發生變化就要重新計算一次遮蔽數據明顯非常不現實,也不能滿足實時渲染的需求。接下來我們將會學到一種基於屏幕空間實時計算的環境光遮蔽技術。

屏幕空間環境光遮蔽(SSAO)

屏幕空間環境光遮蔽(Screen Space Ambient Occlusion)技術的策略是:在每一幀渲染過程中,將場景處在觀察空間中的法向量和深度值渲染到額外的一個屏幕大小的紋理,然後將該紋理作爲輸入來估算每個像素點的環境光遮蔽程度。最終當前像素所接受的從某光源發出的環境光項爲:

\[C_a = ambientAccess \cdot \mathbf{A_L}\otimes\mathbf{m_d} \]

法線和深度值的渲染

首先我們將場景物體渲染到屏幕大小、格式爲DXGI_FORMAT_R16G16B16A16_FLOAT的法向量/深度值紋理貼圖,其中RGB分量代表法向量,Alpha分量代表該點在屏幕空間中深度值。具體的HLSL代碼如下:

// SSAO_NormalDepth_Object_VS.hlsl
#include "SSAO.hlsli"

// 生成觀察空間的法向量和深度值的RTT的頂點着色器
VertexPosHVNormalVTex VS(VertexPosNormalTex vIn)
{
    VertexPosHVNormalVTex vOut;
    
    // 變換到觀察空間
    vOut.PosV = mul(float4(vIn.PosL, 1.0f), g_WorldView).xyz;
    vOut.NormalV = mul(vIn.NormalL, (float3x3) g_WorldInvTransposeView);
    
    // 變換到裁剪空間
    vOut.PosH = mul(float4(vIn.PosL, 1.0f), g_WorldViewProj);
    
    vOut.Tex = vIn.Tex;
    
	return vOut;
}

// SSAO_NormalDepth_Instance_VS.hlsl
#include "SSAO.hlsli"

// 生成觀察空間的法向量和深度值的RTT的頂點着色器
VertexPosHVNormalVTex VS(InstancePosNormalTex vIn)
{
    VertexPosHVNormalVTex vOut;
    
    vector posW = mul(float4(vIn.PosL, 1.0f), vIn.World);
    matrix viewProj = mul(g_View, g_Proj);
    matrix worldView = mul(vIn.World, g_View);
    matrix worldInvTransposeView = mul(vIn.WorldInvTranspose, g_View);
    
    // 變換到觀察空間
    vOut.PosV = mul(float4(vIn.PosL, 1.0f), worldView).xyz;
    vOut.NormalV = mul(vIn.NormalL, (float3x3) worldInvTransposeView);
    
    // 變換到裁剪空間
    vOut.PosH = mul(posW, viewProj);
    
    vOut.Tex = vIn.Tex;
    
	return vOut;
}

// SSAO_NormalDepth_PS.hlsl
#include "SSAO.hlsli"

// 生成觀察空間的法向量和深度值的RTT的像素着色器
float4 PS(VertexPosHVNormalVTex pIn, uniform bool alphaClip) : SV_TARGET
{
    // 將法向量給標準化
    pIn.NormalV = normalize(pIn.NormalV);
    
    if (alphaClip)
    {
        float4 g_TexColor = g_DiffuseMap.Sample(g_SamLinearWrap, pIn.Tex);
        
        clip(g_TexColor.a - 0.1f);
    }
    
    // 返回觀察空間的法向量和深度值
    return float4(pIn.NormalV, pIn.PosV.z);
}

考慮到可能會通過實例化進行繪製,還需要額外配置實例化版本的頂點着色器。由於我們使用的是浮點型DXGI格式,寫入任何浮點數據都是合理的(只要不超出16位浮點表示範圍)。下面兩幅圖分別對應觀察空間法向量/深度圖的RGB部分和Alpha部分

環境光遮蔽的渲染

在繪製好觀察空間法向量和深度紋理之後,我們就禁用深度緩衝區(我們不需要用到它),並在每個像素處調用SSAO像素着色器來繪製一個全屏四邊形。這樣像素着色器將運用法向量/深度紋理來爲每個像素生成環境光可及率。最終生成的貼圖叫SSAO圖。儘管我們以全屏分辨率渲染法向量/深度圖,但在繪製SSAO圖時,出於性能的考慮,我們使用的是一半寬高的分辨率。以一半分辨率渲染並不會對質量有多大的影響,因爲環境光遮蔽也是一種低頻效果(low frequency effect,LFE)。

核心思想

p是當前我們正在處理的像素,我們根據從觀察點到該像素在遠平面內對應點的向量v以及法向量/深度緩衝區中存儲的點p在觀察空間中的深度值來重新構建出點p

q是以點p爲中心的半球內的隨機一點,點r對應的是從觀察點到點q這一路徑上的最近可視點。

如果\(|p_z-r_z|\)足夠小,且r-pn之間的夾角小於90°,那麼可以認爲點r對點q產生遮蔽,故需要將其計入點p的遮蔽值。在本Demo中,我們使用了14個隨機採樣點,根據平均值法求得的遮蔽率來估算屏幕空間中的環境光遮蔽數據。

1. 重新構建待處理點在觀察空間中的位置

當我們爲繪製全屏四邊形而對SSAO圖中的每個像素調用SSAO的像素着色器時,我們可以在頂點着色器以某種方式輸出視錐體遠平面的四個角落點。龍書12的源碼採用的是頂點着色階段只使用SV_VertexID作爲輸入,並且提供NDC空間的頂點經過投影逆變換得到,但用於頂點着色器提供SV_VertexID的話會導致我們不能使用VS的圖形調試器,故在此迴避。

總而言之,目前的做法是在C++端生成視錐體遠平面四個角點,然後通過常量緩衝區傳入,並通過頂點輸入傳入視錐體遠平面頂點數組的索引來獲取。

// SSAORender.cpp
void SSAORender::BuildFrustumFarCorners(float fovY, float farZ)
{
	float aspect = (float)m_Width / (float)m_Height;

	float halfHeight = farZ * tanf(0.5f * fovY);
	float halfWidth = aspect * halfHeight;

	m_FrustumFarCorner[0] = XMFLOAT4(-halfWidth, -halfHeight, farZ, 0.0f);
	m_FrustumFarCorner[1] = XMFLOAT4(-halfWidth, +halfHeight, farZ, 0.0f);
	m_FrustumFarCorner[2] = XMFLOAT4(+halfWidth, +halfHeight, farZ, 0.0f);
	m_FrustumFarCorner[3] = XMFLOAT4(+halfWidth, -halfHeight, farZ, 0.0f);
}
cbuffer CBChangesEveryFrame : register(b1)
{
	// ...
    g_FrustumCorners[4];         // 視錐體遠平面的4個端點
}

// 繪製SSAO圖的頂點着色器
VertexOut VS(VertexIn vIn)
{
    VertexOut vOut;
    
    // 已經在NDC空間
    vOut.PosH = float4(vIn.PosL, 1.0f);
    
    // 我們用它的x分量來索引視錐體遠平面的頂點數組
    vOut.ToFarPlane = g_FrustumCorners[vIn.ToFarPlaneIndex.x].xyz;
    
    vOut.Tex = vIn.Tex;
    
    return vOut;
}

現在,對於每個像素而言,我們得到了從觀察點射向該像素直到遠平面對應一點的向量ToFarPlane(亦即向量v),這些向量都是通過插值算出來的。然後我們對法向量/深度圖進行採樣來得到對應像素在觀察空間中的法向量和深度值。重建屏幕空間座標p的思路爲:已知採樣出的觀察空間的z值,它也正好是點p的z值;並且知道了原點到遠平面的向量v。由於這條射線必然經過點p,故它們滿足:

\[\mathbf{p}=\frac{p_z}{v_z}\mathbf{v} \]

因此就有:

// 繪製SSAO圖的頂點着色器
float4 PS(VertexOut pIn, uniform int sampleCount) : SV_TARGET
{
    // p -- 我們要計算的環境光遮蔽目標點
    // n -- 頂點p的法向量
    // q -- 點p處所在半球內的隨機一點
    // r -- 有可能遮擋點p的一點
    
    // 獲取觀察空間的法向量和當前像素的z座標
    float4 normalDepth = g_NormalDepthMap.SampleLevel(g_SamNormalDepth, pIn.Tex, 0.0f);
    
    float3 n = normalDepth.xyz;
    float pz = normalDepth.w;
    
    //
    // 重建觀察空間座標 (x, y, z)
    // 尋找t使得能夠滿足 p = t * pIn.ToFarPlane
    // p.z = t * pIn.ToFarPlane.z
    // t = p.z / pIn.ToFarPlane.z
    //
    float3 p = (pz / pIn.ToFarPlane.z) * pIn.ToFarPlane;
    
    // ...
}

2. 生成隨機採樣點

這一步模擬的是向半球隨機投射光線的過程。我們以點p爲中心,在指定的遮蔽半徑內隨機地從點p的前側部分採集N個點,並將其中的任意一點記爲q。遮蔽半徑是一項影響藝術效果的參數,它控制着我們採集的隨機樣點相對於點p的距離。而選擇僅採集點p前側部分的點,就相當於在以光線投射的方式執行環境光遮蔽時,就只需在半球內進行投射而不必在完整的球體內投射而已。

接下來的問題是如何來生成隨機樣點。一種解決方案是,我們可以生成隨機向量並將它們存放於一個紋理圖中,再在紋理圖的N個不同位置獲取N個隨機向量。

在C++中,生成隨機向量紋理由下面的方法實現:

HRESULT SSAORender::BuildRandomVectorTexture(ID3D11Device* device)
{
	CD3D11_TEXTURE2D_DESC texDesc(DXGI_FORMAT_R8G8B8A8_UNORM, 256, 256, 1, 1, 
		D3D11_BIND_SHADER_RESOURCE, D3D11_USAGE_IMMUTABLE);
	
	D3D11_SUBRESOURCE_DATA initData = {};
	std::vector<XMCOLOR> randomVectors(256 * 256);

	// 初始化隨機數數據
	std::mt19937 randEngine;
	randEngine.seed(std::random_device()());
	std::uniform_real_distribution<float> randF(0.0f, 1.0f);
	for (int i = 0; i < 256 * 256; ++i)
	{
		randomVectors[i] = XMCOLOR(randF(randEngine), randF(randEngine), randF(randEngine), 0.0f);
	}
	initData.pSysMem = randomVectors.data();
	initData.SysMemPitch = 256 * sizeof(XMCOLOR);

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

	hr = device->CreateShaderResourceView(tex.Get(), nullptr, m_pRandomVectorSRV.GetAddressOf());
	return hr;
}

然而,由於整個計算過程都是隨機的,所以我們並不能保證採集的向量必然是均勻分佈,也就是說,會有全部向量趨於同向的風險,這樣一來,遮蔽率的估算結果必然有失偏頗。爲了解決這個問題,我們將採用下列技巧。在我們實現的方法之中一共使用了N=14個採樣點,並以下列C++代碼生成14個均勻分佈的向量。

void SSAORender::BuildOffsetVectors()
{
	// 從14個均勻分佈的向量開始。我們選擇立方體的8個角點,並沿着立方體的每個面選取中心點
	// 我們總是讓這些點以相對另一邊的形式交替出現。這種辦法可以在我們選擇少於14個採樣點
	// 時仍然能夠讓向量均勻散開

	// 8個立方體角點向量
	m_Offsets[0] = XMFLOAT4(+1.0f, +1.0f, +1.0f, 0.0f);
	m_Offsets[1] = XMFLOAT4(-1.0f, -1.0f, -1.0f, 0.0f);

	m_Offsets[2] = XMFLOAT4(-1.0f, +1.0f, +1.0f, 0.0f);
	m_Offsets[3] = XMFLOAT4(+1.0f, -1.0f, -1.0f, 0.0f);

	m_Offsets[4] = XMFLOAT4(+1.0f, +1.0f, -1.0f, 0.0f);
	m_Offsets[5] = XMFLOAT4(-1.0f, -1.0f, +1.0f, 0.0f);

	m_Offsets[6] = XMFLOAT4(-1.0f, +1.0f, -1.0f, 0.0f);
	m_Offsets[7] = XMFLOAT4(+1.0f, -1.0f, +1.0f, 0.0f);

	// 6個面中心點向量
	m_Offsets[8] = XMFLOAT4(-1.0f, 0.0f, 0.0f, 0.0f);
	m_Offsets[9] = XMFLOAT4(+1.0f, 0.0f, 0.0f, 0.0f);

	m_Offsets[10] = XMFLOAT4(0.0f, -1.0f, 0.0f, 0.0f);
	m_Offsets[11] = XMFLOAT4(0.0f, +1.0f, 0.0f, 0.0f);

	m_Offsets[12] = XMFLOAT4(0.0f, 0.0f, -1.0f, 0.0f);
	m_Offsets[13] = XMFLOAT4(0.0f, 0.0f, +1.0f, 0.0f);


	// 初始化隨機數數據
	std::mt19937 randEngine;
	randEngine.seed(std::random_device()());
	std::uniform_real_distribution<float> randF(0.25f, 1.0f);
	for (int i = 0; i < 14; ++i)
	{
		// 創建長度範圍在[0.25, 1.0]內的隨機長度的向量
		float s = randF(randEngine);

		XMVECTOR v = s * XMVector4Normalize(XMLoadFloat4(&m_Offsets[i]));

		XMStoreFloat4(&m_Offsets[i], v);
	}
}

在從隨機向量貼圖中採樣之後,我們用它來對14個均勻分佈的向量進行反射。其最終結果就是獲得了14個均勻分佈的隨機向量。然後因爲我們需要的是對半球進行採樣,所以我們只需要將位於半球外的向量進行翻轉即可。

// 在以p爲中心的半球內,根據法線n對p周圍的點進行採樣
for (int i = 0; i < sampleCount; ++i)
{
    // 偏移向量都是固定且均勻分佈的(所以我們採用的偏移向量不會在同一方向上扎堆)。
    // 如果我們將這些偏移向量關聯於一個隨機向量進行反射,則得到的必定爲一組均勻分佈
    // 的隨機偏移向量
    float3 offset = reflect(g_OffsetVectors[i].xyz, randVec);
        
    // 如果偏移向量位於(p, n)定義的平面之後,將其翻轉
    float flip = sign(dot(offset, n));
    
    // ...
}

3. 生成潛在的遮蔽點

現在我們擁有了在點p周圍的隨機採樣點q。但是我們不清楚該點所處的位置是空無一物,還是處於實心物體,因此我們不能直接用它來測試是否遮蔽了點p。爲了尋找潛在的遮蔽點,我們需要來自法向量/深度貼圖中的深度信息。接下來我們對點q進行投影,並得到投影紋理座標,從而對貼圖進行採樣來獲取沿着點q發出的射線,到達最近可視像素點r的深度值\(r_z\)。我們一樣能夠用前面的方式重新構建點r在觀察空間中的位置,它們滿足:

\[\mathbf{r}=\frac{r_z}{q_z}\mathbf{q} \]

因此根據每個隨機採樣點q所生產的點r即爲潛在的遮蔽點

4. 進行遮蔽測試

現在我們獲得了潛在的遮蔽點r,接下來就可以進行遮蔽測試,以估算它是否會遮蔽點p。該測試基於下面兩種值:

  1. 觀察空間中點p與點r的深度距離爲\(|p_z-r_z|\)。隨着距離的增長,遮蔽值將按比例線性減小,因爲遮蔽點與目標點的距離越遠,其遮蔽的效果就越弱。如果該距離超過某個指定的最大距離,那麼點r將完全不會遮擋點p。而且,如果此距離過小,我們將認爲點p與點q位於同一平面上,故點q此時也不會遮擋點p
  2. 法向量n與向量r-p的夾角的測定方式爲\(max(\mathbf{n}\cdot(\frac{\mathbf{r-p}}{\Vert \mathbf{r-p} \Vert}), 0)\).這是爲了防止自相交情況的發生

如果點r與點p位於同一平面內,就可以滿足第一個條件,即距離\(|p_z-r_z|\)足夠小以至於點r遮蔽了點q。然而,從上圖可以看出,兩者在同一平面內的時候,點r並沒有遮蔽點p。通過計算\(max(\mathbf{n}\cdot(\frac{\mathbf{r-p}}{\Vert \mathbf{r-p} \Vert}), 0)\)作爲因子相乘遮蔽值可以防止對此情況的誤判

5. 完成計算過程

在對每個採樣點的遮蔽數據相加後,還要通過除以採樣的次數來計算遮蔽率。接着,我們會計算環境光的可及率,並對它進行冪運算以提高對比度(contrast)。當然,我們也能夠按需求適當增加一些數值來提高光照強度,以此爲環境光圖(ambient map)添加亮度。除此之外,我們還可以嘗試不同的對比值和亮度值。

occlusionSum /= g_SampleCount;

float access = 1.0f - occlusionSum;

// 增強SSAO圖的對比度,是的SSAO圖的效果更加明顯
return saturate(pow(access, 4.0f));

完整HLSL實現

// SSAO.hlsli

// ...
Texture2D g_NormalDepthMap : register(t1);
Texture2D g_RandomVecMap : register(t2);
// ...

// ...
SamplerState g_SamNormalDepth : register(s1);
SamplerState g_SamRandomVec : register(s2);
// ...

// ...

cbuffer CBChangesOnResize : register(b2)
{
	// ...
    
    //
    // 用於SSAO
    //
    matrix g_ViewToTexSpace;    // Proj * Texture
    float4 g_FrustumCorners[4]; // 視錐體遠平面的4個端點
}

cbuffer CBChangesRarely : register(b3)
{
    // 14個方向均勻分佈但長度隨機的向量
    float4 g_OffsetVectors[14]; 
    
    // 觀察空間下的座標
    float g_OcclusionRadius = 0.5f;
    float g_OcclusionFadeStart = 0.2f;
    float g_OcclusionFadeEnd = 2.0f;
    float g_SurfaceEpsilon = 0.05f;
    
    // ...
};

//
// 用於SSAO
//
struct VertexIn
{
    float3 PosL : POSITION;
    float3 ToFarPlaneIndex : NORMAL; // 僅使用x分量來進行對視錐體遠平面頂點的索引
    float2 Tex : TEXCOORD;
};

struct VertexOut
{
    float4 PosH : SV_POSITION;
    float3 ToFarPlane : TEXCOORD0; // 遠平面頂點座標
    float2 Tex : TEXCOORD1;
};

其中g_SamNormalDepthg_SamRandomVec使用的是下面創建的採樣器:

D3D11_SAMPLER_DESC samplerDesc;
ZeroMemory(&samplerDesc, sizeof samplerDesc);

// 用於法向量和深度的採樣器
samplerDesc.Filter = D3D11_FILTER_MIN_MAG_LINEAR_MIP_POINT;
samplerDesc.AddressU = samplerDesc.AddressV = samplerDesc.AddressW = D3D11_TEXTURE_ADDRESS_BORDER;
samplerDesc.ComparisonFunc = D3D11_COMPARISON_NEVER;
samplerDesc.BorderColor[3] = 1e5f;	// 設置非常大的深度值 (Normal, depthZ) = (0, 0, 0, 1e5f)
samplerDesc.MinLOD = 0.0f;
samplerDesc.MaxLOD = D3D11_FLOAT32_MAX;
HR(device->CreateSamplerState(&samplerDesc, pImpl->m_pSamNormalDepth.GetAddressOf()));
pImpl->m_pEffectHelper->SetSamplerStateByName("g_SamNormalDepth", pImpl->m_pSamNormalDepth.Get());

// 用於隨機向量的採樣器
samplerDesc.AddressU = samplerDesc.AddressV = samplerDesc.AddressW = D3D11_TEXTURE_ADDRESS_WRAP;
samplerDesc.BorderColor[3] = 0.0f;
HR(device->CreateSamplerState(&samplerDesc, pImpl->m_pSamRandomVec.GetAddressOf()));
pImpl->m_pEffectHelper->SetSamplerStateByName("g_SamRandomVec", pImpl->m_pSamRandomVec.Get());
// SSAO_VS.hlsl
#include "SSAO.hlsli"

// 繪製SSAO圖的頂點着色器
VertexOut VS(VertexIn vIn)
{
    VertexOut vOut;
    
    // 已經在NDC空間
    vOut.PosH = float4(vIn.PosL, 1.0f);
    
    // 我們用它的x分量來索引視錐體遠平面的頂點數組
    vOut.ToFarPlane = g_FrustumCorners[vIn.ToFarPlaneIndex.x].xyz;
    
    vOut.Tex = vIn.Tex;
    
    return vOut;
}

// SSAO_PS.hlsl
#include "SSAO.hlsli"

// 給定點r和p的深度差,計算出採樣點q對點p的遮蔽程度
float OcclusionFunction(float distZ)
{
    //
    // 如果depth(q)在depth(p)之後(超出半球範圍),那點q不能遮蔽點p。此外,如果depth(q)和depth(p)過於接近,
    // 我們也認爲點q不能遮蔽點p,因爲depth(p)-depth(r)需要超過用戶假定的Epsilon值才能認爲點q可以遮蔽點p
    //
    // 我們用下面的函數來確定遮蔽程度
    //
    //    /|\ Occlusion
    // 1.0 |      ---------------\
    //     |      |             |  \
    //     |                         \
    //     |      |             |      \
    //     |                             \
    //     |      |             |          \
    //     |                                 \
    // ----|------|-------------|-------------|-------> zv
    //     0     Eps          zStart         zEnd
    float occlusion = 0.0f;
    if (distZ > g_SurfaceEpsilon)
    {
        float fadeLength = g_OcclusionFadeEnd - g_OcclusionFadeStart;
        // 當distZ由g_OcclusionFadeStart逐漸趨向於g_OcclusionFadeEnd,遮蔽值由1線性減小至0
        occlusion = saturate((g_OcclusionFadeEnd - distZ) / fadeLength);
    }
    return occlusion;
}


// 繪製SSAO圖的頂點着色器
float4 PS(VertexOut pIn, uniform int sampleCount) : SV_TARGET
{
    // p -- 我們要計算的環境光遮蔽目標點
    // n -- 頂點p的法向量
    // q -- 點p處所在半球內的隨機一點
    // r -- 有可能遮擋點p的一點
    
    // 獲取觀察空間的法向量和當前像素的z座標
    float4 normalDepth = g_NormalDepthMap.SampleLevel(g_SamNormalDepth, pIn.Tex, 0.0f);
    
    float3 n = normalDepth.xyz;
    float pz = normalDepth.w;
    
    //
    // 重建觀察空間座標 (x, y, z)
    // 尋找t使得能夠滿足 p = t * pIn.ToFarPlane
    // p.z = t * pIn.ToFarPlane.z
    // t = p.z / pIn.ToFarPlane.z
    //
    float3 p = (pz / pIn.ToFarPlane.z) * pIn.ToFarPlane;
    
    // 獲取隨機向量並從[0, 1]^3映射到[-1, 1]^3
    float3 randVec = g_RandomVecMap.SampleLevel(g_SamRandomVec, 4.0f * pIn.Tex, 0.0f).xyz;
    randVec = 2.0f * randVec - 1.0f;
    
    float occlusionSum = 0.0f;
    
    // 在以p爲中心的半球內,根據法線n對p周圍的點進行採樣
    for (int i = 0; i < sampleCount; ++i)
    {
        // 偏移向量都是固定且均勻分佈的(所以我們採用的偏移向量不會在同一方向上扎堆)。
        // 如果我們將這些偏移向量關聯於一個隨機向量進行反射,則得到的必定爲一組均勻分佈
        // 的隨機偏移向量
        float3 offset = reflect(g_OffsetVectors[i].xyz, randVec);
        
        // 如果偏移向量位於(p, n)定義的平面之後,將其翻轉
        float flip = sign(dot(offset, n));
        
        // 在點p處於遮蔽半徑的半球範圍內進行採樣
        float3 q = p + flip * g_OcclusionRadius * offset;
    
        // 將q進行投影,得到投影紋理座標
        float4 projQ = mul(float4(q, 1.0f), g_ViewToTexSpace);
        projQ /= projQ.w;
        
        // 找到眼睛觀察點q方向所能觀察到的最近點r所處的深度值(有可能點r不存在,此時觀察到
        // 的是遠平面上一點)。爲此,我們需要查看此點在深度圖中的深度值
        float rz = g_NormalDepthMap.SampleLevel(g_SamNormalDepth, projQ.xy, 0.0f).w;
        
        // 重建點r在觀察空間中的座標 r = (rx, ry, rz)
        // 我們知道點r位於眼睛到點q的射線上,故有r = t * q
        // r.z = t * q.z ==> t = t.z / q.z
        float3 r = (rz / q.z) * q;
        
        // 測試點r是否遮蔽p
        //   - 點積dot(n, normalize(r - p))度量遮蔽點r到平面(p, n)前側的距離。越接近於
        //     此平面的前側,我們就給它設定越大的遮蔽權重。同時,這也能防止位於傾斜面
        //     (p, n)上一點r的自陰影所產生出錯誤的遮蔽值(通過設置g_SurfaceEpsilon),這
        //     是因爲在以觀察點的視角來看,它們有着不同的深度值,但事實上,位於傾斜面
        //     (p, n)上的點r卻沒有遮擋目標點p
        //   - 遮蔽權重的大小取決於遮蔽點與其目標點之間的距離。如果遮蔽點r離目標點p過
        //     遠,則認爲點r不會遮擋點p
        
        float distZ = p.z - r.z;
        float dp = max(dot(n, normalize(r - p)), 0.0f);
        float occlusion = dp * OcclusionFunction(distZ);
        
        occlusionSum += occlusion;
    }
    
    occlusionSum /= sampleCount;
    
    float access = 1.0f - occlusionSum;
    
    // 增強SSAO圖的對比度,是的SSAO圖的效果更加明顯
    return saturate(pow(access, 4.0f));
}

模糊過程(雙邊模糊)

下圖展示了我們目前生成的SSAO圖的效果。其中的噪點是由於隨機採樣點過少導致的。但通過採集足夠多的樣點來屏蔽噪點的做法,在實時渲染的前提下並不切實際。對此,常用的解決方案是採用邊緣保留的模糊(edge preserving blur)的過濾方式來使得SSAO圖的過渡更爲平滑。這裏我們使用的是雙邊模糊,即bilateral blur。如果使用的過濾方法爲非邊緣保留的模糊,那麼隨着物體邊緣的明顯劃分轉爲平滑的漸變,會使得場景中的物體難以界定。這種保留邊緣的模糊算法與第30章中實現的模糊方法類似,唯一的區別在於需要添加一個條件語句,以令邊緣不受模糊處理(要使用法線/深度貼圖來檢測邊緣)。

// SSAO.hlsli
// ...
Texture2D g_NormalDepthMap : register(t1);
// ...
Texture2D g_InputImage : register(t3);

// ...
SamplerState g_SamBlur : register(s3); // MIG_MAG_LINEAR_MIP_POINT CLAMP

cbuffer CBChangesRarely : register(b3)
{
    // ...
    
    //
    // 用於SSAO_Blur
    //
    float4 g_BlurWeights[3] =
    {
        float4(0.05f, 0.05f, 0.1f, 0.1f),
        float4(0.1f, 0.2f, 0.1f, 0.1f),
        float4(0.1f, 0.05f, 0.05f, 0.0f)
    };
    
    int g_BlurRadius = 5;
    int3 g_Pad;
}

//
// 用於SSAO_Blur
//
struct VertexPosNormalTex
{
    float3 PosL : POSITION;
    float3 NormalL : NORMAL;
    float2 Tex : TEXCOORD;
};

struct VertexPosHTex
{
    float4 PosH : SV_POSITION;
    float2 Tex : TEXCOORD;
};
// SSAO_Blur_VS.hlsl
#include "SSAO.hlsli"

// 繪製SSAO圖的頂點着色器
VertexPosHTex VS(VertexPosNormalTex vIn)
{
    VertexPosHTex vOut;
    
    // 已經在NDC空間
    vOut.PosH = float4(vIn.PosL, 1.0f);
    
    vOut.Tex = vIn.Tex;
    
    return vOut;
}

// SSAO_Blur_PS.hlsl
#include "SSAO.hlsli"

// 雙邊濾波
float4 PS(VertexPosHTex pIn, uniform bool horizontalBlur) : SV_Target
{
    // 解包到浮點數組
    float blurWeights[12] = (float[12]) g_BlurWeights;
    
    float2 texOffset;
    if (horizontalBlur)
    {
        texOffset = float2(1.0f / g_InputImage.Length.x, 0.0f);
    }
    else
    {
        texOffset = float2(0.0f, 1.0f / g_InputImage.Length.y);
    }
    
    // 總是把中心值加進去計算
    float4 color = blurWeights[g_BlurRadius] * g_InputImage.SampleLevel(g_SamBlur, pIn.Tex, 0.0f);
    float totalWeight = blurWeights[g_BlurRadius];
    
    float4 centerNormalDepth = g_NormalDepthMap.SampleLevel(g_SamBlur, pIn.Tex, 0.0f);
    // 分拆出觀察空間的法向量和深度
    float3 centerNormal = centerNormalDepth.xyz;
    float centerDepth = centerNormalDepth.w;
    
    for (float i = -g_BlurRadius; i <= g_BlurRadius; ++i)
    {
        // 我們已經將中心值加進去了
        if (i == 0)
            continue;
        
        float2 tex = pIn.Tex + i * texOffset;
        
        float4 neighborNormalDepth = g_NormalDepthMap.SampleLevel(g_SamBlur, tex, 0.0f);
        // 分拆出法向量和深度
        float3 neighborNormal = neighborNormalDepth.xyz;
        float neighborDepth = neighborNormalDepth.w;
        
        //
        // 如果中心值和相鄰值的深度或法向量相差太大,我們就認爲當前採樣點處於邊緣區域,
        // 因此不考慮加入當前相鄰值
        //
        
        if (dot(neighborNormal, centerNormal) >= 0.8f && abs(neighborDepth - centerDepth) <= 0.2f)
        {
            float weight = blurWeights[i + g_BlurRadius];
            
            // 將相鄰像素加入進行模糊
            color += weight * g_InputImage.SampleLevel(g_SamBlur, tex, 0.0f);
            totalWeight += weight;
        }
        
    }

    // 通過讓總權值變爲1來補償丟棄的採樣像素
    return color / totalWeight;
}

經過了4次雙邊濾波的模糊處理後,得到的SSAO圖如下:

使用環境光遮蔽圖

到現在我們就已經構造出了環境光遮蔽圖,最後一步便是將其應用到場景當中。我們採用如下策略:在場景渲染到後備緩衝區時,我們要把環境光圖作爲着色器的輸入。接下來再以攝像機的視角生成投影紋理座標,對SSAO圖進行採樣,並將它應用到光照方程的環境光項。

在頂點着色器中,爲了省下傳一個投影紋理矩陣,採用下面的形式計算:

// 從NDC座標[-1, 1]^2變換到紋理空間座標[0, 1]^2
// u = 0.5x + 0.5
// v = -0.5y + 0.5
// ((xw, yw, zw, w) + (w, w, 0, 0)) * (0.5, -0.5, 1, 1) = ((0.5x + 0.5)w, (-0.5y + 0.5)w, zw, w)
//                                                      = (uw, vw, zw, w)
//                                                      =>  (u, v, z, 1)
vOut.SSAOPosH = (vOut.PosH + float4(vOut.PosH.ww, 0.0f, 0.0f)) * float4(0.5f, -0.5f, 1.0f, 1.0f);

而像素着色器則這樣修改:

// 完成紋理投影變換並對SSAO圖採樣
pIn.SSAOPosH /= pIn.SSAOPosH.w;
float ambientAccess = g_SSAOMap.SampleLevel(g_Sam, pIn.SSAOPosH.xy, 0.0f).r;

[unroll]
for (i = 0; i < 5; ++i)
{
    ComputeDirectionalLight(g_Material, g_DirLight[i], pIn.NormalW, toEyeW, A, D, S);
    ambient += ambientAccess * A;	// 此處乘上可及率
    diffuse += shadow[i] * D;
    spec += shadow[i] * S;
}

下面兩幅圖展示了SSAO圖應用後的效果對比。因爲上一章的光照中環境光所佔的比重並不是很大,因此在這一章我們將光照調整到讓環境光所佔的比重增大許多,以此讓SSAO效果的反差更爲顯著。當物體處於陰影之中時,SSAO的優點尤其明顯,能夠更加凸顯出3D立體感。

開啓SSAO(上)和未開啓SSAO(下)的對比,仔細觀察圓柱底部、球的底部、房屋。

在渲染觀察空間中場景法線/深度的同時,我們也在寫入NDC深度到綁定的深度/模板緩衝區。因此,以SSAO圖第二次渲染場景時,應當將深度檢測的比較方法改爲"EQUALS"。由於只有距離觀察點最近的可視像素才能通過這項深度比較檢測,所以這種檢測方法就可以有效防止第二次渲染過程中的重複繪製操作。而且,在第二次渲染過程中也無須向深度緩衝區執行寫操作。

D3D11_DEPTH_STENCIL_DESC dsDesc;
ZeroMemory(&dsDesc, sizeof dsDesc);
// 僅允許深度值一致的像素進行寫入的深度/模板狀態
// 沒必要寫入深度
dsDesc.DepthEnable = true;
dsDesc.DepthWriteMask = D3D11_DEPTH_WRITE_MASK_ZERO;
dsDesc.DepthFunc = D3D11_COMPARISON_EQUAL;

HR(device->CreateDepthStencilState(&dsDesc, DSSEqual.GetAddressOf()));



// BasicEffect.cpp
void BasicEffect::SetSSAOEnabled(bool enabled)
{
	pImpl->m_pEffectHelper->GetConstantBufferVariable("g_EnableSSAO")->SetSInt(enabled);
	// 我們在繪製SSAO法向量/深度圖的時候也已經寫入了主要的深度/模板貼圖,
	// 所以我們可以直接使用深度值相等的測試,這樣可以避免在當前的一趟渲染中
	// 出現任何的重複寫入當前像素的情況,只有距離最近的像素纔會通過深度比較測試
	pImpl->m_pEffectHelper->GetEffectPass("BasicObject")->SetDepthStencilState((enabled ? RenderStates::DSSEqual.Get() : nullptr), 0);
	pImpl->m_pEffectHelper->GetEffectPass("BasicInstance")->SetDepthStencilState((enabled ? RenderStates::DSSEqual.Get() : nullptr), 0);
	pImpl->m_pEffectHelper->GetEffectPass("NormalMapObject")->SetDepthStencilState((enabled ? RenderStates::DSSEqual.Get() : nullptr), 0);
	pImpl->m_pEffectHelper->GetEffectPass("NormalMapInstance")->SetDepthStencilState((enabled ? RenderStates::DSSEqual.Get() : nullptr), 0);
}

實現細節問題

在實現過程中遇到了一系列的問題,在此進行總結。

法向量的變換

對法向量進行世界變換通常是使用世界逆變換的轉置矩陣,而且在HLSL中也僅僅是使用它的3x3部分:

vOut.NormalW = mul(vIn.NormalL, (float3x3) g_WorldInvTranspose);

這樣做當然一點問題都沒有,但問題是在本例中還需要將法向量變換到觀察空間,所使用的矩陣是\(\mathbf{(W^{-1})^{T} V}\)的3x3部分:

vOut.NormalV = mul(vIn.NormalL, (float3x3) worldInvTransposeView);

如果在計算\({(W^{-1}})^{T}\)之前不抹除掉世界矩陣的平移分量的話,經過逆變換再轉置後矩陣的第四列前三行的值很可能就是非0值,然後再乘上觀察矩陣(觀察矩陣的第四行前三列的值也可能是非0值)就會對3x3的部分產生影響,導致錯誤的法向量變換結果。

爲此,我們需要使用下面的函數來進行世界矩陣的求逆再轉置:

// ------------------------------
// InverseTranspose函數
// ------------------------------
inline DirectX::XMMATRIX XM_CALLCONV InverseTranspose(DirectX::FXMMATRIX M)
{
	using namespace DirectX;

	// 世界矩陣的逆的轉置僅針對法向量,我們也不需要世界矩陣的平移分量
	// 而且不去掉的話,後續再乘上觀察矩陣之類的就會產生錯誤的變換結果
	XMMATRIX A = M;
	A.r[3] = g_XMIdentityR3;

	return XMMatrixTranspose(XMMatrixInverse(nullptr, A));
}

關閉多重採樣

在渲染法向量/深度RTV時,如果我們仍然使用開啓4倍msaa的深度/模板緩衝區,那就也要要求法向量/深度RTV的採樣等級和質量與其一致。因此在這一章我們選擇將MSAA給關閉。只需要去D3DApp中將m_Enable4xMsaa設爲false即可。

計算過程不同導致深度值比較不相等

在繪製法向量/深度緩衝區和最終的場景繪製都需要計算NDC深度值,如果使用的計算過程不完全一致,如:

// BasicEffect
vOut.PosH = mul(vIn.PosL, g_WorldViewProj);

// SSAO_NormalDepth
vOut.PosH = mul(vOut.PosV, g_Proj);

計算過程的不一致會導致算出來的深度值很可能會產生誤差,然後導致出現下面這樣的花屏效果:

SSAO的瑕疵

SSAO也並不是沒有瑕疵的,因爲它只針對屏幕空間進行操作,只要我們站的位置和視角刁鑽一些,比如這裏我們低頭往下看,並且沒有看到上面的石球,那麼石球的上半部分無法對石柱頂部產生遮蔽,導致遮蔽效果大幅削弱。

練習題

  1. 修改SSAO演示程序,嘗試用高斯模糊取代邊緣保留模糊。哪種方法更好一些?

  2. 能否用計算着色器實現SSAO?

  3. 下圖展示的是我們不進行自相交檢測所生成的SSAO圖。嘗試修改本演示程序,去掉其中的相交檢測來欣賞。

DirectX11 With Windows SDK完整目錄

Github項目源碼

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

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章