【建議收藏】30 分鐘入門 Vulkan (中文翻譯版)

語言: CN / TW / HK

關於 Vulkan 的學習,網上有一篇很火的文章:《Vulkan in 30 minutes》。

這篇文章是英文的,原文連結如下:

http://renderdoc.org/vulkan-in-30-minutes.html

恰好在知乎上有位大佬將它翻譯成了中文,知乎作者就是:fangcun ,連結如下:

http://zhuanlan.zhihu.com/p/59695433

通過如下的連結可以下載文章對應的 PDF 檔案和程式碼演示:

http://web.engr.oregonstate.edu/~mjb/vulkan/VulkanIn30Minutes.pdf

這裡轉載一波大佬的的翻譯,通俗易懂,值得收藏。

對於 Vulkan 的學習,如果有興趣也可以看看我寫的 Vulkan 版本的 GPUImage。

用 Vulkan 渲染寫一個 Android GPUImage

以下就是翻譯原文:

本文主要面向具有一定圖形API(D3D11或OpenGL)使用經驗的讀者,此外,我們還希望讀者對多執行緒,暫存資源,同步等知識有所瞭解。我們將要介紹的Vulkan大量使用了這些知識。

本文僅僅是為了讓讀者能夠對Vulkan的工作方式有一個大致的瞭解,所以忽略了很多細節。

讀者在閱讀完本文之後,可以參考Vulkan的官方規範或其它Vulkan教程瞭解我們所忽略的細節部分。

概述

在本文的結尾,我們給出了使用Vulkan來繪製一個三角形的虛擬碼,讀者可以參考它來理解本文。

下面是一些有關Vulkan的小知識:

  • Vulkan是一個標準的C API。

  • Vulkan API對型別的使用非常重度。

  • Vulkan API大量使用結構體作為函式呼叫的引數。

  • Vulkan API中用於建立和清除物件的函式帶有一個VkAllocationCallbacks結構體指標引數,允許我們使用它來自定義CPU端的記憶體分配器。如果不想自定義這個CPU端的記憶體分配器,可以將其設定為NULL來使用Vulkan自帶的CPU端的記憶體分配器。

需要讀者注意的是,本文沒有討論任何有關錯誤處理的內容,如果真正地使用Vulkan編寫程式,需要根據Vulkan具體實現的限制,進行相關處理。

第一步

我們通過建立一個Vulkan例項(VkInstance)來完成Vulkan的初始化。

每個Vulkan例項是完全獨立的,一個Vulkan例項對另一個Vulkan例項不存在任何影響。建立Vulkan例項時,我們指定了需要使用的層(layer)和擴充套件。

如果不知道有哪些層(layer)或擴充套件可以使用,可以使用查詢函式來列舉可用的層(layer)和擴充套件。

有了VkInstance後,我們可以檢測可用的GPU裝置(Vulkan不光可以用於GPU,這裡為了方便,統稱為GPU裝置)。

每個GPU裝置有一個VkPhysicalDevice型別的控制代碼。通過GPU裝置的控制代碼,我們可以查詢GPU裝置的名稱,屬性,功能等等。可以查詢的詳細資訊可以參考vkGetPhysicalDeviceProperties和vkGetPhysicalDeviceFeatures函式的官方規範。使用GPU裝置控制代碼VkPhysicalDevice,我們可以建立一個VkDevice。一個VkDevice代表了一個邏輯連結,表明我們在這一GPU上使用Vulkan。可以認為VkDevice等價於OpenGL中的context或D3D11中的device。

一個VkInstance可以有多個VkPhysicalDevice。一個VkPhysicalDevice也可以有多個VkDevice。對於Vulkan 1.0來說,還不支援多GPU互動,但未來版本的Vulkan將會允許多個GPU進行互動。

Vulkan要求我們顯式地設定一切引數,所以從建立VkInstance到選擇使用地VkPhysicalDevice,再到建立VkDevice需要填寫的引數相當多。拋去引數填寫,大致過程看起來是這樣的:vkCreateInstance() → vkEnumeratePhysicalDevices() → vkCreateDevice()。對於我們這樣一個繪製三角形的簡單程式,可以先直接選擇第一個物理裝置,等到後面需要錯誤資訊、啟用可選的裝置特性時再回來根據需要選擇物理裝置。

