Vulkan_SSAO—屏幕空間環境光遮蔽

屏幕空間環境光遮蔽

在這裏插入圖片描述
我們已經在前面的基礎教程中簡單介紹到了這部分內容:環境光照(Ambient Lighting)。環境光照是我們加入場景總體光照中的一個固定光照常量,它被用來模擬光的散射(Scattering)。在現實中,光線會以任意方向散射,它的強度是會一直改變的,所以間接被照到的那部分場景也應該有變化的強度,而不是一成不變的環境光。其中一種間接光照的模擬叫做環境光遮蔽(Ambient Occlusion),它的原理是通過將褶皺、孔洞和非常靠近的牆面變暗的方法近似模擬出間接光照。這些區域很大程度上是被周圍的幾何體遮蔽的,光線會很難流失,所以這些地方看起來會更暗一些。站起來看一看你房間的拐角或者是褶皺,是不是這些地方會看起來有一點暗?

在2007年,Crytek公司發佈了一款叫做**屏幕空間環境光遮蔽(Screen-Space Ambient Occlusion, SSAO)**的技術,並用在了他們的看家作孤島危機上。這一技術使用了屏幕空間場景的深度而不是真實的幾何體數據來確定遮蔽量。這一做法相對於真正的環境光遮蔽不但速度快,而且還能獲得很好的效果,使得它成爲近似實時環境光遮蔽的標準。

SSAO背後的原理很簡單:對於鋪屏四邊形(Screen-filled Quad)上的每一個片段,我們都會根據周邊深度值計算一個遮蔽因子(Occlusion Factor)。這個遮蔽因子之後會被用來減少或者抵消片段的環境光照分量。遮蔽因子是通過採集片段周圍球型核心(Kernel)的多個深度樣本,並和當前片段深度值對比而得到的。高於片段深度值樣本的個數就是我們想要的遮蔽因子。

在這裏插入圖片描述
上圖中在幾何體內灰色的深度樣本都是高於片段深度值的,他們會增加遮蔽因子;幾何體內樣本個數越多,片段獲得的環境光照也就越少。

很明顯,渲染效果的質量和精度與我們採樣的樣本數量有直接關係。如果樣本數量太低,渲染的精度會急劇減少,我們會得到一種叫做波紋(Banding)的效果;如果它太高了,反而會影響性能。我們可以通過引入隨機性到採樣核心(Sample Kernel)的採樣中從而減少樣本的數目。通過隨機旋轉採樣核心,我們能在有限樣本數量中得到高質量的結果。然而這仍然會有一定的麻煩,因爲隨機性引入了一個很明顯的噪聲圖案,我們將需要通過模糊結果來修復這一問題。下面這幅圖片(John Chapman的佛像)展示了波紋效果還有隨機性造成的效果:
在這裏插入圖片描述
你可以看到,儘管我們在低樣本數的情況下得到了很明顯的波紋效果,引入隨機性之後這些波紋效果就完全消失了。

SSAO技術會產生一種特殊的視覺風格。因爲使用的採樣核心是一個球體,它導致平整的牆面也會顯得灰濛濛的,因爲核心中一半的樣本都會在牆這個幾何體上。

由於這個原因,我們將不會使用球體的採樣核心,而使用一個沿着表面法向量的半球體採樣核心。
在這裏插入圖片描述
通過在法向半球體(Normal-oriented Hemisphere)周圍採樣,我們將不會考慮到片段底部的幾何體.它消除了環境光遮蔽灰濛濛的感覺,從而產生更真實的結果。

樣本緩衝

SSAO需要獲取幾何體的信息,因爲我們需要一些方式來確定一個片段的遮蔽因子。對於每一個片段,我們將需要這些數據:

  • 逐片段位置向量
  • 逐片段的法線向量
  • 逐片段的反射顏色
  • 採樣核心
  • 用來旋轉採樣核心的隨機旋轉矢量

通過使用一個逐片段觀察空間位置,我們可以將一個採樣半球核心對準片段的觀察空間表面法線。對於每一個核心樣本我們會採樣線性深度紋理來比較結果。採樣核心會根據旋轉矢量稍微偏轉一點;我們所獲得的遮蔽因子將會之後用來限制最終的環境光照分量。
由於SSAO是一種屏幕空間技巧,我們對鋪屏2D四邊形上每一個片段計算這一效果;也就是說我們沒有場景中幾何體的信息。我們能做的只是渲染幾何體數據到屏幕空間紋理中,我們之後再會將此數據發送到SSAO着色器中,之後我們就能訪問到這些幾何體數據了。你會發現這和延遲渲染很相似。這也就是說SSAO和延遲渲染能完美地兼容,因爲我們可以通過存位置和法線向量等信息到G緩衝中。
G-buffer頂點着色器:

