Skip to content

预备知识

COM

组件对象模型(Component Object Modle),由Windows运行时库(Windows Runtime Library)提供,位于Microsoft::WRL::ComPtr中,通过头文件wrl.h引用。通常将它视为一种接口,但在DirectX中暂且视为类。
所有COM对象都继承自IUnknown这一接口,会统计引用计数。当引用计数归零时,它会自行释放。

在DX12中,常会用到以下ComPtr方法:

  • Get()

    返回一个指向此底层COM接口的原始指针,也就是普通的c++指针,常用于把原始的COM接口指针作为参数传递给函数。

    持有原始指针的函数可以直接用原始指针调用方法,除非在需要存为成员变量以便异步使用或类似的情况,不必转回ComPtr。

    但持有原始指针的函数不应接管指针的生命周期,例如ReleaseAndGetAddressOf()Reset()

  • GetAddressOf()

    返回指向此底层COM接口的原始指针的地址,通常用于初始化ComPtr对象。

    由于GetAddressOf()在使用时并不会操作引用计数,如果该ComPtr原始不为空,通过该方法获取地址并修改,则原始指向的对象不会被释放,容易造成内存泄漏。因此,在DX12开发中,大多时候都会使用更安全的ReleaseAndGetAddressOf()

  • ReleaseAndGetAddressOf()

    释放原本的ComPtr对象,清除相关引用,并返回地址,通常用于初始化Comptr对象。

  • Reset()

    将该ComPtr对象设置为nullptr并释放与之相关的所有引用。

ID3D12Resource 资源

D3D12资源接口。

纹理格式

2D textrue是由数据元素构成的矩阵,用途之一是存储2d图像,允许存储DXGI_FORMAT枚举中的所描述的特定类型的数据类型。

DXGI_FORMAT枚举 示例>
类型 说明
DXGI_FORMAT_R32G32B32_FLOAT 每个元素由三个32位浮点数分量构成
DXGI_FORMAT_R16G16RB16A16_UNORM 每个元素由四个映射到\([0,1]\)区间的16位分量构成
DXGI_FORMAT_R32G32_UINT 每个元素由两个32位无符号整数构成
DXGI_FORMAT_R8G8R8A8_UNORM 每个元素由四个映射到\([0,1]\)区间的8位分量构成
DXGI_FORMAT_R8G8B8A8_SNORM 每个元素由四个映射到\([-1,1]\)区间的8位分量构成
DXGI_FORMAT_R8G8B8A8_SINT 每个元素由四个8位有符号整数分量构成
DXGI_FORMAT_R8G8B8A8_UINT 每个元素由四个8位无符号整数分量构成

除此以外,还有无类型格式的纹理,通常用于预留内存,待绑定到渲染管线时才指定类型。这一过程类似于指定如何翻译这一部分的数据,需要注意只能翻译为相同宽度(位)的类型。例如:

DXGI_FORMAT_R32G32B32_TYPELESS

Swap Chain

为了避免画面闪烁,通常在渲染时会将整个场景绘制完成后再显示。为此需要存在至少两个缓冲区分别用于显示当前帧和绘制下一帧,当下一帧绘制完成后交换两个缓冲区的位置,并在非当前显示的缓冲区绘制下一帧。

这种多个缓冲区交替显示构成的系统叫Swap Chain(交换链),交换两个缓冲区的行为称为Presenting(呈现)。通常情况下两个缓冲区足矣,称为双缓冲,分别将两个缓冲区叫做前台缓冲区和后台缓冲区。

Swap Chain的缓冲区以2d纹理的方式存储。

Depth Buffer

深度缓冲是一种纹理,存储特定像素的深度信息,值为\([0,1]\)。它是一种相对的深度,\(0.0\)代表视锥体中看到的离自己最近的物体,\(1.0\)代表是视锥体中能看到的距离自己最远的物体。

绘制时,会对原有像素和新像素的深度进行比较(深度测试),只有深度更小的像素会被绘制。

深度缓冲区同样以2d纹理的方式存储,且其数据规模与后台缓冲区应相同。

深度缓冲允许使用的纹理格式列表
类型 说明
DXGI_FORMAT_D32_FLOAT_S8X24_UINT 每个元素宽64位,取其中32位作为浮点型深度缓冲区,8位无符号整数分配给模板缓冲区,剩余24位用于内存对齐不做他用。
DXGI_FORMAT_D32_FLOAT 每个元素为32位浮点数深度缓冲区
DXGI_FORMAT_D24_UNORM_S8_UINT 每个元素宽32位,取其中24位作为映射到\([0.0,1.0]\)的无符号深度缓冲,8位无符号整数分配给模板缓冲区
DXGI_FORMAT_D16_UNORM 每个元素为映射到\([0.0,1.0]\)的16位深度缓冲

Stencil Buffer

