飞行中的帧
现在我们的渲染循环有一个明显的缺陷。我们需要等待前一帧完成才能开始渲染下一帧,这导致主机不必要的空闲。
解决这个问题的方法是允许多个帧同时“飞行”,也就是说,允许一帧的渲染不干扰下一帧的记录。我们如何做到这一点?在渲染期间访问和修改的任何资源都必须复制。因此,我们需要多个命令缓冲区、信号量和栅栏。在后面的章节中,我们还将添加其他资源的多个实例,因此我们将看到这个概念再次出现。
首先在程序顶部添加一个常量,定义应同时处理的帧数:
constexpr int MAX_FRAMES_IN_FLIGHT = 2;
我们选择数字 2 是因为我们不希望 CPU 过于领先 GPU。当两帧在飞行中时,CPU 和 GPU 可以同时处理各自的任务。如果 CPU 提前完成,它将等待 GPU 完成渲染后再提交更多工作。如果有三帧或更多帧在飞行中,CPU 可能会领先 GPU,增加帧延迟。通常,额外的延迟是不希望的。但让应用程序控制飞行中的帧数是 Vulkan 显式化的另一个例子。
每一帧都应该有自己的命令缓冲区、信号量集和栅栏。将它们重命名并更改为对象的 std::vector:
std::vector<vk::raii::CommandBuffer> commandBuffers;
...
std::vector<vk::raii::Semaphore> presentCompleteSemaphores;
std::vector<vk::raii::Semaphore> renderFinishedSemaphores;
std::vector<vk::raii::Fence> inFlightFences;
然后我们需要创建多个命令缓冲区。将 createCommandBuffer 重命名为 createCommandBuffers。接下来,我们需要将命令缓冲区向量的大小调整为 MAX_FRAMES_IN_FLIGHT,修改 VkCommandBufferAllocateInfo 以包含这么多命令缓冲区,然后将目标更改为我们的命令缓冲区向量:
void createCommandBuffers() {
commandBuffers.clear();
vk::CommandBufferAllocateInfo allocInfo{ .commandPool = commandPool, .level = vk::CommandBufferLevel::ePrimary,
.commandBufferCount = MAX_FRAMES_IN_FLIGHT };
commandBuffers = vk::raii::CommandBuffers( device, allocInfo );
}
createSyncObjects 函数应更改为创建所有对象:
void createSyncObjects() {
presentCompleteSemaphores.clear();
renderFinishedSemaphores.clear();
inFlightFences.clear();
for (size_t i = 0; i < MAX_FRAMES_IN_FLIGHT; i++) {
presentCompleteSemaphores.emplace_back(device, vk::SemaphoreCreateInfo());
renderFinishedSemaphores.emplace_back(device, vk::SemaphoreCreateInfo());
inFlightFences.emplace_back(device, vk::FenceCreateInfo(vk::FenceCreateFlagBits::eSignaled));
}
}
为了在每一帧使用正确的对象,我们需要跟踪当前帧。我们将使用帧索引来实现这一目的:
uint32_t currentFrame = 0;
现在可以修改 drawFrame 函数以使用正确的对象:
void drawFrame() {
while ( vk::Result::eTimeout == device.waitForFences( inFlightFences[currentFrame], vk::True, UINT64_MAX ) )
;
auto [result, imageIndex] = swapChain.acquireNextImage( UINT64_MAX, presentCompleteSemaphores[currentFrame], nullptr );
device.resetFences( inFlightFences[currentFrame] );
...
commandBuffers[currentFrame].reset();
recordCommandBuffer(commandBuffers[currentFrame], imageIndex);
...
vk::PipelineStageFlags waitDestinationStageMask( vk::PipelineStageFlagBits::eColorAttachmentOutput );
const vk::SubmitInfo submitInfo{ .waitSemaphoreCount = 1, .pWaitSemaphores = &*presentCompleteSemaphores[currentFrame],
.pWaitDstStageMask = &waitDestinationStageMask, .commandBufferCount = 1, .pCommandBuffers = &*commandBuffers[currentFrame],
.signalSemaphoreCount = 1, .pSignalSemaphores = &*renderFinishedSemaphores[currentFrame] };
...
graphicsQueue.submit(submitInfo, inFlightFences[currentFrame]);
}
当然,我们不应该忘记每次前进到下一帧:
void drawFrame() {
...
currentFrame = (currentFrame + 1) % MAX_FRAMES_IN_FLIGHT;
}
通过使用模运算符(%),我们确保帧索引在每 MAX_FRAMES_IN_FLIGHT 个排队帧后循环。
我们现在已经实现了所有必要的同步,以确保排队的工作帧不超过 MAX_FRAMES_IN_FLIGHT,并且这些帧不会相互干扰。请注意,代码的其他部分(如最终清理)依赖于更粗略的同步(如 vkDeviceWaitIdle)是可以的。您应根据性能要求决定使用哪种方法。
此外,我们可以使用时间线信号量而不是这里介绍的二进制信号量。要查看如何使用时间线信号量的示例,请参阅 计算着色器章节。请注意,时间线信号量特别适用于处理计算和图形队列,如该示例所示。这种简单的二进制信号量方法可以被认为是更传统的同步方法。
要了解更多通过示例进行的同步,请查看 Khronos 提供的 这个广泛的概述。
在 下一章 中,我们将处理一个行为良好的 Vulkan 程序所需的另一个小问题。
C++ 代码 / Slang 着色器 / GLSL 顶点着色器 / GLSL 片段着色器