#version 450

layout (location = 0) in vec4 inPos;
layout (location = 1) in vec2 inUV;
layout (location = 2) in vec3 inColor;
layout (location = 3) in vec3 inNormal;

layout (binding = 0) uniform UBO 
{
	mat4 projection;
	mat4 model;
	mat4 view;
} ubo;

layout (location = 0) out vec3 outNormal;
layout (location = 1) out vec2 outUV;
layout (location = 2) out vec3 outColor;
layout (location = 3) out vec3 outPos;

out gl_PerVertex
{
	vec4 gl_Position;
};

void main() 
{
	gl_Position = ubo.projection * ubo.view * ubo.model * inPos;
	
	outUV = inUV;

	// Vertex position in view space
	outPos = vec3(ubo.view * ubo.model * inPos);

	// Normal in view space
	mat3 normalMatrix = transpose(inverse(mat3(ubo.view * ubo.model)));
	outNormal = normalMatrix * inNormal;

	outColor = inColor;
}

G-buffer頂點着色器:

#version 450

layout (location = 0) in vec3 inNormal;
layout (location = 1) in vec2 inUV;
layout (location = 2) in vec3 inColor;
layout (location = 3) in vec3 inPos;

layout (location = 0) out vec4 outPosition;
layout (location = 1) out vec4 outNormal;
layout (location = 2) out vec4 outAlbedo;

const float NEAR_PLANE = 0.1f; // 投影矩陣的近平面
const float FAR_PLANE = 64.0f; // 投影矩陣的遠平面 


float linearDepth(float depth)
{
	float z = depth * 2.0f - 1.0f; // 回到NDC
	return (2.0f * NEAR_PLANE * FAR_PLANE) / (FAR_PLANE + NEAR_PLANE - z * (FAR_PLANE - NEAR_PLANE));	
}

void main() 
{
	// 儲存片段的位置矢量到第一個G緩衝紋理
	// 儲存線性深度到outPosition的alpha分量
	outPosition = vec4(inPos, linearDepth(gl_FragCoord.z));
	outNormal = vec4(normalize(inNormal) * 0.5 + 0.5, 1.0);
	outAlbedo = vec4(inColor * 2.0, 1.0);
}

提取出來的線性深度是在觀察空間中的,所以之後的運算也是在觀察空間中。確保G緩衝中的位置和法線都在觀察空間中(乘上觀察矩陣也一樣)。觀察空間線性深度值之後會被保存在outPosition顏色緩衝的alpha分量中,省得我們再聲明一個新的顏色緩衝紋理,其中在片元着色器中,我們可以在gl_FragCoord.z中提取線性深度。

法向半球

我們需要沿着表面法線方向生成大量的樣本。就像我們在這個教程的開始介紹的那樣,我們想要生成形成半球形的樣本。由於對每個表面法線方向生成採樣核心非常困難,也不合實際,我們將在切線空間(Tangent Space)內生成採樣核心,法向量將指向正z方向。
假設我們有一個單位半球,我們可以獲得一個擁有最大64樣本值的採樣核心,我們在切線空間中以-1.0到1.0爲範圍變換x和y方向,並以0.0和1.0爲範圍變換樣本的z方向(如果以-1.0到1.0爲範圍,取樣核心就變成球型了)。由於採樣核心將會沿着表面法線對齊,所得的樣本矢量將會在半球裏。

目前,所有的樣本都是平均分佈在採樣核心裏的,但是我們更願意將更多的注意放在靠近真正片段的遮蔽上,也就是將核心樣本靠近原點分佈。我們可以用一個加速插值函數實現它:

#define SSAO_KERNEL_SIZE 64
...
        // Sample kernel
		std::vector<glm::vec4> ssaoKernel(SSAO_KERNEL_SIZE);
		for (uint32_t i = 0; i < SSAO_KERNEL_SIZE; ++i)
		{
			glm::vec3 sample(rndDist(rndEngine) * 2.0 - 1.0, rndDist(rndEngine) * 2.0 - 1.0, rndDist(rndEngine));
			sample = glm::normalize(sample);
			sample *= rndDist(rndEngine);
			float scale = float(i) / float(SSAO_KERNEL_SIZE);
			scale = lerp(0.1f, 1.0f, scale * scale);
			ssaoKernel[i] = glm::vec4(sample * scale, 0.0f);
		}