模板缓冲区,通常和深度缓冲区绑定。

Bind&Descriptor

发出绘制命令前,需要将该次绘制调用相关的资源绑定(Bind)到渲染管线。部分资源可能会在没每次绘制调用时都发生变化,所以需要在每次绘制调用前按需更新绑定。

渲染管线通过描述符(Descriptor)间接引用GPU资源。描述符是一个中间层,是对送往GPU的资源进行解释的结构。

注意事项

在DX12中,View和Descriptor是同义词,有时会交替使用。

每个描述符都有一个具体类型,用于指定所描述资源的作用,DX12中常用的描述符如下:

  • CBV/SRV/UAV:分别表示常量缓冲区视图(描述符)、着色器资源视图(描述符)和无序访问视图(描述符);
  • sample描述符表示采样器资源。
  • RTV:表示渲染目标视图(描述符),常见的渲染目标包括前台缓冲区、后台缓冲区。
  • DSV:表示深度/模板视图(描述符)。

一系列特定类型的描述符构成了描述符堆。堆是一段可以根据需要分配和释放的连续内存,描述符堆在底层接近于一个连续空间上的数组。

创建描述符通常(且最好)在初始化期间,最好不要在运行时创建描述符。

Factory

枚举显卡、创建设备(device)、创建交换链。

用完就可以销毁。

Command List

命令列表(Command List),抽象为ID3D12CommandList。但该接口只是一个通用的抽象基类,实际包含的方法非常少,在应用时通常会使用更具体的接口。

ID3D12CommandList通常用于统一管理命令队列,由于它是父类,它的数组允许其元素是不同类型的命令队列。
通常,录制命令时使用更具体的CommandList类型指针(即CommandList的子类),提交命令时使用通用的ComandList指针。不需要显示转换,只需要把具体的CommandList指针加入通用的CommandList列表并提交到命令队列即可,编译器会自行处理。

当命令全部加入命令列表后,使用Close()方法关闭命令列表,并使用m_commandQueue.ExcuteCommandLists()提交到命令队列中。
命令列表不必和命令队列一样创建为成员,它可以根据情况随用随销毁。

GraphicsCommandList

API文档:ID3D12GraphicsCommandList (d3d12.h) - Win32 apps | Microsoft Learn

继承自CoomandList,封装了一系列图形渲染指令,这些方法都只是将命令加入命令列表,而不是立刻执行。

Command Queue

API文档:ID3D12CommandQueue (d3d12.h) - Win32 apps | Microsoft Learn

命令队列(Command Queue)本质是环形缓冲区,在dx12中CPU通过命令列表将命令提交到这个队列中,而命令不会在提交的同时立刻执行

初始化

命令队列在DX12中被抽象为ID3D12CommandQueue接口,创建命令队列前需要先创建对应的描述符。

Command Queue 初始化流程
//声明命令队列
ComPtr<ID3D12CommandQueue> m_commandQueue;

//指定命令队列描述符
D3D12_COMMAND_QUEUE_DESC queueDesc = {};
queueDesc.Flags = D3D12_COMMAND_QUEUE_FLAG_NONE;    //特殊行为标志,位掩码
queueDesc.Type = D3D12_COMMAND_LIST_TYPE_DIRECT;    //命令队列类型,包括DIRECT、BUNDLE、COMPUTE和COPY四种

//通过设备创建命令队列,参数分别是命令队列描述符、接口类型和存放命令队列实例的地址
ThrowIfFailed(m_device->CreateCommandQueue(&queueDesc, IID_PPV_ARGS(&m_commandQueue)));

这里使用了IID_PPV_ARGS,这是WindowsSDK中一个宏,作用是将接口类型转换成GUID并传入接口地址。

常用方法

  • ExecuteCommandLists()

    用于将命令列表数组里的命令添加到命令队列中,一次性可以添加多个命令列表,GPU会按数组中的索引开始顺序执行。。有如下参数:

    • UINT Count :命令列表数组中命令列表的数量
    • ID3D12CommandList *const *ppCommandLists:待执行的命令数组,指向命令列表数组的第一个元素。
    调用例
    1
    2
    3
    4
    5
    6
    7
    8
    // 1. 准备 ComPtr 列表
    ComPtr<ID3D12GraphicsCommandList> cmdList; 
    
    // 2. 提交时,用 Get() 拿到原始指针,放进数组
    ID3D12CommandList* ppLists[] = { cmdList.Get() };
    
    // 3. 数组名 ppLists 会自动转换成 ID3D12CommandList* const*
     m_CommandQueue->ExecuteCommandLists(_countof(ppLists), ppLists);
    

Command Allocator

API文档:ID3D12CommandAllocator (d3d12.h) - Win32 apps | Microsoft Learn