影象和緩衝

現在我們已經建立了一個VkDevice,可以開始建立其它所需的資源。比如VkImage和VkBuffer。

Vulkan要求我們在VkImage建立時指定它的用途。比如它是用作顏色附著,還是用於在著色器中進行取樣、還是用於影象載入/儲存等等。

此外,我們還需要指定VkImage在記憶體中的儲存方式:LINEAR還是OPTIMAL。OPTIMAL儲存方式下,影象資料在記憶體中的組織方式對我們完全不透明。

LINEAR儲存方式下,影象資料會按照我們可以預期的形式存放。影象的儲存方式對影象資料是否可以被直接讀取和寫入,以及可以使用的影象型別有一定影響。不同儲存方式可以支援的影象型別不同。

緩衝和影象類似,需要我們在建立時指定緩衝的用途,以及大小。

我們並不能直接訪問影象資料,需要通過VkImageView來訪問影象資料。VkImageView描述了需要訪問的影象資料範圍,以及將影象資料作為何種格式進行訪問。

緩衝只是一塊記憶體,可以被直接使用。但如果需要在著色器中直接訪問緩衝中的資料,則需要通過VkBufferView進行。

分配GPU記憶體

緩衝和影象在建立後並沒有實際為它們分配記憶體。

我們需要自己為它們分配記憶體。呼叫vkGetPhysicalDeviceMemoryProperties函式可以獲取可以用於分配的記憶體資訊。這些資訊包括可以用於記憶體分配的一個或多個堆的資訊、堆的大小以及可以分配的記憶體型別。每種記憶體型別對應一個可以分配這一型別記憶體的堆。通常,對於帶有獨立顯示卡的PC裝置,會存在兩個可以用於記憶體分配的堆:一個可以分配系統記憶體,一個可以分配GPU記憶體。所有不同型別的記憶體都由這兩個堆之一進行分配。

不同型別的記憶體具有不同的屬性。一些型別的記憶體可以被CPU訪問,一些不可以。一些型別可以在GPU和CPU間保持資料一致性、一些型別可以被CPU快取使用等等。可以通過查詢物理裝置獲取這些資訊。我們可以根據需要使用不同的記憶體型別,比如對於暫存資源,我們需要使用可以被CPU訪問的記憶體型別。對於用於渲染的影象,我們通常為其分配GPU記憶體。此外,記憶體分配還存在一個限制,我們會在下一節討論。

記憶體分配需要呼叫vkAllocateMemory函式。呼叫它需要使用VkDevice和一個描述記憶體分配資訊的結構體作為引數。我們使用這一結構體指定需要分配的記憶體型別、記憶體大小以及分配它的堆。vkAllocateMemory函式呼叫後會返回一個VkDeviceMemory控制代碼。

對於CPU可以訪問的記憶體型別,可以使用vkMapMemory/vkUnmapMemory函式對其進行對映。這一對映是持久化的,只要進行了正確的同步,可以在GPU使用這一記憶體區域時訪問它。

vkMapMemory函式返回的指標可以被儲存使用,只要進行了正確的同步,甚至可以在GPU使用這一記憶體區域時對其進行寫入操作,同步規則可以保證CPU不會寫入資料到GPU正在使用的那部分記憶體。

顯式重新整理的非一致性記憶體除錯起來要比一致性記憶體方便得多。顯式重新整理為我們提供了非常好用的斷點位置。

RenderDoc會對一個使用顯式重新整理的記憶體區域關閉代價極高的記憶體一致性追蹤功能。在除錯時,我們可以對一致性記憶體進行顯式重新整理,來獲得更好的除錯體驗。

繫結記憶體

VkBuffer和VkImage的記憶體需求可以通過呼叫vkGetBufferMemoryRequirements函式和vkGetImageMemoryRequirements函式獲取。

獲取的記憶體需求滿足了多個細化級別間的對齊、隱含的元資料和其它需要佔用記憶體的資訊的需求。此外,記憶體需求還包含了一個掩碼,表明滿足此記憶體需求的記憶體型別。對於使用OPTIMAL儲存方式的用於顏色附著色影象,只有DEVICE_LOCAL型別的記憶體可以使用,不能對它繫結HOST_VISIBLE型別的記憶體。

