渲染引擎开发笔记6

描述符堆管理

Posted Kongouuu's Blog on January 5, 2022

前言

目前开发的进度已经可以加载模型和贴图、使用ShadowMap、以及配置好了ImGui。在前面陆续开发这些功能的时候都是用一个比较笨拙的方式去处理整个DX12的描述符堆。但是因为考虑到后面需要大量的使用类似OpenGL的FBO的这样的东西,所以对描述符堆的进阶管理是不可少的。讲了怎么管理描述符堆后,在贴图,阴影贴图,和ImGui适配上会很明显的感受到描述符堆管理的重要性。

在开始正文之前,先提一下为什么很需要描述符堆的管理。我希望我的渲染引擎之后支持动态的去加载模型和贴图,这个需要一直去建立新的描述符。除了渲染引擎的功能之外,管理对描述符也对开发迭代更有帮助,因为目前我的很多代码都是直接参考龙书的比较赤裸的代码。在更新迭代的过程中需要大量的去更改,所以需要封装一些功能提供更便利的使用。

正文

资源和描述符

总览

在说描述符堆前,要先讲一下资源和描述符。 在DX12里面,所有的资源(一般我们用ID3D12Resource)都是以原始数据的方式提交到GPU内的,然后针对这些初始数据我们需要使用单个描述符来形容数据的实际内容。

本文章会提到其中三种描述符:

  1. CBV_SRV_UAV 描述符的SRV: Constant Buffer View + Shader Resource View + Unordered Access View。这个章节主要会考虑到SRV,因为他是纹理的载体。
  2. RTV描述符: Render Target View, 就是我们一套渲染流程最终渲染到的目标,例如后缓存
  3. DSV描述符: Depth Stencil View,

Sampler,CBV,UAV的用法稍微有些不一样所以这里先不提。

为了管理大量的描述符,我们则需要用到描述符堆,就是一连串的描述符。

使用

在写DX12的过程当中所有的资源都需要搭配一个描述符,有的资源还会绑定多个。比如我们在使用Shadow Map的时候,实际上是先建立了一个资源,然后把他绑定到一个DSV上用于数据写入,同时也绑定到一个SRV用于数据读取。

这样繁琐的操作我们不太方便说在每个有特殊需求的类里(ShadowMap, SSAO)都写一个独特的绑定函数。 最好是我们有一个比较没有依赖性的描述符管理类去派发绑定所有的描述符堆。拿伪代码来说差不多是这样:

1
2
3
4
5
6
7
void ShadowMapInit()
{
	ID3D12Resource resource;
	auto srvHandle = DescriptorHeapManager::BindSrv(resource);
	auto dsvHandle = DescriptorHeapManager::BindDsv(resource);
	// 然后就能用handle去进行使用
}

描述符堆

描述符堆跟描述符一样,分为四种,这里不考虑Sampler,所以有以下三种:

  1. D3D12_DESCRIPTOR_HEAP_TYPE_CBV_SRV_UAV: 用于存放纹理
  2. D3D12_DESCRIPTOR_HEAP_TYPE_RTV: 放 RTV
  3. D3D12_DESCRIPTOR_HEAP_TYPE_DSV: 放 DSV

下面聊一下基本的参数

Shader可见

我们可以设定一个描述符堆里面的数据是否是Shader可见的,这个选项一般只有SRV的描述符堆会用到,因为我们只会把SRV(纹理)放到Shader的寄存器里面去读取。这个选项可以让我们的描述符能够被绑定到根签名上面。

一般我们在渲染的过程中会先设置使用的描述符堆 (mCmdList->SetDescriptorHeaps()),只有Shader Visible的堆才能被绑定。基本上就是把需要使用的SRV描述符堆设置成Shader可见,然后RTV/DSV为默认就可以了。

大小

一个描述符堆在建立的时候需要指明大小,也就是含有的描述符数量,这个是不可更改的。不过我们可以建立无数个描述符堆如果之前建立的不够用的话。

SRV堆跟另外两个不同的地方是一般来说我们SRV堆会建立成Shader可见的,让然后在渲染流程中绑定一个SRV堆来使用里面的描述符所包含的所有纹理。在渲染流程中去切换SRV堆是一个开销比较大的行为要尽量去避免。同时Shader可见SRV是根据硬件条件有大小限制的,所以在项目十分庞大的时候需要额外考虑怎么去管理这个描述符堆。

在非Shader可见的描述符堆中,大小其实是没有限制的,所以我们的RTV和DSV描述符堆都可以尽可能地设置的很大。大的话也没有太大的问题,因为描述符堆本身占用的空间并不是很大。用sizeof(D3D12_SHADER_RESOURCE_VIEW_DESC)得到的也就40字节,我哪怕存他一万个描述符也不会占用太大的空间。

管理

管理方式

首先需要一个管理类 DescriptorHeapManager,然后里面存有SRV, RTV, DSV三个描述符堆。

我对描述符堆的管理是参考的类似对象池的构造。我会为每个描述符堆准备一个vector用于储存还尚未被使用的槽位的offset。举个例子在绑定RTV的时候是这样子的:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
uint32_t BindRTV(ID3D12Resource* resource)
{
	// 检查还有没有空闲的槽位
	if(mAvailableRTVDescriptor.empty())
		assert(0) 
		
	// 提出一个槽位进行绑定
	auto offset = mAvailableRTVDescriptor.back();
	mAvailableRTVDescriptor.pop_back();
	mCmdList->bindRTV(resource, offset);
	
	// 返还offset,这样绑定的那一方可以在解绑或更改内容的时候使用对应的offset
	return offset;
}