...
	float lerp(float a, float b, float f)
	{
		return a + f * (b - a);
	}

這就給了我們一個大部分樣本靠近原點的核心分佈。
在這裏插入圖片描述
每個核心樣本將會被用來偏移觀察空間片段位置從而採樣周圍的幾何體。我們在教程開始的時候看到,如果沒有變化採樣核心,我們將需要大量的樣本來獲得真實的結果。通過引入一個隨機的轉動到採樣核心中,我們可以很大程度上減少這一數量。

隨機核心轉動

通過引入一些隨機性到採樣核心上,我們可以大大減少獲得不錯結果所需的樣本數量。我們可以對場景中每一個片段創建一個隨機旋轉向量,但這會很快將內存耗盡。所以,更好的方法是創建一個小的隨機旋轉向量紋理平鋪在屏幕上。

我們創建一個4x4朝向切線空間平面法線的隨機旋轉向量數組:

		// Random noise
		std::vector<glm::vec4> ssaoNoise(SSAO_NOISE_DIM * SSAO_NOISE_DIM);
		for (uint32_t i = 0; i < static_cast<uint32_t>(ssaoNoise.size()); i++)
		{
			ssaoNoise[i] = glm::vec4(rndDist(rndEngine) * 2.0f - 1.0f, rndDist(rndEngine) * 2.0f - 1.0f, 0.0f, 0.0f);
		}

由於採樣核心是沿着正z方向在切線空間內旋轉,我們設定z分量爲0.0,從而圍繞z軸旋轉。

SSAO着色器

SSAO着色器在2D的鋪屏四邊形上運行,它對於每一個生成的片段計算遮蔽值(爲了在最終的光照着色器中使用)。
下面我們直接貼出SSAO.frag片元着色器,並在註釋中解釋相關代碼含義:

#version 450

layout (binding = 0) uniform sampler2D samplerPositionDepth;
layout (binding = 1) uniform sampler2D samplerNormal;
layout (binding = 2) uniform sampler2D ssaoNoise;

layout (constant_id = 0) const int SSAO_KERNEL_SIZE = 64;
layout (constant_id = 1) const float SSAO_RADIUS = 0.5;

layout (binding = 3) uniform UBOSSAOKernel
{
	vec4 samples[SSAO_KERNEL_SIZE];
} uboSSAOKernel;

layout (binding = 4) uniform UBO 
{
	mat4 projection;
} ubo;

layout (location = 0) in vec2 inUV;

layout (location = 0) out float outFragColor;

