将 Dear ImGui 集成到自定义 Vulkan 渲染器中02

发布于

我们现在可以使用之前创建的附件为我们的渲染通道创建一个子通道。这显然是一个图形子通道。


VkSubpassDescription subpass = {};
subpass.pipelineBindPoint = VK_PIPELINE_BIND_POINT_GRAPHICS;
subpass.colorAttachmentCount = 1;
subpass.pColorAttachments = &color_attachment;

现在棘手的部分来了。我们有两个渲染通道需要同步。据我所知,在Vulkan中没有关于不同渲染通道的隐式同步。在Vulkan中有广泛的同步原语可供选择,但我们可以很快消除其中一些。Fences用于同步CPU和GPU,这是一种过度的方式。信号量用于跨多个队列和/或硬件同步事件。它在这里也不相关。我对事件不太熟悉,似乎它们可以做到这一点,但我们不会在这里使用它们。

最后剩下了管道障碍pipeline barriers和外部子通道依赖关系external subpass dependencies。正如在这篇关于Vulkan同步的伟大概述中所解释的那样,它们都非常相似。子通道依赖基本上就是驱动程序为你插入一个管道屏障。我选择这种方法是因为理论上它比手工更优化。此外,我还查看了imgui_impl_vulkan.cpp,这就是它在那里是如何完成的。

VkSubpassDependency dependency = {};
dependency.srcSubpass = VK_SUBPASS_EXTERNAL;
dependency.dstSubpass = 0;
dependency.srcStageMask = VK_PIPELINE_STAGE_COLOR_ATTACHMENT_OUTPUT_BIT;
dependency.dstStageMask = VK_PIPELINE_STAGE_COLOR_ATTACHMENT_OUTPUT_BIT;
dependency.srcAccessMask = 0;  // or VK_ACCESS_COLOR_ATTACHMENT_WRITE_BIT;
dependency.dstAccessMask = VK_ACCESS_COLOR_ATTACHMENT_WRITE_BIT;

所以我们的srcSubpass必须是VK_SUBPASS_EXTERNAL,以在当前渲染通道之外创建依赖项。我们可以通过dstSubpass的索引0来引用dstSubpass中的第一个也是唯一一个子通道。

现在我们需要说明我们在等待什么。在绘制GUI之前,我们希望已经渲染了几何图形。这意味着我们希望像素已经写入到framebuffer中。

幸运的是,有一个名为VK_PIPELINE_STAGE_COLOR_ATTACHMENT_OUTPUT_BIT的阶段,我们可以将我们的srcStageMask设置为它。我们还可以将dstStageMask设置为相同的值,因为我们的GUI也将被绘制到相同的目标。在我们自己写像素之前,我们基本上是在等待像素被写入。

至于访问掩码,我不太清楚它们是如何工作的。如果我们查看imgui_impl_vulkan.cpp,我们可以看到srcAccessMask被设置为0, dstAccessMask被设置为VK_ACCESS_COLOR_ATTACHMENT_WRITE_BIT。我认为srcAccessMask也应该使用VK_ACCESS_COLOR_ATTACHMENT_WRITE_BIT标志。在我们自己写之前,我们正在等待写完成。我这里没有答案,随你便吧。两者都在我的电脑上工作。


我们现在准备好通过将我们刚刚创建的所有内容放在一起来最终创建渲染通道。为了完整起见,这里是完成渲染通道创建的代码。


VkRenderPassCreateInfo info = {};
info.sType = VK_STRUCTURE_TYPE_RENDER_PASS_CREATE_INFO;
info.attachmentCount = 1;
info.pAttachments = &attachment;
info.subpassCount = 1;
info.pSubpasses = &subpass;
info.dependencyCount = 1;
info.pDependencies = &dependency;
if (vkCreateRenderPass(device, &info, nullptr, &imGuiRenderPass) != VK_SUCCESS) {
    throw std::runtime_error("Could not create Dear ImGui's render pass");
}


