2023. 6. 8. 16:58ㆍDirectX12
갑자기 뜬금없이 Rookiss님의 강의를 정주행하다가 블로그를 정주행하겠다는 제목이 올라와서 뜬금없을 수 있다. 근데 사실 강의를 보고 계속 공부를 하려는데 뭔가... 이해가 안되는 것이다. 더 정확히 말하자면 개운하지 않다는 것이다. 뭔가... 비주얼만 띄워놓고 말로만 설명하니까 귀로 듣는 것으로는 내용이 제대로 이해가 가지 않는 것 같았다. 다시 말해서 이대로 넘어가도 되는지 아닌지조차 스스로 확신이 서지 않았다는 것이다.
근데 이 블로그를 보고 이걸로 개념 정리를 해야겠다는 확신이 들게 되었다. 그래서 이 블로그를 정주행하면서 개념 정리도 좀 하고 Rookiss님의 강의에서 나왔던 코드들을 내 방식대로 정리도 좀 해볼 생각이다. 아래는 해당 블로그의 출처이다. 솔직히 내가 지금까지 정리했던 글보다 훨씬 정리가 잘 되어있는 것 같다. 또한 이 글에 올라오는 이미지는 전부 이 블로그에서 퍼온 것을 밝힌다. (광고아님)
https://ssinyoung.tistory.com/33?category=810267
1. 초기화의 순서
이전 Rookiss님의 강의를 보면 CPU가 그림을 그리는 연산을 하는데 있어서 그 작업을 GPU에게 외주를 맡긴다는 표현을 한 적이 있다. CPU가 GPU에게 외주를 맡기려면 우선 소스 코드 상에서 GPU에게 명령을 내릴 수 있도록 그 환경을 세팅해야 하고 필요한 곳에 접근할 수 있도록 인터페이스를 마련해야 하는데 여기서 그런 작업을 장치 초기화라고 부를 것이다. 장치 초기화가 이루어지는 순서는 아래와 같다.
- Device
- CommandQueue, CommandList
- SwapChain
- Fence
- Render Target과 Depth / Stencil 버퍼
우선 머릿속에 이 그림을 집어넣을 필요가 있다. 이 부분은 그냥 외우는 것이 좋고 운영체제나 하드웨어에 대해서 어느 정도 익숙한 사람은 억지로 외우려고 하지 않아도 머릿속에 금방 들어오게 될 것이다. 모든 컴퓨터는 하드웨어를 필요로 하며 그 위에 소프트웨어를 돌리려면 운영체제가 필요하다. 그리고 그 바로 위에 DXGI라는 것이 보일 것이다. 딱 봐도 중요해 보이지 않는가?
DXGI란? (DirectX Graphics Infrastructure)
이렇게 생긴 처음보는 단어가 나오면 각 단어들을 직역을 해서 그것을 문맥에 따라 해석하는 경우가 많다. 직역 ㄱㄱ
- DirectX = 말 그대로 DirectX
- Graphics = 말 그대로 그래픽스
- Infrastructrue = Infra(기반) + structure(구조) = 기반 구조
해석: DirectX 그래픽스 기반 구조. 즉, DirectX로 그래픽 작업을 하는데 필요한 기반이 되는 프로그램으로 유추할 수 있다.
실제로 운영체제와 매우 밀접하게 동작해서 상당히 저수준(Low-Level)의 작업들을 관리하며 현재 어떤 GPU, 모니터를 사용하는지 창의 모드는 전체 화면인지 창모드인지 등을 관리한다. 이후에 보게 될 D3D12라는 녀석은 이런 DXGI 위에서 동작하는 녀석이다. DXGI가 저수준을 처리하면 D3D12가 그 위에서 3D 그래픽 처리와 관련된 작업을 하는 것이다. 필자가 만들 게임 엔진은 위의 그림에서 응용 프로그램에 해당하는 것으로 DXGI와 D3D12를 적절하게 모두 사용해서 작업을 이어나가게 될 것이다.
그리고 그 과정에서 필연적으로 COM(Component Object Model)객체를 사용하게 될텐데 DX에서 제공하는 언어 독립성과 하위 호환성을 가능하게 하기 위해 만든 개념이다. DX에서 제공하는 실질적인 역할을 하는 클래스는 모두 COM객체이며 DX에서 제공하는 특정 함수를 사용해야만 생성할 수 있다. 또한 삭제를 하려면 반드시 Release함수를 호출해야 하니 주의해야 하며 COM객체를 전용으로 만든 ComPtr이라는 스마트 포인터도 있으니 잘 사용하면 좋다.
2. Device 초기화
DirectX는 DXGI와 GPU에 접근할 수 있는 기능을 제공하고 있는데 가장 먼저 그것을 가능하게 해주는 객체들을 생성할 것이다. DXGI와 D3D12Device라는 녀석인데 각각 DXGI와 GPU에 접근할 수 있는 인터페이스 정도로 보면 된다.
- DXGI: 모니터와 GPU에 접근할 수 있는 COM 객체
- ID3D12Device: GPU에 실질적인 렌더링 명령을 내리는 COM객체
둘 다 GPU의 일부에 접근한다는 공통점이 있지만 실질적인 역할에 그 차이가 있다. 어떠한 세팅이나 하드웨어적인 환경에 대한 정보를 불러오는 것 이외의 렌더링 작업을 위한 명령을 전달하느냐 하지 않느냐에 대한 차이가 바로 그것이라고 할 수 있다. 이제 코드를 보자.
void TCWindow::Init() noexcept
{
// 이 부분부터
DisplayWindow();
Resize(_width, _height);
_viewport = { 0, 0, static_cast<FLOAT>(_width), static_cast<FLOAT>(_height), 0.0f, 1.0f };
_scissorRect = CD3DX12_RECT(0, 0, _width, _height);
// 여기까지는 Win32 API에서 화면을 출력하는 부분이다.
// 이 부분부터
CreateDXGIFactory(IID_PPV_ARGS(&_dxgi));
D3D12CreateDevice(nullptr, D3D_FEATURE_LEVEL_11_0, IID_PPV_ARGS(&_device));
// 여기까지가 DXGI와 Device의 초기화 부분이다.
// 이 부분부터
InitCommandQueue();
InitSwapChain();
InitDescriptorHeap();
// 여기까지가 DXGI와 Device를 이용해서 초기화시키는 부분이다.
}
원래는 D3D12CreateDevice의 첫 번째 인자로 어댑터를 전달하도록 되어있는데 사용자가 현재 사용중인 GPU를 저 함수의 인자로 넘겨주는 것이다. 만약 nullptr이 전달되면 시스템이 기본으로 설정된 어댑터 즉, 현재 유저가 사용중인 GPU를 자동으로 등록시킨다. 필자는 딱히 필요하지 않아서 그냥 nullptr을 전달했다. 아주 간단하게 함수 호출 2번으로 DXGI와 Device의 생성이 완료되었는데 이제부터 DXGI와 Device를 이용해서 CommandQueue, SwapChain, DescriptorHeap을 초기화시킬 것이다.
3. CommandQueue 초기화
사실 CommandQueue라고는 했는데 CommandQueue뿐만 아니라 CommandList, CommandAllocator도 초기화해야 한다. 여기서 말하는 Command는 렌더링에 대한 명령을 의미하며 모두 GPU에게 명령을 내리기 위한 COM객체라고 이해하면 된다.
- CommandList: GPU에게 전달될 명령이 모이는 리스트
- CommandQueue: 명령 리스트들이 모여있는 큐
- CommandAllocator: GPU에게 전달될 명령을 저장하는 메모리 공간을 할당하는 할당자
정리하면 CPU가 GPU에게 일정한 주기마다 렌더링에 대한 명령들을 리스트로 담아서 GPU에 보내고 그런 명령들은 자신의 차례가 올 때까지 큐에 대기하게 되며 GPU가 처리할 명령을 저장할 공간을 CommandAllocator라고 부른다. 다만 그림을 보고 헷갈리면 안되는 것이 각각의 커맨드 리스트들은 각각의 할당자를 가지고 있으며 1:1 관계이니 헷갈리는 일이 없도록 하자. 간단하게 정리해봤으니 이제 코드를 보자.
void TCWindow::InitCommandQueue() noexcept
{
D3D12_COMMAND_QUEUE_DESC queueDesc = {};
queueDesc.Type = D3D12_COMMAND_LIST_TYPE_DIRECT;
queueDesc.Flags = D3D12_COMMAND_QUEUE_FLAG_NONE;
// 이 부분이
_device->CreateCommandQueue(&queueDesc, IID_PPV_ARGS(&_cmdQueue));
_device->CreateCommandAllocator(D3D12_COMMAND_LIST_TYPE_DIRECT, IID_PPV_ARGS(&_cmdAlloc));
_device->CreateCommandList(0, D3D12_COMMAND_LIST_TYPE_DIRECT, _cmdAlloc.Get(), nullptr, IID_PPV_ARGS(&_cmdList));
// 가장 중요!
_device->CreateFence(0, D3D12_FENCE_FLAG_NONE, IID_PPV_ARGS(&_fence));
_fenceEvent = ::CreateEvent(nullptr, FALSE, FALSE, nullptr);
}
주목할 점은 D3D12Device를 이용해서 커맨드 큐에 필요한 COM 객체들을 생성시키는 부분이다. 저 부분이 존재해야 커맨드 큐를 DX12를 이용해서 활용할 수 있다. 그리고 CreateCommandList에 할당자의 주소를 넘기는 부분이 보이는가? 아까 커맨드 리스트와 할당자가 1:1 관계라고 했는데 이것이 코드로 나타난 것이다.
D3D12_COMMAND_QUEUE_DESC는 커맨드 큐 서술자로 해석할 수 있는데 결국 커맨드 큐에 대한 설정이고 이걸 커맨드 큐를 생성하는 함수에 넘겨주는 것을 볼 수 있을 것이다. 여기서 나오는 D3D12_COMMAND_LIST_TYPE_DIRECT는 커맨드 큐에서 사용할 커맨드 리스트의 타입을 결정하는 것으로 이렇게 설정하면 GPU가 직접 실행할 명령 버퍼를 생성하겠다는 뜻이 된다. 설정의 나머지 자잘한 부분은 아래의 링크를 참고하자.
https://ssinyoung.tistory.com/34?category=810267
아까 커맨드 리스트가 명령들을 담는 리스트라고 이야기했다. 그렇다면 어떤 명령들이 올라올까? 아래의 이미지는 커맨드 리스트의 자식 클래스인 ID3D12GraphicsCommandList에서 내릴 수 있는 명령 목록이다. 당연하게 이것을 다 외울 필요는 없고 여기있는 Set함수들을 적절히 호출한 후에 그림을 그리도록 명령을 내리면 그거대로 그림을 그리는 흐름을 이해하면 된다.
아래는 커맨드 리스트가 생성된 후에 GPU에 명령이 전달되는 과정을 요약한 것이다.
- 일단 Command Allocator를 생성한다. 이는 커맨드 리스트에서 명령을 생성할 때마다 적재될 메모리를 관리하기 위함이다.
- 이제 커맨드 리스트를 생성한다. 1에서 생성된 Command Allocator를 커맨드 리스트를 생성하는 함수의 인자로 넣어서 커맨드 리스트와 1:1 매칭되는 관계가 된다.
- 커맨드 리스트가 생성되면 초기 상태가 되는데 이 상태가 되어야 커맨드 리스트에 명령을 넣을 수 있다. 이를 위해 Reset함수를 호출해야 한다. 이후에 커맨드 리스트를 통해 함수가 호출되면 명령이 커맨드 리스트에 쌓이게 된다.
- 이렇게 커맨드 리스트에 명령이 들어오면 리스트에 있는 각각의 명령들은 모두 커맨드 리스트와 매칭된 Command Allocator에 저장된다.
- 최종적으로 커맨드 리스트에서 Close가 호출되면 커맨드 리스트에 명령이 추가되지 않는 상태가 된다. 여기서 커맨드 큐에서 ExcuteCommandLists함수가 호출되면 커맨드 큐에 커맨드 리스트가 적재되는 것이다.
- 이러면 GPU가 일을 계속 처리하다가 하던 일을 다 처리하면 커맨드 큐에서 명령을 꺼내서 해당 명령을 수행한다.
이 과정에서 가장 중요한 것은 "CommandList에서 함수가 호출된다고 해서 그 명령이 바로 실행되는 것이 아니다." 라는 것이다. 커맨드 큐에 전달된 후에 GPU가 커맨드 큐에서 명령을 꺼내서 처리를 해야 실행된다.
이 코드는 위의 과정대로 커맨드 큐를 통해 GPU에게 명령을 내려서 더블 버퍼링을 구현하는 과정이다. 여기서 중요한 것은 각 함수들이 무슨 역할을 하는지 일일히 찾아서 외우는 것이 아니라 그 흐름을 이해하는 것이다. 처음에 Reset함수가 호출되는 부분부터 모든 명령이 추가되고 Close시킨 후에 커맨드 큐에 ExecuteCommandLists로 커맨드 큐에 커맨드 리스트를 넘기는 부분까지 말이다.
void TCWindow::RenderBegin() noexcept
{
_cmdAlloc->Reset();
_cmdList->Reset(_cmdAlloc.Get(), nullptr);
// 화면에 보여주는 버퍼를 그리기 버퍼로 설정을 바꿈
D3D12_RESOURCE_BARRIER barrier = CD3DX12_RESOURCE_BARRIER::Transition(
_renderTargets[_backBufferIndex].Get(), D3D12_RESOURCE_STATE_PRESENT, D3D12_RESOURCE_STATE_RENDER_TARGET);
// 커맨드 리스트에 설정 적용
_cmdList->ResourceBarrier(1, &barrier);
// 뷰포트, ScissorRect 설정
_cmdList->RSSetViewports(1, &_viewport);
_cmdList->RSSetScissorRects(1, &_scissorRect);
// 배경을 색깔 하나로 초기화
D3D12_CPU_DESCRIPTOR_HANDLE backBufferView = _rtvHandle[_backBufferIndex];
_cmdList->ClearRenderTargetView(backBufferView, DirectX::Colors::LightSteelBlue, 0, nullptr);
_cmdList->OMSetRenderTargets(1, &backBufferView, FALSE, nullptr);
}
void TCWindow::RenderEnd() noexcept
{
// 그리기 버퍼를 화면에 보여주는 버퍼로 설정을 바꿈
D3D12_RESOURCE_BARRIER barrier = CD3DX12_RESOURCE_BARRIER::Transition(
_renderTargets[_backBufferIndex].Get(), D3D12_RESOURCE_STATE_RENDER_TARGET, D3D12_RESOURCE_STATE_PRESENT);
// 커맨드 리스트에 설정 적용
_cmdList->ResourceBarrier(1, &barrier);
// 커맨드 리스트를 닫고 커맨드 큐에 커맨드 리스트를 전달
_cmdList->Close();
ID3D12CommandList* cmdListArr[] = { _cmdList.Get() };
_cmdQueue->ExecuteCommandLists(_countof(cmdListArr), cmdListArr);
// 여기서 실제로 더블 버퍼링으로 백 버퍼와 프론트 버퍼가 바뀐다.
_swapChain->Present(0, 0);
// CPU와 GPU 싱크 맞추기
WaitSync();
// 백 버퍼와 프론트 버퍼에 대한 인덱스를 바꿈
_backBufferIndex = (_backBufferIndex + 1) % SWAP_CHAIN_BUFFER_COUNT;
}
주의점
위의 코드에서 커맨드 큐의 ExecuteCommandList를 보면 인자로 커맨드 리스트의 주소가 아니라 커맨드 리스트 배열의 주소를 넘기는 것을 알 수 있다. 즉, 여러 개의 커맨드 리스트를 한꺼번에 넘길 수 있다는 뜻이다. 하지만 그럼에도 불구하고 맨 아래에 있는 블로그 링크에 따르면 일반적으로는 커맨드 리스트를 1개만 사용하는 것을 권장한다. 만약 다음과 같이 커맨드 리스트 배열이 있고 이것을 커맨드 큐에 넘긴다고 해보자.
커맨드 큐 하나에는 커맨드 리스트가 여러 개가 적재되는 것이 가능하다. 1번 리스트부터 10번 리스트까지 모두 커맨드 큐에 적재된 상태라고 가정해보자. 만약 이렇게 되면 명령의 순서가 1, 2, 3, ... 이 될 수도 있지만 10, 9, 8, ... 순서로 실행될 수도 있기 때문에 어지간하면 1개의 커맨드 리스트를 사용한다고 한다. 또한 ExecuteCommandList 자체의 호출이 무겁기 때문에 여러 개를 사용하는 것은 좋지 않다고 한다.
그러나 만약 싱글쓰레드라면 커맨드 리스트가 1개면 충분하지만 그게 아니라면 멀티쓰레딩을 위해 커맨드 리스트가 여러 개가 필요할 수 있다. 그런 경우에는 여러 개의 쓰레드가 1개의 커맨드 리스트를 동시에 참조할 수 없다는 것을 미리 알아두는 것이 좋다.
출처: DirectX 12 장치 초기화 이해하기 (2) (tistory.com)
'DirectX12' 카테고리의 다른 글
루키스 게임수학 정주행 5회차 - 장치 초기화 최종 정리 (2) | 2023.07.06 |
---|---|
루키스 게임수학 정주행 4회차 - "또" 장치 초기화 (0) | 2023.06.19 |
루키스 게임수학 정주행 3회차 - 장치 심화 (0) | 2023.06.06 |
루키스 게임수학 정주행 2회차 - 번외(버그) (0) | 2023.05.17 |
루키스 게임수학 정주행 2회차 - 장치 초기화 (2) | 2023.05.10 |