DirectX12 3D 游戏开发与实战第六章内容
阅读原文时间:2023年07月09日阅读:1

利用Direct3D绘制几何体

  1. 探索用于定义、存储和绘制几何体数据的Direct接口和方法
  2. 学习编写简单的顶点着色器和像素着色器
  3. 了解如何用渲染流水线状态对象来配置渲染流水线
  4. 理解怎样创建常量缓冲区数据。并将其绑定到渲染流水线上
  5. 掌握根签名的用法

由5.5.1节可知,除了空间位置,Direct3D的顶点还可以存储很多其他的属性数据。为了构建自定义的顶点格式,我们首先要创建一个结构体来容纳选定的顶点数据。比如:

//由位置和颜色信息组成的顶点结构体
typedef struct Vertex1
{
    XMFLOAT3 Pos;
    XMFLOAT4 Color;
};

//由位置、法向量以及两组2D纹理坐标组成的顶点结构体
typedef struct Vertex2
{
    XMFLOAT3 Pos;
    XMFLOAT3 Normal;
    XMFLOAT2 Tex0;
    XMFLOAT2 Tex1;
};

定义完顶点结构体之后,我们还需要向Direct3D提供该顶点结构体的描述,使他了解应该要怎样处理顶点结构体中的每一个成员。这种描述称为输入布局描述,我们可以用结构体D3D12_INPUT_LAYOUT_DESC来表示输入布局描述:

typedef struct D3D12_INPUT_LAYOUT_DESC
{
    const D3D12_INPUT_ELEMENT_DESC * pInputElementDesc;     //D3D12_INPUT_ELEMENT_DESC元素构成的数组
    UINT NumElements;                                       //数组元素数量
}D3D12_INPUT_LAYOUT_DESC;

D3D12_INPUT_ELEMENT_DESC数组中的元素依次描述了顶点结构体中对应的成员,如果某一个顶点结构体有两个成员,那么与之对应的D3D12_INPUT_ELEMETN_DESC数组也将会有两个元素。D3D12_INPUT_ELEMENT_DESC结构体的定义如下:

typedef struct D3D12_INPUT_ELEMENT_DESC {
    LPCSTR SemanticName;                                //语义,传达该元素的用途
    UINT SemanticIndex;                                 //附加到语义上的索引
    DXGI_FORMAT Format;                                 //指定顶点元素的格式(即数据类型)
    UINT InputSlot;                                     //指定传递元素所使用的输入槽
    UINT AlignedByteOffset;                             //从C++顶点结构体的首地址到其中某点元素起始地址的偏移量
    D3D12_INPUT_CLASSIFICATION InputSlotClass;          //暂时指定为D3D12_INPUT_CALSSIFICATION_PER_VERTEX_DATA
    UINT InstanceDataSetpRate;                          //暂时指定为0
}D3D12_INPUT_ELEMETN_DESC;

下面是以本节开头的Vertex1和Vertex2这两个顶点结构体的对应的输入布局描述:

    D3D12_INPUT_ELEMETN_DESC desc1[] = {
        {"POSITION",0,DXGI_FORMAT_R32G32B32_FLOAT,0,0,D3D12_INPUT_CLASSIFICATION_PER_VERTEX_DATA,0 },
        {"COLOR",0,DXGI_FORMAT_R32G32B32A32_FLOAT,0,12,D3D12_INPUT_CLASSIFICATION_PER_VERTEX_DATA,0}
    };

    D3D12_INPUT_ELEMENT_DESC desc2[] = {
        {"POSITION",0,DXGI_FORMAT_R32G32B32_FLOAT,0,0,D3D12_INPUT_CLASSIFICATION_PER_VERTEX_DATA,0},
        {"NORMAL",0,DXGI_FORMAT_R32G32B32_FLOAT,0,12,D3D12_INPUT_CLASSIFICATION_PER_VERTEX_DATA,0},
        {"TEXCOORD",0,DXGI_FORMAT_R32G32_FLOAT,0,24,D3D12_INPUT_CLASSIFICATION_PER_VERTEX_DATA,0},
        {"TEXCOORD",1,DXGI_FORMAT_R32G32_FLOAT,0,32,D3D12_INPUT_CLASSIFICATION_PER_VERTEX_DATA,0}
    };

为了使GPU可以访问顶点数组,我们需要把顶点数组放置在称为缓冲区的GPU资源里,我们把存储顶点的缓冲区称为顶点缓冲区。

我们要先通过填写D3D12_RESOURCE_DESC结构体来描述缓冲区资源,接着在调用ID3D12Device::CreateCommittedResource方法来创建ID3D12Resource对象。当然,我们也可以使用D3D12_Resource_Desc的派生类CD3DX12_RESOURCE_DESC来创建ID3d12Resource对象

对于静态几何体(每一帧都不会发生改变的几何体)而言,我们会将它的顶点缓冲区放置在默认堆中来优化性能。因为静态几何体的顶点缓冲区初始化完成之后,只有GPU需要从顶点缓冲区中读取数据,所以可以直接将该顶点缓冲区放在默认堆中。但是,如果CPU不能向默认堆中的顶点缓冲区写入数据,那么我们要怎样才可以初始化该顶点缓冲区呢?

解答:我们需要使用D3D12_HEAP_TYPE_UPLOAD这种堆类型来创建一个处于中介位置的上传缓冲区资源,然后我们就可以把顶点数据从系统内存复制到上传缓冲区中,然后把顶点数据从上传缓冲区复制到真正的顶点缓冲区中

