코승호딩의 메모장

[Frame Resource와 Render Item] 본문

DirectX12/DirectX12 입문

[Frame Resource와 Render Item]

코승호딩 2023. 9. 19. 21:46

01 Frame Resource

Frame Resource 프레임 자원이란 프레임마다 명령 대기열을 완전히 비우지 않고 CPU와 GPU의 활용도를 높이는 최적화 수단이다. CPU와 GPU는 병렬로 작동하기 때문에 동기화가 필요하다고 알고 있을 것이다. 이러한 이유로 매 프레임 끝에서 Fence를 통해 동기화를 해야 하고 결국 CPU는 GPU가 명령들을 모두 처리할 때까지 기다려야 한다. 또한 GPU도 CPU가 명령 목록을 구축하기 전까지 기다려야 한다. 이 동기화를 최적화하기 위한 수단이 바로 프레임 자원이다.

 

프레임 자원은 매 프레임 CPU가 수정해야 할 자원들을 순환 배열로 관리하는 방법이다. 일반적으로 이 프레임 자원은 세 개를 담아 사용하며 프레임 n에서 CPU가 프레임 자원 배열을 훑으며 다음 GPU가 사용하지 않는 자원을 찾는다. GPU가 이전 프레임들을 처리하는 동안 CPU는 프레임 자원을 갱신한다. 다음 n+1 프레임으로 넘어가 이와 같은 일을 반복한다. 프레임 자원 배열의 원소가 세 개라면 CPU는 GPU보다 최대 두 프레임 앞서갈 수 있는 것이다. 따라서 GPU는 CPU를 따라잡기 위해 쉼 없이 일한다.

 

struct FrameResource
{
public:
    //...
    Microsoft::WRL::ComPtr<ID3D12CommandAllocator> CmdListAlloc;

    std::unique_ptr<UploadBuffer<PassConstants>> PassCB = nullptr;
    std::unique_ptr<UploadBuffer<ObjectConstants>> ObjectCB = nullptr;

    UINT64 Fence = 0;
};

위 클래스는 CPU가 한 프레임 명령 목록을 구축하는 데 필요한 자원이다. 명령 대기열이 참조하는 명령 할당자를 프레임 자원들이 각자 가지고 있으며 프레임마다 갱신해야 하는 상수 버퍼 또한 각자 가지고 있다. 마지막 Fence는 아직 GPU가 이 프레임 자원들을 사용하고 있는지 판정하기 위한 용도로 사용된다.

 

FrameResource::FrameResource(ID3D12Device* device, UINT passCount, UINT objectCount)
{
    ThrowIfFailed(device->CreateCommandAllocator(
        D3D12_COMMAND_LIST_TYPE_DIRECT,
		IID_PPV_ARGS(CmdListAlloc.GetAddressOf())));

    PassCB = std::make_unique<UploadBuffer<PassConstants>>(device, passCount, true);
    ObjectCB = std::make_unique<UploadBuffer<ObjectConstants>>(device, objectCount, true);
}

위 코드는 프레임 자원 클래스의 생성자로 각 상수 버퍼의 개수를 UploadBuffer에 넘겨 상수 버퍼들을 생성한다. 


다음으로 응용 프로그램에서 이 클래스를 어떻게 사용하는지 알아보자.

static const int NumFrameResources = 3; // 총 프레임 자원의 개수
vector<std::unique_ptr<FrameResource>> mFrameResources; // 프레임 자원의 배열
FrameResource* mCurrFrameResource = nullptr; // 현재 프레임에 해당하는 프레임 자원
int mCurrFrameResourceIndex = 0; // 현재 프레임에 해당하는 프레임 자원의 인덱스

