延遲渲染
一、簡述
我們現在一直使用的光照方式叫做正向渲染(Forward Rendering)或者正向着色法(Forward Shading),它是我們渲染物體的一種非常直接的方式,在場景中我們根據所有光源照亮一個物體,之後再渲染下一個物體,以此類推。它非常容易理解,也很容易實現,但是同時它對程序性能的影響也很大,因爲對於每一個需要渲染的物體,程序都要對每一個光源每一個需要渲染的片段進行迭代,這是非常多的!因爲大部分片段着色器的輸出都會被之後的輸出覆蓋,正向渲染還會在場景中因爲高深的複雜度(多個物體重合在一個像素上)浪費大量的片段着色器運行時間。
延遲着色法(Deferred Shading),或者說是延遲渲染(Deferred Rendering),爲了解決上述問題而誕生了,它大幅度地改變了我們渲染物體的方式。這給我們優化擁有大量光源的場景提供了很多的選擇,因爲它能夠在渲染上百甚至上千光源的同時還能夠保持能讓人接受的幀率。下面這張對比圖片包含了一共500個點光源,它是使用正向渲染與延遲渲染進行了對比:
二、原理
延遲着色法基於我們延遲(Defer)或推遲(Postpone)大部分計算量非常大的渲染(像是光照)到後期進行處理的想法。它包含兩個處理階段(Pass):
2.1 幾何處理階段(Geometry Pass)
在第一個幾何處理階段(Geometry Pass)中,我們先渲染場景一次,之後獲取對象的各種幾何信息,並儲存在一系列叫做G緩衝(G-buffer)的紋理中;想想位置向量(Position Vector)、顏色向量(Color Vector)、法向量(Normal Vector)和/或鏡面值(Specular Value)。場景中這些儲存在G緩衝中的幾何信息將會在之後用來做(更復雜的)光照計算。下面是一幀中G緩衝的內容:
2.2 光照處理階段(Lighting Pass)
我們會在第二個光照處理階段(Lighting Pass)中使用G緩衝內的紋理數據。在光照處理階段中,我們渲染一個屏幕大小的方形,並使用G緩衝中的幾何數據對每一個片段計算場景的光照;在每個像素中我們都會對G緩衝進行迭代。我們對於渲染過程進行解耦,將它高級的片段處理挪到後期進行,而不是直接將每個對象從頂點着色器帶到片段着色器。光照計算過程還是和我們以前一樣,但是現在我們需要從對應的G緩衝而不是頂點着色器(和一些uniform變量)那裏獲取輸入變量了。
這種渲染方法一個很大的好處就是能保證在G緩衝中的片段和在屏幕上呈現的像素所包含的片段信息是一樣的,因爲深度測試已經最終將這裏的片段信息作爲最頂層的片段。這樣保證了對於在光照處理階段中處理的每一個像素都只處理一次,所以我們能夠省下很多無用的渲染調用。除此之外,延遲渲染還允許我們做更多的優化,從而渲染更多的光源。
當然這種方法也帶來幾個缺陷, 由於G緩衝要求我們在紋理顏色緩衝中存儲相對比較大的場景數據,這會消耗比較多的顯存,尤其是類似位置向量之類的需要高精度的場景數據。 另外一個缺點就是他不支持混色(因爲我們只有最前面的片段信息), 因此也不能使用MSAA了。針對這幾個問題我們可以做一些變通來克服這些缺點,這些我們留會在教程的最後討論。
在幾何處理階段中填充G緩衝非常高效,因爲我們直接儲存像素位置,顏色或者是法線等對象信息到幀緩衝中,而這幾乎不會消耗處理時間。在此基礎上使用多渲染目標(Multiple Render Targets, MRT)技術,我們甚至可以在一個渲染處理之內完成這所有的工作。
三、代碼應用
3.1 G緩衝
G緩衝(G-buffer)是對所有用來儲存光照相關的數據,並在最後的光照處理階段中使用的所有紋理的總稱。趁此機會,讓我們順便複習一下在正向渲染中照亮一個片段所需要的所有數據:
- 一個3D位置向量來計算(插值)片段位置變量供lightDir和viewDir使用
- 一個RGB漫反射顏色向量,也就是反照率(Albedo)
- 一個3D法向量來判斷平面的斜率
- 一個鏡面強度(Specular Intensity)浮點值
- 所有光源的位置和顏色向量
- 玩家或者觀察者的位置向量
有了這些(逐片段)變量的處置權,我們就能夠計算我們很熟悉的布林-馮氏光照(Blinn-Phong Lighting)了。光源的位置,顏色,和玩家的觀察位置可以通過uniform變量來設置,但是其它變量對於每個對象的片段都是不同的。如果我們能以某種方式傳輸完全相同的數據到最終的延遲光照處理階段中,我們就能計算與之前相同的光照效果了,儘管我們只是在渲染一個2D方形的片段。
Vulkan並沒有限制我們能在紋理中能存儲的東西,所以現在你應該清楚在一個或多個屏幕大小的紋理中儲存所有逐片段數據並在之後光照處理階段中使用的可行性了。因爲G緩衝紋理將會和光照處理階段中的2D方形一樣大,我們會獲得和正向渲染設置完全一樣的片段數據,但在光照處理階段這裏是一對一映射。
對於每一個片段我們需要儲存的數據有:一個位置向量、一個法向量,一個顏色向量,一個鏡面強度值。所以我們在幾何處理階段中需要渲染場景中所有的對象並儲存這些數據分量到G緩衝中。我們可以再次使用多渲染目標(Multiple Render Targets)來在一個渲染處理之內渲染多個顏色緩衝。
對於幾何渲染處理階段,我們首先需要初始化一個幀緩衝對象,我們很直觀的稱它爲gBuffer,它包含了多個顏色緩衝和一個單獨的深度渲染緩衝對象(Depth Renderbuffer Object)。對於位置和法向量的紋理,我們希望使用高精度的紋理(每分量16或32位的浮點數),而對於反照率和鏡面值,使用默認的紋理(每分量8位浮點數)就夠了。
// Prepare a new framebuffer and attachments for offscreen rendering (G-Buffer)
void prepareOffscreenFramebuffer()
{
// Color attachments
...
// (World space) Positions
...
// (World space) Normals
...
// Albedo (color)
...
// Depth attachment
...
// Init attachment properties
...
vkCreateFramebuffer(device, &fbufCreateInfo, nullptr, &offScreenFrameBuf.frameBuffer);
}
由於我們使用了多渲染目標,我們需要顯式告訴Vulkan我們需要使用vkCmdBindIndexBuffer渲染的是和GBuffer關聯的哪個顏色緩衝。在vkCmdBindPipeline中使用的管線其對應的描述符局中,我們需要對其進行指明綁定,否則的話將會看不到任何圖形。
void preparePipelines()
{
...
// Blend attachment states required for all color attachments
// This is important, as color write mask will otherwise be 0x0 and you
// won't see anything rendered to the attachment
std::array<VkPipelineColorBlendAttachmentState, 3> blendAttachmentStates = {
vks::initializers::pipelineColorBlendAttachmentState(0xf, VK_FALSE),
vks::initializers::pipelineColorBlendAttachmentState(0xf, VK_FALSE),
vks::initializers::pipelineColorBlendAttachmentState(0xf, VK_FALSE)
};
colorBlendState.attachmentCount = static_cast<uint32_t>(blendAttachmentStates.size());
colorBlendState.pAttachments = blendAttachmentStates.data();
VK_CHECK_RESULT(vkCreateGraphicsPipelines(device, pipelineCache, 1, &pipelineCreateInfo, nullptr, &pipelines.offscreen));
}
接下來我們需要渲染它們到G緩衝中。假設每個對象都有漫反射,一個法線和一個鏡面強度紋理,我們會想使用一些像下面這個頂點和片段着色器的東西來渲染它們到G緩衝中去。
頂點着色器:
#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 (location = 4) in vec3 inTangent;
layout (binding = 0) uniform UBO
{
mat4 projection;
mat4 model;
mat4 view;
vec4 instancePos[3];
} ubo;
layout (location = 0) out vec3 outNormal;
layout (location = 1) out vec2 outUV;
layout (location = 2) out vec3 outColor;
layout (location = 3) out vec3 outWorldPos;
layout (location = 4) out vec3 outTangent;
out gl_PerVertex
{
vec4 gl_Position;
};
void main()
{
vec4 tmpPos = inPos + ubo.instancePos[gl_InstanceIndex];
gl_Position = ubo.projection * ubo.view * ubo.model * tmpPos;
outUV = inUV;
outUV.t = 1.0 - outUV.t;
// 世界空間中的頂點位置
outWorldPos = vec3(ubo.model * tmpPos);
// OpenGL轉Vulkan座標系
outWorldPos.y = -outWorldPos.y;
// 世界空間中的法線
mat3 mNormal = transpose(inverse(mat3(ubo.model)));
outNormal = mNormal * normalize(inNormal);
outTangent = mNormal * normalize(inTangent);
// 當前僅頂點顏色
outColor = inColor;
}
片元着色器:
#version 450
layout (binding = 1) uniform sampler2D samplerColor;
layout (binding = 2) uniform sampler2D samplerNormalMap;
layout (location = 0) in vec3 inNormal;
layout (location = 1) in vec2 inUV;
layout (location = 2) in vec3 inColor;
layout (location = 3) in vec3 inWorldPos;
layout (location = 4) in vec3 inTangent;
layout (location = 0) out vec4 outPosition;
layout (location = 1) out vec4 outNormal;
layout (location = 2) out vec4 outAlbedo;
void main()
{
// 存儲第一個G緩衝紋理中的片段位置向量
outPosition = vec4(inWorldPos, 1.0);
// 計算在切線空間中的法線
vec3 N = normalize(inNormal);
N.y = -N.y;
vec3 T = normalize(inTangent);
vec3 B = cross(N, T);
mat3 TBN = mat3(T, B, N);
// 同樣存儲對每個逐片段法線到G緩衝中
vec3 tnorm = TBN * normalize(texture(samplerNormalMap, inUV).xyz * 2.0 - vec3(1.0));
// 同樣存儲對每個逐片段法線到G緩衝中
outNormal = vec4(tnorm, 1.0);
// 存儲鏡面強度和漫反射對每個逐片段顏色
outAlbedo = texture(samplerColor, inUV);
}
因爲我們使用了多渲染目標,這個佈局指示符(Layout Specifier)告訴了Vulkan我們需要渲染到當前的活躍幀緩衝中的哪一個顏色緩衝。注意我們並沒有儲存鏡面強度到一個單獨的顏色緩衝紋理中,因爲我們可以儲存它單獨的浮點值到其它顏色緩衝紋理的alpha分量中。
下一步,我們就該進入到:光照處理階段了。
3.1 延遲光照處理階段
現在我們已經有了一大堆的片段數據儲存在G緩衝中供我們處置,我們可以選擇通過一個像素一個像素地遍歷各個G緩衝紋理,並將儲存在它們裏面的內容作爲光照算法的輸入,來完全計算場景最終的光照顏色。由於所有的G緩衝紋理都代表的是最終變換的片段值,我們只需要對每一個像素執行一次昂貴的光照運算就行了。這使得延遲光照非常高效,特別是在需要調用大量重型片段着色器的複雜場景中。
對於這個光照處理階段,我們將會渲染一個2D全屏的方形(有一點像後期處理效果)並且在每個像素上運行一個昂貴的光照片段着色器。
在buildCommandBuffers繪製中我們使用vkCmdBindDescriptorSets綁定描述符佈局之前,我們應在setupDescriptorSet函數中先綁定G緩衝中所有相關的紋理,並且發送光照相關的uniform變量到着色器中。
void setupDescriptorSet()
{
std::vector<VkWriteDescriptorSet> writeDescriptorSets;
// Textured quad descriptor set
VkDescriptorSetAllocateInfo allocInfo =
vks::initializers::descriptorSetAllocateInfo(
descriptorPool,
&descriptorSetLayout,
1);
VK_CHECK_RESULT(vkAllocateDescriptorSets(device, &allocInfo, &descriptorSet));
// Image descriptors for the offscreen color attachments
VkDescriptorImageInfo texDescriptorPosition =
vks::initializers::descriptorImageInfo(
colorSampler,
offScreenFrameBuf.position.view,
VK_IMAGE_LAYOUT_SHADER_READ_ONLY_OPTIMAL);
VkDescriptorImageInfo texDescriptorNormal =
vks::initializers::descriptorImageInfo(
colorSampler,
offScreenFrameBuf.normal.view,
VK_IMAGE_LAYOUT_SHADER_READ_ONLY_OPTIMAL);
VkDescriptorImageInfo texDescriptorAlbedo =
vks::initializers::descriptorImageInfo(
colorSampler,
offScreenFrameBuf.albedo.view,
VK_IMAGE_LAYOUT_SHADER_READ_ONLY_OPTIMAL);
writeDescriptorSets = {
// Binding 0 : Vertex shader uniform buffer
vks::initializers::writeDescriptorSet(
descriptorSet,
VK_DESCRIPTOR_TYPE_UNIFORM_BUFFER,
0,
&uniformBuffers.vsFullScreen.descriptor),
// Binding 1 : Position texture target
vks::initializers::writeDescriptorSet(
descriptorSet,
VK_DESCRIPTOR_TYPE_COMBINED_IMAGE_SAMPLER,
1,
&texDescriptorPosition),
// Binding 2 : Normals texture target
vks::initializers::writeDescriptorSet(
descriptorSet,
VK_DESCRIPTOR_TYPE_COMBINED_IMAGE_SAMPLER,
2,
&texDescriptorNormal),
// Binding 3 : Albedo texture target
vks::initializers::writeDescriptorSet(
descriptorSet,
VK_DESCRIPTOR_TYPE_COMBINED_IMAGE_SAMPLER,
3,
&texDescriptorAlbedo),
// Binding 4 : Fragment shader uniform buffer
vks::initializers::writeDescriptorSet(
descriptorSet,
VK_DESCRIPTOR_TYPE_UNIFORM_BUFFER,
4,
&uniformBuffers.fsLights.descriptor),
};
vkUpdateDescriptorSets(device, static_cast<uint32_t>(writeDescriptorSets.size()), writeDescriptorSets.data(), 0, NULL);
...
}
光照處理階段的片段着色器和我們之前一直在用的光照教程着色器是非常相似的,除了我們添加了一個新的方法,從而使我們能夠獲取光照的輸入變量,當然這些變量我們會從G緩衝中直接採樣。
#version 450
layout (binding = 1) uniform sampler2D samplerposition;
layout (binding = 2) uniform sampler2D samplerNormal;
layout (binding = 3) uniform sampler2D samplerAlbedo;
layout (location = 0) in vec2 inUV;
layout (location = 0) out vec4 outFragcolor;
struct Light {
vec4 position;
vec3 color;
float radius;
};
layout (binding = 4) uniform UBO
{
Light lights[6];
vec4 viewPos;
} ubo;
void main()
{
// 從G緩衝中獲取數據
vec3 fragPos = texture(samplerposition, inUV).rgb;
vec3 normal = texture(samplerNormal, inUV).rgb;
vec4 albedo = texture(samplerAlbedo, inUV);
#define lightCount 6
#define ambient 0.0
// 環境光部分
vec3 fragcolor = albedo.rgb * ambient;
for(int i = 0; i < lightCount; ++i)
{
// 像素點到光源方向
vec3 L = ubo.lights[i].position.xyz - fragPos;
// 光源到像素點距離
float dist = length(L);
// 像素點到相機方向
vec3 V = ubo.viewPos.xyz - fragPos;
V = normalize(V);
if(dist < ubo.lights[i].radius)
{
// 像素點到光源方向向量單位化
L = normalize(L);
// 衰減係數
float atten = ubo.lights[i].radius / (pow(dist, 2.0) + 1.0);
// 漫反射部分
vec3 N = normalize(normal);
float NdotL = max(0.0, dot(N, L));
vec3 diff = ubo.lights[i].color * albedo.rgb * NdotL * atten;
// 鏡面反射部分
// Specular map values are stored in alpha of albedo mrt
vec3 R = reflect(-L, N);
float NdotR = max(0.0, dot(R, V));
vec3 spec = ubo.lights[i].color * albedo.a * pow(NdotR, 16.0) * atten;
fragcolor += diff + spec;
}
}
outFragcolor = vec4(fragcolor, 1.0);
}
光照處理階段着色器接受三個uniform紋理,代表G緩衝,它們包含了我們在幾何處理階段儲存的所有數據。如果我們現在再使用當前片段的紋理座標採樣這些數據,我們將會獲得和之前完全一樣的片段值,這就像我們在直接渲染幾何體。在片段着色器的一開始,我們通過一個簡單的紋理查找從G緩衝紋理中獲取了光照相關的變量。注意我們從gAlbedoSpec紋理中同時獲取了Albedo顏色和Spqcular強度。
因爲我們現在已經有了必要的逐片段變量(和相關的uniform變量)來計算布林-馮氏光照(Blinn-Phong Lighting),我們不需要對光照代碼做任何修改了。我們在延遲着色法中唯一需要改的就是獲取光照輸入變量的方法。
運行一個包含6個小光源的簡單Demo會是像這樣子的:
四、延遲渲染小結
延遲渲染的其中一個缺點就是它不能進行混合(Blending),因爲G緩衝中所有的數據都是從一個單獨的片段中來的,而混合需要對多個片段的組合進行操作。延遲着色法另外一個缺點就是它迫使你對大部分場景的光照使用相同的光照算法,你可以通過包含更多關於材質的數據到G緩衝中來減輕這一缺點。
爲了克服這些缺點(特別是混合),我們通常分割我們的渲染器爲兩個部分:一個是延遲渲染的部分,另一個是專門爲了混合或者其他不適合延遲渲染管線的着色器效果而設計的的正向渲染的部分。至於這是如何工作的,後續有時間的話可以繼續探討。