我们需要做的第一件事是将我们需要的同步结构添加到我们的 FrameData 结构中。我们将新成员添加到结构体中。
struct FrameData {
VkSemaphore _swapchainSemaphore, _renderSemaphore;
VkFence _renderFence;
};
我们将需要 2 个信号量和主渲染围栏。让我们开始创建它们。
_swapchainSemaphore
将用于使我们的渲染命令等待交换链图像请求。_renderSemaphore
将用于控制在绘制完成后向操作系统呈现图像 _renderFence
将让我们等待给定帧的绘制命令完成。
让我们初始化它们。检查我们的 vk_initializers.cpp 代码中创建 VkFenceCreateInfo 和 VkSemaphoreCreateInfo 的函数。
VkFenceCreateInfo vkinit::fence_create_info(VkFenceCreateFlags flags /*= 0*/)
{
VkFenceCreateInfo info = {};
info.sType = VK_STRUCTURE_TYPE_FENCE_CREATE_INFO;
info.pNext = nullptr;
info.flags = flags;
return info;
}
VkSemaphoreCreateInfo vkinit::semaphore_create_info(VkSemaphoreCreateFlags flags /*= 0*/)
{
VkSemaphoreCreateInfo info = {};
info.sType = VK_STRUCTURE_TYPE_SEMAPHORE_CREATE_INFO;
info.pNext = nullptr;
info.flags = flags;
return info;
}
这两个结构都非常简单,除了给它们一些标志外,几乎不需要其他选项。有关结构的更多信息,请参阅规范链接 VkFenceCreateInfo, VkSemaphoreCreateInfo
现在让我们编写实际的创建代码。
void VulkanEngine::init_sync_structures()
{
//create syncronization structures
//one fence to control when the gpu has finished rendering the frame,
//and 2 semaphores to syncronize rendering with swapchain
//we want the fence to start signalled so we can wait on it on the first frame
VkFenceCreateInfo fenceCreateInfo = vkinit::fence_create_info(VK_FENCE_CREATE_SIGNALED_BIT);
VkSemaphoreCreateInfo semaphoreCreateInfo = vkinit::semaphore_create_info();
for (int i = 0; i < FRAME_OVERLAP; i++) {
VK_CHECK(vkCreateFence(_device, &fenceCreateInfo, nullptr, &_frames[i]._renderFence));
VK_CHECK(vkCreateSemaphore(_device, &semaphoreCreateInfo, nullptr, &_frames[i]._swapchainSemaphore));
VK_CHECK(vkCreateSemaphore(_device, &semaphoreCreateInfo, nullptr, &_frames[i]._renderSemaphore));
}
}
在围栏上,我们使用标志 VK_FENCE_CREATE_SIGNALED_BIT
。这非常重要,因为它允许我们在新创建的围栏上等待而不会导致错误。如果我们没有该位,当我们第一帧调用 WaitFences 时,在 gpu 工作之前,线程将被阻塞。
我们为每一帧创建 3 个结构。现在我们有了它们,我们可以编写绘制循环了。
绘制循环
让我们通过首先等待 GPU 完成其工作(使用围栏)来启动绘制循环。
void VulkanEngine::draw()
{
// wait until the gpu has finished rendering the last frame. Timeout of 1
// second
VK_CHECK(vkWaitForFences(_device, 1, &get_current_frame()._renderFence, true, 1000000000));
VK_CHECK(vkResetFences(_device, 1, &get_current_frame()._renderFence));
}
我们使用 vkWaitForFences()
等待 GPU 完成其工作,之后我们重置围栏。围栏必须在使用之间重置,您不能在多个 GPU 命令上使用相同的围栏而不重置它。
WaitFences 调用的超时时间为 1 秒。它使用纳秒作为等待时间。如果您以 0 作为超时时间调用该函数,您可以使用它来了解 GPU 是否仍在执行命令。
接下来,我们将从交换链请求图像索引。
//request image from the swapchain
uint32_t swapchainImageIndex;
VK_CHECK(vkAcquireNextImageKHR(_device, _swapchain, 1000000000, get_current_frame()._swapchainSemaphore, nullptr, &swapchainImageIndex));
vkAcquireNextImageKHR 将从交换链请求图像索引,如果交换链没有任何我们可以使用的图像,它将阻塞线程,最大超时时间为 1 秒。
检查我们如何将 _swapchainSemaphore 发送给它。这是为了确保我们可以将其他操作与交换链准备好渲染图像同步。
我们使用此函数给出的索引来决定我们将使用哪个交换链图像进行绘制。
是时候开始渲染命令了。为此,我们将重置此帧的命令缓冲区,然后重新开始。我们将需要使用另一个初始化函数。
VkCommandBufferBeginInfo vkinit::command_buffer_begin_info(VkCommandBufferUsageFlags flags /*= 0*/)
{
VkCommandBufferBeginInfo info = {};
info.sType = VK_STRUCTURE_TYPE_COMMAND_BUFFER_BEGIN_INFO;
info.pNext = nullptr;
info.pInheritanceInfo = nullptr;
info.flags = flags;
return info;
}
当命令缓冲区启动时,我们需要为其提供一个带有某些属性的信息结构。我们将不使用继承信息,因此我们可以将其保留为 nullptr,但我们确实需要标志。
这是此结构的规范链接 VkCommandBufferBeginInfo
回到 VulkanEngine::draw()
,我们首先重置命令缓冲区并重新启动它。
//naming it cmd for shorter writing
VkCommandBuffer cmd = get_current_frame()._mainCommandBuffer;
// now that we are sure that the commands finished executing, we can safely
// reset the command buffer to begin recording again.
VK_CHECK(vkResetCommandBuffer(cmd, 0));
//begin the command buffer recording. We will use this command buffer exactly once, so we want to let vulkan know that
VkCommandBufferBeginInfo cmdBeginInfo = vkinit::command_buffer_begin_info(VK_COMMAND_BUFFER_USAGE_ONE_TIME_SUBMIT_BIT);
//start the command buffer recording
VK_CHECK(vkBeginCommandBuffer(cmd, &cmdBeginInfo));
我们将从我们的 FrameData 中复制命令缓冲区句柄到一个名为 cmd
的变量中。这是为了缩短对它的所有其他引用。Vulkan 句柄只是一个 64 位句柄/指针,因此可以随意复制它们,但请记住,它们的实际数据由 vulkan 本身处理。
现在我们调用 vkResetCommandBuffer
来清除缓冲区。这将完全删除所有命令并释放其内存。我们现在可以使用 vkBeginCommandBuffer
再次启动命令缓冲区。在 cmdBeginInfo 上,我们将为其提供标志 VK_COMMAND_BUFFER_USAGE_ONE_TIME_SUBMIT_BIT
。这是可选的,但如果我们告诉驱动程序此缓冲区仅提交和执行一次,我们可能会从命令编码中获得 небольшое 加速。在命令缓冲区重置之前,我们每帧仅执行 1 次提交,因此这对我们来说非常好。
随着命令缓冲区记录开始,让我们向其中添加命令。我们将首先将交换链图像转换为可绘制布局,然后在上面执行 VkCmdClear,最后将其转换回显示最佳布局。
这意味着我们将需要一种在命令缓冲区中转换图像的方法,而不是使用渲染通道,因此我们将在 vk_images.h 上将其添加为函数
#pragma once
#include <vulkan/vulkan.h>
namespace vkutil {
void transition_image(VkCommandBuffer cmd, VkImage image, VkImageLayout currentLayout, VkImageLayout newLayout);
}
转换图像有很多可能的选项。我们将通过仅使用 currentLayout + newLayout 来实现最简单的方法。
我们将执行管道屏障,使用同步 2 特性/扩展,它是 vulkan 1.3 的一部分。管道屏障可用于许多不同的事物,例如同步命令之间的读/写操作以及控制诸如一个命令绘制到图像中,而另一个命令使用该图像进行读取之类的事情。
将该函数添加到 vk_images.cpp。
#include <vk_initializers.h>
void vkutil::transition_image(VkCommandBuffer cmd, VkImage image, VkImageLayout currentLayout, VkImageLayout newLayout)
{
VkImageMemoryBarrier2 imageBarrier {.sType = VK_STRUCTURE_TYPE_IMAGE_MEMORY_BARRIER_2};
imageBarrier.pNext = nullptr;
imageBarrier.srcStageMask = VK_PIPELINE_STAGE_2_ALL_COMMANDS_BIT;
imageBarrier.srcAccessMask = VK_ACCESS_2_MEMORY_WRITE_BIT;
imageBarrier.dstStageMask = VK_PIPELINE_STAGE_2_ALL_COMMANDS_BIT;
imageBarrier.dstAccessMask = VK_ACCESS_2_MEMORY_WRITE_BIT | VK_ACCESS_2_MEMORY_READ_BIT;
imageBarrier.oldLayout = currentLayout;
imageBarrier.newLayout = newLayout;
VkImageAspectFlags aspectMask = (newLayout == VK_IMAGE_LAYOUT_DEPTH_ATTACHMENT_OPTIMAL) ? VK_IMAGE_ASPECT_DEPTH_BIT : VK_IMAGE_ASPECT_COLOR_BIT;
imageBarrier.subresourceRange = vkinit::image_subresource_range(aspectMask);
imageBarrier.image = image;
VkDependencyInfo depInfo {};
depInfo.sType = VK_STRUCTURE_TYPE_DEPENDENCY_INFO;
depInfo.pNext = nullptr;
depInfo.imageMemoryBarrierCount = 1;
depInfo.pImageMemoryBarriers = &imageBarrier;
vkCmdPipelineBarrier2(cmd, &depInfo);
}
VkImageMemoryBarrier2 包含给定图像屏障的信息。在这里,我们设置旧布局和新布局。在 StageMask 中,我们正在执行 ALL_COMMANDS。这是低效的,因为它会 немного 延迟 GPU 管线。对于我们的需求,这没问题,因为我们每帧只进行少量转换。如果您在后处理链中每帧进行多次转换,则要避免这样做,而是使用更准确的 StageMasks 来完成您正在做的事情。
屏障上的 AllCommands 阶段掩码意味着屏障将在到达屏障时完全停止 gpu 命令。通过使用更细粒度的阶段掩码,可以 немного 重叠跨屏障的 GPU 管线。AccessMask 类似,它控制屏障如何停止 GPU 的不同部分。我们将对我们的源使用 VK_ACCESS_2_MEMORY_WRITE_BIT
,并向我们的目标添加 VK_ACCESS_2_MEMORY_READ_BIT
。这些是通用的选项,效果会很好。
如果您想阅读有关在不同用例中使用管道屏障的最佳方法,您可以在此处找到一个很好的参考 Khronos Vulkan 文档:同步示例 此布局转换对于整个教程都非常有效,但是如果您愿意,您可以添加更复杂、更准确/轻量级的转换函数。
作为屏障的一部分,我们也需要使用 VkImageSubresourceRange
。这使我们可以使用屏障定位图像的一部分。它对于诸如数组图像或 mipmapped 图像之类的东西最有用,在这些图像中,我们只需要在给定的层或 mipmap 级别上设置屏障。我们将完全默认它,并使其转换所有 mipmap 级别和层。
VkImageSubresourceRange vkinit::image_subresource_range(VkImageAspectFlags aspectMask)
{
VkImageSubresourceRange subImage {};
subImage.aspectMask = aspectMask;
subImage.baseMipLevel = 0;
subImage.levelCount = VK_REMAINING_MIP_LEVELS;
subImage.baseArrayLayer = 0;
subImage.layerCount = VK_REMAINING_ARRAY_LAYERS;
return subImage;
}
我们在此结构中关心的一件事是 AspectMask。这将是 VK_IMAGE_ASPECT_COLOR_BIT
或 VK_IMAGE_ASPECT_DEPTH_BIT
。分别用于颜色图像和深度图像。我们不需要任何其他选项。在所有情况下,我们都将其保留为颜色方面,除非目标布局是 VK_IMAGE_LAYOUT_DEPTH_ATTACHMENT_OPTIMAL
,我们稍后在添加深度缓冲区时将使用它。
一旦我们有了范围和屏障,我们就将它们打包到 VkDependencyInfo 结构中并调用 VkCmdPipelineBarrier2
。可以通过将更多 imageMemoryBarriers 发送到依赖信息中来一次布局转换多个图像,如果我们一次对多个事物执行转换或屏障,这可能会提高性能。
通过实现的转换函数,我们现在可以绘制东西了,将 vk_images.h
添加到 vk_engine.cpp
顶部的包含中,以便我们可以使用我们刚刚编写的函数。
//make the swapchain image into writeable mode before rendering
vkutil::transition_image(cmd, _swapchainImages[swapchainImageIndex], VK_IMAGE_LAYOUT_UNDEFINED, VK_IMAGE_LAYOUT_GENERAL);
//make a clear-color from frame number. This will flash with a 120 frame period.
VkClearColorValue clearValue;
float flash = std::abs(std::sin(_frameNumber / 120.f));
clearValue = { { 0.0f, 0.0f, flash, 1.0f } };
VkImageSubresourceRange clearRange = vkinit::image_subresource_range(VK_IMAGE_ASPECT_COLOR_BIT);
//clear image
vkCmdClearColorImage(cmd, _swapchainImages[swapchainImageIndex], VK_IMAGE_LAYOUT_GENERAL, &clearValue, 1, &clearRange);
//make the swapchain image into presentable mode
vkutil::transition_image(cmd, _swapchainImages[swapchainImageIndex],VK_IMAGE_LAYOUT_GENERAL, VK_IMAGE_LAYOUT_PRESENT_SRC_KHR);
//finalize the command buffer (we can no longer add commands, but it can now be executed)
VK_CHECK(vkEndCommandBuffer(cmd));
我们从转换交换链图像开始。VK_IMAGE_LAYOUT_UNDEFINED
是“不在乎”布局。它也是新创建的图像将位于的布局。当我们不在乎图像中已有的数据,并且可以接受 GPU 销毁它时,我们使用它。
我们想要的目标布局是 VK_IMAGE_LAYOUT_GENERAL
。这是一个通用布局,允许从图像读取和写入图像。它不是渲染的最佳布局,但它是我们想要用于 vkCmdClearColorImage
的布局。如果您想从计算着色器写入图像,这就是您要使用的图像布局。如果您想要只读图像或要与光栅化命令一起使用的图像,则有更好的选择。
有关图像布局的更详细列表,您可以查看此处的规范 Vulkan 规范:图像布局
我们现在通过一个基于 _frameNumber 的基本公式计算清除颜色。我们将通过 sin 函数循环它。这将在线性插值黑色和蓝色清除颜色之间。
vkCmdClearColorImage
需要 3 个主要参数才能工作。第一个是图像,它将是来自交换链的图像。然后它需要一个清除颜色,然后它需要一个子资源范围,用于清除图像的哪个部分,我们将为此使用默认的 ImageSubresourceRange。
执行清除命令后,我们现在需要将图像转换为 VK_IMAGE_LAYOUT_PRESENT_SRC_KHR
,这是交换链允许呈现到屏幕的唯一图像布局。最后,我们通过调用 vkEndCommandBuffer
来完成。
有了这个,我们现在有了一个良好的命令缓冲区,它已被记录并准备好调度到 gpu 中。我们已经可以调用 VkQueueSubmit,但是现在它几乎没什么用处,因为我们还需要连接同步结构,以便逻辑与交换链正确交互。
我们将使用 vkQueueSubmit2
来提交我们的命令。这是同步 2 的一部分,是旧版 vulkan 1.0 的 VkQueueSubmit
的更新版本。函数调用需要一个 VkSubmitInfo2
,其中包含有关用作提交一部分的信号量的信息,我们可以为其提供一个 Fence,以便我们可以检查该提交是否已完成执行。VkSubmitInfo2
需要 VkSemaphoreSubmitInfo
用于它使用的每个信号量,以及 VkCommandBufferSubmitInfo
用于将作为提交一部分排队的命令缓冲区。让我们检查一下这些的 vkinit 函数。
VkSemaphoreSubmitInfo vkinit::semaphore_submit_info(VkPipelineStageFlags2 stageMask, VkSemaphore semaphore)
{
VkSemaphoreSubmitInfo submitInfo{};
submitInfo.sType = VK_STRUCTURE_TYPE_SEMAPHORE_SUBMIT_INFO;
submitInfo.pNext = nullptr;
submitInfo.semaphore = semaphore;
submitInfo.stageMask = stageMask;
submitInfo.deviceIndex = 0;
submitInfo.value = 1;
return submitInfo;
}
VkCommandBufferSubmitInfo vkinit::command_buffer_submit_info(VkCommandBuffer cmd)
{
VkCommandBufferSubmitInfo info{};
info.sType = VK_STRUCTURE_TYPE_COMMAND_BUFFER_SUBMIT_INFO;
info.pNext = nullptr;
info.commandBuffer = cmd;
info.deviceMask = 0;
return info;
}
VkSubmitInfo2 vkinit::submit_info(VkCommandBufferSubmitInfo* cmd, VkSemaphoreSubmitInfo* signalSemaphoreInfo,
VkSemaphoreSubmitInfo* waitSemaphoreInfo)
{
VkSubmitInfo2 info = {};
info.sType = VK_STRUCTURE_TYPE_SUBMIT_INFO_2;
info.pNext = nullptr;
info.waitSemaphoreInfoCount = waitSemaphoreInfo == nullptr ? 0 : 1;
info.pWaitSemaphoreInfos = waitSemaphoreInfo;
info.signalSemaphoreInfoCount = signalSemaphoreInfo == nullptr ? 0 : 1;
info.pSignalSemaphoreInfos = signalSemaphoreInfo;
info.commandBufferInfoCount = 1;
info.pCommandBufferInfos = cmd;
return info;
}
command_buffer_submit_info 只需要命令缓冲区句柄。我们不需要任何其他东西,我们可以将 deviceMask 保留为 0,因为我们不是它。
semaphore_submit_info 需要一个 StageMask,这与我们在 transition_image 函数中看到的一样。除此之外,它只需要信号量句柄。设备索引参数用于多 gpu 信号量使用,但我们不会执行任何操作。value 用于时间线信号量,这是一种特殊类型的信号量,它们通过计数器而不是二进制状态工作。我们也不会使用它们,因此我们可以将其默认设置为 1
submit_info 将所有内容排列在一起。它需要命令提交信息,然后是信号量等待和信号信息。我们只使用 1 个信号量分别用于等待和信号,但可以一次信号或等待多个信号量以用于更复杂的系统。
以下是这些结构的规范链接:VkCommandBufferSubmitInfo, VkSemaphoreSubmitInfo, VkSubmitInfo2
使用初始值设定项,我们可以编写提交本身。
//prepare the submission to the queue.
//we want to wait on the _presentSemaphore, as that semaphore is signaled when the swapchain is ready
//we will signal the _renderSemaphore, to signal that rendering has finished
VkCommandBufferSubmitInfo cmdinfo = vkinit::command_buffer_submit_info(cmd);
VkSemaphoreSubmitInfo waitInfo = vkinit::semaphore_submit_info(VK_PIPELINE_STAGE_2_COLOR_ATTACHMENT_OUTPUT_BIT_KHR,get_current_frame()._swapchainSemaphore);
VkSemaphoreSubmitInfo signalInfo = vkinit::semaphore_submit_info(VK_PIPELINE_STAGE_2_ALL_GRAPHICS_BIT, get_current_frame()._renderSemaphore);
VkSubmitInfo2 submit = vkinit::submit_info(&cmdinfo,&signalInfo,&waitInfo);
//submit command buffer to the queue and execute it.
// _renderFence will now block until the graphic commands finish execution
VK_CHECK(vkQueueSubmit2(_graphicsQueue, 1, &submit, get_current_frame()._renderFence));
我们首先创建每个所需的不同信息结构,然后我们调用 vkQueueSubmit2
。对于我们的命令信息,我们只是发送我们刚刚记录的命令。对于等待信息,我们将使用当前帧的交换链信号量。当我们调用 vkAcquireNextImageKHR
时,我们将此相同的信号量设置为已发出信号,因此通过这样做,我们确保在此处执行的命令在交换链图像准备就绪之前不会开始。
对于信号信息,我们将使用当前帧的 _renderSemaphore,这将使我们能够与在屏幕上呈现图像同步。
对于围栏,我们将使用当前帧 _renderFence。在绘制循环开始时,我们等待相同的围栏准备就绪。这就是我们将 gpu 与 cpu 同步的方式,因为当 cpu 领先于 GPU 时,围栏将阻止我们,因此我们不会使用此帧中的任何其他结构,直到绘制命令执行完毕。
我们在帧上需要的最后一件事是将我们刚刚绘制的图像呈现到屏幕上
//prepare present
// this will put the image we just rendered to into the visible window.
// we want to wait on the _renderSemaphore for that,
// as its necessary that drawing commands have finished before the image is displayed to the user
VkPresentInfoKHR presentInfo = {};
presentInfo.sType = VK_STRUCTURE_TYPE_PRESENT_INFO_KHR;
presentInfo.pNext = nullptr;
presentInfo.pSwapchains = &_swapchain;
presentInfo.swapchainCount = 1;
presentInfo.pWaitSemaphores = &get_current_frame()._renderSemaphore;
presentInfo.waitSemaphoreCount = 1;
presentInfo.pImageIndices = &swapchainImageIndex;
VK_CHECK(vkQueuePresentKHR(_graphicsQueue, &presentInfo));
//increase the number of frames drawn
_frameNumber++;
vkQueuePresent
具有与队列提交非常相似的信息结构。它还具有信号量的指针,但它具有图像索引和交换链索引。我们将等待 _renderSemaphore,并将其连接到我们的交换链。这样,我们直到它完成之前提交的渲染命令后才会在屏幕上呈现图像。
在该函数末尾,我们递增帧计数器。
有了这个,我们完成了绘制循环。剩下唯一的事情是在清理函数中正确清理同步结构
for (int i = 0; i < FRAME_OVERLAP; i++) {
//already written from before
vkDestroyCommandPool(_device, _frames[i]._commandPool, nullptr);
//destroy sync objects
vkDestroyFence(_device, _frames[i]._renderFence, nullptr);
vkDestroySemaphore(_device, _frames[i]._renderSemaphore, nullptr);
vkDestroySemaphore(_device ,_frames[i]._swapchainSemaphore, nullptr);
}
此时尝试运行引擎。如果一切正确,您应该会有一个窗口,其中闪烁着蓝色屏幕。检查验证层,因为它们会捕获可能的同步问题。
第一章到此结束,接下来我们将开始使用一些计算着色器来绘制除简单闪烁屏幕之外的其他内容。
下一章:第 2 章:改进渲染循环