DirectX12 개념 블로그 정주행 2회차 - SwapChain과 Fence

2023. 6. 9. 17:46카테고리 없음

1. SwapChain

SwapChain의 존재 이유는 더블 버퍼링 때문이다. 더블 버퍼링이 무엇인지에 대한 설명은 아래의 링크에서 2번에 정리해 놓은 것이 있으니 이것으로 대체하며 더블 버퍼링이 무엇인지 알고 있다는 가정하에 글을 적을 것이다.

https://dafher-diary.tistory.com/54

 

루키스 게임수학 정주행 2회차 - 장치 초기화

1. CPU vs GPU 우리가 대부분 사용하는 컴퓨터에는 CPU와 GPU라는 부품이 거의 대부분 기본적으로 장착되어 있을 것이다. 오늘 할 것은 엔진을 만드는 데 있어서 GPU에게 효율적으로 일을 시키는 일인

dafher-diary.tistory.com

 

더블 버퍼링은 버퍼가 2개가 필요하다. 이 2개의 버퍼를 각각 백 버퍼, 프론트 버퍼라고 한다.

  • 백 버퍼: GPU가 그림을 그리는 영역
  • 프론트 버퍼: 모니터에 보여주는 영역

 

내부적으로 백 버퍼에 있는 내용을 프론트 버퍼에 넣어서 모니터 상에 보여주는 것이다. 백 버퍼에 있는 내용을 프론트 버퍼에 넣어주는 이 작업을 Presentation이라고 하며 DX12에서 IDXGISwapChain의 Present함수를 호출시키면 이 작업을 수행할 수 있다. 사실 여기까지만 알아도 큰 흐름은 이해할 수 있지만 좀 더 자세히 들어가 보도록 하겠다.

 

 

2. Antialiasing (계단 현상 제거)

계단 현상이란 아래와 같이 모니터 화면상에서 가장자리 부분이 매끄럽게 보이지 않고 계단처럼 보인다고 해서 계단 현상이라고 한다. 계속 확대를 하다보면 이런 문제가 생길 수 밖에 없는 것이 사실 우리가 쓰는 컴퓨터의 픽셀은 사각형으로 되어있기 때문이다. 그래서 아래의 그림처럼 확대를 하다보면 계단 현상이 생길 수 밖에 없는 것이다.

멀리서 보면 그냥 선이지만 사실 확대하면 이렇게 되어있다.

하지만 도트가 아닌 이상 고품질 게임이나 영화, 애니메이션 등을 보면 이러한 계단 현상은 좀처럼 잘 나타나지는 않는다. 이것을 어떻게 해결했길래 이런 현상이 거의 없는 것일까? 사실 모니터 자체에 있는 픽셀의 갯수를 엄청나게 늘리면 계단 현상은 일어나지 않을 것이다. 하지만 이는 비용에 대한 문제때문에 실질적으로 불가능하다고 봐야 한다.

 

그래서 다중 샘플링(계단 현상을 제거하는 기법)을 사용한다. 그 중에서도 DX12는 4x다중 샘플링을 사용하는데 내용은 간단하다. 그냥 버퍼에서 가로, 세로를 각각 2배로 만들고 거기에 그림을 그리면 된다. 그럼 가로, 세로가 각각 2배이기 때문에 기존보다 4배가 되는 공간에서 작업을 하게 될 것이고 그만큼 자세하게 그림을 그릴 수 있을테니 같은 픽셀의 모니터가 있다면 4x다중 샘플링을 사용한 것이 좀 더 계단 현상이 없어지게 된다.

 

현재 사용하는 GPU가 다중 샘플링을 지원하는지 보고 싶다면 ID3D12Device의 CheckFeatureSupport를 호출하도록 하자.

참고로 샘플링의 수를 2x2, 4x4 등의 방식으로 설정할 수 있지만 너무 높게 설정하면 속도가 느려지게 되니 주의해야 한다. 설정하는 방법은 아래의 코드처럼 SwapChain에 대해서 설정할 때, DXGI_SWAP_CHAIN_DESC를 통해 설정하면 된다. 전부 외울 필요는 없고 모르는 것들을 그 때 가서 하나하나 찾아보면 된다.

