Skip to content

D3D12 中的资源和堆

本文是对 DirectX 12 龙书第二版(Introduction to 3D Game Programming with DirectX 12 Second Edition)第四章新增内容的整理,主要涉及 4.1.13 节的资源和 4.1.14 节的常见堆类型。

这两小节是第一版完全没有的内容。要理解后面的 CreateCommittedResource、常量缓冲为什么要放 UPLOAD 堆、纹理为什么要先上传到 UPLOAD 再拷贝到 DEFAULT,都必须先搞懂这里。

4.1.13 资源(Resources)

在 Direct3D 12 里,我们要和两类资源(resources)打交道:缓冲(buffers)和纹理(textures)。

  • 缓冲(buffer):某种类型数据元素的数组。比如顶点数据、索引数据、常量数据,本质上就是一块结构化内存。
  • 纹理(texture):§4.1.3 已经简单提过,第九章会详细讲。现在可以把它理解为一张按二维网格排列的图像数据。

为了性能,我们当然希望资源放在显存(VRAM)里,因为 GPU 访问显存的带宽非常高。不过 GPU 也可以通过 PCI Express 总线去读系统内存里的数据,只是慢得多。

Direct3D 里会反复出现一个词:堆(heap)。它其实就是“一块内存”。描述符堆(descriptor heap)是放描述符的内存;资源堆(resource heap)是放资源(缓冲和纹理)的内存。下面要说的三种资源创建方式,核心差别就在于“内存(堆)”和“资源对象”之间是绑定还是分离。


已提交资源(Committed Resources)

已提交资源是最简单、最常用的创建方式。调用一次 API(ID3D12Device::CreateCommittedResource),就会同时创建资源和它背后的堆,二者被绑定成一个整体。

这种方式的缺点是创建本身比较重——因为它要真的去分配内存。所以最好是在初始化或加载资源时一次性创建好,不要在渲染一帧的过程中临时创建。

具体 API 细节在 §4.3.7 讲。


放置资源(Placed Resources)

放置资源的思路是:先创建一个大堆,然后往这个堆里“放置”资源。换句话说,它把分配内存创建资源对象这两步拆开了。

这很像 CPU 上的内存池:你先一次性申请一大块内存,然后在这块内存里分配变量。因为堆已经存在,创建/销毁资源时只需要在堆里找一个位置,所以比已提交资源快得多,适合运行时频繁创建和销毁资源的场景。

放置资源的另一个好处是内存别名(memory aliasing):如果若干资源不会在同一时间被使用,可以让它们在内存中重叠(overlap),从而复用同一块显存。下面图 4.6 就是解释这个。

图 4.6

图 4.6 内存别名:GPU 内存中有一个堆。我们先在堆里放置资源 R1、R2 和 R3;之后又在同一堆里放置 R4 和 R5,其中 R4 与 R1 重叠、与 R2 部分重叠,R5 与 R3 重叠。

放置资源对任务图 / 帧图(task/frame graph)系统特别有用,因为这类系统可以自动计算哪些资源可以复用、哪些可以别名。

堆用 ID3D12Device::CreateHeap 创建,放置资源用 ID3D12Device::CreatePlacedResource 创建。本书后面不会实际使用放置资源,因为它更像是一种优化手段,深入讲会分散注意力;但在商业项目里你很可能会用到,至少要心里有数。


保留资源(Reserved Resources)

保留资源是另一种高级资源类型,本书只简单介绍,不会实际使用。

它为 GPU 资源提供了一种类似虚拟内存的机制:你可以创建一个非常大的资源,但创建时它并不占用任何物理内存。这个大资源被划分成一个个瓦片(tile),Direct3D 提供 API 让程序根据当前需要把瓦片移入或移出物理内存。

典型的例子是一张巨大的卫星地图。你当前只盯着洛杉矶市中心看,那大部分地图根本不需要进物理内存;随着地图平移,再把不同的瓦片换进换出。

Direct3D 11 里这类资源叫“平铺资源(tiled resources)”,概念和保留资源是一样的。想了解更多可以参考 [Sandy14] 和 [Cebenoyan14] 的在线视频。


本节理解

  1. 已提交资源 = 一站式:最省心,但分配开销大,适合初始化时创建静态资源。
  2. 放置资源 = 自己管理内存池:适合频繁创建/销毁,能通过别名大幅节省显存,但代码更复杂。
  3. 保留资源 = GPU 虚拟内存:适合超大资源按需加载,是更高级的优化。

对于初学阶段,先把已提交资源用熟。放置资源和保留资源暂时知道存在即可,等你能熟练写 Demo 后再回来研究。


4.1.14 常见堆类型(Common Heap Types)

前面说过,堆就是一块内存。但这块内存到底住在哪里?在有独显的平台上,我们有 CPU 内存(系统内存 RAM)和 GPU 内存(显存 VRAM)。Direct3D 12 用不同的堆类型来区分它们。


1. D3D12_HEAP_TYPE_DEFAULT:默认堆 / 显存堆

DEFAULT 堆就是把资源放在显存(VRAM)里。

  • GPU 可以直接高速读写。
  • CPU 不能直接读写这里面的资源。
  • 适合静态资源,比如纹理、3D 模型数据:加载一次,反复读取。

对于这类资源,通常的做法是:先在 CPU 可写的 UPLOAD 堆里准备好数据,再通过拷贝命令把它复制到 DEFAULT 堆里的资源上。因为 GPU 访问 VRAM 的带宽非常高,所以我们应该尽可能让资源待在这里。


