使用DX12绘制几何体

使用DX12绘制几何体

顶点与索引

图元类型

要描述一个几何体,可以使用顶点加索引的方式来描述,每一个顶点中储存了一定的数据,而索引来描述如何连接这些顶点,而索引加上图元类型又决定了如何组装所有的顶点。在Directx中有如下图元类型:

D3D_PRIMITIVE_TOPOLOGY_UNDEFINED 值: 0 尚未使用基元拓扑初始化 IA 阶段。 除非定义了基元拓扑,否则 IA 阶段将无法正常运行。
D3D_PRIMITIVE_TOPOLOGY_POINTLIST 值:1 将顶点数据解释为点列表。
D3D_PRIMITIVE_TOPOLOGY_LINELIST 值: 2 将顶点数据解释为线条列表。
D3D_PRIMITIVE_TOPOLOGY_LINESTRIP 值: 3 将顶点数据解释为线条带。
D3D_PRIMITIVE_TOPOLOGY_TRIANGLELIST 值: 4 将顶点数据解释为三角形列表。
D3D_PRIMITIVE_TOPOLOGY_TRIANGLESTRIP 值: 5 将顶点数据解释为三角形带。
D3D_PRIMITIVE_TOPOLOGY_LINELIST_ADJ 值: 10 将顶点数据解释为具有相邻数据的线条列表。
D3D_PRIMITIVE_TOPOLOGY_LINESTRIP_ADJ 值: 11 将顶点数据解释为具有相邻数据的线条带。
D3D_PRIMITIVE_TOPOLOGY_TRIANGLELIST_ADJ 值: 12 将顶点数据解释为具有相邻数据的三角形列表。
D3D_PRIMITIVE_TOPOLOGY_TRIANGLESTRIP_ADJ 值: 13 将顶点数据解释为具有相邻数据的三角形带。

各种图元类型的示意图如下:

其中后缀带ADJ的图元类型便是按照索引的顺序,索引为奇数的顶点被跳过不参与绘制。

在顶点中可以储存各种数据,例如若是需要包含位置、颜色、法线信息的顶点可在HLSL端中表示为:

struct VertexPosLNormalColor
{
    float3 PosL : POSITIONT;
    float3 NormalV : NORMAL;
    float4 Color : COLOR;
};

其中每个变量后面的为语义,用来描述从C++端传过来的变量的含义,同时渲染管线自己也会生成一些变量,可通过使用语义来获取这些中间生成的变量。

该结构体在C++端对应的结构体为:

struct VertexPosLNormalColor
{
    DirectX::XMFLOAT3 m_Pos;
    DirectX::XMFLOAT3 m_Normal;
    DirectX::XMFLOAT4 m_Color;
};

顶点输入布局

定义完两端的结构体后,便需要通过顶点输入布局描述对C++端的数据进行解释,来让DirectX知道如何处理这些顶点数据,顶点输入布局使用如下结构体来表示:

typedef struct D3D12_INPUT_LAYOUT_DESC {
  const D3D12_INPUT_ELEMENT_DESC *pInputElementDescs;
  UINT                           NumElements;
} D3D12_INPUT_LAYOUT_DESC;

其中第一个元素为指向描述结构体成员数组的指针,第二个参数为数组中元素的数量。其中第一个参数对应的结构体如下:

typedef struct D3D12_INPUT_ELEMENT_DESC {
  LPCSTR                     SemanticName;
  UINT                       SemanticIndex;
  DXGI_FORMAT                Format;
  UINT                       InputSlot;
  UINT                       AlignedByteOffset;
  D3D12_INPUT_CLASSIFICATION InputSlotClass;
  UINT                       InstanceDataStepRate;
} D3D12_INPUT_ELEMENT_DESC;
  1. SemanticName:第一个参数为与该元素相关联的HLSL语义,可通过该字符串来让该元素描述的数据与HLSL端中的结构体对应起来。

  2. SemanticIndex:第二个参数为该语义的索引,由于可使用同一个语义来描述多个变量,如纹理坐标TEXCOORD,因此需要使用索引来明确对应哪一个变量。

  3. Format:第三个参数为该元素所使用的形式,使用DXGI_FORMAT 枚举来描述该形式,如DXGI_FORMAT_R32G32B32A32_FLOAT 就表示该元素为四个float也就对应float4,DXGI_FORMAT_R32G32_UINT 表示该元素由两个无符号整型构成,即HLSL端中的uint2,所输入的数据需要与所使用的格式相匹配。

  4. InputSlot:第四个参数为数据所输入的槽位,DirectX中支持16个槽位,索引为1-15,在传输顶点数据的时候,可将不同的数据元素输入到不同的槽位中,如此部分可复用的数据就不需要反复输入,从而提高效率,如使用ShadowMap绘制阴影的时候,所使用的顶点位置完全相同,因此可将顶点的位置和法线等信息分槽输入,复用位置信息。

  5. AlignedByteOffset:第五个参数为C++端结构体的元素距离结构体首地址的偏移量,如:

struct VertexPosLNormalColor
{
    DirectX::XMFLOAT3 m_Pos;	// 偏移0
    DirectX::XMFLOAT3 m_Normal;	// 偏移12
    DirectX::XMFLOAT4 m_Color;	//偏移24
};
  1. InputSlotClass:第六个参数为描述输入槽中的数据类型,有下列枚举来表示:
typedef enum D3D12_INPUT_CLASSIFICATION {
  D3D12_INPUT_CLASSIFICATION_PER_VERTEX_DATA = 0,
  D3D12_INPUT_CLASSIFICATION_PER_INSTANCE_DATA = 1
} ;

​ 其中D3D12_INPUT_CLASSIFICATION_PER_VERTEX_DATA表示该元素为顶点数据,每一份数据与一个顶点相匹配,而第二个枚举量表示该元素为实例数据,用于实例化绘制中,使用实例化可使用一份顶点数据来绘制多个实例。

  1. InstanceDataStepRate:第七个为使用一份实例数据来绘制的实例数,若使用顶点数据,该参数为0。

顶点输入布局可以和C++端的顶点数据封装在一起,通过一个静态函数来获取,方便后续的使用,例如包含顶点和颜色的数据可以定义成如下:

struct VertexPosLColor
{
    static decltype(auto) GetInputLayout()
    {
        static const std::array<D3D12_INPUT_ELEMENT_DESC, 2> inputLayout = {
            D3D12_INPUT_ELEMENT_DESC{ "POSITIONT", 0, DXGI_FORMAT_R32G32B32_FLOAT, 0, 0,
            D3D12_INPUT_CLASSIFICATION_PER_VERTEX_DATA, 0 },
            D3D12_INPUT_ELEMENT_DESC{ "COLOR", 0, DXGI_FORMAT_R32G32B32A32_FLOAT, 0, 12,
            D3D12_INPUT_CLASSIFICATION_PER_VERTEX_DATA, 0 }
        };
        return inputLayout;
    };

    DirectX::XMFLOAT3 m_Pos;
    DirectX::XMFLOAT4 m_Color;
};

这里的顶点输入布局为静态变量,频繁调用时可省去创建变量的开销。

顶点缓冲区与索引缓冲区

创建缓冲区

GPU无法直接访问在CPU端创建的顶点和索引数据,因此需要将创建的资源放置到叫缓冲区的GPU资源中以供GPU访问。在创建缓冲区资源之前,需要填写D3D12_RESOURCE_DESC结构体来描述需要创建的资源,简便起见可以使用包装类CD3DX12_RESOURCE_DESC来创建资源描述,该类继承自D3D12_RESOURCE_DESC并提供了一系列构造函数,可以使用CD3DX12_RESOURCE_DESC::Buffer来创建缓冲区资源,只需要传入缓冲区的总字节数即可。在使用ID3D12Device::CreateCommittedResource接口创建缓冲区资源之前,还需要填写D3D12_HEAP_PROPERTIES结构体,对于顶点和索引缓冲区而言,我们只需要确定需要创建的资源堆的类型即可,因此可以使用辅助构造类CD3DX12_HEAP_PROPERTIES,传入创建缓冲区堆的类型,该类型有如下成员:

typedef enum D3D12_HEAP_TYPE {
  D3D12_HEAP_TYPE_DEFAULT = 1,
  D3D12_HEAP_TYPE_UPLOAD = 2,
  D3D12_HEAP_TYPE_READBACK = 3,
  D3D12_HEAP_TYPE_CUSTOM = 4,
  D3D12_HEAP_TYPE_GPU_UPLOAD
} ;

对于静态的几何物体,由于创建后不需要修改其顶点和索引数据,因此可以使用默认堆类型D3D12_HEAP_TYPE_DEFAULT来创建资源,该堆类型体验 GPU 的最大带宽,但无法提供 CPU 访问,而顶点数据创建后还需要复制到缓冲区中,因此就需要使用一个D3D12_HEAP_TYPE_UPLOAD类型的堆来初始化缓冲区数据,该类型可以将CPU中创建的数据上传到GPU中,但不会体验 GPU 的最大带宽量,所以应该尽量使用D3D12_HEAP_TYPE_DEFAULT来创建资源。

由于使用CPU端的数据初始化默认缓冲区都需要使用上传缓冲区,因此可以将该步骤封装为一个函数以供使用。首先便是使用ID3D12Device::CreateCommittedResource接口创建对应的默认缓冲区和上传缓冲区资源,所使用默认的的堆资源堆属性:

ComPtr<ID3D12Resource> defaultBuffer;
// 创建默认缓冲区资源
auto heapPropertiesDefault = CD3DX12_HEAP_PROPERTIES(D3D12_HEAP_TYPE_DEFAULT);
auto resourceState = CD3DX12_RESOURCE_DESC::Buffer(byteSize);
ThrowIfFailed(device->CreateCommittedResource(
    &heapPropertiesDefault,	// 资源堆属性
    D3D12_HEAP_FLAG_NONE,
    &resourceState,
    D3D12_RESOURCE_STATE_COMMON,
    nullptr,
    IID_PPV_ARGS(defaultBuffer.GetAddressOf())));

