飞行中的帧

      +

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

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

      首先在程序顶部添加一个常量,定义应同时处理的帧数:

      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 程序所需的另一个小问题。