
在本章,你將學到:
- Vulkan以及它背后的基本原理;
- 如何創建一個最簡單的Vulkan應用程序;
- 在本書其余部分將使用到的術語和概念。
本章將介紹并解釋Vulkan是什么。我們會介紹API背后的基本概念,包括初始化、對象生命周期、Vulkan實例以及邏輯和物理設備。在本章的最后,我們會完成一個簡單的Vulkan應用程序,這個程序可以初始化Vulkan系統,查找可用的Vulkan設備并顯示其屬性和功能,最后徹底地關閉程序。
1.1 引言
Vulkan是一個用于圖形和計算設備的編程接口。Vulkan設備通常由一個處理器和一定數量的固定功能硬件模塊組成,用于加速圖形和計算操作。通常,設備中的處理器是高度線程化的,所以在極大程度上Vulkan里的計算模型是基于并行計算的。Vulkan還可以訪問運行應用程序的主處理器上的共享或非共享內存。Vulkan也會給開發人員提供這個內存。
Vulkan是個顯式的API,也就是說,幾乎所有的事情你都需要親自負責。驅動程序是一個軟件,用于接收API調用傳遞過來的指令和數據,并將它們進行轉換,使得硬件可以理解。在老的API(例如OpenGL)里,驅動程序會跟蹤大量對象的狀態,自動管理內存和同步,以及在程序運行時檢查錯誤。這對開發人員非常友好,但是在應用程序經過調試并且正確運行時,會消耗寶貴的CPU性能。Vulkan解決這個問題的方式是,將狀態跟蹤、同步和內存管理交給了應用程序開發人員,同時將正確性檢查交給各個層進行代理,而要想使用這些層必須手動啟用。這些層在正常情況下不會在應用程序里執行。
由于這些原因,Vulkan難以使用,并且在一定程度上很不穩定。你需要做大量的工作來保證Vulkan運行正常,并且API的錯誤使用經常會導致圖形錯亂甚至程序崩潰,而在傳統的圖形API里你通常會提前收到用于幫助解決問題的錯誤消息。以此為代價,Vulkan提供了對設備的更多控制、清晰的線程模型以及比傳統API高得多的性能。
另外,Vulkan不僅僅被設計成圖形API,它還用作異構設備,例如圖形處理單元(Graphics Processing Unit,GPU)、數字信號處理器(Digital Signal Processor,DSP)和固定功能硬件。功能可以粗略地劃分為幾類。Vulkan的當前版本定義了傳輸類別——用于復制數據;計算類別——用于運行著色器進行計算工作;圖形類別——包括光柵化、圖元裝配、混合、深度和模板測試,以及圖形程序員所熟悉的其他功能。
Vulkan設備對每個分類的支持都是可選的,甚至可以根本不支持圖形。因此,將圖像顯示到適配器設備上的API(這個過程叫作展示)不但是可選擇的功能,而且是擴展功能,而不是核心API。
1.2 實例、設備和隊列
Vulkan包含了一個層級化的功能結構,從頂層開始是實例,實例聚集了所有支持Vulkan的設備。每個設備提供了一個或者多個隊列,這些隊列執行應用程序請求的工作。
Vulkan實例是一個軟件概念,在邏輯上將應用程序的狀態與其他應用程序或者運行在應用程序環境里的庫分開。系統里的物理設備表示為實例的成員變量,每個都有一定的功能,包括一組可用的隊列。
物理設備通常表示一個單獨的硬件或者互相連接的一組硬件。在任何系統里,都有一些數量固定的物理設備,除非這個系統支持重新配置,例如熱插拔。由實例創建的邏輯設備是一個與物理設備相關的軟件概念,表示與某個特定物理設備相關的預定資源,其中包括了物理設備上可用隊列的一個子集。可以通過創建多個邏輯設備來表示一個物理設備,應用程序花大部分時間與邏輯設備交互。
圖1.1展示了這個層級關系。圖1.1中,應用程序創建了兩個Vulkan實例。系統里的3個物理設備能夠被這兩個實例使用。經過枚舉,應用程序在第一個物理設備上創建了一個邏輯設備,在第二個物理設備創建了兩個邏輯設備,在第三個物理設備上創建了一個邏輯設備。每個邏輯設備啟用了對應物理設備隊列的不同子集。在實際開發中,大多數Vulkan應用程序不會這么復雜,而會針對系統里的某個物理設備只創建一個邏輯設備,并且使用一個實例。圖1.1僅僅用來展示Vulkan的復雜性。