// 创建一个中介的上传堆来将Cpu内存复制到默认缓冲区
auto heapPropertiesUpLoad = CD3DX12_HEAP_PROPERTIES(D3D12_HEAP_TYPE_UPLOAD);
ThrowIfFailed(device->CreateCommittedResource(
    &heapPropertiesUpLoad,
    D3D12_HEAP_FLAG_NONE,
    &resourceState,
    D3D12_RESOURCE_STATE_GENERIC_READ,
    nullptr,
    IID_PPV_ARGS(upLoadBuffer.GetAddressOf())));

对资源进行操作需要先将其转化为对应的模式,以避免资源的错误使用,在DX11中转化步骤都由驱动层来处理,DX12中进行手动转换,因此需要将资源状态转变转变为拷贝状态,该状态是只写的:

auto commonToCopyDest = CD3DX12_RESOURCE_BARRIER::Transition(
    defaultBuffer.Get(),
    D3D12_RESOURCE_STATE_COMMON,
    D3D12_RESOURCE_STATE_COPY_DEST);
cmdList->ResourceBarrier(1, &commonToCopyDest);

然后便是使用D3D12_SUBRESOURCE_DATA结构体描述需要初始化的资源,使用上传缓冲区将资源拷贝到目标资源当中:

// 描述要复制到默认缓冲区的资源
D3D12_SUBRESOURCE_DATA subResourceData{};
subResourceData.pData = initData;	// 指向包含子资源数据的内存块的指针
subResourceData.RowPitch = byteSize;// 子资源数据的行间距
subResourceData.SlicePitch = subResourceData.RowPitch;
UpdateSubresources<1>(
    cmdList,
    defaultBuffer.Get(),	// 目标资源
    upLoadBuffer.Get(),		// 中介缓冲区
    0, 0, 1,
    &subResourceData);		// 源缓冲区

将初始数据写入目标资源后还需要将该缓冲区转变为只读状态:

auto copyDestToCommon = CD3DX12_RESOURCE_BARRIER::Transition(defaultBuffer.Get(),
    D3D12_RESOURCE_STATE_COPY_DEST,
    D3D12_RESOURCE_STATE_GENERIC_READ);
cmdList->ResourceBarrier(1, &copyDestToCommon);

注意由于CPU将命令加入命令队列后GPU并不会立刻执行,因此上传缓冲堆在GPU执行完之前都不能够销毁。

定义顶点数据

有了默认资源的创建函数,便可以开始创建绘制物体的集合数据了。首先需要定义一个与HLSL端顶点数据匹配的结构体,由于只绘制不受光照影响的物体,因此顶点数据只包含位置和颜色,定义如下:

struct VertexPosLColor
{
    DirectX::XMFLOAT3 m_Pos;
    DirectX::XMFLOAT4 m_Color;
};

这里以正方体为例创建几何数据,顶点数据如下,包含了正方体的四个面:

std::array<VertexPosLColor, 8> vertexs = {
    VertexPosLColor({ XMFLOAT3(-1.0f, -1.0f, -1.0f),XMFLOAT4(Colors::White) }),
    VertexPosLColor({ XMFLOAT3(-1.0f, +1.0f, -1.0f),XMFLOAT4(Colors::Black) }),
    VertexPosLColor({ XMFLOAT3(+1.0f, +1.0f, -1.0f),XMFLOAT4(Colors::Red) }),
    VertexPosLColor({ XMFLOAT3(+1.0f, -1.0f, -1.0f),XMFLOAT4(Colors::Green) }),
    VertexPosLColor({ XMFLOAT3(-1.0f, -1.0f, +1.0f),XMFLOAT4(Colors::Blue) }),
    VertexPosLColor({ XMFLOAT3(-1.0f, +1.0f, +1.0f),XMFLOAT4(Colors::Yellow) }),
    VertexPosLColor({ XMFLOAT3(+1.0f, +1.0f, +1.0f),XMFLOAT4(Colors::Cyan) }),
    VertexPosLColor({ XMFLOAT3(+1.0f, -1.0f, +1.0f),XMFLOAT4(Colors::Magenta) })
};

注意DirectX默认会剔除反向的表面,而其定义顺时针的表面为正向的表面,因此索引的顺序需要为顺时针顺序:

std::array<std::uint16_t, 36> indices =
{
    // front face
    0, 1, 2,
    0, 2, 3,
    // back face
    4, 6, 5,
    4, 7, 6,
    // left face
    4, 5, 1,
    4, 1, 0,
    // right face
    3, 2, 6,
    3, 6, 7,
    // top face
    1, 5, 6,
    1, 6, 2,
    // bottom face
    4, 0, 3,
    4, 3, 7
};

