【DX12龙书】第六章 利用Direct3D绘制几何体

本章是 Direct3D 12 绘制 3D 图形的核心实操章节,围绕 “从几何数据准备到提交绘制命令” 的完整链路展开,详细讲解了顶点 / 索引缓冲创建、着色器编译、管线状态配置等关键步骤,最终通过 “立方体演示(Box Demo)” 整合所有知识点,实现 3D 物体的绘制与变换。

各小节内容概况

6.1 顶点与输入布局

本小节主要讲解 CPU 端顶点数据如何传递到 GPU 端顶点着色器,它们之前的 “数据桥梁”如何搭建:

  • 顶点定义:顶点不仅包含空间位置(XMFLOAT3 Pos),还可自定义扩展属性(如颜色XMFLOAT4 Color、法向量XMFLOAT3 Normal、纹理坐标XMFLOAT2 TexCoord等),通过结构体封装成自定义顶点格式。

  • 输入布局与输入元素:输入布局D3D12_INPUT_LAYOUT_DESC包含一个输入元素D3D12_INPUT_ELEMENT_DESC数组。

    这个输入元素逐一定义每个顶点属性的元数据,核心参数包括:语义(SemanticNameSemanticIndex)、格式(DXGI_FORMAT)、输入槽(InputSlot)、字节偏移(AlignedByteOffset)、实例数据步长进率(InstanceDataStepRate,实例化渲染应设置为1)等信息,确保 GPU 能正确解析顶点数据。

    语义匹配规则:输入布局的语义(如POSITIONCOLOR)需与顶点着色器的输入签名完全一致,语义索引(SemanticIndex)用于区分同类型多组数据(如多组纹理坐标TEXCOORD0TEXCOORD1)。

    D3D12_INPUT_LAYOUT_DESC结构体最终会设置给D3D12_GRAPHICS_PIPELINE_STATE_DESCInputLayout,用于创建管线状态对象。

6.2 顶点缓冲区

