帧缓冲队列

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

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

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

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 程序良好运行所需的另一件小事。

C++ 代码 / 顶点着色器 / 片段着色器