我们在d3dUtil文件中构建了相关的工具函数,以避免在每次使用默认缓冲区时要重复的工作

Microsoft::WRL::ComPtr<ID3D12Resource> d3dUtil::CreateDefaultBuffer(
    ID3D12Device * device,
    ID3D12GraphicsCommandList * cmdList,
    const void * initData,
    UINT64 byteSize,
    Microsoft::WRL::ComPtr<ID3D12Resource> uploadBuffer
)
{
    ComPtr<ID3D12Resource> defaultBuffer;

    //创建实际的默认缓冲区资源
    ThrowIfFailed(device->CreateCommittedResource(&CD3DX12_HEAP_PROPERTIES(D3D12_HEAP_TYPE_DEFAULT), D3D12_HEAP_FLAG_NONE,
        &CD3DX12_RESOURCE_DESC::Buffer(byteSize),
        D3D12_RESOURCE_STATE_COMMON,
        nullptr,
        IID_PPV_ARGS(defaultBuffer.GetAddressOf())));

    //创建一个处于中介位置的上传堆
    ThrowIfFailed(device->CreateCommittedResource(&CD3DX12_HEAP_PROPERTIES(D3D12_HEAP_TYPE_UPLOAD),
        D3D12_HEAP_FLAG_NONE,
        &CD3DX12_RESOURCE_DESC::Buffer(byteSize),
        D3D12_RESOURCE_STATE_GENERIC_READ,
        nullptr,
        IID_PPV_ARGS(uploadBuffer.GetAddressOf())));

    //描述我们希望复制到默认缓冲区中的数据
    D3D12_SUBRESOURCE_DATA subResourceData = {};
    subResourceData.pData = initData;
    subResourceData.RowPitch = byteSize;
    subResourceData.SlicePitch = subResourceData.RowPitch;

    //转换默认缓冲区的状态
    cmdList->ResourceBarrier(1,
        &CD3DX12_RESOURCE_BARRIER::Transition(defaultBuffer.Get(),
            D3D12_RESOURCE_STATE_COMMON,
            D3D12_RESOURCE_STATE_GENERIC_READ));

    //将上传堆的数据复制到默认缓冲区中
    UpdateSubresources(cmdList, defaultBuffer.Get(), uploadBuffer.Get(),
        0, 0, 1, &subResourceData);
    //将默认缓冲区的状态转变为普通状态
    cmdList->ResourceBarrier(1,
        &CD3DX12_RESOURCE_BARRIER::Transition(defaultBuffer.Get(),
            D3D12_RESOURCE_STATE_GENERIC_READ,
            D3D12_RESOURCE_STATE_COMMON));

    //返回默认缓冲区
    return defaultBuffer;

}

下面的代码展示了如何创建存有立方体八个顶点的默认缓冲区,并为每一个顶点都分别赋予了不同的颜色

Vertex vertices[] =
{
    Vertex({ XMFLOAT3(-1.0f, -1.0f, -1.0f), XMFLOAT4(Colors::White) }),
    Vertex({ XMFLOAT3(-1.0f, +1.0f, -1.0f), XMFLOAT4(Colors::Black) }),
    Vertex({ XMFLOAT3(+1.0f, +1.0f, -1.0f), XMFLOAT4(Colors::Red) }),
    Vertex({ XMFLOAT3(+1.0f, -1.0f, -1.0f), XMFLOAT4(Colors::Green) }),
    Vertex({ XMFLOAT3(-1.0f, -1.0f, +1.0f), XMFLOAT4(Colors::Blue) }),
    Vertex({ XMFLOAT3(-1.0f, +1.0f, +1.0f), XMFLOAT4(Colors::Yellow) }),
    Vertex({ XMFLOAT3(+1.0f, +1.0f, +1.0f), XMFLOAT4(Colors::Cyan) }),
    Vertex({ XMFLOAT3(+1.0f, -1.0f, +1.0f), XMFLOAT4(Colors::Magenta) }),
    Vertex({ XMFLOAT3(0, 0, +1.0f), XMFLOAT4(Colors::Red) })
};

const UINT64 vbByteSize = 8 * sizeof(Vertex);
ComPtr<ID3D12Resource> VertexBufferGPU = nullptr;
ComPtr<ID3D12Resource> VertexBufferUploader = nullptr;
VertexBufferGPU = d3dUtil::CreateDefaultBuffer(md3dDevice.Get(), mCommandList.Get(),
    vertices, vbByteSize, VertexBufferUploader);

为了将顶点缓冲区绑定到渲染流水线上,我们还要为顶点缓冲区创建一个顶点缓冲区视图,不过我们不必为顶点缓冲区视图创建描述符堆,顶点缓冲区视图是由结构体D3D12_BUFFER_VIEW表示的:

typedef struct D3D12_VERTEX_BUFFER_VIEW {
    D3D12_GPU_VIRTUAL_ADDRESS BufferLocation;   //顶点缓冲区的虚拟地址
    UINT SizeInByte;                            //顶点缓冲区的大小
    UINT StrideInByte;                          //每个顶点元素占用的字节数
}D3D12_VERTEX_BUFFER_VIEW;

在顶点缓冲区以及其对应的视图创建完成之后,我们就可以将它和渲染流水线上的一个输入槽绑定了。这样我们就可以向流水线中的输入装配阶段传递顶点数据了。此操作可以有以下函数实现:

void ID3D12GraphicsCommandList::IASetVertexBuffers(
    UINT StartSlot,
    UINT NumViews,
    const D3D12_VERTEX_BUFFER_VIEW * pViews
);

