Ch2-1 Rendering Loop
本节当中我们将构建一个最基本的渲染循环(rendering loop)。
就我的写法而言,一个最基本的渲染循环应该是:
1.通过等待一个栅栏,确保前一次的命令已执行完毕(以便接下来覆写同一命令缓冲区)
2.获取交换链图像索引,然后置位信号量A
3.录制命令缓冲区
4.提交命令缓冲区,等待信号量A,命令执行完后置位信号量B和栅栏
5.等待信号量B,确保本次的命令执行完毕后,呈现图像
创建栅栏和信号量
信号量(semaphore)和栅栏(fence)是用于同步的Vulkan对象。
参见Ch3-1 同步原语,我在该节中解释了何为栅栏和信号量,并对它们进行了简单封装,请先阅览该节中的Fence和Semaphore两小节,并完成封装,请务必弄明白封装封装栅栏的类fence和信号量的类semaphore的使用方式(是很简单的C++代码,边抄边读一遍即可)。
在主函数中创建一个栅栏和两个信号量:
using namespace vulkan; //main.cpp里会写一堆vulkan命名空间下的类型,using命名空间以省事 int main() { if (!InitializeWindow({1280,720})) return -1; fence fence(VK_FENCE_CREATE_SIGNALED_BIT); //以置位状态创建栅栏 semaphore semaphore_imageIsAvailable; semaphore semaphore_renderingIsOver; while (!glfwWindowShouldClose(pWindow)) { /*渲染过程,待填充*/ glfwPollEvents(); TitleFps(); } TerminateWindow(); return 0; }
-
fence在渲染完成后被置位。
-
我打算在渲染循环的开头等待栅栏被置位,因此以置位状态创建fence(为了在首次执行渲染循环时能完成等待)。
-
semaphore_imageIsAvailable在取得交换链图像后被置位,在执行命令前等待它。
-
semaphore_renderingIsOver在渲染完成后被置位,在呈现图像前等待它。
在渲染完成后置位fence和semaphore_renderingIsOver两个同步对象(而非一个)的原因在于它们用法不同。
开始录制命令缓冲区前需要在CPU一侧手动等待fence被置位以确保先前的命令已完成执行,虽然执行完命令后同样也会置位semaphore_renderingIsOver,但你不能在CPU一侧手动等待二值信号量。
而用vkQueuePresentKHR(...)呈现图像时,其参数中只能指定需要等待被置位的二值信号量而不能指定栅栏,因此才出现了一个操作后需要置位两个同步对象的情况。
为什么在获取下一张交换链图像前等待fence,而不是在录制命令缓冲区前?
如先前所言,等待fence被置位,是为确保先前的命令已完成执行,以便接下来覆写同一命令缓冲区。那么为什么不能将等待它这一步,延后到录制命令缓冲区前?
为了简化叙事,我在每一帧当中使用了同一套同步对象(你当然可以多创建几套)。
假设在获取交换链图像后等待fence,那么:考虑到“获取下一张交换链图像”这一步所需的同步对象是semaphore_imageIsAvailable,而如果前一帧的命令非常轻量,很快录制完毕的话,很可能在本次的“获取交换链图像”时,前一帧的“在执行命令前等待信号量”还没结束,即semaphore_imageIsAvailable可能仍被前一帧占用着。
等待fence最主要的目的是确保命令执行完毕,但稍做进一步推理,显然也会顺带确保semaphore_imageIsAvailable和semaphore_renderingIsOver已不再被前一帧占用。
你也可以以非置位状态创建fence,然后有两个选项:
1.在呈现图像后等待它,即从下一循环的开头移动到当前循环的最后,执行逻辑上是一致的,同步开销也没有差别。
2.在呈现图像前等待它,如此一来semaphore_renderingIsOver就是多余的。
等待栅栏并非异步(见后文),同步粒度比信号量粗(因为栅栏是在CPU一侧调用函数等待GPU发来信号嘛),这是否有可见的影响当然与硬件及Vulkan的底层实现有关,而这是否可忽略则取决于你的需求。
注意,“在GPU侧等待/置位信号量 ”和“在GPU侧置位栅栏”,这类行为是相对于CPU侧异步发生的,进一步推理,命令的执行也是异步的,这是Vulkan与OpenGL的主要不同点之一。而“等待栅栏”则是相对于CPU侧同步的(意味着要求CPU侧等待到栅栏被置位或超时,然后才能执行后续程序)。
如果你对上述说明中的异步还是没有确切概念,来么,先来更新主函数,以此为例:
using namespace vulkan; int main() { if (!InitializeWindow({1280,720})) return -1; fence fence(VK_FENCE_CREATE_SIGNALED_BIT); semaphore semaphore_imageIsAvailable; semaphore semaphore_renderingIsOver; while (!glfwWindowShouldClose(pWindow)) { /*假定这里有同步执行的A部分代码*/ //等待并重置fence fence.WaitAndReset(); /*假定这里有异步执行的B部分代码*/ glfwPollEvents(); TitleFps(); } TerminateWindow(); return 0; }
在CPU侧执行fence.WaitAndReset();
前的A部分代码时,GPU侧可能正在做其后的B部分代码要求做的事。
比如,执行glfwWindowShouldClose(pWindow)
时,GPU可能正在执行B部分代码中的绘制命令。
(太啰嗦吗?虽然我想看这个教程的人大多知道什么是异步,姑且以防万一)
获取交换链图像索引
用vkAcquireNextImageKHR(...)获取下一张用于渲染的交换链图像的索引:
VkResult VKAPI_CALL vkAcquireNextImageKHR(...) 的参数说明 |
|
---|---|
VkDevice device |
逻辑设备的handle |
VkSwapchainKHR swapchain |
交换链的handle |
uint64_t timeout |
超时时间,单位为纳秒,若无限制,将其指定为UINT64_MAX |
VkSemaphore semaphore |
成功获取图像索引,且所获取的图像可被安全读写后,需被置位的二值信号量 |
VkFence fence |
成功获取图像索引,且所获取的图像可被安全读写后,需被置位的栅栏 |
uint32_t* pImageIndex |
若函数执行成功,将获取到的交换链图像索引写入*pImageIndex |
-
semaphore和fence中至少有一个不得为VK_NULL_HANDLE。
-
函数执行成功时会立刻更新*pImageIndex,但呈现引擎可能还在读取所获取的图像,semaphore和fence会在呈现引擎不再使用该图像后(很可能在函数返回后)被置位。
-
若在timeout指定的时限内无法取得任何一张可用于渲染的图像,则返回VK_TIMEOUT。
-
若在timeout指定为0且无法当即取得任何一张可用于渲染的图像,则返回VK_NOT_READY。
-
对于需要重建交换链的情况,可能返回VK_SUBOPTIMAL_KHR(窗口相关参数改变但仍能获取到图像)或VK_ERROR_OUT_OF_DATE_KHR(无法获取到图像)。
可同时提供栅栏和信号量,但只需一个即可。
若选择只提供栅栏,那么必须在提交命令前在CPU一侧手动等待栅栏被置位;若选择只提供信号量,则能在命令执行到特定阶段前等待信号量被置位,换言之信号量的同步粒度更精细,因为我选择只提供信号量。
需要注意的是,获取到的图像索引未必依序而来。
举例而言,如果交换链中一共有三张图像,vkAcquireNextImageKHR(...)可能总是按0、1、2的顺序获取图像,也可能总是按0、2、1的顺序。更极端的是,其顺序可能在每个周期(这里以交换链图像个数次渲染循环为一个周期)中不同,比如在先按0、1、2的顺序获取并呈现图像,然后在下一个周期中按0、2、1的顺序获取到图像,但这种情况理论上只在待获取图像可能超过一张的情况下发生。
因此,在渲染循环中总是应当使用graphicsBase::Base.CurrentImageIndex()来获取当前图像索引,不要使用一个总是从0到2(假定图像数量为3张)递增的变量。
于是在graphicsBase中加入以下内容:
private: //当前取得的交换链图像索引 uint32_t currentImageIndex = 0; public: //Getter uint32_t CurrentImageIndex() const { return currentImageIndex; } //该函数用于获取交换链图像索引到currentImageIndex,以及在需要重建交换链时调用RecreateSwapchain()、重建交换链后销毁旧交换链 result_t SwapImage(VkSemaphore semaphore_imageIsAvailable) { //销毁旧交换链(若存在) if (swapchainCreateInfo.oldSwapchain && swapchainCreateInfo.oldSwapchain != swapchain) { vkDestroySwapchainKHR(device, swapchainCreateInfo.oldSwapchain, nullptr); swapchainCreateInfo.oldSwapchain = VK_NULL_HANDLE; } //获取交换链图像索引 while (VkResult result = vkAcquireNextImageKHR(device, swapchain, UINT64_MAX, semaphore_imageIsAvailable, VK_NULL_HANDLE, ¤tImageIndex)) switch (result) { case VK_SUBOPTIMAL_KHR: case VK_ERROR_OUT_OF_DATE_KHR: if (VkResult result = RecreateSwapchain()) return result; break; //注意重建交换链后仍需要获取图像,通过break递归,再次执行while的条件判定语句 default: outStream << std::format("[ graphicsBase ] ERROR\nFailed to acquire the next image!\nError code: {}\n", int32_t(result)); return result; } return VK_SUCCESS; }
-
在RecreateSwapchain()中,旧交换链的handle被记录在swapchainCreateInfo.oldSwapchain,所以销毁旧交换链的首要条件是验证swapchainCreateInfo.oldSwapchain是否为空。旧交换链应该存活至创建新交换链成功为止,由于RecreateSwapchain()执行失败时不会覆写成员变量swapchain,因此通过判断swapchainCreateInfo.oldSwapchain是否等于swapchain,来判断前一帧中RecreateSwapchain()是否执行成功。两个条件中间添加&&来短路执行。
-
由于超时时间被指定为UINT64_MAX,不需要考虑返回VK_TIMEOUT和VK_NOT_READY的情况
如之前在Ch1-4 创建交换链中所说,不能在重建交换链后立刻销毁旧交换链。SwapImage(...)中的逻辑是,若在当前帧重建交换链,那么在下一帧销毁交换链(这也是为什么重建交换链后使用了break来再次执行while,而非递归地调用SwapImage(...))。
返回VK_SUBOPTIMAL_KHR的情况下,信号量可能会被置位,这会导致验证层在第二次调用vkAcquireNextImageKHR(...)时报错,但并不影响后续执行逻辑:因为是新创建的交换链图像,不需要等呈现引擎把它吐出来,已经置位的信号量就这么保留置位即可(要消掉这时的报错,代码改起来很烦,就不改了)。
主函数更新为:
int main() { if (!InitializeWindow({1280,720})) return -1; fence fence(VK_FENCE_CREATE_SIGNALED_BIT); semaphore semaphore_imageIsAvailable; semaphore semaphore_renderingIsOver; while (!glfwWindowShouldClose(pWindow)) { //等待并重置fence fence.WaitAndReset(); //获取交换链图像索引 graphicsBase::Base().SwapImage(semaphore_imageIsAvailable); /*待后续填充*/ glfwPollEvents(); TitleFps(); } TerminateWindow(); return 0; }
创建命令池和命令缓冲区
同OpenGL或DirectX11这类API不同,DirectX12、Vulkan等底层API中的命令被录制在命令缓冲区(command buffer)中,而命令缓冲区从命令池(command pool)分配。
本节中还不会录制任何实际的命令,仅为完成渲染循环,需要先写好开始录制命令、结束录制命令、提交命令缓冲区用的代码。
参见Ch3-5 命令缓冲区,我在该节中对命令缓冲区和命令池做了简单封装,请先阅览该节(关于二级命令缓冲区的部分请暂且无视),并完成封装,请务必弄明白封装命令缓冲区的类commandBuffer和命令池的类commandPool的使用方式。
在主函数中创建一个命令池,并从中分配一个命令缓冲区:
int main() { if (!InitializeWindow({1280,720})) return -1; fence fence(VK_FENCE_CREATE_SIGNALED_BIT); semaphore semaphore_imageIsAvailable; semaphore semaphore_renderingIsOver; commandBuffer commandBuffer; commandPool commandPool(graphicsBase::Base().QueueFamilyIndex_Graphics(), VK_COMMAND_POOL_CREATE_RESET_COMMAND_BUFFER_BIT); commandPool.AllocateBuffers(commandBuffer); while (!glfwWindowShouldClose(pWindow)) { fence.WaitAndReset(); graphicsBase::Base().SwapImage(semaphore_imageIsAvailable); /*待后续填充*/ glfwPollEvents(); TitleFps(); } TerminateWindow(); return 0; }
录制及提交命令缓冲区
调用函数开始命令缓冲区的录制,中间渲染过程留待填充,然后结束命令缓冲区的录制:
int main() { if (!InitializeWindow({1280,720})) return -1; fence fence(VK_FENCE_CREATE_SIGNALED_BIT); semaphore semaphore_imageIsAvailable; semaphore semaphore_renderingIsOver; commandBuffer commandBuffer; commandPool commandPool(graphicsBase::Base().QueueFamilyIndex_Graphics(), VK_COMMAND_POOL_CREATE_RESET_COMMAND_BUFFER_BIT); commandPool.AllocateBuffers(commandBuffer); while (!glfwWindowShouldClose(pWindow)) { fence.WaitAndReset(); graphicsBase::Base().SwapImage(semaphore_imageIsAvailable); commandBuffer.Begin(VK_COMMAND_BUFFER_USAGE_ONE_TIME_SUBMIT_BIT); /*渲染命令,待填充*/ commandBuffer.End(); /*提交命令缓冲区,待后续填充*/ /*呈现图像,待后续填充*/ glfwPollEvents(); TitleFps(); } TerminateWindow(); return 0; }
用vkQueueSubmit(...)提交命令缓冲区,相关参数已经在Ch3-5 命令缓冲区中叙述,现对其进行封装,在graphicsBase中加入多个成员函数:
public: //该函数用于将命令缓冲区提交到用于图形的队列 result_t SubmitCommandBuffer_Graphics(VkSubmitInfo& submitInfo, VkFence fence = VK_NULL_HANDLE) const { submitInfo.sType = VK_STRUCTURE_TYPE_SUBMIT_INFO; VkResult result = vkQueueSubmit(queue_graphics, 1, &submitInfo, fence); if (result) outStream << std::format("[ graphicsBase ] ERROR\nFailed to submit the command buffer!\nError code: {}\n", int32_t(result)); return result; } //该函数用于在渲染循环中将命令缓冲区提交到图形队列的常见情形 result_t SubmitCommandBuffer_Graphics(VkCommandBuffer commandBuffer, VkSemaphore semaphore_imageIsAvailable = VK_NULL_HANDLE, VkSemaphore semaphore_renderingIsOver = VK_NULL_HANDLE, VkFence fence = VK_NULL_HANDLE, VkPipelineStageFlags waitDstStage_imageIsAvailable = VK_PIPELINE_STAGE_COLOR_ATTACHMENT_OUTPUT_BIT) const { VkSubmitInfo submitInfo = { .commandBufferCount = 1, .pCommandBuffers = &commandBuffer }; if (semaphore_imageIsAvailable) submitInfo.waitSemaphoreCount = 1, submitInfo.pWaitSemaphores = &semaphore_imageIsAvailable, submitInfo.pWaitDstStageMask = &waitDstStage_imageIsAvailable; if (semaphore_renderingIsOver) submitInfo.signalSemaphoreCount = 1, submitInfo.pSignalSemaphores = &semaphore_renderingIsOver; return SubmitCommandBuffer_Graphics(submitInfo, fence); } //该函数用于将命令缓冲区提交到用于图形的队列,且只使用栅栏的常见情形 result_t SubmitCommandBuffer_Graphics(VkCommandBuffer commandBuffer, VkFence fence = VK_NULL_HANDLE) const { VkSubmitInfo submitInfo = { .commandBufferCount = 1, .pCommandBuffers = &commandBuffer }; return SubmitCommandBuffer_Graphics(submitInfo, fence); } //该函数用于将命令缓冲区提交到用于计算的队列 result_t SubmitCommandBuffer_Compute(VkSubmitInfo& submitInfo, VkFence fence = VK_NULL_HANDLE) const { submitInfo.sType = VK_STRUCTURE_TYPE_SUBMIT_INFO; VkResult result = vkQueueSubmit(queue_compute, 1, &submitInfo, fence); if (result) outStream << std::format("[ graphicsBase ] ERROR\nFailed to submit the command buffer!\nError code: {}\n", int32_t(result)); return result; } //该函数用于将命令缓冲区提交到用于计算的队列,且只使用栅栏的常见情形 result_t SubmitCommandBuffer_Compute(VkCommandBuffer commandBuffer, VkFence fence = VK_NULL_HANDLE) const { VkSubmitInfo submitInfo = { .commandBufferCount = 1, .pCommandBuffers = &commandBuffer }; return SubmitCommandBuffer_Compute(submitInfo, fence); }
-
在渲染循环中将命令缓冲区提交到图形队列时,若不需要做深度或模板测试,最迟可以在VK_PIPELINE_STAGE_COLOR_ATTACHMENT_OUTPUT_BIT阶段等待获取到交换链图像,渲染结果在该阶段被写入到交换链图像。
-
包含图形命令的命令缓冲区可能不带任何信号量(在渲染循环之外完全有理由这么做),而只包含数据转移命令的话通常也不会带任何信号量(就使用情形而言多是在“加载”这一环节中),数据转移命令可以被提交给图形或计算队列。
向queue_presentation提交命令缓冲区的情况,仅限于用于呈现的队列族与用于图形的队列族不一致时,见本节的最后一小节。
主函数更新为:
int main() { if (!InitializeWindow({1280,720})) return -1; fence fence(VK_FENCE_CREATE_SIGNALED_BIT); semaphore semaphore_imageIsAvailable; semaphore semaphore_renderingIsOver; commandBuffer commandBuffer; commandPool commandPool(graphicsBase::Base().QueueFamilyIndex_Graphics(), VK_COMMAND_POOL_CREATE_RESET_COMMAND_BUFFER_BIT); commandPool.AllocateBuffers(commandBuffer); while (!glfwWindowShouldClose(pWindow)) { fence.WaitAndReset(); graphicsBase::Base().SwapImage(semaphore_imageIsAvailable); commandBuffer.Begin(VK_COMMAND_BUFFER_USAGE_ONE_TIME_SUBMIT_BIT); /*渲染命令,待填充*/ commandBuffer.End(); graphicsBase::Base().SubmitCommandBuffer_Graphics(commandBuffer, semaphore_imageIsAvailable, semaphore_renderingIsOver, fence); /*呈现图像,待填充*/ glfwPollEvents(); TitleFps(); } TerminateWindow(); return 0; }
呈现图像
用vkQueuePresentKHR(...)呈现图像:
VkResult VKAPI_CALL vkQueuePresentKHR(...) 的参数说明 |
|
---|---|
VkQueue queue |
队列的handle |
const VkPresentInfoKHR* pPresentInfo |
指向呈现信息 |
struct VkPresentInfoKHR 的成员说明 |
|
---|---|
VkStructureType sType |
结构体的类型,本处必须是VK_STRUCTURE_TYPE_PRESENT_INFO_KHR |
const void* pNext |
如有必要,指向一个用于扩展该结构体的结构体 |
uint32_t waitSemaphoreCount |
所需等待被置位的信号量的个数 |
const VkSemaphore* pWaitSemaphores |
指向所需等待被置位的信号量的数组 |
uint32_t swapchainCount |
有图像需要被呈现的交换链的个数 |
const VkSwapchainKHR* pSwapchains |
指向有图像需要被呈现的交换链的数组 |
const uint32_t* pImageIndices |
指向各个交换链中需要被呈现的图像的索引构成的数组 |
VkResult* pResults |
若非nullptr,将对各个交换链中各图像的呈现结果写入pResults所指数组 |
多交换链适用于程序有多个窗口的情况,由于我们的程序只有一个交换链,可以直接从vkQueuePresentKHR(...)的返回值得知其执行结果,pResults填写为nullptr。
vkQueuePresentKHR(...)同vkAcquireNextImageKHR(...)一样,可能会遭遇需要重建交换链的情况,因此在graphicsBase中对其做如下封装:
result_t PresentImage(VkPresentInfoKHR& presentInfo) { presentInfo.sType = VK_STRUCTURE_TYPE_PRESENT_INFO_KHR; switch (VkResult result = vkQueuePresentKHR(queue_presentation, &presentInfo)) { case VK_SUCCESS: return VK_SUCCESS; case VK_SUBOPTIMAL_KHR: case VK_ERROR_OUT_OF_DATE_KHR: return RecreateSwapchain(); default: outStream << std::format("[ graphicsBase ] ERROR\nFailed to queue the image for presentation!\nError code: {}\n", int32_t(result)); return result; } } //该函数用于在渲染循环中呈现图像的常见情形 result_t PresentImage(VkSemaphore semaphore_renderingIsOver = VK_NULL_HANDLE) { VkPresentInfoKHR presentInfo = { .swapchainCount = 1, .pSwapchains = &swapchain, .pImageIndices = ¤tImageIndex }; if (semaphore_renderingIsOver) presentInfo.waitSemaphoreCount = 1, presentInfo.pWaitSemaphores = &semaphore_renderingIsOver; return PresentImage(presentInfo); }
-
跟SwapImage(...)中的情形不同,PresentImage(...)在重建交换链后直接返回,这会导致必定丢1帧。要保留这1帧的话,得在重建交换链后再获取交换链图像、呈现图像,考虑到获取交换链图像时还要创建临时的同步对象,代码会写得比较麻烦,按下不表。
渲染循环更新为:
while (!glfwWindowShouldClose(pWindow)) { fence.WaitAndReset(); graphicsBase::Base().SwapImage(semaphore_imageIsAvailable); commandBuffer.Begin(VK_COMMAND_BUFFER_USAGE_ONE_TIME_SUBMIT_BIT); /*渲染命令,待填充*/ commandBuffer.End(); graphicsBase::Base().SubmitCommandBuffer_Graphics(commandBuffer, semaphore_imageIsAvailable, semaphore_renderingIsOver, fence); graphicsBase::Base().PresentImage(semaphore_renderingIsOver); glfwPollEvents(); TitleFps(); }
在窗口最小化时暂停渲染循环
在窗口最小化到任务栏时继续渲染循环,可能收到来自验证层的报错信息:
Validation Error: [ VUID-vkQueueSubmit-pWaitSemaphores-03238 ]恢复窗口大小后会持续收到另一个报错信息:
Validation Error: [ VUID-vkAcquireNextImageKHR-swapchain-01802 ]尽管上述情况可能不影响程序的正常执行,以防万一之外,也出于节省CPU和GPU占用的考量,有必要在窗口最小化时阻塞渲染循环。
这可以通过一串简单的代码来实现:
while (!glfwWindowShouldClose(pWindow)) { //新增------------------------------------ while (glfwGetWindowAttrib(pWindow, GLFW_ICONIFIED)) glfwWaitEvents(); //---------------------------------------- fence.WaitAndReset(); graphicsBase::Base().SwapImage(semaphore_imageIsAvailable); commandBuffer.Begin(VK_COMMAND_BUFFER_USAGE_ONE_TIME_SUBMIT_BIT); /*渲染命令,待填充*/ commandBuffer.End(); graphicsBase::Base().SubmitCommandBuffer_Graphics(commandBuffer, semaphore_imageIsAvailable, semaphore_renderingIsOver, fence); graphicsBase::Base().PresentImage(semaphore_renderingIsOver); glfwPollEvents(); TitleFps(); }
-
使用glfwGetWindowAttrib(GLFWWindow* window, int attrib)检查窗口是否具有某个性质,若具有性质则返回值非0。这里GLFW_ICONIFIED表示窗口是否被图标化(最小化到任务栏时只有图标)。
-
用glfwWaitEvents()等待事件,因为接收到的事件不会只有“恢复窗口大小”一种(比如键鼠输入),所以需要while循环反复判定窗口是否被图标化,直到接收到恢复窗口大小的事件为止。
最后一件事:等待栅栏的时机
前面将“等待栅栏”放在渲染循环开头,是为了方便说明其与“获取交换链图像”的先后关系,及说明以置位状态创建栅栏的意义所在。
这么做有个缺陷,考虑如下情况:
while (!glfwWindowShouldClose(pWindow)) { while (glfwGetWindowAttrib(pWindow, GLFW_ICONIFIED)) glfwWaitEvents(); fence.WaitAndReset(); /*在这里将上一帧的命令执行结果拷贝到CPU侧*/ graphicsBase::Base().SwapImage(semaphore_imageIsAvailable); commandBuffer.Begin(VK_COMMAND_BUFFER_USAGE_ONE_TIME_SUBMIT_BIT); /*命令*/ commandBuffer.End(); graphicsBase::Base().SubmitCommandBuffer_Graphics(commandBuffer, semaphore_imageIsAvailable, semaphore_renderingIsOver, fence); graphicsBase::Base().PresentImage(semaphore_renderingIsOver); glfwPollEvents(); TitleFps(); }
要将上一帧的命令执行结果拷贝到CPU侧,当然得等上一帧的命令执行完,即等待到fence之后。
而在首次执行渲染循环时,若如上述代码这般在执行命令前进行拷贝,因为没有“上一帧”,要么无从拷贝,要么得在代码中对首次执行做特殊处理。
因此把fence.WaitAndReset();
和得在命令执行完后的干的事,统统移动到循环末尾,在代码逻辑上会比较省事(但未必总是合适,见后文即时帧)。
于是,至此为止主函数为:
int main() { if (!InitializeWindow({1280,720})) return -1; fence fence; //以非置位状态创建栅栏 semaphore semaphore_imageIsAvailable; semaphore semaphore_renderingIsOver; commandBuffer commandBuffer; commandPool commandPool(graphicsBase::Base().QueueFamilyIndex_Graphics(), VK_COMMAND_POOL_CREATE_RESET_COMMAND_BUFFER_BIT); commandPool.AllocateBuffers(commandBuffer); while (!glfwWindowShouldClose(pWindow)) { while (glfwGetWindowAttrib(pWindow, GLFW_ICONIFIED)) glfwWaitEvents(); graphicsBase::Base().SwapImage(semaphore_imageIsAvailable); commandBuffer.Begin(VK_COMMAND_BUFFER_USAGE_ONE_TIME_SUBMIT_BIT); /*渲染命令,待填充*/ commandBuffer.End(); graphicsBase::Base().SubmitCommandBuffer_Graphics(commandBuffer, semaphore_imageIsAvailable, semaphore_renderingIsOver, fence); graphicsBase::Base().PresentImage(semaphore_renderingIsOver); glfwPollEvents(); TitleFps(); fence.WaitAndReset(); } TerminateWindow(); return 0; }
接下来是非必要的选读内容。
即时帧
上述渲染循环代码中,每一帧所用的同步对象是相同的,这意味着必须渲染完上一帧才能渲染当前帧。
然而,考虑到交换链中存在多张图像,既然当前帧和上一帧所写入的图像不同,在渲染当前帧时何必等待渲染完上一帧呢?
通过给交换链中的每张图像创建一套专用的同步对象、命令缓冲区、帧缓冲,以及其他一切在循环的单帧中会被更新、写入的Vulkan对象,以此在渲染每一帧图像的过程中避免资源竞争,减少阻塞,这种做法叫做即时帧(frames in flight)。
即时帧的好处显而易见,对每张交换链图像的写入,只需发生在呈现引擎上一次读取同一图像之后即可,假设交互链图像有3张,那么便是与2帧之前的操作同步,相比与上一帧同步,大幅提升了并行度。
但是即时帧的设备内存开销也是成倍增加的,由于所有会被更新、写入的Vulkan对象都得创建与交换链图像相同的份数,在一些情况下会产生惊人的内存开销(比如延迟渲染)。而且即便应用了即时帧也未必能大幅提升帧数,如果每帧的渲染时间都很短的话,即时帧不会对帧数产生影响,换言之只应该在每帧渲染时间较长的情况下应用即时帧(起码应该要长于屏幕的垂直刷新间隔)。
后续教程中不会应用即时帧,仅在此略作知会。除了内存开销外,代码会被写更复杂难看也是个原因(会出现一堆用于存放Vulkan对象的vector和用来创建这些对象的循环代码,虽然可以通过封装每一套Vulkan对象或者某些更省事的奇怪手段来简化)。
如果你想应用即时帧,那么渲染循环应被改成:
int main() { if (!InitializeWindow({1280,720})) return -1; struct perFrameObjects_t { fence fence = { VK_FENCE_CREATE_SIGNALED_BIT }; //直接 = VK_FENCE_CREATE_SIGNALED_BIT也行,这里按照我的编程习惯在初始化类/结构体时保留花括号 semaphore semaphore_imageIsAvailable; semaphore semaphore_renderingIsOver; commandBuffer commandBuffer; }; std::vector<perFrameObjects_t> perFrameObjects(graphicsBase::Base().SwapchainImageCount()); commandPool commandPool(graphicsBase::Base().QueueFamilyIndex_Graphics(), VK_COMMAND_POOL_CREATE_RESET_COMMAND_BUFFER_BIT); for (auto& i : perFrameObjects) commandPool.AllocateBuffers(i.commandBuffer); uint32_t i = 0; while (!glfwWindowShouldClose(pWindow)) { while (glfwGetWindowAttrib(pWindow, GLFW_ICONIFIED)) glfwWaitEvents(); const auto& [fence, semaphore_imageIsAvailable, semaphore_renderingIsOver, commandBuffer] = perFrameObjects[i]; i = (i + 1) % graphicsBase::Base().SwapchainImageCount(); fence.WaitAndReset(); //在渲染循环开头等待与当前交换链图像配套的栅栏(在渲染循环末尾等待的话,下一帧必须等待当前帧渲染完,即时帧的做法便没了意义) graphicsBase::Base().SwapImage(semaphore_imageIsAvailable); commandBuffer.Begin(VK_COMMAND_BUFFER_USAGE_ONE_TIME_SUBMIT_BIT); /*渲染命令*/ commandBuffer.End(); graphicsBase::Base().SubmitCommandBuffer_Graphics(commandBuffer, semaphore_imageIsAvailable, semaphore_renderingIsOver, fence); graphicsBase::Base().PresentImage(semaphore_renderingIsOver); glfwPollEvents(); TitleFps(); } TerminateWindow(); return 0; }
假定获取到的交换链图像索引在每个周期中有固定顺序,那么虽然这里的i可能跟当前图像索引不同(如果获取到的交换链图像索引总是0、1、2依序而来,那么i跟当前图像索引一致),但i总是跟图像索引一一对应(也就是一种映射关系)。
如先前获取交换链图像索引所述,Vulkan标准对交换链图像索引在每个周期中的顺序并无要求。那么上述代码是否还能确保执行逻辑不出错呢?
考虑比较极端的情况:如果连续两次获取到索引为0的交换链图像,而你的同步对象按0、1的索引依序使用。既然是连续两次渲染到同一交换链图像,后一次得等待前一次命令执行完成,可前一次点亮的是0号栅栏,后一次等待的为1号栅栏,这么一来同步不就出问题了吗!?
上述情况没有什么值得你担心的:
只有在将图像交还给呈现引擎后(此时已通过信号量确保已完成了先前在该图像上的渲染),vkAcquireNextImageKHR(...)才能获取到与该图像对应的索引。
因此,前文的代码在获取到的交换链图像索引是乱序时也不会有问题,只是也难以减少阻塞。
队列族所有权转移
若这一小节里有任何看不懂的地方,请看过往后的章节后再回过来看。
这套教程中不会做队列族所有权转移(日后更新说不准),因为实际上你总能找到一个同时支持图形、呈现、计算的队列族(虽然我不敢100%肯定,但至少用来开发图形程序的PC不应该糟糕到连这都无法满足),仅在这里简单演示一下,从用于图形的队列将所有权转移到用于呈现的队列。
通常而言,出于对资源访问效率的考虑,资源在某一时刻只应被单一队列族独占访问,并在创建资源时将sharingMode指定为VK_SHARING_MODE_EXCLUSIVE。当这样的资源要被另一队列族访问时,应当事先显式地告知Vulkan。
队列所有权转移的必要性为同时满足以下两点:
1.一块物理设备内存区域先后被两个不同队列族中的队列使用
2.后使用该资源的队列需要保留其中的数据
举例而言,若用于图形的队列QG与用于呈现的队列QP不同,从QG到QP需要做队列族所有权转移,因为呈现需要读取已渲染完的图像数据,而从QP到QG则不需要,因为渲染一张新图像时,不需要保留先前的内容。
队列族所有权转移是内存屏障的功能之一,内存屏障参见Pipeline Barrier
只要内存屏障的参数中,srcQueueFamilyIndex和dstQueueFamilyIndex不同,那么资源的队列族所有权就会被转移。
首先来看下Vulkan官方示例中的做法:
以下内存屏障录制在被提交到图形队列的命令缓冲区末尾,使得图形队列释放图像所有权:
uint32_t currentImageIndex = graphicsBase::Base().CurrentImageIndex(); VkImageMemoryBarrier imageMemoryBarrier_g2p_release = { .sType = VK_STRUCTURE_TYPE_IMAGE_MEMORY_BARRIER, .srcAccessMask = VK_ACCESS_COLOR_ATTACHMENT_WRITE_BIT, .dstAccessMask = 0,//因为vkCmdPipelineBarrier(...)的参数中dstStage是BOTTOM_OF_PIPE,不需要dstAccessMask .oldLayout = VK_IMAGE_LAYOUT_PRESENT_SRC_KHR,//内存布局已经在渲染通道结束时转换(这是下一节的内容),此处oldLayout和newLayout相同,不发生转变 .newLayout = VK_IMAGE_LAYOUT_PRESENT_SRC_KHR, .srcQueueFamilyIndex = graphicsBase::Base().QueueFamilyIndex_Graphics(), .dstQueueFamilyIndex = graphicsBase::Base().QueueFamilyIndex_Presentation(), .image = graphicsBase::Base().SwapchainImage(currentImageIndex), .subresourceRange = { VK_IMAGE_ASPECT_COLOR_BIT, 0, 1, 0, 1 } }; vkCmdPipelineBarrier(commandBuffer, VK_PIPELINE_STAGE_COLOR_ATTACHMENT_OUTPUT_BIT,VK_PIPELINE_STAGE_BOTTOM_OF_PIPE_BIT, 0, 0, nullptr, 0, nullptr, 1, &imageMemoryBarrier_g2p_release);
-
这里使用的srcStage是VK_PIPELINE_STAGE_COLOR_ATTACHMENT_OUTPUT_BIT,srcAccessMask是VK_ACCESS_COLOR_ATTACHMENT_WRITE_BIT,说明释放所有权必须发生在将渲染结果输出到图像之后。
以下内存屏障录制在被提交到呈现队列的命令缓冲区中,使得呈现队列获得图像所有权:
//这个VkImageMemoryBarrier可以跟上文的一模一样 VkImageMemoryBarrier imageMemoryBarrier_g2p_acquire = { .sType = VK_STRUCTURE_TYPE_IMAGE_MEMORY_BARRIER, //因为srcStage是VK_PIPELINE_STAGE_TOP_OF_PIPE_BIT,不需要srcAccessMask //因为dstStage是VK_PIPELINE_STAGE_BOTTOM_OF_PIPE_BIT,不需要dstAccessMask .oldLayout = VK_IMAGE_LAYOUT_PRESENT_SRC_KHR, .newLayout = VK_IMAGE_LAYOUT_PRESENT_SRC_KHR, .srcQueueFamilyIndex = graphicsBase::Base().QueueFamilyIndex_Graphics(), .dstQueueFamilyIndex = graphicsBase::Base().QueueFamilyIndex_Presentation(), .image = graphicsBase::Base().SwapchainImage(currentImageIndex), .subresourceRange = { VK_IMAGE_ASPECT_COLOR_BIT, 0, 1, 0, 1 } }; vkCmdPipelineBarrier(commandBuffer, VK_PIPELINE_STAGE_TOP_OF_PIPE_BIT, VK_PIPELINE_STAGE_BOTTOM_OF_PIPE_BIT, 0, 0, nullptr, 0, nullptr, 1, &imageMemoryBarrier_g2p_acquire);
-
队列族所有权转移的典型做法,是在两个不同的队列中使用两个参数完全一致的内存屏障。
然而这里使用的srcStage是VK_PIPELINE_STAGE_TOP_OF_PIPE_BIT,srcAccessMask是0,与CmdReleaseImageOwnership_Graphics(...)中的不一致,这是因为有信号量这一粒度更粗的同步手段,使得CmdAcquireImageOwnership_Presentation(...)中的内存屏障必然在渲染命令及其附属的内存屏障之后执行,因此这里的srcStage也就不再重要了。
如果使用以上两个内存屏障进行交换链图像的队列族所有权转移,那么主函数改为:
int main() { if (!InitializeWindow({1280,720})) return -1; fence fence(VK_FENCE_CREATE_SIGNALED_BIT); semaphore semaphore_imageIsAvailable; semaphore semaphore_renderingIsOver; semaphore semaphore_ownershipIsTransfered; commandBuffer commandBuffer_graphics; commandBuffer commandBuffer_presentation; commandPool commandPool_graphics(graphicsBase::Base().QueueFamilyIndex_Graphics(), VK_COMMAND_POOL_CREATE_RESET_COMMAND_BUFFER_BIT); commandPool commandPool_presentation(VK_COMMAND_POOL_CREATE_RESET_COMMAND_BUFFER_BIT, graphicsBase::Base().QueueFamilyIndex_Presentation()); commandPool_graphics.AllocateBuffers(commandBuffer_graphics); commandPool_presentation.AllocateBuffers(commandBuffer_presentation); while (!glfwWindowShouldClose(pWindow)) { while (glfwGetWindowAttrib(pWindow, GLFW_ICONIFIED)) glfwWaitEvents(); graphicsBase::Base().SwapImage(semaphore_imageIsAvailable); //提交到图形队列的命令缓冲区 commandBuffer_graphics.Begin(VK_COMMAND_BUFFER_USAGE_ONE_TIME_SUBMIT_BIT); /*渲染命令,待填充*/ VkImageMemoryBarrier imageMemoryBarrier_g2p_release = { .sType = VK_STRUCTURE_TYPE_IMAGE_MEMORY_BARRIER, .srcAccessMask = VK_ACCESS_COLOR_ATTACHMENT_WRITE_BIT, .oldLayout = VK_IMAGE_LAYOUT_PRESENT_SRC_KHR, .newLayout = VK_IMAGE_LAYOUT_PRESENT_SRC_KHR, .srcQueueFamilyIndex = graphicsBase::Base().QueueFamilyIndex_Graphics(), .dstQueueFamilyIndex = graphicsBase::Base().QueueFamilyIndex_Presentation(), .image = graphicsBase::Base().SwapchainImage(currentImageIndex), .subresourceRange = { VK_IMAGE_ASPECT_COLOR_BIT, 0, 1, 0, 1 } }; vkCmdPipelineBarrier(commandBuffer, VK_PIPELINE_STAGE_COLOR_ATTACHMENT_OUTPUT_BIT,VK_PIPELINE_STAGE_BOTTOM_OF_PIPE_BIT, 0, 0, nullptr, 0, nullptr, 1, &imageMemoryBarrier_g2p_release); commandBuffer_graphics.End(); graphicsBase::Base().SubmitCommandBuffer_Graphics(commandBuffer_graphics, semaphore_imageIsAvailable, semaphore_renderingIsOver); //提交到呈现队列的命令缓冲区 commandBuffer_presentation.Begin(VK_COMMAND_BUFFER_USAGE_ONE_TIME_SUBMIT_BIT); VkImageMemoryBarrier imageMemoryBarrier_g2p_acquire = { .sType = VK_STRUCTURE_TYPE_IMAGE_MEMORY_BARRIER, .oldLayout = VK_IMAGE_LAYOUT_PRESENT_SRC_KHR, .newLayout = VK_IMAGE_LAYOUT_PRESENT_SRC_KHR, .srcQueueFamilyIndex = graphicsBase::Base().QueueFamilyIndex_Graphics(), .dstQueueFamilyIndex = graphicsBase::Base().QueueFamilyIndex_Presentation(), .image = graphicsBase::Base().SwapchainImage(currentImageIndex), .subresourceRange = { VK_IMAGE_ASPECT_COLOR_BIT, 0, 1, 0, 1 } }; vkCmdPipelineBarrier(commandBuffer, VK_PIPELINE_STAGE_TOP_OF_PIPE_BIT, VK_PIPELINE_STAGE_BOTTOM_OF_PIPE_BIT, 0, 0, nullptr, 0, nullptr, 1, &imageMemoryBarrier_g2p_acquire); commandBuffer_presentation.End(); graphicsBase::Base().SubmitCommandBuffer_Presentation(commandBuffer_presentation, semaphore_renderingIsOver, semaphore_ownershipIsTransfered, fence); graphicsBase::Base().PresentImage(semaphore_ownershipIsTransfered); glfwPollEvents(); TitleFps(); fence.WaitAndReset(); } TerminateWindow(); return 0; }
其中graphicsBase的成员函数,SubmitCommandBuffer_Presentation(...)的定义如下:
public: result_t SubmitCommandBuffer_Presentation(VkCommandBuffer commandBuffer, VkSemaphore semaphore_renderingIsOver = VK_NULL_HANDLE, VkSemaphore semaphore_ownershipIsTransfered = VK_NULL_HANDLE, VkFence fence = VK_NULL_HANDLE) const { static constexpr VkPipelineStageFlags waitDstStage = VK_PIPELINE_STAGE_ALL_COMMANDS_BIT; VkSubmitInfo submitInfo = { .sType = VK_STRUCTURE_TYPE_SUBMIT_INFO, .commandBufferCount = 1, .pCommandBuffers = &commandBuffer }; if (semaphore_renderingIsOver) submitInfo.waitSemaphoreCount = 1, submitInfo.pWaitSemaphores = &semaphore_renderingIsOver, submitInfo.pWaitDstStageMask = &waitDstStage; if (semaphore_ownershipIsTransfered) submitInfo.signalSemaphoreCount = 1, submitInfo.pSignalSemaphores = &semaphore_ownershipIsTransfered; VkResult result = vkQueueSubmit(queue_presentation, 1, &submitInfo, fence); if (result) outStream << std::format("[ graphicsBase ] ERROR\nFailed to submit the presentation command buffer!\nError code: {}\n", int32_t(result)); return result; }
-
在呈现队列上任何操作开始执行前等待渲染完成,因此submitInfo.pWaitDstStageMask指向的值为VK_PIPELINE_STAGE_ALL_COMMANDS_BIT。
如果使用完全一致的内存屏障,那么中间可以省去一个信号量。
这里将所用的内存屏障封装为graphicsBase的成员函数:
public: void CmdTransferImageOwnership(VkCommandBuffer commandBuffer) const { VkImageMemoryBarrier imageMemoryBarrier_g2p = { .sType = VK_STRUCTURE_TYPE_IMAGE_MEMORY_BARRIER, .srcAccessMask = VK_ACCESS_COLOR_ATTACHMENT_WRITE_BIT, .dstAccessMask = 0, .oldLayout = VK_IMAGE_LAYOUT_PRESENT_SRC_KHR, .newLayout = VK_IMAGE_LAYOUT_PRESENT_SRC_KHR, .srcQueueFamilyIndex = queueFamilyIndex_graphics, .dstQueueFamilyIndex = queueFamilyIndex_presentation, .image = swapchainImages[currentImageIndex], .subresourceRange = { VK_IMAGE_ASPECT_COLOR_BIT, 0, 1, 0, 1 } }; vkCmdPipelineBarrier(commandBuffer, VK_PIPELINE_STAGE_COLOR_ATTACHMENT_OUTPUT_BIT, VK_PIPELINE_STAGE_BOTTOM_OF_PIPE_BIT, 0, 0, nullptr, 0, nullptr, 1, &imageMemoryBarrier_g2p); }
然后主函数改为:
int main() { if (!InitializeWindow({1280,720})) return -1; fence fence(VK_FENCE_CREATE_SIGNALED_BIT); semaphore semaphore_imageIsAvailable; semaphore semaphore_ownershipIsTransfered; commandBuffer commandBuffer_graphics; commandBuffer commandBuffer_presentation; commandPool commandPool_graphics(graphicsBase::Base().QueueFamilyIndex_Graphics(), VK_COMMAND_POOL_CREATE_RESET_COMMAND_BUFFER_BIT); commandPool commandPool_presentation(VK_COMMAND_POOL_CREATE_RESET_COMMAND_BUFFER_BIT, graphicsBase::Base().QueueFamilyIndex_Presentation()); commandPool_graphics.AllocateBuffers(commandBuffer_graphics); commandPool_presentation.AllocateBuffers(commandBuffer_presentation); while (!glfwWindowShouldClose(pWindow)) { while (glfwGetWindowAttrib(pWindow, GLFW_ICONIFIED)) glfwWaitEvents(); graphicsBase::Base().SwapImage(semaphore_imageIsAvailable); //提交到图形队列的命令缓冲区 commandBuffer_graphics.Begin(VK_COMMAND_BUFFER_USAGE_ONE_TIME_SUBMIT_BIT); /*渲染命令,待填充*/ graphicsBase::Base().CmdTransferImageOwnership(commandBuffer_graphics); commandBuffer_graphics.End(); graphicsBase::Base().SubmitCommandBuffer_Graphics(commandBuffer_graphics, semaphore_imageIsAvailable); //提交到呈现队列的命令缓冲区 commandBuffer_presentation.Begin(VK_COMMAND_BUFFER_USAGE_ONE_TIME_SUBMIT_BIT); graphicsBase::Base().CmdTransferImageOwnership(commandBuffer_presentation); commandBuffer_presentation.End(); graphicsBase::Base().SubmitCommandBuffer_Presentation(commandBuffer_presentation, VK_NULL_HANDLE, semaphore_ownershipIsTransfered, fence); graphicsBase::Base().PresentImage(semaphore_ownershipIsTransfered); glfwPollEvents(); TitleFps(); fence.WaitAndReset(); } TerminateWindow(); return 0; }