延遲渲染
一、簡述
我們現在一直使用的光照方式叫做正向渲染(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)技術,我們甚至可以在一個渲染處理之内完成這所有的工作。
2.3 vulkan流程圖
三、代碼應用
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緩沖中來減輕這一缺點。
為了克服這些缺點(特别是混合),我們通常分割我們的渲染器為兩個部分:一個是延遲渲染的部分,另一個是專門為了混合或者其他不适合延遲渲染管線的着色器效果而設計的的正向渲染的部分。至于這是如何工作的,後續有時間的話可以繼續探讨。