将顶点缓冲区设置到输入槽上并不会对其执行真正的绘制操作,而是仅仅为顶点数据传送到渲染流水线上做准备,我们通过ID3D12GraphicsCommanList::DrawInstanced方法才可以真正地绘制顶点:

void ID3D12GraphicsCommandList::DrawInstanced(
    UINT VertexCountPerInstance,        //每个实例要绘制的顶点数量
    UINT InstanceCount,                 //暂时设置为1
    UINT StartVertexLocation,           //指定顶点缓冲区内第一个被绘制的顶点的索引
    UINT StartInstanceLoaction          //暂时设置为0
);

和顶点相似,为了使GPU可以访问索引数组,我们需要把索引反之放置在GPU的缓冲区资源你(ID3D12Resource)中,存储索引的缓冲区成为索引缓冲区,我们也可以使用d3dUtil::CreateDefaultBuffer函数来创建索引缓冲区。

为了使索引缓冲区和渲染流水线相互绑定,我们需要为索引缓冲区创建索引缓冲区视图,和顶点缓冲区视图一样,我们不需要为索引缓冲区视图创建描述符堆,索引缓冲区视图由结构体D3D12_INDEX_BUFFER_VIEW表示:

typedef struct D3D12_INDEX_BUFFER_VIEW {
    D3D12_GPU_VIRTUAL_ADDRESS BufferLoaction;   //索引缓冲区的虚拟地址
    UINT SizeInByte;                            //索引缓冲区的大小
    DXGI_FORMAT Format;                         //索引的格式
}D3D12_INDEX_BUFFER_VIEW;

和顶点缓冲区相似,在使用之前,我们要使用ID3D12GraphicsCommandList::IASetIndexBuffer函数来将索引缓冲区绑定到输入装配阶段。最后,我们要使用ID3D12GraphicsCommandList::DrawIndexedInstanced方法来绘制。

void ID3D12GraphicsCommandList::DrawIndexedInstanced(
    UINT IndexCountPerInstance,     //每个实例需要绘制的顶点数量
    UINT InstanceCount,             //暂时设置为1
    UINT StartIndexLoaction,        //指向索引缓冲区中的某一个元素,该元素为起始索引
    int BaseVertexLoaction,         //为每一个索引加上这个整数值
    UINT StartInstanceLoaction      //暂时设置为0
);

以下代码实现的是一个简单的顶点着色器(vertex shader):

cbuffer cbPerObject : register(b0)
{
    float4x4 gWorldViewProj;
};

void VS(float3 iPosL : POSITION,
    float4 iColor:COLOR,
    out float4 oPosH : SV_POSITION,
    out float4 oColor : COLOR
)
{
    //把顶点变换到齐次裁剪空间
    oPosH = mul(float4(iPosL, 1.0f), gWorldViewProj);
    //直接将顶点的颜色信息输出到像素着色器中
    oColor = iColor;

}

在Driect3D中,编写着色器的语言为高级着色语言(High Level Shading Language),其语法和c++十分相似。一般情况下,着色器通常要编写在以.hlsl为扩展名的文本文件中。

顶点着色器就是上面那个名为VS的函数,上述顶点着色器有四个参数,前面两个为输入参数,后面两个为输出参数,因为HLSL没有引用和指针的概念,所以需要借助结构体或是多个输出参数才可以返回多个数值。

前两个输入参数分别对应绘制立方体时自定义的顶点结构体中的两个数据成员,也构成了顶点着色器的输入签名,参数语义“POSITION”和“COLOR”用于将顶点结构体的元素映射到顶点着色器的输入签名中。输出参数也有各自的语义,输出参数会根据语义,将顶点着色器的输出映射到下一处理阶段(几何着色器或者像素着色器)中,这里有个“SV_POSITION”语义比较特殊,因为它所修饰顶点着色器输出元素存有齐次裁剪空间中的位置信息。

补充:内置函数mul用于计算向量和矩阵之间的乘法,也可以用于矩阵和矩阵之间的乘法

下面我们将把顶点着色器的的返回类型和输入签名用结构体替换,以避免出现过程的参数列表。即把上述顶点着色器改写成另一种等价实现:

cbuffer cbPerObject : register(b0)
{
    float4x4 gWorldViewProj;
};

struct VertexIn
{
    float3 PosL : POSITION;
    float4 Color : COLOR;
};

struct VertexOut
{
    float4 PosH : POSITION;
    float4 Color : COLOR;
};

VertexOut VS(VertexIn vIn)
{
    VertexOut vOut;
    //将顶点数据从局部空间变换到齐次裁剪空间
    vOut.PosH = mul(float4(vIn, 1.0f), gWorldViewProj);
    //直接把顶点颜色作为输出
    vOut.Color = vIn.Color;

    return vOut;
}

注意:如果没有使用几何着色器(十二章介绍),那么顶点着色器必须使用SV_POSITION语义输出顶点在齐次裁剪空间中的位置,因为硬件希望获得顶点在齐次裁剪空间中的坐标,如果使用了几何着色器,那么可以把输出顶点在齐次裁剪空间中的坐标的任务交给几何着色器来处理

略(该小节主要介绍输入的顶点数据和顶点着色器期望的输入不符合的情况)

为了计算出三角形内的每一个像素的属性,我们会再光栅化阶段对顶点着色器(或是几何着色器)输出的顶点属性进行插值,然后这些插值数据会作为像素着色器的输入。

