일 | 월 | 화 | 수 | 목 | 금 | 토 |
---|---|---|---|---|---|---|
1 | ||||||
2 | 3 | 4 | 5 | 6 | 7 | 8 |
9 | 10 | 11 | 12 | 13 | 14 | 15 |
16 | 17 | 18 | 19 | 20 | 21 | 22 |
23 | 24 | 25 | 26 | 27 | 28 |
- 절두체 컬링
- Dynamic Indexing
- 게임 디자인 패턴
- TCP/IP
- effective C++
- 디퍼드 렌더링
- light
- Deferred Rendering
- direct3d
- 큐브 매핑
- DirectX12
- gitscm
- gitlab
- 노멀 맵핑
- 조명 처리
- 장치 초기화
- Render Target
- DirectX
- 네트워크
- FrameResource
- 게임 클래스
- 네트워크 게임 프로그래밍
- Direct3D12
- 직교 투영
- C++
- InputManager
- 입방체 매핑
- Frustum Culling
- 동적 색인화
- 게임 프로그래밍
- Today
- Total
코승호딩의 메모장
[ConstantBuffer와 FrameResource] 본문
이번 글에서는 쉐이더에 필요한 ConstantBuffer를 넘겨주는 방법과 [DirectX 입문]에서 배운 CPU와 GPU의 동기화 작업을 최적화하기 위한 수단인 FrameResource를 적용해보려고 합니다. 이에 대한 내용은 이전 글을 참고하면 됩니다.
[Frame Resource와 Render Item]
01 Frame Resource Frame Resource 프레임 자원이란 프레임마다 명령 대기열을 완전히 비우지 않고 CPU와 GPU의 활용도를 높이는 최적화 수단이다. CPU와 GPU는 병렬로 작동하기 때문에 동기화가 필요하다고
suengho2257.tistory.com
ConstantBuffer
상수 버퍼를 쉐이더로 넘기기 위해서 루트 시그니처를 사용해야 하고 이를 위해 루트 파라미터를 정의해야 한다는 것을 알고 있을 것이다. 또한 이 방법이 서술자 테이블, 루트 서술자, 루트 상수 총 세 가지 방법이 있는 것도 알고 있다고 가정한다.
void BuildDescriptorHeaps() {
UINT objCBByteSize = CalcConstantBufferByteSize(sizeof(ObjectConstants));
D3D12_GPU_VIRTUAL_ADDRESS cbAddress = mObjectCB->Resource()->GetGPUVirtualAddress();
D3D12_CONSTANT_BUFFER_VIEW_DESC cbvDesc;
cbvDesc.BufferLocation = cbAddress;
cbvDesc.SizeInBytes = objCBByteSize;
// 1번째 상수버퍼 뷰 생성
auto handle = CD3DX12_CPU_DESCRIPTOR_HANDLE(mCbvHeap->GetCPUDescriptorHandleForHeapStart());
md3dDevice->CreateConstantBufferView(&cbvDesc, handle);
// 2번째 상수버퍼 뷰 생성
cbvDesc.BufferLocation = cbAddress + objCBByteSize;
handle.Offset(boxCBufIndex, mCbvSrvUavDescriptorSize);
md3dDevice->CreateConstantBufferView(&cbvDesc, handle);
}
[DirectX 12를 이용한 3D 게임프로그래밍 입문] 책에서는 서술자 테이블을 이용하여 상수 버퍼를 넘기고자 할 때, 서술자 힙을 생성한 다음 해당 상수 버퍼에 대한 서술자를 생성한다. 여러 객체를 그리고자 한다면, CreateConstantBufferView를 할 때, 매번 서술자 힙을 GetDescriptorHandleIncrementSize을 사용해 얻은 사이즈만큼 더하여 인자에 넣어준다.
void Draw() {
CD3DX12_GPU_DESCRIPTOR_HANDLE cbv(mCbvHeap->GetGPUDescriptorHandleForHeapStart());
cbv.Offset(0, mCbvSrvUavDescriptorSize);
mCommandList->SetGraphicsRootDescriptorTable(0, cbv1);
mCommandList->DrawIndexedInstanced(//Draw Box1);
cbv.Offset(1, mCbvSrvUavDescriptorSize);
mCommandList->SetGraphicsRootDescriptorTable(0, cbv2);
mCommandList->DrawIndexedInstanced(//Draw Box2);
}
위와 같은 방식대로 서술자 힙을 구성한다면 다음과 같이 Draw를 할 때, 각 객체의 순서에 따라 Offset을 통해 해당되는 상수 버퍼 서술자의 GpuHandle을 가져와서 그려줄 수 있을 것이다. 각 오브젝트들이 추가될 때마다 힙의 사이즈를 늘려가며 각자 다른 GpuHandle을 사용하게 된다. 이 방법은 구현하기도 쉽고 이해하기도 쉬울 것이다.
추가적으로 유동적인 구현을 위해 렌더링이 되어야 할 오브젝트마다 ObjCBIndex라는 것을 가지고 있으며 오브젝트가 추가될 때마다 ObjCBIndex를 1씩 증가시킨다. 이렇게 되면 매번 테이블을 셋을 하거나 memcpy를 통해 상수 버퍼를 넘겨줄 때, 자신의 ObjCBIndex에 해당하는 값을 넘겨주어 Offset 하면 될 것이다.
그러나 만약 게임과 같이 오브젝트들이 새로 생성되거나 삭제가 되는 일이 빈번히 일어난다면 어떤 상황에 놓여질까?
게임 오브젝트가 중간에 삭제되더라도 계속 ObjCBIndex를 늘려가며 서술자 힙 또한 늘려가기 때문에 문제는 생기지 않을 것이다. 그러나 오브젝트가 없음에도 불구하고 서술자 힙은 계속 늘어나고 중간중간에 빈 공간이 생길 것이다. 사용하지도 않는데 말이다. 극단적으로 10000개의 오브젝트가 있는데 9999개의 오브젝트가 사라져서 1개의 오브젝트만 남아있음에도 불구하고 서술자 힙에는 10000개의 서술자들을 담은 상태인 것이다. 물론 정적인 오브젝트들은 이러한 방식이 더 쉬울 것이다.
그렇다면 더 좋은 방식은 없을까? 더 좋은 방식이라기보다는 상황에 맞게 사용할 수 있는 또 다른 방법이 있다. 구현 아이디어는 이렇다. 상수 버퍼에 currentIndex라는 현재의 상수 버퍼 서술자를 가리키는 인덱스를 가지고 있도록 한다. 그리고 memcpy를 통해 매핑된 버퍼에 데이터를 복사할 때, currentIndex를 1씩 증가시키는 것이다. 매 프레임마다 혹은 그려야 할 오브젝트가 바뀔 때마다 memcpy를 통해 지금 넘겨주고 싶은 데이터를 복사한다. 이때, currentIndex를 통해 접근하고 다음 프레임으로 넘어간다면 이 currentIndex를 다시 0으로 클리어하는 것이다. 그리고 이 상수 버퍼를 만들 때 내부 변수로 상수 버퍼 힙을 생성하여 상수 버퍼 서술자를 만들어 주는 것이다. 이 상수 버퍼 서술자를 서술자 테이블에 원하는 오브젝트의 상수 버퍼를 Set 해주고 싶다면 CopyDescriptors를 이용하여 복사해 주면 서술자 테이블은 SetGraphicsRootDescriptorTable을 이용해 현재 상수 버퍼 서술자에 해당하는 인덱스를 가지고 있기 때문에 이를 통해 셋 할 수 있다. 여기서 서술자 테이블도 currentIndex를 두고 그리기가 모두 끝났다면 다시 0으로 초기화하는 것이다. 이를 구현한 코드는 다음과 같다.
void ConstantBuffer::Init(CBV_REGISTER reg, uint32 size, uint32 count)
{
_reg = reg;
_elementSize = (size + 255) & ~255;
_elementCount = count;
CreateBuffer();
CreateView();
}
우선 상수 버퍼는 256의 배수이어야 하기 때문에 다음과 같이 버퍼 사이즈를 정해주고 이에 대한 상수 버퍼와 서술자를 만든다.
void ConstantBuffer::PushData(void* buffer, uint32 size)
{
::memcpy(&_mappedBuffer[_currentIndex * _elementSize], buffer, size);
D3D12_CPU_DESCRIPTOR_HANDLE cpuHandle = GetCpuHandle(_currentIndex);
gEngine->GetTableDescHeap()->SetCBV(cpuHandle, _reg);
_currentIndex++;
}
원하는 오브젝트에 대한 상수 버퍼를 넘기고자 할 때, 해당 오브젝트에 대한 인덱스를 넘길 필요 없이 그냥 현재 인덱스인 currentIndex 내부 변수를 통해 매핑된 버퍼에 데이터를 복사시켜 준다.
void TableDescriptorHeap::Init(uint32 count)
{
_groupCount = count;
D3D12_DESCRIPTOR_HEAP_DESC desc = {};
desc.NumDescriptors = count * REGISTER_COUNT;
desc.Flags = D3D12_DESCRIPTOR_HEAP_FLAG_SHADER_VISIBLE;
desc.Type = D3D12_DESCRIPTOR_HEAP_TYPE_CBV_SRV_UAV;
DEVICE->CreateDescriptorHeap(&desc, IID_PPV_ARGS(&_descHeap));
_handleSize = DEVICE->GetDescriptorHandleIncrementSize(D3D12_DESCRIPTOR_HEAP_TYPE_CBV_SRV_UAV);
_groupSize = _handleSize * REGISTER_COUNT;
}
상수 버퍼 서술자를 서술자 테이블에 배치하기 위해 서술자 테이블을 생성한다.
groupSize는 총레지스터의 개수 * handleSize 일 것이다.
서술자 테이블을 살펴보면 위 그림과 같다. currentIndex에 해당하는 b0~t4까지가 하나의 group이다. 하나의 group은 하나의 오브젝트를 그리기 위해 필요한 상수 버퍼 서술자 또는 쉐이더 리소스 서술자등에 해당한다. 그리고 하나의 오브젝트에 해당하는 그리기 명령이 끝났다면 다음 오브젝트를 그리기 위해서 currIndex를 1 증가시킨다.
void TableDescriptorHeap::CommitTable()
{
D3D12_GPU_DESCRIPTOR_HANDLE handle = _descHeap->GetGPUDescriptorHandleForHeapStart();
handle.ptr += _currentGroupIndex * _groupSize;
CMD_LIST->SetGraphicsRootDescriptorTable(0, handle);
_currentGroupIndex++;
}
이 코드가 현재 하나의 오브젝트 그리기 명령을 제출하는 코드이다.
D3D12_CPU_DESCRIPTOR_HANDLE TableDescriptorHeap::GetCPUHandle(uint8 reg)
{
D3D12_CPU_DESCRIPTOR_HANDLE handle = _descHeap->GetCPUDescriptorHandleForHeapStart();
handle.ptr += _currentGroupIndex * _groupSize;
handle.ptr += reg * _handleSize;
return handle;
}
현재 해당하는 CpuHandle을 구하기 위해서는 위 코드와 같을 것이다. 레지스터는 handleSize만큼 증가시켜야 다음 레지스터로 접근할 수 있으며 하나의 오브젝트 그리기가 끝났다면 다음 오브젝트를 그리기 위해 groupSize에 현재 currIndex를 곱해준다면 다음 그룹으로 넘어갈 수 있을 것이다. 한 프레임의 그리기가 끝났다면 다음 프레임에서는 다시 currIndex를 0으로 설정하면 다시 서술자 테이블에서 상수 버퍼 서술자를 0 인덱스부터 채울 것이다.
이 방식을 사용한다면 속도는 조금 저하될지 몰라도 오브젝트의 관리가 매우 쉬워진다. 매 프레임마다 서술자 테이블과 상수 버퍼를 초기화하고 0부터 다시 채워나가기 때문에 중간의 오브젝트가 사라지더라도 다음 오브젝트에 대한 상수 버퍼는 그만큼 앞으로 당겨오기 때문이다. 또한 서술자 테이블을 사용함으로써 하나의 서술자 힙에 모든 서술자들을 모아 놓는 개념이기 때문에 사용하기도 편리하다. 그러나 이 방법이 무조건적으로 좋다고 할 수는 없다. 상황에 맞게 고려하여 어떤 방식을 사용할지 논의해야 한다.
void RootSignature::Init(ComPtr<ID3D12Device> device)
{
CD3DX12_DESCRIPTOR_RANGE ranges[2];
ranges[0].Init(D3D12_DESCRIPTOR_RANGE_TYPE_CBV, CBV_REGISTER_COUNT, 0); // b0~b4
ranges[1].Init(D3D12_DESCRIPTOR_RANGE_TYPE_SRV, SRV_REGISTER_COUNT, 0); // t0~t4
CD3DX12_ROOT_PARAMETER rootParameter[1];
rootParameter[0].InitAsDescriptorTable(2, ranges, D3D12_SHADER_VISIBILITY_ALL);
//... CreateRootSignature
}
마지막으로 서술자 테이블에 대한 루트 시그니처를 생성한다.
참고로 두 방식의 차이점 중 하나는 첫 방식은 서술자 테이블에 서술자를 셋 할 때, 기존 cbvHeap 서술자 힙에 그리고자 하는 오브젝트의 ObjCBIndex * incrementSize만큼 GpuHandle에 Offset을 하는 방식이었지만 두 번째 방식은 cbvHeap을 생성하여 서술자 테이블에 CopyDescriptors를 이용하여 서술자를 복사해 주는 방식이기 때문에 GpuHandle이 아닌 CpuHandle이 필요하다는 점이다.
FrameResource
이제 ConstantBuffer를 구현하였으니 FrameResource를 구현해 보도록 하자. 우선 FrameResource는 3개를 두는 것으로 하고 내부적으로 명령 할당자와 상수 버퍼들, 자신의 Fence 값을 가지고 있어야 한다.
#include "ConstantBuffer.h"
class FrameResource
{
//...
public:
ComPtr<ID3D12CommandAllocator> CmdAlloc;
sptr<ConstantBuffer> ObjectCB = nullptr;
sptr<ConstantBuffer> MaterialCB = nullptr;
uint64 Fence = 0;
};
그리고 생성자에서 명령 할당자를 생성하고 상수 버퍼들을 생성하여 최대 사이즈만큼 추가하도록 한다.
FrameResource::FrameResource(ComPtr<ID3D12Device> device)
{
ThrowIfFailed(device->CreateCommandAllocator(
D3D12_COMMAND_LIST_TYPE_DIRECT,
IID_PPV_ARGS(CmdAlloc.GetAddressOf())));
ObjectCB = std::make_shared<ConstantBuffer>();
ObjectCB->Init(CBV_REGISTER::b0, sizeof(ObjectConstants), 256);
MaterialCB = std::make_shared<ConstantBuffer>();
MaterialCB->Init(CBV_REGISTER::b1, sizeof(MaterialConstants), 256);
}
Init의 인자들은 각각 상수 버퍼에 해당하는 레지스터와 넘겨줄 데이터의 크기, 그리고 몇 개의 상수 버퍼를 사용할 것인지이다.
class CommandQueue
{
public:
//...
void BuildFrameResource(ComPtr<ID3D12Device> device);
FrameResource* GetCurrFrameResource() { return mCurrFrameResource; }
private:
//...
std::vector<uptr<FrameResource>> mFrameResources;
FrameResource* mCurrFrameResource = nullptr;
int mCurrFrameResourceIndex = 0;
};
프레임 리소스를 커맨드 큐에 vector의 형태로 만들어준다. 프레임 리소스를 커맨드 큐에 만들지 엔진에 만들지는 아직 정하지 않았기 때문에 일단 커맨드 큐에 만들도록 한다.
void CommandQueue::BuildFrameResource(ComPtr<ID3D12Device> device)
{
for (int i = 0; i < gNumFrameResources; ++i)
{
mFrameResources.push_back(std::make_unique<FrameResource>(device));
}
}
총 3개의 프레임 리소스들을 만들어준다.
void CommandQueue::Update()
{
mCurrFrameResourceIndex = (mCurrFrameResourceIndex + 1) % gNumFrameResources;
mCurrFrameResource = mFrameResources[mCurrFrameResourceIndex].get();
if (mCurrFrameResource->Fence != 0 && _fence->GetCompletedValue() < mCurrFrameResource->Fence)
{
HANDLE eventHandle = CreateEventEx(nullptr, false, false, EVENT_ALL_ACCESS);
ThrowIfFailed(_fence->SetEventOnCompletion(mCurrFrameResource->Fence, eventHandle));
WaitForSingleObject(eventHandle, INFINITE);
CloseHandle(eventHandle);
}
}
이곳이 중요한 부분이다. 프레임마다 인덱스를 1씩 증가시키며 다음 프레임으로 넘어갈 때, 최대 2 프레임까지만 증가시켜야 다음 프레임 리소스를 덮어쓰지 않기 때문에 어쩔 수 없이 Cpu가 Gpu보다 3 프레임 이상으로 앞서간다면 동기화를 시켜준다.
이제 엔진의 초기화 함수에서 커맨드 큐의 BuildFrameResource를 이용하여 프레임 리소스를 생성할 수 있다. 그리고 상수 버퍼를 사용할 때마다 현재 프레임에 해당하는 프레임 리소스의 상수 버퍼를 가져와야 한다.
#define CB(type) gEngine->GetCmdQueue()->GetCurrFrameResource()->GetConstantBuffer(type)
CB(CONSTANT_BUFFER_TYPE::OBJECT)->PushData(&_objectConstant, sizeof(ObjectConstants));
이를 편하게 접근하기 위해서 매크로를 정의하여 사용하도록 한다. type에는 어떠한 레지스터를 쓸 지에 대한 인자이다.
CB(CONSTANT_BUFFER_TYPE::OBJECT)->Clear();
CB(CONSTANT_BUFFER_TYPE::MATERIAL)->Clear();
커맨드 큐의 RenderBegin에서 각 상수 버퍼를 클리어하여 인덱스를 0으로 초기화하는 작업을 하면 끝이다. 이제 실행해 보자.
(???...)
잘 실행이 되지만 응용 프로그램을 종료하면 이상한 오류가 뜬다. 출력창에 뜨는 메시지를 보니 아니나 다를까 GPU에서 아직 완료되지 않은 작업이 있는 동안 ID3DResource 객체를 해제하려고 할 때 발생하는 오류이다. 내 생각으로는 우리는 프레임 리소스를 통해 최대 2 프레임 CPU를 앞서가도록 만들었다. 그러나 GPU가 현재 n프레임을 처리하고 있는 데 사용을 종료하게 되면 앞서 나간 n+1, n+2 프레임의 명령들은 GPU가 모두 완료하지 않은 상태이기 때문에 오류가 발생하는 것이다.
Engine::~Engine()
{
if (_device != nullptr)
_cmdQueue->WaitSync();
}
해결 방법으로 간단하게 커맨드 큐에 모든 명령이 완료될 때까지 CPU와 GPU를 동기화하도록 한다. 이제 사용을 종료하더라도 위 오류가 뜨지 않고 잘 실행이 된다.
이전 책에서 배운 FrameResource 즉, CPU, GPU 동기화 최적화 방법을 사용하여 새로운 프로젝트에 구현하려니 막히는 부분이 많았다. 단지 명령 할당자만 현재 프레임에 해당하는 FrameResource를 사용하면 되었는데 말이다. CPU가 GPU보다 최대 2 프레임을 앞서 가는 상황에서 종료를 눌렀을 때, GPU 명령이 남아 있다는 것은 생각하지 못한 것도 변수였다. 그러나 이번 오류를 해결하며 많은 것을 깨달았다. 엔진 종료 시 소멸자에 동기화를 시켜줌으로써 해결할 수 있다는 사실을 알았으며 FrameResource를 정확하게 사용하는 방법도 깨달았다.
'DirectX12 > DirectX12 응용' 카테고리의 다른 글
[SceneManager] (1) | 2023.10.08 |
---|---|
[Component] (0) | 2023.10.07 |
[InputManager와 GameTimer] (1) | 2023.10.07 |
[Material] (0) | 2023.10.07 |
[Texture Mapping] (0) | 2023.10.07 |
[Mesh] (1) | 2023.10.07 |
[장치 초기화] (1) | 2023.10.07 |