D3D12 教程:描述符堆 (Descriptor Heap) 和描述符句柄 (Descriptor Handle)
引言
在 Direct3D 12 (D3D12) 图形 API 中,描述符 (Descriptor) 是一个至关重要的概念。 描述符用于向 GPU 描述各种资源的信息,例如纹理、缓冲区、常量缓冲区等。 描述符堆 (Descriptor Heap) 则用于管理和存储这些描述符。 理解描述符堆和描述符句柄是掌握 D3D12 资源管理和渲染流程的关键。
本教程将深入讲解 D3D12 描述符、描述符堆以及描述符句柄的概念、类型、作用和使用方法,并结合代码示例,帮助你快速入门 D3D12 的描述符管理。
1. 什么是描述符 (Descriptor)?
在 D3D12 中,描述符 (Descriptor) 本质上是一个小的、GPU 可以理解的数据块,用于描述 GPU 资源的属性和位置。 着色器程序 (Shader) 通过描述符来访问各种 GPU 资源,例如:
纹理 (Texture): 描述符描述纹理的格式、尺寸、mipmap 级别、采样方式等信息,以及纹理数据在 GPU 内存中的位置。
缓冲区 (Buffer): 描述符描述缓冲区的数据格式、元素大小、缓冲区大小等信息,以及缓冲区数据在 GPU 内存中的位置。
常量缓冲区 (Constant Buffer): 描述符描述常量缓冲区的数据大小,以及常量缓冲区数据在 GPU 内存中的位置。
采样器 (Sampler): 描述符描述纹理采样时使用的过滤模式、寻址模式、边界颜色等采样状态。
描述符类型: D3D12 定义了多种描述符类型,每种类型用于描述不同类型的 GPU 资源:
CBV (Constant Buffer View - 常量缓冲区视图): 用于描述常量缓冲区。
作用: 向着色器提供全局或逐帧更新的常量数据,例如,模型-视图-投影矩阵、材质属性、灯光参数等。
资源类型:
ID3D12Resource
,必须是D3D12_HEAP_TYPE_UPLOAD
或DEFAULT
堆类型。
SRV (Shader Resource View - 着色器资源视图): 用于描述只读的着色器资源,例如纹理和缓冲区。
作用: 允许着色器程序 只读 访问纹理数据、模型网格数据、计算着色器输入数据等。
资源类型: 纹理 (
Texture2D
,Texture3D
,TextureCube
等) 和缓冲区 (Buffer
,StructuredBuffer
等)。
UAV (Unordered Access View - 无序访问视图): 用于描述可读写的着色器资源,例如,可读写纹理和缓冲区。
作用: 允许着色器程序 读写 访问资源,常用于计算着色器的输出、后处理效果、以及需要原子操作和乱序访问的场景。
资源类型: 可读写纹理 (
RWTexture2D
,RWTexture3D
等) 和可读写缓冲区 (RWBuffer
,RWStructuredBuffer
等),资源必须创建时指定D3D12_RESOURCE_FLAG_ALLOW_UNORDERED_ACCESS
标志。
RTV (Render Target View - 渲染目标视图): 用于描述渲染目标,即像素着色器的输出目标。
作用: 指定像素着色器输出的颜色数据 写入到哪个纹理资源。 例如,指定输出到后缓冲 (Back Buffer) 或自定义的渲染纹理 (Render Texture),实现多渲染目标 (MRT)。
资源类型: 2D 纹理,资源必须创建时指定
D3D12_RESOURCE_FLAG_ALLOW_RENDER_TARGET
标志。
DSV (Depth Stencil View - 深度/模板视图): 用于描述深度/模板缓冲区。
作用: 指定深度测试和模板测试使用的 深度和模板数据存储在哪个纹理资源。 深度/模板缓冲区用于实现深度遮挡剔除、阴影生成、后期特效等。
资源类型: 深度/模板格式的 2D 纹理,例如
DXGI_FORMAT_D24_UNORM_S8_UINT
。
Sampler (采样器描述符): 用于描述纹理采样状态。
作用: 定义纹理采样时使用的 过滤模式 (Filter Mode), 寻址模式 (Address Mode), 边界颜色 (Border Color), mipmap 级别选择 等采样状态。 控制纹理采样的行为和效果。
资源无关: 采样器描述符 不直接绑定纹理资源,它只描述采样规则,着色器在采样纹理时会引用采样器描述符中定义的采样状态。
2. 描述符堆 (Descriptor Heap):描述符的内存仓库
描述符堆 (Descriptor Heap) 是 D3D12 中用于分配和管理描述符内存的对象。 D3D12 要求描述符必须从描述符堆中分配内存,不能使用其他任何内存分配机制来创建描述符。
描述符堆类型: D3D12 定义了四种描述符堆类型,每种类型用于存放特定类型的描述符:
D3D12_DESCRIPTOR_HEAP_TYPE_CBV_SRV_UAV
: 用于存放 CBV, SRV, UAV 描述符。 通常用于 Shader Resource View (SRV) 堆,也常用于包含 Constant Buffer View (CBV) 和 Unordered Access View (UAV)。D3D12_DESCRIPTOR_HEAP_TYPE_SAMPLER
: 用于存放 Sampler 描述符。 专门用于采样器描述符的堆。D3D12_DESCRIPTOR_HEAP_TYPE_RTV
: 用于存放 RTV 描述符。 专门用于渲染目标视图的堆。D3D12_DESCRIPTOR_HEAP_TYPE_DSV
: 用于存放 DSV 描述符。 专门用于深度/模板视图的堆。
为什么要使用描述符堆?:
高效的内存管理: 描述符堆允许 D3D12 驱动程序 集中管理描述符内存,提高内存分配和访问效率。
GPU 硬件优化: 描述符堆的设计 有利于 GPU 硬件的优化,例如,提高描述符缓存的命中率,减少内存带宽占用。
简化资源绑定: 通过描述符堆,可以更方便地将描述符 批量绑定 到命令列表 (Command List) 和 Root Signature 中,简化资源绑定流程。
3. 描述符句柄 (Descriptor Handle):访问描述符的钥匙
描述符句柄 (Descriptor Handle) 是访问描述符的“钥匙”。 每个描述符句柄都指向描述符堆中 一个特定的描述符内存位置。 D3D12 使用句柄来 间接访问 描述符,而不是直接使用内存地址。
描述符句柄类型: 描述符句柄分为两种类型:
CPU 描述符句柄 (CPU Descriptor Handle -
D3D12_CPU_DESCRIPTOR_HANDLE
): CPU 使用的句柄,用于在 CPU 端 创建、修改和复制 描述符。 CPU 描述符句柄指向描述符堆中描述符数据在 CPU 可访问内存 中的位置 (可能是系统内存或显存,取决于描述符堆的属性)。GPU 描述符句柄 (GPU Descriptor Handle -
D3D12_GPU_DESCRIPTOR_HANDLE
): GPU 使用的句柄,用于在 GPU 端 访问描述符,例如,在着色器程序中通过句柄来访问纹理或常量缓冲区。 GPU 描述符句柄指向描述符堆中描述符数据在 GPU 可访问内存 中的位置 (通常是显存)。 GPU 描述符句柄只有在 Shader-Visible 的描述符堆中才有效。
描述符句柄的作用:
CPU 端描述符操作: CPU 使用 CPU 描述符句柄来 创建各种类型的描述符 (例如,CBV, SRV, RTV 等),并将描述符数据写入到描述符堆中。 例如,使用
device->CreateConstantBufferView
,device->CreateShaderResourceView
等函数创建描述符时,需要提供 CPU 描述符句柄作为输出参数,用于接收新创建的描述符的句柄。GPU 端资源绑定: 在命令列表中,通过 Root Signature 将描述符堆和描述符句柄 绑定到渲染管线。 着色器程序可以通过 Root Signature 中定义的描述符表 (Descriptor Table) 和描述符句柄,访问到描述符堆中的资源。
4. descriptor_handle
结构体和 descriptor_heap
类代码详解
现在,我们结合代码,深入理解 descriptor_handle
结构体和 descriptor_heap
类的实现细节。
4.1 descriptor_handle
结构体
C++
struct descriptor_handle
{
D3D12_CPU_DESCRIPTOR_HANDLE cpu{};
D3D12_GPU_DESCRIPTOR_HANDLE gpu{};
constexpr bool is_valid() const { return cpu.ptr != 0; }
constexpr bool is_shader_visible() const { return gpu.ptr != 0; }
#ifdef _DEBUG
private:
friend class descriptor_heap;
descriptor_heap* container{ nullptr };
u32 index{ u32_invalid_id };
#endif
};
D3D12_CPU_DESCRIPTOR_HANDLE cpu
: 存储 CPU 描述符句柄。D3D12_CPU_DESCRIPTOR_HANDLE
是一个结构体,但本质上可以理解为一个 64 位或 32 位的整数,表示内存地址。D3D12_GPU_DESCRIPTOR_HANDLE gpu
: 存储 GPU 描述符句柄。 与D3D12_CPU_DESCRIPTOR_HANDLE
类似,也是表示内存地址的结构体。 只有当描述符句柄来自 Shader-Visible 的描述符堆时,gpu
成员才有效 (非 0)。is_valid()
: constexpr 函数,用于检查descriptor_handle
是否有效。 通过判断cpu.ptr
是否为 0 来确定,因为有效的描述符句柄cpu.ptr
不为 0。is_shader_visible()
: constexpr 函数,用于检查descriptor_handle
是否是 Shader-Visible 的。 通过判断gpu.ptr
是否为 0 来确定,只有 Shader-Visible 描述符堆的句柄gpu.ptr
才不为 0。#ifdef _DEBUG ... #endif
: 调试模式下的成员变量:descriptor_heap* container
: 指向拥有此descriptor_handle
的descriptor_heap
对象,方便在调试时追踪描述符的来源。u32 index
: 描述符在描述符堆中的索引,用于调试时定位描述符在堆中的位置。friend class descriptor_heap;
: 声明descriptor_heap
类为友元类,允许descriptor_heap
类访问descriptor_handle
的私有成员 (主要是为了在descriptor_heap
类内部初始化container
和index
成员)。
4.2 descriptor_heap
类
C++
class descriptor_heap
{
public:
explicit descriptor_heap(D3D12_DESCRIPTOR_HEAP_TYPE type) : _type{ type } {}
DISABLE_COPY_AND_MOVE(descriptor_heap);
~descriptor_heap() { assert(!_heap); }
bool initialize(u32 capacity, bool is_shader_visible);
void release();
[[nodiscard]] descriptor_handle allocate();
void free(descriptor_handle handle);
// 访问器 (Accessors)
// ... (getter 函数) ...
private:
ID3D12DescriptorHeap* _heap;
D3D12_CPU_DESCRIPTOR_HANDLE _cpu_start{};
D3D12_GPU_DESCRIPTOR_HANDLE _gpu_start{};
std::unique_ptr<u32[]> _free_handles{};
std::mutex _mutex{};
u32 _capacity{ 0 };
u32 _size{ 0 };
u32 _descriptor_size{};
const D3D12_DESCRIPTOR_HEAP_TYPE _type{};
};
explicit descriptor_heap(D3D12_DESCRIPTOR_HEAP_TYPE type)
: 构造函数。 接收一个D3D12_DESCRIPTOR_HEAP_TYPE
枚举值,指定要创建的描述符堆的类型 (例如D3D12_DESCRIPTOR_HEAP_TYPE_CBV_SRV_UAV
),并保存在_type
成员变量中。explicit
关键字防止隐式类型转换。DISABLE_COPY_AND_MOVE(descriptor_heap)
: 使用宏禁用拷贝构造函数、拷贝赋值运算符、移动构造函数和移动赋值运算符。 描述符堆对象通常不应该被拷贝或移动,因为它们管理着 GPU 资源。 禁用拷贝和移动操作可以防止潜在的资源管理错误。~descriptor_heap() { assert(!_heap); }
: 析构函数。 在对象销毁时被调用。 断言_heap
成员变量为空指针 (!_heap
)。 期望在descriptor_heap
对象销毁之前,release()
函数已经被调用,释放了 D3D12 描述符堆资源。 如果_heap
不为空,说明资源没有被正确释放,触发断言,帮助开发者尽早发现资源泄漏问题。bool initialize(u32 capacity, bool is_shader_visible)
**: 初始化描述符堆。capacity
: 指定描述符堆的 容量,即可以存储的最大描述符数量。is_shader_visible
: 布尔值,指定描述符堆是否是 Shader-Visible 的。 Shader-Visible 描述符堆的 GPU 描述符句柄可以被着色器程序直接访问。 非 Shader-Visible 描述符堆通常用于 RTV 和 DSV 堆,CPU 端操作,GPU 不直接访问。函数内部会创建 D3D12 描述符堆对象 (
ID3D12DescriptorHeap
),并初始化内部成员变量,例如,空闲句柄索引数组_free_handles
, 描述符大小_descriptor_size
, CPU/GPU 起始句柄_cpu_start
,_gpu_start
等。线程安全: 使用
std::lock_guard{ _mutex }
加锁,保证在多线程环境下调用initialize
函数的线程安全性。错误处理: 如果描述符堆创建失败 (
device->CreateDescriptorHeap
返回错误码),返回false
,否则返回true
。
void release()
: 释放描述符堆资源。函数内部会释放 D3D12 描述符堆对象 (
_heap
),并将内部成员变量重置为初始状态。线程安全: 使用
std::lock_guard{ _mutex }
加锁,保证线程安全性。
[[nodiscard]] descriptor_handle allocate()
: 分配描述符句柄。从描述符堆中 分配一个空闲的描述符句柄。
使用简单的 线性分配策略,从
_free_handles
数组中取出一个空闲索引,计算出描述符在堆中的偏移量,并根据偏移量计算出 CPU 和 GPU 描述符句柄。线程安全: 使用
std::lock_guard{ _mutex }
加锁,保证线程安全性。[[nodiscard]]
: C++17 属性,建议编译器检查函数返回值是否被使用,提醒开发者不要忽略返回值,因为allocate()
函数返回的是新分配的描述符句柄,通常需要被使用。
void free(descriptor_handle handle)
: 释放描述符句柄。将之前分配的描述符句柄 标记为 "空闲",以便后续可以被重新分配。
线程安全: 使用
std::lock_guard{ _mutex }
加锁,保证线程安全性。TODO:延迟释放: 代码中标记了
TODO:延迟释放
,说明当前的free
函数实现只是一个占位符,实际的释放逻辑需要根据具体的内存管理策略来完善。 在实际应用中,可能需要实现更复杂的内存管理策略,例如延迟释放、对象池等,以提高性能。 例如,可以将释放的描述符句柄索引放回_free_handles
数组,或者使用更高效的数据结构来管理空闲句柄。
访问器 (Accessors): 提供
constexpr
getter 函数,用于 安全地访问descriptor_heap
类的私有成员变量,例如,获取描述符堆类型、起始句柄、容量、已分配数量、描述符大小、Shader-Visible 状态等。constexpr
表示这些函数可以在编译时求值,提高性能。
5. 总结
descriptor_heap
类和 descriptor_handle
结构体共同实现了 D3D12 描述符堆的封装和管理。 descriptor_heap
类负责创建、初始化、释放描述符堆,并提供分配和释放描述符句柄的接口。 descriptor_handle
结构体则表示描述符句柄,包含 CPU 和 GPU 两种类型的句柄,并提供一些辅助函数和调试信息。
理解描述符堆和描述符句柄是 D3D12 编程的基础。 通过本教程和代码示例,你应该对 D3D12 描述符管理有了更深入的认识。 在后续的 D3D12 学习中,你将频繁地使用描述符堆和描述符句柄来管理各种 GPU 资源,构建复杂的渲染场景。 请务必牢牢掌握这些核心概念。