對於同一類的影象或緩衝,它們需要的記憶體型別是一樣的,只需要對需要的記憶體大小和對齊方式進行檢查,然後分配記憶體即可。

我們可以一次分配一大塊記憶體,然後將這一大塊記憶體通過使用不同的偏移值分配給多個影象或緩衝使用。分配的偏移值需要滿足影象或緩衝的對齊需求。通常,實踐中由於記憶體分配的總次數有一定限制,我們總是這樣做來減少記憶體分配次數。

同一個VkDeviceMemory中存放的VkImage和VkBuffer使用的記憶體之間還需要滿足一個最小間隔bufferImageGranularity。讀者可以閱讀Vulkan規範,獲取有關它的更多資訊。這一要求和效能表現有關。

繫結影象或緩衝的記憶體可以通過呼叫vkBindImageMemory函式或vkBindBufferMemory函式進行。我們需要在使用緩衝或影象前對它們繫結記憶體,並且繫結是不可更改的。

指令緩衝和提交指令

指令需要先被記錄到指令緩衝中,然後提交給佇列執行。

VkCommandBuffer需要使用VkCommandPool來分配。我們可以為每個執行緒使用一個獨立的VkCommandPool來避免進行同步,不同VkCommandPool使用自己的記憶體資源分配VkCommandBuffer。

開始記錄VkCommandBuffer後,呼叫的GPU指令,會被寫入VkCommandBuffer。等待提交給佇列執行。

指令緩衝完成指令記錄後,會被提交給VkQueue。可以認為VkQueue是一個包含了GPU待執行工作的佇列。通過VkPhysicalDevice,我們可以獲取物理裝置所支援的具有不同功能的佇列族。比如圖形佇列族和計算佇列族。在建立VkDevice時,可以從這些佇列族請求一定數量的佇列,在VkDevice建立後通過呼叫vkGetDeviceQueue獲取請求的佇列控制代碼。

使用多個佇列需要進行同步操作,這裡,為了簡單起見,我們只使用一個可以滿足所有需要的佇列。需要注意有些Vulkan實現可能會要求為交換鏈呈現使用獨立的佇列,雖然大多數情況下應該不需要,但還是提醒讀者注意,更多資訊可以參考Vulkan的官方規範。

可以通過呼叫vkQueueSubmit函式一次提交多個指令緩衝到一個佇列中,提交到佇列的指令緩衝會按順序被執行。Vulkan對於指令執行順序有非常具體的要求,讀者需要特別注意Vulkan官方規範中有關這一部分的說明,保證進行了正確的同步操作。

著色器和管線狀態物件

下面介紹Vulkan的著色器資料繫結模型:

  • 每個著色器階段有自己獨立的名稱空間,片段著色器的0號紋理繫結和頂點著色器的0號紋理繫結沒有任何關係。

  • 不同型別的資源位於不同的名稱空間,0號uniform緩衝繫結和0號紋理繫結沒有任何關係。

  • 資源被獨立地進行繫結和解繫結。

Vulkan的基本繫結單位是描述符。描述符是一個不透明的繫結表示。它可以表示一個影象、一個取樣器或一個uniform緩衝等等。它甚至可以表示陣列,比如一個影象陣列。

描述符的設定並不是獨立進行的,它被帶有特定VkDescriptorSetLayout的VkDescriptorSet進行統一設定。VkDescriptorSetLayout描述了VkDescriptorSet中每個繫結的型別。

讀者可以這樣理解:把VkDescriptorSetLayout看作是一個結構體型別,它描述了使用的成員變數的變數型別。VkDescriptorSet是VkDescriptorSetLayout結構體型別的一個例項,它被用於具體的資料繫結。

我們傳遞一個包含了型別、陣列大小和繫結的列表給Vulkan來建立VkDescriptorSetLayout。然後使用它從VkDescriptorPool中分配VkDescriptorSet。VkDescriptorPool和VkCommandPool類似,我們可以為每個執行緒建立獨立的VkDescriptorPool來避免進行同步操作。