在创建资源缓冲区之前,可以将需要的数据都整合进一个结构体中,方便管理,同时在DX12中调用绘制,也就是DrawCall有不使用索引和使用索引绘制的两种接口,这里使用带索引的ID3D12GraphicsCommandList::D rawIndexedInstanced,若不适用带索引的接口,会默认按输入顶点的顺序来绘制,该绘制接口定义如下:

void DrawIndexedInstanced(
  [in] UINT IndexCountPerInstance,	// 从每个实例的索引缓冲区读取的索引数。
  [in] UINT InstanceCount,			// 要绘制的实例数。
  [in] UINT StartIndexLocation,		// GPU 从索引缓冲区读取的第一个索引的位置。
  [in] INT  BaseVertexLocation,		// 从顶点缓冲区读取顶点之前添加到每个索引的值。
  [in] UINT StartInstanceLocation	// 从顶点缓冲区读取每个实例数据之前添加到每个索引的值。
);

其中第二个和第五个参数是为实例化绘制而服务的,实例化绘制可以显著减少DrawCall的调用,是一种复用部分数据的优化方式。从第三个和第四个参数可以得知,绘制的时候可以对顶点和索引的位置进行偏移,也就是说可以将好几个网格数据整合到一起,然后通过对应的顶点和索引偏移来进行绘制特定的物体,比如一个人物模型分为头和身子,那么就可以将人物模型的数据统一存放到一个顶点和索引缓冲区中,然后统一绘制,若想使用一个Shader单独绘制头部,然后使用另一个Shader绘制身体,就可以利用偏移来使用同一份资源缓冲区来分别绘制。

着色器的编写和编译

定义了这些数据还不足以使GPU绘制出我们想要的结果,我们还需要创建对应的着色器来告诉GPU如何来处理输入的各种数据。要绘制一个物体至少需要一个顶点着色器和像素着色器,若是使用VS中的着色器编译器,则不同的着色器之间需要分开编写并分别编译,并且不能使用预处理指令来编译同一份着色器代码。因此建议使用着色器编译接口来进行实时编译或是使用编译工具来进行离线编译,在运行时直接使用编译好的字节码。

在此先不考虑光源的作用,只是输出顶点的位置和颜色,因此HLSL端的顶点结构体定义如下:

struct VertexPosLColor	// 程序输入的顶点数据
{
    float3 PosL : POSITION;
    float4 Color : COLOR;
};

struct VertexPosHColor	// 顶点着色器输出的顶点数据
{
    float4 PosH : SV_Position;
    float4 Color : COLOR;
};

想要将输入的顶点绘制在屏幕上,就需要进行相应的矩阵变换,分别是从局部空间变换到世界空间的世界矩阵、从世界空间变换到观察空间的观察矩阵和从观察空间变换到齐次裁剪空间的透视投影矩阵,若是绘制2D物体,则是使用正交投影矩阵,后续变换到标准化设备空间(NDC)的操作由GPU来完成,而在转变为NDC后,GPU还会将其转变为屏幕空间,然后再输入给像素着色器。

其中变换到齐次裁剪空间是需要我们再顶点着色器中手动完成,因此顶点着色器的定义如下:

VertexPosHColor VS(VertexPosLColor v)
{
    VertexPosHColor o;
    o.PosH = mul(float4(v.PosL, 1.0), gWorldViewProj);
    o.Color = v.Color;
    return o;
}

其中gWorldViewProj为常量缓冲区的变量,从C++端中输入。

由于不需要计算光照,因此像素着色器只需要将自定义的颜色输出即可:

float4 PS(VertexPosHColor i) : SV_Target
{
    return i.Color;
}

实时编译

编写完着色器后便需要将着色器编译为字节码,实时编译可使用D3DCompileFromFile接口来编译,其定义如下:

HRESULT D3DCompileFromFile(
  [in]            LPCWSTR                pFileName,
  [in, optional]  const D3D_SHADER_MACRO *pDefines,
  [in, optional]  ID3DInclude            *pInclude,
  [in]            LPCSTR                 pEntrypoint,
  [in]            LPCSTR                 pTarget,
  [in]            UINT                   Flags1,
  [in]            UINT                   Flags2,
  [out]           ID3DBlob               **ppCode,
  [out, optional] ID3DBlob               **ppErrorMsgs
);
  • 参数一pFileName为包含着色器的文件的名字。

  • 参数二pDefines为包含着色器宏的数组,需要使用结构体D3D_SHADER_MACRO 来进行定义,并且最后需要一个参数都为null的结构体来作为终止符,如下例子定义了三个都为5的宏:

    D3D_SHADER_MACRO shaderMacro[] = {
    	{"MAXDIRLIGHTCOUNT", 5},
    	{"MAXPOINTLIGHTCOUNT", 5},
    	{"MAXSPOTLIGHTCOUNT", 5},
    	{nullptr, nullptr}
    };
    
  • 参数三pInclude为着色器文件是否能使用 #include指令包含其他文件。

  • 参数四pEntrypoint为函数的入口名称。

  • 参数五pTarget为编译着色器时使用的ShaderModel版本。

  • 参数六Flags1为着色器编译选项,使用D3DCOMPILE 常量用来指定如何编译着色器,各常量只用OR 操作来组合。

  • 参数八ppCode为编译完成后输出的字节码,使用ID3DBlob来储存。

  • 参数九ppErrorMsgs为编译失败时的错误信息。