void main() 
{
	// 獲取G-Buffer中的數據
	vec3 fragPos = texture(samplerPositionDepth, inUV).rgb;
	vec3 normal = normalize(texture(samplerNormal, inUV).rgb * 2.0 - 1.0);

	// 使用噪音紋理得到一個隨機向量
	ivec2 texDim = textureSize(samplerPositionDepth, 0); 
	ivec2 noiseDim = textureSize(ssaoNoise, 0);
	const vec2 noiseUV = vec2(float(texDim.x)/float(noiseDim.x), float(texDim.y)/(noiseDim.y)) * inUV;  
	vec3 randomVec = texture(ssaoNoise, noiseUV).xyz * 2.0 - 1.0;
	
	// 創建一個TBN矩陣,將向量從切線空間變換到觀察空間
	vec3 tangent = normalize(randomVec - normal * dot(randomVec, normal));
	vec3 bitangent = cross(tangent, normal);
	mat3 TBN = mat3(tangent, bitangent, normal);
	
	float occlusion = 0.0f;
	const float bias = 0.01f;
	//我們對每個核心樣本進行迭代,將樣本從切線空間變換到觀察空間,將它們加到當前像素位置上,並將片段位置深度與儲存在原始深度緩衝中的樣本深度進行比較
	for(int i = 0; i < SSAO_KERNEL_SIZE; i++)
	{		
		// 獲取樣本位置
		vec3 samplePos = TBN * uboSSAOKernel.samples[i].xyz; // 切線->觀察空間
		samplePos = fragPos + samplePos * SSAO_RADIUS; 
		
		// 我們變換sample到屏幕空間,從而我們可以就像正在直接渲染它的位置到屏幕上一樣取樣sample的(線性)深度值。由於這個向量目前在觀察空間,我們將首先使用projection矩陣uniform變換它到裁剪空間。
		vec4 offset = vec4(samplePos, 1.0f);
		offset = ubo.projection * offset; 
		offset.xyz /= offset.w; 
		offset.xyz = offset.xyz * 0.5f + 0.5f; 
		// 在變量被變換到裁剪空間之後,我們用xyz分量除以w分量進行透視劃分。結果所得的標準化設備座標之後變換到[0.0, 1.0]範圍以便我們使用它們去取樣深度紋理:
		float sampleDepth = -texture(samplerPositionDepth, offset.xy).w; 

#define RANGE_CHECK 1
#ifdef RANGE_CHECK
		// 引入一個範圍測試從而保證我們只當被測深度值在取樣半徑內時影響遮蔽因子
		float rangeCheck = smoothstep(0.0f, 1.0f, SSAO_RADIUS / abs(fragPos.z - sampleDepth));
		occlusion += (sampleDepth >= samplePos.z + bias ? 1.0f : 0.0f) * rangeCheck;           
#else
		//我們使用offset向量的x和y分量採樣線性深度紋理從而獲取樣本位置從觀察者視角的深度值(第一個不被遮蔽的可見片段)。我們接下來檢查樣本的當前深度值是否大於存儲的深度值,如果是的,添加到最終的貢獻因子上。
		occlusion += (sampleDepth >= samplePos.z + bias ? 1.0f : 0.0f);  
#endif
	}
	//最後一步,我們需要將遮蔽貢獻根據核心的大小標準化,並輸出結果。注意我們用1.0減去了遮蔽因子,以便直接使用遮蔽因子去縮放環境光照分量。
	occlusion = 1.0 - (occlusion / float(SSAO_KERNEL_SIZE));
	outFragColor = occlusion;
}


其中GLSL的smoothstep函數,它非常光滑地在第一和第二個參數範圍內插值了第三個參數。如果深度差因此最終取值在radius之間,它們的值將會光滑地根據下面這個曲線插值在0.0和1.0之間:
在這裏插入圖片描述
如果我們使用一個在深度值在radius之外就突然移除遮蔽貢獻的硬界限範圍檢測(Hard Cut-off Range Check),我們將會在範圍檢測應用的地方看見一個明顯的(很難看的)邊緣。
首先,貼出一張未使用SSAO技術的模型:
在這裏插入圖片描述
編譯着色器,運行環境遮蔽着色器產生了以下的效果:
在這裏插入圖片描述
上圖是在shader中我們引入了範圍測試的效果,下邊我們關閉引入,可對比如下(在其柱與牆體交接處可明顯看出效果):
在這裏插入圖片描述

環境遮蔽模糊

在SSAO階段和光照階段之間,我們想要進行模糊SSAO紋理的處理,所以我們又創建了一個幀緩衝對象來儲存模糊結果。

#version 450

layout (binding = 0) uniform sampler2D samplerSSAO;

layout (location = 0) in vec2 inUV;

layout (location = 0) out float outFragColor;

void main() 
{
	const int blurRange = 2;
	int n = 0;
	vec2 texelSize = 1.0 / vec2(textureSize(samplerSSAO, 0));
	float result = 0.0;
	for (int x = -blurRange; x < blurRange; x++) 
	{
		for (int y = -blurRange; y < blurRange; y++) 
		{
			vec2 offset = vec2(float(x), float(y)) * texelSize;
			result += texture(samplerSSAO, inUV + offset).r;
			n++;
		}
	}
	outFragColor = result / (float(n));
}

這裏我們遍歷了周圍在-2.0和2.0之間的SSAO紋理單元(Texel),採樣與噪聲紋理維度相同數量的SSAO紋理。我們通過使用返回vec2紋理維度的textureSize,根據紋理單元的真實大小偏移了每一個紋理座標。我們平均所得的結果,獲得一個簡單但是有效的模糊效果:
在這裏插入圖片描述
屏幕空間環境遮蔽是一個可高度自定義的效果,它的效果很大程度上依賴於我們根據場景類型調整它的參數。對所有類型的場景並不存在什麼完美的參數組合方式。一些場景只在小半徑情況下工作,又有些場景會需要更大的半徑和更大的樣本數量才能看起來更真實。當前這個演示用了64個樣本,屬於比較多的了,你可以調調更小的核心大小從而獲得更好的結果。

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