目录
Creating the synchronization objects
Waiting for the previous frame
Acquiring an image from the swap chain
在这一章中,一切都将汇集在一起。我们将编写drawFrame函数,该函数将从主循环调用,以将三角形显示在屏幕上。让我们从创建函数开始,并从mainLoop调用它:
void mainLoop() {
while (!glfwWindowShouldClose(window)) {
glfwPollEvents();
drawFrame();
}
}
...
void drawFrame() {
}
Outline of a frame
在较高级别上,在Vulkan中渲染帧包括一组常见的步骤:
- 等待上一帧完成
- 从交换链获取图像
- 记录将场景绘制到图像上的命令缓冲区
- 提交记录的命令缓冲区
- 展示交换链图像
虽然我们将在后面的章节中扩展绘图功能,但目前这是渲染循环的核心。
Synchronization
Vulkan的核心设计理念是GPU上执行的同步是明确的。操作的顺序由我们使用各种同步原语来定义,这些原语告诉驱动程序我们希望运行的顺序。这意味着许多开始在GPU上执行工作的Vulkan API调用是异步的,函数将在操作完成之前返回。
在本章中,有许多事件需要我们明确排序,因为它们发生在GPU上,例如:
- 从交换链获取图像
- 执行绘制到采集图像上的命令
- 将该图像显示在屏幕上,然后将其返回到swapchain
这些事件中的每一个都使用一个函数调用来设置,但都是异步执行的。函数调用将在操作实际完成之前返回,并且执行顺序也未定义。这是不幸的,因为每个操作都取决于前一个完成。因此,我们需要探索可以使用哪些原语来实现所需的排序。
Semaphores
信号量用于在队列操作之间添加顺序。队列操作是指我们在命令缓冲区中或从函数中提交给队列的工作,我们将在后面看到。队列的示例有图形队列和演示队列。信号量既用于对同一队列内的工作排序,也用于对不同队列之间的工作排序。
Vulkan中恰好有两种信号量,二进制和时间线。因为本教程中只使用二进制信号量,所以我们将不讨论时间线信号量。进一步提到信号量一词,仅指二进制信号量。
信号灯不是无信号就是有信号。它以无信号开始生活。我们使用信号量对队列操作进行排序的方式是在一个队列操作中提供与“signal”信号量相同的信号量,而在另一队列操作中则提供与“wait”信号量一样的信号量。例如,假设我们有信号量S和队列操作A和B,我们希望按顺序执行它们。我们告诉Vulkan的是,当操作A完成执行时,它将向信号量S发出“信号”,而操作B将在信号量S上“等待”,然后再开始执行。当操作A完成时,信号量S将被发信号,而操作B在S被发信号之前不会开始。操作B开始执行后,信号量S会自动重置为无信号状态,允许再次使用。
刚才描述的伪代码:
VkCommandBuffer A, B = ... // record command buffers
VkSemaphore S = ... // create a semaphore
// enqueue A, signal S when done - starts executing immediately
vkQueueSubmit(work: A, signal: S, wait: None)
// enqueue B, wait on S to start
vkQueueSubmit(work: B, signal: None, wait: S)
注意,在此代码段中,对vkQueueSubmit()的两个调用都会立即返回-等待只发生在GPU上。CPU继续运行而不阻塞。为了让CPU等待,我们需要一个不同的同步原语,我们现在将对此进行描述。
Fences
围栏也有类似的用途,因为它用于同步执行,但它用于在CPU(也称为主机)上命令执行。简单地说,如果主机需要知道GPU何时完成了某件事情,我们使用围栏。
与信号灯类似,围栏要么处于有信号状态,要么处于无信号状态。每当我们提交要执行的工作时,我们都可以为该工作设置围栏。工作完成后,围栏将发出信号。然后,我们可以让主人等待围栏发出信号,确保在主人继续之前工作已经完成。
一个具体的例子是截图。假设我们已经在GPU上完成了必要的工作。现在需要将图像从GPU传输到主机,然后将内存保存到文件中。我们有执行传输的命令缓冲区A和围栏F。我们将命令缓冲区与围栏F一起提交,然后立即告诉主机等待F发出信号。这会导致主机阻塞,直到命令缓冲区A完成执行。因此,当内存传输完成时,我们可以安全地让主机将文件保存到磁盘。
描述内容的伪代码:
VkCommandBuffer A = ... // record command buffer with the transfer
VkFence F = ... // create the fence
// enqueue A, start work immediately, signal F when done
vkQueueSubmit(work: A, fence: F)
vkWaitForFence(F) // blocks execution until A has finished executing
save_screenshot_to_disk() // can't run until the transfer has finished
与信号量示例不同,此示例确实阻止主机执行。这意味着主机不会做任何事情,除非等待执行完成。在这种情况下,我们必须确保传输完成,然后才能将截图保存到磁盘。
通常,除非必要,否则最好不要阻止主机。我们想给GPU和主机提供有用的工作。等待围栏发出信号是不有用的。因此,我们更喜欢信号量或其他尚未涵盖的同步原语来同步我们的工作。
必须手动重置围栏,使其恢复无信号状态。这是因为围栏用于控制主机的执行,因此主机可以决定何时重置围栏。这与用于在GPU上命令工作而不涉及主机的信号量形成对比。
总之,信号量用于指定GPU上操作的执行顺序,而围栏用于保持CPU和GPU彼此同步。
What to choose?
我们有两个同步原语可供使用,并方便地在两个地方应用同步:Swapchain操作和等待上一帧完成。我们希望在swapchain操作中使用信号量,因为它们发生在GPU上,因此我们不想让主机等待,如果我们可以帮助它的话。对于等待前一帧完成,我们希望使用围栏,因为我们需要主机等待。这是因为我们一次不会绘制多个帧。因为我们每一帧都重新记录命令缓冲区,所以在当前帧完成执行之前,我们无法将下一帧的工作记录到命令缓冲区中,因为我们不想在GPU使用命令缓冲区时覆盖命令缓冲区的当前内容。
Creating the synchronization objects
我们需要一个信号灯来指示图像已从swapchain获取并准备好渲染,另一个信号信号灯来表示渲染已完成并可以进行演示,以及一个围栏来确保一次只渲染一帧。
创建三个类成员来存储这些信号量对象和围栏对象:
VkSemaphore imageAvailableSemaphore;
VkSemaphore renderFinishedSemaphore;
VkFence inFlightFence;
为了创建信号量,我们将为教程的这一部分添加最后一个创建函数:createSyncObjects:
void initVulkan() {
createInstance();
setupDebugMessenger();
createSurface();
pickPhysicalDevice();
createLogicalDevice();
createSwapChain();
createImageViews();
createRenderPass();
createGraphicsPipeline();
createFramebuffers();
createCommandPool();
createCommandBuffer();
createSyncObjects();
}
...
void createSyncObjects() {
}
创建信号量需要填写VkSemaphoreCreateInfo,但在当前版本的API中,除了sType之外,它实际上没有任何必需的字段:
void createSyncObjects() {
VkSemaphoreCreateInfo semaphoreInfo{};
semaphoreInfo.sType = VK_STRUCTURE_TYPE_SEMAPHORE_CREATE_INFO;
}
Vulkan API或扩展的未来版本可能会像其他结构一样为标志和pNext参数添加功能。
创建围栏需要填写VkFenceCreateInfo:
VkFenceCreateInfo fenceInfo{};
fenceInfo.sType = VK_STRUCTURE_TYPE_FENCE_CREATE_INFO;
创建信号量和围栏遵循vkCreateSemaphore和vkCreateFence熟悉的模式:
if (vkCreateSemaphore(device, &semaphoreInfo, nullptr, &imageAvailableSemaphore) != VK_SUCCESS ||
vkCreateSemaphore(device, &semaphoreInfo, nullptr, &renderFinishedSemaphore) != VK_SUCCESS ||
vkCreateFence(device, &fenceInfo, nullptr, &inFlightFence) != VK_SUCCESS) {
throw std::runtime_error("failed to create semaphores!");
}
程序结束时,当所有命令都已完成且无需再同步时,应清理信号灯和围栏:
void cleanup() {
vkDestroySemaphore(device, imageAvailableSemaphore, nullptr);
vkDestroySemaphore(device, renderFinishedSemaphore, nullptr);
vkDestroyFence(device, inFlightFence, nullptr);
转到主绘图功能!
Waiting for the previous frame
在帧开始时,我们希望等待上一帧完成,以便命令缓冲区和信号量可以使用。为此,我们称vkWaitForFences为:
void drawFrame() {
vkWaitForFences(device, 1, &inFlightFence, VK_TRUE, UINT64_MAX);
}
vkWaitForFences函数获取一组围栏,并在返回前等待主机发出任何或所有围栏的信号。我们在这里传递的VK_TRUE表示我们希望等待所有围栏,但在单个围栏的情况下,这无关紧要。该函数还有一个超时参数,我们将该参数设置为64位无符号整数UINT64_MAX的最大值,这有效地禁用了超时。
等待后,我们需要使用vkResetFences调用手动将围栏重置为无信号状态:
vkResetFences(device, 1, &inFlightFence);
在我们开始之前,我们的设计有一个小问题。在第一帧中,我们调用drawFrame(),它立即等待inFlightFence发出信号。inFlightFence仅在一帧完成渲染后发出信号,但由于这是第一帧,因此没有之前的帧可以发出信号!因此,vkWaitForFences()无限期地阻塞,等待永远不会发生的事情。
在解决这一困境的众多解决方案中,API内置了一个巧妙的变通方法。在已发出信号的状态下创建围栏,以便第一次调用vkWaitForFences()立即返回,因为围栏已发出信号。
为此,我们将VK_FENCE_CREATE_SIGNALED_BIT标志添加到VkFenceCreateInfo:
void createSyncObjects() {
...
VkFenceCreateInfo fenceInfo{};
fenceInfo.sType = VK_STRUCTURE_TYPE_FENCE_CREATE_INFO;
fenceInfo.flags = VK_FENCE_CREATE_SIGNALED_BIT;
...
}
Acquiring an image from the swap chain
我们在drawFrame函数中需要做的下一件事是从交换链获取图像。回想一下,交换链是一个扩展特性,因此我们必须使用具有vk*KHR命名约定的函数:
void drawFrame() {
uint32_t imageIndex;
vkAcquireNextImageKHR(device, swapChain, UINT64_MAX, imageAvailableSemaphore, VK_NULL_HANDLE, &imageIndex);
}
vkAcquireNextImageKHR的前两个参数是逻辑设备和我们希望从中获取图像的交换链。第三个参数指定图像可用的超时时间(以纳秒为单位)。使用64位无符号整数的最大值意味着我们有效地禁用超时。
接下来的两个参数指定了当演示引擎使用图像完成时要发出信号的同步对象。这是我们可以开始绘制的时间点。可以指定信号灯、围栏或两者。我们将使用我们的imageAvailableSemaphore来实现这一目的。
最后一个参数指定一个变量,用于输出已变为可用的交换链映像的索引。索引引用swapChainImages数组中的VkImage。我们将使用该索引来选择VkFrameBuffer。
Recording the command buffer
通过imageIndex指定要使用的交换链图像,我们现在可以记录命令缓冲区。首先,我们在命令缓冲区上调用vkResetCommandBuffer,以确保它能够被记录。
vkResetCommandBuffer(commandBuffer, 0);
vkResetCommandBuffer的第二个参数是VkCommandBufferResetFlagBits标志。因为我们不想做任何特殊的事情,所以我们将其保留为0。
现在调用函数recordCommandBuffer来记录我们想要的命令。
recordCommandBuffer(commandBuffer, imageIndex);
有了完全记录的命令缓冲区,我们现在可以提交它了。
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;
前三个参数指定在执行开始之前等待哪些信号量,以及在管道的哪个阶段等待。我们希望等待将颜色写入图像,直到它可用,因此我们正在指定写入颜色附件的图形管道的阶段。这意味着理论上,当图像还不可用时,实现已经可以开始执行我们的顶点着色器等。waitSages数组中的每个条目对应于pWaitSeaphores中具有相同索引的信号量。
submitInfo.commandBufferCount = 1;
submitInfo.pCommandBuffers = &commandBuffer;
接下来的两个参数指定实际提交哪些命令缓冲区以供执行。我们只需提交我们拥有的单个命令缓冲区。
VkSemaphore signalSemaphores[] = {renderFinishedSemaphore};
submitInfo.signalSemaphoreCount = 1;
submitInfo.pSignalSemaphores = signalSemaphores;
signalSemaphoreCount和pSignalSemaphores参数指定在命令缓冲区完成执行后发出信号的信号量。在我们的例子中,我们使用renderFinishedMaster来实现这个目的。
if (vkQueueSubmit(graphicsQueue, 1, &submitInfo, inFlightFence) != VK_SUCCESS) {
throw std::runtime_error("failed to submit draw command buffer!");
}
现在,我们可以使用vkQueueSubmit将命令缓冲区提交到图形队列。当工作负载大得多时,该函数将VkSubmitInfo结构数组作为效率的参数。最后一个参数引用了一个可选的围栏,当命令缓冲区完成执行时,该围栏将发出信号。这允许我们知道何时可以安全地重用命令缓冲区,因此我们希望在FlightFence中提供它。现在,在下一帧中,CPU将等待该命令缓冲区完成执行,然后再将新命令记录到其中。
Subpass dependencies
请记住,渲染过程中的子过程会自动处理图像布局过渡。这些转换由子通道依赖项控制,子通道依赖关系指定子通道之间的内存和执行依赖项。我们现在只有一个子通路,但是在这个子通路之前和之后的操作也算作隐式的“子通路”。
有两个内置依赖项负责渲染过程开始时和渲染过程结束时的过渡,但前者不会在正确的时间发生。它假设转换发生在管道的开始处,但此时我们还没有获得图像!有两种方法可以解决这个问题。我们可以将imageAvailableSemaphore的waitStages更改为VK_PIPELINE_STAGE_TOP_OF_PIPE_BIT,以确保渲染过程在图像可用之前不会开始,或者我们可以使渲染过程等待VK_PIPE LINE_STAAGE_COLOR_ATTACHMENT_OUTPUTT_BIT阶段。我决定在这里使用第二个选项,因为这是一个很好的借口,可以看看子路径依赖关系及其工作方式。
在VkSubpassDependency结构中指定了子类依赖项。转到createRenderPass函数并添加一个:
VkSubpassDependency dependency{};
dependency.srcSubpass = VK_SUBPASS_EXTERNAL;
dependency.dstSubpass = 0;
前两个字段指定依赖项和依赖子路径的索引。特殊值VK_SUBPASS_EXTERNAL引用渲染过程之前或之后的隐式子过程,具体取决于它是在srcSubpass还是dstSubpass中指定的。索引0引用我们的子路径,这是第一个也是唯一一个。dstSubpass必须始终高于srcSubpass,以防止依赖关系图中出现循环(除非其中一个子通道是VK_SUBPASS_EXTERNAL)。
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_WRITE_BIT;
应等待此操作的操作处于颜色附件阶段,并涉及颜色附件的编写。这些设置将阻止转换发生,直到实际需要(并且允许):当我们想要开始向其写入颜色时。
renderPassInfo.dependencyCount = 1;
renderPassInfo.pDependencies = &dependency;
VkRenderPassCreateInfo结构有两个字段用于指定依赖项数组。
Presentation
绘制框架的最后一步是将结果提交回交换链,以便最终显示在屏幕上。演示通过drawFrame函数末尾的VkPresentInfoKHR结构进行配置。
VkPresentInfoKHR presentInfo{};
presentInfo.sType = VK_STRUCTURE_TYPE_PRESENT_INFO_KHR;
presentInfo.waitSemaphoreCount = 1;
presentInfo.pWaitSemaphores = signalSemaphores;
前两个参数指定在显示之前要等待的信号量,就像VkSubmitInfo一样。由于我们希望等待命令缓冲区完成执行,从而绘制三角形,因此我们获取将发出信号的信号量并等待它们,因此我们使用信号信号量。
VkSwapchainKHR swapChains[] = {swapChain};
presentInfo.swapchainCount = 1;
presentInfo.pSwapchains = swapChains;
presentInfo.pImageIndices = &imageIndex;
接下来的两个参数指定要向其显示图像的交换链以及每个交换链的图像索引。这几乎总是一个单一的。
presentInfo.pResults = nullptr; // Optional
最后一个可选参数叫做pResults。它允许您指定一个VkResult值数组,以检查每个交换链是否显示成功。如果您只使用一个交换链,这是不必要的,因为您可以简单地使用当前函数的返回值。
vkQueuePresentKHR(presentQueue, &presentInfo);
vkQueuePresentKHR函数提交向交换链呈现图像的请求。我们将在下一章中为vkAcquireNextImageKHR和vkQueuePresentKHR添加错误处理,因为它们的失败不一定意味着程序应该终止,这与我们目前看到的功能不同。
如果到目前为止,您做的一切都正确,那么在运行程序时,您现在应该看到类似以下内容:
这个彩色三角形看起来可能与您在图形教程中看到的有点不同。这是因为本教程允许着色器在线性颜色空间中插值,然后转换为sRGB颜色空间。有关差异的讨论,请参阅此博客文章。
耶!不幸的是,您将看到,当启用验证层时,程序一关闭就崩溃。从debugCallback打印到终端的消息告诉我们原因:
请记住,drawFrame中的所有操作都是异步的。这意味着,当我们在mainLoop中退出循环时,绘图和演示操作可能仍在进行。在这一过程中清理资源是一个坏主意。
要解决该问题,我们应该等待逻辑设备完成操作,然后退出mainLoop并销毁窗口:
void mainLoop() {
while (!glfwWindowShouldClose(window)) {
glfwPollEvents();
drawFrame();
}
vkDeviceWaitIdle(device);
}
您还可以使用vkQueueWaitIdle等待特定命令队列中的操作完成。这些函数可以用作执行同步的一种非常基本的方式。您将看到程序现在在关闭窗口时退出时没有问题。
Conclusion
900多行代码之后,我们终于到了看到屏幕上弹出一些东西的阶段!启动一个Vulkan程序无疑是一项艰巨的工作,但要传递的信息是,Vulkan通过其明确性给了你巨大的控制权。我建议您现在花点时间重新阅读代码,建立一个关于程序中所有Vulkan对象的用途以及它们之间相互关系的心理模型。从这一点开始,我们将在这些知识的基础上扩展程序的功能。
下一章将扩展渲染循环以处理飞行中的多个帧。