可以将编译接口与错误处理封装成一个函数:

ComPtr<ID3DBlob> D3DUtil::CompileShader(
    const WCHAR* fileName,
    const D3D_SHADER_MACRO* defines,
    const std::string& enteryPoint,
    const std::string& target,
    const WCHAR* outputFileName)
{
    ComPtr<ID3DBlob> byteCode = nullptr;
    ComPtr<ID3DBlob> errors = nullptr;
    UINT compilFlags = D3DCOMPILE_ENABLE_STRICTNESS;	// 强制严格编译

#if defined(DEBUG) || defined(_DEBUG) 
    compilFlags |= D3DCOMPILE_DEBUG;
    compilFlags |= D3DCOMPILE_SKIP_OPTIMIZATION;	// 跳过优化步骤
#endif

    auto hr = D3DCompileFromFile(
        fileName,
        defines,
        D3D_COMPILE_STANDARD_FILE_INCLUDE,
        enteryPoint.c_str(),
        target.c_str(),
        compilFlags,
        0,
        byteCode.GetAddressOf(),
        errors.GetAddressOf());

    if (errors != nullptr) {
        OutputDebugStringA(static_cast<char*>(errors->GetBufferPointer()));
    }
    ThrowIfFailed(hr);

    if (outputFileName != nullptr) {
        ThrowIfFailed(D3DWriteBlobToFile(byteCode.Get(), outputFileName, TRUE));
    }

    return byteCode;
}

离线编译

除了实时编译还可以使用编译工具进行离线编译,对于复杂的着色器程序使用离线编译可以大大缩短程序加载的时间。可以使用DirectX自带的效果编译器工具FXC来进行离线编译。具体的使用可查看MSDN,编译后会生成.cos文件,可以通过直接读取文件来获取着色器的字节码:

ComPtr<ID3DBlob> D3DUtil::LoadShaderBinary(const std::wstring& filename)
{
    std::ifstream fin(filename, std::ios::binary);

    fin.seekg(0, std::ios_base::end);
    std::ifstream::pos_type size = (int)fin.tellg();
    fin.seekg(0, std::ios_base::beg);

    ComPtr<ID3DBlob> blob;
    ThrowIfFailed(D3DCreateBlob(size, blob.GetAddressOf()));

    fin.read((char*)blob->GetBufferPointer(), size);
    fin.close();

    return blob;
}

有时为了更详细的了解着色器,我们需要阅读编译后生成的汇编代码,此时也可以使用FXC工具来讲着色器编译成汇编代码。

常量缓冲区

创建常量缓冲区资源

前面提到过为了讲物体正确的显示到屏幕上,需要先将顶点变换到齐次裁剪空间中,而其中的变换矩阵就需要在CPU端中计算并传递到GPU中,这时便需要使用常量缓冲区资源。常量缓冲区是一种GPU资源ID3D12Resource,可以被着色器程序所访问,常量缓冲区内的资源经常会被更新,因此就不能够像顶点缓冲区和索引缓冲区一样使用D3D12_HEAP_TYPE_DEFAULT来创建默认堆资源ID3D12Resource,而是需要使用包含D3D12_HEAP_TYPE_UPLOAD的堆属性来创建GPU资源。而常量缓冲区对使用内存的大小有特殊的要求,其大小必须为硬件最小分配内存的整数倍,一般是256bit的整数倍,因此创建常量缓冲区时需要判断其大小是否满足条件,若是不满足则需要调整大小。具体的调整可使用如下函数来调整:

UINT D3DUtil::CalcConstantBufferByteSize(UINT byteSize) noexcept
{
    // 常量缓冲区必须为硬件最小分配内存的整数倍,通常为256.
    // 通过给byteSize加上256后,再屏蔽掉十六进制的最后八个bit可得到256的整数倍。
    // Example: Suppose byteSize = 300.
    // (300 + 255) & ~255
    // 555 & ~255
    // 0x022B & ~0x00ff
    // 0x022B & 0xff00
    // 0x0200
    // 512
    return (byteSize + 255) & ~255;
}

此时与顶点缓冲区类似的调用ID3D12Device::CreateCommittedResource即可创建上传堆。

而想要更新上传堆,就需要调用ID3D12Resource::MapID3D12Resource::Unmap两个函数,Map函数的函数参数如下:

HRESULT Map(
                  UINT              Subresource,	// 指定子资源的索引号
  [in, optional]  const D3D12_RANGE *pReadRange,	// 指向描述要访问的内存范围的指针
  [out, optional] void              **ppData		// 指向内存块的指针,该内存块接收指向资源数据的指针。
);

