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

发布于

Prerequisite

在学习vulkan tutorial过程中,给自己的项目代码加上了UI控件, 便于学习vulkan的渲染管线是怎样搭建的和实现一个离线算法(ps: 比OpenGL复杂很多) 此文主要是参考Integrating Dear ImGui in a custom Vulkan renderer的文章基础上,加上了自己的实践总结。

接触Dear ImGui

Dear ImGui 包含在几个文件中,您可以轻松地复制并编译到您的应用程序/引擎中:


  • imgui.cpp
  • imgui.h
  • imgui_demo.cpp
  • imgui_draw.cpp
  • imgui_widgets.cpp
  • imgui_internal.h
  • imconfig.h(默认为空,用户可编辑)
  • imstb_rectpack.h
  • imstb_textedit.h
  • imstb_truetype.h
  • 不需要特定的构建过程。您可以将.cpp文件添加到项目中,或者从现有文件中#include它们。

这里需要注意什么?

  1. 所有的.cpp文件必须添加到项目中。忘记其中一些似乎是一个常见的错误,因为我自己做过,并立即找到了一个Stack Overflow关于它的帖子。我确信这是一个头文件库,结果不是。所以如果你使用Visual Studio,你应该把上面列出的所有.cpp文件添加到你的源文件中。
  2. 您还需要包括头文件。为此,你可以使用#include "my_path/my_file.h"来包含它们,或者告诉VisualStudio它们的位置并使用#include 来包含它们。允许您这样做的菜单位于:Debug->Properties->C/ c++ ->Additional Include Directories

好了,让我们继续阅读。接下来是一个看起来很容易理解的代码示例。


ImGui::Text("Hello, world %d", 123);  
if (ImGui::Button("Save"))  
{  
    // do stuff  
}  
ImGui::InputText("string", buf, IM_ARRAYSIZE(buf));  
ImGui::SliderFloat("float", &f, 0.0f, 1.0f);


那怎么办?我是否只包含 imgui.h,复制并粘贴此代码示例,并且这一切都通过某种黑魔法起作用?那会很酷,但也不是很灵活。要了解为什么这不起作用,我们需要了解 Dear ImGui 是什么。首先,它是一个即时模式的 GUI,这意味着用户可以控制数据而不是 GUI 本身。这使得 GUI 比保留模式 GUI 低级一点。然而,正如作者所说,将即时模式 GUI 与即时模式渲染分开是很重要的。


即时模式渲染(…)通常意味着在调用 GUI 函数时,用一堆低效的绘制调用和状态更改来驱动你的驱动程序/GPU。这不是 Dear ImGui 所做的。Dear ImGui 输出顶点缓冲区和一小部分绘制调用批次


这个句子有趣的另一个原因是。它只是给了我们一个核心想法,我们需要做什么才能使用Dear ImGui。我们需要能够使用它输出的顶点缓冲区并处理绘制调用批次。它明确地解释了为什么我们不能简单地复制和粘贴一些代码示例。我们需要在引擎中更深入地集成Dear ImGui,以便能够处理其输出。我们如何做到这一点?好吧,readme文件的下一部分就是关于它的。

在您的自定义引擎中集成 Dear ImGui 只需:

  1. 连接鼠标/键盘/游戏手柄输入
  2. 将一个纹理上传到您的 GPU/渲染引擎
  3. 提供可以绑定纹理和渲染纹理三角形的渲染功能。examples/ 文件夹中填充了执行此操作的应用程序。

阅读样例

让我们打开示例文件夹,看看我们有什么。里面有很多东西,但只有两个关于 Vulkan 的文件夹:example_glfw_vulkan和example_sdl_vulkan. 我对 SDL 没有任何经验,我遵循的教程使用 GLFW,

因此我们将example_glfw_vulkan在本文的其余部分使用。

在这个文件夹中有几个与构建相关的文件,Visual Studio 文件,着色器代码文件,最后是main.cpp. 在此文件的开头,您将找到一些有关如何设置 Dear ImGui 的相关信息。

// dear imgui: standalone example application for Glfw + Vulkan 
// If you are new to dear imgui, see examples/README.txt and documentation at the top of imgui.cpp.

让我们从examples/README.txt. 它首先提醒您需要将字体加载到 GPU,将键盘和鼠标输入传递给 Dear ImGui,并为其提供渲染三角形的方法。到目前为止没有什么新鲜事。下一位包含更多相关信息。


