渲染和呈现

本章将把所有内容整合在一起。我们将编写 drawFrame 函数,该函数将从主循环调用,以便将三角形放置在屏幕上。让我们首先创建该函数并从 mainLoop 调用它。

void mainLoop() {
    while (!glfwWindowShouldClose(window)) {
        glfwPollEvents();
        drawFrame();
    }
}

...

void drawFrame() {

}

帧的轮廓

在高层次上,在 Vulkan 中渲染帧包含一组常见的步骤

  • 等待前一帧完成

  • 从交换链获取图像

  • 记录一个命令缓冲区,该缓冲区将场景绘制到该图像上

  • 提交已记录的命令缓冲区

  • 呈现交换链图像

尽管我们将在后面的章节中扩展绘制函数,但现在这是我们渲染循环的核心。

同步

Vulkan 的核心设计理念是 GPU 上执行的同步是显式的。操作顺序由我们来定义,使用各种同步原语告诉驱动程序我们希望事情运行的顺序。这意味着许多 Vulkan API 调用开始在 GPU 上执行工作是异步的,函数将在操作完成之前返回。

在本章中,我们需要明确排序一些事件,因为它们发生在 GPU 上,例如

  • 从交换链获取图像

  • 执行在获取的图像上绘制的命令

  • 将该图像呈现到屏幕上进行呈现,将其返回到交换链

这些事件中的每一个都是使用单个函数调用启动的,但都是异步执行的。函数调用将在操作实际完成之前返回,并且执行顺序也是未定义的。这很不幸,因为每个操作都依赖于前一个操作的完成。因此,我们需要探索可以使用哪些原语来实现所需的排序。

信号量

信号量用于在队列操作之间添加顺序。队列操作指的是我们提交到队列中的工作,无论是在命令缓冲区中还是在函数中,我们稍后会看到。队列的示例包括图形队列和呈现队列。信号量既用于对同一队列内的作业进行排序,也用于在不同队列之间进行排序。

Vulkan 中恰好有两种类型的信号量,二进制和时间线。由于本教程中只使用二进制信号量,因此我们不讨论时间线信号量。进一步提及的术语信号量专门指二进制信号量。

信号量要么是未发出信号的,要么是已发出信号的。它开始时是未发出信号的。我们使用信号量来对队列操作进行排序的方法是,在一个队列操作中提供相同的信号量作为“信号”信号量,并在另一个队列操作中提供相同的信号量作为“等待”信号量。例如,假设我们有信号量 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 等待,我们需要一个不同的同步原语,我们现在将描述它。

栅栏

栅栏的用途类似,因为它用于同步执行,但它是用于对 CPU(也称为主机)上的执行进行排序的。简而言之,如果主机需要知道 GPU 何时完成某件事,我们会使用栅栏。

与信号量类似,栅栏也处于已发出信号或未发出信号的状态。每当我们提交要执行的工作时,我们都可以将栅栏附加到该工作。当工作完成时,将发出栅栏信号。然后,我们可以让主机等待栅栏发出信号,从而保证主机在继续之前工作已完成。

一个具体的例子是拍摄屏幕截图。假设我们已经在 GPU 上完成了必要的工作。现在需要将图像从 GPU 传输到主机,然后将内存保存到文件中。我们有命令缓冲区 A,它执行传输和栅栏 F。我们提交带有栅栏 F 的命令缓冲区 A,然后立即告诉主机等待 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 彼此同步。

如何选择?

我们有两种同步原语可供使用,并且方便地有两个地方可以应用同步:交换链操作和等待前一帧完成。我们希望使用信号量来进行交换链操作,因为它们发生在 GPU 上,因此如果可以避免,我们不希望让主机等待。对于等待前一帧完成,我们希望使用栅栏,原因恰恰相反,因为我们需要主机等待。这样我们就不会一次绘制多个帧。因为我们每帧都重新记录命令缓冲区,所以在当前帧执行完成之前,我们不能将下一帧的工作记录到命令缓冲区中,因为我们不希望在 GPU 使用命令缓冲区时覆盖其当前内容。

创建同步对象

我们需要一个信号量来表示已从交换链获取图像并准备好进行渲染,另一个信号量来表示渲染已完成并且可以进行呈现,以及一个栅栏来确保一次只渲染一帧。

