渲染引擎开发笔记7

ImGui的加入

Posted Kongouuu's Blog on January 7, 2022

前言

上一章节比较草草了事的简单提了一下Input Assembly部分的一些思路吧,并没有特别的细节,毕竟也只是个人反思。

Imgui对一个渲染引擎来说是不能少的,因为我们很多时候在测试效果的时候会想要去调整物体的变换,或者去开关不同的效果等。这时候就需要一个GUI去让我们能对这些东西去进行一个控制。

这里主要讲一下怎么使用Imgui去管理我的场景。先看结果:

使用ImGui Docking就可以达到这种长得比较像商业引擎的效果,这样在尝试一些新的光照效果的过程中可以更好的管理我的场景,也可以实时控制不同的效果的开关。在这之上如果再加一个Picking的功能,就可以达到类似于商业引擎的物体编辑效果(通过拖动画面内的物体)。

正文

初始化

首先我这里使用的是ImGuiDocking分支,这可以让我们的UI拖动到窗口外面,也同时提供了让UI吸附在UI上的这么一个功能,能提供一个更好的视觉效果。因为ImGui在我这个引擎中我认为是一个固有的东西,所以把他放在引擎侧并且不提供任何能在使用引擎的App侧去进行修改的空间。初始化主要是把ImGui和我们的窗口以及渲染器接上,基础的设置是这样:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
void ImguiManager::ImguiInit()
	{ 
		// 检查版本
		ImGui::DebugCheckVersionAndDataLayout(IMGUI_VERSION, sizeof(ImGuiIO), sizeof(ImGuiStyle), sizeof(ImVec2), sizeof(ImVec4), sizeof(ImDrawVert), sizeof(ImDrawIdx));
		
		// 建立上下文环境
		ImGui::CreateContext();
		ImGuiIO& io = ImGui::GetIO(); (void)io;
		
		// 开启Docking和Viewports设定,让我们一开始就能把ui互相吸附以及拖动到窗口外
		io.ConfigFlags |= ImGuiConfigFlags_DockingEnable;
		io.ConfigFlags |= ImGuiConfigFlags_ViewportsEnable;
		io.Fonts->Build();
		
		// 初始化外观设置
		ImGui::StyleColorsDark();
		ImGuiStyle& style = ImGui::GetStyle();
		if (io.ConfigFlags & ImGuiConfigFlags_ViewportsEnable)
		{
			style.WindowRounding = 0.0f;
			style.Colors[ImGuiCol_WindowBg].w = 1.0f;
		}

		// 把ImGui连接到窗口和DX12
		// 这里要SRV地址应该是为了之后能读取DX12内部储存的纹理数据?
		Application& App = Application::Get();
		const DX12Renderer& DXContext = DX12Renderer::GetInstance();
		ImGui_ImplWin32_Init(App.GetWindow()->MainWnd());
		ImGui_ImplDX12_Init(DXContext.D3dDevice().Get(), NUM_FRAMES_IN_FLIGHT,
			DXGI_FORMAT_R8G8B8A8_UNORM, DXContext.SrvHeap().Get(),
			DXContext.SrvHeap()->GetCPUDescriptorHandleForHeapStart(),
			DXContext.SrvHeap()->GetGPUDescriptorHandleForHeapStart());
		
		// 设置清除色
		ImVec4 clear_color = ImVec4(0.45f, 0.55f, 0.60f, 1.00f);
	}

之后我们只需要在引擎内部初始完窗口以及DX12(包括Descriptor Heap部分),就可以进行整个ImGui的初始化

消息管理

消息管理这里因为我们引擎侧已经有一套把原始消息进行封装且分发的系统,是下面这个样子:

通俗点说就是消息分为接收和处理两个阶段,而两个阶段都需要通知ImGui

接收

ImGui本身是需要在窗口接收到原始消息的时候预先去处理这些原始消息的。因为ImGui是自己独立的一套系统,所以需要先通知它窗口收到的消息它才能去修改内部的状态,比如ImGui内部的鼠标和键盘的状态,来让我们控制那些UI。这里的话使用一个原生的ImGuiMsgProc就可以了:

处理

在有的时候我们并不想要让引擎去处理一些消息,需要让ImGui把消息拦截住,比如说在操控UI的时候希望我们的程序不要收到任何消息【例如设定了可以鼠标控制摄像机,但我们要拖动UI的时候让摄像机保持静止】。

因为我们自定义的消息系统里面有位消息设定一个Handled值来判断消息是否已经被解决,如果消息被解决了那么就停止分发。所以在引擎这里需要还需要把包装好的消息类型发给ImGui去判断当前的消息类型是否拦截。

小结

ImGui消息这块并不是很复杂,因为我们不会需要这个系统去真的对事件有特殊的处理。大部分的消息相关的处理在ImGui内部其实都已经写好了。这里判断是否要拦截消息就可以了。

绘制流程

DirectX12

ImGui的绘制调用包装的特别完善,实际上就跟写HTML差不了太多。不过在讲绘制前有一个细节就是他和DX12的搭配。DX12这个渲染API的运行方式是:

我们在进行ImGui的绘制的时候实际上只是把UI的绘制写到当前开放的CommandList,它内部并不会有 开CommandList, 关CommandList, 推送到CommandQueue 这一系列的操作。

意思就是在使用ImGui的时候我们在每个渲染环节,要在关闭指令集前去先绘制ImGui。这个理解了后就很好处理,只需要把常规的渲染流程里面的结尾部分封装出来就好了,这样在我们的引擎侧就是这样去调用整个渲染环节了:

流程和UI窗体

整个流程分为三个部分,每一帧都要调用:

  1. 开始渲染UI
  2. 绘制窗口
  3. 提交绘制
开始

每次开始的流程其实很简单,只需要三行代码,这里不做更多讲解

1
2
3
	ImGui_ImplDX12_NewFrame();
	ImGui_ImplWin32_NewFrame();
	ImGui::NewFrame();
UI窗口

每个UI窗口都是通过一样的指令去绘制:

1
2
3
		ImGui::Begin("窗口名字");
		// 里面的UI配置,跟HTML差不多
		ImGui::End();

窗口的大小、位置等都是不需要设置的。在我们每次手动拖动来调整位置大小之后,相应的设置都会储存在本地的,所以使用上特别的简单。

提交

提交方面因为ImGui本身已经写好了对后端的适配。我们只需要调用他给的函数就可以了。这里因为我们不是用的master分支,而是docking分支,所以在后面还需要额外调用UpdatePlatformWindows()

ViewPort

总览

我们在DX12的渲染是直接渲染到后缓冲的,也就是覆盖整个窗口的缓冲区。但是如果我们想要变得更像商业引擎,就是说有一个比较小的Viewport,不占满整个窗口,那么需要从渲染器上也做一些调整。

先说下这个效果的本质,本质上就是我们不把所有东西渲染到后缓冲,一开始就只渲染到一个贴图,然后之后在ImGui中使用这个贴图就行了:

实现

1. 在渲染器内部建立FrameBuffer类

用于代替后缓冲作为我们画面来展示的这个帖图实际上就是一个FrameBuffer,也就是说一个可读且可写的一个纹理。它需要的是:

  1. 一个资源
  2. 一个绑定了的RTV句柄,这样资源可以用于当作Render Target作为Shader的渲染目标
  3. 一个绑定了的SRV句柄,这样可以让ImGui读这个纹理
  4. RTVSRVOffset,在我们更新分辨率的时候可以去根据新的资源更新绑定的句柄
  5. D3D12_VIEWPORT 和 **D3D12_RECT **, 决定这个纹理的渲染的大小

由于大部分工作在上一章的描述符堆管理中已经解决了

2. 在Renderer里定义一个FrameBuffer实例

我把这个FrameBuffer命名为mViewportBuffer,直接定义在Renderer里而不是额外分出一个Viewport类是因为它完完全全就只是一个FrameBuffer,不需要什么额外的功能。

