1. 创建常量缓冲区
常量缓冲区 (constant buffer) 也是一种 GPU 资源 (ID3D12Resource),其数据内容可供着色器程序所引用。顶点着色器实例中:
cpp
cbuffer cbPerObject : register(b0)
{
float4x4 gWorldViewProj;
};
在这段代码中,cbuffer 对象 (常量缓冲区) 的名称为 cbPerObject,其中存储的是一个 4*4 矩阵 gWorldViewProj,表示把一个点从局部空间变换到齐次裁剪空间所用到的由世界、视图和投影 3 种变换组合而成的矩阵。
与顶点缓冲区和索引缓冲区不同的是,常量缓冲区通常由 CPU 每帧更新一次。举个例子,如果摄像机每帧都在不停地移动,那么常量缓冲区也需要在每一帧都随之以新的视图矩阵而更新。所以,我们会把常量缓冲区创建到一个上传堆而非默认堆中,这样做能使我们从 CPU 端更新常量。
常量缓冲区对硬件也有特别的要求,即常量缓冲区的大小必为硬件最小分配空间(256B)的整数倍。
我们经常需要用到多个相同类型的常量缓冲区。下列代码展示了如何创建一个缓冲区资源,并利用它来存储 NumElements 个常量缓冲区。
cpp
struct ObjectConstants
{
DirectX::XMFLOAT4X4 WorldViewProj = MathHelper::Identity4x4();
};
UINT mElementByteSize = d3dUtil::CalcConstantBufferByteSize(sizeof(ObjectConstants));
ComPtr<ID3D12Resource> mUploadCBuffer;
device->CreateCommittedResource(
&CD3DX12_HEAP_PROPERTIES(D3D12_HEAP_TYPE_UPLOAD),
D3D12_HEAP_FLAG_NONE,
&CD3DX12_RESOURCE_DESC::Buffer(mElementByteSize * NumElements),
D3D12_RESOURCE_STATE_GENERIC_READ,
nullptr,
IID_PPV_ARGS(&mUploadCBuffer));
我们可以认为 mUploadCBuffer 中存储了一个 ObjectConstants 类型的常量缓冲区数组(同时按 256 字节的整数倍来为之填充数据)。待到绘制物体的时候,只要将常量缓冲区视图(Constant Buffer View,CBV)绑定到存有物体相应常量数据的缓冲区子区域即可。由于 mUploadCBuffer 缓冲区存储的是一个常量缓冲区数组,因此,我们把它称之为常量缓冲区。
工具函数 d3dUtil::CalcConstantBufferByteSize 会做适当的运算,使缓冲区的大小凑整为硬件最小分配空间(256B)的整数倍。
cpp
UINT d3dUtil::CalcConstantBufferByteSize(UINT byteSize)
{
// 常量缓冲区的大小必须是硬件最小分配空间(通常是256B)的整数倍
// 为此,要将其凑整为满足需求的最小的256的整数倍。我们现在通过为输入值byteSize加上255,
// 再屏蔽求和结果的低2字节(即计算结果中小于256的数据部分)来实现这一点
// 例如:假设byteSize = 300
// (300 + 255) & ~255
// 555 & ~255
// 0x022B & ~0x00ff
// 0x022B & 0xff00
// 0x0200
// 512
return (byteSize + 255) & ~255;
}
尽管我们已经按照上述方式在程序中分配出了 256 整数倍字节大小的数据空间,但是却无须为 HLSL 结构体中显式填充相应的常量数据,这是因为它会暗中自行完成这项工作:
cpp
// 隐式填充为 256B
cbuffer cbPerObject : register(b0)
{
float4x4 gWorldViewProj;
};
// 显式填充为 256B
cbuffer cbPerObject : register(b0)
{
float4x4 gWorldViewProj;
float4x4 Pad0;
float4x4 Pad1;
float4x4 Pad2;
};
为了免去系统将常量缓冲区元素隐式凑整为 256 字节整数倍的这项处理环节,我们可以手动地填充所有的常量缓冲区结构体,使之皆为 256 字节的整数倍。
随 Direct3D 12 一同推出的是着色器模型 (shader model,SM) 5.1。其中新引进了一条可用于定义常量缓冲区的 HLSL 语法,使用方法如下:
cpp
struct ObjectConstants
{
float4x4 gWorldViewProj;
uint matIndex;
};
ConstantBuffer<ObjectConstants> gObjConstants : register(b0);
在此段代码中,常量缓冲区的数据元素被定义在一个单独的结构体中,随后再用此结构体来创建一个常量缓冲区。这样一来,我们就可以利用下列获取数据成员的语法,在着色器里访问常量缓冲区中的各个字段:
cpp
uint index = gObjConstants.matIndex;
2. 更新常量缓冲区
由于常量缓冲区是用 D3D12_HEAP_TYPE_UPLOAD 这种堆类型来创建的,所以我们就能通过 CPU 为常量缓冲区资源更新数据。为此,我们首先要获得指向欲更新资源数据的指针,可用 Map 方法来做到这一点:
cpp
ComPtr<ID3D12Resource> mUploadBuffer;
BYTE* mMappedData = nullptr;
mUploadBuffer->Map(0, nullptr, reinterpret_cast<void**>(&mMappedData));
第一个参数是子资源 (subresource) 的索引 ,指定了欲映射的子资源。对于缓冲区来说,它自身就是唯一的子资源,所以我们将此参数设置为 0。第二个参数是一个可选项 ,是个指向 D3D12_RANGE 结构体的指针,此结构体描述了内存的映射范围,若将该参数指定为空指针,则对整个资源进行映射。第三个参数则借助双重指针,返回待映射资源数据的目标内存块。我们利用 memcpy 函数将数据从系统内存 (system memory,也就是 CPU 端控制的内存) 复制到常量缓冲区:
cpp
memcpy(mMappedData, &data, dataSizeInBytes);
当常量缓冲区更新完成后,我们应在释放映射内存之前对其进行 Unmap (取消映射) 操作:
cpp
if(mUploadBuffer != nullptr)
mUploadBuffer->Unmap(0, nullptr);
mMappedData = nullptr;
Unmap 的第一个参数是子资源索引,指定了将被取消映射的子资源。若取消映射的是缓冲区,则将其置为0。第二个参数是个可选项,是一个指向 D3D12_RANGE 结构体的指针,用于描述取消映射的内存范围,若将它指定为空指针,则取消整个资源的映射。
3. 上传缓冲区辅助函数
将上传缓冲区的相关操作简单地封装一下,使用起来会更加方便。在 UploadBuffer.h 文件中定义了下面这个类,令上传缓冲区的相关处理工作更加轻松。它替我们实现了上传缓冲区资源的构造与析构函数、处理资源的映射和取消映射操作,还提供了 CopyData 方法来更新缓冲区内的特点元素。在需要通过 CPU 修改上传缓冲区中数据的时候(例如,当观察矩阵有了变化),便可以使用 CopyData。注意,此类可用于各种类型的上传缓冲区,而并非只针对常量缓冲区。当用此类管理常量缓冲区时,我们就需要通过构造函数参数 isConstantBuffer 来对此加以描述。另外,如果此类中存储的是常量缓冲区,那么其中的构造函数将自动填充内存,使每个常量缓冲区的大小都成为 256B 的整数倍。
cpp
// UploadBuffer.h
template<typename T>
class UploadBuffer
{
public:
UploadBuffer(ID3D12Device* device, UINT elementCount, bool isConstantBuffer) :
mIsConstantBuffer(isConstantBuffer)
{
mElementByteSize = sizeof(T);
// 常量缓冲区的大小为 256B 的整数倍。
// 这是因为硬件只能按 m*256B 的偏移量和 n*256B 的数据长度
// 这两种规格来查看常量数据
// typedef struct D3D12_CONSTANT_BUFFER_VIEW_DESC {
// UINT64 OffsetInBytes; // 256 的整数倍
// UINT SizeInBytes; // 256 的整数倍
// } D3D12_CONSTANT_BUFFER_VIEW_DESC;
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)));
// 只要还会修改当前的资源,我们就无须取消映射
// 但是,在资源被 GPU 使用期间,我们千万不可向该资源进行写操作
// (所以必须借助于同步技术)
}
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 elementIndex, const T& data)
{
memcpy(&mMappedData[elementIndex*mElementByteSize], &data, sizeof(T));
}
private:
Microsoft::WRL::ComPtr<ID3D12Resource> mUploadBuffer;
BYTE* mMappedData = nullptr;
UINT mElementByteSize = 0;
bool mIsConstantBuffer = false;
};
一般来说,物体的世界矩阵将随其移动/旋转/缩放而改变,观察矩阵随虚拟摄像机的移动/旋转而改变,投影矩阵随窗口大小的调整而改变。用户可以通过鼠标来旋转和移动摄像机,变换观察角度。因此,我们在每一帧都要用 Update 函数,以新的观察矩阵来更新"世界---观察---投影" 3 种矩阵组合而成的复合矩阵:
cpp
void BoxApp::OnMouseMove(WPARAM btnState, int x, int y)
{
if((btnState & MK_LBUTTON) != 0)
{
// 根据鼠标的移动距离计算旋转角度,令每个像素按此角度的1/4进行旋转
float dx = XMConvertToRadians(0.25f*static_cast<float>
(x - mLastMousePos.x));
float dy = XMConvertToRadians(0.25f*static_cast<float>
(y - mLastMousePos.y));
// 根据鼠标的输入来更新摄像机绕立方体旋转的角度
mTheta += dx;
mPhi += dy;
// 限制角度mPhi的范围
mPhi = MathHelper::Clamp(mPhi, 0.1f, MathHelper::Pi - 0.1f);
}
else if((btnState & MK_RBUTTON) != 0)
{
// 使场景中的每个像素按鼠标移动距离的0.005倍进行缩放
float dx = 0.005f*static_cast<float>(x - mLastMousePos.x);
float dy = 0.005f*static_cast<float>(y - mLastMousePos.y);
// 根据鼠标的输入更新摄像机的可视范围半径
mRadius += dx - dy;
// 限制可视半径的范围
mRadius = MathHelper::Clamp(mRadius, 3.0f, 15.0f);
}
mLastMousePos.x = x;
mLastMousePos.y = y;
}
void BoxApp::Update(const GameTimer& gt)
{
// 由球坐标(也有译作球面坐标)转换为笛卡儿坐标
float x = mRadius*sinf(mPhi)*cosf(mTheta);
float z = mRadius*sinf(mPhi)*sinf(mTheta);
float y = mRadius*cosf(mPhi);
// 构建观察矩阵
XMVECTOR pos = XMVectorSet(x, y, z, 1.0f);
XMVECTOR target = XMVectorZero();
XMVECTOR up = XMVectorSet(0.0f, 1.0f, 0.0f, 0.0f);
XMMATRIX view = XMMatrixLookAtLH(pos, target, up);
XMStoreFloat4x4(&mView, view);
XMMATRIX world = XMLoadFloat4x4(&mWorld);
XMMATRIX proj = XMLoadFloat4x4(&mProj);
XMMATRIX worldViewProj = world*view*proj;
// 用最新的worldViewProj 矩阵来更新常量缓冲区
ObjectConstants objConstants;
XMStoreFloat4x4(&objConstants.WorldViewProj,
XMMatrixTranspose(worldViewProj));
mObjectCB->CopyData(0, objConstants);
}
4. 常量缓冲区描述符
现在还需要利用描述符将常量缓冲区绑定至渲染流水线上。而且常量缓冲区描述符都要存放在以 D3D12_DESCRIPTOR_HEAP_TYPE_CBV_SRV_UAV 类型所建的描述符堆里。这种堆内可以混合存储常量缓冲区描述符、着色器资源描述符和无序访问 (unordered access) 描述符。为了存放这些新类型的描述符,我们需要为之创建以下类型的新式描述符堆:
cpp
D3D12_DESCRIPTOR_HEAP_DESC cbvHeapDesc;
cbvHeapDesc.NumDescriptors = 1;
cbvHeapDesc.Type = D3D12_DESCRIPTOR_HEAP_TYPE_CBV_SRV_UAV;
cbvHeapDesc.Flags = D3D12_DESCRIPTOR_HEAP_FLAG_SHADER_VISIBLE;
cbvHeapDesc.NodeMask = 0;
ComPtr<ID3D12DescriptorHeap> mCbvHeap
md3dDevice->CreateDescriptorHeap(&cbvHeapDesc,
IID_PPV_ARGS(&mCbvHeap));
这段代码与我们之前创建渲染目标和深度/模板缓冲区这两种资源描述符堆的过程很相似。然而,其中却有着一个重要的区别,那就是在创建供着色器程序访问资源的描述符时,我们要把标志 Flags 指定为 DESCRIPTOR_HEAP_FLAG_SHADER_VISIBLE。在本章的示范程序中,我们并没有使用 SRV (shader resource view,着色器资源视图) 描述符或 UAV (unordered access view,无序访问视图) 描述符,仅是绘制了一个物体而已,因此只需创建一个存有单个 CBV 描述符的堆即可。
通过填写 D3D12_CONSTANT_VIEW_DESC 实例,再调用 ID3D12Device::CreateConstantBufferView 方法,便可创建常量缓冲区:
cpp
// 绘制物体所用的常量数据
struct ObjectConstants
{
XMFLOAT4X4 WorldViewProj = MathHelper::Identity4x4();
};
// 此常量缓冲区存储了绘制n个物体所需的常量数据
std::unique_ptr<UploadBuffer<ObjectConstants>> mObjectCB = nullptr;
mObjectCB = std::make_unique<UploadBuffer<ObjectConstants>>(
md3dDevice.Get(), n, true);
UINT objCBByteSize = d3dUtil::CalcConstantBufferByteSize(sizeof(ObjectConstants));
// 缓冲区的起始地址(即索引为0的那个常量缓冲区的地址)
D3D12_GPU_VIRTUAL_ADDRESS cbAddress = mObjectCB->Resource()-
>GetGPUVirtualAddress();
// 偏移到常量缓冲区中绘制第i个物体所需的常量数据
int boxCBufIndex = i;
cbAddress += boxCBufIndex*objCBByteSize;
D3D12_CONSTANT_BUFFER_VIEW_DESC cbvDesc;
cbvDesc.BufferLocation = cbAddress;
cbvDesc.SizeInBytes = d3dUtil::CalcConstantBufferByteSize(sizeof(
ObjectConstants));
//创建常量缓冲区
md3dDevice->CreateConstantBufferView(
&cbvDesc,
mCbvHeap->GetCPUDescriptorHandleForHeapStart());
结构体 D3D12_CONSTANT_BUFFER_VIEW_DESC 描述的是绑定到 HLSL 常量缓冲区结构体的常量缓冲区资源子集。正如前面所提到的,如果常量缓冲区存储了一个内有 n 个物体常量数据的常量数组,那么我们就可以通过 BufferLocation 和 SizeInBytes 参数来获取第 i 个物体的常量数据。考虑到硬件的需求(即硬件的最小分配空间),成员 SizeInBytes 与 BufferLocation 必须为 256B 的整数倍。例如,若将上述两个成员的值都指定为 64,那么我们将看到下列调试错误:
cpp
D3D12 ERROR: ID3D12Device::CreateConstantBufferView: SizeInBytes of 64 is invalid. Device requires SizeInBytes be a multiple of 256.
D3D12 ERROR: ID3D12Device:: CreateConstantBufferView: BufferLocation of 64 is invalid. Device requires BufferLocation be a multiple of 256.
5. 跟签名和描述符表
通常来讲,在绘制调用开始执行之前,我们应将不同的着色器程序所需的各种类型的资源绑定到渲染流水线上。事实上,不同类型的资源会被绑定到特定的寄存器槽(register slot) 上,以供着色器程序访问。
寄存器槽就是向着色器传递资源的手段,register(*#)中*表示寄存器传递的资源类型,可以是t(表示着色器资源视图)、s(采样器)、u(无序访问视图)以及b(常量缓冲区视图),#则为所用的寄存器编号。
比如说,前文代码中的顶点着色器和像素着色器需要的是一个绑定到寄存器 b0 的常量缓冲区。在后续内容中,我们会用到这两种着色器更高级的配置方法,以便多个常量缓冲区、纹理和采样器都能与各自的寄存器槽相绑定:
cpp
// 将纹理资源绑定到纹理寄存器槽0
Texture2D gDiffuseMap : register(t0);
// 把下列采样器资源依次绑定到采样器寄存器槽0~5
SamplerState gsamPointWrap : register(s0);
SamplerState gsamPointClamp : register(s1);
SamplerState gsamLinearWrap : register(s2);
SamplerState gsamLinearClamp : register(s3);
SamplerState gsamAnisotropicWrap : register(s4);
SamplerState gsamAnisotropicClamp : register(s5);
// 将常量缓冲区资源(cbuffer)绑定到常量缓冲区寄存器槽0
cbuffer cbPerObject : register(b0)
{
float4x4 gWorld;
float4x4 gTexTransform;
};
// 绘制过程中所用的杂项常量数据
cbuffer cbPass : register(b1)
{
float4x4 gView;
float4x4 gProj;
[...] // 为篇幅而省略的其他字段
};
// 绘制每种材质所需的各种不同的常量数据
cbuffer cbMaterial : register(b2)
{
float4 gDiffuseAlbedo;
float3 gFresnelR0;
float gRoughness;
float4x4 gMatTransform;
};
根签名(root signature):在执行绘制命令之前,那些应用程序将绑定到渲染流水线上的资源,它们会被映射到着色器的对应输入寄存器。根签名一定要与使用它的着色器相兼容(即在绘制开始之前,根签名一定要为着色器提供其执行期间需要绑定到渲染流水线的所有资源),在创建流水线状态对象 (pipeline state object) 时会对此进行验证。不同的绘制调用可能会用到一组不同的着色器程序,这也就意味着要用到不同的根签名。
如果我们把着色器程序当作一个函数,而将输入资源看作着色器的函数参数,那么根签名则定义了函数签名。通过绑定不同的资源作为参数,着色器的输出也将有所差别。
在 Direct3D 中,根签名由 ID3D12RootSignature 接口来表示,并以一组描述绘制调用过程中着色器所需资源的根参数 (root parameter) 定义而成。根参数可以是根常量 (root constant)、根描述符 (root descriptor) 或者描述符表 (descriptor table)。我们在本章中仅使用描述符表。描述符表指定的是描述符堆中存有描述符的一块连续区域。
下面的代码创建了一个根签名,它的根参数为一个描述符表,其大小足以容下一个 CBV (常量缓冲区视图,constant buffer view)。
cpp
// 根参数可以是描述符表、根描述符或根常量
CD3DX12_ROOT_PARAMETER slotRootParameter[1];
// 创建一个只存有一个CBV的描述符表
CD3DX12_DESCRIPTOR_RANGE cbvTable;
cbvTable.Init(
D3D12_DESCRIPTOR_RANGE_TYPE_CBV,
1, // 表中的描述符数量
0);// 将这段描述符区域绑定至此基准着色器寄存器(base shader register)
slotRootParameter[0].InitAsDescriptorTable(
1, // 描述符区域的数量
&cbvTable); // 指向描述符区域数组的指针
// 根签名由一组根参数构成
CD3DX12_ROOT_SIGNATURE_DESC rootSigDesc(1, slotRootParameter, 0, nullptr,
D3D12_ROOT_SIGNATURE_FLAG_ALLOW_INPUT_ASSEMBLER_INPUT_LAYOUT);
// 创建仅含一个槽位(该槽位指向一个仅由单个常量缓冲区组成的描述符区域)的根签名
ComPtr serializedRootSig = nullptr;
ComPtr errorBlob = nullptr;
HRESULT hr = D3D12SerializeRootSignature(&rootSigDesc,
D3D_ROOT_SIGNATURE_VERSION_1,
serializedRootSig.GetAddressOf(),
errorBlob.GetAddressOf());[15]
ThrowIfFailed(md3dDevice->CreateRootSignature(
0,
serializedRootSig->GetBufferPointer(),
serializedRootSig->GetBufferSize(),
IID_PPV_ARGS(&mRootSignature)));
下面代码创建了一个根参数,目的是将含有一个 CBV 的描述符表绑定到常量缓冲区寄存器 0,即 HLSL 代码中的 register(b0)。
cpp
CD3DX12_ROOT_PARAMETER slotRootParameter[1];
CD3DX12_DESCRIPTOR_RANGE cbvTable;
cbvTable.Init(
D3D12_DESCRIPTOR_RANGE_TYPE_CBV, // 描述符表的类型
1, // 表中描述符的数量
0);// 将这段描述符区域绑定至此基址着色器寄存器
slotRootParameter[0].InitAsDescriptorTable(
1, // 描述符区域的数量
&cbvTable); // 指向描述符区域数组的指针
根签名只定义了应用程序要绑定到渲染流水线的资源,却没有真正地执行任何资源绑定操作。只要率先通过命令列表 (command list) 设置好根签名,我们就能用 ID3D12GraphicsCommandList::SetGraphicsRootDescriptorTable 方法令描述符表与渲染流水线相绑定。
cpp
void ID3D12GraphicsCommandList::SetGraphicsRootDescriptorTable(
UINT RootParameterIndex,
D3D12_GPU_DESCRIPTOR_HANDLE BaseDescriptor);
-
RootParameterIndex:将根参数按此索引(即欲绑定到的寄存器槽号)进行设置。
-
BaseDescriptor:此参数指定的是将要向着色器绑定的描述符表中第一个描述符位于描述符堆中的句柄。比如说,如果根签名指明当前描述符表中共有 5 个描述符,则堆中的 BaseDescriptor 及其后面的 4 个描述符将被设置到此描述符表中。
下列代码先将根签名和 CBV 堆设置到命令列表上,并随后再通过设置描述符表来指定我们希望绑定到渲染流水线的资源:
cpp
mCommandList->SetGraphicsRootSignature(mRootSignature.Get());
ID3D12DescriptorHeap* descriptorHeaps[] = { mCbvHeap.Get() };
mCommandList->SetDescriptorHeaps(_countof(descriptorHeaps),
descriptorHeaps);
// 偏移到此次绘制调用所需的CBV处
CD3DX12_GPU_DESCRIPTOR_HANDLE cbv(mCbvHeap
->GetGPUDescriptorHandleForHeapStart());
cbv.Offset(cbvIndex, mCbvSrvUavDescriptorSize);
mCommandList->SetGraphicsRootDescriptorTable(0, cbv);
出于性能的原因,我们应当使根签名的规模尽可能地小。除此之外,还要试着尽量减少每帧渲染过程中根签名的修改次数。
每当在(图形)绘制调用或(计算)调度 (dispatch,也有译作分派) 调用(此"调度调用"指调度计算着色器进行 GPU 通用计算)之间有根签名的内容(即描述符表、根常量以及根描述符)发生改变时,通过 D3D12 的驱动程序便会将与应用程序相绑定的根签名内容自动更新为最新的消息。因此,在每次绘制/调度调用时都会产生一整套独立的根签名状态。
如果更改了根签名,则会失去现存的所有绑定关系。也就是说,在修改了根签名后,我们需要按新的根签名定义重新将所有的对应资源绑定到渲染流水线上。