DX12 学习笔记

最近开始学习DirectX 12,记录一些学习笔记和心得。

主要来源是微软的 图形示例库 DirectX-Graphics-Samples 和一些相关的教程。

一些公共类介绍

Win32Application

这个例子中把win32相关的内容单独封装在了Win32Application类中,主要负责创建窗口和消息循环。
都是一些win32的API调用,比较标准的做法。没啥好说的。(其实换其他的窗口库也是可以的,比如NVidia的NRI库就是用GLFW来创建窗口的)

DXSample

DXSample是一个抽象类,封装了一些DX12的初始化和渲染的基本流程。主要包括:

公共方法:
OnInit:初始化
OnUpdate:更新
OnRender:渲染
OnDestroy:销毁
OnKeyDown 和 OnKeyUp:键盘输入处理
GetWidth 和 GetHeight:获取窗口尺寸
GetTitle:获取窗口标题
ParseCommandLineArgs:解析命令行参数

以及封装了一些常用的工具方法,比如

GetAssetFullPath:获取资源文件的完整路径
GetHardwareAdapter:获取硬件适配器

HelloWindow

最基础的HelloWorld例子,实现了一个简单的窗口和渲染循环。

D3D12HelloWorld类,继承自DXSample,实现了具体的初始化和渲染逻辑。

首先是OnInit的实现,主要包括:

  • LoadPipeline
  • LoadAssets

LoadPipeline()

故名思议,负责初始化DX12的渲染管线。
这里的渲染管线和Unity中的渲染管线概念不太一样。
Unity中的渲染管线是指渲染的整体流程和配置,如URP、HDRP等,定义了渲染的各个阶段和效果,如光照、阴影、后处理等。
而DX12中的渲染管线更底层一些,指的是从顶点处理到像素输出的整个过程,包括输入布局、着色器阶段、光栅化状态、混合状态等。

一个DX12程序大多会有多个渲染管线状态对象(PSO),每个PSO定义了一组固定的渲染状态,用于不同的渲染任务。

主要步骤包括:

  1. 创建DXGI工厂(IDXGIFactory)
  2. 选择硬件适配器(IDXGIAdapter)
  3. 创建D3D12设备(ID3D12Device)
  4. 创建命令队列(ID3D12CommandQueue)
  5. 创建交换链(IDXGISwapChain) 这里注意,创建交换链时,传入了一个命令队列,因为交换链需要与命令队列关联,以便在呈现图像时使用该命令队列。
  6. 创建RTV描述符堆(ID3D12DescriptorHeap)
  7. 创建每个帧的RTV(Render Target View)(ID3D12Resource)
  8. 创建命令分配器(ID3D12CommandAllocator)

LoadAssets()

负责加载和创建渲染所需的资源。
主要步骤包括:

  1. 创建命令列表(ID3D12GraphicsCommandList)
  2. 创建围栏(ID3D12Fence)
  3. 创建事件句柄(HANDLE)

OnRender()

渲染流程主要在OnRender方法中实现,主要包括:

  1. 重置命令分配器和命令列表
  2. 设置渲染目标
  3. 清除渲染目标
  4. 执行命令列表
  5. 显示交换链
  6. 等待GPU完成工作

这里着重说一下等待GPU完成工作的部分。
在DX12中,CPU和GPU是异步执行的,CPU会提前提交命令给GPU执行,而GPU可能还在处理之前提交的命令。为了确保GPU完成当前帧的渲染工作,通常会使用围栏(Fence)来同步CPU和GPU的工作。
围栏是一个同步对象,CPU可以等待围栏达到某个值,表示GPU已经完成了对应的工作。具体步骤包括:

第一帧:

  1. 初始时m_frameIndex为0,提交命令列表给GPU执行,这里用的是BackBuffer0
  2. 要求交换链显示BackBuffer0,这里其实并没有立刻显示,而是将显示请求加入到之前交换链绑定的命令队列中
  3. 调用m_commandQueue->Signal(m_fence.Get(), 1)方法。意思是让命令队列在执行完之前提交的所有命令后,将围栏的值设置为1。
  4. 获取了m_fence->GetCompletedValue(),此时应该是0,因为这是第一帧,GPU还没有执行任何命令。
  5. 所以要等待,直到围栏的值达到1,表示GPU已经完成了第一帧的渲染工作。通过调用m_fence->SetEventOnCompletion(1, m_fenceEvent)来设置一个事件,当围栏值达到1时触发该事件。然后调用WaitForSingleObject(m_fenceEvent, INFINITE)等待事件触发。

这是最简单的同步方式,确保CPU在继续执行下一帧之前,GPU已经完成了当前帧的渲染工作。
正因为如此,我们只需要一个CommandAllocator就可以了,因为我们确保每一帧GPU完成后,才会重用CommandAllocator。

HelloTriangle

在HelloWindow的基础上,增加了绘制一个简单三角形的功能。