像素着色器和顶点着色器相似,后者是针对每一个顶点而运行的函数,而前者是针对每一个像素片段而运行的函数。只要为像素着色器指定了输入数,它就会为像素片段计算出一个对应的颜色。不过输入像素着色器的片段那不一定会被传入或留存在后台缓冲区中,可能会在进入后台缓冲区之前被裁剪掉了,或者是没有通过深度/模板测试而被丢弃。

下面是一段像素着色器代码,因为要和上一节的顶点着色器相呼应,所以这里也会把顶点着色器的代码一起给出来

cbuffer cbPerObject : register(b0)
{
    float4x4 gWorldViewProj;
}

void VS(float3 iPosL:POSITION,
float4 iColor:COLOR,
out float4 oPosL:SV_POSITION,
out float4 oColor : COLOR
)
{
    //将顶点变换到齐次裁剪空间
    oPosL = mul(float4(iPosL, 1.0f), gWorldViewProj);
    //直接将顶点颜色传递到像素着色器
    oColor = iColor;
}

float4 PS(flaot4 posH : SV_POSITION, float4 color : COLOR) : SV_Target
{
    return color;
}

在上面的示例中,像素着色器只是简单的返回了插值颜色数据,可以发现,像素着色器的输入和顶点着色器的输出是精确匹配的,这是必须要满足的一点。而位于像素着色器参数列表后面的语义SV_TARGE则表示该返回值的类型和渲染目标格式相互匹配(该输出会被存到渲染目标之中)

和顶点着色器一样,我们可以利用输入/输出结构体重写像素着色器,如下:

cbuffer cbPerObject : register(b0)
{
    float4x4 gWorldViewProj;
}

struct VertexIn
{
    float3 Pos : POSITION;
    flaot4 Color : COLOR;
};

struct VertexOut
{
    float4 PosH : SV_POSITION;
    float4 Color : COLOR;
};

VertexOut VS(VertexIn vIn)
{
    VertexOut vOut;
    //将顶点坐标从局部空间转换到齐次裁剪空间
    vOut.PosH = mul(flaot4(vIn.Pos, 1.0f), gWorldViewProj);
    //直接将输入颜色输出到像素着色器中
    vOut.Color = vIn.Color;
    return vOut;
}

flaot4 PS(VertexIn vIn):SV_Target
{
    return vIn.Color;
}

常量缓冲区也是一种GPU资源(ID3D12Resource),其数据内容可以给着色器程序使用,就像我们即将学习到的纹理等其他资源一样,他们都可以被着色器程序使用。

和顶点缓冲区不同的是,常量缓冲区由CPU每帧更新一次,所以我们会把常量缓冲区创建到一个上传堆中而不是默认堆(只有GPU能访问的堆,CPU无法对其进行写入)中。同时,常量缓冲区对硬件也有特别的要求,即常量缓冲区的大小必须是硬件最小分配空间(256B)的整数倍

由于我们经常要使用多个相同类型的常量缓冲区,所以下面的代码将展示如何创建一个缓冲区资源,并利用该缓冲区来存储NumElements个常量缓冲区:

struct ObjectConstants
{
    DirectX::XMFLOAT4X4 WorldViewProj = MathHelper::Identity4x4();
};

UINT mElementByteSize = d3dUtil::CalcConstantBufferByteSize(sizeof(ObjectConstants));

ComPtr<ID3D12Resource> mUploadCBuffer;

md3dDevice->CreateCommittedResource(&CD3DX12_HEAP_PROPERTIES(D3D12_HEAP_TYPE_UPLOAD),
    D3D12_HEAP_FLAG_NONE,
    &CD3DX12_RESOURCE_DESC::Buffer(mElementByteSize*NumElement),
    D3D12_RESOURCE_STATE_GENERIC_READ,
    nullptr,
    IID_PPV_ARGS(&mUploadCBuffer)
    )

工具函数d3dUtil::CalcConstantBufferByteSize()会进行适当的计算,使缓冲区的大小凑整为硬件的最小分配空间的整数倍。(函数内部具体实现不解释)

随着Direct3D一起推出的是着色器模型(Shader Model)5.1,其中新引进了一条可以用于定义常量缓冲区的HLSL语法,它的使用方法如下:

struct ObjectConstances
{
    flaot4x4 gWorldViewProj;
};

ConstantBuffer<ObjectConstances> gObjectConstants : register(b0);

我们在前面的实例中使用的都是着色器模型5.0的标准,接下来我们会尽可能的使用着色器模型5.1的标准,(5.1暂时不支持Driect11)

由于常量缓冲区是使用D3D12_HEAP_TYPE_UPLOAD这种类型创建的,所以我们可以通过CPU来更新数据,为此,我们需要获取指向欲更新数据的指针,可以使用Map方法获取:

ComPtr<ID3D12Resource> mUploadBuffer;
BYTE* mMappedData = nullptr;
mUploadBuffer->Map(0, nullptr, reinterpret_cast<void**>(&mMappedData));

Map方法的三个参数的意义分别是:

  1. 指定了欲映射的子资源的索引,对于缓冲区来说,它自身便是唯一的子资源,所以我们可以把这个参数设置为0;
  2. 第二个参数是一个可选项,用于指定内存的映射范围,如果该参数指定为空,则对整个资源进行映射;
  3. 返回待映射资源数据的目标内存块

当常量缓冲区更新完成之后,我们应该在释放映射内存之前对其进行Unmap(取消映射)操作。

if(mUploadBuffer != nullptr)
{
    mUploadBuffer->Unmap(0,nullptr);
}