这个文件夹包含两个东西:

  • 流行平台/图形 API 的绑定示例,您可以按原样使用或适应您自己的使用。它们是在backends/中找到的imgui_impl_XXXX文件。
  • 使用上述绑定的示例应用程序(独立的、可构建的)。它们位于XXXX_example/子文件夹中。

基本上,这意味着在示例文件夹中有一些实际的示例应用程序,也有一些核心文件的扩展。

这些扩展可以用来更容易地将Dear ImGui集成到您的引擎中。backends/文件夹中提供了各种图形 API 和渲染平台的后端,我们应该对backends/文件夹中的imgui_impl_vulkan文件感兴趣。再读一点,看起来我们还需要imgui_impl_glfw文件。


大多数示例绑定分为两部分:

  • “平台”绑定,负责:鼠标/键盘/游戏手柄输入、光标形状、时间、窗口。 示例:Windows ( imgui_impl_win32.cpp)、GLFW ( imgui_impl_glfw.cpp)、SDL2 ( imgui_impl_sdl.cpp)
  • “Renderer”绑定,负责:创建主字体纹理,渲染imgui绘制数据。 示例:DirectX11 ( imgui_impl_dx11.cpp)、GL3 ( imgui_impl_opengl3.cpp)、Vulkan ( imgui_impl_vulkan.cpp)

好的,所以现在你们中的一些人可能会认为他们想要自己编写所有代码,并且使用这些imgui_impl_vulkan文件是作弊或其他什么。是的,当然。您可以自己从头开始重写所有内容。如果您想控制整个代码库并使其一切都很好且统一,那么这可能是最终最好的方法,但作为第一步,采用简单的方法可能是最好的

  • 如果您使用自己的引擎,您可能会决定使用一些现有的绑定和/或使用自己的 API 重写一些。作为建议,如果您是 Dear ImGui 的新手,请先尝试按原样使用现有绑定,然后再继续重写一些代码。尽管重写这两个imgui_impl_xxxx文件以适应您的编码风格是很诱人的,但考虑到这不是必需的!事实上,如果您是 Dear ImGui 的新手,重写它们几乎总是更难。

这就是examples/README.txt的内容。到下一个文件,imgui.cpp 有大量的信息。事实上,Dear ImGui 的整个文档都在那里。我不会一步一步地引导你,因为要介绍的内容太多了。如果您想重新编写imgui_impl_xxxx文件,这尤其有趣,但我们现在不这样做。

好,回到我们开始的地方,example_glfw_vulkan文件夹中的main.cpp文件。现在我们已经简单地阅读了文档,我们已经准备好了解接下来的内容了。

// 给希望将 imgui_impl_vulkan.cpp/.h 集成到他们自己的引擎/应用程序中的读者的重要提示。
//  常用 ImGui_ImplVulkan_XXX 函数和结构用于与 imgui_impl_vulkan.cpp/.h 交互。
// 如果你想在你的引擎/应用程序中使用这个渲染后端,你将使用上面那些函数与结构体。
// - Helper 
// ImGui_ImplVulkanH_XXX 函数和结构仅用于此示例 (main.cpp) 和后端本身 (imgui_impl_vulkan.cpp),
// 但您自己的引擎/应用代码可能不应该使用它。
// 阅读 imgui_impl_vulkan.h 中的注释。

这是一条非常重要的信息。我们被告知应该主要使用ImGui_ImplVulkan_XXX函数和结构,而不是ImGui_ImplVulkanH_XXX函数和结构。为什么?因为这些都是与Vulkan相关的辅助函数,你的引擎中应该已经有了大部分的辅助函数。

现在我们知道了什么是可以保存的,什么是必须插入到我们自己的自定义引擎中的,我们已经准备好阅读代码本身了。

研究案例代码

在main.cpp文件的开头,我们看到我们显然需要包括imgui_impl_glfw.h和imgui_impl_vulkan.h。您还可以看到与GLFW相关的内容,但您应该已经拥有这些内容。

在此之后,声明了一堆与Vulkan相关的变量和函数。这些都是你应该有的。现在不要太担心它们,我们稍后会看到你的引擎需要暴露什么来与Dear ImGui交互。为此,我们直接看一下主函数。

初始化Dear ImGui