LoadPipeline()

与HelloWindow完全相同。

LoadAssets()

增加了创建根签名和渲染管线状态对象(PSO)的步骤。

  1. 创建根签名(ID3D12RootSignature)
    根签名定义了着色器访问资源的方式,包括常量缓冲区、纹理、采样器等。但这里的根签名是空的,因为这个例子中没有使用任何资源。
  2. 编译了顶点着色器和像素着色器
    使用D3DCompileFromFile函数从文件编译HLSL着色器代码,生成顶点着色器和像素着色器的字节码。
  3. 定义输入布局
    定义了顶点数据的格式和布局,这里只包含位置和颜色两个属性。
  4. 创建渲染管线状态对象(ID3D12PipelineState)
    渲染管线状态对象(PSO)封装了渲染管线的各种状态设置,包括输入布局、根签名、着色器字节码、光栅化状态、混合状态、深度模板状态等。
  5. 创建命令列表
    与HelloWindow相同。
  6. 创建顶点缓冲区资源/顶点缓冲区视图
    1. 定义顶点数据
    2. 创建和上传堆(CPU可访问内存)
    3. 使用map/unmap将顶点数据复制到顶点缓冲区
    4. 创建顶点缓冲区视图(D3D12_VERTEX_BUFFER_VIEW)
  7. 创建围栏和事件句柄
    与HelloWindow相同。

这里记录下DX12中堆的概念:
在DirectX 12中,堆(Heap)是用于管理GPU内存的一种机制。堆允许开发者更灵活地分配和管理内存资源,以满足不同的性能和使用需求。堆可以分为几种类型,主要包括:

  1. 默认堆(Default Heap)
    默认堆是GPU专用的内存区域,通常用于存储需要频繁访问的资源,如纹理、顶点缓冲区等。默认堆的内存访问速度较快,但CPU无法直接访问,需要通过命令列表进行数据传输。
  2. 上传堆(Upload Heap)
    上传堆是CPU可访问的内存区域,主要用于将数据从CPU传输到GPU。上传堆通常用于存储临时数据,如动态顶点缓冲区、常量缓冲区等。上传堆的内存访问速度较慢,但可以直接由CPU写入数据。
  3. 读取回堆(Readback Heap)
    读取回堆是用于将数据从GPU传输回CPU的内存区域。读取回堆通常用于存储需要从GPU读取的结果数据,如计算着色器的输出等。读取回堆的内存访问速度较慢,但可以直接由CPU读取数据。

OnRender()

在HelloWindow的基础上,增加了绘制三角形的命令。

主要步骤包括:

  1. 重置命令分配器
  2. 重置命令列表并设置渲染状态
  3. 设置根签名
  4. 设置视区和裁剪矩形
  5. 设置资源屏障,转换渲染目标状态至RENDER_TARGET
  6. 指定渲染目标
  7. 清除渲染目标
  8. 指定绘制三角形
  9. 绑定顶点缓冲区
  10. 绘制调用
  11. 设置资源屏障,转换渲染目标状态至PRESENT

HelloBundles

在HelloTriangle的基础上,增加了使用命令捆绑(Command Bundles)的功能。
大致就是在
LoadPipeline时创建一个命令捆绑分配器(ID3D12CommandAllocator)
在LoadAssets时创建命令捆绑(ID3D12GraphicsCommandList)
并记录下以下命令:

  1. 设置根签名
  2. 设置绘制基本图元
  3. 绑定顶点缓冲区
  4. 绘制调用
    然后在OnRender中,执行命令捆绑。m_commandList->ExecuteBundle(m_bundle.Get());

HelloConstBuffer

在HelloTriangle的基础上,增加了使用常量缓冲区(Constant Buffer)的功能。

在LoadPipeline()中
增加了创建常量缓冲区视图描述符堆(ID3D12DescriptorHeap)cbvHeap 的步骤。

首先是根签名的修改

  1. 先定义一个常量缓冲区描述符范围(D3D12_DESCRIPTOR_RANGE)
    设置类型为常量缓冲区视图(CBV),数量为1,基址寄存器为0。
  2. 然后定义一个根参数(D3D12_ROOT_PARAMETER)
    设置类型为描述符表,包含上面定义的描述符范围。
  3. 设置根签名Flags
    设置为允许输入布局和着色器访问。
  4. 创建根签名描述
  5. 创建根签名

然后是常量缓冲区的创建

  1. 定义常量缓冲区结构体(ConstantBuffer)
  2. 在上传堆创建一个常量缓冲区资源
  3. 映射常量缓冲区资源,获取CPU可访问的指针
  4. 初始化常量缓冲区数据
  5. 创建常量缓冲区视图描述 CBVDesc
  6. 在cbvHeap(常量缓冲区视图描述符堆)中创建常量缓冲区视图

最后是在OnRender中绑定常量缓冲区

  1. 设置描述符堆
  2. 设置根描述符表,绑定常量缓冲区视图
    之后的流程与HelloTriangle相同。