为了使上传缓冲区的相关处理工作更加轻松,我们在UploadBuffer.h文件中定义了下面这个类,它会替我们实现上传缓冲区资源的构造和析构函数,处理资源的映射和取消映射操作,还提供了CopyData方法来更新缓冲区中的特定元素。(这个类不是仅仅针对常量缓冲区,也可以用来管理各种类型的上传缓冲区)。

template<typename T>
class UploadBuffer
{
public:
    UploadBuffer(ID3D12Device* device, UINT elementCount, bool isConstantBuffer) :mIsConStantBuffer(isConstantBuffer)
    {
        mElementByteSize = sizeof(T);

        //如果是常量缓冲区。将缓冲区的大小设置为硬件最小分配空间的整数倍
        if (isConstantBuffer)
        {
            mElementByteSize = d3dUtil::CalcConstantBufferByteSize(sizeof(T));
        }

        ThrowIfFailed(device->CreateCommittedResource(&CD3DX12_HEAP_PROPERTIES(D3D12_HEAP_TYPE_UPLOAD),
            D3D12_HEAP_FLAG_NONE, &CD3DX12_RESOURCE_DESC::Buffer(
                mElementByteSize*elementCount), D3D12_RESOURCE_STATE_GENERIC_READ,
                nullptr,
                IID_PPV_ARGS(&mUploadBuffer)
            ));
        ThrowIfFailed(mUploadBuffer->Map(0, nullptr, reinterpret_cast<void**>(&mMappedData)));
    }

    UploadBuffer(const UploadBuffer& rhs) = delete;
    UploadBuffer& operator=(const UploadBuffer& rhs) = delete;
    ~UploadBuffer()
    {
        if (mUploadBuffer != nullptr)
        {
            mUploadBuffer->Unmap(0, nullptr);
        }

        mMappedData = nullptr;
    }

    ID3D12Resource* Resource()const
    {
        return mUploadBuffer.Get();
    }

    void CopyData(int elemetnIndex, const T& data)
    {
        memcpy(&mMappedData[elemetnIndex*mElementByteSize], &data, sizeof(T));
    }

private:
    Microsoft::WRL::ComPtr<ID3D12Resource> mUploadBuffer;
    BYTE* mMappedData = nullptr;

    UINT mElementByteSize = 0;
    bool mIsConStantBuffer = false;
};

题外话:一般来说,物体的世界矩阵会根据移动/旋转/缩放而改变,观察矩阵会根据虚拟摄像机的移动/旋转而改变,投影矩阵会根据窗口大小的调整而改变。

到目前为止,我们已经介绍了渲染目标,深度/模板缓冲区,顶点缓冲区以及索引缓冲区这几种资源视图(描述符)的使用方法,接下来我们将介绍如何利用描述符将常量缓冲区绑定到渲染流水线中,

因为常量缓冲区需要使用D3D12_DESCRIPTOR_HEAP_CBV_SRV_UAV类型所创建的描述符堆,这种堆内可以存储常量缓冲区视图,着色器资源视图以及无序访问视图(unordered access),为了存放这些描述符,我们需要创建以下类型的描述符堆

D3D12_DESCRIPTOR_HEAP_DESC cbvHeapDesc;
cbvHeapDesc.NumDescriptors = 1;
cbvHeapDesc.Type = D3D12_DESCRIPTOR_HEAP_TYPE_CBV_SRV_UAV;
cbvHeapDesc.NodeMask = 0;
cbvHeapDesc.Flags = D3D12_DESCRIPTOR_HEAP_FLAG_SHADER_VISIBLE;

ComPtr<ID3D12DescriptorHeap> mCbvHeap;
md3dDevice->CreateDescriptorHeap(&cbvHeapDesc, IID_PPV_ARGS(&mCbvHeap));

然后通过填写D3D12_CONSTANT_BUFFER_VIEW_DESC实例,再调用ID3D12Device::CreateConstantBufferView方法便可以创建常量缓冲区视图:

//绘制物体所用对象的常量数据
struct ObjectConstant
{
    XMFLOAT4X4 WorldViewProj = MathHelper::Identity4x4();
};
//创建一个存储绘制n个物体所需常量数据的常量缓冲区
std::unique_ptr<UploadBuffer<ObjectConstant>> mObjectCB = nullptr;
mObjectCB = std::make_unique<UploadBuffer<ObjectConstant>>(md3dDevice.Get(), n, true);

UINT objCBByteSize = d3dUtil::CalcConstantBufferByteSize(sizeof(ObjectConstant));

//缓冲区的起始地址(索引为0的常量缓冲区地址)
D3D12_GPU_VIRTUAL_ADDRESS cbAddress = mObjectCB->Resource()->GetGPUVirtualAddress();

//偏移到常量缓冲区中绘制第i个物体所需要的常量数据
int boxCBufferIndex = i;
cbAddress += objCBByteSize * boxCBufferIndex;

//绑定到HLSL常量缓冲区结构体的常量缓冲区资源子集
D3D12_CONSTANT_BUFFER_VIEW_DESC cbvDesc;
cbvDesc.BufferLocation = cbAddress;
cbvDesc.SizeInBytes = d3dUtil::CalcConstantBufferByteSize(sizeof(ObjectConstant));
md3dDevice->CreateConstantBufferView(&cbvDesc, mCbvHeap->GetCPUDescriptorHandleForHeapStart());

在绘制调用开始之前,我们要把不同类型的资源绑定到特定的寄存器槽上,以供着色器程序访问。比如说,前文的顶点着色器和像素着色器需要的就是一个绑定到寄存器b0的常量缓冲区,在后续的章节中,我们会使用到这两种着色器更高级的配置方法,以使多个常量缓冲区、纹理和采样器都可以和各自的寄存器槽相互绑定