调用Map之后会获得一个指向资源数据的指针,可通过使用memcpy将CPU中的更新好的数据拷贝到该指针指向的内存块中来实现资源的更新。在使用Map映射资源数据之后读取映射到的资源的速度是十分缓慢的,因此映射之后应避免CPU的读取。

Map有两种使用模型,分别为简单使用模型高级使用模型。简单使用模型需要在更新完资源之后调用ID3D12Resource::Unmap来取消映射,要再次更新的时候在重新调用Map来映射,简单使用模型可以最大程度的提高工具的性能,因此更推荐使用这种方式。高级使用模型在创建资源后立即调用一次Map,之后都不需要调用Unmap,保持永久映射,但是此时必须保证CPU在GPU执行读取或写入内存的命令之前完成数据的写入。

为了方便使用,可以将创建资源和更新资源的操作都封装在一起:

template<typename T>
class UploadBuffer
{
public:
    template <class P>
    using ComPtr = Microsoft::WRL::ComPtr<P>;
    UploadBuffer(ID3D12Device* device, UINT elementByteSize, UINT elementCount, bool isConstantBuffer);
    UploadBuffer(const UploadBuffer& other) = delete;
    UploadBuffer(UploadBuffer&& other) = default;
    UploadBuffer& operator=(const UploadBuffer& other) = delete;
    UploadBuffer& operator=(UploadBuffer&& other) = default;

    void Map();
    void Unmap();
    ID3D12Resource* GetResource();
    void CopyData(int elementIndex, const T* const data, const std::size_t& byteSize);

public:
    bool m_IsDirty = false;

private:
    ComPtr<ID3D12Resource> m_UploadBuffer;
    BYTE* m_MappedData = nullptr;
    UINT m_ElementByteSize = 0;
    bool m_IsConstantBuffer = false;
};


template<typename T>
inline UploadBuffer<T>::UploadBuffer(
    ID3D12Device* device,
    UINT elementByteSize,
    UINT elementCount,
    bool isConstantBuffer)
    :m_IsConstantBuffer(isConstantBuffer) {
    m_ElementByteSize = m_IsConstantBuffer ?
        D3DUtil::CalcConstantBufferByteSize(elementByteSize) : elementByteSize;
    auto heapProperties = CD3DX12_HEAP_PROPERTIES(D3D12_HEAP_TYPE_UPLOAD);
    auto bufferDesc = CD3DX12_RESOURCE_DESC::Buffer(m_ElementByteSize * elementCount);
    ThrowIfFailed(device->CreateCommittedResource(
        &heapProperties,
        D3D12_HEAP_FLAG_NONE,
        &bufferDesc,
        D3D12_RESOURCE_STATE_GENERIC_READ,
        nullptr,
        IID_PPV_ARGS(m_UploadBuffer.GetAddressOf())));
}

template<typename T>
inline void UploadBuffer<T>::Map()
{
    ThrowIfFailed(m_UploadBuffer->Map(0, nullptr, reinterpret_cast<void**>(&m_MappedData)));
}

template<typename T>
inline void UploadBuffer<T>::Unmap()
{
    m_UploadBuffer->Unmap(0, nullptr);
}

template<typename T>
inline ID3D12Resource* UploadBuffer<T>::GetResource()
{
    return m_UploadBuffer.Get();
}

template<typename T>
inline void UploadBuffer<T>::CopyData(int elementIndex, const T* const data, const std::size_t& byteSize)
{
    if (m_IsDirty) {
        memcpy(&m_MappedData[m_ElementByteSize * elementIndex], data, byteSize);
        m_IsDirty = false;
    }
}

创建常量缓冲区视图

创建好常量缓冲区资源之后,要想将常量缓冲区资源绑定到渲染管线中,还需要创建对应的常量缓冲区视图,而常量缓冲区需要储存在类型为D3D12_DESCRIPTOR_HEAP_TYPE_CBV_SRV_UAV的描述符堆之中。可通过填写D3D12_DESCRIPTOR_HEAP_DESC结构体来描述需要创建的描述符堆,其定义如下:

typedef struct D3D12_DESCRIPTOR_HEAP_DESC {
  D3D12_DESCRIPTOR_HEAP_TYPE  Type;				// 描述符堆的类型
  UINT                        NumDescriptors;	// 描述符的数量
  D3D12_DESCRIPTOR_HEAP_FLAGS Flags;			// 描述符堆的标志
  UINT                        NodeMask;			// 节点掩码,多适配器时使用
} D3D12_DESCRIPTOR_HEAP_DESC;

随后调用ID3D12Device::CreateDescriptorHeap方法来创建描述符堆。

创建完描述符堆之后,便可以填写D3D12_CONSTANT_BUFFER_VIEW_DESC结构体来描述需要创建的常量缓冲区,其定义如下:

