首先我们回顾下,在第一篇初始化基础模块中,我们实现了FlushCmdQueue函数。它的作用是保证CPU和GPU的同步。实现的大致思路是:创建一个CPU端围栏值mCurrentFence=0,再创建一个GPU端围栏值Fence=0,每当CPU将当前帧的绘制命令提交至GPU后,mCurrentFence++,而每当GPU处理完当前帧的命令后,Fence++,通过我们判定这两个围栏值的大小,来判断GPU是否已经处理完命令,只有处理完后,CPU才能继续下一帧的命令提交工作。代码如下:
void FlushCmdQueue()
{
int mCurrentFence = 0; //初始CPU上的围栏点为0
mCurrentFence++; //CPU传完命令并关闭后,将当前围栏值+1
cmdQueue->Signal(fence.Get(), mCurrentFence); //当GPU处理完CPU传入的命令后,将fence接口中的围栏值+1,即fence->GetCompletedValue()+1
if (fence->GetCompletedValue() < mCurrentFence) //如果小于,说明GPU没有处理完所有命令
{
HANDLE eventHandle = CreateEvent(nullptr, false, false, L"FenceSetDone"); //创建事件
fence->SetEventOnCompletion(mCurrentFence, eventHandle);//当围栏达到mCurrentFence值(即执行到Signal()指令修改了围栏值)时触发的eventHandle事件
WaitForSingleObject(eventHandle, INFINITE);//等待GPU命中围栏,激发事件(阻塞当前线程直到事件触发,注意此Enent需先设置再等待,
//如果没有Set就Wait,就死锁了,Set永远不会调用,所以也就没线程可以唤醒这个线程)
CloseHandle(eventHandle);
}
}
但是,这样的同步操作虽然简单易行,弊端也很明显,那就是CPU和GPU的互相等待时间较长,整体效率偏低。于是我们改进一下同步机制,让CPU连续处理3帧的绘制命令,并提交至GPU,当GPU处理完一帧命令后,CPU迅速提交命令,始终保证有3帧的命令储配。这样的话,如果CPU提交命令的速度高于GPU处理命令的速度,那GPU就会一直处于忙碌的状态,而空闲的CPU可以处理游戏逻辑、AI、物理模拟等等的计算。我们将每一帧提交的命令称为帧资源,而3帧的命令储备相当于一个3个帧资源元素的环形数组,随着GPU的进程,CPU不断更新环形数组中的数据元素。
1.创建帧资源
首先我们新建个文件,并创建FrameResources类,这个类中包括每帧绘制所需要的数据,它们是:每帧独立的命令分配器、每帧独立的常量缓冲区、每帧的围栏值。顺带的,我们把每帧需要的顶点索引以及常量数据结构体也一并移过来。
#pragma once
#include "../../common/ToolFunc.h"
#include "../../common//UploadBufferResource.h"
using namespace DirectX::PackedVector;
using namespace DirectX;
//定义顶点结构体
struct Vertex
{
XMFLOAT3 Pos;
XMCOLOR Color;
};
//单个物体的物体常量数据(不变的)
struct ObjectConstants
{
XMFLOAT4X4 world = MathHelper::Identity4x4();
};
//单个物体的过程常量数据(每帧变化)
struct PassConstants
{
XMFLOAT4X4 viewProj = MathHelper::Identity4x4();
};
struct FrameResources
{
public:
FrameResources(ID3D12Device* device, UINT passCount, UINT objCount);
FrameResources(const FrameResources& rhs) = delete;
FrameResources& operator = (const FrameResources& rhs) = delete;
~FrameResources();
//每帧资源都需要独立的命令分配器
ComPtr<ID3D12CommandAllocator> cmdAllocator;
//每帧都需要单独的资源缓冲区(此案例仅为2个常量缓冲区)
std::unique_ptr<UploadBufferResource<ObjectConstants>> objCB = nullptr;
std::unique_ptr<UploadBufferResource<PassConstants>> passCB = nullptr;
//CPU端的围栏值
UINT64 fenceCPU = 0;
};
接着在.cpp文件中赋值类中变量。为什么每个帧资源需要单独创建一个commandAllocator呢?因为在绘制开始时,我们需要重置commandAllocator,为了保证其他两帧的绘制命令不被提前释放,所以我们每个帧资源都设置一个commandAllocator。
#include"FrameResources.h"
FrameResources::FrameResources(ID3D12Device* device, UINT passCount, UINT objCount)
{
ThrowIfFailed(device->CreateCommandAllocator(
D3D12_COMMAND_LIST_TYPE_DIRECT,
IID_PPV_ARGS(&cmdAllocator)));
objCB = std::make_unique<UploadBufferResource<ObjectConstants>>(device, objCount, true);
passCB = std::make_unique<UploadBufferResource<PassConstants>>(device, passCount, true);
}
FrameResources::~FrameResources(){}
然后我们在主文件中,填充帧资源数组。
int frameResourcesCount = 3;
void ShapesApp::BuildFrameResources()
{
for (int i = 0; i < frameResourcesCount; i++)
{
FrameResourcesArray.push_back(std::make_unique<FrameResources>(
d3dDevice.Get(),
1, //passCount
(UINT)allRitems.size())); //objCount
}
}
2.同步CPU和GPU
同步原理开篇已经叙述,我们只需在Update函数中检查GPU是否命中围栏点,如果命中则GPU空闲,如果没有命中,则令CPU等待。可以看到,一开始我们就利用取模运算,循环遍历环形数组中的三个元素。
//每帧遍历一个帧资源(多帧的话就是环形遍历)
currFrameResourcesIndex = (currFrameResourcesIndex + 1) % frameResourcesCount;
currFrameResources = FrameResourcesArray[currFrameResourcesIndex].get();
//如果GPU端围栏值小于CPU端围栏值,即CPU速度快于GPU,则令CPU等待
if (currFrameResources->fenceCPU != 0 && fence->GetCompletedValue() < currFrameResources->fenceCPU)
{
HANDLE eventHandle = CreateEvent(nullptr, false, false, L"FenceSetDone");
ThrowIfFailed(fence->SetEventOnCompletion(currFrameResources->fenceCPU, eventHandle));
WaitForSingleObject(eventHandle, INFINITE);
CloseHandle(eventHandle);
}
以上代码判定的依据是什么?自然是fence了。我们在Draw函数里提交完命令后,需要将CPU端的fence++,并且当GPU处理完命令后将GPU端的fence++。这样,我们就可以将FlushCmdQueue函数去掉了。
void ShapesApp::Draw()
{
......
UINT currentFence = 0;
currFrameResources->fenceCPU = ++currentFence;
cmdQueue->Signal(fence.Get(), currentFence);
}
3.传输数据
接着我们将objConstants传至GPU,可以看到,当传完3个帧资源的数据后,numFramesDirty=0,就不会继续传值了。这样就保证了数据及时更新到帧资源中。
ObjectConstants objConstants;
PassConstants passConstants;
for (auto& e : allRitems)
{
if (e->numFramesDirty > 0)
{
world = e->world;
XMMATRIX w = XMLoadFloat4x4(&world);
//XMMATRIX赋值给XMFLOAT4X4
XMStoreFloat4x4(&objConstants.world, XMMatrixTranspose(w));
//将数据拷贝至GPU缓存
currFrameResources->objCB->CopyData(e->objCBIndex, objConstants);
e->numFramesDirty--;
}
}
然后将passConstants也传至GPU,这里的代码跟之前并没有变化,因为在一个帧资源中,passConstants只有一个。
float x = radius * sinf(phi) * cosf(theta);
float y = radius * cosf(phi);
float z = radius * sinf(phi) * sinf(theta);
XMVECTOR pos = XMVectorSet(x, y, z, 1.0f);
XMVECTOR target = XMVectorZero();
XMVECTOR up = XMVectorSet(0.0f, 1.0f, 0.0f, 0.0f);
XMMATRIX v = XMMatrixLookAtLH(pos, target, up);
//投影矩阵数据传递至GPU
XMMATRIX p = XMLoadFloat4x4(&proj);
//矩阵计算
XMMATRIX VP_Matrix = v * p;
XMStoreFloat4x4(&passConstants.viewProj, XMMatrixTranspose(VP_Matrix));
currFrameResources->passCB->CopyData(0, passConstants);
3.修改CBV
首先我们来理下思绪,现在的情况是:一个常量缓冲区堆中存放着objCB和passCB两种类型的常量缓冲区。objCB中存放着objectCount个(22个)子缓冲区,passCB中存放着1个子缓冲区,一共23个子缓冲区。现在有3个帧资源,所以我们要将23*3,一共是69个子缓冲区,即需要69个CBV。
我们创建CBV堆,其中的描述符数量应该就是69。
UINT objectCount = (UINT)allRitems.size();
int frameResourcesCount = 3;
cbHeapDesc.NumDescriptors = (objectCount + 1) * frameResourcesCount;
接着创建objCBV,现在我们要考虑帧资源因素,所以套2层循环,外层循环控制帧资源,内层循环控制渲染项。注意:此时的堆中地址要考虑第几个帧资源元素,所以heapIndex的值是objectCount * frameIndex + i。
std::vector<std::unique_ptr<FrameResources>> FrameResourcesArray;
for (int frameIndex = 0; frameIndex < frameResourcesCount; frameIndex++)
{
for (int i = 0; i < objectCount; i++)
{
D3D12_GPU_VIRTUAL_ADDRESS objCB_Address;
objCB_Address = FrameResourcesArray[frameIndex]->objCB->Resource()->GetGPUVirtualAddress();
objCB_Address += i * objConstSize;//子物体在常量缓冲区中的地址
int heapIndex = objectCount * frameIndex + i; //CBV堆中的CBV元素索引
auto handle = CD3DX12_CPU_DESCRIPTOR_HANDLE(cbvHeap->GetCPUDescriptorHandleForHeapStart());
handle.Offset(heapIndex, cbv_srv_uavDescriptorSize); //CBV句柄(CBV堆中的CBV元素地址)
//创建CBV描述符
D3D12_CONSTANT_BUFFER_VIEW_DESC cbvDesc;
cbvDesc.BufferLocation = objCB_Address;
cbvDesc.SizeInBytes = objConstSize;
d3dDevice->CreateConstantBufferView(&cbvDesc, handle);
}
}
然后创建passCBV,因为有了帧资源因素,所以我们要加一层循环,来生成3个passCBV。这里需要修改heapIndex值,因为passCBV堆中的地址是在objCBV后面的,即要在22*3个的objCBV之后。
for (int frameIndex = 0; frameIndex < frameResourcesCount; frameIndex++)
{
D3D12_GPU_VIRTUAL_ADDRESS passCB_Address;
passCB_Address = FrameResourcesArray[frameIndex]->passCB->Resource()->GetGPUVirtualAddress();
int passCbElementIndex = 0;
passCB_Address += passCbElementIndex * passConstSize;
int heapIndex = objectCount * frameResourcesCount + frameIndex;
auto handle = CD3DX12_CPU_DESCRIPTOR_HANDLE(cbvHeap->GetCPUDescriptorHandleForHeapStart());
handle.Offset(heapIndex, cbv_srv_uavDescriptorSize);
//创建CBV描述符
D3D12_CONSTANT_BUFFER_VIEW_DESC cbvDesc;
cbvDesc.BufferLocation = passCB_Address;
cbvDesc.SizeInBytes = passConstSize;
d3dDevice->CreateConstantBufferView(&cbvDesc, handle);
}
4.绘制
相应的,我们也要将帧资源因素在DrawRenderItems函数中考虑进去。这里设置跟描述符表objCbvIndex的地址和objCBV的地址是同一个,加上帧资源索引和每个帧资源的物体个数乘积即可。
void ShapesApp::DrawRenderItems()
{
......//将智能指针数组转换成普通指针数组
//遍历渲染项数组
for (size_t i = 0; i < ritems.size(); i++)
{
......//设置顶点索引缓存
//设置根描述符表
UINT objCbvIndex = currFrameResourcesIndex * (UINT)allRitems.size() + ritem->objCBIndex;
auto handle = CD3DX12_GPU_DESCRIPTOR_HANDLE(cbvHeap->GetGPUDescriptorHandleForHeapStart());
handle.Offset(objCbvIndex, cbv_srv_uavDescriptorSize);
......//绘制
}
}
最后,在Draw函数中,也要将第2个CBV描述符表的堆地址改了。
int passCbvIndex = (int)allRitems.size() * frameResourcesCount + currFrameResourcesIndex;
auto passCbvHandle = CD3DX12_GPU_DESCRIPTOR_HANDLE(cbvHeap->GetGPUDescriptorHandleForHeapStart());
passCbvHandle.Offset(passCbvIndex, cbv_srv_uavDescriptorSize);
cmdList->SetGraphicsRootDescriptorTable(1, //根参数的起始索引
passCbvHandle);
差点忘记很重要的一点。那就是cmdAllocator,因为每个帧资源都有自己的命令分配器,所以在Draw函数开始,我们就要Reset当前帧资源中的cmdAllocator。
auto currCmdAllocator = currFrameResources->cmdAllocator;
ThrowIfFailed(currCmdAllocator->Reset());//重复使用记录命令的相关内存
ThrowIfFailed(cmdList->Reset(currCmdAllocator.Get(), PSO.Get()));//复用命令列表及其内存
编译运行,效果和之前是一样的,但现在的同步机制是和之前不同的。为了之后光照的计算,我们将模型实体显示。
![5050030534feb6247cf7c4cedfff1f0e.png](https://i-blog.csdnimg.cn/blog_migrate/0229ac2d731b2b04b658e98e4957061c.jpeg)
到这儿,我们就完成了这个案例此章的所有代码。通过这个案例我们学到了模型静态合批处理、模型实例复制、同步性优化等知识。知识点看似不多,但是它们互相之间环环相扣,特别是在计算各种地址的时候需要非常小心。好了,今天就先到这里,下篇见。