根签名:在执行绘制命令之前,根签名一定要为着色器提供其执行期间所需要绑定到渲染流水线的所有资源,在创建流水线状态对象(pipeline state object)时会对此进行验证,不同的绘制调用可能需要不同的着色器程序,这样意味着要使用不同的根签名。

在Direct3D中,根签名由ID3DRootSignature接口表示,并通过一组根参数(用以描述绘制调用过程中着色器所需的资源)定义而成,根参数可以是根常量、根描述符、或者描述符表。下面的代码将创建一个根签名,他的根参数为描述符表(描述符表是描述符堆一块连续区域):

CD3DX12_ROOT_PARAMETER slotRootParameter[1];

//创建一个只存有一个CBV的描述符表
CD3DX12_DESCRIPTOR_RANGE cbvTable;
cbvTable.Init(
    D3D12_DESCRIPTOR_RANGE_TYPE_CBV,
    1,          //表中描述符数量
    0           //这段描述符区域绑定的目标寄存器槽编号
);

slotRootParameter[0].InitAsDescriptorTable(1, &cbvTable);

//根签名由一组根参数组成
CD3DX12_ROOT_SIGNATURE_DESC rootSignatureDesc(1, slotRootParameter, 0, nullptr,
    D3D12_ROOT_SIGNATURE_FLAG_ALLOW_INPUT_ASSEMBLER_INPUT_LAYOUT);

//创建一个仅含有一个槽位的根签名
ComPtr<ID3D12RootSignature> mRootSignature = nullptr;
ComPtr<ID3DBlob> serializedRootSig = nullptr;
ComPtr<ID3DBlob> errorBlod = nullptr;
HRESULT hr = D3D12SerializeRootSignature(&rootSignatureDesc,
    D3D_ROOT_SIGNATURE_VERSION_1,
    serializedRootSig.GetAddressOf(),
    errorBlod.GetAddressOf());

ThrowIfFailed(md3dDevice->CreateRootSignature(
    0,
    serializedRootSig->GetBufferPointer(),
    serializedRootSig->GetBufferSize(),
    IID_PPV_ARGS(&mRootSignature)));

我们将在第七章对CD3DX12_ROOT_PARAMETER和CD3DX12_DESCRIPTOR_RANGE这两个结构体进行详细的说明,在这里只需要理解以下代码即可:

CD3DX12_ROOT_PARAMETER slotRootParameter[1];

//创建一个只存有一个CBV的描述符表
CD3DX12_DESCRIPTOR_RANGE cbvTable;
cbvTable.Init(
    D3D12·_DESCRIPTOR_RANGE_TYPE_CBV,
    1,          //表中描述符数量
    0           //这段描述符区域绑定的目标寄存器槽编号
);

slotRootParameter[0].InitAsDescriptorTable(1, &cbvTable);

这段代码创建了一个根参数,目的是将含有一个CBV的描述符表绑定到常量缓冲区寄存器0

根签名只定义了应用程序要绑定的渲染流水线的资源,不过没有真正执行任何资源绑定操作,只有率先通过命令列表设置好根签名,然后使用ID3D12GraphicsCommandList::SetGraphicRootDescriptorTable方法令描述符表和渲染流水线相互绑定

下列代码先将根签名和CBV设置到命令列表中,然后通过设置描述符表来指定我们希望绑定到渲染流水线的资源:

mCommandList->SetGraphicsRootSignature(mRootSignature.Get());
ID3D12DescriptorHeap* descriptorHeaps[] = { mCbvHeap.Get() };
mCommandList->SetDescriptorHeaps(_countof(descriptorHeaps), descriptorHeaps);

//偏移到此次绘制调用所需的CBV处
CD3DX12_GPU_DESCRIPTOR_HANDLE cbv(mCbvHeap->GetGPUDescriptorHandleForHeapStart());
cbv.Offset(cbvIndex, mCbvSruUavDescriptorSize);

mCommandList->SetGraphicsRootDescriptorTable(0, cbv);

在Direct3D中,着色器程序必须要先被编译为一种可移植的字节码,接下来,图形驱动程序将获取这些字节码,并将这些字节码重新编译为针对当前系统GPU所优化的本地指令,我们在运行期间可以使用以下函数对着色器程序进行编译:

HRESULT D3DCompileFormFile(
    LPCWSTR pFlieName,
    const D3D_SHADER_MACRO * pDefines,
    ID3DInclude * pInclude,
    LPCSTR pEntrypoint,
    LPCSTR pTarget,
    UINT Falgs1,
    UINT Flags2,
    ID3DBlob ** ppCode,
    ID3DBlob ** ppErrorMsgs
);

为了能够输出编译着色器的错误信息,我们在d3dUtil文件中实现了下列辅助函数在运行时编译着色器:

ComPtr<ID3DBlob> d3dUtil::CompileShader(
    const std::wstring& filename,
    const D3D_SHADER_MACRO* defines,
    const std::string& entrypoint,
    const std::string& target
)
{
    //如果处于调试状态,则使用调试标志
    UINT compileFalgs = 0;

#if defined(DEBUG) || defined(_DEBUG)
    compileFalgs = D3DCOMPILE_DEBUG | D3DCOMPILE_SKIP_OPTIMIZATION;
#endif
    HRESULT hr = S_OK;

    ComPtr<ID3DBlob> byteCode = nullptr;
    ComPtr<ID3DBlob> errors = nullptr;

    hr = D3DCompileFromFile(filename.c_str(), defines,
        D3D_COMPILE_STANDARD_FILE_INCLUDE,
        entrypoint.c_str(), target.c_str(), compileFalgs, 0, &byteCode, &errors);

    //将错误信息输出到调试窗口
    if (errors != nullptr)
    {
        OutputDebugStringA((char*)errors->GetBufferPointer());
    }

    ThrowIfFailed(hr);

    return byteCode;
}

