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

X_Jun發表於2020-07-06

前言

由於效能的限制,實時光照模型往往會忽略間接光因素(即場景中其他物體所反彈的光線)。但在現實生活中,大部分光照其實是間接光。在第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,以及有什麼問題也可以在這裡彙報。

相關文章