VkDescriptorSetLayoutBinding bindings[] = {
// binding 0 is a UBO, array size 1, visible to all stages
{ 0, VK_DESCRIPTOR_TYPE_UNIFORM_BUFFER, 1, VK_SHADER_STAGE_ALL_GRAPHICS, NULL },
// binding 1 is a sampler, array size 1, visible to all stages
{ 1, VK_DESCRIPTOR_TYPE_SAMPLER, 1, VK_SHADER_STAGE_ALL_GRAPHICS, NULL },
// binding 5 is an image, array size 10, visible only to fragment shader
{ 5, VK_DESCRIPTOR_TYPE_SAMPLED_IMAGE, 10, VK_SHADER_STAGE_FRAGMENT_BIT, NULL },
};

有了描述符集後,我們就可以通過繫結來更新資料,以及在不同描述符集間複製資料。

在建立管線時,可以對一個VkPipelineLayout指定多個需要使用的VkDescriptorSetLayouts。進行資料繫結時,只能使用匹配的VkDescriptorSet。不同的描述符集可以按照不同的頻率更新資料,可以按照更新頻率來劃分描述符集。

繼續考慮之前的類比,我們可以將管線看作一個函式,它具有多個結構體引數。建立管線時,它的每個引數的型別被確定(VkDescriptorSetLayout),進行資料繫結時我們將例項(VkDescriptorSet)傳遞給管線。

著色器中的繫結設定相對來說就很簡單了,只需要指定資源來自哪個描述符集和描述符集中的哪一繫結即可。

#version 430
layout(set = 0, binding = 0) uniform MyUniformBufferType {
// ...
} MyUniformBufferInstance;
// note in the C++ sample above, this is just a sampler ‐ not a combined image+sampler
// as is typical in GL.
layout(set = 0, binding = 1) sampler MySampler;
layout(set = 0, binding = 5) uniform image2D MyImages[10];

同步

同步大概是Vulkan最難處理的部分,甚至有時忘記進行某一同步操作,程式執行後看起來也跟完全沒有問題一樣。

在兩個不同的執行緒上使用同一個VkQueue需要進行同步,否則會引起程式崩潰。

對於在多個執行緒使用某一物件是否需要同步可以參考Vulkan的官方規範。一般來說,使用VkDevice作為引數的建立函式不需要進行同步,但像記錄指令和提交指令緩衝這類操作需要進行同步。

Vulkan沒有對使用的資源進行引用計數,我們需要自己保證在不再使用資源時釋放它。

Vulkan提供了VkEvent、VkSemaphore和VkFence用於CPU-GPU和GPU-GPU同步。Vulkan的官方規範對於執行順序的明確規定很少,進行同步操作需要格外小心。

管線屏障是一個新的概念。它被用來保證GPU端操作的執行順序。比如可以保證在開始一個操作前某個操作已經完成,或在某一資源上的某一型別操作已經完成可以開始另一型別操作。

有三種記憶體屏障型別:VkMemoryBarrier、VkBufferMemoryBarrier和VkImageMemoryBarrier。VkMemoryBarrier可以進行所有記憶體資源的同步操作,其它兩種型別的記憶體屏障用於同步特定的記憶體資源。

我們通過記憶體屏障指定需要進行的同步操作。比如設定記憶體屏障的srcAccessMask = ACCESS_COLOR_ATTACHMENT_WRITE和dstAccessMask = ACCESS_SHADER_READ後,著色器讀取資料前所有顏色寫入操作必須完成。如果不進行這樣的設定,我們可能會讀取到過期的資料。

影象佈局

影象資源存在一個叫做影象佈局的狀態。VkImageMemoryBarrier可以對影象資源的影象佈局進行變換。對影象進行的操作需要影象滿足一定的佈局。存在一個通用的可以進行任意操作的影象佈局,但使用它的效能表現不佳。對於需要在影象上進行的特定操作使用特定的影象佈局效能表現更好。比如用作顏色附著、深度附著和需要在著色器中進行取樣的影象都有一個特別適合的影象佈局。

影象初始時處於UNDEFINED或PREINITIALIZED狀態。PREINITIALIZED狀態用於填充有資料的影象。對於處於UNDEFINED狀態 的影象,將它變換到GENERAL狀態時,會丟失之前的影象資料,但處於PREINITIALIZED狀態的影象變換到GENERAL狀態時,不會丟失之前的影象資料。處於這兩個初始影象佈局狀態的影象都不能直接被GPU使用,需要進行至少一次影象佈局變換才可以被GPU使用。

