Vulkan 教程 Khronos Vulkan® Tutorial · 中文翻译 GitHub

目前我们的渲染循环有一个明显的缺陷。 开始渲染下一帧之前,我们必须等上一帧完成,这会让主机(host)白白空转。

解决办法是允许同时有多个帧处于在途(in-flight)状态,也就是说,让某一帧的渲染不会干扰下一帧的命令记录。 具体怎么做? 渲染期间被访问和修改的任何资源都必须准备多份。 因此,我们需要多个命令缓冲区、信号量和栅栏。 后面的章节还会为其他资源引入多份实例,所以这个概念会再次出现。

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

constexpr int MAX_FRAMES_IN_FLIGHT = 2;

选 2 是因为我们不想让 CPU 比 GPU 跑得 过于 靠前。 有两帧在途时,CPU 和 GPU 可以同时处理各自的任务。 如果 CPU 提前完成,它会等到 GPU 渲染完毕再提交更多工作。 若有三帧或更多帧在途,CPU 就可能跑到 GPU 前面,引入若干帧的延迟。 通常我们并不希望多出延迟。 不过,把在途帧数量交给应用控制,正是 Vulkan 处处显式表达的又一个例子。

每一帧都应该有自己的命令缓冲区、一组信号量和栅栏。 把它们改名,并改成存放这些对象的 std::vector。原来那个单独的栅栏叫 drawFence,这里改名为 inFlightFences,而不是单纯加个 s,因为现在每个在途帧各有一个栅栏:

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,修改 vk::CommandBufferAllocateInfo,让它包含同样数量的命令缓冲区,最后把目标改成我们的命令缓冲区向量:

void createCommandBuffers()
{
		vk::CommandBufferAllocateInfo allocInfo{.commandPool = commandPool, .level = vk::CommandBufferLevel::ePrimary, .commandBufferCount = MAX_FRAMES_IN_FLIGHT};
    commandBuffers = vk::raii::CommandBuffers( device, allocInfo );
}

createSyncObjects 函数也要改成一次性创建所有对象:

void createSyncObjects()
{
		assert(presentCompleteSemaphores.empty() && renderFinishedSemaphores.empty() && inFlightFences.empty());

		for (size_t i = 0; i < swapChainImages.size(); i++)
		{
			renderFinishedSemaphores.emplace_back(device, vk::SemaphoreCreateInfo());
		}

		for (size_t i = 0; i < MAX_FRAMES_IN_FLIGHT; i++)
		{
			presentCompleteSemaphores.emplace_back(device, vk::SemaphoreCreateInfo());
			inFlightFences.emplace_back(device, vk::FenceCreateInfo{.flags = vk::FenceCreateFlagBits::eSignaled});
		}
}

注意,renderFinishedSemaphores 的大小取自 swapChainImages.size(),而 presentCompleteSemaphores 和 inFlightFences 是按 MAX_FRAMES_IN_FLIGHT 分配的。 这看起来和本章其他地方贯彻的“每个在途帧都复制一份”原则不一致,但这是有意为之。

每个同步对象都有 两 端:一端负责置位,另一端负责等待。 对于 presentCompleteSemaphores 和 inFlightFences,两端都由我们自己的 frameIndex 驱动,所以按 MAX_FRAMES_IN_FLIGHT 分配并用 frameIndex 索引既正确又够用。

renderFinishedSemaphores 则不同:图形队列向某张交换链图像渲染完毕时,它被置位;呈现引擎会在呈现同一张图像之前等待它。 呈现引擎并不知道我们的 frameIndex——它只知道 vk::raii::SwapchainKHR::acquireNextImage 交给我们的 imageIndex,我们也必须用这个索引去查该置位哪一个 renderFinishedSemaphores 条目、以及呈现会等待哪一个。 交换链的图像数量可能多于 MAX_FRAMES_IN_FLIGHT(取决于呈现模式,往往确实更多),而且连续的 acquireNextImage 调用不保证按与 frameIndex 相同的规律返回图像索引,所以若按 frameIndex 来分配和索引信号量,就可能出现新的一帧再次置位某个信号量、而呈现引擎仍在等待上一次使用它的情形。 按交换链图像的实际数量来分配 renderFinishedSemaphores,就彻底绕开了这个问题:每张图像恰好对应一个信号量,因此无论驱动如何发放图像,仅凭图像索引就能让置位与等待正确配对。

要每帧都用上正确的对象,就得跟踪当前帧。 为此我们使用一个帧索引:

uint32_t frameIndex = 0;

commandBuffer 现在也是向量了,recordCommandBuffer 不能再直接使用它——在函数开头为当前帧的缓冲区加一个局部引用:

void recordCommandBuffer(uint32_t imageIndex)
{
    auto &commandBuffer = commandBuffers[frameIndex];
    ...
}

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

void drawFrame() {
		auto fenceResult = device.waitForFences(*inFlightFences[frameIndex], vk::True, UINT64_MAX);
		if (fenceResult != vk::Result::eSuccess)
		{
			throw std::runtime_error("failed to wait for fence!");
		}
		device.resetFences(*inFlightFences[frameIndex]);

		auto [result, imageIndex] = swapChain.acquireNextImage(UINT64_MAX, *presentCompleteSemaphores[frameIndex], nullptr);

		commandBuffers[frameIndex].reset();
		recordCommandBuffer(imageIndex);

		vk::PipelineStageFlags waitDestinationStageMask(vk::PipelineStageFlagBits::eColorAttachmentOutput);
		const vk::SubmitInfo   submitInfo{.waitSemaphoreCount   = 1,
		                                  .pWaitSemaphores      = &*presentCompleteSemaphores[frameIndex],
		                                  .pWaitDstStageMask    = &waitDestinationStageMask,
		                                  .commandBufferCount   = 1,
		                                  .pCommandBuffers      = &*commandBuffers[frameIndex],
		                                  .signalSemaphoreCount = 1,
		                                  .pSignalSemaphores    = &*renderFinishedSemaphores[imageIndex]};
		queue.submit(submitInfo, *inFlightFences[frameIndex]);
}

当然,每次都要记得推进到下一帧:

void drawFrame() {
    ...

    frameIndex = (frameIndex + 1) % MAX_FRAMES_IN_FLIGHT;
}

借助取模(%)运算符,可以保证每入队 MAX_FRAMES_IN_FLIGHT 帧之后帧索引就回绕一次。

至此我们已经实现了全部必要的同步,确保入队的工作不超过 MAX_FRAMES_IN_FLIGHT 帧,并且这些帧不会互相踩踏。 注意,代码的其他部分——比如最终的清理——依赖 vk::raii::Device::waitIdle 这类更粗放的同步也没问题。 具体采用哪种方式,应当依据性能需求来决定。

此外,我们也可以用时间线信号量(timeline semaphore)代替这里介绍的二元信号量。 时间线信号量的用法示例见 计算着色器一章。 注意,像那个例子中同时处理计算队列和图形队列的场景,时间线信号量尤其好用。 这里这种简单二元信号量的做法,可以看作更传统的同步方式。

想通过示例进一步了解同步,可以看看 Khronos 维护的 这份详尽综述。

在 下一章 中,我们还要处理一件小事,它是写出行为规范的 Vulkan 程序所必需的。