这种类似对象池的管理方式可以确保如果在程式跑的正确的情况下(资源有正确的呼叫解绑),并不会有空闲的槽位。

当然有很多时候可能根签名方面会设计成想要使用好几个连续的描述符的资源,我现在还不考虑那个。 如果需要连续的话需要额外的去对空闲的所有槽位的最大连续大小额外排序。

RTV DSV

我的项目比较小, 我觉得需要用到RTV(来做帧缓冲效果)的地方可能并不是很多,虽然说这里也没有硬性的限制,不过我给RTVDSV的堆描述符都设置成256的大小应该是够用的。这个并不是很重要。

SRV 方案1 [不使用]

SRV堆描述符的管理一般来说是比较复杂的一点。

网上的一个非常主流的办法是先做一个不限数量的Shader不可见SRV描述符堆来管理,然后在每次绘制指令的时候把需要用到的纹理用CopyDescriptor复制到我们绑定到流水线上面的SRV描述符堆。

这样做的最主要的好处是我们可以非常灵活的去管理我们的SRV,并且不会受到硬件上面的数量限制。我看3dgep 上的封装就是这样。然后他在管理Shader不可见的描述符堆则是类似虚拟内存的Paging一样在不够用的时候频繁的建立新的描述符堆。

这里只是简单提一下这个方案,虽然说UE4也是这么做的所以效率上应该不会有大问题。不过我个人比较担心在不善用连续的描述符堆地址这方面可能会让Copy Descriptor函数的开销影响到我的程序的效率。

SRV 方案2

所以我这里使用一个比较简单的方法,就是只建立一个唯一的Shader可见SRV描述符堆。虽然说根据硬件有大小限制,不过看了一圈应该大部分情况大小设个几万都不是问题,我这里使用8092

实际上就是靠着类似对象池的管理方式去处理这些SRV。在我解绑的时候我并不需要对解绑了的描述符进行任何操作,我只需要把他的偏移值放到池子里就可以了,对于这种比较小的项目来说是更好的管理。

绑定和重新绑定

我们把资源绑定到一个描述符堆的时候有两种情况,一种是资源还没有绑定过任何描述符,一种是资源已经绑定过描述符,但是因为资源更新所以需要重新绑定。所以这里把两个操作分成了两个不同的函数Bind/Rebind,一个可以分配出一个新的便宜,一个复用之前的便宜。先看一下Bind的定义:

BindSRV

具体流程就是先检查是否有空闲的槽位->根据输入决定描述符的属性->绑定资源到特定的槽位

RebindSRV

重新绑定这里主要有两点不一样。一是我们是复用之前的地址,所以不需要分配一个新的槽位出来,二是我们的描述符的属性实际上是不会有变化的,意思是我们不需要在重新绑定的时候大费周章的还需要传入什么不同的Format或者说ViewDimension什么的数据。不过我们是没有办法直接在描述符堆里面找到一个描述符的定义的,所以我们在描述符堆管理的这个类里面需要为每个堆建立一个Unordered_map来记录每个槽位的描述符属性。 这里并不需要考虑什么内存开销大的问题,因为描述符本身也就40字节,我储存这些并不需要花费太大的空间,就可以提高我迭代这个引擎的效率,这点是很值得的。

雷点

SRV的格式和资源的格式

为了让代码变得简单,我写的时候想了一下,既然每个Resource在建立的时候就已经设定好了他的DXGI_FORMAT,那我其实在绑定SRV描述符的时候,完全可以调用: resource->GetDesc().Format 来取得正确的格式,这样在绑定的时候甚至不需要为每次绑定指名不同的格式了。

这个想法是完全错误的,我的Shadow Map的资源本身是使用的[DXGI_FORMAT_R24G8_TYPELESS],但没有type的数据是Shader没有办法读取的。所以如果我绑定这个到SRV描述符,DX12会出错误。但这里因为绑定的函数本身他并没有返回什么HRESULT,或是在里面有assert,我隔了好一段时间才发现是绑定这块让D3dDevice 崩了…

总结

用一句话来概括就是,初始化的时候为每个类型建立一个超大的描述符堆,然后使用对象池的管理方式去管理可以用的描述符。

我的渲染引擎在这之前是根据加载了的贴图数量来决定SRV Heap的大小的。在添加Shadow Map的时候还要手动给Shadow Map多加一个描述符。然后还需要在描述符堆管理类里面建立什么BuildShadowMapDescriptor这样拉跨的函数。

在要用ImGui Docking功能的时候,我需要一个类似OpenGL FBO的东西,就是让我能渲染到一个贴图内然后把贴图取出这样的功能。想当然我不能说为了ImGui去额外又手动设这个设那个,最重要的还是迭代的时候的自动化。

从一开始就能初始化好的描述符堆如果需要等加载完纹理才能成功搭建,太费力气了。主要还是当时并不是很懂大小的设置等等对运行效率的影响。只要对DX12的内核稍微进一步的去了解就能明白更好的管理方式。也就是现在这样。

后续

很显然, 物体的Constant Buffer也可以通过类似的手段去管理,但这要留到后面去做了。