2. D3D12_HEAP_TYPE_UPLOAD:上传堆

UPLOAD 堆是系统内存,CPU 可以写,GPU 可以通过 PCI Express 总线读。

  • 使用 write combining(写合并):把零散的写操作攒成较大的块再提交,提高通过 PCIe 写的效率。
  • 这块内存对 CPU 是 uncached(不可缓存) 的,所以不要在 CPU 侧读取它。一个很隐蔽的性能 bug 是在上传堆里做 += 这种“先读后写”的操作。
  • GPU 从上传堆读取的速度受 PCIe 带宽限制,通常比读 VRAM 慢不少。但如果 GPU 会多次读取同一份数据,它可以缓存这些数据,避免反复跨 PCIe 去取。

凡是 CPU 每帧都要更新、GPU 又要读的数据(比如常量缓冲、每帧变化的顶点数据),一般就放在 UPLOAD 堆里。

另外还有一种做法:把数据先写到 UPLOAD 堆,再拷贝到 DEFAULT 堆。如果 GPU 在同一帧里会多次读取这份数据,那么多花一次拷贝、换来在 VRAM 里高速读取,往往更划算。


3. D3D12_HEAP_TYPE_READBACK:回读堆

READBACK 堆用于 GPU 把数据写回 CPU 的场景。

  • 资源分配在系统内存里。
  • 需要 GPU→CPU 的拷贝,同样受 PCIe 带宽限制。
  • 很重要的一点:当你发出一个 GPU→CPU 的拷贝命令后,拷贝不会立刻完成。GPU 是异步工作的,CPU 不能马上假设数据已经可读。

因此代码里通常要避免阻塞等待 GPU 完成拷贝。常见的做法是允许回读数据比 GPU 当前正在渲染的内容晚一两帧。换句话说,CPU 读到的可能是上一帧或上两帧的数据,这样大家都不会卡住。


4. D3D12_HEAP_TYPE_GPU_UPLOAD:GPU 上传堆(预览特性)

截至本书写作时,这个堆类型还处于开发者预览阶段。使用它需要检查 D3D12_FEATURE_DATA_D3D12_OPTIONS16::GPUUploadHeapSupported(通过 ID3D12Device::CheckFeatureSupport),并且可能需要 BIOS 配置。

它的特殊之处在于:CPU 可以直接拿到一个指向 VRAM 的指针,并通过 PCIe 直接往显存里写。这样:

  • 非常适合 CPU 每帧都要写的资源:CPU 直接写到 VRAM,然后 GPU 以高带宽直接读取。
  • 也可以用来直接初始化静态 GPU 资源,省掉先建 UPLOAD 堆再拷贝到 DEFAULT 堆那一步。

当然,CPU 写到 VRAM 仍然受 PCIe 带宽限制;但一旦数据进了 VRAM,GPU 访问就非常快。


五种数据流动(图 4.7)

下面这张图概括了 CPU、GPU、RAM、VRAM 之间的读写关系:

图 4.7

图 4.7 堆类型总览: - (a) CPU 可以读写系统内存。 - (b) GPU 可以高速读写显存;放在 VRAM 里的资源属于 DEFAULT 堆。 - (c) 资源可以先在系统内存的 UPLOAD 堆里创建,再拷贝到 VRAM 的 DEFAULT 堆资源里;反过来,数据也可以通过 READBACK 从 GPU 回读到 CPU。 - (d) GPU 可以直接读系统内存里的 UPLOAD 堆,但速度较慢,受 PCIe 带宽限制。 - (e) 使用 D3D12_HEAP_TYPE_GPU_UPLOAD 时,CPU 可以直接通过 PCIe 往 VRAM 里写;这也受 PCIe 带宽限制,但数据一旦进入 VRAM,GPU 访问就会非常快。

想了解更多 GPU 内存细节,可以看看 [Sawicki21] 的短片和 [Pettineo22] 的博客。


本节理解

  • DEFAULT 堆:GPU 读最快的家。静态纹理、模型的顶点和索引数据尽量放这里;CPU 要写必须先走 UPLOAD 中转。
  • UPLOAD 堆:CPU 每帧写、GPU 读的中转站。常量缓冲、动态顶点数据典型住户;CPU 不要读它。
  • READBACK 堆:GPU 写回 CPU 的出口。注意异步延迟,别让 CPU 傻等。
  • GPU_UPLOAD 堆:CPU 直写 VRAM 的预览特性。可以理解为 UPLOAD 堆的“升级版”,但还不是所有平台都支持。

这四种堆型是后面所有 Demo 的基石:纹理为什么要 CopyBufferRegion / CopyTextureRegion、常量缓冲为什么直接放 UPLOAD 堆、CPU 为什么不能直接改纹理内容——答案都在这里。


整体总结

这两节把 Direct3D 12 里“资源到底存在哪里”这件事讲透了:

概念 一句话
已提交资源 一次 API 同时创建资源和堆,简单但分配重
放置资源 先建堆再往里面放资源,支持别名复用显存
保留资源 GPU 虚拟内存,适合超大资源流式加载
DEFAULT 堆 显存,GPU 读最快,CPU 写不了
UPLOAD 堆 系统内存,CPU 写、GPU 读,适合每帧更新的数据
READBACK 堆 系统内存,GPU 写回 CPU,注意异步延迟
GPU_UPLOAD 堆 CPU 直写显存的预览特性

把这两节吃透之后,再看后面创建交换链、创建深度模板缓冲、上传纹理、管理常量缓冲,都会清晰很多。