typedef struct D3D12_CONSTANT_BUFFER_VIEW_DESC {
  D3D12_GPU_VIRTUAL_ADDRESS BufferLocation;		// 资源在GPU的中的虚拟地址
  UINT                      SizeInBytes;		// 常量缓冲区的大小
} D3D12_CONSTANT_BUFFER_VIEW_DESC;
  1. BufferLocation: 资源在GPU的中的虚拟地址,可通过ID3D12Resource::GetGPUVirtualAddress方法来获取。
  2. SizeInBytes:常量缓冲区的大小,注意需要为256的整数倍

若一个常量缓冲区资源ID3D12Resource中储存了多个常量缓冲区的数据,可通过偏移使用ID3D12Resource::GetGPUVirtualAddress获取的结构体来更改GPU虚拟地址的位置,例如常量缓冲区资源中储存了两个常量缓冲区的数据,想获取第二个:

auto cbAdress = constBuffer->GetGPUVirtualAddress();
cbAdress += 1 * cbByteSize;

同时描述符堆可以储存多个描述符,也可以使用类似的方法来进行偏移:

auto handle = cbv->GetCPUDescriptorHandleForHeapStart();
handle.ptr += 1 * m_CbvSrvUavDescriptorSize;

其中m_CbvSrvUavDescriptorSize是常量缓冲/着色器资源/无序访问描述符(视图)的大小。

根签名

根签名是什么