void TCWindow::InitSwapChain() noexcept
{
    _swapChain.Reset();

    DXGI_SWAP_CHAIN_DESC swapChainDesc;

    swapChainDesc.BufferDesc.Width = _width;
    swapChainDesc.BufferDesc.Height = _height;
    swapChainDesc.BufferDesc.RefreshRate.Numerator = 60;
    swapChainDesc.BufferDesc.RefreshRate.Denominator = 1;
    swapChainDesc.BufferDesc.Format = DXGI_FORMAT_R8G8B8A8_UNORM;
    swapChainDesc.BufferDesc.ScanlineOrdering = DXGI_MODE_SCANLINE_ORDER_UNSPECIFIED;
    swapChainDesc.BufferDesc.Scaling = DXGI_MODE_SCALING_UNSPECIFIED;
    swapChainDesc.SampleDesc.Count = 1;
    swapChainDesc.SampleDesc.Quality = 0;
    swapChainDesc.BufferUsage = DXGI_USAGE_RENDER_TARGET_OUTPUT;
    swapChainDesc.BufferCount = SWAP_CHAIN_BUFFER_COUNT;
    swapChainDesc.OutputWindow = _hwnd;
    swapChainDesc.Windowed = _windowed;
    swapChainDesc.SwapEffect = DXGI_SWAP_EFFECT_FLIP_DISCARD;
    swapChainDesc.Flags = DXGI_SWAP_CHAIN_FLAG_ALLOW_MODE_SWITCH;

    _dxgi->CreateSwapChain(_cmdQueue.Get(), &swapChainDesc, &_swapChain);

    for (int32 i = 0; i < SWAP_CHAIN_BUFFER_COUNT; i++)
        _swapChain->GetBuffer(i, IID_PPV_ARGS(&_renderTargets[i]));
}

 

각 설정들에 대한 설명은 아래의 링크에 자세히 나와있다. 잘 정리되어 있어서 상당히 좋았다.

https://ssinyoung.tistory.com/35?category=810267 

 

DirectX 12 장치 초기화 이해하기 (3)

