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定义了一组固定的渲染状态,用于不同的渲染任务。
主要步骤包括:
- 创建DXGI工厂(IDXGIFactory)
- 选择硬件适配器(IDXGIAdapter)
- 创建D3D12设备(ID3D12Device)
- 创建命令队列(ID3D12CommandQueue)
- 创建交换链(IDXGISwapChain) 这里注意,创建交换链时,传入了一个命令队列,因为交换链需要与命令队列关联,以便在呈现图像时使用该命令队列。
- 创建RTV描述符堆(ID3D12DescriptorHeap)
- 创建每个帧的RTV(Render Target View)(ID3D12Resource)
- 创建命令分配器(ID3D12CommandAllocator)
LoadAssets()
负责加载和创建渲染所需的资源。
主要步骤包括:
- 创建命令列表(ID3D12GraphicsCommandList)
- 创建围栏(ID3D12Fence)
- 创建事件句柄(HANDLE)
OnRender()
渲染流程主要在OnRender方法中实现,主要包括:
- 重置命令分配器和命令列表
- 设置渲染目标
- 清除渲染目标
- 执行命令列表
- 显示交换链
- 等待GPU完成工作
这里着重说一下等待GPU完成工作的部分。
在DX12中,CPU和GPU是异步执行的,CPU会提前提交命令给GPU执行,而GPU可能还在处理之前提交的命令。为了确保GPU完成当前帧的渲染工作,通常会使用围栏(Fence)来同步CPU和GPU的工作。
围栏是一个同步对象,CPU可以等待围栏达到某个值,表示GPU已经完成了对应的工作。具体步骤包括:
第一帧:
- 初始时m_frameIndex为0,提交命令列表给GPU执行,这里用的是BackBuffer0
- 要求交换链显示BackBuffer0,这里其实并没有立刻显示,而是将显示请求加入到之前交换链绑定的命令队列中
- 调用m_commandQueue->Signal(m_fence.Get(), 1)方法。意思是让命令队列在执行完之前提交的所有命令后,将围栏的值设置为1。
- 获取了m_fence->GetCompletedValue(),此时应该是0,因为这是第一帧,GPU还没有执行任何命令。
- 所以要等待,直到围栏的值达到1,表示GPU已经完成了第一帧的渲染工作。通过调用m_fence->SetEventOnCompletion(1, m_fenceEvent)来设置一个事件,当围栏值达到1时触发该事件。然后调用WaitForSingleObject(m_fenceEvent, INFINITE)等待事件触发。
这是最简单的同步方式,确保CPU在继续执行下一帧之前,GPU已经完成了当前帧的渲染工作。
正因为如此,我们只需要一个CommandAllocator就可以了,因为我们确保每一帧GPU完成后,才会重用CommandAllocator。
HelloTriangle
在HelloWindow的基础上,增加了绘制一个简单三角形的功能。
LoadPipeline()
与HelloWindow完全相同。
LoadAssets()
增加了创建根签名和渲染管线状态对象(PSO)的步骤。
- 创建根签名(ID3D12RootSignature)
根签名定义了着色器访问资源的方式,包括常量缓冲区、纹理、采样器等。但这里的根签名是空的,因为这个例子中没有使用任何资源。 - 编译了顶点着色器和像素着色器
使用D3DCompileFromFile函数从文件编译HLSL着色器代码,生成顶点着色器和像素着色器的字节码。 - 定义输入布局
定义了顶点数据的格式和布局,这里只包含位置和颜色两个属性。 - 创建渲染管线状态对象(ID3D12PipelineState)
渲染管线状态对象(PSO)封装了渲染管线的各种状态设置,包括输入布局、根签名、着色器字节码、光栅化状态、混合状态、深度模板状态等。 - 创建命令列表
与HelloWindow相同。 - 创建顶点缓冲区资源/顶点缓冲区视图
- 定义顶点数据
- 创建和上传堆(CPU可访问内存)
- 使用map/unmap将顶点数据复制到顶点缓冲区
- 创建顶点缓冲区视图(D3D12_VERTEX_BUFFER_VIEW)
- 创建围栏和事件句柄
与HelloWindow相同。
这里记录下DX12中堆的概念:
在DirectX
12中,堆(Heap)是用于管理GPU内存的一种机制。堆允许开发者更灵活地分配和管理内存资源,以满足不同的性能和使用需求。堆可以分为几种类型,主要包括:
- 默认堆(Default Heap)
默认堆是GPU专用的内存区域,通常用于存储需要频繁访问的资源,如纹理、顶点缓冲区等。默认堆的内存访问速度较快,但CPU无法直接访问,需要通过命令列表进行数据传输。 - 上传堆(Upload Heap)
上传堆是CPU可访问的内存区域,主要用于将数据从CPU传输到GPU。上传堆通常用于存储临时数据,如动态顶点缓冲区、常量缓冲区等。上传堆的内存访问速度较慢,但可以直接由CPU写入数据。 - 读取回堆(Readback Heap)
读取回堆是用于将数据从GPU传输回CPU的内存区域。读取回堆通常用于存储需要从GPU读取的结果数据,如计算着色器的输出等。读取回堆的内存访问速度较慢,但可以直接由CPU读取数据。
OnRender()
在HelloWindow的基础上,增加了绘制三角形的命令。
主要步骤包括:
- 重置命令分配器
- 重置命令列表并设置渲染状态
- 设置根签名
- 设置视区和裁剪矩形
- 设置资源屏障,转换渲染目标状态至RENDER_TARGET
- 指定渲染目标
- 清除渲染目标
- 指定绘制三角形
- 绑定顶点缓冲区
- 绘制调用
- 设置资源屏障,转换渲染目标状态至PRESENT
HelloBundles
在HelloTriangle的基础上,增加了使用命令捆绑(Command
Bundles)的功能。
大致就是在
LoadPipeline时创建一个命令捆绑分配器(ID3D12CommandAllocator)
在LoadAssets时创建命令捆绑(ID3D12GraphicsCommandList)
并记录下以下命令:
- 设置根签名
- 设置绘制基本图元
- 绑定顶点缓冲区
- 绘制调用
然后在OnRender中,执行命令捆绑。m_commandList->ExecuteBundle(m_bundle.Get());
HelloConstBuffer
在HelloTriangle的基础上,增加了使用常量缓冲区(Constant Buffer)的功能。
在LoadPipeline()中
增加了创建常量缓冲区视图描述符堆(ID3D12DescriptorHeap)cbvHeap
的步骤。
首先是根签名的修改
- 先定义一个常量缓冲区描述符范围(D3D12_DESCRIPTOR_RANGE)
设置类型为常量缓冲区视图(CBV),数量为1,基址寄存器为0。 - 然后定义一个根参数(D3D12_ROOT_PARAMETER)
设置类型为描述符表,包含上面定义的描述符范围。 - 设置根签名Flags
设置为允许输入布局和着色器访问。 - 创建根签名描述
- 创建根签名
然后是常量缓冲区的创建
- 定义常量缓冲区结构体(ConstantBuffer)
- 在上传堆创建一个常量缓冲区资源
- 映射常量缓冲区资源,获取CPU可访问的指针
- 初始化常量缓冲区数据
- 创建常量缓冲区视图描述 CBVDesc
- 在cbvHeap(常量缓冲区视图描述符堆)中创建常量缓冲区视图
最后是在OnRender中绑定常量缓冲区
- 设置描述符堆
- 设置根描述符表,绑定常量缓冲区视图
之后的流程与HelloTriangle相同。
HelloTexture
然后是纹理。
大致流程是
- 描述并创建SRV描述符堆
- 根签名增加一个描述符表,绑定SRV
- 同时定义一个静态采样器一块放到根签名描述中
- 描述一个2D纹理资源
- 创建一个默认堆资源用于存储纹理
- 创建一个上传堆资源用于上传纹理数据
- 使用UpdateSubresources函数将纹理数据从上传堆复制到默认堆
- 将默认堆资源的状态从COPY_DEST转换为PIXEL_SHADER_RESOURCE
- 创建SRV描述符,绑定到SRV描述符堆
- 在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,说明第一帧的命令还没有完成,需要等待。
- 围栏值为2,大于等于A的围栏值2,说明第一帧的命令已经完成,可以继续执行。
- 围栏值为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渲染。