第一个代码块是关于设置 GLFW 窗口、Vulkan 上下文、表面和主帧缓冲区。为此,调用了我们之前跳过的一些函数。如果您已经有一个启动并运行的 Vulkan 应用程序,那么您已经在某个地方完成了此操作。稍后,我们将需要公开在此阶段创建的一些对象,例如 GLFW window,以便将 Dear ImGui 与您的引擎接口。

下一个区块最后是关于Dear ImGui 的一些细节。在这里,我们正在为 Dear ImGui 创建一个上下文,选择我们希望为其提供访问权限的输入和一个主题。这些只是来自的功能imgui.h,这里没有任何与 Vulkan 或 GLFW 相关的内容。


// Setup Dear ImGui context
IMGUI_CHECKVERSION();
ImGui::CreateContext();
ImGuiIO& io = ImGui::GetIO(); (void)io;
//io.ConfigFlags |= ImGuiConfigFlags_NavEnableKeyboard;     // Enable Keyboard Controls
//io.ConfigFlags |= ImGuiConfigFlags_NavEnableGamepad;      // Enable Gamepad Controls

// Setup Dear ImGui style
ImGui::StyleColorsDark();
//ImGui::StyleColorsClassic();


然后是棘手的部分,将引擎的相关部分暴露给 Dear ImGui。这主要是通过一个名为 ImGui_ImplVulkan_InitInfo的结构体


// Setup Platform/Renderer bindings
ImGui_ImplGlfw_InitForVulkan(window, true);
ImGui_ImplVulkan_InitInfo init_info = {};
init_info.Instance = g_Instance;
init_info.PhysicalDevice = g_PhysicalDevice;
init_info.Device = g_Device;
init_info.QueueFamily = g_QueueFamily;
init_info.Queue = g_Queue;
init_info.PipelineCache = g_PipelineCache;
init_info.DescriptorPool = g_DescriptorPool;
init_info.Allocator = g_Allocator;
init_info.MinImageCount = g_MinImageCount;
init_info.ImageCount = wd->ImageCount;
init_info.CheckVkResultFn = check_vk_result;
ImGui_ImplVulkan_Init(&init_info, wd->RenderPass);


第一个函数ImGui_ImplGlfw_InitForVulkan来自imgui_impl_glfw.cpp. 它让 Dear ImGui 以非侵入性的方式与 GLFW 交互。例如,Dear ImGui 将有权访问键盘和鼠标事件,但之后仍会运行用户注册的回调。这样我们就不必关心处理与 GUI 相关的输入,我们仍然可以使用鼠标位置来控制相机。为了让库帮助您,您需要向它提供前面GLFW window提到的.

接下来是函数ImGui_ImplVulkan_InitInfo使用的结构ImGui_ImplVulkan_Init。如您所见,它们都遵循模式ImGui_ImplVulkan_XXX而不是ImGui_ImplVulkanH_XXX. 这意味着我们鼓励使用它们。我们已经可以看到,这个结构是您的引擎和 Dear ImGui 之间的一座桥梁。很多 Vulkan 内部结构都在这里暴露出来,我们将找出它们在我们的引擎中的位置。

Vulkan 实例和设备

第一个变量是g_Instance,这是VkInstance您必须通过vkCreateInstance调用创建的。它是 Vulkan 的低级砖块之一。它包含运行程序所需的扩展以及验证层。

然后来了g_PhysicalDevice和g_Device。这些应该是不言自明的,它们是VkPhysicalDevice您VkDevice在引擎中创建的。


队列queue

下一点可能有点令人困惑。该结构需要 a QueueFamily和 a Queue。如果您熟悉 Vulkan,就会知道大多数操作都是通过提交到队列的命令缓冲区完成的。队列来自允许有限操作子集的队列族。例如,一个族只能允许计算操作或传输操作。您自己可能正在使用多个队列,那么哪一个是正确的呢?

最有可能的是图形队列GraphicQueue,因为 Dear ImGui 只会绘制东西。如果您快速浏览代码,您会发现我们确实在寻找图形队列和相关系列。

    QueueFamilyIndices indices = findQueueFamilies(physicalDevice);
    uint32_t g_QueueFamily = indices.graphicsFamily;

		init_info.QueueFamily = g_QueueFamily;
    init_info.Queue = graphicsQueue;
