——D3D12资源与堆管理:从上传到驻留)
通用GUI编程技术——图形渲染实战四十五——D3D12资源与堆管理从上传到驻留仓库已经开源喜欢的话点个⭐仓库Win32和Win32图形栈的部分目前已完成教程力争做一个完备的GUI教程欢迎各位大佬前来参观https://github.com/Charliechen114514/anatomy_gui上回我们把 D3D12 的命令系统理了一遍——CommandAllocator 是命令内存池CommandList 是命令录制接口CommandQueue 是命令提交通道Fence 负责同步。命令系统告诉我们怎么指挥 GPU但指挥 GPU 做什么呢操作数据。资源管理告诉我们GPU 操作的数据在哪里、怎么从 CPU 传到 GPU。这篇我们就来拆解 D3D12 的内存模型——三种堆类型、三种资源创建方式、以及资源屏障的正确使用。前言为什么 D3D12 的内存管理这么复杂如果你还记得 D3D11 中的资源创建那是一件相当简单的事情——调用CreateTexture2D或CreateBuffer传一个描述结构体驱动帮你选择合适的内存位置帮你管理驻留Residency帮你在需要的时候迁移数据。你根本不需要知道数据最终是放在系统内存还是显存里。D3D12 把这些全部交给了开发者。你现在需要知道的是GPU 有自己的专用内存显存CPU 有自己的内存系统内存两者之间的数据传输是异步的、有延迟的。你的任务是合理地安排数据在什么时候放在哪里以及确保 GPU 在读取数据的时候数据确实还在那里。这不是无意义的复杂化——了解底层细节意味着你可以做更激进的优化。比如把频繁更新的数据放在 Upload 堆中CPU 可写把只读的纹理放在 Default 堆中GPU 本地访问最快把需要回读到 CPU 的数据比如截图放在 Readback 堆中。这种精细控制在 D3D11 中是不可能的。环境说明本篇延续前两篇 D3D12 文章的环境配置操作系统: Windows 11 Pro 10.0.26200编译器: MSVC (Visual Studio 2022, v143 工具集)Windows SDK: 10.0.26100 或更高版本依赖:d3d12.h、dxgi1_4.h、d3dx12.h辅助头文件前置知识: 文章 43D3D12 设计哲学、文章 44命令列表与队列三种堆类型D3D12 定义了三种预定义的堆类型每种堆类型对应不同的 CPU-GPU 访问模式。理解它们各自的用途和限制是掌握 D3D12 内存管理的基础。DEFAULT 堆——GPU 的私人领地D3D12_HEAP_TYPE_DEFAULT是 GPU 本地内存通常是显存。CPU 不能直接访问这块内存——你不能用memcpy或指针来读写它。数据必须通过 GPU 的拷贝命令CopyBufferRegion、CopyTextureRegion等从其他堆传输到 DEFAULT 堆。DEFAULT 堆的特点是 GPU 访问速度最快。纹理、渲染目标、深度缓冲、以及那些一旦上传就不需要 CPU 端修改的顶点缓冲和索引缓冲都应该放在 DEFAULT 堆中。对于游戏和图形应用来说这是使用量最大的堆类型。UPLOAD 堆——CPU 写入的传送带D3D12_HEAP_TYPE_UPLOAD是一种 CPU 可写、GPU 可读的内存。你可以通过Map获取这块内存的 CPU 端指针然后用memcpy写入数据GPU 就能读到。它的主要用途是作为上传中转站——你先把数据写到 UPLOAD 堆然后通过 GPU 拷贝命令把数据从 UPLOAD 堆复制到 DEFAULT 堆。UPLOAD 堆的 GPU 访问速度比 DEFAULT 堆慢因为它通常位于系统内存中或者PCIe BAR空间GPU 需要通过 PCIe 总线来访问。所以 UPLOAD 堆不适合存放 GPU 需要高频读取的数据——比如每帧被采样数百万次的纹理。但对于需要每帧从 CPU 更新的数据比如 MVP 矩阵的常量缓冲区UPLOAD 堆是最合适的选择。READBACK 堆——GPU 写回的回收站D3D12_HEAP_TYPE_READBACK是 CPU 可读、GPU 可写的内存。它的用途是把 GPU 端的数据回读到 CPU——比如截图把渲染目标的内容读回来保存为图片、GPU 计算结果比如 GPU 端的碰撞检测结果。READBACK 堆的使用频率远低于 DEFAULT 和 UPLOAD。根据 Microsoft Learn - Heap Types 的文档三种堆类型的内存访问属性可以总结如下堆类型CPU 访问GPU 访问典型用途DEFAULT不可最快纹理、渲染目标、静态顶点缓冲UPLOAD可写较慢常量缓冲、上传中转READBACK可读可写截图、回读计算结果三种资源创建方式D3D12 提供了三种创建资源的方式复杂度递增但灵活性也递增。CreateCommittedResource——一步到位这是最简单的方式相当于创建一个堆并在堆中放置一个资源。一个函数调用就完成了堆的创建和资源的放置// 创建一个上传堆中的常量缓冲区ComPtrID3D12ResourcepConstantBuffer;D3D12_HEAP_PROPERTIES heapProps{};heapProps.TypeD3D12_HEAP_TYPE_UPLOAD;heapProps.CPUPagePropertyD3D12_CPU_PAGE_PROPERTY_UNKNOWN;heapProps.MemoryPoolPreferenceD3D12_MEMORY_POOL_UNKNOWN;heapProps.CreationNodeMask0;heapProps.VisibleNodeMask0;D3D12_RESOURCE_DESC resourceDesc{};resourceDesc.DimensionD3D12_RESOURCE_DIMENSION_BUFFER;resourceDesc.Width256;// CBV 必须 256 字节对齐resourceDesc.Height1;resourceDesc.DepthOrArraySize1;resourceDesc.MipLevels1;resourceDesc.FormatDXGI_FORMAT_UNKNOWN;resourceDesc.SampleDesc.Count1;resourceDesc.LayoutD3D12_TEXTURE_LAYOUT_ROW_MAJOR;HRESULT hrg_device-CreateCommittedResource(heapProps,D3D12_HEAP_FLAG_NONE,resourceDesc,D3D12_RESOURCE_STATE_GENERIC_READ,// UPLOAD 堆的初始状态nullptr,// 清除值仅用于渲染目标/深度缓冲IID_PPV_ARGS(pConstantBuffer));CreateCommittedResource的优点是简单——你不需要预先创建堆一个调用搞定。缺点是每次调用都会创建一个独立的堆堆之间可能有对齐填充导致的内存浪费。如果你需要创建大量小资源比如几十个常量缓冲区每个都CreateCommittedResource的话对齐填充会浪费大量显存。CreatePlacedResource——手动管理堆空间这种方式把堆的创建和资源的放置分开了——你先创建一个大的堆然后在堆中的指定偏移位置放置资源// 先创建一个大堆D3D12_HEAP_DESC heapDesc{};heapDesc.SizeInBytes1024*1024;// 1MBheapDesc.Properties.TypeD3D12_HEAP_TYPE_DEFAULT;heapDesc.AlignmentD3D12_DEFAULT_MSAA_RESOURCE_PLACEMENT_ALIGNMENT;heapDesc.FlagsD3D12_HEAP_FLAG_NONE;ComPtrID3D12HeappHeap;g_device-CreateHeap(heapDesc,IID_PPV_ARGS(pHeap));// 在堆的偏移 0 处放置顶点缓冲D3D12_RESOURCE_DESC vbDesc{};vbDesc.DimensionD3D12_RESOURCE_DIMENSION_BUFFER;vbDesc.Widthsizeof(vertices);// ... 其他字段ComPtrID3D12ResourcepVertexBuffer;g_device-CreatePlacedResource(pHeap.Get(),0,// 偏移量vbDesc,D3D12_RESOURCE_STATE_COPY_DEST,nullptr,IID_PPV_ARGS(pVertexBuffer));// 在堆的另一个偏移处放置索引缓冲// 需要计算正确的对齐偏移UINT64 vbOffsetAlignUp(sizeof(vertices),65536);// 64KB 对齐ComPtrID3D12ResourcepIndexBuffer;g_device-CreatePlacedResource(pHeap.Get(),vbOffset,ibDesc,D3D12_RESOURCE_STATE_COPY_DEST,nullptr,IID_PPV_ARGS(pIndexBuffer));CreatePlacedResource的优势是多个资源可以共享同一个堆减少内存浪费和对齐填充。代价是你需要手动管理堆内空间的分配和回收类似一个简单的内存分配器。CreateReservedResource——虚拟内存映射这是最复杂的方式对应 D3D12 的 Tiled Resources平铺资源。它创建一个虚拟地址空间你可以按需提交物理页面。主要用于超大纹理的流式加载——比如一个 16384x16384 的地形纹理大部分区域在当前帧是看不到的不需要全部加载到显存中。CreateReservedResource在 GUI 编程中用得很少我们就不展开了。第一步——上传顶点数据的标准流程理论讲够了现在我们来实际操作。D3D12 中上传顶点数据到 GPU 的标准流程是创建一个 UPLOAD 堆的中转缓冲区Map获取 CPU 端指针memcpy写入数据创建一个 DEFAULT 堆的目标缓冲区通过 GPU 拷贝命令把数据从 UPLOAD 复制到 DEFAULT插入资源屏障将资源状态从COPY_DEST转换为VERTEX_AND_CONSTANT_BUFFER创建上传缓冲区// 顶点数据structVertex{XMFLOAT3 position;XMFLOAT4 color;};Vertex triangleVertices[]{{XMFLOAT3(0.0f,0.5f,0.0f),XMFLOAT4(1,0,0,1)},{XMFLOAT3(0.5f,-0.5f,0.0f),XMFLOAT4(0,1,0,1)},{XMFLOAT3(-0.5f,-0.5f,0.0f),XMFLOAT4(0,0,1,1)},};constUINT vertexBufferSizesizeof(triangleVertices);// 创建 UPLOAD 堆的中转缓冲区ComPtrID3D12ResourcepUploadBuffer;D3D12_HEAP_PROPERTIES uploadHeapProps{};uploadHeapProps.TypeD3D12_HEAP_TYPE_UPLOAD;D3D12_RESOURCE_DESC bufferDesc{};bufferDesc.DimensionD3D12_RESOURCE_DIMENSION_BUFFER;bufferDesc.WidthvertexBufferSize;bufferDesc.Height1;bufferDesc.DepthOrArraySize1;bufferDesc.MipLevels1;bufferDesc.FormatDXGI_FORMAT_UNKNOWN;bufferDesc.SampleDesc.Count1;bufferDesc.LayoutD3D12_TEXTURE_LAYOUT_ROW_MAJOR;g_device-CreateCommittedResource(uploadHeapProps,D3D12_HEAP_FLAG_NONE,bufferDesc,D3D12_RESOURCE_STATE_GENERIC_READ,nullptr,IID_PPV_ARGS(pUploadBuffer));Map/Memcpy/Unmap 写入数据// 获取CPU端指针void*pDatanullptr;pUploadBuffer-Map(0,nullptr,pData);// 拷贝顶点数据memcpy(pData,triangleVertices,vertexBufferSize);// 解除映射pUploadBuffer-Unmap(0,nullptr);Map函数返回一个 CPU 可写的内存指针。对于 UPLOAD 堆这个指针总是可用的——不需要等待 GPU。根据 Microsoft Learn - ID3D12Resource::Map 的文档UPLOAD 堆的资源始终处于可以被 CPU 访问的状态。Unmap调用是可选的——如果你频繁更新数据可以保持 Map 状态而不 UnmapGPU 仍然可以读取数据。但为了代码清晰建议在不更新时 Unmap。创建默认缓冲区并拷贝// 创建 DEFAULT 堆的目标缓冲区ComPtrID3D12ResourcepVertexBuffer;D3D12_HEAP_PROPERTIES defaultHeapProps{};defaultHeapProps.TypeD3D12_HEAP_TYPE_DEFAULT;g_device-CreateCommittedResource(defaultHeapProps,D3D12_HEAP_FLAG_NONE,bufferDesc,D3D12_RESOURCE_STATE_COPY_DEST,// 初始状态拷贝目标nullptr,IID_PPV_ARGS(pVertexBuffer));注意初始状态是D3D12_RESOURCE_STATE_COPY_DEST——因为我们马上要从 UPLOAD 堆拷贝数据到这个缓冲区。接下来在命令列表中录制拷贝命令// 录制拷贝命令pCommandList-CopyBufferRegion(pVertexBuffer.Get(),0,// 目标偏移 0pUploadBuffer.Get(),0,// 源偏移 0vertexBufferSize// 大小);// 拷贝完成后转换资源状态CD3DX12_RESOURCE_BARRIER barrierCD3DX12_RESOURCE_BARRIER::Transition(pVertexBuffer.Get(),D3D12_RESOURCE_STATE_COPY_DEST,D3D12_RESOURCE_STATE_VERTEX_AND_CONSTANT_BUFFER);pCommandList-ResourceBarrier(1,barrier);CopyBufferRegion是一个 GPU 命令——它不是立即执行的而是被录制到命令列表中在提交给 GPU 后才执行。这意味着 CPU 端的CopyBufferRegion调用几乎是即时的真正的数据传输发生在 GPU 端。拷贝完成后我们必须通过ResourceBarrier把资源状态从COPY_DEST转换为VERTEX_AND_CONSTANT_BUFFER。这个状态转换告诉 GPU“这个缓冲区不再作为拷贝目标了接下来要作为顶点缓冲使用”。GPU 在内部可能需要做一些缓存刷新或地址映射的调整这些都在 Barrier 的掩护下自动完成。设置顶点缓冲视图资源状态转换完成后我们就可以创建顶点缓冲视图VBV并在渲染时使用了D3D12_VERTEX_BUFFER_VIEW vbv{};vbv.BufferLocationpVertexBuffer-GetGPUVirtualAddress();vbv.StrideInBytessizeof(Vertex);vbv.SizeInBytesvertexBufferSize;// 渲染时pCommandList-IASetVertexBuffers(0,1,vbv);pCommandList-IASetPrimitiveTopology(D3D_PRIMITIVE_TOPOLOGY_TRIANGLELIST);pCommandList-DrawInstanced(3,1,0,0);Resource Barrier 详解资源屏障是 D3D12 中最重要的概念之一也是初学者最容易忘记的地方。Transition Barrier——状态转换最常见的屏障类型是 Transition Barrier用于在资源的不同使用状态之间切换。一个缓冲区在某个时刻只能处于一种状态——你不能在它还是COPY_DEST的时候就把它当作VERTEX_BUFFER来用。一些常见的状态转换场景包括COPY_DEST → VERTEX_AND_CONSTANT_BUFFER上传顶点/常量数据后COPY_DEST → PIXEL_SHADER_RESOURCE上传纹理后PRESENT → RENDER_TARGET开始渲染前RENDER_TARGET → PRESENT渲染完成后RENDER_TARGET → PIXEL_SHADER_RESOURCE后处理时把渲染结果作为输入UAV Barrier——无序访问同步当你有两个连续的 Compute Shader dispatch 或像素着色器 UAV 写入操作后一个需要等前一个完成时使用 UAV Barrier。它不改变资源状态只是告诉 GPU “等前一个 UAV 写入完成再开始下一个”。Aliasing Barrier——放置资源复用当两个CreatePlacedResource创建的资源在同一堆的同一位置交替使用时需要 Aliasing Barrier 来确保一个资源的写入对另一个资源可见。这种场景比较少见。⚠️ 踩坑预警坑点一忘记上传后转换资源状态如果你上传了顶点数据但忘了插入COPY_DEST → VERTEX_BUFFER的 Barrier调试层会报一个错误告诉你资源处于错误的状态。但如果你禁用了调试层结果是不确定的——可能正常工作因为某些 GPU 驱动比较宽容可能在某些硬件上画面完全错误也可能直接导致设备丢失。所以在上传数据后始终记得插入 Barrier。坑点二Upload Buffer 在命令执行期间被释放这是一个非常阴险的 bug。CopyBufferRegion只是把拷贝命令录入了命令列表真正的拷贝发生在 GPU 执行命令时。如果你在ExecuteCommandLists之前就把 Upload Buffer 的 COM 对象释放了比如它是一个局部变量函数返回后自动释放GPU 执行时源数据已经不存在了——读取到的是无效数据。解决方案是确保 Upload Buffer 的生命周期覆盖到 GPU 确认执行完毕之后。通常的做法是把 Upload Buffer 作为类成员存储在下一帧开始时通过 Fence 确认上一帧 GPU 已完成才复用或释放它。// 正确做法保留 Upload Buffer 直到 GPU 完成拷贝structFrameResource{ComPtrID3D12ResourceuploadBuffer;// 保留不能提前释放// ... 其他帧资源};坑点三常量缓冲区的 256 字节对齐D3D12 的常量缓冲区视图CBV要求缓冲区大小必须是 256 字节的倍数。如果你的常量结构体只有 64 字节你需要分配至少 256 字节的缓冲区空间。这不是建议是硬性要求——违反会导致创建 CBV 时报错。// 正确的大小计算constexprUINT cbSize(sizeof(MyConstants)255)~255;坑点四堆类型和初始状态不匹配每种堆类型有允许的初始状态。UPLOAD 堆的初始状态只能是GENERIC_READREADBACK 堆的初始状态只能是COPY_DEST。如果你给 UPLOAD 堆设了COPY_DEST的初始状态创建会直接失败。封装 GpuUploadBuffer 工具类上传流程涉及的代码比较多我们可以封装一个工具类来简化日常使用classGpuUploadBuffer{public:GpuUploadBuffer()default;~GpuUploadBuffer()default;// 上传数据到 DEFAULT 堆返回目标资源ComPtrID3D12ResourceUpload(ID3D12Device*pDevice,ID3D12GraphicsCommandList*pCmdList,constvoid*pData,SIZE_T dataSize,D3D12_RESOURCE_STATES targetState){// 创建上传缓冲区autouploadHeapPropsCD3DX12_HEAP_PROPERTIES(D3D12_HEAP_TYPE_UPLOAD);autobufferDescCD3DX12_RESOURCE_DESC::Buffer(dataSize);ComPtrID3D12ResourcepUpload;pDevice-CreateCommittedResource(uploadHeapProps,D3D12_HEAP_FLAG_NONE,bufferDesc,D3D12_RESOURCE_STATE_GENERIC_READ,nullptr,IID_PPV_ARGS(pUpload));// Map 并写入数据void*pMappednullptr;pUpload-Map(0,nullptr,pMapped);memcpy(pMapped,pData,dataSize);pUpload-Unmap(0,nullptr);// 创建目标缓冲区autodefaultHeapPropsCD3DX12_HEAP_PROPERTIES(D3D12_HEAP_TYPE_DEFAULT);ComPtrID3D12ResourcepDest;pDevice-CreateCommittedResource(defaultHeapProps,D3D12_HEAP_FLAG_NONE,bufferDesc,D3D12_RESOURCE_STATE_COPY_DEST,nullptr,IID_PPV_ARGS(pDest));// 录制拷贝命令pCmdList-CopyBufferRegion(pDest.Get(),0,pUpload.Get(),0,dataSize);// 转换资源状态autobarrierCD3DX12_RESOURCE_BARRIER::Transition(pDest.Get(),D3D12_RESOURCE_STATE_COPY_DEST,targetState);pCmdList-ResourceBarrier(1,barrier);// 保留上传缓冲区由调用者确保生命周期m_uploadBuffers.push_back(pUpload);returnpDest;}// 在确认 GPU 完成后调用释放上传缓冲区voidReleaseUploadBuffers(){m_uploadBuffers.clear();}private:std::vectorComPtrID3D12Resourcem_uploadBuffers;};⚠️ 这个类有一个重要的使用约束ReleaseUploadBuffers只能在确认 GPU 已经执行完所有拷贝命令后调用通过 Fence 确认。如果在 GPU 还没完成拷贝时就释放了上传缓冲区GPU 读到的就是无效数据。在实际项目中你通常会把上传缓冲区挂到帧资源上每帧结束时等待 GPU 完成后再清理。常见问题Q: 能不能不用 UPLOAD 堆直接把数据写到 DEFAULT 堆不能。DEFAULT 堆对 CPU 不可见你没有办法通过Map或指针来直接写入数据。数据必须通过 GPU 拷贝命令从 UPLOAD 堆或另一个 DEFAULT 堆资源传输过来。Q: 什么情况下用 CreateCommittedResource什么情况下用 CreatePlacedResource简单原则如果你只需要创建少量资源比如几十个缓冲区和纹理用CreateCommittedResource就够了。它的内存浪费可以忽略不计。如果你需要创建大量资源比如一个纹理流式加载系统管理数百个纹理用CreatePlacedResource把它们放在少数几个大堆中减少堆数量和内存碎片。Q: 每帧都 Map/Unmap 常量缓冲区会不会影响性能对于 UPLOAD 堆中的常量缓冲区Map的开销几乎为零——它只是返回一个 CPU 端指针。真正的开销在于 CPU 和 GPU 之间的数据传输但只要你的常量缓冲区在 UPLOAD 堆中GPU 通过 PCIe 总线读取它的开销是固定的、不可避免的。所以放心 Map/Unmap这不是性能瓶颈。总结这篇我们拆解了 D3D12 内存管理的核心内容。三种堆类型各有分工DEFAULT 堆是 GPU 的快速本地内存存放纹理、渲染目标和静态几何数据UPLOAD 堆是 CPU 可写的上传通道用于将数据从 CPU 传输到 GPUREADBACK 堆是 GPU 写回 CPU 的回收通道用于截图和计算结果回读。三种资源创建方式复杂度递增CreateCommittedResource最简单但可能有内存浪费CreatePlacedResource更灵活但需要手动管理堆空间CreateReservedResource支持虚拟内存映射但使用场景较少。我们实现了上传顶点数据的标准流程——CPU → Upload Buffer → CopyBufferRegion → Default Buffer → Barrier → 使用并且封装了一个GpuUploadBuffer工具类来简化日常开发。踩坑方面最危险的是 Upload Buffer 的生命周期问题——必须在 GPU 确认完成拷贝后才能释放。下一篇我们要把资源管理推向更深的一层——描述符堆和根签名。资源创建好之后GPU 还需要知道怎么找到它们这就是描述符机制要做的事情。练习使用本文的标准上传流程创建一个 D3D12 项目上传一组顶点数据到 DEFAULT 堆渲染一个彩色三角形。确保 Barrier 正确地从COPY_DEST转换到VERTEX_BUFFER。故意去掉上传后的ResourceBarrier调用在 Debug 模式下运行。观察调试层的输出信息理解为什么状态转换是必须的。封装GpuUploadBuffer工具类并添加对纹理上传的支持。提示纹理上传需要用CopyTextureRegion而不是CopyBufferRegion而且需要设置D3D12_PLACED_SUBRESOURCE_FOOTPRINT来描述纹理在缓冲区中的布局。实验对比CreateCommittedResource和CreatePlacedResource创建一个大堆然后在其中放置多个小缓冲区。计算在两种方案下 10 个 256 字节的常量缓冲区各自消耗的总内存量考虑 64KB 的堆对齐理解为什么大量小资源不应该用 Committed 方式创建。参考资料:Memory Management in D3D12 - Microsoft LearnHeap Types - Microsoft LearnID3D12Device::CreateCommittedResource - Microsoft LearnID3D12Device::CreatePlacedResource - Microsoft LearnID3D12Resource::Map - Microsoft LearnUsing Resource Barriers - Microsoft LearnD3D12_RESOURCE_STATES enumeration - Microsoft Learn相关阅读嵌入式Linux嵌入式Linux驱动开发设备树驱动改造——从硬编码到设备树的实战之旅 - 相似度 100%嵌入式Linux驱动开发pinctrl篇1——从寄存器到子系统驱动演进之路 - 相似度 100%04. OF API 基础与验证——从 DTS 到代码的桥梁 - 相似度 82%