创建三个类成员来存储这些信号量对象和栅栏对象

VkSemaphore imageAvailableSemaphore;
VkSemaphore renderFinishedSemaphore;
VkFence inFlightFence;

要创建信号量,我们将为此部分教程添加最后一个 create 函数: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 或扩展的未来版本可能会像其他结构一样为 flagspNext 参数添加功能。

创建栅栏需要填写 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);

接下来是主要的绘制函数!

等待前一帧

在帧开始时,我们希望等待前一帧完成,以便可以使用命令缓冲区和信号量。为此,我们调用 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;

    ...
}

从交换链获取图像

我们在 drawFrame 函数中需要做的下一件事是从交换链获取图像。回想一下,交换链是一个扩展功能,因此我们必须使用带有 vk*KHR 命名约定的函数

void drawFrame() {
    ...

    uint32_t imageIndex;
    vkAcquireNextImageKHR(device, swapChain, UINT64_MAX, imageAvailableSemaphore, VK_NULL_HANDLE, &imageIndex);
}

vkAcquireNextImageKHR 的前两个参数是逻辑设备和我们希望从中获取图像的交换链。第三个参数指定图像变为可用的超时时间(以纳秒为单位)。使用 64 位无符号整数的最大值意味着我们实际上禁用了超时。

接下来的两个参数指定当呈现引擎完成使用图像时要发出信号的同步对象。这是我们可以开始绘制它的时间点。可以指定信号量、栅栏或两者。我们在这里将使用我们的 imageAvailableSemaphore 来达到此目的。

最后一个参数指定一个变量,用于输出已变为可用的交换链图像的索引。该索引引用我们 swapChainImages 数组中的 VkImage。我们将使用该索引来选择 VkFrameBuffer

记录命令缓冲区

有了 imageIndex 指定要使用的交换链图像,我们现在可以记录命令缓冲区。首先,我们在命令缓冲区上调用 vkResetCommandBuffer 以确保它可以被记录。

vkResetCommandBuffer(commandBuffer, 0);

vkResetCommandBuffer 的第二个参数是 VkCommandBufferResetFlagBits 标志。由于我们不想做任何特别的事情,所以将其保留为 0。

现在调用函数 recordCommandBuffer 来记录我们想要的命令。

recordCommandBuffer(commandBuffer, imageIndex);

有了完全记录的命令缓冲区,我们现在可以提交它。

提交命令缓冲区

队列提交和同步是通过 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;

前三个参数指定在执行开始之前要等待哪些信号量以及在管道的哪个阶段等待。我们希望在图像可用之前等待写入颜色到图像,因此我们指定写入颜色附件的图形管道的阶段。这意味着理论上,在图像尚未可用时,实现已经可以开始执行我们的顶点着色器等。waitStages 数组中的每个条目都对应于 pWaitSemaphores 中具有相同索引的信号量。

submitInfo.commandBufferCount = 1;
submitInfo.pCommandBuffers = &commandBuffer;

接下来的两个参数指定要实际提交执行的命令缓冲区。我们只提交我们拥有的单个命令缓冲区。

VkSemaphore signalSemaphores[] = {renderFinishedSemaphore};
submitInfo.signalSemaphoreCount = 1;
submitInfo.pSignalSemaphores = signalSemaphores;

signalSemaphoreCountpSignalSemaphores 参数指定在命令缓冲区执行完成后要发出信号的信号量。在我们的例子中,我们为此目的使用 renderFinishedSemaphore

if (vkQueueSubmit(graphicsQueue, 1, &submitInfo, inFlightFence) != VK_SUCCESS) {
    throw std::runtime_error("failed to submit draw command buffer!");
}

我们现在可以使用 vkQueueSubmit 将命令缓冲区提交到图形队列。该函数接受一个 VkSubmitInfo 结构数组作为参数,以便在工作负载大得多时提高效率。最后一个参数引用一个可选的栅栏,该栅栏将在命令缓冲区完成执行时发出信号。这使我们知道何时可以安全地重用命令缓冲区,因此我们希望将其提供给 inFlightFence。现在,在下一帧,CPU 将等待此命令缓冲区执行完毕,然后才能在其内部记录新命令。