// Check what command queues the GPU is capable of handling.
QueueFamilyIndices VulkanApplication::findQueueFamilies(VkPhysicalDevice device) {
    QueueFamilyIndices indices;

    uint32_t queueFamilyCount = 0;
    vkGetPhysicalDeviceQueueFamilyProperties(device, &queueFamilyCount, nullptr);

    std::vector<VkQueueFamilyProperties> queueFamilies(queueFamilyCount);
    vkGetPhysicalDeviceQueueFamilyProperties(device, &queueFamilyCount, queueFamilies.data());

    int i = 0;
  	//!!! Look for Graphic queue !!!
    for (const auto& queueFamily : queueFamilies) {
        if (queueFamily.queueCount > 0 && queueFamily.queueFlags & VK_QUEUE_GRAPHICS_BIT) {
            indices.graphicsFamily = i;
        }
        
        //TODO: is this correct
        if (queueFamily.queueCount > 0 && queueFamily.queueFlags & VK_QUEUE_COMPUTE_BIT) {
            indices.computeFamily = i;
        }

        VkBool32 presentSupport = false;
        vkGetPhysicalDeviceSurfaceSupportKHR(device, i, surface, &presentSupport);

        if (queueFamily.queueCount > 0 && presentSupport) {
            indices.presentFamily = i;
        }

        if (indices.isComplete()) {
            break;
        }

        i++;
    }

    return indices;
}


注意:我在 Dear ImGui 的代码中找不到图形系列的任何用途,所以我尝试在其中放入一个随机数,它工作正常。

管道缓存Pipeline cache

队列来了之后PipelineCache。我没有使用过这些,但我想这是一个包含先前创建的管道(例如图形管道)的对象。如果你有一个,对你有好处。如果你不这样做,VK_NULL_HANDLE将工作得很好。据我所知,它仅在创建 Dear ImGui 的图形管道时使用。

VkPipelineCache g_PipelineCache = VK_NULL_HANDLE;

init_info.PipelineCache = g_PipelineCache;
描述符池DescriptorPool

这DescriptorPool是另一个有趣的元素。同样,您可能已经拥有一个。如果您使用的是图像采样器 image samplers 或统一缓冲区uniform buffers ,则您已从池中分配了它们的描述符。但是,查看代码,您可以看到g_DescriptorPool使用以下 pool_sizes 创建的代码。里面有很多东西。您的DescriptorPool很可能没有这一切。

    // Create Descriptor Pool
    {
        VkDescriptorPoolSize pool_sizes[] =
        {
            { VK_DESCRIPTOR_TYPE_SAMPLER, 1000 },
            { VK_DESCRIPTOR_TYPE_COMBINED_IMAGE_SAMPLER, 1000 },
            { VK_DESCRIPTOR_TYPE_SAMPLED_IMAGE, 1000 },
            { VK_DESCRIPTOR_TYPE_STORAGE_IMAGE, 1000 },
            { VK_DESCRIPTOR_TYPE_UNIFORM_TEXEL_BUFFER, 1000 },
            { VK_DESCRIPTOR_TYPE_STORAGE_TEXEL_BUFFER, 1000 },
            { VK_DESCRIPTOR_TYPE_UNIFORM_BUFFER, 1000 },
            { VK_DESCRIPTOR_TYPE_STORAGE_BUFFER, 1000 },
            { VK_DESCRIPTOR_TYPE_UNIFORM_BUFFER_DYNAMIC, 1000 },
            { VK_DESCRIPTOR_TYPE_STORAGE_BUFFER_DYNAMIC, 1000 },
            { VK_DESCRIPTOR_TYPE_INPUT_ATTACHMENT, 1000 }
        };
        VkDescriptorPoolCreateInfo pool_info = {};
        pool_info.sType = VK_STRUCTURE_TYPE_DESCRIPTOR_POOL_CREATE_INFO;
        pool_info.flags = VK_DESCRIPTOR_POOL_CREATE_FREE_DESCRIPTOR_SET_BIT;
        pool_info.maxSets = 1000 * IM_ARRAYSIZE(pool_sizes);
        pool_info.poolSizeCount = (uint32_t)IM_ARRAYSIZE(pool_sizes);
        pool_info.pPoolSizes = pool_sizes;
        err = vkCreateDescriptorPool(g_Device, &pool_info, g_Allocator, &g_DescriptorPool);
        check_vk_result(err);
    }


现在有两种解决方案,您可以扩展自己的池以赋予它这样的容量,或者您可以为 Dear ImGui 创建一个特殊的池。我不确定有分离池的含义是什么,但为了保持一切良好的分离,我决定使用main.cpp中SetupVulkan函数末尾提供的代码为Dear ImGui创建一个自定义池。