顺便说一下,不要忘记改变你的倒数第二个渲染通道。

假设你只有一个主要渲染的过程。你应该设置它的finalLayout字段为VK_IMAGE_LAYOUT_COLOR_ATTACHMENT_OPTIMAL,因为它不再呈现了

将字体上传到 GPU Uploading fonts to the GPU

评论建议的下一点是关于将字体上传到 GPU。起初它可能看起来很复杂,但归结为 Dear ImGui 特有的一行。我还没有调查它到底做了什么,但是如果你有兴趣的话,周围有很多评论指向正确的方向。


ImGui_ImplVulkan_CreateFontsTexture(command_buffer);


它周围的代码处理命令缓冲区的创建和提交。您可能已经准备好用于一次性使用命令缓冲区的函数(例如,如果您按照Alexander Overvoorde 的教程进行操作)。代码简化为 3 行。


VkCommandBuffer command_buffer = beginSingleTimeCommands();
ImGui_ImplVulkan_CreateFontsTexture(command_buffer);
endSingleTimeCommands(command_buffer);


如果你没有准备好这样的东西,没关系。您可以简单地重用提供的代码。该变量g_Device是您在引擎中创建的逻辑设备,g_Queue也是您查询的图形队列。您可能想知道应该为VkCommandBufferand使用什么VkCommandPool。好吧,我们将重用允许我们将 Dear ImGui 相关命令提交给 GPU 的命令缓冲区和命令池。我们马上就会讲到这些。

主循环The main loop

好的,据我们所知,我们已经完成了初始化。你可以看出,因为主循环正在启动。我们正在启动一个无限循环,只有当 GLFW 告诉我们用户要求关闭窗口时才会停止。同样,您应该已经在某个地方启动并运行它。


while (!glfwWindowShouldClose(window)){
    // Your amazing Vulkan stuff
}


该函数的开头非常标准:调用以glfwPollEvents检测用户输入和一些代码以在需要时重新创建交换链。您应该已经拥有这两者来处理用户输入和事件,例如调整窗口大小。这里与 DearImGui 唯一相关的是,在重新创建交换链时,图像视图的最小数量可能已经改变。你必须打电话告诉Dear ImGui ImGui_ImplVulkan_SetMinImageCount。


if (g_SwapChainRebuild)
{
    g_SwapChainRebuild = false;
    ImGui_ImplVulkan_SetMinImageCount(g_MinImageCount);
    ImGui_ImplVulkanH_CreateWindow(g_Instance, g_PhysicalDevice, g_Device, &g_MainWindowData, 
            g_QueueFamily, g_Allocator, g_SwapChainResizeWidth, g_SwapChainResizeHeight, g_MinImageCount);
    g_MainWindowData.FrameIndex = 0;
}


描述用户界面Describing the UI

下一部分是您可以实际编写 UI 代码的地方。这不是本文要讨论的内容,因此我们将仅使用演示窗口。它还有助于对正在发生的事情有更高的了解。


ImGui_ImplVulkan_NewFrame();
ImGui_ImplGlfw_NewFrame();
ImGui::NewFrame();
ImGui::ShowDemoWindow();
ImGui::Render();


首先我们需要创建一个新框架,然后我们描述我们的 UI,最后我们让 Dear ImGui 来渲染它。这很简单。您可能想知道为什么创建一个新框架需要 3 个不同的调用,这是一个非常好的问题。的实现ImGui_ImplVulkan_NewFrame实际上是空的。我想这只是作者的一个预防措施,以防有一天这里有一些 Vulkan 特定的代码要添加。ImGui_ImplGlfw_NewFrame另一方面,调用 to是有道理的。它用于处理用户输入、屏幕大小调整等。最后,我们需要初始化一个实际的 ImGuiFrame。