圖1.1 Vulkan里關于實例、設備和隊列的層級關系
后面的小節將討論如何創建Vulkan實例,如何查詢系統里的物理設備,并將一個邏輯設備關聯到某個物理設備上,最后獲取設備提供的隊列句柄。
1.2.1 Vulkan實例
Vulkan可以被看作應用程序的子系統。一旦應用程序連接了Vulkan庫并初始化,Vulkan就會追蹤一些狀態。因為Vulkan并不向應用程序引入任何全局狀態,所以所有追蹤的狀態必須存儲在你提供的一個對象里。這就是實例對象,由VkInstance對象來表示。為了構建這個對象,我們會調用第一個Vulkan函數vkCreateInstance(),其原型如下。
VkResult vkCreateInstance ( const VkInstanceCreateInfo* pCreateInfo, const VkAllocationCallbacks* pAllocator, VkInstance* pInstance);
該聲明是個典型的Vulkan函數:把多個參數傳入Vulkan,函數通常接收結構體的指針。這里,pCreateInfo是指向結構體VkInstanceCreateInfo的實例的指針。這個結構體包含了用來描述新的Vulkan實例的參數,其定義如下。
typedef struct VkInstanceCreateInfo { VkStructureType sType; const void* pNext; VkInstanceCreateFlags flags; const VkApplicationInfo* pApplicationInfo; uint32_t enabledLayerCount; const char* const* ppEnabledLayerNames; uint32_t enabledExtensionCount; const char* const* ppEnabledExtensionNames; } VkInstanceCreateInfo;
幾乎每一個用于向API傳遞參數的Vulkan結構體的第一個成員都是字段sType,該字段告訴Vulkan這個結構體的類型是什么。核心API以及任何擴展里的每個結構體都有一個指定的結構體標簽。通過檢查這個標簽,Vulkan工具、層和驅動可以確定結構體的類型,用于驗證以及在擴展里使用。另外,字段pNext允許將一個相連的結構體鏈表傳入函數。這樣在一個擴展中,允許對參數集進行擴展,而不用將整個核心結構體替換掉。因為這里使用了核心的實例創建結構體,將字段sType設置為VK_STRUCTURE_TYPE_INSTANCE_CREATE_INFO,并且將pNext設置為nullptr。
字段flags留待將來使用,應該設置為0。下一個字段pApplicationInfo是個可選的指針,指向另一個描述應用程序的結構體。可以將它設置為nullptr,但是推薦填充為有用的信息。pApplicationInfo指向結構體VkApplicationInfo的一個實例,其定義如下。
typedef struct VkApplicationInfo { VkStructureType sType; const void* pNext; const char* pApplicationName; uint32_t applicationVersion; const char* pEngineName; uint32_t engineVersion; uint32_t apiVersion; } VkApplicationInfo;
我們再一次看到了字段sType和pNext。SType 應該設置為VK_STRUCTURE_TYPE_APPLICATION_INFO,并且可以將pNext設置為nullptr。pApplicationName是個指針,指向以nul為結尾的字符串[1],這個字符串用于包含應用程序的名字。applicationVersion是應用程序的版本號。這樣就允許工具和驅動決定如何對待應用程序,而不用猜測[2]哪個應用程序正在運行。同樣,pEngineName與engineVersion也分別包含了引擎或者中間件(應用程序基于此構建)的名字和版本號。
最后,apiVersion包含了應用程序期望運行的Vulkan API的版本號。這個應該設置為你期望應用程序運行所需的Vulkan的絕對最小版本號——并不是你安裝的頭文件中的版本號。這樣允許更多設備和平臺運行應用程序,即使并不能更新它們的Vulkan實現。
回到結構體VkInstanceCreateInfo,接下來是字段enabledLayerCount和ppEnabledLayerNames。這兩個分別是你想激活的實例層的個數以及名字。層用于攔截Vulkan的API調用,提供日志、性能分析、調試或者其他特性。如果不需要層,只需要將enabledLayerCount設置為0,將ppEnabledLayerNames設置為nullptr。同樣,enabledExtensionCount是你想激活的擴展的個數[3],ppEnabledExtensionNames是名字列表。如果我們不想使用任何的擴展,同樣可以將這些字段分別設置為0和nullptr。
最后,回到函數vkCreateInstance(),參數pAllocator是個指向主機內存分配器的指針,該分配器由應用程序提供,用于管理Vulkan系統使用的主機內存。將這個參數設置為nullptr會導致Vulkan系統使用它內置的分配器。在這里先這樣設置。應用程序托管的主機內存將會在第2章中講解。
如果函數vkCreateInstance()成功,會返回VK_SUCCESS,并且會將新實例的句柄放置在變量pInstance里。句柄是用于引用對象的值。Vulkan句柄總是64位寬,與主機系統的位數無關。一旦有了Vulkan實例的句柄,就可以用它調用實例函數了。
1.2.2 Vulkan物理設備
一旦有了實例,就可以查找系統里安裝的與Vulkan兼容的設備。Vulkan有兩種設備:物理設備和邏輯設備。物理設備通常是系統的一部分——顯卡、加速器、數字信號處理器或者其他的組件。系統里有固定數量的物理設備,每個物理設備都有自己的一組固定的功能。
邏輯設備是物理設備的軟件抽象,以應用程序指定的方式配置。邏輯設備是應用程序花費大部分時間處理的對象。但是在創建邏輯設備之前,必須查找連接的物理設備。需要調用函數vkEnumeratePhysicalDevices(),其原型如下。
VkResult vkEnumeratePhysicalDevices ( VkInstance instance, uint32_t* pPhysicalDeviceCount, VkPhysicalDevice* pPhysicalDevices);
函數vkEnumeratePhysicalDevices()的第一個參數instance是之前創建的實例。下一個參數pPhysicalDeviceCount是一個指向無符號整型變量的指針,同時作為輸入和輸出。作為輸出,Vulkan將系統里的物理設備數量寫入該指針變量。作為輸入,它會初始化為應用程序能夠處理的設備的最大數量。參數pPhysicalDevices是個指向VkPhysicalDevice句柄數組的指針。
如果你只想知道系統里有多少個設備,將pPhysicalDevices設置為nullptr,這樣Vulkan將忽視pPhysicalDeviceCount的初始值,將它重寫為支持的設備的數量。可以調用vkEnumerate PhysicalDevices()兩次,動態調整VkPhysicalDevice數組的大小:第一次僅將pPhysicalDevices設置為nullptr(盡管pPhysicalDeviceCount仍然必須是個有效的指針),第二次將pPhysicalDevices設置為一個數組(數組的大小已經調整為第一次調用返回的物理設備數量)。
如果調用成功,函數vkEnumeratePhysicalDevices()返回VK_SUCCESS,并且將識別出來的物理設備數量存儲進pPhysicalDeviceCount中,還將它們的句柄存儲進pPhysicalDevices中。代碼清單1.1展示了一個例子:構造結構體VkApplicationInfo和VkInstanceCreateInfo,創建Vulkan實例,查詢支持設備的數量,并最終查詢物理設備的句柄。這是例子框架里面的vkapp::init的簡化版本。
代碼清單1.1 創建Vulkan實例
VkResult vkapp::init() { VkResult result = VK_SUCCESS; VkApplicationInfo appInfo = { }; VkInstanceCreateInfo instanceCreateInfo = { }; // 通用的應用程序信息結構體 appInfo.sType = VK_STRUCTURE_TYPE_APPLICATION_INFO; appInfo.pApplicationName = "Application"; appInfo.applicationVersion = 1; appInfo.apiVersion = VK_MAKE_VERSION(1, 0, 0); // 創建實例 instanceCreateInfo.sType = VK_STRUCTURE_TYPE_INSTANCE_CREATE_INFO; instanceCreateInfo.pApplicationInfo = &appInfo; result = vkCreateInstance(&instanceCreateInfo, nullptr, &m_instance); if (result == VK_SUCCESS) { // 首先判斷系統里有多少個設備 uint32_t physicalDeviceCount = 0; vkEnumeratePhysicalDevices(m_instance, &physicalDeviceCount, nullptr); if (result == VK_SUCCESS) { // 調整設備數組的大小,并獲取物理設備的句柄 m_physicalDevices.resize(physicalDeviceCount); vkEnumeratePhysicalDevices(m_instance, &physicalDeviceCount, &m_physicalDevices[0]); } } return result; }
物理設備句柄用于查詢設備的功能,并最終用于創建邏輯設備。第一次執行的查詢是vkGet PhysicalDeviceProperties(),該函數會填充描述物理設備所有屬性的結構體。其原型如下。
void vkGetPhysicalDeviceProperties ( VkPhysicalDevice physicalDevice, VkPhysicalDeviceProperties* pProperties);
當調用vkGetPhysicalDeviceProperties()時,向參數physicalDevice傳遞vkEnumeratePhysical Devices()返回的句柄之一,向參數pProperties傳遞一個指向結構體VkPhysicalDeviceProperties實例的指針。VkPhysicalDeviceProperties是個大結構體,包含了大量描述物理設備屬性的字段。其定義如下。
typedef struct VkPhysicalDeviceProperties { uint32_t apiVersion; uint32_t driverVersion; uint32_t vendorID; uint32_t deviceID; VkPhysicalDeviceType deviceType; char deviceName [VK_MAX_PHYSICAL_DEVICE_NAME_SIZE]; uint8_t pipelineCacheUUID[VK_UUID_SIZE]; VkPhysicalDeviceLimits limits; VkPhysicalDeviceSparseProperties sparseProperties; } VkPhysicalDeviceProperties;
字段apiVersion包含了設備支持的Vulkan的最高版本,字段driverVersion包含了用于控制設備的驅動的版本號。這是硬件生產商特定的,所以對比不同的生產商的驅動版本沒有任何意義。字段vendorID與deviceID標識了生產商和設備,并且通常是PCI生產商和設備標識符[4]。
字段deviceName包含了可讀字符串來命名設備。字段pipelineCacheUUID用于管線緩存,這會在第6章中講到。
除了剛剛列出的屬性之外,結構體VkPhysicalDeviceProperties內嵌了VkPhysicalDeviceLimits和VkPhysicalDeviceSparseProperties,包含了物理設備的最大和最小限制,以及和稀疏紋理有關的屬性。這兩個結構體里有大量信息,這些字段會在討論相關特性時介紹,在此不再詳述。
除了核心特性(有些有更高的限制或約束)之外,Vulkan還可能有一些物理設備支持的可選特性。如果設備宣傳支持某個特性,它必須激活(非常像擴展)。但是一旦激活,這個特性就變成了API的“一等公民”,就像任何核心特性一樣。為了判定物理設備支持哪些特性,調用vkGetPhysicalDeviceFeatures()。其原型如下。
void vkGetPhysicalDeviceFeatures ( VkPhysicalDevice physicalDevice, VkPhysicalDeviceFeatures* pFeatures);
結構體vkPhysicalDeviceFeatures也非常大,并且Vulkan支持的每一個可選特性都有一個布爾類型的字段。字段太多,就不在此詳細羅列了,但是本章最后展示的例子會讀取特性集并輸出其內容。
1.2.3 物理設備內存
在許多情況下,Vulkan設備要么是一個獨立于主機處理器之外的一塊物理硬件,要么工作方式非常不同,以獨有的方式訪問內存。Vulkan里的設備內存是指,設備能夠訪問到并且用作紋理和其他數據的后備存儲器的內存。內存可以分為幾類,每一類都有一套屬性,例如緩存標志位以及主機和設備之間的一致性行為。每種類型的內存都由設備的某個堆(可能會有多個堆)進行支持。
為了查詢堆配置以及設備支持的內存類型,需要調用以下代碼。
void vkGetPhysicalDeviceMemoryProperties ( VkPhysicalDevice physicalDevice, VkPhysicalDeviceMemoryProperties* pMemoryProperties);
查詢到的內存組織信息會存儲進結構體 VkPhysicalDeviceMemoryProperties中,地址通過pMemoryProperties傳入。結構體VkPhysicalDeviceMemoryProperties包含了關于設備的堆以及其支持的內存類型的屬性。該結構體的定義如下。
typedef struct VkPhysicalDeviceMemoryProperties { uint32_t memoryTypeCount; VkMemoryType memoryTypes[VK_MAX_MEMORY_TYPES]; uint32_t memoryHeapCount; VkMemoryHeap memoryHeaps[VK_MAX_MEMORY_HEAPS]; } VkPhysicalDeviceMemoryProperties;
內存類型數量包含在字段memoryTypeCount里。可能報告的內存類型的最大數量是VK_MAX_MEMORY_TYPES定義的值,這個宏定義為32。數組memoryTypes包含memoryTypeCount個結構體VkMemoryType對象,每個對象都描述了一種內存類型。VkMemoryType的定義如下。
typedef struct VkMemoryType { VkMemoryPropertyFlags propertyFlags; uint32_t heapIndex; } VkMemoryType;
這是個簡單的結構體,只包含了一套標志位以及內存類型的堆棧索引。字段flags描述了內存的類型,并由VkMemoryPropertyFlagBits類型的標志位組合而成。標志位的含義如下。
- VK_MEMORY_PROPERTY_DEVICE_LOCAL_BIT意味著內存對于設備來說是本地的(也就是說,物理上是和設備連接的)。如果沒有設置這個標志位,可以認為該內存對于主機來說是本地的。
- VK_MEMORY_PROPERTY_HOST_VISIBLE_BIT意味著以這種方式分配的內存可以被主機映射以及讀寫。如果沒有設置這個標志位,那么內存不能被主機直接訪問,只能由設備使用。
- VK_MEMORY_PROPERTY_HOST_COHERENT_BIT意味著當這種內存同時被主機和設備訪問時,這兩個客戶之間的訪問保持一致。如果沒有設置這個標志位,設備或者主機不能看到對方執行的寫操作,直到顯式地刷新緩存。
- VK_MEMORY_PROPERTY_HOST_CACHED_BIT意味著這種內存里的數據在主機里面進行緩存。對這種內存的讀取操作比不設置這個標志位通常要快。然而,設備的訪問延遲稍微高一些,尤其當內存也保持一致時。
- VK_MEMORY_PROPERTY_LAZILY_ALLOCATED_BIT意味著這種內存分配類型不一定立即使用關聯的堆的空間,驅動可能延遲分配物理內存,直到內存對象用來支持某個資源。
每種內存類型都指定了從哪個堆上使用空間,這由結構體VkMemoryType里的字段heapIndex來標識。這個字段是數組memoryHeaps (在調用vkGetPhysicalDeviceMemoryProperties()返回的結構體VkPhysicalDeviceMemoryProperties里面)的索引。數組memoryHeaps里面的每一個元素描述了設備的一個內存堆。結構體的定義如下。
typedef struct VkMemoryHeap { VkDeviceSize size; VkMemoryHeapFlags flags; } VkMemoryHeap;
同樣,這也是個簡單的結構體,包含了堆的大小(單位是字節)以及描述這個堆的標識符。在Vulkan 1.0里,唯一定義的標識符是VK_MEMORY_HEAP_DEVICE_LOCAL_BIT。如果定義了這個標識符,堆對于設備來說就是本地的。這對應于以類似方式命名的用于描述內存類型的標識符。
1.2.4 設備隊列
Vulkan設備執行提交給隊列的工作。每個設備都有一個或者多個隊列,每個隊列都從屬于設備的某個隊列族。一個隊列族是一組擁有相同功能同時又能并行運行的隊列。隊列族的數量、每個族的功能以及每個族擁有的隊列數量都是物理設備的屬性。為了查詢設備的隊列族,調用vkGetPhysicalDeviceQueueFamilyProperties(),其原型如下。
void vkGetPhysicalDeviceQueueFamilyProperties ( VkPhysicalDevice physicalDevice, uint32_t* pQueueFamilyPropertyCount, VkQueueFamilyProperties* pQueueFamilyProperties);
vkGetPhysicalDeviceQueueFamilyProperties()的運行方式在一定程度上和vkEnumeratePhysical Devices()類似,需要調用前者兩次。第一次,將nullptr傳遞給pQueueFamilyProperties,并給pQueueFamilyPropertyCount傳遞一個指針,指向表示設備支持的隊列族數量的變量。可以使用該值調整VkQueueFamilyProperties類型的數組的大小。接下來,在第二次調用中,將該數組傳入pQueueFamilyProperties,Vulkan將會用隊列的屬性填充該數組。VkQueueFamilyProperties的定義如下。
typedef struct VkQueueFamilyProperties { VkQueueFlags queueFlags; uint32_t queueCount; uint32_t timestampValidBits; VkExtent3D minImageTransferGranularity; } VkQueueFamilyProperties;
該結構體里的第一個字段是queueFlags,描述了隊列的所有功能。這個字段由VkQueueFlagBits類型的標志位的組合組成,其含義如下。
- VK_QUEUE_GRAPHICS_BIT 如果設置了,該族里的隊列支持圖形操作,例如繪制點、線和三角形。
- VK_QUEUE_COMPUTE_BIT如果設置了,該族里的隊列支持計算操作,例如發送計算著色器。
- VK_QUEUE_TRANSFER_BIT 如果設置了,該族里的隊列支持傳送操作,例如復制緩沖區和圖像內容。
- VK_QUEUE_SPARSE_BINDING_BIT 如果設置了,該族里的隊列支持內存綁定操作,用于更新稀疏資源。
字段queueCount表示族里的隊列數量,該值可能是1。如果設備支持具有相同基礎功能的多個隊列,該值也可能更高。
字段timestampValidBits表示當從隊列里取時間戳時,多少位有效。如果這個值設置為0,那么隊列不支持時間戳。如果不是0,那么會保證最少支持36位。如果設備的結構體VkPhysicalDeviceLimits里的字段timestampComputeAndGraphics是VK_TRUE,那么所有支持VK_QUEUE_GRAPHICS_BIT或者VK_QUEUE_COMPUTE_BIT的隊列都能保證支持36位的時間戳。這種情況下,無須檢查每一個隊列。
最后,字段minImageTimestampGranularity指定了隊列傳輸圖像時支持多少單位(如果有的話)。
注意,有可能出現這種情形,設備報告多個明顯擁有相同屬性的隊列族。一個族里的所有隊列實質上都等同。不同族里的隊列可能擁有不同的內部功能,而這些不能在Vulkan API里輕易表達。由于這個原因,具體實現可能選擇將類似的隊列作為不同族的成員。這對資源如何在隊列間共享施加了更多限制,這可能允許具體實現接納這些不同。
代碼清單1.2展示了如何查詢物理設備的內存屬性和隊列族屬性。需要在創建邏輯設備(在下一節會講到)之前獲取隊列族的屬性。
代碼清單1.2 查詢物理設備的屬性
uint32_t queueFamilyPropertyCount; std::vector<VkQueueFamilyProperties> queueFamilyProperties; VkPhysicalDeviceMemoryProperties physicalDeviceMemoryProperties; //獲取物理設備的內存屬性 vkGetPhysicalDeviceMemoryProperties( m_physicalDevices[deviceIndex], &physicalDeviceMemoryProperties); //首先查詢物理設備支持的隊列族的數量 vkGetPhysicalDeviceQueueFamilyProperties( m_physicalDevices[0], &queueFamilyPropertyCount, nullptr); //為隊列屬性結構體分配足夠的空間 queueFamilyProperties.resize(queueFamilyPropertyCount); //現在查詢所有隊列族的實際屬性 vkGetPhysicalDeviceQueueFamilyProperties( m_physicalDevices[0], &queueFamilyPropertyCount, queueFamilyProperties.data());
1.2.5 創建邏輯設備
在枚舉完系統里的所有物理設備之后,應用程序應該選擇一個設備,并且針對該設備創建邏輯設備。邏輯設備代表處于初始化狀態的設備。在創建邏輯設備時,可以選擇可選特性,開啟需要的擴展,等等。創建邏輯設備需要調用vkCreateDevice(),其原型如下。
VkResult vkCreateDevice ( VkPhysicalDevice physicalDevice, const VkDeviceCreateInfo* pCreateInfo, const VkAllocationCallbacks* pAllocator, VkDevice* pDevice);
把與邏輯設備相對應的物理設備傳給physicalDevice,把關于新的邏輯對象的信息傳給結構體VkDeviceCreateInfo的實例pCreateInfo。VkDeviceCreateInfo的定義如下。
typedef struct VkDeviceCreateInfo { VkStructureType sType; const void* pNext; VkDeviceCreateFlags flags; uint32_t queueCreateInfoCount; const VkDeviceQueueCreateInfo* pQueueCreateInfos; uint32_t enabledLayerCount; const char* const* ppEnabledLayerNames; uint32_t enabledExtensionCount; const char* const* ppEnabledExtensionNames; const VkPhysicalDeviceFeatures* pEnabledFeatures; } VkDeviceCreateInfo;
字段sType應該設置為VK_STRUCTURE_TYPE_DEVICE_CREATE_INFO。通常,除非你希望使用擴展,否則pNext應該設置為nullptr。Vulkan當前版本沒有為字段flags定義標志位,所以將這個字段設置為0。
接下來是隊列創建信息。pQueueCreateInfos是指向結構體VkDeviceQueueCreateInfo的數組的指針,每個結構體VkDeviceQueueCreateInfo的對象允許描述一個或者多個隊列。數組里的結構體數量由queueCreateInfoCount給定。VkDeviceQueueCreateInfo的定義如下。
typedef struct VkDeviceQueueCreateInfo { VkStructureType sType; const void* pNext; VkDeviceQueueCreateFlags flags; uint32_t queueFamilyIndex; uint32_t queueCount; const float* pQueuePriorities; } VkDeviceQueueCreateInfo;
字段sType設置成VK_STRUCTURE_TYPE_DEVICE_QUEUE_CREATE_INFO。Vulkan當前版本沒有為字段flags定義標志位,所以將這個字段設置為0。字段queueFamilyIndex指定了你希望創建的隊列所屬的族,這是個索引值,與調用vkGetPhysicalDeviceQueueFamilyProperties()返回的隊列族的數組對應。為了在這個族里創建隊列,將queueCount設置為你希望創建的隊列個數。當然,設備在你選擇的族中支持的隊列數量必須不小于這個值。
字段pQueuePriorities是個可選的指針,指向浮點數數組,表示提交給每個隊列的工作的相對優先級。這些數字是個歸一化的數字,取值范圍是0.0~1.0。給高優先級的隊列會分配更多的處理資源或者更頻繁地調度它們。將pQueuePriorities設置為nullptr等同于為所有的隊列都指定相同的默認優先級。
請求的隊列按照優先級排序,并且給它們指定了與設備相關的相對優先級。一個隊列能夠表示的離散的優先級數量是設備特定的參數。這個參數從結構體VkPhysicalDeviceLimits(調用vkGetPhysicalDeviceProperties()的返回值)里的字段discreteQueuePriorities得到。例如,如果設備只支持高低兩種優先級的工作負載,這個字段就是2。所有設備最少支持兩個離散的優先級。然而,如果設備支持任意的優先級,這個字段的數值就會非常大。不管discreteQueuePriorities的數值有多大,隊列的相對優先級仍然是浮點數。
回到結構體VkDeviceCreateInfo,字段enabledLayerCount、ppEnabledLayerNames、enabled ExtensionCount與ppEnabledExtensionNames用于激活層和擴展。本章后面會講到這兩個主題。現在將enabledLayerCount和enabledExtensionCount設置為0,將ppEnabledLayerNames和ppEnabed ExtensionNames設置為nullptr。
VkDeviceCreateInfo的最后一個字段是pEnabledFeatures,這是個指向結構體VkPhysical DeviceFeatures的實例的指針,這個實例指明了哪些可選擴展是應用程序希望使用的。如果你不想使用任何可選的特性,只需要將它設置為nullptr。當然,這種方式下Vulkan就會相當受限,大量有意思的功能就不能使用了。
為了判斷某個設備支持哪些可選的特性,像之前討論的那樣調用vkGetPhysicalDeviceFeatures()即可。vkGetPhysicalDeviceFeatures()將設備支持的特性組寫入你傳入結構體VkPhysicalDeviceFeatures的實例。查詢物理設備的特性并將結構體VkPhysicalDeviceFeatures原封不動地傳給vkCreateDevice(),你會激活設備支持的所有可選特性,同時也不會請求設備不支持的特性。
然而,激活所有支持的特性會帶來性能影響。對于有些特性,Vulkan具體實現可能需要分配額外的內存,跟蹤額外的狀態,以不同的方式配置硬件,或者執行其他影響應用程序性能的操作。所以,激活不會使用的特性不是個好主意。你應該查詢設備支持的特性,然后激活應用程序需要的特性。
代碼清單1.3展示了一個簡單的例子,它查詢設備支持的特性并設置應用程序需要的功能列表。此處需要支持曲面細分和幾何著色器,如果設備支持,就激活多次間接繪制(multidraw indirect),代碼接下來使用第一個隊列的單一實例創建設備。
代碼清單1.3 創建一個邏輯設備
VkResult result; VkPhysicalDeviceFeatures supportedFeatures; VkPhysicalDeviceFeatures requiredFeatures = {}; vkGetPhysicalDeviceFeatures( m_physicalDevices[0], &supportedFeatures); requiredFeatures.multiDrawIndirect = supportedFeatures.multiDrawIndirect; requiredFeatures.tessellationShader = VK_TRUE; requiredFeatures.geometryShader = VK_TRUE; const VkDeviceQueueCreateInfo deviceQueueCreateInfo = { VK_STRUCTURE_TYPE_DEVICE_QUEUE_CREATE_INFO, // sType nullptr, // pNext 0, // flags 0, // queueFamilyIndex 1, // queueCount nullptr // pQueuePriorities }; const VkDeviceCreateInfo deviceCreateInfo = { VK_STRUCTURE_TYPE_DEVICE_CREATE_INFO, // sType nullptr, // pNext 0, // flags 1, // queueCreateInfoCount &deviceQueueCreateInfo, // pQueueCreateInfos 0, // enabledLayerCount nullptr, // ppEnabledLayerNames 0, // enabledExtensionCount nullptr, // ppEnabledExtensionNames &requiredFeatures // pEnabledFeatures }; result = vkCreateDevice( m_physicalDevices[0], &deviceCreateInfo, nullptr, &m_logicalDevice);
在代碼清單1.3運行成功并創建邏輯設備之后,啟用的特性集合就存儲在了變量requiredFeatures里。這可以留待以后用,選擇使用某個特性的代碼可以檢查這個特性是否成功激活并優雅地回退。
1.3 對象類型和函數約定
事實上,Vulkan里面的所有東西都表示為對象,這些對象靠句柄引用。句柄可以分為兩大類:可調度對象和不可調度對象。在極大程度上,這與應用程序無關,僅僅影響API的構造以及系統級別的組件,例如Vulkan加載器和層如何與這些對象互操作。
可調度對象內部包含了一個調度表,其實就是函數表,在應用程序調用Vulkan時,各種組件據此判斷執行哪一部分代碼。這些類型的對象通常是重量級的概念,目前有實例(VkInstance)、物理設備(VkPhysicalDevice)、邏輯設備(VkDevice)、命令緩沖區(VkCommandBuffer)和隊列(VkQueue)。其他剩余的對象都可以被視為不可調度對象。
任何Vulkan函數的第一個參數總是個可調度對象,唯一的例外是創建和初始化實例的相關函數。
1.4 管理內存
Vulkan提供兩種內存:主機內存和設備內存。通常,Vulkan API創建的對象需要一定數量的主機內存。Vulkan實現在這里存儲對象的狀態并實現這個API所需的數據。資源對象(例如緩沖區和圖像)需要一定數量的設備內存。這就是用于存儲資源里數據的內存。
應用程序有可能為Vulkan具體的實現管理主機內存,但是要求應用程序管理設備內存。因此,需要創建設備內存管理子系統。可以查詢創建的每個資源,得到用于支持它的內存的數量和類型。應用程序分配正確數量的內存并在使用資源對象前將它附加在這個對象上。
對于高級API,例如OpenGL,這個功能由驅動程序代替應用程序執行。然而,有的應用程序需要大量的小資源,有的應用程序需要少量非常大的資源。有些應用程序在執行期間創建和銷毀資源,而有的在初始化時創建所有的資源,直到程序結束才釋放。
這些情況下的分配策略可能相當不同,不存在萬全之策。因為OpenGL驅動無法預測應用程序的行為,所以必須調整分配策略,以適應你的使用方式。另一方面,作為應用程序的開發者,你完全知道應用程序的行為。可以將資源分為長期和短期兩組。可以將一起使用的資源放入幾個池式分配的內存里。你可以決定應用程序使用哪種分配策略。
需要特別注意的是,每次動態內存分配都會在系統上產生開銷。因此,盡量少分配對象是非常重要的。推薦做法是,設備內存分配器要分配大塊的內存。大量小的資源可以放置在少數幾個設備內存塊里面。關于設備內存分配器的例子會在第2章中討論,到時會討論內存分配里的很多細節。
1.5 Vulkan里的多線程
對多線程應用程序的支持是Vulkan設計中不可或缺的一部分。Vulkan通常會假設應用程序能夠保證兩個線程會在同一個時間修改同一個對象,這稱為外部同步。在Vulkan里性能至上的部分(例如構建命令緩沖區)中,絕大部分Vulkan命令根本沒有提供同步功能。
為了具體定義各種Vulkan命令中和線程相關的請求,把防止主機同步訪問的每一個參數標識為外部同步。在某些情況下,把對象的句柄或者其他的數據內嵌到數據結構體里,包括進數組里,或者通過間接方式傳入指令中。那些參數也必須在外部同步。
這么做的目的是Vulkan實現從來不需要在內部使用互斥量或者其他的同步原語來保護數據結構體。這意味著多線程程序很少由于跨線程引起卡頓或者阻塞。
除了在跨線程使用共享對象時要求主機同步訪問之外,Vulkan還包含了若干高級特性,專門用來允許多線程執行任務時互不阻塞。這些高級特性如下。
- 主機內存分配可以通過如下方式進行:將一個主機內存分配結構體傳入創建對象的函數。通過每個線程使用一個分配器,這個分配器里的數據結構體就不需要保護了。主機內存分配器在第2章中會講到。
- 命令緩沖區是從內存池中分配的,并且訪問內存池是由外部同步的。如果應用程序對每個線程都使用單獨的命令池,那么命令緩沖區就可以從池內分配空間,而不會互相造成阻塞。命令緩沖區和池將在第3章里講到。
- 描述符是從描述符池里的集合分配的。描述符代表了運行在設備上的著色器使用的資源。這將在第6章里講到。如果每個線程都使用單獨的池,描述符集就可以從池中分配,而不會彼此阻塞線程。
- 副命令緩沖區允許大型渲染通道(必須包含在某個命令緩沖區里)里的內容并行產生,然后聚集起來,就像它們是從主命令緩沖區調用的一樣。副命令緩沖區會在第13章里講到。
當你正在編寫一個非常簡單的單線程應用程序時,創建用于分配對象的內存池就顯得冗余了。然而,隨著應用程序使用的線程不斷增多,為了提高性能,這些對象就必不可少了。
在本書剩下的篇幅中,在講解命令時,和多線程有關的額外需求都會明確指出來。
1.6 數學概念
計算機圖形學和大多數異構計算應用程序都嚴重地依賴數學。大多數Vulkan設備都是基于極其強大的計算處理器的。在本書寫作時,即使是很普通的移動處理器也提供了每秒幾十億次浮點運算(GFLOPS)的數據處理能力,而高端臺式機和工作站的處理器又提供每秒幾萬億次浮點運算(TFLOPS)的數據處理能力。因此,有趣的應用程序構建在數學密集型的著色器之上。另外,Vulkan處理管線中的一些固定功能構建在“硬連接”到設備和規范的數學概念之上。
1.6.1 向量和矩陣
在圖形程序中最基本的“積木”之一就是向量。不管它代表位置、方向、顏色或者其他量,向量在圖形學著作中會從頭到尾使用到。向量的一種常用形式是齊次向量,這也是個向量,只不過比它所表示的數值多一個維度。這些向量用于存儲投影坐標。用任何標量乘以一個齊次向量會產生一個新的向量,代表了相同的投影坐標。要投影一個點向量,需要每一個元素都除以最后一個元素,這樣會產生具有x、y、z和1.0(如果是4個元素的向量)這類形式的向量。
如果要將一個向量從一個坐標空間變換到另一個,需要將這個向量乘以一個矩陣。因為3D空間里的點由具有4個元素的齊次向量表示,所以變換矩陣就應該是4×4的矩陣。
3D空間里的點由齊次向量表示,按照慣例,里面的4個元素分別是x、y、z和w。對于一個點來說,成員w一般來說最開始是1.0,與投影變換矩陣相乘以后就改變了。在除以w之后,這個點就經歷了所有的變換,完成了投影變換。如果變換矩陣里沒有投影變換矩陣,w仍然是1.0,除以1.0對向量來說沒有任何影響。如果向量經過透視變換,w就不等于1.0了,但是使用這個透視變換矩陣除以向量以后,w就由變成1.0了。
同時,3D空間里的方向也由齊次向量來表示,只是w是0.0。如果用正確構造的4×4投影變換矩陣乘以方向向量,w值仍是0.0,這樣不會對其他元素產生影響。只需要丟棄額外的元素,你就能像4D齊次3D點向量那樣,讓3D方向向量經歷同樣的變換,使它經過同樣的旋轉、縮放和其他的變換。
1.6.2 坐標系
Vulkan通過將端點或者拐角表示成3D空間里的點,來表示基本圖元,例如線和三角形。這些基本單位稱為頂點。輸入Vulkan系統的3D坐標系空間(表示為w元素是1.0的齊次向量)里的頂點坐標,這些頂點坐標是相對于當前對象的原點的數值。這個坐標空間稱為對象空間或者模型空間。
一般情況下,管線里的第一個著色器會將這個頂點變換到觀察空間中,也就是相對于觀察者的位置。這個變換操作是通過用一個變換矩陣乘以這個頂點的位置向量實現的。這個矩陣通常稱為對象-視圖變換矩陣,或者模型-視圖變換矩陣。
有時候,需要頂點的絕對坐標,例如查找某個頂點相對于其他對象的距離。這個全局空間稱為世界空間,是頂點位置相對于全局原點的位置。
從觀察坐標系出來后,把頂點位置變換到裁剪空間。這是Vulkan中幾何處理部分的最后一個空間,也是當把頂點推送進3D應用程序使用的投影空間時,這些頂點變換進的空間。把這個空間稱為裁剪空間是因為在這個空間里大多數實現都執行裁剪操作,也就是渲染的可見區域之外的圖元部分都會被移除。
從裁剪空間出來后,頂點位置通過除以w歸一化。這樣就產生了一個新的坐標空間,叫作標準化設備坐標(NDC)。而這個操作通常稱為透視除法。在這個空間里,在x和y兩個方向上坐標系上的可見部分是?1.0~1.0,z方向上是0.0~1.0。這個區域之外的任何東西都會在透視除法之前被剔除掉。
最終,頂點的標準化設備坐標由視口變換矩陣進行變換,這個變換矩陣描述了NDC如何映射到正在被渲染的窗口或者圖像中。
1.7 增強Vulkan
盡管Vulkan的核心API的設計規范相當豐富,但絕不是包羅萬象的。有些功能是可選的,而更多的是以層(修改或者增強了現有的行為)和擴展(增加了Vulkan的新功能)的形式使用的。兩種增強機制在下面會講到。
1.7.1 層
層是Vulkan中的一種特性,允許修改它的行為。通常,層完全或者部分攔截Vulkan,并增加新的功能,例如日志、追蹤、診斷、性能分析等。層可以添加到實例層面,這樣,它會影響整個Vulkan實例,也有可能影響由實例創建的每個設備。或者,層可以添加到設備層面中,這樣,它僅僅會影響激活這個層的設備。
為了查詢系統里的實例可用的層,調用vkEnumerateInstanceLayerProperties(),其原型如下。
VkResult vkEnumerateInstanceLayerProperties ( uint32_t* pPropertyCount, VkLayerProperties* pProperties);
如果pProperties是nullptr,那么pPropertyCount應該指向一個變量,用于接收Vulkan可用的層的數量。如果pProperties不是nullptr,那么它應該指向結構體VkLayerProperties類型的數組,會向這個數組填充關于系統里注冊的層的信息。這種情況下,pPropertyCount指向的變量的初始值是pProperties 指向的數組的長度,并且這個變量會被重寫成數組里由指令重寫的條目數。
數組pProperties 里的每個元素都是結構體VkLayerProperties的實例,其定義如下。
typedef struct VkLayerProperties { char layerName[VK_MAX_EXTENSION_NAME_SIZE]; uint32_t specVersion; uint32_t implementationVersion; char description[VK_MAX_DESCRIPTION_SIZE]; } VkLayerProperties;
每一個層都有個正式的名字,存儲在結構體VkLayerProperties里的成員layerName中。每個層的規范都可能不斷改進,進一步明晰,或者添加新功能,層實現的版本號存儲在specVersion中。
隨著規范不斷改進,具體實現也需要不斷改進。具體實現的版本號存儲在結構體VkLayer Properties的字段implementationVersion里。這樣就允許改進性能,修正Bug,實現更豐富的可選特性集,等等。應用程序作者可能識別出某個層的特定實現,并選擇使用它,只要這個實現的版本號超過了某個版本(例如,后一個版本有個已知的嚴重Bug需要修復)。
最終,描述層的可讀字符串存儲在description中。這個字段的唯一目的是輸出日志,或者在用戶界面展示,僅僅用作提供信息。
代碼清單1.4演示了如何查詢Vulkan系統支持的實例層。
代碼清單1.4 查詢實例層
uint32_t numInstanceLayers = 0; std::vector<VkLayerProperties> instanceLayerProperties; //查詢實例層 vkEnumerateInstanceLayerProperties( &numInstanceExtensions, nullptr); //如果有支持的層,查詢它們的屬性 if (numInstanceLayers != 0) { instanceLayerProperties.resize(numInstanceLayers); vkEnumerateInstanceLayerProperties( nullptr, &numInstanceLayers, instanceLayerProperties.data()); }
如前所述,不但可以在實例層面注入層,而且可以應用在設備層面應用層。為了檢查哪些層是設備可用的,調用vkEnumerateDeviceLayerProperties(),其原型如下。
VkResult vkEnumerateDeviceLayerProperties ( VkPhysicalDevice physicalDevice, uint32_t* pPropertyCount, VkLayerProperties* pProperties);
因為系統里的每個物理設備可用的層可能不一樣,所以每個物理設備可能報告出一套不同的層。需要查詢可用層的物理設備通過physicalDevice傳入。傳入vkEnumerateDeviceLayerProperties()的參數pPropertyCount和pProperties的行為與傳入vkEnumerateInstanceLayerProperties()的相似。設備層也由結構體VkLayerProperties的實例描述。
為了在實例層面激活某個層,需要將其名字包含在結構體VkInstanceCreateInfo的字段ppEnabledLayerNames里,這個結構體用于創建實例。同樣,為了在創建對應系統里的某個物理設備的邏輯設備時激活某個層,需要將這個層的名字包含在結構體VkDeviceCreateInfo的成員ppEnabledLayerNames里,這個結構體用于創建設備。
官方SDK包含若干個層,大部分與調試、參數驗證和日志有關。具體內容如下。
- VK_LAYER_LUNARG_api_dump 將Vulkan的函數調用以及參數輸出到控制臺。
- VK_LAYER_LUNARG_core_validation 執行對用于描述符集、管線狀態和動態狀態的參數和狀態的驗證;驗證SPIR-V模塊和圖形管線之間的接口;跟蹤和驗證用于支持對象的GPU內存的使用。
- VK_LAYER_LUNARG_device_limits 保證作為參數或者數據結構體成員傳入Vulkan的數值處于設備支持的特性集范圍內。
- VK_LAYER_LUNARG_image 驗證圖像使用和支持的格式是否相一致。
- VK_LAYER_LUNARG_object_tracker 執行Vulkan對象追蹤,捕捉內存泄漏、釋放后使用的錯誤以及其他的無效對象使用。
- VK_LAYER_LUNARG_parameter_validation 確認所有傳入Vulkan函數的參數值都有效。
- VK_LAYER_LUNARG_swapchain 執行WSI(Window System Integration,這將在第5章中講解)擴展提供的功能的驗證。
- VK_LAYER_GOOGLE_threading 保證Vulkan命令在涉及多線程時有效使用,保證兩個線程不會同時訪問同一個對象(如果這種操作不允許的話)。
- VK_LAYER_GOOGLE_unique_objects 確保每個對象都有一個獨一無二的句柄,以便于應用程序追蹤狀態,這樣能避免下述情況的發生:某個實現可能刪除代表了擁有相同參數的對象的句柄。
除此之外,把大量不同的層分到單個更大的層中,這個層名叫VK_LAYER_LUNARG_standard_validation,這樣就很容易開啟了。本書的應用程序框架在調試模式下編譯時激活了這個層,而在發布模式下關閉了所有的層。
1.7.2 擴展
對于任何跨平臺的開放式API(例如Vulkan),擴展都是最根本的特性。這些擴展允許實現者不斷試驗、創新并且最終推動技術進步。有用的特性最初作為擴展出現,經過實踐證明后,最終變成API的未來版本。然而,擴展并不是沒有開銷的。有些擴展可能要求具體實現跟蹤額外的狀態,在命令緩沖區構建時進行額外的檢查,或者即使擴展沒有直接使用,也會帶來性能損失。因此,擴展在使用前必須被應用程序顯式啟用。這意味著,應用程序如果不使用某個擴展就不需要為此付出增加性能開銷和提高復雜性的代價。這也意味著,不會出現意外使用某個擴展的特性,這可以改善可移植性。
擴展可以分為兩類:實例擴展和設備擴展。實例擴展用于在某個平臺上整體增強Vulkan系統。這種擴展或者通過設備無關的層提供,或者只是每個設備都暴露出來并提升進實例的擴展。設備擴展用于擴展系統里一個或者多個設備的能力,但是這種能力沒必要每個設備都具備。
每個擴展都可以定義新的函數、類型、結構體、枚舉,等等。一旦激活,就可以認為這個擴展是API的一部分,對應用程序可用。實例和設備擴展必須在創建Vlukan實例與設備時激活。這導致了“雞和蛋”的悖論:在初始化Vulkan實例之前我們怎么知道哪些擴展可用?
Vulkan實例創建之前,只有少數的函數可用,查詢支持的實例擴展是其中一個。通過調用函數vkEnumerateInstanceExtensionProperties()來執行這個操作,其原型如下。
VkResult vkEnumerateInstanceExtensionProperties ( const char* pLayerName, uint32_t* pPropertyCount, VkExtensionProperties* pProperties);
字段pLayerName是可能提供擴展的層的名字,目前將這個字段設置為nullptr。pPropertyCount指向一個變量,用于存儲從Vulkan查詢到的實例擴展的數量,pProperties是個指向結構體VkExtensionProperties類型的數組的指針,會向這個數組中填充支持的擴展的信息。如果pProperties是nullptr,那么pPropertyCount指向的變量的初始值就會被忽略,并重寫為支持的實例擴展的數量。
如果pProperties不是nullptr,那么數組里的條目數量就是pPropertyCount指向的變量的值,此時,數組里的條目會被填充為支持的擴展的信息。pPropertyCount指向的變量會重寫為實際填充到pProperties 的條目的數量。
為了正確查詢所有支持的實例擴展,調用vkEnumerateInstanceExtensionProperties()兩次。第一次調用時,將pProperties設置為nullptr,以獲取支持的實例擴展的數量。接著正確調整接收擴展屬性的數組的大小,并再次調用vkEnumerateInstanceExtensionProperties(),這一次用pProperties傳入數組的地址。代碼清單1.5展示了如何操作。
代碼清單1.5 查詢實例擴展
uint32_t numInstanceExtensions = 0; std::vector<VkExtensionProperties> instanceExtensionProperties; //查詢實例擴展 vkEnumerateInstanceExtensionProperties( nullptr, &numInstanceExtensions, nullptr); //如果有支持的擴展,查詢它們的屬性 if (numInstanceExtensions != 0) { instanceExtensionProperties.resize(numInstanceExtensions); vkEnumerateInstanceExtensionProperties( nullptr, &numInstanceExtensions, instanceExtensionProperties.data()); }
在代碼清單1.5執行后,instanceExtensionProperties就包含了實例支持的擴展列表。VkExtension Properties類型的數組的每個元素描述了一個擴展。VkExtensionProperties的定義如下。
typedef struct VkExtensionProperties { char extensionName[VK_MAX_EXTENSION_NAME_SIZE]; uint32_t specVersion; } VkExtensionProperties;
結構體VkExtensionProperties僅僅包含擴展名和版本號。擴展可能隨著新的修訂版的推出增加新的功能。字段specVersion允許在擴展中增加新的小功能,而無須創建新的擴展。擴展的名字存儲在extensionName里面。
就像你之前看到的,當創建Vulkan實例時,結構體VkInstanceCreateInfo有一個名叫ppEnabled ExtensionNames的成員,這個指針指向一個用于命名需要激活的擴展的字符串數組。如果某個平臺上的Vulkan系統支持某個擴展,這個擴展就會包含在vkEnumerateInstanceExtensionProperties()返回的數組里,然后它的名字就可以通過結構體VkInstanceCreateInfo里的字段ppEnabledExtension Names傳遞給vkCreateInstance()。
查詢支持的設備擴展是個相似的過程,需要調用函數vkEnumerateDeviceExtensionProperties(),其原型如下。
VkResult vkEnumerateDeviceExtensionProperties ( VkPhysicalDevice physicalDevice, const char* pLayerName, uint32_t* pPropertyCount, VkExtensionProperties* pProperties);
vkEnumerateDeviceExtensionProperties()的原型和vkEnumerateInstanceExtensionProperties()幾乎一樣,只是多了一個參數physicalDevice。參數physicalDevice是需要查詢擴展的設備的句柄。就像vkEnumerateInstanceExtensionProperties()一樣,如果pProperties是nullptr,vkEnumerateDevice ExtensionProperties()將pPropertyCount重寫成支持的擴展的數量;如果pProprties不是nullptr,就用支持的擴展的信息填充這個數組。結構體VkExtensionProperties同時用于實例擴展和設備擴展。
當創建邏輯設備時,結構體VkDeviceCreateInfo里的字段ppEnabledExtensionNames可能包含一個指針,指向vkEnumerateDeviceExtensionProperties()返回的字符串中的一個。
有些擴展以可以調用的額外入口點的形式提供了新的功能。這些以函數指針的形式提供,這些指針必須在擴展激活后從實例或者設備中查詢。實例函數對整個實例有效。如果某個擴展擴充了實例層面的功能,你應該使用實例層面的函數指針訪問新特性。
為了獲取實例層面的函數指針,調用vkGetInstanceProcAddr(),其原型如下。
PFN_vkVoidFunction vkGetInstanceProcAddr ( VkInstance instance, const char* pName);
參數instance是需要獲取函數指針的實例的句柄。如果應用程序使用了多個Vulkan實例,那么這個指令返回的函數指針只對引用的實例所擁有的對象有效。函數名通過pName傳入,這是個以nul結尾的UTF-8類型的字符串。如果識別了函數名并且激活了這個擴展,vkGetInstance ProcAddr()的返回值是一個函數指針,可以在應用程序里調用。
PFN_vkVoidFunction是個函數指針定義,其聲明如下。
VKAPI_ATTR void VKAPI_CALL vkVoidFunction(void);
Vulkan里沒有這種特定簽名的函數,擴展也不太可能引入這樣的函數。絕大部分情況下,需要在使用前將生成的函數指針類型強制轉換為有正確簽名的函數指針。
實例層面的函數指針對這個實例所擁有的所有對象都有效——假如創建這些對象(或者設備本身,如果函數在這個設備上調度)的設備支持這個擴展,并且這個設備激活了這個擴展。由于每個設備可能在不同的Vulkan驅動里實現,因此實例函數指針必須通過一個間接層登錄正確的模塊進行調度。因為管理這個間接層可能引起額外開銷,所以為了避免這個開銷,你可以獲取一個特定于設備的函數指針,這樣可以直接進入正確的驅動。
為了獲取設備層面的函數指針,調用vkGetDeviceProcAddr(),其原型如下。
PFN_vkVoidFunction vkGetDeviceProcAddr ( VkDevice device, const char* pName);
使用函數指針的設備通過參數device傳入。需要查詢的函數的名字需要使用pName傳入,這是個以nul 結尾的UTF-8類型的字符串。返回的函數指針只在參數device指定的設備上有效。device必須指向支持這個擴展(提供了這個新函數)的設備,并且這個擴展已經激活。
vkGetDeviceProcAddr()返回的函數指針特定于參數device。即使同樣的物理設備使用同樣的參數創建出了多個邏輯設備,你也只能在查詢這個函數指針的邏輯設備上使用該指針。
1.8 徹底地關閉應用程序
在程序結束之前,你需要自己負責清理干凈。在許多情況下,操作系統會在應用程序結束時清理已經創建的資源。然而,應用程序和代碼同時結束的情景并不經常出現。比如你正在寫一個大型應用程序的組件,應用程序可能結束了使用Vulkan實現的渲染和計算操作,但是并沒有完全退出。
在清除時,通常來說,較好的做法如下。
- 完成或者終結應用程序正在主機和設備上、Vulkan相關的所有線程里所做的所有工作。
- 按照創建對象的時間逆序銷毀對象。
邏輯設備很可能是初始化應用程序時創建的最后一個對象(除了運行時使用的對象之外)。在銷毀設備之前,需要保證它沒有正在執行來自應用程序的任何工作。為了達到這個目的,調用vkDeviceWaitIdle(),其原型如下。
VkResult vkDeviceWaitIdle ( VkDevice device);
把設備的句柄傳入device。當vkDeviceWaitIdle()返回時,所有提交給設備的工作都保證已經完成了——當然,除非同時你繼續向設備提交工作。需要保證其他可能向設備提交工作的線程已經終止了。
一旦確認了設備處于空閑狀態,就可以安全地銷毀它了。這需要調用vkDestroyDevice(),其原型如下。
void vkDestroyDevice ( VkDevice device, const VkAllocationCallbacks* pAllocator);
把需要銷毀的設備的句柄傳遞給參數device,并且訪問該設備需要在外部同步。需要注意的是,其他指令對設備的訪問都不需要外部同步。然而,應用程序需要保證當訪問該設備的其他指令正在另一個線程里執行時,這個設備不要銷毀。
pAllocator應該指向一個分配的結構體,該結構體需要與創建設備的結構體兼容。一旦設備對象被銷毀了,就不能繼續向它提交指令了。進一步說,設備句柄就不可能再作為任何函數的參數了,包括其他將設備句柄作為第一個參數的對象銷毀方法。這是應該按照創建對象的時間逆序來銷毀對象的另一個原因。
一旦與Vulkan實例相關聯的所有設備都銷毀了,銷毀實例就安全了。這是通過調用函數vkDestroyInstance()實現的,其原型如下。
void vkDestroyInstance ( VkInstance instance, const VkAllocationCallbacks* pAllocator);
將需要銷毀的實例的句柄傳給instance,與vkDestroyDevice()一樣,與創建實例使用的分配結構體相兼容的結構體的指針應該傳遞給pAllocator。如果傳遞給vkCreateInstance()的參數pAllocator是nullptr,那么傳遞給vkDestroyInstance()的參數pAllocator也應該是這樣。
需要注意的是,物理設備不用銷毀。物理設備并不像邏輯設備那樣由一個專用的創建函數來創建。相反,物理設備通過調用vkEnumeratePhysicalDevices()來獲取,并且屬于實例。因此,當實例銷毀后,和每個物理設備相關的實例資源也都銷毀了。
1.9 總結
本章介紹了Vulkan。你已看到了Vulkan狀態整體上如何包含在一個實例里。實例提供了訪問物理設備的權限,每個物理設備提供了一些用于執行工作的隊列。本章還演示了如何根據物理設備創建邏輯設備,如何擴展Vulkan,如何判斷實例,設備能用哪些擴展,以及如何啟用這些擴展。最后還演示了如何徹底地關閉Vulkan系統,操作順序依次是等待設備完成應用程序提交,銷毀設備句柄,銷毀實例句柄。
[1] 是的,確實是nul。字面量為零的ASCII字符被官方稱為NUL。現在,不要再告訴我應該改成NULL。這是個指針,不是字符的名字。
[2] 對于一個程序來說是最好的,但在另一個程序中就未必如此。另外,程序是由人編寫的,人在寫代碼時就會有Bug。為了完全優化,或者消除應用程序的Bug,驅動有時候會使用可執行文件的名字,甚至使用應用程序的行為來猜測正在哪個應用程序上運行,并相應地改變行為。雖然并不完美,但這個新的機制至少消除了猜測。
[3] 和OpenGL一樣,Vulkan支持將擴展作為API的中心部分。然而,在OpenGL里,我們會創建一個運行上下文,查詢支持的擴展,然后開始使用它們。這意味著,驅動需要假設應用程序可能在任何時候突然開始使用某個擴展,并隨時準備好。另外,驅動不可能知道你正在查找哪些擴展,這一點更加重了這個過程的困難程度。在Vulkan里,要求應用程序選擇性地加入擴展,并顯式地啟用它們。這允許驅動關閉沒有使用的擴展,這也使得應用程序突然開始使用本沒有打算啟用的擴展中的部分功能變得更加困難。
[4] 并沒有關于PCI廠商或者設備標識符的官方的中央版本庫。PCI SIG(可從pcisig網站獲取)將廠商標識符指定給了它的成員,這些成員又將設備標識符指定給了它們的產品。人和機器同時可讀的清單可從pcidatabase網站獲取。
本文摘自《Vulkan 應用開發指南》
《Vulkan 應用開發指南》
[美] 格拉漢姆·塞勒斯(Graham Sellers) 著,李曉波 等 譯

下一代OpenGL規范已經重新進行了設計,從而使得應用程序可以直接控制GPU的加速。本書系統地介紹下一代OpenGL規范Vulkan、它的目標以及構建其API的關鍵概念,揭示了Vulkan的獨特性。
本書討論的主題非常寬泛,從繪圖命令到內存,再到計算著色器的線程。本書重點展示了如何處理現在由開發人員負責的同步、調度和內存管理等任務。本書是Vulkan開發人員的指南和參考手冊,有助于讀者迅速掌握跨平臺圖形的下一代規范。你將從本書中學習到可用于從視頻游戲到醫學成像等領域的3D開發技術,以及解決復雜的科學計算問題的先進方法。
本書主要內容
- 大量經過反復測試的代碼示例,用于演示Vulkan的功能并展示它與OpenGL的區別。
- Vulkan中的新內存系統。
- 隊列、命令和移動數據的方法。
- SPIR-V二進制著色語言和計算/圖形管道。
- 繪圖命令、幾何處理、片段處理、同步原語,以及將Vulkan數據讀入應用程序。
- 完整的案例研究應用程序:使用復雜的多通道架構和多個處理隊列的延遲渲染。
- Vulkan函數和SPIR-V操作碼,以及完整的Vulkan詞匯表。