常量缓冲区是一种着色器资源,会被绑定到其对应的寄存器槽上,而其他不同的着色器资源需要被绑定到对应的寄存器槽上,而根签名的定义便是:在执行绘制命令之前,通过应用程序绑定到渲染流水线上的资源,会被映射到着色器对应的寄存器槽中,若把着色器资源当作输入着色器形参,那么两者的关系便可看作函数与函数形参,其共同构成了函数签名,根签名的名字也是由此而来。在HLSL中,可使用如下语法声明寄存器槽:
:register ( [shader_profile], Type#[subcomponent] )

使用例子如下:

sampler myVar : register( ps_5_0, s0 );

其中shader_profile为着色器配置文件,Type对应不同的着色器资源,对应关系如下:

类型 注册说明
b 常量缓冲区
t 纹理和纹理缓冲区
c 缓冲区偏移量
S 采样器
u 无序访问视图

根签名限制

可见根签名与着色器程序使用的资源是一一对应的关系,绑定不同的资源会使用不同的根签名,因此在应用程序阶段,就需要创建合适的根签名来告诉GPU需要使用什么样的资源,使着色器资源被映射到对应的寄存器槽上。根签名对应的接口为ID3D12RootSignature,根签名中包含了根参数,因此还需要根据使用的着色器资源创建对应的根参数,在DX12中,根参数有如下三种形式:根常量根描述符根描述符表

  • 根常量:直接绑定32位的常量值。
  • 根描述符(内联描述符):可以直接指定要绑定的资源,无需创建描述符堆。但是只能绑定CBV(常量缓冲区视图)、缓冲区的SRV(着色器资源视图)和UAV(无序访问视图)。
  • 根描述符表:可以绑定储存在描述符堆中的一块连续范围。

三种不同的形式也对应了不同的开销。
内存开销
根签名的最大的大小为64DWORD,其中一个根常量占用1DWORD,可以直接绑定32位的常量值;一个根描述符占用2DWORD 可以绑定一个描述符(资源视图);一个根描述符表占用1DWORD,可以绑定储存在描述符堆中的一块连续范围。虽然一个根常量只占用1DWORD,但是其绑定的内存的大小也只有32位,也就是说若想使用根常量绑定一个4x4的矩阵,就需要消耗16DWORD,也就是需要占用\(\frac{1}{4}\)的内存,因此根常量的使用较少。

性能开销
三种根参数形式的性能开销关系如下:$$根描述符表>根描述符>根常量$$。可见根描述符表虽然能一次绑定大量的资源,但是同时也会带来更大的内存开销,通常绑定纹理资源时才会使用。

因此通常根据如下准则设置根签名:

  • 尽量使用小的根签名,但较大根签名灵活性较强,需要进行取舍。
  • 在根签名中安排参数时,尽可能使经常更改的参数首先出现,或者如果给定参数的低访问延迟很重要的话参数首先出现。
  • 尽可能用根常量或根常量缓冲区视图,而不是将常量缓冲区视图放在描述符堆中。

创建根签名

以根描述符表为例创建根签名:

// 创建根签名
// 创建一个根参数来将含有一个CBV的描述符表绑定到 HLSL 中的 register(0)
// 根参数可以是描述符表,根描述符,根常量
CD3DX12_ROOT_PARAMETER slotRootParameter[1];
// 创建只有一个CBV的描述符表
CD3DX12_DESCRIPTOR_RANGE cbvTable;
cbvTable.Init(
    D3D12_DESCRIPTOR_RANGE_TYPE_CBV,	// 描述符表的类型
    1,		// 表中的描述符数量
    0);		// 将描述符区域绑定到基准着色器寄存器

slotRootParameter[0].InitAsDescriptorTable(
    1,				// 描述符区域的数量
    &cbvTable);		// 指向描述符数组的指针

D3D12_ROOT_SIGNATURE_DESC signatureDesc{};
signatureDesc.Flags = D3D12_ROOT_SIGNATURE_FLAG_ALLOW_INPUT_ASSEMBLER_INPUT_LAYOUT;
signatureDesc.NumParameters = rootParamer.size();
signatureDesc.pParameters = rootParamer.data();
signatureDesc.NumStaticSamplers = 0;
signatureDesc.pStaticSamplers = nullptr;

// 创建含一个槽位的根签名
ComPtr<ID3DBlob> serializedRootSig = nullptr;
ComPtr<ID3DBlob> errorBlob = nullptr;
// 创建根签名之前需要对根签名的描述布局进行序列化,转换为以ID3DBlob接口表示的序列化数据格式
auto hr = D3D12SerializeRootSignature(
    &rootSigDesc,
    D3D_ROOT_SIGNATURE_VERSION_1,
    serializedRootSig.GetAddressOf(),
    errorBlob.GetAddressOf());

if (errorBlob) {
    ::OutputDebugStringA((char*)errorBlob->GetBufferPointer());
}
ThrowIfFailed(hr);

// 创建根签名
ThrowIfFailed(m_D3D12Device->CreateRootSignature(
    0,
    serializedRootSig->GetBufferPointer(),
    serializedRootSig->GetBufferSize(),
    IID_PPV_ARGS(m_RootSignature.GetAddressOf())));

其中CD3DX12_ROOT_PARAMETER为其基类D3D12_ROOT_PARAMETER提供了很多初始化函数,方便构造D3D12_ROOT_PARAMETERD3D12_ROOT_PARAMETER的定义如下:

typedef struct D3D12_ROOT_PARAMETER {
  D3D12_ROOT_PARAMETER_TYPE ParameterType;
  union {
    D3D12_ROOT_DESCRIPTOR_TABLE DescriptorTable;
    D3D12_ROOT_CONSTANTS        Constants;
    D3D12_ROOT_DESCRIPTOR       Descriptor;
  };
  D3D12_SHADER_VISIBILITY   ShaderVisibility;
} D3D12_ROOT_PARAMETER;

其中包含一个联合体,分别代表了根参数的三种形式。通过第一个参数D3D12_ROOT_PARAMETER_TYPE即可表明该根参数代表了什么资源,分别对应了三种根参数。

填写完参数后便是创建D3D12_ROOT_SIGNATURE_DESC结构体并将根参数传递进去,静态采样器由于暂不使用纹理因此忽略。

根参数的设置与版本控制

创建根签名的时候,我们定义了根参数的形参,而再绘制物体之前,还需要传递跟参数的实参,根据三种不同的根参数,可使用如下接口设置实参:

  • ID3D12GraphicsCommandList::SetGraphicsRoot32BitConstant
  • ID3D12GraphicsCommandList::SetGraphicsRootConstantBufferView
  • ID3D12GraphicsCommandList::SetGraphicsRootDescriptorTable

若是绘制前不通过相应接口传递实参,则会使用上一次绘制时设置的实参,这是由于在调用绘制接口时,硬件会保存当前根实参的快照,因此使用的根签名越复杂、根参数越多所保存的根实参快照也会越大,所以根签名不易过大,但是根签名的切换也会带来开销,因此需要在根签名大小和灵活性之间进行权衡。同时由于硬件会保存根实参的快照,每次更新根参数也只需要更新有变动的参数,可以将常量缓冲区根据更新频率拆分为多个常量缓冲区:

struct ObjectConstants
{
    float4x4 World;
    float4x4 WorldInvTranspos;
};

struct PassConstants
{
    float4x4 View;
    float4x4 InvView;
    float4x4 Proj;
    float4x4 InvProj;
    float3 EyePosW;
    float pad0;
    float2 RenderTargetSize;
    float2 InvRenderTargetSize;
    float NearZ;
    float FarZ;
    float TotalTime;
    float DeltaTime;
};

同时官方也推荐根据更新频率由高到低的排列根参数。

流水线状态对象

​ 相比DX11,DX12还多出了一个流水线状态对象(PSO)的概念,实际上就是离散的流水线信息,如混合状态、光栅化状态、顶点输入布局、各种着色器等整合到了一起。将这些信息整合在一起是为了让D3D确定所有的状态是否能够兼容,从而在初始化阶段就能够生成编程流水线状态的代码,在DX11中,由于这些信息时离散的,为了避免由于中途改变某个状态信息而使驱动程序对硬件重新编程,驱动程序会将该操作推迟到绘制被调用后进行,而这种延迟操作会造成额外的开销。创建PSO所消耗的时间较大,因此除了需要在程序初始化阶段就提前创建并储存好各种不同的PSO,在程序运行阶段直接使用。

posted @ 2025-10-13 23:50  单身喵  阅读(2)  评论(0)    收藏  举报