子通道依赖关系

请记住,渲染通道中的子通道会自动处理图像布局转换。这些转换由子通道依赖关系控制,这些依赖关系指定子通道之间的内存和执行依赖关系。我们现在只有一个子通道,但是此子通道之前和之后的立即操作也算作隐式的“子通道”。

在渲染通道的开始和结束时,有两个内置的依赖项负责处理过渡,但前者发生的时间不正确。它假设过渡发生在管线的开始,但我们在那时还没有获取图像! 有两种方法可以解决这个问题。我们可以将 imageAvailableSemaphorewaitStages 更改为 VK_PIPELINE_STAGE_TOP_OF_PIPE_BIT,以确保渲染通道在图像可用之前不会开始,或者我们可以使渲染通道等待 VK_PIPELINE_STAGE_COLOR_ATTACHMENT_OUTPUT_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 结构有两个字段用于指定依赖项数组。

演示

绘制帧的最后一步是将结果提交回交换链,以便最终在屏幕上显示。演示在 drawFrame 函数的末尾通过 VkPresentInfoKHR 结构进行配置。

VkPresentInfoKHR presentInfo{};
presentInfo.sType = VK_STRUCTURE_TYPE_PRESENT_INFO_KHR;

presentInfo.waitSemaphoreCount = 1;
presentInfo.pWaitSemaphores = signalSemaphores;

前两个参数指定在演示发生之前要等待的信号量,就像 VkSubmitInfo 一样。由于我们想等待命令缓冲区完成执行(从而绘制我们的三角形),我们采用将被发出信号的信号量并等待它们,因此我们使用 signalSemaphores

VkSwapchainKHR swapChains[] = {swapChain};
presentInfo.swapchainCount = 1;
presentInfo.pSwapchains = swapChains;
presentInfo.pImageIndices = &imageIndex;

接下来的两个参数指定要向其显示图像的交换链以及每个交换链的图像索引。这几乎总是单个的。

presentInfo.pResults = nullptr; // Optional

还有一个可选的最后一个参数叫做 pResults。它允许您指定一个 VkResult 值数组,以检查每个单独的交换链演示是否成功。如果您只使用单个交换链,则没有必要,因为您可以简单地使用 present 函数的返回值。

vkQueuePresentKHR(presentQueue, &presentInfo);

vkQueuePresentKHR 函数提交将图像呈现给交换链的请求。我们将在下一章中为 vkAcquireNextImageKHRvkQueuePresentKHR 添加错误处理,因为它们的失败并不一定意味着程序应该终止,这与我们目前为止看到的函数不同。

如果您到目前为止一切都正确,那么当您运行程序时,您应该会看到类似以下内容

triangle

这个彩色的三角形可能看起来与您在图形教程中习惯看到的有点不同。这是因为本教程让着色器在线性颜色空间中进行插值,然后转换为 sRGB 颜色空间。

耶!不幸的是,您会发现当启用验证层时,程序会在您关闭它后立即崩溃。从 debugCallback 打印到终端的消息告诉我们原因

semaphore in use

请记住,drawFrame 中的所有操作都是异步的。这意味着当我们在 mainLoop 中退出循环时,绘制和演示操作可能仍在进行中。当这种情况发生时清理资源是一个坏主意。

要解决这个问题,我们应该在退出 mainLoop 并销毁窗口之前等待逻辑设备完成操作

void mainLoop() {
    while (!glfwWindowShouldClose(window)) {
        glfwPollEvents();
        drawFrame();
    }

    vkDeviceWaitIdle(device);
}

您还可以使用 vkQueueWaitIdle 等待特定命令队列中的操作完成。这些函数可以用作执行同步的一种非常基本的方法。您会看到,现在关闭窗口时,程序可以正常退出。

结论

在超过 900 行代码之后,我们终于到了看到屏幕上弹出东西的阶段!引导一个 Vulkan 程序绝对是一项艰巨的工作,但重点是 Vulkan 通过其显式性为您提供了大量的控制权。我建议您现在花一些时间重新阅读代码,并建立一个关于程序中所有 Vulkan 对象的用途以及它们如何相互关联的心理模型。我们将以此为基础来扩展程序的功能。

下一章将扩展渲染循环以处理多个帧。