本小节主要讲解GPU资源的创建与使用,并以顶点缓冲区,这一GPU 存储顶点数据的核心资源为例,实现将 CPU 端顶点数据上传到 GPU:

  • 顶点缓冲区是存储顶点数据的 GPU 资源,负责将 CPU 定义的顶点数据传递给 GPU:

  • 创建流程

    1. 定义顶点数据(如立方体的 8 个顶点);

    2. 填写D3D12_RESOURCE_DESC(指定资源类型为缓冲区、大小、绑定标志D3D12_BIND_VERTEX_BUFFER);

    3. 通过ID3D12Device::CreateCommittedResource创建资源(通常使用上传堆D3D12_HEAP_TYPE_UPLOAD,方便 CPU 写入数据);

    4. 映射资源(Map),将 CPU 端顶点数据拷贝到 GPU 缓冲,再解除映射(Unmap

  • 顶点缓冲视图:通过D3D12_VERTEX_BUFFER_VIEW结构体,描述缓冲区的 GPU 起始地址、总字节大小、单个顶点的字节跨度。GPU起始地址,可以通过ID3D12Resource::GetGPUVirtualAddress获得

    通过ID3D12GraphicsCommandList::IASetVertexBuffers将顶点缓冲绑定到输入汇编器阶段,需指定输入槽索引(与输入布局的InputSlot对应)。

    通过ID3D12GraphicsCommandList::IASetPrimitiveTopology设置图元拓扑(例如三角形列表D3D_PRIMITIVE_TOPOLOGY_TRIANGLELIST)。

  • 绘制:通过ID3D12GraphicsCommandList::DrawInstanced绘制,需指定每个实例绘制的顶点数、顶点缓冲区内第一个被绘制顶点的索引。

6.3 索引与索引缓冲区

本小节主要讲解索引,索引缓冲区的创建和使用

  • 索引的作用:用整数索引指向顶点缓冲中的顶点,避免重复存储共享顶点(如立方体的 8 个顶点可通过 36 个索引组成 12 个三角形)。

  • 索引缓冲视图:流程与顶点缓冲类似,数据为索引数组(整数序列)。通过D3D12_INDEX_BUFFER_VIEW描述缓冲地址、大小、索引格式。索引格式有(DXGI_FORMAT_R16_UINTDXGI_FORMAT_R32_UINT)。

    通过ID3D12GraphicsCommandList::IASetIndexBuffer绑定到输入汇编器。

  • 绘制:通过ID3D12GraphicsCommandList::DrawIndexedInstanced,需指定索引数量、起始索引位置等参数。需要考虑一种绘制方式,将所有物体各自独立的顶点缓冲区和索引缓冲区合并成一个全局的顶点缓冲区和全局的索引缓冲区。这种绘制方式将在下一章介绍。

6.4 顶点着色器示例

本小节以 HLSL 编写的顶点着色器为示例,展示顶点数据的处理与转换:

  • 输入输出签名:输入签名(结构体 or 多个变量)与 CPU 端顶点格式、输入布局语义对应;输出签名包含插值后的顶点属性(如颜色、纹理坐标),传递给像素着色器。

  • 核心功能:实现顶点的空间转换(局部空间→世界空间→视图空间→投影空间),通过常量缓冲接收外部传入的变换矩阵(世界矩阵、视图投影矩阵)。
    如果渲染管线后续没有几何着色器阶段,需要在顶点着色器阶段输出齐次裁剪空间坐标。这个齐次裁剪空间的位置,必须用SV_POSITION语义来修饰
    这个阶段最终进行到与投影矩阵相乘,转换到投影空间。透视除法(除以w分量)转到NDC空间由硬件执行。有些时候希望是C++侧直接给出NDC坐标,例如画一个覆盖全屏幕的三角面,则可以使用正则投影(w为1)。

  • 输入签名匹配:着色器输入语义必须与输入签名的SemanticNameSemanticIndex完全一致,否则 GPU 无法解析数据;但两者类型可以稍有差异,Direct3D允许用户对输入寄存器重新解析,但会触发警告。

6.5 像素着色器示例

本小节以HLSL 编写的像素着色器为示例,展示计算每个像素的最终颜色:

  • 输入来源:接收光栅化阶段插值后的顶点属性(如VSOutput中的颜色、纹理坐标),插值过程由 GPU 自动完成。
    early-z rejection技术,在像素着色器阶段前,可以进行深度测试,剔除一些被遮挡的像素片段。这样可以优化性能。但如果像素着色器中有对像素深度值的修改,那么early-z将被禁用。

  • 核心功能:基于输入属性计算像素颜色,支持固定颜色输出、顶点颜色插值、简单光照计算等,输出为 RGBA 格式(float4)。

  • 与顶点着色器的衔接:输出结构体的属性名和类型需与顶点着色器输出完全匹配,确保插值数据正确传递。

6.6 常量缓冲区

本小节主要讲解常量缓冲区的创建、更新,以及其过程中涉及到的根签名:

常量缓冲用于传递动态数据(如世界矩阵、视图投影矩阵、光照参数),支持 CPU 实时更新并传递给着色器:

  • 核心特点:数据可动态修改(如每帧更新相机视图矩阵),绑定到管线后供着色器读取,格式需满足 16 字节对齐要求(Direct3D 12 强制约束)。

  • 创建与更新流程

    1. 创建常量缓冲资源(上传堆或默认堆,上传堆适合 CPU 频繁更新);

    2. 映射缓冲,拷贝 CPU 端数据(如XMMATRIX world)到缓冲,解除映射;

    3. 创建常量缓冲视图(CBV),描述缓冲的地址和大小,通过根签名绑定到管线。

  • 根签名与描述符表:常量缓冲需通过根签名(Root Signature)声明,根签名定义着色器可访问的资源(如 CBV、纹理),描述符表(Descriptor Table)用于批量绑定多个 CBV 资源,提升效率。

6.7 编译着色器

讲解 HLSL 着色器的编译流程,将源码转换为 GPU 可执行的字节码:

  • 编译方式

    • 运行时编译:调用D3DCompileFromFile函数动态编译

      • 编译选项:需指定着色器模型(如cs_5_0vs_5_1)、优化级别(如D3DCOMPILE_OPTIMIZATION_LEVEL3)、入口函数名(如VSPS)。

      • 字节码加载:编译生成的字节码(ID3DBlob)用于创建着色器对象,或作为管线状态对象(PSO)的输入参数。

    • 离线编译:通过 DirectX自带的 FXC命令行编译工具生成.cso字节码文件。Visual Studio对此有集成,可以将调用工具编译作为构建流程的一部分。

      fxc "shader.hlsl" /T vs_5_0 /E "VS_Main" /Fo "shader_vs.cso" Fc "shader_vs.asm"
      
      参数 描述
      /Od 禁用优化(对调试十分有用)
      /Zi 开启调试信息
      /T [string] 着色器类型和着色器版本,例如vs_5_0
      /E [string] 着色器入口点 "VS_Main"
      /Fo [string] 经过编译的着色器对象字节码 "shader_vs.cso"
      /Fc [string] 输出一个着色器的汇编文件清单 "shader_vs.asm"
  • 着色器汇编查看:可通过FXC编译工具加入/Fc 生成可移植着色器汇编代码,由此可以得到着色器程序指令数量等细节,用于调试着色器逻辑和性能优化。

6.8 光栅化状态

配置光栅化阶段的行为,通过D3D12_RASTERIZER_DESC结构体定义:

  • 核心配置项

    • 填充模式(FillMode):D3D12_FILL_MODE_SOLID(实体填充)或D3D12_FILL_MODE_WIREFRAME(线框填充);

    • 背面剔除(CullMode):D3D12_CULL_MODE_BACK(剔除背面)、D3D12_CULL_MODE_FRONT(剔除正面)或D3D12_CULL_MODE_NONE(不剔除);

    • 深度偏移(DepthBias):解决共面物体的深度冲突;

    • 视口与裁剪矩形:指定光栅化的目标区域(通常为整个屏幕)。

  • 状态创建与绑定:光栅化描述结构体设置给D3D12_GRAPHICS_PIPELINE_STATE_DESC,来创建管线状态对象(PSO)。

6.9 管线状态对象

Direct3D 12将管线的所有可配置状态封装为一个对象,整合管线各阶段的配置,包含根签名、着色器、输入布局、光栅化状态、深度模板状态等:

  • 创建流程:填充D3D12_GRAPHICS_PIPELINE_STATE_DESC结构体,指定各阶段状态(如着色器字节码、输入布局、光栅化状态、根签名等),调用ID3D12Device::CreateGraphicsPipelineState创建管线状态对象ID3D12PipelineState

  • ID3D12PipelineState:将输入布局、顶点着色器、像素着色器、光栅化状态、深度 / 模板状态、混合状态等所有管线配置封装为一个不可变对象,GPU 可预编译状态数据,变更时统一切换减少状态切换时的驱动开销。

  • 使用方式:绘制前通过ID3D12GraphicsCommandList::SetPipelineState设置当前活跃的 PSO,切换 PSO 时 GPU 可快速加载预编译状态,提升绘制效率。

    可以预先创建多个 PSO(如不同着色器、不同光栅化状态),绘制时按需绑定,避免重复创建。

    PSO切换有性能消耗,应该避免频繁修改单个状态导致的 GPU 开销,提升绘制效率。

6.10 几何辅助结构

介绍了龙书代码中的一些对几何体的封装,包括MeshGeometry、SubmeshGeometry结构体,提供生成简单几何(如立方体、球体)的辅助函数 / 结构体,封装顶点和索引数据的创建逻辑

6.11 立方体演示程序

综合所有知识点的完整示例,实现彩色立方体的绘制与交互

6.12 总结 与 6.13 习题

  • 总结:提炼 Direct3D 绘制的核心链路(几何数据→缓冲创建→着色器编译→PSO 配置→绘制命令),强调各环节的衔接逻辑(如语义匹配、状态封装)。

  • 习题:通过实践题巩固知识点(如自定义顶点格式、修改常量缓冲数据、实现线框模式绘制等)。


核心知识点列表

1. 顶点与输入布局

  • 自定义顶点格式:通过结构体封装位置、颜色、法向量等属性;

  • 输入布局描述:D3D12_INPUT_ELEMENT_DESC的核心字段(语义名、格式、字节偏移);

  • 语义匹配规则:输入布局与顶点着色器输入签名的语义必须一致。

2. 顶点缓冲与索引缓冲

  • 缓冲创建流程:描述符填充→资源创建→数据上传(上传堆→默认堆);

  • 资源ID3D12Resource的创建,更新,以及内存对齐;

  • 绑定 API:IASetVertexBuffers(顶点缓冲)、IASetIndexBuffer(索引缓冲);

  • 索引的优势:复用顶点,减少内存占用,支持DrawIndexedInstanced绘制。

3. 着色器编程(HLSL)

  • 顶点着色器:空间转换(局部→世界→视图→投影)、常量缓冲访问、输入输出结构体定义;

  • 像素着色器:插值属性接收、颜色计算、RGBA 输出;

  • 编译流程:离线 / 运行时编译、字节码加载、着色器模型选择。

4. 常量缓冲

  • 创建配置:动态使用方式(D3D12_USAGE_DYNAMIC)、CPU 访问标志、对齐要求;

  • 数据更新:Map/Unmap流程,避免频繁创建缓冲区;

  • 绑定方式:通过 CBV 描述符、根签名配置访问权限;

  • 描述符堆:一段连续内容,可以写入多个资源,供外部使用;

  • 根签名:将一小段内存/各种View/采样器/描述符堆关联的资源,映射到着色器的寄存器,供使用。

5. 光栅化状态与 PSO

  • 光栅化状态核心配置:填充模式、背面剔除、深度偏移;

  • PSO 的作用:封装管线所有状态,优化状态切换性能;

  • PSO 创建:D3D12_GRAPHICS_PIPELINE_STATE_DESC的关键字段(着色器字节码、输入布局、光栅化状态)。

6. 绘制流程与 API

  • 完整绘制链路:几何数据准备→缓冲创建→着色器编译→输入布局创建→PSO 创建→绑定资源→提交绘制命令;

  • 核心 API:CreateCommittedResource(创建缓冲)、CreateGraphicsPipelineState(创建 PSO)、SetPipelineState(设置 PSO)、DrawIndexedInstanced(索引绘制)。


难点笔记

资源、描述符堆、根签名

之前完成过一些网上的DX12的教程,但对于这几个概念,还是知其然不知其所以然。于是现在再次碰上,可以深入了解一番。

(ps:根签名其实在第七章有详细介绍😂🤣)

资源ID3D12Resource

资源描述D3D12_RESOURCE_DESC,通过该结构体,可以指定资源维度D3D12_RESOURCE_DIMENSION、缓冲区大小、内存对齐、资源格式DXGI_FORMAT、布局D3D12_TEXTURE_LAYOUT、资源标志D3D12_RESOURCE_FLAGS等。

堆属性:在 Direct3D 12 中,堆(Heap) 是 GPU 内存的抽象容器,所有 GPU 资源(如顶点缓冲、纹理、渲染目标等)最终都存储在堆中。D3D12_HEAP_PROPERTIES 结构体用于描述堆的核心属性,它最重要的成员变量Type(D3D12_HEAP_TYPE)指定堆类型,这个枚举

功能 访问特性 适用场景 注意事项
D3D12_HEAP_TYPE_DEFAULT(默认堆) GPU 专用堆,是性能最高的堆类型 GPU 可以直接、高速地读写(存储在 GPU 本地显存中)。
CPU 无法直接访问(不能通过 Map 映射内存)。
GPU 频繁访问的资源,如渲染目标(RTV)、深度 / 模板缓冲(DSV)、纹理、静态顶点 / 索引缓冲(初始化后不再修改) 若需要将 CPU 数据传输到 DEFAULT 堆,需先创建一个 UPLOAD 堆作为 “中转”,通过 CopyBufferRegionCopyTextureRegion 等 GPU 复制命令完成数据传输。
D3D12_HEAP_TYPE_UPLOAD(上传堆) CPU 写 → GPU 读的中转堆,用于将 CPU 数据上传到 GPU CPU 可以映射(Map)并写入数据(写结合 Write-Combined 内存,CPU 写效率高,但读效率极低,应避免 CPU 读取)。
GPU 可以读取数据,但访问速度低于 DEFAULT 堆。
动态顶点 / 索引缓冲(每帧或频繁更新的几何数据)。
向 DEFAULT 堆传输数据的 “临时中转”(如初始化纹理、静态缓冲时)。
常量缓冲(CBV),因为常量缓冲通常由 CPU 每帧更新,GPU 读取。
CPU 写入时应顺序写入(避免随机访问),以充分利用写结合内存的性能优势。
不要在 UPLOAD 堆中存储 GPU 需要频繁写入的资源(如渲染目标)。
D3D12_HEAP_TYPE_READBACK(读回堆) CPU 写 → GPU 读的中转堆,用于将 CPU 数据上传到 GPU。 GPU 可以写入数据。
CPU 可以映射(Map)并读取数据(读回 Read-Back 内存,CPU 读效率高,但写效率低,应避免 CPU 写入)。
截图(将渲染目标的像素数据读回 CPU 保存为图片)。
GPU 计算结果的回读(如 Compute Shader 计算后的统计数据)。
调试时读取 GPU 资源内容(如检查顶点缓冲数据是否正确)。
GPU 写入到 READBACK 堆后,需通过 ID3D12Fence 同步,确保 GPU 写入完成后 CPU 再读取,避免数据竞争。
不要在 READBACK 堆中存储 GPU 需要频繁读取的资源
资源的创建流程

按需填充D3D12_HEAP_PROPERTIES

按需填充D3D12_RESOURCE_DESC

通过ID3D12Device::CreateCommittedResource创建ID3D12Resource;

最后通过 “上传堆” ,将 CPU 顶点数据复制到 GPU 顶点缓冲。

ID3D12Resource* CreateResource(ID3D12Device* device
        , UINT64 inResourceSize
        , D3D12_HEAP_TYPE inHeapType
        , D3D12_RESOURCE_STATES inResourceState
        , D3D12_RESOURCE_DIMENSION inResourceDimension = D3D12_RESOURCE_DIMENSION::D3D12_RESOURCE_DIMENSION_BUFFER
        , DXGI_FORMAT inFormat = DXGI_FORMAT::DXGI_FORMAT_UNKNOWN
        , D3D12_TEXTURE_LAYOUT inLayout = D3D12_TEXTURE_LAYOUT::D3D12_TEXTURE_LAYOUT_ROW_MAJOR
        , UINT64 inAlignment = 0u
        , UINT16 inMipLevel = 1u
        )
{
    //堆属性
    D3D12_HEAP_PROPERTIES heapProperties = {};
    heapProperties.Type = inHeapType;
    
    //资源描述
    D3DX12_RESOURCE_DESC resourceDesc = {};
    resourceDesc.Dimension = inResourceDimension;
    resourceDesc.Alignment = inAlignment;
    resourceDesc.Width = inResourceSize;
    resourceDesc.Height = 1;
    resourceDesc.DepthOrArraySize = 1;//普通贴图是1张,如果是cubemap,这里是6
    resourceDesc.MipLevels = inMipLevel;
    resourceDesc.Format = inFormat;
    resourceDesc.SampleDesc.Count = 1;//资源采样的描述,1就是没有使用msaa
    resourceDesc.SampleDesc.Quality = 0;
    resourceDesc.Layout = inLayout;
    resourceDesc.Flags = inResourceFlags;
    
    //通过Device创建资源
    ID3D12Resource* resource = nullptr;
    ThrowIfFailed(device->CreateCommittedResource(&heapProperties
        , D3D12_HEAP_FLAG_NONE
        , &resourceDesc
        , inResourceState
        , nullptr
        , IID_PPV_ARGS(resource)));
    
    return resource;
}
资源的数据由CPU到GPU流程

数据由CPU侧的内存上传到GPU专用堆,都需要经过上传堆的中转。我接触到的方法有两种:

  • 通过d3dx12.h提供的封装的模板方法UpdateSubresources
ID3D12Resource* CreateGPUResourceAndUploadData(ID3D12Device* device
                    , ID3D12GraphicsCommandList* cmdList
                    , const void* inData
                    , UINT64 inByteSize)
{
    ID3D12Resource* uploadResource = CreateResource(device
            , inByteSize
            , D3D12_HEAP_TYPE_UPLOAD
            , D3D12_RESOURCE_STATE_GENERIC_READ);
            
    ID3D12Resource* resource = CreateResource(device
            , inByteSize
            , D3D12_HEAP_TYPE_DEFAULT
            , D3D12_RESOURCE_STATE_COPY_DEST);
    
    //拷贝上传数据 
    D3D12_SUBRESOURCE_DATA subResourceData = {};
    subResourceData.pData = inData;
    subResourceData.RowPitch = inByteSize;
    subResourceData.SlicePitch = subResourceData.RowPitch;
    
    //通过中转上传数据到GPU Resource
    UpdateSubresources<1>(cmdList, resource, uploadResource, 0, 0, 1, &subResourceData);

    //通过Barrier等待resource转换为GPU侧可用资源
    D3D12_RESOURCE_BARRIER barriers[1];
    barriers[0] = InitResourceBarrier(resource
        , D3D12_RESOURCE_STATE_COPY_DEST, D3D12_RESOURCE_STATE_COMMON);
    cmdList->ResourceBarrier(_countof(barriers), barriers);

    return resource;
}
  • 自己实现内存拷贝

通过ID3D12Resource::Map映射内存,然后手动拷贝内存。这需要对内存布局有精确控制。而在 DirectX 12 中,纹理/缓冲区数据在内存中的布局可能与我们直观理解的不同,因为:

  • 行对齐要求:GPU 对纹理行的字节数有对齐要求

  • 不同格式的像素大小不同

  • Mip level的存在增加了复杂度

所以需要通过GetCopyableFootprints 函数,自动计算这些复杂的布局信息,以确保我们能够正确地处理纹理/缓冲区数据的内存操作。

ID3D12Resource* CreateGPUResourceAndUploadData(ID3D12Device* device
                    , ID3D12GraphicsCommandList* cmdList
                    , const void* inData
                    , UINT64 inByteSize)
{
    ID3D12Resource* uploadResource = CreateResource(device
            , byteSize
            , D3D12_HEAP_TYPE_UPLOAD
            , D3D12_RESOURCE_STATE_GENERIC_READ);
            
    ID3D12Resource* resource = CreateResource(device
            , byteSize
            , D3D12_HEAP_TYPE_DEFAULT
            , D3D12_RESOURCE_STATE_COPY_DEST);
    
    //查询资源的内存布局
    D3D12_RESOURCE_DESC d3d12ResourceDesc = resource->GetDesc();
    UINT64 memorySizeUsed = 0;
    UINT64 rowSizeInBtyes = 0;
    UINT rowUsed = 0;
    D3D12_PLACED_SUBRESOURCE_FOOTPRINT subresourceFootprint;
    device->GetCopyableFootprints(&d3d12ResourceDesc
        , 0//起始子资源索引
        , 1//要计算的子资源数量
        , 0//基础偏移量
        , &subresourceFootprint//输出的子资源布局信息
        , &rowUsed//输出的每子资源的行数
        , &rowSizeInBtyes//输出的每行字节数
        , &memorySizeUsed//输出的总字节数
        );
    
    BYTE* pData;
    //映射上传缓冲区到CPU可访问内存
    uploadResource->Map(0, nullptr, reinterpret_cast<void**>(&pData));
    //上传缓冲区的内存指针
    BYTE* pDstTempBuffer = reinterpret_cast<BYTE*>(pData + subresourceFootprint.Offset);
    //源数据的内存指针
    const BYTE* pSrcData = reinterpret_cast<BYTE*>(inData);
    //确定每一行要复制的数据大小
    size_t dataToCopy = rowSizeInBytes > inByteSize ? inByteSize : rowSizeInBytes;
    //逐行复制数据
    for (UINT i = 0; i < rowUsed; i++) {
        memcpy(pDstTempBuffer + subresourceFootprint.Footprint.RowPitch*i
            , pSrcData + rowSizeInBtyes*i
            , dataToCopy);
    }
    //完成后解除内存映射
    uploadResource->Unmap(0, nullptr);
    
    //将数据从临时缓冲区复制到目标GPU缓冲区
    inCommandList->CopyBufferRegion(resource, 0,
        uploadResource, 0,
        subresourceFootprint.Footprint.Width);    

    //通过Barrier等待resource转换为GPU侧可用资源
    D3D12_RESOURCE_BARRIER barriers[1];
    barriers[0] = InitResourceBarrier(resource
        , D3D12_RESOURCE_STATE_COPY_DEST, D3D12_RESOURCE_STATE_COMMON);    
    cmdList->ResourceBarrier(_countof(barriers), barriers);

    return resource;
}
常量缓冲区

常量缓冲区也是一种ID3D12Resource,可以实现 CPU 向 GPU 的传递数据,最终在着色器中使用。
它多用于存储动态变化的数据(如变换矩阵、光照数据)。这些数据通常被 CPU 每帧更新,GPU 端着色器能高效读取。支持多组参数批量传递,减少管线状态切换开销。

创建常量缓冲区

  • 缓冲区的堆类型需要为D3D12_HEAP_TYPE_UPLOAD,CPU写入GPU读取

  • 缓冲区大小需满足 Direct3D 12 的对齐要求(通常为 256 字节的整数倍)。

// 确保缓冲区大小为硬件最小分配空间(256B)的整数倍
UINT CalcConstantBufferSize(UINT byteSize)
{
    return (byteSize + 255) & ~255;
}


ID3D12Resource* CreateConsterBuffer(ID3D12Device* device
                                , UINT64 inBufferByteSize)
{
    //创建Resource,D3D12_HEAP_TYPE_UPLOAD
    return CreateResource(device
            , CalcConstantBufferSize(inBufferByteSize)
            , D3D12_HEAP_TYPE_UPLOAD
            , D3D12_RESOURCE_STATE_GENERIC_READ);
}

常量缓冲区数据更新

  • 通过Map获得常量缓冲区的指针,向指针写入数据,完成后通过Unmap释放内存映射

  • 通过memcpy,将数据写入缓冲区。

void UpdateConstantBuffer(ID3D12Resource* inCB
            , void* inData
            , int inDataLen)
{
    //D3D12_RANGE在DirectX12中用于指定资源映射区域,通过设置它:
    //GPU可以据此优化内存访问和同步操作;也提供内存访问边界检查
    //Begin/End都为0则映射整个资源供CPU访问,
    D3D12_RANGE d3d12Range = { 0 };
    unsigned char* pBuffer = nullptr;
    inCB->Map(0, &d3d12Range, (void**)&pBuffer);
    memcpy(pBuffer, inData, inDataLen);
    inCB->Unmap(0, nullptr);
}

上述代码,在映射之后,立刻写入数据,然后立刻释放映射。做法简单直接,但效率有提高的空间:

  • 指定D3D12_RANGE可以优化内存访问

  • Map/Unmap 的开销较大,每帧都进行,有一定损耗。龙书是使用一个类封装。在构造函数中mMappedData就通过Map映射到缓冲区,映射一直保持,直到析构函数中通过Unmap释放映射。

    template<typename T>
    class UploadBuffer
    {
    public:
        UploadBuffer(ID3D12Device* device
            , UINT elementCount
            , bool isConstantBuffer)
                : mIsConstantBuffer(isConstantBuffer)
        {
            mUploadBuffer = CreateConsterBuffer(device, elementCount);
            ThrowIfFailed(mUploadBuffer->Map(0
                , nullptr
                , reinterpret_cast<void**>(&mMappedData)));
        }
        
        ~UploadBuffer()
        {
            if(mUploadBuffer != nullptr)
                mUploadBuffer->Unmap(0, nullptr);
        
            mMappedData = nullptr;
        }
        
        void CopyData(int elementIndex, const T& data)
        {
            memcpy(&mMappedData[elementIndex*mElementByteSize], &data, sizeof(T));
        }
        
    private:
        ID3D12Resource* mUploadBuffer;
        BYTE* mMappedData = nullptr;
    
        UINT mElementByteSize = 0;
        bool mIsConstantBuffer = false;
    };
    

    常量缓冲区由于用户输入会被写入修改,而渲染需要使用缓冲区的数据,这两个都是异步发生的,这样做是有隐患的,可能出现CPU写入和GPU读取的竞争。项目代码注释中提到需要使用同步机制处理。我个人理解,第六章的代码并没有处理读写竞争,需要等第七章的7.1帧资源,每个 FrameResource 有自己独立的UploadBuffer,CPU 只在当前未被 GPU 使用的 FrameResource 上写数据;提交后把一个 fence 值写入当前帧资源;下次要重用该帧资源时先检查 fence,若 GPU 未完成则等待,这样才能确保没有常量缓冲区读写安全。

描述符堆ID3D12DescriptorHeap

描述符Descriptor的作用可以概括为:GPU 管线的 “插槽”,将GPU 不能直接访问原始的 ID3D12Resource 资源对象(如顶点缓冲、纹理、常量缓冲)绑定到管线,供Shader使用,是连接 CPU 资源管理与 GPU 资源访问的关键桥梁。

描述符堆:多个同类型的空间连续的描述符组成描述符堆ID3D12DescriptorHeap。通过描述符堆ID3D12DescriptorHeap,可以解耦 “资源创建” 与 “资源绑定”:创建 ID3D12Resource 时,资源本身与描述符无关;管线运行(例如绘制)前,只需通过描述符堆的偏移量得到描述符句柄,与相应资源绑定创建相应的视图,即可在Shader中使用相关资源。

描述符堆有不同的类型

堆类型(D3D12_DESCRIPTOR_HEAP_TYPE) 可存放的描述符 着色器可见性 核心用途
D3D12_DESCRIPTOR_HEAP_TYPE_CBV_SRV_UAV CBV、SRV、UAV SHADER_VISIBLE 供着色器访问的资源视图(最常用的堆类型)
D3D12_DESCRIPTOR_HEAP_TYPE_SAMPLER 采样器描述符(Sampler) SHADER_VISIBLE 供着色器采样纹理的采样器状态
D3D12_DESCRIPTOR_HEAP_TYPE_RTV RTV NONE 渲染目标视图(管线输出颜色)
D3D12_DESCRIPTOR_HEAP_TYPE_DSV DSV NONE 深度 / 模板视图(管线深度测试)
描述符堆的创建与使用

创建描述符堆

ID3D12DescriptorHeap* CreateDescriptorHeaps(ID3D12Device* inDevice
                            , D3D12_DESCRIPTOR_HEAP_TYPE inHeapType
                            , UINT inDescriptorInHeap
                            , D3D12_DESCRIPTOR_HEAP_FLAGS inHeapFlag)
{
    ID3D12DescriptorHeap* descriptorHeap = nullptr;
    D3D12_DESCRIPTOR_HEAP_DESC heapDesc;
    heapDesc.NumDescriptors = inDescriptorInHeap;//堆中可存放描述符个数
    heapDesc.Type = inHeapType;//堆类型
    heapDesc.Flags = inHeapFlag;//堆是否对Shader可见
    heapDesc.NodeMask = 0;
    ThrowIfFailed(inDevice->CreateDescriptorHeap(&heapDesc,
        IID_PPV_ARGS(&descriptorHeap)));
        
    return descriptorHeap;
}

从描述符堆中获得指定的描述符的句柄

D3D12_CPU_DESCRIPTOR_HANDLE GetCPUHandleFromDescriptorHeap(ID3D12Device* inDevice
                                    , ID3D12DescriptorHeap* inDescriptorHeap
                                    , UINT index
                                    , D3D12_DESCRIPTOR_HEAP_TYPE inHeapType)
{
    //获取描述符的大小(不同 GPU 可能不同)
    UINT descriptorSize = inDevice->GetDescriptorHandleIncrementSize(inHeapType);    
    //描述符堆起始地址
    SIZE_T ptrHeapStart = inDescriptorHeap->GetCPUDescriptorHandleForHeapStart().ptr;    
    //加上偏移,构建出指定描述符的句柄返回
    D3D12_CPU_DESCRIPTOR_HANDLE handle;
    handle.ptr = ptrHeapStart + index * descriptorSize;
    return handle;
} 

创建 “常量缓冲视图(CBV)” ,将资源写到相应的描述符堆中

void CreateConstantBufferView(ID3D12Device* inDevice
                             , ID3D12DescriptorHeap* inDescriptorHeap
                             , ID3D12Resource* inCB
                             , UINT64 inBufferByteSize)
{
    D3D12_CPU_DESCRIPTOR_HANDLE cpuHandle = GetCPUHandleFromDescriptorHeap(inDevice
                                        , inDescriptorHeap
                                        , 0
                                        , D3D12_DESCRIPTOR_HEAP_TYPE_CBV_SRV_UAV);

    D3D12_CONSTANT_BUFFER_VIEW_DESC cbvDesc = {};
    cbvDesc.BufferLocation = inCB->GetGPUVirtualAddress();//应是256B的整数倍,不然会报错
    cbvDesc.SizeInBytes = CalcConstantBufferSize(inBufferByteSize);//应是256B的整数倍,不然会报错
    inDevice->CreateConstantBufferView(&cbvDesc, cpuHandle);
}

另外一些资源,如不同类型(不同格式的Texture2D、不同结构的Buffer)的UAV、SRV,通过构建相应的视图描述结构体(D3D12_UNORDERED_ACCESS_VIEW_DESCD3D12_SHADER_RESOURCE_VIEW_DESC),传入ID3D12Device提供了创建相应View的方法(CreateUnorderedAccessViewCreateShaderResourceView),就可以将资源绑定到描述符关联的管线“插槽”上。

根签名ID3D12RootSignature

根签名

根签名ID3D12RootSignature)是着色器与 GPU 管线之间的 “资源绑定约定 / 契约”它告诉 GPU:

  • 着色器要用到哪些资源(CBV、SRV、UAV、采样器)

  • 这些资源放在哪里(在哪个描述符堆、哪个位置)

  • 以什么格式读取

