飞行中的帧

目前,我们的渲染循环有一个明显的缺陷。我们必须等待前一帧完成才能开始渲染下一帧,这会导致主机不必要的空闲。

解决这个问题的方法是允许多个帧同时飞行,也就是说,允许一帧的渲染不干扰下一帧的录制。我们如何做到这一点?任何在渲染期间被访问和修改的资源都必须被复制。因此,我们需要多个命令缓冲区、信号量和栅栏。在后面的章节中,我们还会添加其他资源的多个实例,因此我们将看到这个概念再次出现。

首先在程序顶部添加一个常量,定义应该同时处理多少帧

const int MAX_FRAMES_IN_FLIGHT = 2;

我们选择数字 2,因为我们不希望 CPU 超前于 GPU。当有 2 个帧在飞行时,CPU 和 GPU 可以同时处理它们自己的任务。如果 CPU 提前完成,它将等待 GPU 完成渲染后再提交更多工作。当有 3 个或更多帧在飞行时,CPU 可能会超前于 GPU,从而增加延迟。通常,不希望额外的延迟。但是让应用程序控制飞行中的帧数是 Vulkan 显式性的另一个例子。

每一帧都应该有自己的命令缓冲区、信号量和栅栏。重命名,然后将它们更改为对象的 std::vector

std::vector<VkCommandBuffer> commandBuffers;

...

std::vector<VkSemaphore> imageAvailableSemaphores;
std::vector<VkSemaphore> renderFinishedSemaphores;
std::vector<VkFence> inFlightFences;

然后我们需要创建多个命令缓冲区。将 createCommandBuffer 重命名为 createCommandBuffers。接下来,我们需要将命令缓冲区向量的大小调整为 MAX_FRAMES_IN_FLIGHT 的大小,修改 VkCommandBufferAllocateInfo 以包含那么多命令缓冲区,然后将目标更改为我们的命令缓冲区向量

void createCommandBuffers() {
    commandBuffers.resize(MAX_FRAMES_IN_FLIGHT);
    ...
    allocInfo.commandBufferCount = (uint32_t) commandBuffers.size();

    if (vkAllocateCommandBuffers(device, &allocInfo, commandBuffers.data()) != VK_SUCCESS) {
        throw std::runtime_error("failed to allocate command buffers!");
    }
}

应该修改 createSyncObjects 函数以创建所有对象

void createSyncObjects() {
    imageAvailableSemaphores.resize(MAX_FRAMES_IN_FLIGHT);
    renderFinishedSemaphores.resize(MAX_FRAMES_IN_FLIGHT);
    inFlightFences.resize(MAX_FRAMES_IN_FLIGHT);

    VkSemaphoreCreateInfo semaphoreInfo{};
    semaphoreInfo.sType = VK_STRUCTURE_TYPE_SEMAPHORE_CREATE_INFO;

    VkFenceCreateInfo fenceInfo{};
    fenceInfo.sType = VK_STRUCTURE_TYPE_FENCE_CREATE_INFO;
    fenceInfo.flags = VK_FENCE_CREATE_SIGNALED_BIT;

    for (size_t i = 0; i < MAX_FRAMES_IN_FLIGHT; i++) {
        if (vkCreateSemaphore(device, &semaphoreInfo, nullptr, &imageAvailableSemaphores[i]) != VK_SUCCESS ||
            vkCreateSemaphore(device, &semaphoreInfo, nullptr, &renderFinishedSemaphores[i]) != VK_SUCCESS ||
            vkCreateFence(device, &fenceInfo, nullptr, &inFlightFences[i]) != VK_SUCCESS) {

            throw std::runtime_error("failed to create synchronization objects for a frame!");
        }
    }
}

类似地,它们也应该全部被清理

void cleanup() {
    for (size_t i = 0; i < MAX_FRAMES_IN_FLIGHT; i++) {
        vkDestroySemaphore(device, renderFinishedSemaphores[i], nullptr);
        vkDestroySemaphore(device, imageAvailableSemaphores[i], nullptr);
        vkDestroyFence(device, inFlightFences[i], nullptr);
    }

    ...
}

请记住,由于当我们释放命令池时,命令缓冲区会被释放,因此对于命令缓冲区清理,无需执行任何额外操作。

为了每帧使用正确的对象,我们需要跟踪当前帧。我们将为此目的使用帧索引

uint32_t currentFrame = 0;

现在可以修改 drawFrame 函数以使用正确的对象

void drawFrame() {
    vkWaitForFences(device, 1, &inFlightFences[currentFrame], VK_TRUE, UINT64_MAX);
    vkResetFences(device, 1, &inFlightFences[currentFrame]);

    vkAcquireNextImageKHR(device, swapChain, UINT64_MAX, imageAvailableSemaphores[currentFrame], VK_NULL_HANDLE, &imageIndex);

    ...

    vkResetCommandBuffer(commandBuffers[currentFrame],  0);
    recordCommandBuffer(commandBuffers[currentFrame], imageIndex);

    ...

    submitInfo.pCommandBuffers = &commandBuffers[currentFrame];

    ...

    VkSemaphore waitSemaphores[] = {imageAvailableSemaphores[currentFrame]};

    ...

    VkSemaphore signalSemaphores[] = {renderFinishedSemaphores[currentFrame]};

    ...

    if (vkQueueSubmit(graphicsQueue, 1, &submitInfo, inFlightFences[currentFrame]) != VK_SUCCESS) {
}

当然,我们不应该忘记每次都前进到下一帧

void drawFrame() {
    ...

    currentFrame = (currentFrame + 1) % MAX_FRAMES_IN_FLIGHT;
}

通过使用模 (%) 运算符,我们确保帧索引在每次 MAX_FRAMES_IN_FLIGHT 入队帧后循环。

我们现在已经实现了所有必要的同步,以确保没有超过 MAX_FRAMES_IN_FLIGHT 个工作帧入队,并且这些帧不会互相覆盖。请注意,代码的其他部分(如最终清理)可以依赖更粗糙的同步,例如 vkDeviceWaitIdle。您应该根据性能要求决定使用哪种方法。

要通过示例了解有关同步的更多信息,请查看 Khronos 提供的此广泛的概述

下一章中,我们将处理一个完善的 Vulkan 程序所需的另一件小事。