通常我們需要準確指定影象變換之前的佈局和變換之後的佈局。但使用UNDEFINED作為之前的影象佈局也是常見的,它表明我們不需要之前的影象資料,只需要將影象變換為需要的新佈局。

渲染流程

Vulkan使用VkRenderpass來顯式地定義渲染操作流程。對於基於tile的渲染,VkRenderpass可以極大的提高記憶體利用,減少頻繁的資料傳輸。

一個VkRenderPass包含了一系列的子流程。對於我們這個簡單的程式,它只包含了一個子流程。子流程指定了幀緩衝的顏色附著、深度模板附著。如果有多個子流程可能會為它們指定不同的附著設定,一個子流程將其用作資料輸入,另一個子流程可能將其用作資料輸出。

繪製指令只可以在VkRenderPass中執行,複製資料和清除資料的指令只可以在VkRenderPass外執行。狀態繫結的指令的執行可以在VkRenderPass外也可以VkRenderPass內。

子流程不會繼承之前的狀態。所以每次開始一個VkRenderPass或進入一個新的子流程,我們必須重新繫結所有狀態。子流程還指定了讀寫附著時執行的附加操作。比如使用值1.0來清除深度附著的內容,接下來顏色附著會被新資料完全覆蓋掉,不進行顏色附著的清除。這些資訊為驅動程式優化提供了很大空間。

最後需要考慮的是多個不同物件之間的匹配問題。建立VkRenderPass(以及它的所有子流程)時我們指定了使用的所有附著以及附著的格式。之後,建立VkFramebuffer時,指定使用我們建立的VkRenderPass。這樣指定後,並不意味著之後必須使用這一個VkRenderPass,只要和指定的這一個VkRenderPass相相容(具有相同的附著和附著格式)的VkRenderPass都可以在之後被VkFramebuffer使用。建立VkPipeline時也需要指定使用的VkRenderPass和子流程,同樣之後只要與指定的VkRenderPass和子流程相相容的物件都可以供VkPipeline使用。

如果渲染流程帶有多個子流程,就需要定義子流程之間的依賴和記憶體屏障,以及它們使用的附著及其用途。更多資訊可以參考Vulkan的官方規範。

後臺緩衝和呈現

Vulkan通過擴充套件來和原生視窗系統進行互動。我們需要在建立VkInstance和VkDevice時顯式地請求這一擴充套件。

首先,我們使用原生視窗系統的資訊建立一個VkSurfaceKHR。

然後,為它建立一個VkSwapchainKHR。這需要我們查詢VkSurfaceKHR支援的影象資料格式,以及我們可以在交換鏈中使用的後臺緩衝個數。

我們可以呼叫vkGetSwapchainImagesKHR函式從VkSwapchainKHR獲取VkImage影象控制代碼。交換鏈中的影象由Vulkan自動建立。我們只需要建立對應的影象檢視就可以訪問它們。

當需要對交換鏈影象進行渲染操作時,可以呼叫vkAcquireNextImageKHR函式,它會返回一個交換鏈影象的索引,我們使用這一索引使用對應影象檢視來對影象進行渲染。最後呼叫vkQueuePresentKHR函式將渲染的影象呈現到螢幕上。

有大量設定可以用於優化交換鏈的效能表現,但對於我們這樣一個簡單的程式,並非必要。

總結

本文跳過了大量繁瑣的細節,也沒有對稀疏資源,主要和次要指令緩衝等一些很酷的特性進行介紹。有關這些內容讀者可以參考Vulkan的官方規範。

技術交流,歡迎加我微信:ezglumes ,拉你入技術交流群。

推薦閱讀:

音視訊面試基礎題

OpenGL ES 學習資源分享

開通專輯 | 細數那些年寫過的技術文章專輯

NDK 學習進階免費視訊來了

推薦幾個堪稱教科書級別的 Android 音視訊入門專案

百萬高薪,十萬獎金!網易應用創新開發者大賽正式開賽!

覺得不錯,點個在看唄~