在DX12中,用户可以完全控制资源绑定,所以GPU 必须提前知道,有几个 CBV/SRV/UAV/Sampler,这个 “提前告诉 GPU” 的东西,就是根签名。根签名将资源映射到着色器寄存器。没有根签名,着色器就找不到任何资源,完全无法运行根签名这个设计的优点:驱动不用实时计算资源布局,用户能精确控制,有助性能提升;根签名绑定到PSO后,供多线程使用

如果把着色器程序看作一个函数,而将输入资源看做着色器的函数参数,那么根签名则是函数签名

最终是通过这个“函数签名”,创建出管线状态ID3D12PipelineState的,可以说根签名是与管线绑定的。PSO 是不可变对象(Immutable),与 RootSignature 是一一绑定、不可拆分的关系,所有状态(包括根签名)在创建后都不能修改。如果要改变不同的资源绑定布局(不同根签名),只能从PSO这个层级进行切换。

根参数

根签名最重要的组成部分,是它包含了那些根参数。根参数由D3D12_ROOT_PARAMETER描述,该结构体熟悉包含ParameterTypeD3D12_ROOT_PARAMETER_TYPE)、ShaderVisibilityD3D12_SHADER_VISIBILITY)和一个union

D3D12_ROOT_PARAMETER_TYPE有多种类型:

名称 跟参数类型 功能
空间占用 关联的union 工作模式 通过ID3D12CommandList的方法,直接把资源绑定到根参数(不经过堆) 使用场景 hlsl寄存器
根常量 D3D12_ROOT_PARAMETER_TYPE_32BIT_CONSTANTS 直接把多个32bit常量塞进根签名 每个常量32位,占用1 DWORD Constants
D3D12_ROOT_CONSTANTS
直接模式 SetGraphicsRoot32BitConstants
传入32bit内存指针、数据个数
极高频更新的小数据(时间、开关、索引) bX
根描述符
(内联描述符 inline descriptor)
D3D12_ROOT_PARAMETER_TYPE_CBV 直接绑定一个常量缓冲 每个根描述符占用2 DWORD Descriptor
D3D12_ROOT_DESCRIPTOR
直接模式
SetGraphicsRootConstantBufferView
传入常量缓冲区资源的GPU虚拟地址
单个常量缓冲区 bX
D3D12_ROOT_PARAMETER_TYPE_SRV 直接绑定一个纹理 SetGraphicsRootShaderResourceView
传入SRV资源的GPU虚拟地址
单个纹理 tX
D3D12_ROOT_PARAMETER_TYPE_UAV 直接绑定一个缓冲 SetGraphicsRootUnorderedAccessView
传入UAV资源的GPU虚拟地址
单个 SSBO (UAV) uX
描述符表 D3D12_ROOT_PARAMETER_TYPE_DESCRIPTOR_TABLE 绑定一整段 “描述符表”
指向描述符堆里的一段连续描述符
一次绑多个 CBV/SRV/UAV/Sampler
每个描述符表占用1 DWORD
DescriptorTable
D3D12_ROOT_DESCRIPTOR_TABLE
间接引用 SetGraphicsRootDescriptorTable
传入描述符堆的GPU句柄
最灵活,适用任何情况 采样器:sX
资源引用