以下是调用此函数的实例:

ComPtr<ID3DBlob> mvsByteCode = nullptr;
ComPtr<ID3DBlob> mpsByteCode = nullptr;

mvsByteCode = d3dUtil::CompileShader(L"Shaders\\color,hlsl", nullptr, "VS", "vs_5_1");
mpsByteCode = d3dUtil::CompileShader(L"Shadres\\color.hlsl", nullptr, "PS", "ps_5_1");

Visual Studio 2015 集成了一些对着色器程序进行编译工作的支持,我们可以向工程内添加hlsl文件,而Visual Studio会识别他们并提供编译的选项。但是,使用Visual Studio集成的HLSL工具有一个缺点,即它只允许每一个文件中只能用一个着色器程序。因此,这个限制将导致顶点着色器和像素着色器不能同时放置在一个文件中,否则必有一个不会被编译。

在DriectX3D12的渲染流水线中,大多阶段都是可以编程的,但是有些特定阶段只接受配置,比如用于配置渲染流水线中光栅化阶段的光栅器状态组则由结构体D3D12_RASTERIZER_DESC表示

typedef struct D3D12_RASTERIZER_DESC
{
    D3D12_FILL_MODE FillMode;       //默认值为:D3D12_FILL_SOLID
    D3D12_CULL_MODE CullMode;       //默认值为:D3D12_CULL_BACK
    BOOL FrontCounterClockwise;     //默认值为:false
    INT DepthBias;                  //默认值为:0
    FLOAT DepthBiasClamp;           //默认值为:0.0f
    FLOAT SlopeScaleDepthBias;      //默认值为:0.0f
    BOOL DepthClipEnable;           //默认值为:true
    BOOL MultisampleEnable;         //默认值为:false
    BOOL AntialiasedLineEnable;     //默认值为:false
    UINT ForcedSampleCount;         //默认值为:0
};

上面的结构体中大部分对我们而言都是不怎么使用的成员,这里主要介绍三个:

  1. FileMode:用于指定是使用实体模式渲染还是使用线框模式进行渲染
  2. CullMode:用于指定剔除模式,是使用背面剔除、正面剔除还是不剔除
  3. FrontCounterClockwise:如果指定为false,则根据摄像机的观察视角,将顶点顺序为顺时针的视为正面朝向,如果为true,则根据将顶点顺序为逆时针的视为正面朝向

下列代码展示如何创建一个开启线框模式而且禁用剔除操作的光栅器状态:

CD3DX12_RASTERIZER_DESC rsDesc(D3D12_DEFAULT);
rsDesc.FillMode = D3D12_FILL_MODE_WIREFRAME;
rsDesc.CullMode = D3D12_CULL_MODE_NONE;

CD3DX12_RASTERIZER_DESC是扩展自D3D12_RASTERIZER_DESC结构体的基础上又添加了一些辅助构造函数的工具类,

到目前为止,我们已经展示了编写输入布局描述,创建顶点着色器和像素着色器,以及配置光栅器状态组这3个步骤,但是我们还没有讲解如何将这些对象绑定到推向流水线上,用于绘制图形。流水线状态对象(Pipeline State Object,PSO)是控制大多数流水线状态对象的统称,ID3D12PipelineState接口表示,要创建PSO,首先我们要填写一份描述其中细节的D3D12_GRAPHICS_PIPELINE_STATE_DESC结构体实例:

typedef struct D3D12_GRAPHICS_PIPELINE_STATE_DESC {
    ID3D12RootSignature * pRootSignature;                   //指向一个与该PSO绑定的根签名的指针
    D3D12_SHADER_BYTECODE VS;                               //待绑定的顶点着色器
    D3D12_SHADER_BYTECODE PS;                               //待绑定的像素着色器
    D3D12_SHADER_BYTECODE DS;                               //待绑定的域着色器
    D3D12_SHADER_BYTECODE HS;                               //待绑定的外壳着色器
    D3D12_SHADER_BYTECODE GS;                               //待绑定的几何着色器
    D3D12_STREAM_OUTPUT_DESC StreamOutput;                  //用于实现一种称为流输出的高级技术
    D3D12_BLEND_DESC BlendState;                            //指定混合操作时所使用的混合状态
    UINT SmapleMask;                                        //设置每个采样点的采集情况(采集或者禁止采集)
    D3D12_RASTERIZER_DESC RasterizerState;                  //指定用来配置光栅器的光栅器状态
    D3D12_DEPTH_STENCIL_DESC DepthStencilState;             //指定用于配置深度/模板测试的深度/模板状态
    D3D12_INPUT_LAYOUT_DESC InputLayout;                    //输入布局描述
    D3D12_PRIMITIVE_TOPOLOGY_TYPE PrimitiveTopologyType;    //指定图元的拓扑类型
    UINT NumRenderTargets;                                  //同时所用的渲染目标数量
    DXGI_FORMAT RTVFormats[8];                              //渲染目标的格式
    DXGI_FORMAT DSVForamt;                                  //深度/模板缓冲区的格式
    DXGI_SAMPLE_DESC SmapleDesc;                            //描述多重采样对每一个像素的采样数量以及质量级别
}D3D12_GRAPHICS_PIPELINE_STATE_DESC;