void BuildFrameResources()
{
    for(int i = 0; i < gNumFrameResources; ++i)
    {
        mFrameResources.push_back(std::make_unique<FrameResource>(md3dDevice.Get(), 1, (UINT)mAllRitems.size());
    }
}

응용 프로그램에서는 위 함수를 통해 프레임 자원 배열 세 개에 각 자원을 생성한다.

 

void Update(const GameTimer& gt)
{
    //...
    mCurrFrameResourceIndex = (mCurrFrameResourceIndex + 1) % gNumFrameResources;
    mCurrFrameResource = mFrameResources[mCurrFrameResourceIndex].get();

    if(mCurrFrameResource->Fence != 0 && mFence->GetCompletedValue() < mCurrFrameResource->Fence)
    {
        HANDLE eventHandle = CreateEventEx(nullptr, false, false, EVENT_ALL_ACCESS);
        ThrowIfFailed(mFence->SetEventOnCompletion(mCurrFrameResource->Fence, eventHandle));
        WaitForSingleObject(eventHandle, INFINITE);
        CloseHandle(eventHandle);
    }

    UpdateObjectCBs(gt);
    UpdateMainPassCB(gt);
}

그리고 응용 프로그램의 매 프레임마다 현재 순환적으로 다음 프레임 자원의 배열에 접근한다. 만약 GPU가 아직 n프레임을 처리하지 않았는데 CPU가 n+1과 n+2를 모두 처리하였다면 기다려야 할 것이다. 왜냐하면 CPU가 순환하여 다시 n+3프레임 즉, n 프레임의 자원에 접근하여 변경한다면 GPU가 처리할 n프레임에서는 이상한 값이 들어오게 될 것이다. 따라서 최소한의 동기화를 해 주는 것이다. 때문에 CPU는 최대 GPU의 프레임 두 개만 앞설 수 있는 것이다.

 

void UpdateObjectCBs(const GameTimer& gt)
{
    auto currObjectCB = mCurrFrameResource->ObjectCB.get();
    // currObjectCB의 값을 변경해주는 일련의 과정 
    // 만약 currObjectCB에 월드 행렬이 있다면 월드 행렬을 갱신해야 한다.
}

이후 현재 세 개의 프레임 자원 중 해당 프레임 자원에 접근하여 자료를 갱신하면 된다. 

 

void Draw(const GameTimer& gt)
{
    auto cmdListAlloc = mCurrFrameResource->CmdListAlloc;
    ThrowIfFailed(cmdListAlloc->Reset());

    auto passCB = mCurrFrameResource->PassCB->Resource();
    mCommandList->SetGraphicsRootConstantBufferView(2, passCB->GetGPUVirtualAddress());
	
    // Render begin
    // ...
    // Render end

    mCurrFrameResource->Fence = ++mCurrentFence;
    mCommandQueue->Signal(mFence.Get(), mCurrentFence);
}

마지막으로 현재 프레임 명령 목록들을 구축하고 제출한다. 그리고 현재 프레임의 Fence 값을 현재 Fence의 값에 1을 더해 증가 시켜준다. 이렇게 되면 Update에서 CPU가 순환 배열의 마지막에서 기다릴 수 있도록 할 수 있다. 예를 들어 Fence의 값은 다음과 같이 증가할 것이다.

 

  0 1 2 3 4 5
Resource1 1 1 1 4 4 4
Resource2 0 2 2 2 5 5
Resource3 0 0 3 3 3 6

 

위 그림은 이해를 위해 GPU가 엄청 느린 속도로 움직인다고 가정 하였을 때, 프레임 0부터 프레임 5까지의 각 프레임 자원의 Fence 값을 나타낸 것이다. 우선 모두 0으로 시작해서 CPU가 자원을 훑어가며 프레임 자원 1부터 3까지 각각 1, 2, 3으로 Fence 값을 올린다. 이때, GPU가 엄청 느리다고 가정하였기 때문에 아직 0 프레임의 명령 목록을 처리하고 있다고 하자 그리고 현재의 프레임은 3번이라고 한다면, 다시 순환하여 첫 번째 배열을 가리킬 것이고 mCurrFrameResource->Fence의 값은 현재 GPU가 실행하는 Fence값 보다 크기 때문에 동기화를 하며 기다린다. 만약 GPU가 0번째 프레임의 명령을 모두 처리하였다면 Fence의 값이 1로 증가하고 다음 프레임 자원을 갱신한다. 이렇게 CPU가 자원을 순환하며 중복된 자원에 접근하는 문제를 동기화로 해결할 수 있다.

 

위 해법이 물론 CPU의 대기를 완전히 없애지는 않지만 GPU가 놀지 않도록 계속해서 CPU가 일감을 제공하며 GPU가 노는 상황을 최대한으로 피할 수 있을 것이다. 


02 Render Item

하나의 물체를 그리는데 정점 버퍼, 색인 버퍼, 상수, 기본도형 종류 등 여러 가지 매개변수 설정이 필요하다. 이들을 캡슐화하여 경량의 구조체에 두면 편리해진다. 이러한 자료 집합을 Reder Item 렌더 항목이라고 부른다. 

struct RenderItem
{
    RenderItem() = default;

    XMFLOAT4X4 World = MathHelper::Identity4x4();
    XMFLOAT4X4 TexTransform = MathHelper::Identity4x4();
    
    int NumFramesDirty = gNumFrameResources;

    UINT ObjCBIndex = -1;

    Material* Mat = nullptr;
    MeshGeometry* Geo = nullptr;

    D3D12_PRIMITIVE_TOPOLOGY PrimitiveType = D3D11_PRIMITIVE_TOPOLOGY_TRIANGLELIST;

    UINT IndexCount = 0;
    UINT StartIndexLocation = 0;
    int BaseVertexLocation = 0;
};

위의 구조체처럼 물체를 그리기 위해 필요한 정보들을 하나의 구조체 안에 넣어 둔 것이다. NumFramesDirty 변수는 물체의 자료가 변해서 상수 버퍼를 갱신해야 하는지 여부를 뜻한다. 만약 나무나 돌 같이 정적인 물체들은 상수 버퍼를 굳이 갱신하지 않아도 되는데 캐릭터나 몬스터, 물과 같은 동적인 물체들은 월드 변환 및 텍스쳐 변환이 계속 변경되므로 이 변수를 계속 갱신해 줘야 Update 함수에서 이 변수를 확인하여 내용을 갱신할 수 있다.

 

vector<std::unique_ptr<RenderItem>> mAllRitems;
array<vector<RenderItem*>, (int)RenderLayer::Count> mRitemLayer;

만약 렌더 항목들이 같은 PSO를 사용한다면 다음과 같이 같은 목록에 두면 여러 항목으로 조직화할 수 있다.


패스별 상수 버퍼

unique_ptr<UploadBuffer<PassConstants>> PassCB = nullptr;

이 버퍼는 하나의 렌더링 패스 전체에서 변하지 않는 상수 자료를 저장한다. 예를 들어 View, Proj과 같은 행렬들은 하나의 렌더링 패스가 아닌 프레임마다 갱신해야 하는 상수 버퍼이다. 만약 오브젝트의 월드 변환은 하나의 렌더링 패스 전체에서 물체마다 갱신해야 한다. 반대로 앞에서 설명한 View나 Proj 행렬은 프레임마다 갱신하는 것이다. 상수 버퍼에 여분의 정보를 추가로 제공하는 것은 비용이 매우 낮기 때문에 그냥 모든 패스 자료를 하나의 버퍼에 담아둔다.

 

cbuffer cbPerObject : register(b0)
{
    float4x4 gWorld;
    float4x4 gTexTransform;
};

cbuffer cbPass : register(b1)
{
    float4x4 gView;
    float4x4 gInvView;
    float4x4 gProj;
    float4x4 gInvProj;
    float4x4 gViewProj;
    float4x4 gInvViewProj;
    float3 gEyePosW;
    float cbPerObjectPad1;
    float2 gRenderTargetSize;
    float2 gInvRenderTargetSize;
    float gNearZ;
    float gFarZ;
    float gTotalTime;
    float gDeltaTime;
    float4 gAmbientLight;
    Light gLights[MaxLights];
};

위 hlsl 코드와 같이 각각 다른 타이밍에 갱신되는 상수 버퍼를 선언할 수 있다. 

 

for(auto& e : mAllRitems)
{
	if(e->NumFramesDirty > 0)
	{
		XMMATRIX world = XMLoadFloat4x4(&e->World);
		XMMATRIX texTransform = XMLoadFloat4x4(&e->TexTransform);

		ObjectConstants objConstants;
		XMStoreFloat4x4(&objConstants.World, XMMatrixTranspose(world));
		XMStoreFloat4x4(&objConstants.TexTransform, XMMatrixTranspose(texTransform));

		currObjectCB->CopyData(e->ObjCBIndex, objConstants);

		e->NumFramesDirty--;
	}
}

앞서 살펴본 NumFramesDirty가 0보다 큰 값만 갱신한다. 이 뜻은 물체의 상수 자료가 갱신되었다는 뜻이고 프레임 자원이 총 3개이므로 자료가 갱신될 때 NumFramesDirty를 3으로 계속 업데이트하면 동적인 물체 즉 상수 버퍼의 갱신이 필요한 물체만 상수 버퍼를 갱신할 수 있다. CopyData는 memcpy를 통해 GPU에 시스템 메모리의 값을 복사하는 함수인데 CPU에서 GPU로의 접근은 시간이 오래 걸리는 작업이기 때문에 위처럼 꼭 정적인 물체는 한 번만 설정하도록 하자.

 

그리고 프레임 당 한 번씩 업데이트를 해야 하는 PassConstants의 경우 프레임마다 자료를 갱신한 다음 CopyData를 통해 자료를 복사하면 된다.

 

이렇게 상수 버퍼를 두 개를 사용하도록 변경하였으므로 루트 서명도 달라져야 한다. CBV들이 서로 다른 빈도로 설정되기 때문에 서술자 테이블을 두 개 두도록 한다. 패스별 CBV는 렌더링 패스당 한 번, 물체별 CBV는 렌더 항목마다 Set한다.

CD3DX12_DESCRIPTOR_RANGE objTable;
objTable.Init(D3D12_DESCRIPTOR_RANGE_TYPE_CBV, 1, 0);

CD3DX12_DESCRIPTOR_RANGE passTable;
passTable.Init(D3D12_DESCRIPTOR_RANGE_TYPE_CBV, 1, 1);

CD3DX12_ROOT_PARAMETER slotRootParameter[2];
slotRootParameter[0].InitAsDescriptorTable(1, &objTable);
slotRootParameter[0].InitAsDescriptorTable(1, &passTable);

'DirectX12 > DirectX12 입문' 카테고리의 다른 글

[Direct3D 렌더링]  (0) 2023.09.18
[시간 측정]  (0) 2023.09.14
[전체 화면 모드 전환]  (0) 2023.09.11
[Direct3D 개요와 초기화]  (0) 2023.09.11