3. 在Renderer里面定义一个ViewPortResize函数

我们的DX12Renderer类是跟着龙书去写的,会在每次窗口更新大小的时候去调用一个OnResize的函数来调整我们SwapChain的大小以及重新跟RTV绑定。不过由于我们打算把画面放到一个大小跟整个窗体不一样的容器中,我们不能根据窗体的大小去调整我们的帧缓冲的资源。

所以这里建立了一个自己的Resize函数,会根据我们在ImGui管理的容器里的大小去调整我们的资源大小和重新绑定SRV,RTV

4. 在ImGui里设置好Viewport+Docking

ImGui Docking的机制是UI可以附在别的UI上面,所以我们在开始之前需要先建立一个覆盖整个窗口的UI,刚好也有一个内置函数提供了这个效果:

ImGui::DockSpaceOverViewport(ImGui::GetMainViewport());

有了一个底层之后我们就可以把UI像这样吸附在窗口上:

5.在ImGui上投影我们的贴图

在投影的时候我们只需要建立一个UI窗口,然后在里面调用ImGui::Image就可以渲染图片了。不过这里有两个要注意的地方,一是我们怎么得到贴图,二是怎么决定渲染贴图的大小(同时怎么更新在渲染器内部的资源大小)

  1. 贴图上由于我们前面设置ImGui的时候有好好绑定SRV Descriptor Heap,所以在调用ImGui::Image的时候只需要把D3D12_GPU_DESCRIPTOR_HANDLE当作index写入就可以了。
  2. 渲染贴图的大小肯定是要等同于我们渲染到的UI窗口的大小,这里ImGui已经提供好了一个函数。我们通过函数得到对应的大小,一方面可以决定我们使用ImGui::Image来渲染图片时的分辨率,另一方面可以通知渲染器怎么去更新资源的大小。

当标记为脏的时候,会在每次更新的时候去调整ViewPortBuffer的大小

注意点

开发时有一个踩雷的地方是我对于ViewPortResize产生的问题。实际上在做这种Resize操作的时候我们是重新去建立了一次这个资源,也就是说覆写,在进行这个操作的时候应该要确保之前跑的指令全部都跑完了,且重新建立完资源后要马上提交。

如果没有等前面的指令跑完,很可能发生在写入Viewport Buffer的同时这个资源被重新创建,导致D3dDevice崩溃的这么一个问题出现。所以要确保在重新建立资源的时候一定要让前面的指令先跑完再操作。

思考

实际上这样的操作就是用一个贴图来替代我们本身应该当作后缓冲的东西。这样的话有一个需要注意的地方就是从现在开始所有的操作,例如ShadowMapping等所使用的分辨率都应该按照Viewport的分辨率来,而不是Window的。

在做这一部分的时候让我意识到去包装Descriptor Heap的重要性,也是在包装完之后才可以这么简单的达到类似的效果。

这里记录一下开发时的一个比较笨拙的思考,当时开发的时候在想为什么后缓冲需要两个,而我拿来替代它的Viewport Buffer只需要一个资源。这里其实是当时概念比较混淆了,因为虽然我们看似使用了Viewport去替代后缓冲当作被写入对象,但在显示的时候他并没有做到完全替代后缓冲的效果。实际上我们在做的并不是 把Viewport贴图实时的放到屏幕上, 而是在每一帧 把Viewport贴图写入后缓冲。这两个区别是很大的。当我们把Viewport贴图写入后缓冲后,放在我们屏幕上的东西可以说是跟当时用于写入的Viewport贴图 毫无关西,所以当然也不会有任何硬件上的读写问题。

后记

写到这里的时候也开发完了ShadowMap的功能,使用的是一个比较简易的PCF,也同时在光照方面采用的是PBRCook-Torrance BRDF来做直接光照计算。在这里实际上是可以继续把所有学的东西写上来,比如说SSAOIBLDeferred Rendering等,但个人先打算开始看RTR4巩固一下基础,并且在学习的同时用我这个有一定基础建设的小引擎去尝试里面的效果。