void mainLoop()
{
while (!glfwWindowShouldClose(window)) {
glfwPollEvents();
drawFrame();
}
}
...
void drawFrame()
{
}
本章是所有内容汇聚在一起的一章。
我们将编写 drawFrame 函数,该函数将从主循环中调用,将三角形显示在屏幕上。
首先创建该函数并在 mainLoop 中调用它:
void mainLoop()
{
while (!glfwWindowShouldClose(window)) {
glfwPollEvents();
drawFrame();
}
}
...
void drawFrame()
{
}
从高层来看,在 Vulkan 中渲染一帧包含一组常见的步骤:
等待上一帧完成
从交换链获取一张图像
记录一个命令缓冲,将场景绘制到该图像上
提交记录的命令缓冲
呈现交换链图像
虽然我们将在后续章节中扩展绘制函数,但现阶段这就是我们渲染循环的核心。
Vulkan 的一个核心设计理念是 GPU 上执行的同步是显式的。 操作的顺序由我们使用各种同步原语来定义,这些原语告诉驱动程序我们希望的运行顺序。 这意味着许多开始执行 GPU 工作的 Vulkan API 调用是异步的,函数会在操作完成之前返回。
在本章中,有许多事件我们需要显式排序,因为它们发生在 GPU 上,例如:
从交换链获取一张图像
执行绘制到获取的图像上的命令
将该图像呈现到屏幕上,并将其返回给交换链
这些事件中的每一个都是通过单个函数调用启动的,但都是异步执行的。 函数调用会在操作实际完成之前返回,并且执行的顺序也是未定义的。 这是不幸的,因为每个操作都依赖于前一个操作的完成。 因此,我们需要探索可以使用哪些原语来实现所需的顺序。
二值信号量用于在队列操作之间添加顺序。 队列操作指的是我们提交到队列的工作,无论是在命令缓冲中,还是在函数内部(我们稍后会看到)。 队列的例子包括图形队列和呈现队列。 信号量既用于同一队列内部工作的排序,也用于不同队列之间的排序。
Vulkan 中恰好有两种信号量:二值信号量和时间线信号量。 因为本教程只使用二值信号量,所以我们不讨论时间线信号量。 之后提到的“信号量”一词专指二值信号量。
二值信号量要么处于未 signaled 状态,要么处于 signaled 状态。它初始为未 signaled。 我们使用二值信号量来排序队列操作的方式是,将同一个信号量作为一个队列操作的“信号”信号量,并在另一个队列操作中作为“等待”信号量。 例如,假设我们有信号量 S 以及队列操作 A 和 B,我们希望它们按顺序执行。 我们告诉 Vulkan 的是,操作 A 在执行完成时会“信号”信号量 S,而操作 B 在开始执行之前会“等待”信号量 S。 当操作 A 完成时,信号量 S 被 signaled,而操作 B 直到 S 被 signaled 才会开始。 在操作 B 开始执行后,信号量 S 会自动重置回未 signaled 状态,以便再次使用。
上述描述的伪代码:
vk::raii::CommandBuffer A, B = ... // 记录命令缓冲 vk::raii::Semaphore S = ... // 创建一个信号量 // 将 A 入队,完成后 signal S - 立即开始执行 queue.submit(work: A, signal: S, wait: None) // 将 B 入队,等待 S 被 signaled 后开始 queue.submit(work: B, signal: None, wait: S)
请注意,在这个代码片段中,两次 queue.submit() 调用都会立即返回——等待只发生在 GPU 上。
CPU 会继续运行而不会阻塞。
要让 CPU 等待,我们需要另一种同步原语,我们现在就来描述它。
围栏的用途类似,也是用于同步执行,但它是用于排序 CPU(也称为主机)上的执行。 具体来说,当主机需要知道 GPU 何时完成了某项工作时,我们使用围栏。
与信号量类似,围栏也处于 signaled 或未 signaled 状态。 每当我们提交要执行的工作时,我们可以将一个围栏附加到该工作上。 当工作完成时,围栏会被 signaled。 然后我们可以让主机等待围栏被 signaled,从而保证在主机继续之前工作已经完成。
一个具体的例子是截屏。 假设我们已经在 GPU 上完成了必要的工作。 现在需要将图像从 GPU 传输到主机,然后将内存保存到文件。 我们有执行传输的命令缓冲 A 和围栏 F。 我们提交命令缓冲 A 并附带围栏 F,然后立即让主机等待 F 被 signaled。 这会导致主机阻塞,直到命令缓冲 A 执行完成。 因此,我们可以安全地让主机将文件保存到磁盘,因为内存传输已经完成。
上述描述的伪代码:
vk::raii::CommandBuffer A = ... // 记录包含传输操作的命令缓冲 vk::raii::Fence F = ... // 创建围栏 // 将 A 入队,立即开始工作,完成后 signal F queue.submit(work: A, fence: F) device.waitForFences(F) // 阻塞执行,直到 A 执行完成 save_screenshot_to_disk() // 直到传输完成后才能运行
与信号量示例不同,这个示例 确实 会阻塞主机执行。 这意味着主机除了等待执行完成之外不会做任何事。 对于这种情况,我们必须确保传输完成才能将截图保存到磁盘。
通常,除非必要,否则最好不让主机阻塞。 我们希望让 GPU 和主机都有有用的工作可做。 等待围栏 signaled 不是有用工作。 因此,我们更倾向于使用信号量或其他尚未介绍的同步原语来同步我们的工作。
围栏必须手动重置,以便将它们恢复到未 signaled 状态。 这是因为围栏用于控制主机的执行,因此主机可以决定何时重置围栏。 与此相对,信号量用于在 GPU 上排序工作,而无需主机参与。
总之,信号量用于指定 GPU 上操作的执行顺序,而围栏用于保持 CPU 和 GPU 之间的同步。
我们有两种同步原语可用,并且恰好有两个地方需要应用同步:交换链操作和等待上一帧完成。 我们希望在交换链操作中使用信号量,因为它们发生在 GPU 上,因此如果可能的话,我们不想让主机等待。 对于等待上一帧完成,我们出于相反的原因希望使用围栏,因为我们需要主机等待。 这样我们就不会同时绘制超过一帧。 因为我们每帧都重新记录命令缓冲,所以我们不能在当前帧执行完成之前将下一帧的工作记录到命令缓冲中。我们不想在 GPU 正在使用命令缓冲时覆盖其当前内容。
我们需要一个信号量来指示已经成功从交换链获取了一张图像,可以开始渲染了。另一个信号量指示渲染已完成,可以进行呈现了;还需要一个围栏来确保一次只渲染一帧。
创建三个类成员来存储这些信号量对象和围栏对象:
vk::raii::Semaphore presentCompleteSemaphore = nullptr;
vk::raii::Semaphore renderFinishedSemaphore = nullptr;
vk::raii::Fence drawFence = nullptr;
为了创建信号量,我们将为本教程的这一部分添加最后一个 create 函数:createSyncObjects:
void initVulkan()
{
createInstance();
setupDebugMessenger();
createSurface();
pickPhysicalDevice();
createLogicalDevice();
createSwapChain();
createImageViews();
createGraphicsPipeline();
createCommandPool();
createCommandBuffer();
createSyncObjects();
}
...
void createSyncObjects()
{
}
创建信号量需要填充 vk::SemaphoreCreateInfo,但在当前版本的 API 中,它实际上没有任何与本教程相关的字段:
void createSyncObjects()
{
presentCompleteSemaphore = vk::raii::Semaphore(device, vk::SemaphoreCreateInfo());
renderFinishedSemaphore = vk::raii::Semaphore(device, vk::SemaphoreCreateInfo());
drawFence = vk::raii::Fence(device, {.flags = vk::FenceCreateFlagBits::eSignaled});
}
未来版本的 Vulkan API 或扩展可能会为 vk::SemaphoreCreateInfo 的 flags 和 pNext 成员添加功能,就像其他结构体一样。
接下来是主要的绘制函数!
在帧开始时,我们希望等待上一帧完成,以便命令缓冲和信号量可用。
为此,我们调用 vk::raii::Device::waitForFences:
void drawFrame()
{
auto fenceResult = device.waitForFences(*drawFence, vk::True, UINT64_MAX);
if (fenceResult != vk::Result::eSuccess)
{
throw std::runtime_error("failed to wait for fence!");
}
device.resetFences(*drawFence);
}
vk::raii::Device::waitForFences 函数接受一个围栏数组,并在主机上等待,直到其中任何一个或所有围栏被 signaled 后才返回。
我们在这里传递的 vk::True 表示我们希望等待所有围栏,但对于单个围栏来说没有区别。
此函数还有一个超时参数,我们将其设置为 64 位无符号整数的最大值 UINT64_MAX,这实际上禁用了超时。
我们需要确保如果上一帧已经执行,围栏被重置,这样我们才知道稍后要等待它。
接下来,在上一帧完成后,让我们从帧缓冲中获取一张图像:
void drawFrame()
{
...
auto [result, imageIndex] = swapChain.acquireNextImage(UINT64_MAX, *presentCompleteSemaphore, nullptr);
}
第一个参数指定等待图像变为可用的超时时间(以纳秒为单位)。 使用 64 位无符号整数的最大值意味着我们实际上禁用了超时。
接下来的两个参数指定同步对象,当呈现引擎完成使用该图像时,这些对象将被 signaled。
那正是我们可以开始绘制到图像的时间点。
可以指定信号量、围栏或两者都指定。
我们在这里将使用 presentCompleteSemaphore 来实现该目的。
该函数返回一对值:通常的 vk::Result,以及已变为可用的交换链图像的索引。
该索引指向 swapChainImages 数组中的 vk::Image。
我们将使用该索引来选择 vk::raii::FrameBuffer。然后我们将记录到那个帧缓冲中。
有了指定要使用的交换链图像的 imageIndex,我们现在可以记录命令缓冲了。
现在调用 recordCommandBuffer 函数来记录我们想要的命令。
recordCommandBuffer(imageIndex);
有了完整记录的命令缓冲,我们现在可以提交它了。
队列提交和同步通过 vk::SubmitInfo 结构体中的参数进行配置。
vk::PipelineStageFlags waitDestinationStageMask( vk::PipelineStageFlagBits::eColorAttachmentOutput );
const vk::SubmitInfo submitInfo{.waitSemaphoreCount = 1,
.pWaitSemaphores = &*presentCompleteSemaphore,
.pWaitDstStageMask = &waitDestinationStageMask,
.commandBufferCount = 1,
.pCommandBuffers = &*commandBuffer,
.signalSemaphoreCount = 1,
.pSignalSemaphores = &*renderFinishedSemaphore};
前三个参数指定在开始执行之前要等待哪些信号量,以及等待管线的哪个(些)阶段。
我们希望等到图像可用后再写入颜色,因此我们指定了写入颜色附件的图形管线阶段。
这意味着理论上,实现可以在图像尚未可用时就开始执行我们的顶点着色器等。
waitStages 数组中的每个条目对应于 pWaitSemaphores 中相同索引的信号量。
下一个参数指定要实际提交执行的命令缓冲。 我们只需提交我们拥有的单个命令缓冲。
pSignalSemaphores 参数指定在命令缓冲执行完成后要 signaled 哪些信号量。
在我们的例子中,我们使用 renderFinishedSemaphore 来实现该目的。
queue.submit(submitInfo, *drawFence);
我们现在可以使用 vk::raii::Queue::submit 将命令缓冲提交到图形队列。
该函数接受一个 vk::SubmitInfo 结构体数组作为参数,以便在工作量更大时提高效率。
最后一个参数引用一个可选的围栏,当命令缓冲执行完成时,该围栏将被 signaled。
这让我们知道何时可以安全地重用命令缓冲,因此我们想给它 *drawFence,它将在下一帧中被等待。
本节是可选的,并且比必要的更为明确。
请记住,渲染通道中的子通道会自动处理图像布局转换。 这些转换由 子通道依赖 控制,它指定了子通道之间的内存和执行依赖关系。 我们现在只有一个子通道,但此子通道之前和之后的操作也被视为隐式的“子通道”。
有两个内置依赖关系负责处理渲染通道开始和结束时的转换,但前者发生的时间不对。
它假设转换发生在管线开始时,但那时我们还没有获取到图像!
有两种方法可以处理这个问题。
我们可以将 presentCompleteSemaphore 的 waitStages 改为 vk::PipelineStageFlagBits::eTopOfPipe,以确保在图像可用之前渲染通道不会开始,或者我们可以让渲染通道等待 vk::PipelineStageFlagBits::eColorAttachmentOutput 阶段。
我在这里选择了第二种方法,因为这是一个了解子通道依赖及其工作方式的好借口。
子通道依赖在 vk::SubpassDependency 结构体中指定。
转到 createRenderPass 函数并添加一个:
vk::SubpassDependency dependency{
.srcSubpass = vk::SubpassExternal,
.dstSubpass = 0}
前两个字段指定依赖子通道和依赖目标子通道的索引。
特殊值 vk::SubpassExternal 指的是渲染通道之前或之后的隐式子通道,具体取决于它是在 srcSubpass 还是 dstSubpass 中指定。
索引 0 指的是我们的子通道,它是第一个也是唯一一个。
dstSubpass 必须始终高于 srcSubpass,以防止依赖图中出现循环(除非其中一个子通道是 vk::SubpassExternal)。
vk::SubpassDependency dependency{
.srcSubpass = vk::SubpassExternal,
.dstSubpass = 0,
.srcStageMask = vk::PipelineStageFlagBits::eColorAttachmentOutput,
.dstStageMask = vk::PipelineStageFlagBits::eColorAttachmentOutput,
.srcAccessMask = vk::AccessFlagBits::eNone,
.dstAccessMask = vk::AccessFlagBits::eColorAttachmentWrite};
接下来的两个字段指定要等待的操作以及应该在颜色附件阶段等待此操作的操作。 最后两个字段指定这些操作发生的阶段,并涉及颜色附件的写入。 我们需要等待交换链完成从图像读取后,我们才能访问它。 这可以通过等待颜色附件输出阶段本身来实现。
这些设置将阻止转换发生,直到实际需要(且允许)时:即当我们想要开始向其中写入颜色时。
renderPassInfo.dependencyCount = 1;
renderPassInfo.pDependencies = &dependency;
vk::RenderPassCreateInfo 结构体有两个字段来指定依赖数组。
上述内容完全是可选的,并未在 演示代码 中重现。
绘制帧的最后一步是将结果提交回交换链,以便最终在屏幕上显示。
呈现通过 drawFrame 函数末尾的 vk::PresentInfoKHR 结构体进行配置。
const vk::PresentInfoKHR presentInfoKHR{
.waitSemaphoreCount = 1,
.pWaitSemaphores = &*renderFinishedSemaphore,
.swapchainCount = 1,
.pSwapchains = &*swapChain,
.pImageIndices = &imageIndex};
前两个参数指定在呈现发生之前要等待哪些信号量,就像 vk::SubmitInfo 一样。
因为我们希望等待命令缓冲执行完成,即我们的三角形绘制完成,所以我们使用将被 signaled 的信号量并等待它们,因此我们使用 renderFinishedSemaphore。
接下来的两个参数指定要呈现图像的交换链以及每个交换链的图像索引。 这几乎总是单个。
presentInfo.pResults = nullptr; // 可选
还有一个可选参数叫做 pResults。
它允许你指定一个 vk::Result 值数组,用于检查每个交换链的呈现是否成功。
如果你只使用单个交换链,则没有必要,因为你可以使用呈现函数的返回值。
result = queue.presentKHR(presentInfoKHR);
vk::raii::Queue::presentKHR 函数提交呈现图像到交换链的请求。
我们将在下一章为 vk::raii::SwapchainKHR::acquireNextImage 和 vk::raii::Queue::presentKHR 添加错误处理,因为它们的失败不一定意味着程序应该终止,这与我们到目前为止看到的函数不同。
如果到目前为止你做的一切都正确,那么当你运行程序时,你应该会看到类似以下的内容:
这个彩色三角形可能看起来与你习惯在图形教程中看到的略有不同。 这是因为本教程让着色器在线性颜色空间中插值,然后再转换为 sRGB 颜色空间。
耶!
不幸的是,你会看到当启用验证层时,程序在你关闭它时会崩溃。
从 debugCallback 打印到终端的消息告诉了我们原因:
请记住,drawFrame 中的所有操作都是异步的。
这意味着当我们退出 mainLoop 中的循环时,绘制和呈现操作可能仍在进行中。
在此期间清理资源是不好的做法。
为了解决这个问题,我们应该在退出 mainLoop 并销毁窗口之前等待逻辑设备完成操作:
void mainLoop() {
while (!glfwWindowShouldClose(window)) {
glfwPollEvents();
drawFrame();
}
device.waitIdle();
}
你也可以使用 vk::raii::Queue::waitIdle 等待特定命令队列中的操作完成。
这些函数可以用作执行同步的一种非常基础的方式。
你会看到程序现在在关闭窗口时可以正常退出了。
经过大约 500 行代码之后,我们终于到了看到屏幕上出现一些东西的阶段! 启动一个 Vulkan 程序确实需要大量工作,但关键信息是 Vulkan 通过其明确性赋予了你巨大的控制力。 我建议你现在花一些时间重新阅读代码,并在脑海中建立程序中所有 Vulkan 对象的用途以及它们之间关系的模型。 从这一点开始,我们将在此知识基础上扩展程序的功能。
此外,在未来的章节中,我们将讨论时间线信号量和内存屏障,并进一步完善我们对 Vulkan 中同步的理解。 同步是利用 Vulkan 真正力量的最大领域之一,因此它相当复杂。虽然最初几次理解起来很复杂,但这确实是后续内容的基石。从这里开始,当你的工具包中有更多工具来做更细致的事情时,事情真的会变得更容易。
下一章将扩展渲染循环,以处理多帧并行。
C++ 代码 / Slang 着色器 / GLSL 顶点着色器 / GLSL 片段着色器
Vulkan 是 Khronos Group Inc. 的注册商标
教程内容版权归原作者,遵循 CC BY-SA 4.0;本站独立代码及设计除外。