前言
由于性能的限制,實時光照模型往往會忽略間接光因素(即場景中其他物體所反彈的光線),但在現實生活中,大部分光照其實是間接光,在第7章里面的光照方程里面引入了環境光項:
\[C_a = \mathbf{A_L}\otimes\mathbf{m_d} \]
其中顏色\(\mathbf{A_L}\)表示的是從某光源發出,經過環境光反射而照射到物體表面的間接光總量,漫反射\(\mathbf{m_d}\)則是物體表面根據漫反射率將入射光反射回的總量,這種方式的計算只是一種簡化,并非真正的物理計算,它直接假定物體表面任意一點接收到的光照都是相同的,并且都能以相同的反射系數最終反射到我們眼睛,下圖展示了如果僅采用環境光項來繪制模型的情況,物體將會被同一種單色所渲染:

當然,這種環境光項是不真實的,我們對其還有一些改良的余地,
學習目標:
- 了解環境光遮蔽技術背后的基本原理,并知道如何通過投射光線來實作環境光遮蔽(見龍書d3d11CodeSet3/AmbientOcclusion專案)
- 熟悉螢屏空間環境光遮蔽這種近似于實時的環境光遮蔽技術(本章專案),
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-p與n之間的夾角小于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 = https://www.cnblogs.com/X-Jun/p/{};
std::vector randomVectors(256 * 256);
// 初始化亂數資料
std::mt19937 randEngine;
randEngine.seed(std::random_device()());
std::uniform_real_distribution 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 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,該測驗基于下面兩種值:
- 觀察空間中點p與點r的深度距離為\(|p_z-r_z|\),隨著距離的增長,遮蔽值將按比例線性減小,因為遮蔽點與目標點的距離越遠,其遮蔽的效果就越弱,如果該距離超過某個指定的最大距離,那么點r將完全不會遮擋點p,而且,如果此距離過小,我們將認為點p與點q位于同一平面上,故點q此時也不會遮擋點p
- 法向量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_SamNormalDepth和g_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);
}
C++端代碼實作
在本章中,與SSAO直接相關的類有BasicEffect、SSAOEffect、SSAORender,GameApp類承擔了實作程序,
SSAORender類
SSAORender類負責用來生成SSAO圖,將SSAOEffect的使用給封裝了起來:
class SSAORender
{
public:
template<class T>
using ComPtr = Microsoft::WRL::ComPtr<T>;
SSAORender() = default;
~SSAORender() = default;
// 不允許拷貝,允許移動
SSAORender(const SSAORender&) = delete;
SSAORender& operator=(const SSAORender&) = delete;
SSAORender(SSAORender&&) = default;
SSAORender& operator=(SSAORender&&) = default;
HRESULT InitResource(ID3D11Device* device,
int width, int height, float fovY, float farZ);
HRESULT OnResize(ID3D11Device* device,
int width, int height, float fovY, float farZ);
// 開始繪制場景到法向量/深度圖
// 快取當前RTV、DSV和視口
// 當我們渲染到法向量/深度圖時,需要傳遞程式使用的深度緩沖區
void Begin(ID3D11DeviceContext* deviceContext, ID3D11DepthStencilView* dsv, bool enableAlphaClip = false);
// 完成最終的SSAO圖繪制后進行恢復
void End(ID3D11DeviceContext* deviceContext);
// 設定SSAO圖作為RTV,并繪制到一個全屏矩形以啟用像素著色器來計算環境光遮蔽項
// 盡管我們仍保持主深度緩沖區系結到管線上,但要禁用深度緩沖區的讀寫,
// 因為我們不需要深度緩沖區來計算SSAO圖
void ComputeSSAO(ID3D11DeviceContext* deviceContext, SSAOEffect& ssaoEffect, const Camera& camera);
// 對SSAO圖進行模糊,使得由于每個像素的采樣次數較少而產生的噪點進行平滑處理
// 這里使用邊緣保留的模糊
void BlurAmbientMap(ID3D11DeviceContext* deviceContext, SSAOEffect& ssaoEffect, int blurCount);
// 獲取SSAO圖
ID3D11ShaderResourceView* GetAmbientTexture();
// 設定除錯物件名
void SetDebugObjectName(const std::string& name);
private:
void BuildFrustumFarCorners(float fovY, float farZ);
void BuildOffsetVectors();
HRESULT BuildTextureViews(ID3D11Device* device, int width, int height);
HRESULT BuildFullScreenQuad(ID3D11Device* device);
HRESULT BuildRandomVectorTexture(ID3D11Device* device);
void BlurAmbientMap(ID3D11DeviceContext* deviceContext, SSAOEffect& ssaoEffect,
ID3D11ShaderResourceView* inputSRV, ID3D11RenderTargetView* outputRTV, bool horzBlur);
private:
UINT m_Width = 0;
UINT m_Height = 0;
DirectX::XMFLOAT4 m_FrustumFarCorner[4] = {};
DirectX::XMFLOAT4 m_Offsets[14] = {};
D3D11_VIEWPORT m_AmbientMapViewPort = {};
ComPtr<ID3D11ShaderResourceView> m_pRandomVectorSRV;
ComPtr<ID3D11RenderTargetView> m_pNormalDepthRTV;
ComPtr<ID3D11ShaderResourceView> m_pNormalDepthSRV;
ComPtr<ID3D11Buffer> m_pScreenQuadVB;
ComPtr<ID3D11Buffer> m_pScreenQuadIB;
ComPtr<ID3D11RenderTargetView> m_pCachedRTV;
ComPtr<ID3D11DepthStencilView> m_pCachedDSV;
D3D11_VIEWPORT m_CachedViewPort = {};
//
// 在模糊期間使用兩個紋理進行Ping-Pong交換
//
ComPtr<ID3D11RenderTargetView> m_pAmbientRTV0;
ComPtr<ID3D11ShaderResourceView> m_pAmbientSRV0;
ComPtr<ID3D11RenderTargetView> m_pAmbientRTV1;
ComPtr<ID3D11ShaderResourceView> m_pAmbientSRV1;
};
這樣在GameApp::DrawScene中繪制到SSAO圖的程序可以簡化為:
// ******************
// 繪制到SSAO圖
//
if (m_EnableSSAO)
{
m_pSSAOMap->Begin(m_pd3dImmediateContext.Get(), m_pDepthStencilView.Get());
{
// 繪制場景到法向量/深度緩沖區
DrawScene(m_pSSAOEffect.get());
// 計算環境光遮蔽值到SSAO圖
m_pSSAOMap->ComputeSSAO(m_pd3dImmediateContext.Get(), *m_pSSAOEffect, *m_pCamera);
// 進行模糊
m_pSSAOMap->BlurAmbientMap(m_pd3dImmediateContext.Get(), *m_pSSAOEffect, 4);
}
m_pSSAOMap->End(m_pd3dImmediateContext.Get());
}
實作細節問題
在實作程序中遇到了一系列的問題,在此進行總結,
法向量的變換
對法向量進行世界變換通常是使用世界逆變換的轉置矩陣,而且在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也并不是沒有瑕疵的,因為它只針對螢屏空間進行操作,只要我們站的位置和視角刁鉆一些,比如這里我們低頭往下看,并且沒有看到上面的石球,那么石球的上半部分無法對石柱頂部產生遮蔽,導致遮蔽效果大幅削弱,

練習題
-
修改SSAO演示程式,嘗試用高斯模糊取代邊緣保留模糊,哪種方法更好一些?
-
能否用計算著色器實作SSAO?
-
下圖展示的是我們不進行自相交檢測所生成的SSAO圖,嘗試修改本演示程式,去掉其中的相交檢測來欣賞,

DirectX11 With Windows SDK完整目錄
Github專案原始碼
歡迎加入QQ群: 727623616 可以一起探討DX11,以及有什么問題也可以在這里匯報,
轉載請註明出處,本文鏈接:https://www.uj5u.com/qita/31161.html
標籤:其他