在D3D12_GRAPHICS_PIPELINE_DESC实例填写完毕之后,我们便可以使用ID3D12Device::CreateGraphicsPipelineState方法来创建ID3D12PipelineState对象:

D3D12_GRAPHICS_PIPELINE_STATE_DESC PSODesc;
ZeroMemory(&PSODesc, sizeof(D3D12_GRAPHICS_PIPELINE_STATE_DESC));
PSODesc.InputLayout = { mInputLayout.data(),mInputLayout.size() };
PSODesc.pRootSignature = mRootSignature.Get();
PSODesc.VS =
{
    reinterpret_cast<BYTE*>(mvsByteCode->GetBufferPointer()),
    mvsByteCode->GetBufferSize()
};
PSODesc.PS = {
    reinterpret_cast<BYTE*>(mpsByteCode->GetBufferPointer()),
    mpsByteCode->GetBufferSize()
};
PSODesc.RasterizerState = CD3DX12_RASTERIZER_DESC(D3D12_DEFAULT);
PSODesc.BlendState = CD3DX12_BLEND_DESC(D3D12_DEFAULT);
PSODesc.DepthStencilState = CD3DX12_DEPTH_STENCIL_DESC(D3D12_DEFAULT);
PSODesc.SampleMask = UINT_MAX;
PSODesc.PrimitiveTopologyType = D3D12_PRIMITIVE_TOPOLOGY_TYPE_TRIANGLE;
PSODesc.RTVFormats[0] = mBackBufferFormat;
PSODesc.SampleDesc.Count = m4xMsaaState ? 4 : 1;
PSODesc.SampleDesc.Quality = m4xMsaaState ? (m4xMsaaQuality - 1) : 0;
PSODesc.DSVFormat = mDepthStencilFormat;

ComPtr<ID3D12PipelineState> mPSO;
md3dDevice->CreateComputePipelineState(&PSODesc, IID_PPV_ARGS(&mPSO));

并非所有的渲染状态都封装在PSO内,比如视口和裁剪矩形等属性就独立于PSO。Direct3D实质上就是一种状态机,里面的事物会保持它们各自的状态,直到我们将他们改变。

一般来说,我们都会通过创建一个同时存有顶点缓冲区和索引缓冲区的结构体来方便的定义多个结构体,当需要定义多个结构体时,我们就可以使用定义在d3dUtil文件中的MeshGeometry结构体:

//先利用SubMeshGeometry来定义MeshGeometry中存储的单个结合体
//此结构体适用于将多个几何体数据存于一个顶点缓冲区和一个索引缓冲区的情况
struct SubmeshGeometry
{
    UINT IndexCount = 0;
    UINT StartIndexLocaltion = 0;
    INT BaseVertexLoaction = 0;

    //通过此子网格来定义当前SubmeshGeometry结构体中所存结合体的包围盒(bounding box)
    DirectX::BoundingBox Bounds;
};

struct MeshGeometry
{
    //指定此几何体网格集合的名称,这样我们就能根据名称找到它
    std::string Name;

    //系统内存的副本,由于顶点/索引可以是泛型格式,所以用Blod类型表示
    //待用户使用时再将他转换为适当的类型
    Microsoft::WRL::ComPtr<ID3DBlob> VertexBufferCPU = nullptr;
    Microsoft::WRL::ComPtr<ID3DBlob> IndexBufferCPU = nullptr;

    Microsoft::WRL::ComPtr<ID3D12Resource> VertexBufferGPU = nullptr;
    Microsoft::WRL::ComPtr<ID3D12Resource> IndexBUfferGPU = nullptr;

    Microsoft::WRL::ComPtr<ID3D12Resource> VertexBufferUploader = nullptr;
    Microsoft::WRL::ComPtr<ID3D12Resource> IndexBufferUploader = nullptr;

    //与缓冲区相关的数据
    UINT VertexByteStride = 0;
    UINT VertexBufferByteSize = 0;
    DXGI_FORMAT IndexForamt = DXGI_FORMAT_R16_UINT;
    UINT IndexBufferByteSize = 0;

    //一个MeshGeometry结构体能够存储一组顶点/索引缓冲区的多个几何体
    //若利用下列容器阿里定义子网格几何体,我们就能单独地绘制出其中的几何体
    std::unordered_map<std::string, SubmeshGeometry> DrawArgs;

    //返回顶点缓冲区视图的方法
    D3D12_VERTEX_BUFFER_VIEW VertexBufferView()const
    {
        D3D12_VERTEX_BUFFER_VIEW vbv;
        vbv.BufferLocation = VertexBufferGPU->GetGPUVirtualAddress();
        vbv.StrideInBytes = VertexByteStride;

        return vbv;
    }

    //返回索引缓冲区视图的方法
    D3D12_INDEX_BUFFER_VIEW IndexBufferView()const
    {
        D3D12_INDEX_BUFFER_VIEW ibv;
        ibv.BufferLocation = IndexBUfferGPU->GetGPUVirtualAddress();
        ibv.Format = IndexForamt;
        ibv.SizeInBytes = IndexBufferByteSize;

        return ibv;
    }

    //待数据上传到GPU后,我们就可以释放这些内存了
    void DisposeUploaders()
    {
        VertexBufferUploader = nullptr;
        IndexBufferUploader = nullptr;
    }
};

手机扫一扫

移动阅读更方便

阿里云服务器
腾讯云服务器
七牛云服务器

你可能感兴趣的文章