先借由龙书中的描述引入Descriptors的作用。
在渲染处理过程中,GPU可能会对资源进行读和写。在发出绘制命令之前,我们需要将与本次绘制调用相关的资源绑定到渲染流水线上。部分资源可能在每次绘制调用时都会有所变化,所以我们也就需要每次按需更新绑定。但是,GPU资源并非直接与渲染流水线相绑定,而是要通过一种名为描述符的对象来使用。
可以看出描述符是用于资源绑定的,微软在D3D12中为资源绑定添加了新的概念,包括descriptors, descriptor tables, descriptor heaps,和 root signatures,接下来我们依次介绍。(因为这些概念会相互关联,所以建议至少阅读两次来关联上下文理解)
Descriptors
定义:资源描述符,是一块用来描述在GPU中的各种渲染资源的数据。从本质上来讲,descriptors实际上即为一个中间层,它描述了资源的地址和类型信息。
descriptors共有以下几种类型:(注意在d3d12中,descriptors与view同义)
描述符的大小是和gpu硬件关联的,可以通过接口GetDescriptorHandleIncrementSize获取。
描述符的创建通过驱动层API完成,对于不同的描述符有不同的API,但基本都是用一个对应的结构体来描述descriptors所包含的信息(如下图)
,以下以SRV为例,SRV对应的结构体是D3D12_SHADER_RESOURCE_VIEW_DESC,
typedef struct D3D12_SHADER_RESOURCE_VIEW_DESC
{
DXGI_FORMAT Format;
D3D12_SRV_DIMENSION ViewDimension;
UINT Shader4ComponentMapping;
union
{
D3D12_BUFFER_SRV Buffer;
D3D12_TEX1D_SRV Texture1D;
D3D12_TEX1D_ARRAY_SRV Texture1DArray;
D3D12_TEX2D_SRV Texture2D;
D3D12_TEX2D_ARRAY_SRV Texture2DArray;
D3D12_TEX2DMS_SRV Texture2DMS;
D3D12_TEX2DMS_ARRAY_SRV Texture2DMSArray;
D3D12_TEX3D_SRV Texture3D;
D3D12_TEXCUBE_SRV TextureCube;
D3D12_TEXCUBE_ARRAY_SRV TextureCubeArray;
} ;
} D3D12_SHADER_RESOURCE_VIEW_DESC;
Format表示贴图资源的数据格式。
typedef enum DXGI_FORMAT {
DXGI_FORMAT_UNKNOWN = 0,
DXGI_FORMAT_R32G32B32A32_TYPELESS = 1,
DXGI_FORMAT_R32G32B32A32_FLOAT = 2,
DXGI_FORMAT_R32G32B32A32_UINT = 3,
DXGI_FORMAT_R32G32B32A32_SINT = 4,
DXGI_FORMAT_R32G32B32_TYPELESS = 5,
DXGI_FORMAT_R32G32B32_FLOAT = 6,
DXGI_FORMAT_R32G32B32_UINT = 7,
DXGI_FORMAT_R32G32B32_SINT = 8,
···
} ;
ViewDimension表示资源的尺寸,比如1D贴图,2D贴图,立方体贴图等。
typedef enum D3D12_SRV_DIMENSION {
D3D12_SRV_DIMENSION_UNKNOWN = 0,
D3D12_SRV_DIMENSION_BUFFER = 1,
D3D12_SRV_DIMENSION_TEXTURE1D = 2,
D3D12_SRV_DIMENSION_TEXTURE1DARRAY = 3,
D3D12_SRV_DIMENSION_TEXTURE2D = 4,
D3D12_SRV_DIMENSION_TEXTURE2DARRAY = 5,
D3D12_SRV_DIMENSION_TEXTURE2DMS = 6,
D3D12_SRV_DIMENSION_TEXTURE2DMSARRAY = 7,
D3D12_SRV_DIMENSION_TEXTURE3D = 8,
D3D12_SRV_DIMENSION_TEXTURECUBE = 9,
D3D12_SRV_DIMENSION_TEXTURECUBEARRAY = 10,
D3D12_SRV_DIMENSION_RAYTRACING_ACCELERATION_STRUCTURE = 11
} ;
Shader4ComponentMapping表示对采样贴图时返回的数据进行排序,正常情况使用D3D12_DEFAULT_SHADER_4_COMPONENT_MAPPING即可。
结构体内包含的Union表示这个资源只能选择其中一个元素使用,如果要生成2D贴图资源就选择D3D12_TEX2D_SRV Texture2D。
typedef struct D3D12_TEX2D_SRV {
UINT MostDetailedMip; //确定细节程度最高的mipmap索引,范围[0,Mipmap - 1]
UINT MipLevels; //mimap数量
UINT PlaneSlice; //平面索引
FLOAT ResourceMinLODClamp; //可以获取的最小的mipmap等级,0表示所有等级都可以获取
} D3D12_TEX2D_SRV;
最后是完整的创建:
D3D12_SHADER_RESOURCE_VIEW_DESC srvDesc = {};
srvDesc.Shader4ComponentMapping = D3D12_DEFAULT_SHADER_4_COMPONENT_MAPPING;
srvDesc.Format = bricksTex->GetDesc().Format;
srvDesc.ViewDimension = D3D12_SRV_DIMENSION_TEXTURE2D;
srvDesc.Texture2D.MostDetailedMip = 0;
srvDesc.Texture2D.MipLevels = bricksTex->GetDesc().MipLevels;
srvDesc.Texture2D.ResourceMinLODClamp = 0.f;
md3dDevice->CreateShaderResourceView(bricksTex.Get(), &srvDesc, hDescriptor);
Descriptor Heaps
定义:描述符堆,其中存有一系列描述符(可将其看作是描述符堆),本质上是存放用户程序中某种特定类型描述符的一块内存。每个描述符堆只能存放一种特定的描述符(SRV/CBV/UAV可以看作是一种类型)。
根据微软文档的说明,一个描述符堆最大可存储100万个描述符(具体视硬件而定)。
接下来跟着Descriptors Heap的创建流程去了解,我们以存储Sampler为例。
类似于Descriptors的创建,都是填充一个结构体然后通过驱动层API去创建。
// Describe and create a sampler descriptor heap.
D3D12_DESCRIPTOR_HEAP_DESC samplerHeapDesc = {};
samplerHeapDesc.NumDescriptors = 1;
samplerHeapDesc.Type = D3D12_DESCRIPTOR_HEAP_TYPE_SAMPLER;
samplerHeapDesc.Flags = D3D12_DESCRIPTOR_HEAP_FLAG_SHADER_VISIBLE;
ThrowIfFailed(m_device->CreateDescriptorHeap(&samplerHeapDesc, IID_PPV_ARGS(&m_samplerHeap)));
NumDescriptors看名字就知道是指定堆中可以存放的描述符数量,Type表示存储的描述符类型,
typedef enum D3D12_DESCRIPTOR_HEAP_TYPE
{
D3D12_DESCRIPTOR_HEAP_TYPE_CBV_SRV_UAV, // Constant buffer/Shader resource/Unordered access views
D3D12_DESCRIPTOR_HEAP_TYPE_SAMPLER, // Samplers
D3D12_DESCRIPTOR_HEAP_TYPE_RTV, // Render target view
D3D12_DESCRIPTOR_HEAP_TYPE_DSV, // Depth stencil view
D3D12_DESCRIPTOR_HEAP_TYPE_NUM_TYPES // Simply the number of descriptor heap types
} D3D12_DESCRIPTOR_HEAP_TYPE;
注意后面参数Flags,是描述符堆的选项,可供选择的值有两个,
typedef enum D3D12_DESCRIPTOR_HEAP_FLAGS {
D3D12_DESCRIPTOR_HEAP_FLAG_NONE = 0,
D3D12_DESCRIPTOR_HEAP_FLAG_SHADER_VISIBLE = 0x1
} ;
在龙书中这个标记并没有详细解释,去看了文档发现这是理解整个资源绑定流程的关键(误。
先看一下文档中的描述:
The flag D3D12_DESCRIPTOR_HEAP_FLAG_SHADER_VISIBLE can optionally be set on a descriptor heap to indicate it is be bound on a command list for reference by shaders. Descriptor heaps created without this flag allow applications the option to stage descriptors in CPU memory before copying them to a shader visible descriptor heap, as a convenience.
用人话讲就是,D3D12_DESCRIPTOR_HEAP_FLAGS指示了描述符堆的存储方式,D3D12_DESCRIPTOR_HEAP_FLAG_NONE表示堆存储在系统内存中(描述符明明是关联GPU资源的,为什么堆可以存储在系统内存呢?后面解释),D3D12_DESCRIPTOR_HEAP_FLAG_SHADER_VISIBLE表示描述符堆存储在GPU内存中。
有了上面这个概念后,我们可以对描述符进行分类,以便更好的理解。
首先,放在GPU描述符堆中的描述符称为GPU Descriptors,只能用来存储SRV/CBV/UAV/SAMPLER,因为这些资源在gpu中的shader可以直接引用,也可以称Shader Visible。
同样,在CPU描述符堆中的描述符就是CPU Desciptors,可以存储所有类型描述符,但是因为不能被Shader直接访问到,所以称为Non Shader Visible,需要特殊说明的是,只有CPU Descriptors可以执行显示的赋值操作。
另外还有一种特殊描述符Transparent Descriptors,可以理解为是存储在隐藏于 D3D12 Pipeline 内部的 GPU 描述符堆,不需要应用层维护额外的描述符堆,在其内部实现上可能比 GPU 描述符堆更加简单,因此可以用于绑定在 Pipeline 上的资源,例如:Vertex Buffer View、Index Buffer View、Stream Output View 以及直接存储在 Root Signature 的 CBV\SRV\UAV\Sampler 描述符。
看到这里可能会有个问题,描述符堆为什么要区分GPU和CPU呢?其实在上面描述符堆的介绍中提到过,每个描述符堆最多存储100万个描述符,那么我们就不可能将游戏内所有资源都放在GPU描述符堆中(我怎么觉得完全用不完?),既然不能放gpu中那么就放系统内存里,这个空间要比gpu的内存大多了,但是要注意这里不是存储的资源数据,而是资源的引用,数据始终在GPU中,当渲染需要时再从CPU描述符堆把资源的引用调入到GPU描述符或者通过CommandList传到GPU,这也就解释了前面堆可以存储在系统内存的问题了。
Shader Visible/Non Shader Visible是从GPU视角来分类,还可以从CPU视角分类,CPU Descriptors必然是CPU Visible的,但其实GPU Descriptors也是CPU Visible,因为GPU Descriptor Heap上有和CPU Visible对应的描述符,可以通过GetCPUDescriptorHandleForHeapStart函数获取起始CPU描述符地址。
创建完描述符堆后,接下来是将描述符写入描述符堆。
描述符堆其实可以理解成数组,如果是常规数组,我们想读写其中的内容有两种方法。第一种是使用下标访问,可惜d3d12并没有提供这样的接口,那么就剩下了第二种通过指针,对C或C++中的数组有了解的知道,数组其实就是指针,我们可以使用指向第一个元素的指针来通过偏移的方式访问所有元素,在描述符堆中就是使用了这种方式。描述符堆的头指针被封装成了另一个结构,Descriptor Handle,对应CPU和GPU描述符堆分别有CPU handle和GPU handle。
GetCPUDescriptorHandleForHeapStart返回对应CPU visible类型的描述符堆的CPU handle,如果不是CPU visible那么返回NULL,,以下API需要使用CPU handle:
GetGPUDescriptorHandleForHeapStart返回对应shader visible类型的描述符堆的GPU handle,如果不是shader visible那么返回NULL,以下API使用GPU handle:
ID3D12Device::GetDescriptorHandleIncrementSize 返回描述符堆中的递增大小,结合handle就可以遍历描述符堆中的所有描述符了。
以下创建一个描述符堆并写入描述符:
D3D12_DESCRIPTOR_HEAP_DESC cbvHeapDesc;
cbvHeapDesc.NumDescriptors = 1;
cbvHeapDesc.Type = D3D12_DESCRIPTOR_HEAP_TYPE_CBV_SRV_UAV;
cbvHeapDesc.Flags = D3D12_DESCRIPTOR_HEAP_FLAG_SHADER_VISIBLE;
cbvHeapDesc.NodeMask = 0;
ThrowIfFailed(md3dDevice->CreateDescriptorHeap(&cbvHeapDesc,
IID_PPV_ARGS(&mCbvHeap)));
D3D12_CONSTANT_BUFFER_VIEW_DESC cbvDesc;
cbvDesc.BufferLocation = cbAddress;
cbvDesc.SizeInBytes = d3dUtil::CalcConstantBufferByteSize(sizeof(ObjectConstants));
md3dDevice->CreateConstantBufferView(
&cbvDesc,
mCbvHeap->GetCPUDescriptorHandleForHeapStart());
注意在堆内创建描述符需要用CPU handle,这是在cpu先建立gpu地址和cpu描述符的关联,等到渲染需要传入的时候再使用gpu handle供shader读取。
Root Signatures
再次回想文章开始的引用,“通常在绘制调用开始执行之前,我们应将不同的着色器程序所需的各种类型的资源绑定到渲染流水线”,而我们在之前讲到的描述符堆只是对资源的引用,还没有关联到Shader,那该如何做呢?
在d3d11中这部分封装的比较简单,直接将资源绑定到插操就好了,但是d3d12将这部分工作交给了用户,并提供了一个新的特性Root Signatures(即根签名),来将绑定到渲染流水线上的资源映射到着色器对应的寄存器中。如果把着色器程序当作一个函数,将输入资源看作传入着色器的函数实参,根签名则定义了函数形参,所以根签名一定要和着色器兼容(这涉及到了又一个概念流水线状态,因为不影响理解,所以暂时不写了)。
在根签名中每个Shader可访问的数据项称为Root Parameter(根参数),可分为以下几种类型:
- Root Constants 根常量,以32位对齐尺寸的常量数据,直接存储在根参数中,无间接开销,所以访问成本为0
- Root Descriptor 根描述符,直接存储在根参数中的资源描述符,指向一个具体资源或资源数组,限制只能存储SRV/CBV/UAV,因为存储的是资源handle,所以多了一层访问开销。
- Descriptor Table 描述符表,指定的是描述符堆中存有描述符的一块连续区域,可以通过下面的图理解,这个描述符堆必须是GPU Visible的,因为存储的是Table的handle,所以有两层开销。
前两种根参数Constant和Descriptor比较好理解,这里重点讲解描述符表。
描述符表实际上就是描述符堆中一定范围的描述符,因为描述符的内存分配是由描述符堆完成的,所以创建一个描述符表来引用其中一部分是很效率的操作。根签名通过对描述符表的引用,进而间接引用到描述符堆,在根签名中保存了描述符表的起始位置和长度。
创建根签名:
与前面的创建类似,根签名的创建需要填充结构体D3D12_ROOT_SIGNATURE_DESC
typedef struct D3D12_ROOT_SIGNATURE_DESC {
UINT NumParameters;
const D3D12_ROOT_PARAMETER *pParameters;
UINT NumStaticSamplers;
const D3D12_STATIC_SAMPLER_DESC *pStaticSamplers;
D3D12_ROOT_SIGNATURE_FLAGS Flags;
} D3D12_ROOT_SIGNATURE_DESC;
根签名中每个插槽类型由D3D12_ROOT_PARAMETER和D3D12_ROOT_PARAMETER_TYPE构成,
typedef struct D3D12_ROOT_PARAMETER {
D3D12_ROOT_PARAMETER_TYPE ParameterType;
union {
D3D12_ROOT_DESCRIPTOR_TABLE DescriptorTable;
D3D12_ROOT_CONSTANTS Constants;
D3D12_ROOT_DESCRIPTOR Descriptor;
};
D3D12_SHADER_VISIBILITY ShaderVisibility;
} D3D12_ROOT_PARAMETER;
typedef enum D3D12_ROOT_PARAMETER_TYPE {
D3D12_ROOT_PARAMETER_TYPE_DESCRIPTOR_TABLE = 0,
D3D12_ROOT_PARAMETER_TYPE_32BIT_CONSTANTS,
D3D12_ROOT_PARAMETER_TYPE_CBV,
D3D12_ROOT_PARAMETER_TYPE_SRV,
D3D12_ROOT_PARAMETER_TYPE_UAV
} ;
其中,参数ShaderVisibility表示当前根参数绑定的插槽对哪些渲染管线阶段可见,如果是Compute着色器这种一个阶段的就设置成_All。
typedef enum D3D12_SHADER_VISIBILITY {
D3D12_SHADER_VISIBILITY_ALL = 0,
D3D12_SHADER_VISIBILITY_VERTEX = 1,
D3D12_SHADER_VISIBILITY_HULL = 2,
D3D12_SHADER_VISIBILITY_DOMAIN = 3,
D3D12_SHADER_VISIBILITY_GEOMETRY = 4,
D3D12_SHADER_VISIBILITY_PIXEL = 5,
D3D12_SHADER_VISIBILITY_AMPLIFICATION = 6,
D3D12_SHADER_VISIBILITY_MESH = 7
} ;
根签名通过接口ID3D12Device::CreateRootSignature创建,但要注意,这个只是创建了根签名,为了能让渲染管线识别,需要传入PSO对象,以下展示完整的创建和使用流程:
我们创建的根签名每个插槽所对应的内容如下,
这里有一个使用技巧,根参数本质上是一块可以在 GPU 中访问的内存,每个 Draw\Compute 之间修改的根参数都会被驱动或硬件保存一份单独的数据,这被称为根参数的版本控制。所以如果根参数比较大,而每次改动比较小,会造成大部分没有变动的数据冗余保存,增加了硬件开销。有些硬件会比较优化的存储这些改动,减少不变的部分的冗余。另外,硬件为了保证 API 的绘制语义正确,当根参数超出硬件存储的大小时,会溢出到更慢的系统内存或硬件内部的其它更慢的存储机制(溢出访问成本加1),这种情况下如果能将频繁变化的 RP 保存在的 RP 溢出范围内,则能极大程度上减少由于溢出访问更慢存储而造成的开销,这也就是 D3D12 建议将频繁变化的 RP 尽可能的设置在靠近 RP 堆头部最本质的原因。
//定义描述符表的范围和对应的描述符类型
CD3DX12_DESCRIPTOR_RANGE1 DescRange[6];
DescRange[0].Init(D3D12_DESCRIPTOR_RANGE_SRV,6,2); // t2-t7
DescRange[1].Init(D3D12_DESCRIPTOR_RANGE_UAV,4,0); // u0-u3
DescRange[2].Init(D3D12_DESCRIPTOR_RANGE_SAMPLER,2,0); // s0-s1
DescRange[3].Init(D3D12_DESCRIPTOR_RANGE_SRV,-1,8, 0,
D3D12_DESCRIPTOR_RANGE_FLAG_DESCRIPTORS_VOLATILE); // t8-unbounded
DescRange[4].Init(D3D12_DESCRIPTOR_RANGE_SRV,-1,0,1,
D3D12_DESCRIPTOR_RANGE_FLAG_DESCRIPTORS_VOLATILE);
// (t0,space1)-unbounded
DescRange[5].Init(D3D12_DESCRIPTOR_RANGE_CBV,1,1,
D3D12_DESCRIPTOR_RANGE_FLAG_DATA_STATIC); // b1
//定义根参数
CD3DX12_ROOT_PARAMETER1 RP[7];
// [0]插槽定义为Constant
RP[0].InitAsConstants(3,2); // 3 constants at b2
// [1]插槽定义为描述表
RP[1].InitAsDescriptorTable(2,&DescRange[0]); // 2 ranges t2-t7 and u0-u3
// [2]插槽定义为CBV
RP[2].InitAsConstantBufferView(0, 0,
D3D12_ROOT_DESCRIPTOR_FLAG_DATA_STATIC); // b0
RP[3].InitAsDescriptorTable(1,&DescRange[2]); // s0-s1
RP[4].InitAsDescriptorTable(1,&DescRange[3]); // t8-unbounded
RP[5].InitAsDescriptorTable(1,&DescRange[4]); // (t0,space1)-unbounded
RP[6].InitAsDescriptorTable(1,&DescRange[5]); // b1
//创建static sampler
CD3DX12_STATIC_SAMPLER StaticSamplers[1];
StaticSamplers[0].Init(3, D3D12_FILTER_ANISOTROPIC); // s3
CD3DX12_VERSIONED_ROOT_SIGNATURE_DESC RootSig(7,RP,1,StaticSamplers);
ID3DBlob* pSerializedRootSig;
CheckHR(D3D12SerializeVersionedRootSignature(&RootSig,pSerializedRootSig));
//创建根签名
ID3D12RootSignature* pRootSignature;
hr = CheckHR(pDevice->CreateRootSignature(
pSerializedRootSig->GetBufferPointer(),pSerializedRootSig->GetBufferSize(),
__uuidof(ID3D12RootSignature),
&pRootSignature));
//最后我们将描述符堆中的资源传入根签名
//每一个传入的资源都必须和上面定义的根参数类型匹配
ID3D12DescriptorHeap* pHeaps[2] = {pCommonHeap, pSamplerHeap};
pGraphicsCommandList->SetDescriptorHeaps(2,pHeaps);
pGraphicsCommandList->SetGraphicsRootSignature(pRootSignature);
pGraphicsCommandList->SetGraphicsRootDescriptorTable(
6,heapOffsetForMoreData,DescRange[5].NumDescriptors);
pGraphicsCommandList->SetGraphicsRootDescriptorTable(5,heapOffsetForMisc,5000);
pGraphicsCommandList->SetGraphicsRootDescriptorTable(4,heapOffsetForTerrain,20000);
pGraphicsCommandList->SetGraphicsRootDescriptorTable(
3,heapOffsetForSamplers,DescRange[2].NumDescriptors);
pGraphicsCommandList->SetComputeRootConstantBufferView(2,pDynamicCBHeap,&CBVDesc);
pGraphicsCommandList->SetSetGraphicsRoot32BitConstants(
0,&stuff,0,RTSlot[0].Constants.Num32BitValues);
for(UINT i = 0; i < numObjects; i++)
{
pGraphicsCommandList->SetPipelineState(PSO[i]);
pGraphicsCommandList->SetGraphicsRootDescriptorTable(
1,heapOffsetForFooAndBar[i],DescRange[1].NumDescriptors);
pGraphicsCommandList->SetGraphicsRoot32BitConstant(0,&i,1,drawIDOffset);
SetMyIndexBuffers(i);
pGraphicsCommandList->DrawIndexedInstanced(...);
}