操作系統:Windows8.1
顯卡:Nivida GTX965M
開發工具:Visual Studio 2017
Setup
這一章節會把之前的所有內容進行整合。我們將會編寫drawFrame函數,通過主循環main loop將三角形繪制到屏幕。在mainLoop函數調用:
void mainLoop() { while (!glfwWindowShouldClose(window)) { glfwPollEvents(); drawFrame(); } } ... void drawFrame() { }
Synchronization
drawFrame函數將會執行如下操作:
- 從交換鏈中獲取一個圖像
- 在幀緩沖區中,使用作為附件的圖像來執行命令緩沖區中的命令
- 為了最終呈現,將圖像返還到交換鏈
每個事件派發都有一個函數調用來對應,但它們的執行是異步的。函數調用將在操作實際完成之前返回,並且執行順序也是未定義的。這是不理想的,因為每一個操作都取決於前一個操作。
同步交換鏈事件有兩種方法:柵欄和信號量。它們都是可以通過使用一個操作信號,負責協調操作的對象。另一個操作等待柵欄或者信號量從無信號狀態轉變到有信號狀態。
不同之處在於可以在應用程序中調用vkWaitForFence進入柵欄狀態,而信號量不可以。柵欄主要用於應用程序自身與渲染操作進行同步,而信號量用於在命令隊列內或者跨命令隊列同步操作。我們期望同步繪制與呈現的隊列操作,所以使用信號量最合適。
Semaphores
在獲得一個圖像時,我們需要發出一個信號量准備進行渲染,另一個信號量的發出用於渲染結束,准備進行呈現presentation。創建兩個成員變量存儲信號量對象:
VkSemaphore imageAvailableSemaphore;
VkSemaphore renderFinishedSemaphore;
為了創建信號量semaphores,我們將要新增本系列教程最后一個函數: createSemaphores:
void initVulkan() { createInstance(); setupDebugCallback(); createSurface(); pickPhysicalDevice(); createLogicalDevice(); createSwapChain(); createImageViews(); createRenderPass(); createGraphicsPipeline(); createFramebuffers(); createCommandPool(); createCommandBuffers(); createSemaphores(); } ... void createSemaphores() { }
創建信號量對象需要填充VkSemaphoreCreateInfo結構體,但是在當前版本的API中,實際上不需要填充任何字段,除了sType:
void createSemaphores() { VkSemaphoreCreateInfo semaphoreInfo = {}; semaphoreInfo.sType = VK_STRUCTURE_TYPE_SEMAPHORE_CREATE_INFO; }
Vulkan API未來版本或者擴展中或許會為flags和pNext參數增加功能選項。創建信號量對象的過程很熟悉了,在這里使用vkCreateSemaphore:
if (vkCreateSemaphore(device, &semaphoreInfo, nullptr, &imageAvailableSemaphore) != VK_SUCCESS || vkCreateSemaphore(device, &semaphoreInfo, nullptr, &renderFinishedSemaphore) != VK_SUCCESS) { throw std::runtime_error("failed to create semaphores!"); }
在程序結束時,當所有命令完成並不需要同步時,應該清除信號量:
void cleanup() { vkDestroySemaphore(device, renderFinishedSemaphore, nullptr); vkDestroySemaphore(device, imageAvailableSemaphore, nullptr);
Acquiring an image from the swap chain
就像之前說到的,drawFrame函數需要做的第一件事情就是從交換鏈中獲取圖像。回想一下交換鏈是一個擴展功能,所以我們必須使用具有vk*KHR命名約定的函數:
void drawFrame() { uint32_t imageIndex; vkAcquireNextImageKHR(device, swapChain, std::numeric_limits<uint64_t>::max(), imageAvailableSemaphore, VK_NULL_HANDLE, &imageIndex); }
vkAcquireNextImageKHR函數前兩個參數是我們希望獲取到圖像的邏輯設備和交換鏈。第三個參數指定獲取有效圖像的操作timeout,單位納秒。我們使用64位無符號最大值禁止timeout。
接下來的兩個參數指定使用的同步對象,當presentation引擎完成了圖像的呈現后會使用該對象發起信號。這就是開始繪制的時間點。它可以指定一個信號量semaphore或者柵欄或者兩者。出於目的性,我們會使用imageAvailableSemaphore。
最后的參數指定交換鏈中成為available狀態的圖像對應的索引。其中索引會引用交換鏈圖像數組swapChainImages的圖像VkImage。我們使用這個索引選擇正確的命令緩沖區。
Submitting the command buffer
隊列提交和同步通過VkSubmitInfo結構體進行參數配置。
VkSubmitInfo submitInfo = {}; submitInfo.sType = VK_STRUCTURE_TYPE_SUBMIT_INFO; VkSemaphore waitSemaphores[] = {imageAvailableSemaphore}; VkPipelineStageFlags waitStages[] = {VK_PIPELINE_STAGE_COLOR_ATTACHMENT_OUTPUT_BIT}; submitInfo.waitSemaphoreCount = 1; submitInfo.pWaitSemaphores = waitSemaphores; submitInfo.pWaitDstStageMask = waitStages;
前三個參數指定在執行開始之前要等待的哪個信號量及要等待的通道的哪個階段。為了向圖像寫入顏色,我們會等待圖像狀態變為available,所我們指定寫入顏色附件的圖形管線階段。理論上這意味着,具體的頂點着色器開始執行,而圖像不可用。waitStages數組對應pWaitSemaphores中具有相同索引的信號量。
submitInfo.commandBufferCount = 1; submitInfo.pCommandBuffers = &commandBuffers[imageIndex];
接下來的兩個參數指定哪個命令緩沖區被實際提交執行。如初期提到的,我們應該提交命令緩沖區,它將我們剛獲取的交換鏈圖像做為顏色附件進行綁定。
VkSemaphore signalSemaphores[] = {renderFinishedSemaphore}; submitInfo.signalSemaphoreCount = 1; submitInfo.pSignalSemaphores = signalSemaphores;
signalSemaphoreCount和pSignalSemaphores參數指定了當命令緩沖區執行結束向哪些信號量發出信號。根據我們的需要使用renderFinishedSemaphore。
if (vkQueueSubmit(graphicsQueue, 1, &submitInfo, VK_NULL_HANDLE) != VK_SUCCESS) { throw std::runtime_error("failed to submit draw command buffer!"); }
使用vkQueueSubmit函數向圖像隊列提交命令緩沖區。當開銷負載比較大的時候,處於效率考慮,函數可以持有VkSubmitInfo結構體數組。最后一個參數引用了一個可選的柵欄,當命令緩沖區執行完畢時候它會被發送信號。我們使用信號量進行同步,所以我們需要傳遞VK_NULL_HANDLE。
Subpass dependencies
請記住,渲染通道中的子通道會自動處理布局的變換。這些變換通過子通道的依賴關系進行控制,它們指定了彼此之間內存和執行的依賴關系。現在只有一個子通道,但是在此子通道之前和之后的操作也被視為隱式“子通道”。
有兩個內置的依賴關系在渲染通道開始和渲染通道結束處理轉換,但是前者不會在當下發生。假設轉換發生在管線的起始階段,但是我們還沒有獲取圖像!有兩個方法處理這個問題可以將imageAvailableSemaphore的waitStages更改為VK_PIPELINE_STAGE_TOP_OF_PIPE_BIT,確保圖像有效之前渲染通道不會開始,或者我們讓渲染通道等待VK_PIPELINE_STAGE_COLOR_ATTACHMENT_OUTPUT_BIT階段。我覺得使用第二個選項,因為可以比較全面的了解subpass依賴關系及其工作方式。
子通道依賴關系可以通過VkSubpassDependency結構體指定,在createRenderPass函數中添加:
VkSubpassDependency dependency = {}; dependency.srcSubpass = VK_SUBPASS_EXTERNAL; dependency.dstSubpass = 0;
前兩個參數指定依賴的關系和從屬子通道的索引。特殊值VK_SUBPASS_EXTERNAL是指在渲染通道之前或者之后的隱式子通道,取決於它是否在srcSubpass或者dstSubPass中指定。索引0指定我們的子通道,這是第一個也是唯一的。dstSubpass必須始終高於srcSubPass以防止依賴關系出現循環。
dependency.srcStageMask = VK_PIPELINE_STAGE_COLOR_ATTACHMENT_OUTPUT_BIT; dependency.srcAccessMask = 0;
接下來的兩個參數字段指定要等待的操作和這些操作發生的階段。在我們可以訪問對象之前,我們需要等待交換鏈完成對應圖像的讀取操作。這可以通過等待顏色附件輸出的階段來實現。
dependency.dstStageMask = VK_PIPELINE_STAGE_COLOR_ATTACHMENT_OUTPUT_BIT;
dependency.dstAccessMask = VK_ACCESS_COLOR_ATTACHMENT_READ_BIT | VK_ACCESS_COLOR_ATTACHMENT_WRITE_BIT;
在顏色附件階段的操作及涉及顏色附件的讀取和寫入的操作應該等待。這些設置將阻止轉換發生,直到實際需要(並允許):當我們需要寫入顏色時候。
renderPassInfo.dependencyCount = 1; renderPassInfo.pDependencies = &dependency;
VkRenderPassCreateInfo結構體有兩個字段指定依賴的數組。
Presentation
繪制幀最后一個步驟是將結果提交到交換鏈,使其最終顯示在屏幕上。Presentation通過VkPresentInfoKHR結構體配置,具體位置在drawFrame函數最后。
VkPresentInfoKHR presentInfo = {}; presentInfo.sType = VK_STRUCTURE_TYPE_PRESENT_INFO_KHR; presentInfo.waitSemaphoreCount = 1; presentInfo.pWaitSemaphores = signalSemaphores;
前兩個參數指定在進行presentation之前要等待的信號量,就像VkSubmitInfo一樣。
VkSwapchainKHR swapChains[] = {swapChain}; presentInfo.swapchainCount = 1; presentInfo.pSwapchains = swapChains; presentInfo.pImageIndices = &imageIndex;
接下來的兩個參數指定用於提交圖像的交換鏈和每個交換鏈圖像索引。大多數情況下僅一個。
presentInfo.pResults = nullptr; // Optional
最后一個可選參數pResults,它允許指定一組VkResult值,以便在presentation成功時檢查每個獨立的交換鏈。如果只使用單個交換鏈,則不需要,因為可以簡單的使用當前函數的返回值。
vkQueuePresentKHR(presentQueue, &presentInfo);
vkQueuePresentKHR函數提交請求呈現交換鏈中的圖像。我們在下一個章節為vkAcquireNextImageKHR和vkQueuePresentKHR可以添加錯誤處理。因為它們失敗並不一定意味着程序應該終止,與我們迄今為止看到的功能不同。
如果一切順利,當再次運行程序時候,應該可以看到一下內容:
遺憾的是,只要程序關閉,由於開啟了validation layers你將會看到程序崩潰的信息。從終端控制台打印的信息來源debugCallback,告訴了我們具體的原因:
需要了解的是drawFrame函數中所有的操作都是異步的。意味着當程序退出mainLoop,繪制和呈現操作可能仍然在執行。所以清理該部分的資源是不友好的。
為了解決這個問題,我們應該在退出mainLoop銷毀窗體前等待邏輯設備的操作完成:
void mainLoop() { while (!glfwWindowShouldClose(window)) { glfwPollEvents(); drawFrame(); } vkDeviceWaitIdle(device); }
也可以使用vkQueueWaitIdle等待特定命令隊列中的操作完成。這些功能可以作為一個非常基本的方式來執行同步。這個時候窗體關閉后該問題不會出現。
Memory leak
如果運行時啟用了validation layers並監視應用程序的內存使用情況,你會發現它在慢慢增加。原因是validation layers的實現期望與GPU同步。雖然在技術上是不需要的,但是一旦這樣做,每一針幀不會出現明顯的性能影響。
我們可以在開始繪制下一幀之前明確的等待presentation完成:
void drawFrame() { ... vkQueuePresentKHR(presentQueue, &presentInfo); vkQueueWaitIdle(presentQueue); }
在很多應用程序的的狀態也會在每一幀更新。為此更高效的繪制一陣的方式如下:
void drawFrame() { updateAppState(); vkQueueWaitIdle(presentQueue);
vkAcquireNextImageKHR(...) submitDrawCommands(); vkQueuePresentKHR(presentQueue, &presentInfo); }
該方法允許我們更新應用程序的狀態,比如運行游戲的AI協同程序,而前一幀被渲染。這樣,始終保持CPU和GPU處於工作狀態。
Conclusion
大約800行代碼之后,我們終於看到了三角形繪制在屏幕上!Vulkan引導程序需要很多的工作要去做,但好處是Vulkan通過要求每一個明確的實現,帶來了了巨大的控制權。建議花費一些時間重新讀代碼,並建立一個思維導圖模型,目的在於了解Vulkan中每一個對象,以及它們的互相的關系。之后我們將會基於這個基礎構建擴展程序功能。
在下一章節中,我們將細化Vulkan程序中的一些細節,使其表現更穩定。
項目代碼 GitHub 地址。