[ DirectX 12 장치 초기화 단계 ] 1 단계 Device(그래픽 디바이스) 생성. 2 단계 CommandQueue와 CommandList 생성. 3 단계 SwapChain 생성. 4 단계 FenceObject 생성. 5 단계 렌더타겟(RenderTarget)과 깊이/스텐실(Depth/Stenci

ssinyoung.tistory.com

 

 

3. Fence

DX12는 Fence라는 것을 클래스로 제공한다. 직역하면 울타리라는 뜻인데 도대체 울타리가 지금 이것과 어떤 상관이 있는걸까? 사실 이 내용은 이미 이 글에서 간략하게나마 다룬 적이 있었다. 왜 울타리로 이름을 지었는지도 추측성 글로나마 적어놓은 바 있다. 그러나 제대로 다룬 것 같지가 않아서 이번 글로 다시 한번 제대로 다뤄보려고 한다.

https://dafher-diary.tistory.com/60

 

루키스 게임수학 정주행 3회차 - 장치 심화

사실 이전에 "장치 초기화"라는 제목으로 글을 쓴 적이 있었는데 4가지 클래스를 정의한 적이 있었다. 근데 사실 코드를 따라 치면서 이해하지 못한 부분들이 있었고 그래서 그것들을 하나하나

dafher-diary.tistory.com

 

CPU와 GPU는 기본적으로 병렬적인 작동을 해야 한다. 그렇지 않으면 누군가가 일을 할 동안 누군가는 아주 잠깐이나마 놀게될 것이기 때문이다. 그런데 컴퓨터에서 무언가가 병렬적으로 동작하게 된다는 것은 그것들이 하나의 자원을 참조할 때, 그 자원의 상태에 대해서 동기화를 시켜줘야 한다는 뜻이 된다. Fence는 바로 CPU와 GPU 사이에서 동기화를 시켜주는 역할을 한다. 여기까지 이해한 뒤에 아래의 그림을 보자.

다른 블로그에서 가져온 것인데 그 블로그 주인이 누구인지 모르겠지만 정말 잘 정리된 그림이다.

위 그림에 대한 동작은 CPU가 리소스를 만들면 GPU가 그 리소스를 가져다 쓰는 구조로 이루어져 있다. CPU가 리소스를 만들면 그것을 ID3D12Resource에 저장된다. 그리고 GPU에서 이 ID3D12Resource에 있는 리소스 데이터를 가져다 쓰는데 이것이 매 프레임마다 일어나는 것이다. 즉, 아래와 같은 흐름이 이루어지는 것이다.

n번째 프레임에 처리해야 하는 CPU의 일처리 → n번째 프레임에 처리해야 하는 GPU의 일처리 →
n + 1번째 프레임에 처리해야 하는 CPU의 일처리 → n + 1번째 프레임에 처리해야 하는 GPU의 일처리 →
n + 2번째 프레임... → n + 3번째 프레임... → .....

 

근데 만약에 CPU와 GPU의 성능 차이때문에 어느 한 쪽이 일을 너무 빠르게 처리하면 어떻게 될까? 예를 들어서 n번째 프레임에서 보여줘야 하는 리소스가 있다고 치자. 근데 GPU가 n번째 프레임의 리소스를 그리기도 전에 CPU에서 n + 1번째 리소스를 만든 후에 GPU에게 그림을 그리도록 명령을 내리면 어떻게 되느냐는 것이다. 당연히 문제가 생기기 때문에 CPU는 n + 1번째 프레임으로 넘어가기 전에 GPU가 처리할 일이 끝날 때까지 기다려야 한다.

 

그래서 이름을 Fence로 지었다고 생각한다. Fence는 울타리로 무언가가 지나가지 못하도록 막는 역할을 한다. 마찬가지로 CPU의 작업이 다음 프레임으로 먼저 이어지지 못하도록 기다리게 만들기 위해 즉, 작업이 진행되지 못하도록 울타리를 치는 개념으로 지어진 이름이라는 것이다. ID3D12Device의 멤버인 CreateFence로 Fence를 생성할 수 있다.

HRESULT CraeteFence(UINT64 InitialValue, D3D12_FENCE_FLAGS Flags, REFIID riid, _COM_Outptr_ void **ppFence);

// 이런 방식으로 호출하면 된다.
device->CreateFence(0, D3D12_FENCE_FLAG_NONE, IID_PPV_ARGS(&_fence));

 

지금까지 Fence의 내용을 정리하면 내부적으로 CPU와 GPU의 작업은 비동기적으로 일어나며 그로 인해 생기는 문제를 해결하기 위한 것이 바로 Fence라는 것이었으며 CPU의 작업이 끝나고 GPU의 작업이 끝날 때까지 울타리를 쳐서 작업이 진행되지 못하게 대기시키는 것이라고 했었다.

 

위에서 보이는 CreateFence라는 함수가 바로 그런 Fence를 만들기 위한 함수인데 첫 인자는 FenceValue의 초기값으로 일반적으로 0을 넣는다. FenceValue는 비동기적으로 진행되는 작업을 추적하기 위한 값이며 매 프레임마다 값이 증가된다. 그래서 FenceValue의 초기값인 첫 인자를 일반적으로 0을 넣는 것이다. FenceValue의 첫 프레임은 0번째 프레임이 될 것이기 때문이다.

 

최종적으로 어떤 프레임에서 FenceValue의 값이 증가할 때까지 CPU는 대기하다가 GPU가 일을 모두 처리하면 값을 올리는 방식으로 동작하게 된다는 것이다. 그럼 여기서 아래의 코드를 보자.

#define CreateEvent CreateEventW

// FenceValue의 변경을 알려줄 이벤트를 생성. DX가 아닌 Win32 API에서 제공되는 함수이다.
CreateEventW(_In_opt_ LPSECURITY_ATTRIBUTES lpEventAttributes,
    _In_ BOOL bManualReset, _In_ BOOL bInitialState, _In_opt_ LPCWSTR lpName);

// 현재 Fence의 FenceValue를 반환
UINT64 ID3D12Fence::GetCompletedValue(void);

// Fence에 있는 FenceValue의 값을 변경
HRESULT ID3D12Fence::Signal(ID3D12Fence *pFence, UINT64 Value);

// FenceValue와 첫 인자를 검사하고 조건이 충족되면 2번째 인자로 들어온 이벤트를 TRUE로 변경
HRESULT ID3D12Fence::SetEventOnCompletion(UINT64 Value, HANDLE hEvent);

void TCCommandQueue::Init(const Microsoft::WRL::ComPtr<ID3D12Device>& device)
{
	D3D12_COMMAND_QUEUE_DESC computeQueueDesc = {};
	computeQueueDesc.Type = D3D12_COMMAND_LIST_TYPE_COMPUTE;
	computeQueueDesc.Flags = D3D12_COMMAND_QUEUE_FLAG_NONE;

	device->CreateCommandQueue(&computeQueueDesc, IID_PPV_ARGS(&_cmdQueue));
	device->CreateCommandAllocator(D3D12_COMMAND_LIST_TYPE_COMPUTE, IID_PPV_ARGS(&_cmdAlloc));
	device->CreateCommandList(0, D3D12_COMMAND_LIST_TYPE_COMPUTE, _cmdAlloc.Get(), nullptr, IID_PPV_ARGS(&_cmdList));
	
	device->CreateFence(0, D3D12_FENCE_FLAG_NONE, IID_PPV_ARGS(&_fence));
	_fenceEvent = ::CreateEvent(nullptr, FALSE, FALSE, nullptr);
}

void TCCommandQueue::WaitSync()
{
	_fenceValue++;

	_cmdQueue->Signal(_fence.Get(), _fenceValue);

	if (_fence->GetCompletedValue() < _fenceValue)
	{
		_fence->SetEventOnCompletion(_fenceValue, _fenceEvent);
		WaitForSingleObject(_fenceEvent, INFINITE);
	}
}

 

맨 위의 함수 선언과 설명을 모두 읽었다면 WaitSync를 보자. 사실 CommandQueue의 Init은 맨 밑 부분의 CreateEvent를 통해서 이벤트를 생성했다는 것 외에는 딱히 주목할 부분은 없다. 이미 모두 다룬 내용이기 때문이다. WaitSync는 아래의 순서로 동작하게 된다.

  1. CPU의 작업이 끝났다는 뜻으로 _fenceValue를 1증가한다. 여기서 CPU의 작업이 끝났다는 것은 GPU가 작업하는데 필요한 렌더링 데이터가 모두 입력되었다는 뜻이다.
  2. 증가한 값을 커맨드 큐에 전달한다.
  3. 그리고 Fence에 있는 FenceValue와 조건을 검사한다. 만약 GPU가 작업을 모두 마쳤으면 if문 안으로는 들어가지 않게 된다.
  4. 이제 미리 만들었던 이벤트를 Fence에 등록한다. GPU가 일을 모두 마쳐서 Fence의 값이 인자로 전달된 _fenceValue와 같게 되면 _fenceEvent를 true로 만들어서 이벤트로 GPU의 일이 끝났다는 것을 알리는 것이다.
  5. WaitForSingleObject를 통해서 GPU의 일이 모두 끝날 때까지 기다린다. 끝나면 _fenceEvent를 통해 끝났다는 것을 통보한다. 그 전까지는 인자로 IFINITE를 넣었기 때문에 프로그램이 멈춰서 무한히 대기하는 상태가 된다.

 

참고로 이 흐름에는 치명적인 단점이 존재한다. 멀티 쓰레딩도 마찬가지겠지만 동기화라는 것을 시키는 과정에서 필연적으로 누군가가 작업을 마치기를 기다려야 하기 때문에 프로그램의 속도가 조금이라도 느려질 수 밖에 없기 때문이다. 이 단점을 얼마나 최소화시키느냐가 앞으로의 핵심 관건이 될 것이다. 지금은 인프런 강의의 예제 코드를 작성하는 중이기 때문에 우선 이렇게 코드를 작성한 상태에서 계속 진행하고자 한다.