命令分配器。存储命令的接口,可以与多个命令列表关联,但同一个命令分配器同一时间,只允许有一个命令列表处于录制状态且与该命令分配器关联。
逻辑关系上,可以把Coomand List视为笔,Command Allocatoe视为纸。
命令列表创建和重置时默认处于打开状态,因此不能连续创建命令列表并将其加入Allocator

命令分配器不需要描述符

创建与销毁CommandAllocator的开销极为昂贵,通用的做法是创建与Swap Chain的缓冲区数量相同数量的Command Allocator,每个缓冲区的录制统一使用一个。在确认GPU执行完命令分配器中的命令后,可以通过Reset()方法重置Allocator。
重置Allocator会一并清空所有渲染管线状态,如果在onRender中重置,则需立刻重新设置视口、裁剪矩形、根签名和资源屏障。

初始化Command Allocator需要使用ID3D12Device。

初始化Command Allocator
1
2
3
4
5
m_device->CreateCommandAllocator(
    D3D12_COMMAND_LIST_TYPE type,
    REFIID riid,
    void **ppCommandAllocator
)

其中:

  • type

    指定与分配器关联的命令列表的类型,常用的有两种:

    • D3D12_COMMAND_LIST_TYPE_DIRECT
    • D3D12_COMMAND_LIST_TYPE_BUNDLE
  • rrid:接口的com id。
  • ppCommandAllocator:输出指针

Descriptor Heap

Note

DESCdescriptor是不同的概念,前者是对特定结构的描述,仅在创建该结构的时候需要用到,创建结束后就可以销毁。

描述符堆。

dx12允许创建四种描述符堆,描述符必须放进对应的堆。创建描述符堆时,通过 D3D12_DESCRIPTOR_HEAP_TYPE 枚举指定堆类型。

堆的类型(Type) 可使用描述符 作用
D3D12_DESCRIPTOR_HEAP_TYPE_RTV RTV (Render Target View) 渲染目标(画板)。告诉 GPU 画面应该吐到哪张全屏纹理上。
D3D12_DESCRIPTOR_HEAP_TYPE_DSV DSV (Depth Stencil View) 深度/模板缓冲区。用来做 Z-Buffer 测试,决定物体的遮挡关系(谁在前、谁在后)。
D3D12_DESCRIPTOR_HEAP_TYPE_CBV_SRV_UAV CBV (Constant Buffer View) SRV (Shader Resource View) UAV (Unordered Access View) 3 种和着色器密切相关的资源: • 3D 物体的世界变换矩阵(CBV) • 游戏角色身上的各种贴图/纹理(SRV) • 允许 GPU 读写的通用缓冲区(UAV)
D3D12_DESCRIPTOR_HEAP_TYPE_SAMPLER Sampler (采样器状态) 纹理采样器。告诉 GPU 在放大或缩小贴图时,应该用双线性插值(Bilinear)还是各向异性过滤(Anisotropic)。

flushing the command queue &Fench

API文档:ID3D12Fence(d3d12.h)- Win32 应用 | Microsoft Learn --- ID3D12Fence (d3d12.h) - Win32 apps | Microsoft Learn

为了避免CPU和GPU在并行计算时不同步,导致CPU提前覆写GPU需要使用的数据,DX12使用Fence(围栏)来进行CPU与GPU之间的同步。
其思路是强制CPU进行等待,直到GPU执行完命令达到指定的fence point为止。

初始化fence
1
2
3
4
5
m_device->CreateFence(UINT64 InitialValue,                    
                      D3D12_FENCE_FLAGS Flag, 
                      REFIID riid,
                      void **ppFence;
                     );

每个Fence对象维护一个UINT64类型的值InitialValue,用来标识Fence point。
创建Fence对象时,InitialValue应传入\(0\);每当需要标记一个新的Fence point时,InitialValue的值就加\(1\)

Resource Barrier

为了避免资源转换未完成时GPU就执行相关达到读写操作,DX12使用一系列状态标记资源。
需要注意资源转换和跟踪状态这一行为有一定开销,应该谨慎地使用。

资源状态与当前正在操作的资源关联,而非与命令列表关联。

Shader Compile and load

着色器(hlsl文件)需要被编译为可移植的字节码,才能被D3D程序使用。

运行时编译着色器的方法
HRESULT D3DCompileFromFile(
  [in]            LPCWSTR                pFileName,     //shader文件源码路径
  [in, optional]  const D3D_SHADER_MACRO *pDefines,    //着色器宏,可以置空(nullptr)
  [in, optional]  ID3DInclude            *pInclude,    //包含文件,可以置空(nullptr)
  [in]            LPCSTR                 pEntrypoint,    //字符串,shader文件中的入口函数名
  [in]            LPCSTR                 pTarget,      //字符串,着色器目标版本
  [in]            UINT                   Flags1,        //标志(控制核心渲染设置
  [in]            UINT                   Flags2,        //标志(控制高级/效果
  [out]           ID3DBlob               **ppCode,      //存储编译成功后的二进制数据的容器
  [out, optional] ID3DBlob               **ppErrorMsgs  //存储编译失败后的  
);