HelloTexture

然后是纹理。

大致流程是

  1. 描述并创建SRV描述符堆
  2. 根签名增加一个描述符表,绑定SRV
  3. 同时定义一个静态采样器一块放到根签名描述中
  4. 描述一个2D纹理资源
  5. 创建一个默认堆资源用于存储纹理
  6. 创建一个上传堆资源用于上传纹理数据
  7. 使用UpdateSubresources函数将纹理数据从上传堆复制到默认堆
  8. 将默认堆资源的状态从COPY_DEST转换为PIXEL_SHADER_RESOURCE
  9. 创建SRV描述符,绑定到SRV描述符堆
  10. 在OnRender中绑定SRV描述符堆和设置根描述符表

HelloFrameBuffering

在HelloTexture的基础上,增加了多帧缓冲(Frame Buffering)的功能。
之前的同步方式是每帧等待GPU完成工作,效率较低。
这里改为使用多个帧资源,每帧使用不同的命令分配器和围栏值,来实现更高效的CPU-GPU并行工作。

首先是初始化时
m_frameIndex为0,记作A缓冲区。
此时A的围栏值为0。
A的围栏值加一,变为1。
在WaitForGpu中。
插入信号,完成后将围栏值设置为1。
然后围栏设定事件,当围栏值达到1时触发事件。
等待事件触发。
这里就确保了初始化中的GPU工作完成。
然后A的围栏值再加一,变为2。

接着到了OnRender中。
第一帧:
填充命令列表,提交命令列表。
要求交换链显示A缓冲区。
在MoveToNextFrame中。
获取当前帧A的围栏值,应该是2。
插入信号,完成后将围栏值设置为2。

更新m_frameIndex,变为1,记作B缓冲区。
此时B的围栏值为0。
而此时围栏值为2,大于B的围栏值0,所以不需要等待,直接继续执行。
B的围栏值加一,变为3。

第二帧:
填充命令列表,提交命令列表。
要求交换链显示B缓冲区。
在MoveToNextFrame中。
获取当前帧B的围栏值,应该是3。
插入信号,完成后将围栏值设置为3。

更新m_frameIndex,及将要操作的帧,变为0,记作A缓冲区。
此时缓冲区A的围栏值为2。
此时围栏值有三种可能:

  1. 围栏值为1,说明第一帧的命令还没有完成,需要等待。
  2. 围栏值为2,大于等于A的围栏值2,说明第一帧的命令已经完成,可以继续执行。
  3. 围栏值为3,说明第二帧的命令都已经完成,也可以继续执行。

HelloTransformation

这里增加了两块内容。
一是深度模板视图相关的。
二是内联常量缓冲区相关的。

深度模板视图

和RTV类似,首先需要创建一个DSV描述符堆。
但是RTV是由交换链提供的,而深度模板缓冲区需要我们自己创建。
先定义深度模板缓冲区的描述(D3D12_DEPTH_STENCIL_VIEW_DESC),

然后创建一个默认堆资源用于存储深度模板缓冲区。
最后创建深度模板视图(ID3D12DepthStencilView),放到DSV描述符堆中。

内联常量缓冲区

这里因为要渲染两个物体,而且由两个RTV,所以常量缓冲区的大小是c_numDrawCalls * FrameCount * sizeof(ConstantBuffer);

在上传堆创建一个常量缓冲区资源。
这里并没有创建描述符堆和CBV,而是直接使用GPU虚拟地址来绑定常量缓冲区。

在PopulateCommandList中,依据当前帧序号和要绘制的物体序号,计算出对应的常量缓冲区偏移

unsigned int constantBufferIndex = c_numDrawCalls * (m_frameIndex % FrameCount);

然后填充常量缓冲区数据

ConstantBuffer cbParameters = {};

XMStoreFloat4x4(&cbParameters.worldMatrix,XMMatrixTranspose(m_worldMatrix));
XMStoreFloat4x4(&cbParameters.viewMatrix,XMMatrixTranspose(m_viewMatrix));
XMStoreFloat4x4(&cbParameters.projectionMatrix,XMMatrixTranspose(m_projectionMatrix));

memcpy(&m_mappedConstantData[constantBufferIndex], &cbParameters, sizeof(cbParameters));

填充后获取对应的GPU虚拟地址,并绑定常量缓冲区
auto baseGpuAddress = m_constantDataGpuAddr + constantBufferIndex * sizeof(ConstantBuffer);
m_commandList->SetGraphicsRootConstantBufferView(0, baseGpuAddress);

总结

至此,常见的DX12基础功能都已经了解了一遍。
接下来的目标是实现一个最简单的渲染器,加载一个模型并使用前向渲染进行PBR渲染。


DX12 学习笔记
https://www.kuanmi.top/2026/01/12/LearnDX12/
作者
KuanMi
发布于
2026年1月12日
许可协议