现在是我们主循环的最后三行。第一个特定于示例并且无关紧要。另一方面,接下来的两个是我感兴趣的。您实际上应该自己拥有一些版本。这是您从交换链获取图像视图的地方,可能会记录一些命令缓冲区并最终提交它们。我们仍然需要稍微修改这些函数以集成我们新的渲染操作。

渲染 UI - 第 1 部分 我们先来看看FrameRender。前几行是关于从交换链获取新图像并使用栅栏将 CPU 与 GPU 同步。最后一个操作确保我们向 GPU 提交的帧不会超过我们可用的帧数。这不是 Dear ImGui 特有的,您的代码中应该有相同类型的构造。


VkSemaphore image_acquired_semaphore  = wd->FrameSemaphores[wd->SemaphoreIndex].ImageAcquiredSemaphore;
VkSemaphore render_complete_semaphore = wd->FrameSemaphores[wd->SemaphoreIndex].RenderCompleteSemaphore;
err = vkAcquireNextImageKHR(g_Device, wd->Swapchain, UINT64_MAX, image_acquired_semaphore, VK_NULL_HANDLE, &wd->FrameIndex);
check_vk_result(err);

ImGui_ImplVulkanH_Frame* fd = &wd->Frames[wd->FrameIndex];
{
    err = vkWaitForFences(g_Device, 1, &fd->Fence, VK_TRUE, UINT64_MAX);    // wait indefinitely instead of periodically checking
    check_vk_result(err);

    err = vkResetFences(g_Device, 1, &fd->Fence);
    check_vk_result(err);
}


下一部分变得越来越有趣。我们正在重置命令池并开始将命令记录到命令缓冲区中。那么我们为什么要这样做呢?好吧,我们的 UI 可以有多种状态,窗口会来来去去,按钮会被添加等等。我们不能一劳永逸地记录我们的缓冲区。每次内容更改时,我们都需要记录它们。快速简便的解决方案是每帧都执行此操作。我想人们可以找到一种方法来散列 UI 并仅在必要时重建命令缓冲区,但我们现在不会这样做。


{
    err = vkResetCommandPool(g_Device, fd->CommandPool, 0);
    check_vk_result(err);
    VkCommandBufferBeginInfo info = {};
    info.sType = VK_STRUCTURE_TYPE_COMMAND_BUFFER_BEGIN_INFO;
    info.flags |= VK_COMMAND_BUFFER_USAGE_ONE_TIME_SUBMIT_BIT;
    err = vkBeginCommandBuffer(fd->CommandBuffer, &info);
    check_vk_result(err);
}


更多初始化 我们还没有真正在初始化部分设置任何命令池或命令缓冲区,所以我们必须对此进行补救。我们需要知道的第一件事是我们需要多少。快速浏览一下代码应该会告诉您单个命令缓冲区足以进行绘制。命令池也是如此。但是,与 Vulkan 中的往常一样,我们可能会提前准备多个帧。这意味着我们不能对多个帧使用相同的命令缓冲区。我们必须创建与帧一样多的命令缓冲区和命令池。

人们可能认为单个池足以容纳所有命令缓冲区,但vkResetCommandPool会重置所有命令缓冲区,包括仍在使用的命令缓冲区。因此,我们需要其中的几个。好消息是,如果你尝试,验证层会抓住你(好吧,他们抓住了我)。


imGuiCommandPools.resize(imageViews.size());
imGuiCommandBuffers.resize(imageViews.size());
for (size_t i = 0; i < imageViews.size(); i++) {
    createCommandPool(&imGuiCommandPools[i], VK_COMMAND_POOL_CREATE_RESET_COMMAND_BUFFER_BIT);
    createCommandBuffers(&imGuiCommandBuffers[i], 1, imGuiCommandPools[i]);
}