内存分配器Memory allocator

Allocator字段可用于将特定的内存分配器传递给Dear ImGui调用的Vulkan函数。如果你没有的话,你可以传递nullptr。

	VkAllocationCallbacks* g_Allocator = NULL;

	init_info.Allocator = g_Allocator;
图像计数Image count

MinImageCount和ImageCount与交换链图像相关。这些不是 Dear ImGui 特定的属性,您的引擎应该公开它们。ImageCount让 Dear ImGui 知道它通常应该分配多少帧缓冲区和资源。MinImageCount实际上没有使用,即使在初始化时检查它的值是否大于1。

        // may be different from the real swapchain image count
        // see ImGui_ImplVulkanH_GetMinImageCountFromPresentMode
        init_info.MinImageCount = 3;
        init_info.ImageCount    = 3;
错误处理Error handling

CheckVkResultFn是一个被调用来验证一些 Vulkan 操作是否顺利的函数。例如,所有的vkCreateorvkAllocate函数都会返回一个状态码,它应该是VK_SUCCESS. 如果没有,那就是出了点问题。检查是个好习惯,Dear ImGui 允许您传递自己的错误处理逻辑。

static void check_vk_result(VkResult err)
{
	if (err == 0)
		return;
	fprintf(stderr, "[vulkan] Error: VkResult = %d\n", err);
	if (err < 0)
		abort();
}


init_info.CheckVkResultFn = check_vk_result;
最后调用函数Final call

最后,我们VulkanImpl通过调用ImGui_ImplVulkan_Init刚刚填充的结构和渲染通道来初始化我们的。正如您所料,这应该是专用于 Dear ImGui 的特定渲染通道。那么它是wd->RenderPass从哪里来的呢?之前我们跳过了一些用于初始化 Vulkan 的函数,事实证明它们也创建了这个渲染通道。我们必须创建自己的。

渲染通道Render pass

为了创建这个渲染通道,我们首先需要创建一个VkAttachmentDescription

让我们先摆脱无聊的字段:我们不需要任何模板,所以我们不关心它的操作符,样本的数量应该是1。dear ImGui的输出没有MSAA仍旧看起来很好。使用的格式取决于你的交换链,你应该能够在你的代码中找到相关的VkFormat。它通常从函数querySwapChainSupport返回的SwapChainSupportDetails结构中提取

VkAttachmentDescription attachment = {};
attachment.format = swapChain.imagesFormat;
attachment.samples = VK_SAMPLE_COUNT_1_BIT;
attachment.loadOp = VK_ATTACHMENT_LOAD_OP_LOAD;
attachment.storeOp = VK_ATTACHMENT_STORE_OP_STORE;
attachment.stencilLoadOp = VK_ATTACHMENT_LOAD_OP_DONT_CARE;
attachment.stencilStoreOp = VK_ATTACHMENT_STORE_OP_DONT_CARE;
attachment.initialLayout = VK_IMAGE_LAYOUT_COLOR_ATTACHMENT_OPTIMAL;
attachment.finalLayout = VK_IMAGE_LAYOUT_PRESENT_SRC_KHR;

好的,所以这里最相关的两个部分是loadOp和initialLayout。

  • 第一个应该是VK_ATTACHMENT_LOAD_OP_LOAD因为您希望在主渲染上绘制 GUI。这告诉 Vulkan 您不想清除帧缓冲区的内容,而是想在其上绘制。
  • 由于我们要绘制一些东西,为了最佳性能,我们还希望initialLayout设为VK_IMAGE_LAYOUT_COLOR_ATTACHMENT_OPTIMAL。
  • 因为这个渲染通道是最后一个,我们希望finalLayout设置为VK_IMAGE_LAYOUT_PRESENT_SRC_KHR. 这将自动将我们的attachment转换为正确的布局进行展示。


现在我们有了这个,我们可以创建渲染通道需要VkAttachmentDescription的实际颜色。VkAttachmentReference如上所述,我们用来绘制的布局是VK_IMAGE_LAYOUT_COLOR_ATTACHMENT_OPTIMAL.


VkAttachmentReference color_attachment = {};
color_attachment.attachment = 0;
color_attachment.layout = VK_IMAGE_LAYOUT_COLOR_ATTACHMENT_OPTIMAL;



3
评论
收藏