命令与多线程

Command List、Command Allocator都不是线程自由对象,每个线程只能使用各自的对象。

Command Queue是线程自由对象,且多个线程可以同时向Command Queue提交Command List。

Multisampling

为了在显示像素受限的情况下改善走样(即锯齿),开发过程中会使用各种反走样(抗锯齿)技术,常见的抗锯齿技术包括SSAA(超级采样,Super Sample Anti-Aliasing)和MSAA(多重采样,MultuSample Anti-Aliasing)。

SSAA是一种开销昂贵的技术,它使用4倍屏幕分辨率大小的后台缓冲区和深度缓冲区,当后台缓冲区被推向前台时,会将后台缓冲区按4个像素一组进行解析(降采样),以四个像素的平均值作为实际像素的值。它需要计算四倍分辨率像素的颜色,而计算像素颜色是渲染管线中开销最大的操作之一。

MSAA同样使用多倍屏幕分辨率大小的后台缓冲区和深度缓冲区,和SSAA的区别在于MSAA对每组子像素只计算一次颜色(而不是SSAA的逐像素计算),但会对每个子像素进行深度测试,只有通过深度测试的的像素会更新为新的颜色;当后台缓冲区被推到前台时,会对子像素分组进行加权均值计算。

注意事项

采样时,子像素划分并非将一个子像素均匀地切分为多个子像素,实际的切割往往带有不同的旋转与平移,因此各个子像素的中心点和像素的中心点距离也不一定相等。

在DX12中,采样使用DXGI_SAMPLE_DESC描述符,它是一个结构体,原型如下:

1
2
3
4
typedef struct DXGI_SAMPLE_DESC{
    UNIT Count;         //指定每个像素的采样次数
    UNIT Quality;       //图像质量级别
}DXGI_SAMPLE_DESC;

Feature Level

功能级别。

DXGI

DirectX Graphics Infrastructure,一套与Direct3D配合使用的API,用于处理多种图形API中共有的底层任务,以及一些常用的图形功能。

性能计时器 Performance timer

相关方法位于<windows.h>中。

性能计时器使用count作为时间计量单位,常见用法如下:

  • QueryPerformanceCounter((LARGE_INTEGER*)&currtime)

    获取当前的时刻值,单位为count

    其中,currtime的类型是__int64

  • QueryPerformanceFrequency((LARGE_INTEGER*)&countsPerSec)

    获取性能计时器的频率,单位是count/sec,由此可以推出:

    1.0/(double)countsPerSec:将count转换为以秒为单位的时长。

游戏计时器 GameTimer

GameTimer典型结构
class GameTimer
{
public:
    GameTimer();

    float TotalTime()const;  // 单位为秒
    float DeltaTime()const; // 单位为秒

    void Reset(); // 消息循环前调用
    void Start(); // 取消暂停时调用
    void Stop();  // 暂停时调用
    void Tick();  // 每帧调用

private:
    double mSecondsPerCount;
    double mDeltaTime;

    __int64 mBaseTime;
    __int64 mPausedTime;
    __int64 mStopTime;
    __int64 mPrevTime;
    __int64 mCurrTime;

    bool mStopped;
};

GameTimer是一个由开发者实现的普通类,用于在游戏中处理时间。

具体时间可以详见DX12龙书4.4.2-4.4.4。

帧的统计信息

为了测量和记录FPS,会使用CalculateFrameStats方法,这个方法也需要自行实现,或使用已有框架。在DX12中建议使用包含该方法的D3DApp,但实践中往往需要更复杂的计算,D3DApp仅能作为参考。

CalculateFrameStates使用例
oid D3DApp::CalculateFrameStats()
{
    // 计算每秒平均帧数的代码,还计算了绘制一帧的平均时间。 
    // 这些统计信息会显示在窗口标题栏中。
    static int frameCnt = 0;
    static float timeElapsed = 0.0f;

    frameCnt++;

    // 计算一秒时间内的平均值
    if( (mTimer.TotalTime() - timeElapsed) >= 1.0f )
    {
        float fps = (float)frameCnt; // fps = frameCnt / 1
        float mspf = 1000.0f / fps;

        std::wostringstream outs;   
        outs.precision(6);
        outs << mMainWndCaption << L"    "
             << L"FPS: " << fps << L"    " 
             << L"Frame Time: " << mspf << L" (ms)";
        SetWindowText(mhMainWnd, outs.str().c_str());

        // 为了计算下一个平均值重置一些值。
        frameCnt = 0;
        timeElapsed += 1.0f;
    }
}

每一帧都要调用以上方法。