我们先来看看命令池。我们的createCommandPool函数只需要两个参数,要创建的命令池和我们要设置的标志。在这种情况下,我们使用VK_COMMAND_POOL_CREATE_RESET_COMMAND_BUFFER_BIT. 为什么我们使用这个标志?首先是因为我看了imgui_impl_vulkan.cpp看它就在那里。其次,因为我们正在调用vkResetCommandPool每一帧。这是规范对此的看法。

VK_COMMAND_POOL_CREATE_RESET_COMMAND_BUFFER_BIT 允许从池中分配的任何命令缓冲区单独重置为初始状态;通过调用 vkResetCommandBuffer,或者通过调用 vkBeginCommandBuffer 时的隐式重置。

所以规范基本上是说调用vkResetCommandPool是没用的,对吧?好吧,我会这么认为。我在我的电脑上试过了,它工作正常,验证层也没有抱怨。我想由你决定你希望你的代码有多明确。但这让我想到,如果我不需要重置整个池,那么我也不需要多个池。我尝试从同一个池中设置所有命令缓冲区并删除调用vkResetCommandPool,一切似乎都很好,验证层明智或其他。这是新代码的样子。


createCommandPool(&imGuiCommandPool, VK_COMMAND_POOL_CREATE_RESET_COMMAND_BUFFER_BIT);
imGuiCommandBuffers.resize(imageViews.size());
createCommandBuffers(imGuiCommandBuffers.data(), static_cast<uint32_t>(imGuiCommandBuffers.size()), imGuiCommandPool;


另一方面,根本不设置VK_COMMAND_POOL_CREATE_RESET_COMMAND_BUFFER_BIT标志会给你带来很多麻烦!

VK_COMMAND_POOL_CREATE_RESET_COMMAND_BUFFER_BIT allows any command buffer allocated from a pool to be individually reset to the initial state; either by calling vkResetCommandBuffer, or via the implicit reset when calling vkBeginCommandBuffer.

如果需要,这里是createCommandPool函数的完整代码。它没有什么花哨的。


void createCommandPool(VkCommandPool* commandPool, VkCommandPoolCreateFlags flags) {
    VkCommandPoolCreateInfo commandPoolCreateInfo = {};
    commandPoolCreateInfo.sType = VK_STRUCTURE_TYPE_COMMAND_POOL_CREATE_INFO;
    commandPoolCreateInfo.queueFamilyIndex = graphicsFamilyIndex;
    commandPoolCreateInfo.flags = flags;

    if (vkCreateCommandPool(device, &commandPoolCreateInfo, nullptr, commandPool) != VK_SUCCESS) {
        throw std::runtime_error("Could not create graphics command pool");
    }
}


最后我们可以创建我们的命令缓冲区。这没有什么特别的,我们只是传递关联的命令池并分配命令缓冲区。请注意,vkAllocateCommandBuffers可以同时分配多个缓冲区,但只能从同一个池中分配。这就是为什么我们必须createCommandBuffers在第一个版本中调用循环内部的原因,现在我们可以一次调用它来一次分配所有命令缓冲区。同样,如果您需要,这里是完整的代码。


void createCommandBuffers(VkCommandBuffer* commandBuffer, uint32_t commandBufferCount, VkCommandPool &commandPool) {
    VkCommandBufferAllocateInfo commandBufferAllocateInfo = {};
    commandBufferAllocateInfo.sType = VK_STRUCTURE_TYPE_COMMAND_BUFFER_ALLOCATE_INFO;
    commandBufferAllocateInfo.level = VK_COMMAND_BUFFER_LEVEL_PRIMARY;
    commandBufferAllocateInfo.commandPool = commandPool;
    commandBufferAllocateInfo.commandBufferCount = commandBufferCount;
    vkAllocateCommandBuffers(device, &commandBufferAllocateInfo, commandBuffer);
}


如果您选择保留由main.cpp. 您可以使用我们的新命令池和任何新命令缓冲区将字体上传到 GPU。

渲染 UI - 第 2 部分 回到我们的FrameRender功能。下一点是关于开始渲染过程。我们可以看到我们需要一个渲染通道。这是我们在这里专门为 DearImGui 创建的。我们还看到我们需要一个帧缓冲区。问题是我们还没有创建任何东西,所以我们需要做更多的初始化。

宽度和高度参数可以在VkExtent2D您用于创建交换链和clearValueCount定义pClearValues您要用于清除帧缓冲区的颜色中找到。最后,我们使用当前帧的命令缓冲区和我们刚刚构建的信息启动渲染通道。


{
    VkRenderPassBeginInfo info = {};
    info.sType = VK_STRUCTURE_TYPE_RENDER_PASS_BEGIN_INFO;
    info.renderPass = wd->RenderPass;
    info.framebuffer = fd->Framebuffer;
    info.renderArea.extent.width = wd->Width;
    info.renderArea.extent.height = wd->Height;
    info.clearValueCount = 1;
    info.pClearValues = &wd->ClearValue;
    vkCmdBeginRenderPass(fd->CommandBuffer, &info, VK_SUBPASS_CONTENTS_INLINE);
}


我并没有非常坚持这样一个事实,即对于交换链中的每一个imageView,我们都使用特定的资源。这在您的脑海中应该非常清楚。这就是为什么我们有一个命令缓冲区数组并且我们将很快创建一个帧缓冲区数组的原因。Dear ImGui 提供的示例将这种复杂性隐藏在其fd对象后面。在上面的代码片段中,您应该理解fd->XXX为imageViewResources[imageViewId]->XXX. 该索引imageViewId由 提供vkAcquireNextImageKHR。

更多初始化 - 第 2 部分 那么我们应该使用什么作为我们的帧缓冲区呢?您可能已经有一组帧缓冲区,每个帧都有一个。那么为什么不使用它们呢?您可以尝试,但验证层很可能再次对您大喊大叫。他们首先会告诉您您正在尝试呈现布局错误的图像,然后渲染通道与帧缓冲区不兼容,因为附件数量不兼容等等。简而言之,这是一个坏主意。它们不兼容。我们再次(我保证,这是最后一次)忘记初始化某些东西。

那么我们的主要渲染帧缓冲区有什么问题呢?那么首先他们可能有太多的附件。例如,Dear ImGui 不需要深度缓冲区,或者如果您使用 MSAA,Dear ImGui 也不需要解析缓冲区。那他们需要什么?最简单的方法是通过ctrl-f您的方式imgui_impl_vulkan.cpp找到一个呼叫vkCreateFramebuffer。这是我发现的。


{
    VkImageView attachment[1];
    VkFramebufferCreateInfo info = {};
    info.sType = VK_STRUCTURE_TYPE_FRAMEBUFFER_CREATE_INFO;
    info.renderPass = wd->RenderPass;
    info.attachmentCount = 1;
    info.pAttachments = attachment;
    info.width = wd->Width;
    info.height = wd->Height;
    info.layers = 1;
    for (uint32_t i = 0; i < wd->ImageCount; i++)
    {
        ImGui_ImplVulkanH_Frame* fd = &wd->Frames[i];
        attachment[0] = fd->BackbufferView;
        err = vkCreateFramebuffer(device, &info, allocator, &fd->Framebuffer);
        check_vk_result(err);
    }
}


正如我所提到的,我们只需要一个附件,即我们将要吸引的那个附件。渲染通道、宽度和高度与我们在此处提供的完全相同,并且 layers为 1,因为我们的 imageView 是单个图像而不是数组。

现在对于实际的帧缓冲区创建,我们再次创建尽可能多的 imageViews。这里的命名对我来说不是很明确,但实际上发生的事情非常简单。我们只需要为每个 imageView 创建一个 framebuffer 并在附件中引用它。这就是 for 循环中发生的所有事情。imageViewwd->Frames[i]->BackbufferView在 Dear ImGui 的代码中。同样,您已经在某个地方拥有这些,否则您将无法绘制或呈现任何东西。

渲染 UI - 第 3 部分 那么,我们做了什么?我们创建了特定于 GUI 的帧缓冲区,并使用它们在我们当前正在记录的命令缓冲区内开始渲染传递。下一个函数调用是完成所有工作的函数。这是 Dear ImGui 将其所有绘制调用添加到我们的渲染通道。


// Record Imgui Draw Data and draw funcs into command buffer
ImGui_ImplVulkan_RenderDrawData(ImGui::GetDrawData(), fd->CommandBuffer);


记录所有调用后,我们可以结束渲染过程和命令缓冲区。在当前版本的示例中,结束命令缓冲区的调用在代码中稍稍靠后,但我发现在结束渲染过程后立即将其放入更易读。它根本不会改变行为。


// Submit command buffer
vkCmdEndRenderPass(fd->CommandBuffer);
err = vkEndCommandBuffer(fd->CommandBuffer);
check_vk_result(err);


示例中的下一部分是将命令缓冲区提交给 GPU。此代码有点特定于您的引擎,并且取决于您如何设置帧之间的同步。好消息是您已经拥有此代码。vkQueueSubmit您基本上通过以 aVkSubmitInfo作为参数的调用来提交命令。此提交信息结构有一个字段pCommandBuffers,您可以在其中放置单个命令缓冲区或它们的数组。您唯一需要做的就是将与 GUI 相关的命令缓冲区添加到此数组中。如果您之前只有一个命令缓冲区,只需动态创建一个包含这两个命令缓冲区的数组。从顶层看,它应该看起来像这样。


std::array<VkCommandBuffer, 2> submitCommandBuffers = 
    { commandBuffers[imageIndex], imGuiCommandBuffers[imageIndex] };
VkSubmitInfo submitInfo = {};
submitInfo.sType = VK_STRUCTURE_TYPE_SUBMIT_INFO;
(...)
submitInfo.commandBufferCount = static_cast<uint32_t>(submitCommandBuffers.size());
submitInfo.pCommandBuffers = submitCommandBuffers.data();
if (vkQueueSubmit(graphicsQueue, 1, &submitInfo, inFlightFences[currentFrame]) != VK_SUCCESS) {
    throw std::runtime_error("Could not submit commands to the graphics queue");
}


展示

我们完成了FrameRender!我们留下了FramePresent,然后我们将完成主循环。你知道吗?Dear ImGui 并没有什么特别之处。没错,您已经再次拥有此代码。

另外,不要忘记查看控制台,验证层会抱怨您没有做正确的事情并自行清理。我们为 Dear ImGui 创建了许多资源,并且从未花时间清理这些资源。不要忘记在交换链重新创建时,Dear ImGui 渲染通道、命令池、命令缓冲区和帧缓冲区必须被销毁然后重新创建!


// Resources to destroy on swapchain recreation
for (auto framebuffer : imGuiFramebuffers) {
    vkDestroyFramebuffer(device, framebuffer, nullptr);
}

vkDestroyRenderPass(device, imGuiRenderPass, nullptr);

vkFreeCommandBuffers(device, imGuiCommandPool, static_cast<uint32_t>(imGuiCommandBuffers.size()), imGuiCommandBuffers.data());
vkDestroyCommandPool(device, imGuiCommandPool, nullptr);

// Resources to destroy when the program ends
ImGui_ImplVulkan_Shutdown();
ImGui_ImplGlfw_Shutdown();
ImGui::DestroyContext();
vkDestroyDescriptorPool(device, imguiDescriptorPool, nullptr);


参考资料[An introduction to the Dear ImGui library] (https://blog.conan.io/2019/06/26/An-introduction-to-the-Dear-ImGui-library.html)


5
评论 1
收藏
  • MR7
    MR7
    欢迎加入Piccolo社区!感谢分享~