将资源与根签名关联起来,可以理解这一步是给“根函数填入根实参”。

第六章 利用Direct3D绘制几何体-diagram

根描述符是直接绑定的方式,把资源直接塞进根签名,速度快,但数量有限制(根签名总大小限制 64 DWORD);通过描述符表间接引用,可以理解为根参数只存一个指针,指针指向描述符堆里的一堆资源。能突破数量限制,但比直接绑定慢一点。
通过ID3D12CommandList的Set[Graphics/Compute]Root[32BitConstants/ConstantBufferView/ShaderResourceView/UnorderedAccessView],可以不经过描述符堆,直接把资源绑定到根参数。但这种绑定,在每帧渲染前都可以绑定,效率极高。但受到根签名只有64 DWORD大小的限制,数量有限。

更常用的是通过描述符表,将多个资源通过描述符堆来间接引用来使用,这种方法必须:

  • 使用资源创建视图(ID3D12Device的Create[ConstantBuffer/ShaderResource/UnorderedAccess]View

  • 创建描述符堆(ID3D12CommandList::SetDescriptorHeaps

  • ID3D12CommandListSet[Graphics/Compute]RootDescriptorTable传入:

    根参数(D3D12_ROOT_PARAMETER数组)的索引 -- 描述符堆的GPU句柄

    每帧渲染前将根参数与描述符堆关联起来

通过这写结构体的(Base)ShaderRegister,结合参数本身的类型(表中HLSL寄存器列),可以让着色器hlsl源码与相应资源关联上。

对于描述符表,通过D3D12_DESCRIPTOR_RANGE描述表中具体的参数,也就是描述符范围(Descriptor Range)。通过ParameterType可以指定参数是SRV/UAV/CBV/SAMPLER。与D3D12_ROOT_DESCRIPTOR类似,可以指定寄存器号和空间。D3D12_ROOT_DESCRIPTOR还有属性NumDescriptors,表示这个描述符范围包含多少个连续的资源;属性OffsetInDescriptorsFromTableStart,表示这个范围从描述符表的第几个描述符开始。

根签名还包括一种静态采样器(Static Samplers)这是一种特殊的根签名参数,不是描述符表,也不用描述符堆,直接写死在根签名里。由D3D12_STATIC_SAMPLER_DESC描述

最终通过D3D12_ROOT_SIGNATURE_DESC描述来创建根签名。D3D12_ROOT_SIGNATURE_DESC的成员变量Flag,可以告诉这个根签名 “不使用” 哪些着色器阶段,进而让驱动优化性能。例如

Flags =
    //允许顶点输入,画 3D 物体必须开启!否则会因为没有顶点输入管线跑步起来而黑屏
    D3D12_ROOT_SIGNATURE_FLAG_ALLOW_INPUT_ASSEMBLER_INPUT_LAYOUT
    //禁用不用的 HS/DS/GS
    | D3D12_ROOT_SIGNATURE_FLAG_DENY_HULL_SHADER_ROOT_ACCESS
    | D3D12_ROOT_SIGNATURE_FLAG_DENY_DOMAIN_SHADER_ROOT_ACCESS
    | D3D12_ROOT_SIGNATURE_FLAG_DENY_GEOMETRY_SHADER_ROOT_ACCESS;
根签名的创建
ID3D12RootSignature* CreateRootSignature(ID3D12Device* inDevice)
{
    D3D12_ROOT_PARAMETER rootParams[5];
    
    //1. 32bit常量
    rootParams[0].ParameterType = D3D12_ROOT_PARAMETER_TYPE_32BIT_CONSTANTS;
    rootParams[0].ShaderVisibility = D3D12_SHADER_VISIBILITY_ALL;    
    rootParams[0].Constants.RegisterSpace = 0;//对应shader中的register(b0,space0),是寄存器的命名空间(第二索引),让同一个寄存器号(b0)可以重复使用不冲突    
    rootParams[0].Constants.ShaderRegister = 0;//寄存器号,这里对应shader中cbuffer的register(b0)    
    rootParams[0].Constants.Num32BitValues = 36;//立即常量数量,有多少个32bit的常量塞进根签名。根签名大小最大64*32=2048bit,所以数量有限制只能是1~64
    
    //2. CBV
    rootParams[1].ParameterType = D3D12_ROOT_PARAMETER_TYPE_CBV;
    rootParams[1].ShaderVisibility = D3D12_SHADER_VISIBILITY_VERTEX;
    rootParams[1].Descriptor.RegisterSpace = 0;
    rootParams[1].Descriptor.ShaderRegister = 1;//这里对应shader中cbuffer的register(b1)
    
    //3. SRV
    rootParams[2].ParameterType = D3D12_ROOT_PARAMETER_TYPE_SRV;
    rootParams[2].ShaderVisibility = D3D12_SHADER_VISIBILITY_PIXEL;
    rootParams[2].Descriptor.RegisterSpace = 0;
    rootParams[2].Descriptor.ShaderRegister = 0;//这里对应shader中register(t0)
    
    //4. UAV
    rootParams[3].ParameterType = D3D12_ROOT_PARAMETER_TYPE_UAV;
    rootParams[3].ShaderVisibility = D3D12_SHADER_VISIBILITY_ALL;
    rootParams[3].Descriptor.RegisterSpace = 0;
    rootParams[3].Descriptor.ShaderRegister = 0;//这里对应shader中register(u0)
    
    //5. Descriptor Table,一个要占用64bit
    //描述符表中具体的参数
    D3D12_DESCRIPTOR_RANGE descriptorRange[4];
    descriptorRange[0].RangeType = D3D12_DESCRIPTOR_RANGE_TYPE_SRV;
    descriptorRange[0].RegisterSpace = 0;
    descriptorRange[0].BaseShaderRegister = 1;//t1
    descriptorRange[0].NumDescriptors = 2;//对应hlsl中的 Texture2D<float4> gTextures[2] : register(t1);
    descriptorRange[0].OffsetInDescriptorsFromTableStart = D3D12_DESCRIPTOR_RANGE_OFFSET_APPEND;//无效值,表示不会偏移
    
    descriptorRange[1].RangeType = D3D12_DESCRIPTOR_RANGE_TYPE_UAV;
    descriptorRange[1].RegisterSpace = 0;
    descriptorRange[1].BaseShaderRegister = 1;//u1
    descriptorRange[1].NumDescriptors = 3;//对应hlsl中的 ByteAddressBuffer buffer[5] : register(u1);
    descriptorRange[1].OffsetInDescriptorsFromTableStart = 2;//表示从buffer[5]中,偏移2个后,取3个连续的
    
    descriptorRange[2].RangeType = D3D12_DESCRIPTOR_RANGE_TYPE_CBV;
    descriptorRange[2].RegisterSpace = 1;
    descriptorRange[2].BaseShaderRegister = 0;//b0, space1
    descriptorRange[2].NumDescriptors = 1;
    descriptorRange[2].OffsetInDescriptorsFromTableStart = D3D12_DESCRIPTOR_RANGE_OFFSET_APPEND;
    
    descriptorRange[2].RangeType = D3D12_DESCRIPTOR_RANGE_TYPE_SAMPLER;//采样器
    descriptorRange[2].RegisterSpace = 0;
    descriptorRange[2].BaseShaderRegister = 1;//s1
    descriptorRange[2].NumDescriptors = 1;
    descriptorRange[2].OffsetInDescriptorsFromTableStart = D3D12_DESCRIPTOR_RANGE_OFFSET_APPEND;
    
    rootParams[4].ParameterType = D3D12_ROOT_PARAMETER_TYPE_DESCRIPTOR_TABLE;
    rootParams[4].ShaderVisibility = D3D12_SHADER_VISIBILITY_ALL;
    rootParams[4].DescriptorTable.pDescriptorRanges = descriptorRange;
    rootParams[4].DescriptorTable.NumDescriptorRanges = _countof(descriptorRange);
    
    //6. Sampler
    D3D12_STATIC_SAMPLER_DESC samplerDescs[1];
    memset(samplerDescs, 0, sizeof(D3D12_STATIC_SAMPLER_DESC)*_countof(samplerDescs));
    samplerDescs[0].Filter = D3D12_FILTER_MIN_MAG_MIP_LINEAR;
    samplerDescs[0].AddressU = D3D12_TEXTURE_ADDRESS_MODE_CLAMP;    
    samplerDescs[0].AddressV = D3D12_TEXTURE_ADDRESS_MODE_CLAMP;
    samplerDescs[0].AddressW = D3D12_TEXTURE_ADDRESS_MODE_CLAMP;
    samplerDescs[0].BorderColor = D3D12_STATIC_BORDER_COLOR_OPAQUE_BLACK;
    samplerDescs[0].MaxLOD = D3D12_FLOAT32_MAX;
    samplerDescs[0].RegisterSpace = 0;
    samplerDescs[0].ShaderRegister = 0;//s0
    samplerDescs[0].ShaderVisibility = D3D12_SHADER_VISIBILITY_PIXEL;
    
    //设置根签名描述结构体
    D3D12_ROOT_SIGNATURE_DESC rootSignatureDesc = {};
    rootSignatureDesc.NumParameters = _countof(rootParams);
    rootSignatureDesc.pParameters = rootParams;
    rootSignatureDesc.NumStaticSamplers = _countof(samplerDescs);
    rootSignatureDesc.pStaticSamplers = samplerDescs;
    rootSignatureDesc.Flags = D3D12_ROOT_SIGNATURE_FLAG_ALLOW_INPUT_ASSEMBLER_INPUT_LAYOUT;
    //创建根签名
    ID3DBlob* blobRootSignature, blobError;
    ThrowIfFailed(D3D12SerializeRootSignature(&rootSignatureDesc
                        , D3D_ROOT_SIGNATURE_VERSION_1
                        , blobRootSignature
                        , blobError));
    ID3D12RootSignature* rootSignature = nullptr;
    ThrowIfFailed(inDevice->CreateRootSignature(0//针对多显卡,一般为0
                               , blobRootSignature->GetBufferPointer()
                               , blobRootSignature->GetBufferSize()
                               , IID_PPV_ARGS(&rootSignature)));
    
    return rootSignature;
}

龙书中将上述的多个结构体,通过派生子类进行封装,也是一个不错的思路,可以参考。

小结

现在,我们可以总结一下DirectX 12中这些资源、描述符、根签名之间的关系如图

第六章 利用Direct3D绘制几何体-diagram-1

  • Device创建Resource和DescriptorHeap

  • Resource可以通过根参数,直接写入根签名

  • 多个Resource连续写入DescriptorHeap后,DescriptorHeap可以通过DescriptorTable这种根参数,写入根签名

  • 根签名描述了,根参数与着色器寄存器的对应关系

posted @ 2026-04-07 16:20  斌伯  阅读(50)  评论(0)    收藏  举报