일 | 월 | 화 | 수 | 목 | 금 | 토 |
---|---|---|---|---|---|---|
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
- DirectX
- C++
- 입방체 매핑
- Deferred Rendering
- light
- Frustum Culling
- 노멀 맵핑
- effective C++
- FrameResource
- 동적 색인화
- gitscm
- 게임 클래스
- 직교 투영
- 장치 초기화
- DirectX12
- Render Target
- 조명 처리
- direct3d
- 네트워크 게임 프로그래밍
- 게임 프로그래밍
- 큐브 매핑
- 디퍼드 렌더링
- 절두체 컬링
- gitlab
- TCP/IP
- Direct3D12
- InputManager
- 게임 디자인 패턴
- 네트워크
- Today
- Total
코승호딩의 메모장
[Direct3D 개요와 초기화] 본문
·
DirectX 12를 이용한 3D 게임 프로그래밍 입문 : 네이버 도서
네이버 도서 상세정보를 제공합니다.
search.shopping.naver.com
이번 포스팅(3D 게임 프로그래밍 입문)에서는 위 책과 한국공학대학교 게임공학과 이용희 교수님 수업 3D 게임 프로그래밍의 강의자료 및 수업내용을 기반으로 DirectX12를 이용한 게임 프로그래밍에 대해 기술하며 예제 및 연습 문제를 푸는 방식으로 진행하도록 하겠습니다. 또한 구조체나 함수의 매개변수와 자세한 정보는 MSDN의 링크를 걸었으니 참고하시면 됩니다. 이번 포스팅은 [DirectX12 입문]에서 공부한 내용을 기반으로 [DirectX12 응용] 포스팅에서 다양한 기법을 활용하여 게임을 제작하도록 하겠습니다.
01 Direct3D 12의 개요
Direct3D란 응용 프로그램에서 GPU를 제어하고 프로그래밍하는 데 쓰이는 저수준 그래픽 API이다. 이를 통해 응용 프로그램은 3차원 세계를 렌더링할 수 있다. 응용 프로그램과 그래픽 하드웨어 사이에 Direct3D라는 간접층과 하드웨어 드라이버가 Direct3D 명령들을 GPU가 이해하는 고유한 기계어 번역해 주는 것이다.
Direct3D 12가 이전 버전들에 비한 주된 개선점은 CPU 부담을 크게 줄이고 다중 스레드 지원을 개선하기 위해 설계를 다시 했다는 점이다. 때문에 더 낮은 수준의 API가 되었으며 추상화가 출었고 개발자가 손수 관리해야 할 사항들이 늘었다. 요약하면 API를 사용하기가 좀 더 어려워졌지만, 대신 성능이 개선되었다.
COM
COM(Component Object Model)은 DirectX의 언어 독립성과 하위 호환성을 가능하게 하는 기술로 C++로 DirectX를 프로그래밍할 때 COM의 세부사항 대부분은 프로그래머에게 드러나지 않고 메서드들만 노출한다. 따라서 프로그래머가 알아야 할 것은 COM을 가리키는 포인터를 특별한 함수를 통해 얻는 방법뿐이다. COM은 new 키워드로 직접 생성할 일이 없으며 사용이 끝나면 delete로 삭제하는 것이 아니라 COM의 메서드인 Release를 호출해 주어야 한다. COM은 참조 횟수가 0이 되면 메모리에서 해제된다. 그러나 C++의 스마트 포인터처럼 COM 객체의 수명 관리를 돕기 위한 Microsoft::WRL::ComPtr이라는 클래스가 제공된다. 이 클래스는 자동으로 Release를 호출해준다. 다음은 ComPtr의 메서드 중 주로 사용하는 것이다.
- Get : COM을 가리키는 포인터를 돌려준다. 해당 COM 인터페이스 포인터 형식의 인수를 받는 함수를 호출할 때 흔히 쓰인다.
- GetAddressOf : COM을 가리키는 포인터의 주소를 돌려준다. 함수 매개변수를 통해 COM 포인터를 돌려받을 때 흔히 쓰인다.
- Reset : ComPtr 인스턴스를 nullptr로 설정하고 참조 횟수를 1 감소한다. 인스턴스에 직접 nullptr 를 배정해도 된다.
텍스쳐 형식
2차원 텍스처는 자료 원소들의 2차원 배열이다. 2차원 텍스처의 용도 중 하나는 2차원 이미지 자료를 저장하는 것이며 이때 텍스처 각 원소는 픽셀 하나의 색상을 담는다. 이 밖에도 텍스처는 색상이 아니라 3차원 벡터를 담을 수도 있다. 이처럼 텍스처는 단순한 자료 배열이 아닌 밉맵 수준들도 존재할 수 있으며 필터링이나 다중 표본화 등의 특별한 연산을 적용할 수도 있다. 여기서 중요한 점은 텍스처에 아무 자료나 담을 수 있는 것이 아닌 특정 형식(format)의 원소들만 담을 수 있다는 점이다. 다음으로 텍스처에 담을 수 있는 자료 원소 형식들의 몇 가지 예를 들어보겠다.
- DXGI_FORMAT_R32G32B32_FLOAT : 각 원소는 32비트 부동소수점 성분 세 개로 이뤄짐
- DXGI_FORMAT_R8G8B8A8_UNORM : 각 원소는 [0, 1] 구간으로 사상되는 부호 없는 8비트 성분 네 개로 이뤄짐
- DXGI_FORMAT_R8G8B8A8_SINT : 각 원소는 [-128, 127] 구간으로 사상되는 부호 있는 8비트 성분 네 개로 이뤄짐
- DXGI_FORMAT_R16G16B16A16_TYPELESS : 무형식 텍스처로 메모리만 확보해 두고 자료의 구체적 해석 방식은 나중에 텍스처를 파이프라인에 묶을 때 지정하는 용도로 쓰인다.
다음처럼 텍스처에는 아무 자료나 담을 수 있는 것은 아니지만 반드시 색상만을 담아야 하는 것도 아니다. DXGI_FORMAT_R32G32B32_FLOAT의 경우 3차원 벡터를 담을 수 있는 것이다. DXGI_FORMAT 형식은 정점 자료 형식과 색인 자료 형식을 서술할 때에도 쓰인다.
교환 사슬(Swap Chain)
애니메이션이 껌벅이는 현상을 피하기 위해선 두 개의 텍스처 버퍼가 필요하다. 이러한 기법을 이중 버퍼링이라고 부르는데 하나는 전면 버퍼, 하나는 후면 버퍼이다. 전면 버퍼가 화면에 표시되는 동안 다음 프레임을 후면 버퍼에 그리고 다 그려지면 후면 버퍼와 전면 버퍼를 맞바꾼다. 이 두 버퍼는 하드웨어로 관리하며 후면 버퍼와 전면 버퍼를 교환해서 페이지가 전환되게 하는 것을 Direct3D에서는 제시(presenting)라고 부른다. 전면 버퍼와 후면 버퍼는 하나의 교환 사슬을 형성한다. 교환 사슬은 전면 버퍼와 후면 버퍼 텍스처를 담고 있는 것이다.
- 플리핑(Flipping) : 하드웨어적인 방법으로 전면버퍼와 후면버퍼의 포인터만 바꾸는 방법
- 블리트(Blit) : 후면버퍼의 내용을 전면버퍼에 복사하는 방법
플리핑은 전체화면 모드일 경우에만 동작하며 블리트는 모든 내용을 복사해야 하기 때문에 속도가 느리다. 또한 응용 프로그램은 전면 버퍼에 접근이 불가능하고 후면 버퍼에만 접근이 가능하다.
깊이 버퍼링
깊이 버퍼는 이미지 자료를 담지 않는 텍스처중 하나이다. 깊이 버퍼는 각 픽셀의 깊이 정보를 담으며 깊이는 가장 가까운 물체에 해당하는 0부터 가장 먼 물체에 해당하는 1에 해당한다. 깊이 버퍼와 후면 버퍼의 픽셀들은 일대일로 대응되며 만약 후면 버퍼 해상도가 1280x1024라면 깊이 버퍼도 1280x1024이다.
왼쪽 그림은 몇몇의 물체가 다른 물체들을 가리고 있는 장면이며 이를 판정하기 위해 Direct3D는 깊이 버퍼링 또는 z 버퍼링이라는 기법을 사용한다. 여기서 중요한 점은, 깊이 버퍼링은 그리는 순서와 무관하다는 것이다. 깊이 문제를 해결하는 방법 중 하나는 물체를 먼 것부터 가까운 것 순서로 그리는 것이지만, 모든 물체들을 정렬해야 하므로 시간이 오래 걸릴 수 있고 맞물린 물체들은 제대로 처리하지 못한다. 반면에 깊이 버퍼링은 하드웨어에서 공짜로 일어나며 맞물린 물체도 제대로 그릴 수 있다.
다음으로 오른쪽 그림은 물체들이 렌더링되며 깊이가 갱신되는 과정이다. 우선 깊이 버퍼는 일바적으로 픽셀이 가질 수 있는 최대 깊이 1을 기본값으로 사용한다. 이 후 그림과 같이 하나의 픽셀에 서로 다른 세 개의 픽셀이 투영된다면 픽셀과 해당 깊이 값을 조사하여 해당 깊이 값이 픽셀 값보다 더 작다면 판정에 성공하여 버퍼가 해당 깊이를 가진 픽셀로 갱신된다.
이러한 깊이 버퍼도 하나의 텍스처이기 때문에 생성 시 특정한 Format을 지정해야 한다.
- DXGI_FORMAT_D32_FLOAT_S8X24UINT : 각 텍셀은 32비트 부동소수점 깊이 값과 [0, 255] 구간으로 사상되는 부호 없는 8비트 정수 스텐실 값, 그리고 패딩용으로 쓰이는 24비트로 구성된다.
- DXGI_FORMAT_D32_FLOAT : 각 텍셀은 하나의 32비트 부동소수점 깊이 값이다.
- DXGI_FORMAT_D24_UNORM_S8_UINT : 각 텍셀은 [0, 1] 구간으로 사상되는 부호 없는 깊이 값 하나와 [0, 255] 구간으로 사상되는 부호 없는 8비트 정수 스텐실 값으로 구성된다.
- DXGI_FORMAT_D16_UNORM : 각 텍셀은 [0, 1] 구간으로 사상되는 부호 없는 16비트 값이다.
자원과 서술자
렌더링 과정에서 GPU는 후면 버퍼나 깊이 버퍼와 같은 자원들에 자료를 기록하거나 텍스처와 같은 자원들에 자료를 읽어 들인다. 그리기 명령을 제출하기 전, 먼저 그리기 호출이 참조할 자원들을 렌더링 파이프라인에 묶어야 한다. 그런데 GPU 자원들이 파이프라인에 직접 묶이는 것이 아니라 실제로 묶이는 것은 서술자(descriptor)이다. 서술자는 자원을 GPU에게 서술해주는 경량의 자료구조이다. 자원 자체는 렌더 대상으로 쓰여야 하는지 깊이 스텐실 버퍼로 쓰여야 하는지 셰이더 자원으로 쓰여야 하는지 아무 말도 하지 않기 때문에 서술자가 Direct3D에게 자원의 사용법을 말해주는 것이다.
- CBV/SRV/UAV : 상수 버퍼, 셰이더 자원, 순서 없는 접근 자원을 서술한다.
- sampler : 텍스처 적용에 쓰이는 표본추출기 자원을 서술한다.
- RTV : 렌더 대상 자원을 서술한다.
- DSV : 깊이 스텐실 자원을 서술한다.
이처럼 응용 프로그램이 사용하는 서술자들이 저장되는 곳이 바로 서술자 힙(descriptor heap)이다. 서술자 종류마다 개별적인 서술자 힙이 필요하며 같은 종류의 서술자들은 같은 서술자 힙에 저장된다. 또한 한 종류의 서술자에 대해 여러 개의 힙을 둘 수 있다. 하나의 자원에 대한 서술자가 여러 개 일수도 있는데 예를 들어 하나의 텍스처를 렌더 대상이자 셰이더 자원으로 사용하고 싶다면 RTV와 SRV 형식의 서술자를 만들 수 있다. 서술자들은 응용 프로그램의 초기화 시점에서 생성하는 것이 낫다.
다중표본화
모니터의 픽셀은 무한히 작지 않기 때문에 화면에 선을 완벽하게 나타내는 것은 불가능 하며 다음 그림과 같이 선을 픽셀들의 배열로 근사하기 때문에 위 그림과 같은 계단 현상 앨리어싱 효과가 나타난다. 이 앨리어싱 효과를 제거하는 기법 중 하나가 초과표본화(supersampling)이다. 초과 표본화는 후면, 깊이 버퍼를 해상도보다 4배 크게 잡고, 3차원 장면을 4배 크기의 해상도에서 후면 버퍼에 렌더링 한다. 이미지를 화면에 presenting할 때, 후면 버퍼를 원래 크기로 하향표본화 하는데 4픽셀 블록의 네 색상의 평균을 그 블록에 해당하는 픽셀의 최종 색상으로 사용하는 것이다. 초과표본화는 픽셀 처리량과 메모리 소비량이 네 배이기 때문에 비용이 높다. 따라서 Direct3D는 다중표본화를 지원한다.
다중표본화 또한 해상도의 4배인 후면, 깊이 버퍼를 사용하지만 각 부분픽셀마다 계산하는 것이 아닌 픽셀당 한 번만 픽셀의 중심에서 계산하고 그 색상과 부분픽셀들의 가시성과 포괄도를 이용해서 최종 색상을 결정한다. 오른쪽 그림과 같이 다중표본화는 이미지 색상이 픽셀의 중심에서 한 번만 계산되어 그 색상이 다각형에 덮이 모든 부분픽셀에 복제된다. 그러나 초과표본화는 이미지 색상이 부분픽셀별로 계싼되기 때문에 한 픽셀의 부분픽셀들의 색이 각자 다를 수 있다. 따라서 각자의 장단점이 있으며 다중표본화는 비용이 낮지만 초과표본화보다 정확한 결과는 아니다.
Direct3D에서의 다중표본화
다중표본화를 위해서 DXGI_SAMPLE_DESC라는 구조체 인스턴스를 적절히 채워야 한다.
struct DXGI_SAMPLE_DESC
{
UINT Count; // 픽셀당 추출할 표본의 개수
UINT Quality; // 원하는 품질 수준
}
D3D12_FEATURE_DATA_MULTISAMPLE_QUALITY_LEVELS msQualityLevels;
msQualityLevels.Format = mBackBufferFormat;
msQualityLevels.SampleCount = 4;
msQualityLevels.Flags = D3D12_MULTISAMPLE_QUALITY_LEVELS_FLAGS_NONE;
msQualityLevels.NumQualityLevels = 0;
md3dDevice->CheckFeatureSupport(
D3D12_FEATURE_MULTISAMPLE_QUALITY_LEVELS,
&msQualityLevels,
sizeof(msQualityLevels)));
주어진 텍스처 형식과 표본 개수의 조합에 대한 품질 수준들의 개수는 ID3D12Device::CheckFeatureSupport 메서드로 알아낼 수 있다. 이 메서드는 둘째 매개변수로 텍스처 형식과 표본 개수를 읽고, 그에 해당하는 품질 수준 개수를 NumQualityLevels의 멤버에 설정한다. 실제 응용에서는 대부분 표본을 4개나 8개만 추출하는 경우가 많다. 만약 다중표본화를 사용하고 싶지 않다면 표본 개수를 1로, 품질 수준을 0으로 설정하면 된다.
기능 수준
기능 수준(feature level)들은 GPU가 지원하는 기능들의 집합을 정의한다. 현재 GPU의 기능 수준을 파악하면, 어떤 기능을 사용할 수 있는지를 알 수 있고, 사용자의 하드웨어가 특정 기능 수준을 지원하지 않는 경우 응용 프로그램이 실행을 아예 포기하는 대신 더 낮은 기능 수준으로 후퇴하는 전력을 사용할 수 있다. 응용 프로그램은 먼저 하드웨어가 Direct3D 12를 지원하는지 점검 후 아니라면 11로 내려간다. 따라서 실제 응용 프로그램에서는 사용자층을 최대화하기 위해 구형 하드웨어 지원도 신경써야 할 것이다.
DXGI
DXGI는 Direct3D와 함께 쓰이는 API로 전체 화면 모드 전환, 디스플레이 어댑터나 모니터, 디스플레이 모드 같은 그래픽 시스템 정보의 열거 등의 기능을 제공한다. 또한 format과 같은 형식들도 정의되어 있다. DXGI의 핵심 인터페이스 중 하나인 IDXGIFactory는 주로 인터페이스 생성과 디스플레이 어댑터(그래픽 카드) 열거에 쓰인다. 어댑터를 열거한 뒤 이에 맞물려 있는 디스플레이 모드 열거 또한 전체 화면 모드로 갈 때 특히 중요하다. 전체 화면 성능을 극대화하기 위해선 지정된 디스플레이 모드가 반드시 모니터가 지원하는 디스플레이 모드와 일치해야 하기 때문이다. 따라서 모니터가 지원하는 디스플레이 모드들을 열거하여 그 중 하나를 지정하면 그러한 일치가 보장된다.
상주성
복잡한 게임들은 텍스처나 메시 등 수많은 자원을 사용한다. 그런데 이 자원들 모두를 GPU에서 항상 사용할까? 그렇지 않다. 예를 들어 숲과 동굴이 배경인 게임에서 동굴에 사용되는 자원은 동굴에 들어가기 전까지는 필요하지 않다. 그리고 동굴에 들어가면 숲의 자원 또한 필요하지 않다. 따라서 Direct3D 12에서는 GPU 메모리에 있는 자원을 내림(퇴거)과 필요시 올림(입주)으로써 자원의 상주성을 관리할 수 있다. 성능의 측면에서는 짧은 시간 내에 자원을 GPU 메모리에 올렸다 내렸다 하는 상황은 피해야 한다. 이상적으로는 게임의 레벨이나 지역이 바뀌는 시점과 같이 자원 상주성을 변경하기 적합한 시점을 정해야 한다. ID3D12Device::MakeResident와 ID3D12Device::Evict 함수를 통해 응용 프로그램이 상주성을 직접 제어할 수 있다. 각각의 매개 변수에는 자원들의 배열과 자원들의 개수가 들어간다.
02 CPU와 GPU의 상호작용
CPU와 GPU는 병렬로 작동하지만, 때때로는 동기화가 필요하다. 최적의 성능을 얻기 위해선 둘 다 바쁘게 돌아가야 하며 동기화를 최소화해야 한다. 동기화는 한 처리 장치가 작업을 마칠 때까지 다른 처리 장치가 놀고 있어야 함을 의미하며 병렬성을 망친다.
명령 대기열과 명령 목록
CPU는 그리기 명령들이 담긴 명령 목록(Command List)을 Direct3D API를 통해 GPU에 있는 명령 대기열(Command Queue)에 제출한다. 명령 대기열에 명령 목록을 제출하여도 GPU가 그 명령들을 바로 실행하는 것이 아니라 GPU가 처리할 준비가 되면 명령들을 처리한다. CPU가 제출한 명령들은 GPU 명령 대기열에 쌓이며 이전에 제출된 명령들을 처리하다가 해당 명령 시점이 되면 그제서야 처리하게 되는 것이다. 명령 대기열이 비면 GPU가 할 일이 없다는 뜻이고 반대로 명령 대기열이 꽉 차면 명령 대기열에 자리가 생길 때까지 CPU가 놀게 된다. 따라서 게임과 같은 성능이 중요시 되는 응용 프로그램에서는 CPU와 GPU를 둘 다 쉬지 않고 바쁘게 돌아가게 만드는 것이 궁극적인 목표이다.
명령 대기열을 대표하는 인터페이스는 ID3D12CommandQueue이며 이를 생성하기 위해서는 D3D12_COMMAND_QUEUE_DESC 구조체를 채워 ID3D12Device::CreateCommandQueue를 호출해야 한다. 명령 대기열의 주요 메서드 중 하나는 명령 목록에 있는 명령들을 명령 대기열에 추가하는 ID3D12CommandQueue::ExecuteCommandLists이다. 명령 목록들은 배열의 첫 원소부터 차례대로 실행이 된다. 예를 들어 commandList->RSSetViewports나 commandList->DrawInstanced와 같은 명령들은 ExecuteCommandLists를 호출해야 명령 대기열에 추가가 되고 GPU가 나중에 명령들을 하나씩 처리한다. 명령 목록에 모든 명령을 추가하였다면 ID3D12GraphicsCommandList::Close 메서드를 호출하여 명령들의 기록이 끝났음을 알려줘야 한다. 반드시 ExecuteCommandLists로 명령 목록을 제출하기 전 명령 목록을 닫아야 한다.
명령 목록에는 메모리 할당자(ID3D12CommandAllocator)가 하나 연관된다. 명령 목록에 추가된 명령들은 이 할당자의 메모리에 저장되고 ExecuteCommandLists로 명령 목록을 실행하면 명령 대기열은 할당자에 담긴 명령들을 참조하는 것이다.
명령 목록에 대하여 주의할 점은 명령들을 여러 명령 목록에 동시에 기록할 수 없다는 것이다. 현재 명령들을 추가하는 명령 목록을 제외한 모든 명령 목록은 닫혀 있어야 한다. 이렇게 해야 한 명령 목록의 모든 명령이 할당자 안에 인접해서 저장된다. 명령 목록을 생성하거나 재설정하면 열린 상태가 되기 때문에 같은 할당자로 두 명령 목록을 연달아 생성하면 오류가 생긴다. ID3D12CommandList::Reset메서드를 호출하면 내부 메모리를 새로운 명령들을 기록하는 데 재사용할 수 있다. 때문에 처음 생성했을 때와 같은 상태로 만드는 것이다. 그러나 이처럼 명령 목록을 재설정하더라도 명령 대기열에 있는 명령들은 영향이 미치지 않는다. 명령 대기열이 참조하는 명령들은 연관된 명령 할당자의 메모리에 남아 있기 때문이다.
하나의 프레임을 렌더링 하는데 필요한 명령들을 모두 GPU에 제출한 후 명령 할당자 메모리를 다음 프레임을 위해서 재사용해야 한다. 이때 ID3D12CommandAllocator::Reset 함수를 이용한다. 여기서 명령 대기열이 할당자 안의 자료를 참조할 수 있기 때문에 GPU가 명령 할당자에 담긴 모든 명령을 실행하였음이 확실해지기 전까지는 명령 할당자를 재설정하지 말아야 한다.
CPU/GPU 동기화
하나의 시스템에서 CPU와 GPU가 병렬로 실행되다 보면 여러 가지 문제가 발생한다. 위 그림과 같이 그리고자 하는 어떤 기하구조의 위치를 R이라는 자원에 담는다고 하였을 때, p1에 그리려는 목적으로 CPU는 위치 p1을 R에 추가하고 그리기 명령 C를 대기열에 추가한다. CPU는 다음 단계로 넘어가서 GPU가 그리기 명령 C를 실행하기 전, CPU가 새 위치 p2를 p1에 덮어쓰면, 기하구조는 의도했던 위치에 그려지지 않는다. 이 문제의 해결책 하나는 GPU가 명령 대기열의 명령들 중 특정 지점까지의 모든 명령을 다 처리할 때까지 CPU를 기다리게 하는 것이다. 이때 필요한 것이 ID3D12Fence이다.
fence는 UINT64 값 하나를 관리하는데 이 값은 시간상의 특정 울타리 지점을 식별하는 정수이다. 처음에는 이 값을 0으로 두고, 새 울타리 지점을 만들 때마다 이 값을 1씩 증가시킨다. 다음은 fence를 이용해 명령 대기열을 비우는 방법이다.
UINT64 mCurrentFence = 0;
void D3DApp::FlushCommandQueue()
{
mCurrentFence++;
ThrowIfFailed(mCommandQueue->Signal(mFence.Get(), mCurrentFence));
if(mFence->GetCompletedValue() < mCurrentFence)
{
HANDLE eventHandle = CreateEventEx(nullptr, false, false, EVENT_ALL_ACCESS);
ThrowIfFailed(mFence->SetEventOnCompletion(mCurrentFence, eventHandle));
WaitForSingleObject(eventHandle, INFINITE);
CloseHandle(eventHandle);
}
}
위 코드는 자세히 살펴보자. 우선 현재 fence의 값은 0일 것이다. 그리고 mCurrentFence의 값은 0에서 1이 되며 Signal 함수를 통해 fence를 mCurrentFence의 값 즉 1로 설정하는 명령을 명령 대기열에 추가한다. 알다시피 위 명령은 바로 실행되는 것이 아닌 명령 대기열에 추가만 한 것이다. 따라서 fence의 값이 1이 아닐 수 있기 때문에 if 문에서 걸린다. if문 내에서는 fence의 값이 mCurrentFence의 값과 같아질 때까지 기다리다가 같아진다면 이벤트를 호출하여 다음 프레임으로 넘어간다.
따라서 만약 그리기 명령 후 FlushCommandQueue 함수를 호출한다면 그리기 명령 바로 위에 현재 fence의 값을 다음 값으로 변경하는 Signal 명령이 추가될 것이고 이 Signal 명령이 호출되기 전까지는 CPU를 기다리게 하는 것이다. 그렇게 된다면 그리기 명령을 GPU가 모두 처리할 때까지 CPU는 놀것이다.
그러나 GPU의 작업이 끝날 때까지 CPU를 기다리게 하는 것은 이상적인 해결책이 아니다. 나중에 포스팅에서 FrameResource라는 더 좋은 기법을 소개하도록 하겠다.
자원 상태 전이
흔히 한 단계에서 GPU가 자원 R에 자료를 기록하고 이후의 단계에서 그 자원 R의 자료를 읽는 식으로 구현이 된다. 그런데 GPU가 자원에 다 기록하지 않았거나 기록을 시작하지도 않은 상태에서 자원을 읽으려 하면 문제가 생긴다. 이를 자원 위험 상황이라고 부른다. 이를 해결하기 위해서 Direct3D는 자원들에 상태를 부여한다. 상태 전이를 Direct3D에게 보고하는 것은 전적으로 응용 프로그래머의 일이다. 예를 들어 텍스처에 기록해야 한다면 텍스처의 상태를 렌더 대상 상태로 설정하고 읽어야 한다면, 셰이더 자원 상태로 변경한다. 이렇게 응용 프로그래머가 Direct3D에게 자원의 상태 전이를 보고함으로써, GPU는 자원 위험 상황을 피할 수 있다.
자원 상태 전이는 전이 자원 장벽(barrier)들의 배열을 설정해서 지정한다. 때문에 한 번의 API 호출로 여러 개의 자원을 전이할 수 있다. 자원 장벽은 D3D12_RESOURCE_BARRIER_DESC 구조체로 서술하지만 마이크로 소프트의 d3dx12.h가 비공식적으로 제공되며 이를 활용하면 보조 함수로 쉽게 사용할 수 있다. CD3DX12_RESOURCE_BARRIER_DESC는 D3D12_RESOURCE_BARRIER_DESC를 상속받고 편의용 메서드들을 추가한 확장 버전이다.
D3D12_RESOURCE_BARRIER d3dResourceBarrier;
::ZeroMemory(&d3dResourceBarrier, sizeof(D3D12_RESOURCE_BARRIER));
d3dResourceBarrier.Type = D3D12_RESOURCE_BARRIER_TYPE_TRANSITION;
d3dResourceBarrier.Flags = D3D12_RESOURCE_BARRIER_FLAG_NONE;
d3dResourceBarrier.Transition.pResource = m_ppd3dSwapChainBackBuffers[m_nSwapChainBufferIndex];
d3dResourceBarrier.Transition.StateBefore = D3D12_RESOURCE_STATE_PRESENT;
d3dResourceBarrier.Transition.StateAfter = D3D12_RESOURCE_STATE_RENDER_TARGET;
d3dResourceBarrier.Transition.Subresource = D3D12_RESOURCE_BARRIER_ALL_SUBRESOURCES;
m_pd3dCommandList->ResourceBarrier(1, &d3dResourceBarrier);
위 코드는 보조 함수를 사용하지 않은 자원 전이 방법이다. 그러나 아래 코드와 같이 보조 함수를 통해 간결하게 사용할 수 있다.
mCommandList->ResourceBarrier(1,
&CD3DX12_RESOURCE_BARRIER::Transition(
CurrentBackBuffer(),
D3D12_RESOURCE_STATE_PRESENT,
D3D12_RESOURCE_STATE_RENDER_TARGET));
위 코드들은 텍스처 자원을 present 상태에서 렌더 대상 상태로 전이한다. 주목할 점은 자원 장벽이 명령 목록에 추가된다는 것이다. 전이 자원 장벽을 GPU에게 자원의 상태가 전이됨을 알려주는 하나의 명령이라고 생각하면 된다. 이 명령을 통해 자원 상태 전이를 알려줌으로써 GPU는 이후 명령을 실행할 때 자원 위험 상황을 피할 수 있게 된다.
03 Direct3D 초기화
다음으로 Direct3D 초기화 과정에 대해서 살펴본다. 초기화 과정은 꽤나 길지만, 응용 프로그램 실행 시 한 번만 해 주면 된다. 아래의 과정을 차례대로 서술하도록 한다.
- D3D12CreateDevice 함수를 이용해 ID3D12Device를 생성한다.
- ID3D12Fence 객체를 생성하고 서술자들의 크기를 얻는다.
- 4X MSAA 품질 수준 지원 여부를 점검한다.
- 명령 대기열과 명령 할당자, 명령 목록을 생성한다.
- 교환 사슬을 서술하고 생성한다.
- 응용 프로그램에 필요한 서술자 힙들을 생성한다.
- 후면 버퍼의 크기를 설정하고, 후면 버퍼에 대한 렌더 대상 뷰를 생성한다.
- 깊이·스텐실 버퍼를 생성하고, 그와 연관된 뷰를 생성한다.
- 뷰포트와 가위 판정용 사각형들을 설정한다.
1. D3D12CreateDevice 함수를 이용해 ID3D12Device를 생성한다.
우선 Direct3D의 초기화는 ID3D12Device를 생성하는 것부터 시작한다. device는 디스플레이 어댑터(비디오 카드)를 나타내는 객체이다. 보통 디스플레이 어댑터는 물리적인 그래픽 하드웨어 장치이지만 이를 흉내 내는 소프트웨어 디스플레이 어댑터(WARP 어댑터)도 있다. Direct3D12 device는 기능 지원 점검, 자원이나 뷰, 명령 목록 등의 모든 Direct3D 객체를 생성하는데 쓰인다. D3D12CreateDevice 함수를 통해서 device를 생성할 수 있다.
#if defined(DEBUG) || defined(_DEBUG)
{
ComPtr<ID3D12Debug> debugController;
ThrowIfFailed(D3D12GetDebugInterface(IID_PPV_ARGS(&debugController)));
debugController->EnableDebugLayer();
}
#endif
ThrowIfFailed(CreateDXGIFactory1(IID_PPV_ARGS(&mdxgiFactory)));
HRESULT hardwareResult = D3D12CreateDevice(
nullptr,
D3D_FEATURE_LEVEL_11_0,
IID_PPV_ARGS(&md3dDevice));
if(FAILED(hardwareResult))
{
ComPtr<IDXGIAdapter> pWarpAdapter;
ThrowIfFailed(mdxgiFactory->EnumWarpAdapter(IID_PPV_ARGS(&pWarpAdapter)));
ThrowIfFailed(D3D12CreateDevice(
pWarpAdapter.Get(),
D3D_FEATURE_LEVEL_11_0,
IID_PPV_ARGS(&md3dDevice)));
}
위 코드는 디버그층을 활성화하여 VC++의 출력창에 디버그 메시지를 보낸다. 만약 D3D12CreateDevice함수가 실패하면 소프트웨어 어댑터인 WARP device를 생성한다. 여기서 주의할 점이 WARP 어댑터를 생성하기 위해선 IDXGIFactory4이어야 하고 EnumWarpAdapter를 호출해야 어댑터 나열 시 WARP 어댑터도 나타난다.
2. ID3D12Fence 객체를 생성하고 서술자들의 크기를 얻는다.
CPU와 GPU의 동기화를 위한 fence 객체를 생성한다. 그리고 GPU마다 서술자의 크기가 다를 수 있기 때문에 실행 시점에 필요한 서술자들의 크기를 미리 설정하여 멤버 변수에 저장한다.
ThrowIfFailed(md3dDevice->CreateFence(0, D3D12_FENCE_FLAG_NONE, IID_PPV_ARGS(&mFence)));
mRtvDescriptorSize = md3dDevice->GetDescriptorHandleIncrementSize(D3D12_DESCRIPTOR_HEAP_TYPE_RTV);
mDsvDescriptorSize = md3dDevice->GetDescriptorHandleIncrementSize(D3D12_DESCRIPTOR_HEAP_TYPE_DSV);
mCbvSrvUavDescriptorSize = md3dDevice->GetDescriptorHandleIncrementSize(D3D12_DESCRIPTOR_HEAP_TYPE_CBV_SRV_UAV);
3. 4X MSAA 품질 수준 지원 여부를 점검한다.
D3D12_FEATURE_DATA_MULTISAMPLE_QUALITY_LEVELS msQualityLevels;
msQualityLevels.Format = mBackBufferFormat;
msQualityLevels.SampleCount = 4;
msQualityLevels.Flags = D3D12_MULTISAMPLE_QUALITY_LEVELS_FLAG_NONE;
msQualityLevels.NumQualityLevels = 0;
ThrowIfFailed(md3dDevice->CheckFeatureSupport(
D3D12_FEATURE_MULTISAMPLE_QUALITY_LEVELS,
&msQualityLevels,
sizeof(msQualityLevels)));
m4xMsaaQuality = msQualityLevels.NumQualityLevels;
assert(m4xMsaaQuality > 0 && "Unexpected MSAA quality level.");
현재 장치의 기능 수준이 Direct3D 11 이상임을 확인했다면 4X MSAA 지원 여부는 따로 확인할 필요가 없다. 4X MSAA가 항상 지원되므로 반환된 품질 수준(m4xMsaaQuality)은 항상 0보다 커야 한다.
4. 명령 대기열과 명령 할당자, 명령 목록을 생성한다.
D3D12_COMMAND_QUEUE_DESC queueDesc = {};
queueDesc.Type = D3D12_COMMAND_LIST_TYPE_DIRECT;
queueDesc.Flags = D3D12_COMMAND_QUEUE_FLAG_NONE;
ThrowIfFailed(md3dDevice->CreateCommandQueue(&queueDesc, IID_PPV_ARGS(&mCommandQueue)));
ThrowIfFailed(md3dDevice->CreateCommandAllocator(
D3D12_COMMAND_LIST_TYPE_DIRECT,
IID_PPV_ARGS(mDirectCmdListAlloc.GetAddressOf())));
ThrowIfFailed(md3dDevice->CreateCommandList(
0,
D3D12_COMMAND_LIST_TYPE_DIRECT,
mDirectCmdListAlloc.Get(),
nullptr, // Initial PipelineStateObject
IID_PPV_ARGS(mCommandList.GetAddressOf())));
mCommandList->Close();
위 코드에서 CreateCommandList 함수 호출 시 nullptr을 지정한 부분은 어떤 그리기 명령도 제출하지 않기 때문에 유효한 파이프라인 상태 객체를 지정하지 않아도 되는 것이다. 이후 Reset 함수또는 PSO의 Set을 통해서 지정할 수 있다.
5. 교환 사슬을 서술하고 생성한다.
교환 사슬을 생성하기 위해서는 DXGI_SWAP_CHAIN_DESC 구조체 멤버들을 생성하고자 하는 교환 사슬에 맞게 설정해야 한다. 이 구조체를 다 채웠다면 IDXGIFactory::CreateSwapChain 메섣를 호출하여 교환 사슬을 생성한다.
void D3DApp::CreateSwapChain()
{
mSwapChain.Reset();
DXGI_SWAP_CHAIN_DESC sd;
sd.BufferDesc.Width = mClientWidth;
sd.BufferDesc.Height = mClientHeight;
sd.BufferDesc.RefreshRate.Numerator = 60;
sd.BufferDesc.RefreshRate.Denominator = 1;
sd.BufferDesc.Format = mBackBufferFormat;
sd.BufferDesc.ScanlineOrdering = DXGI_MODE_SCANLINE_ORDER_UNSPECIFIED;
sd.BufferDesc.Scaling = DXGI_MODE_SCALING_UNSPECIFIED;
sd.SampleDesc.Count = m4xMsaaState ? 4 : 1;
sd.SampleDesc.Quality = m4xMsaaState ? (m4xMsaaQuality - 1) : 0;
sd.BufferUsage = DXGI_USAGE_RENDER_TARGET_OUTPUT;
sd.BufferCount = SwapChainBufferCount;
sd.OutputWindow = mhMainWnd;
sd.Windowed = true;
sd.SwapEffect = DXGI_SWAP_EFFECT_FLIP_DISCARD;
sd.Flags = DXGI_SWAP_CHAIN_FLAG_ALLOW_MODE_SWITCH;
ThrowIfFailed(mdxgiFactory->CreateSwapChain(
mCommandQueue.Get(),
&sd,
mSwapChain.GetAddressOf()));
}
주목할 점은 맨 처음에 Reset함수를 통해 위 함수가 여러 번 호출되어도 문제가 없도록 설계한 점이다. 이 함수는 기존의 교환 사슬을 먼저 해제한 후 새로운 교환 사슬을 생성하기 때문에 다른 설정으로 교환 사슬을 다시 생성할 수 있다. 특히, 실행 도중 다중표본화 설정을 변경할 수 있는 장점이 있다.
전체화면에 관련된 내용은 해당 포스팅에서 찾아볼 수 있습니다.
[Direct3D에서 전체화면 모드 전환]
이번 포스팅은 DirectX12에서 전체화면 모드로의 전환을 구현하는 방법을 기술합니다.
suengho2257.tistory.com
6. 응용 프로그램에 필요한 서술자 힙들을 생성한다.
다음으로 서술자들을 담을 서술자 힙을 만들어야 한다. 다음 코드에서는 SwapChainBufferCount에 설정된 개수만큼 RTV들과 하나의 DSV를 담을 힙을 생성하는 코드이다. 서술자 힙은 종류마다 따로 만들어야 한다는 점을 기억할 것이다.
void D3DApp::CreateRtvAndDsvDescriptorHeaps()
{
D3D12_DESCRIPTOR_HEAP_DESC rtvHeapDesc;
rtvHeapDesc.NumDescriptors = SwapChainBufferCount;
rtvHeapDesc.Type = D3D12_DESCRIPTOR_HEAP_TYPE_RTV;
rtvHeapDesc.Flags = D3D12_DESCRIPTOR_HEAP_FLAG_NONE;
rtvHeapDesc.NodeMask = 0;
ThrowIfFailed(md3dDevice->CreateDescriptorHeap(
&rtvHeapDesc, IID_PPV_ARGS(mRtvHeap.GetAddressOf())));
D3D12_DESCRIPTOR_HEAP_DESC dsvHeapDesc;
dsvHeapDesc.NumDescriptors = 1;
dsvHeapDesc.Type = D3D12_DESCRIPTOR_HEAP_TYPE_DSV;
dsvHeapDesc.Flags = D3D12_DESCRIPTOR_HEAP_FLAG_NONE;
dsvHeapDesc.NodeMask = 0;
ThrowIfFailed(md3dDevice->CreateDescriptorHeap(
&dsvHeapDesc, IID_PPV_ARGS(mDsvHeap.GetAddressOf())));
}
힙을 생성하고 나면 힙에 저장된 서술자들에 접근할 수 있고, 이를 위해 핸들을 통해 서술자들을 참조한다. 힙의 첫 서술자에 대한 핸들은 ID3D12DescriptorHeap::GetCPUDescriptorHandleForHeapStart 메서드로 얻을 수 있다.
D3D12_CPU_DESCRIPTOR_HANDLE D3DApp::CurrentBackBufferView()const
{
return CD3DX12_CPU_DESCRIPTOR_HANDLE(
mRtvHeap->GetCPUDescriptorHandleForHeapStart(),
mCurrBackBuffer,
mRtvDescriptorSize);
}
D3D12_CPU_DESCRIPTOR_HANDLE D3DApp::DepthStencilView()const
{
return mDsvHeap->GetCPUDescriptorHandleForHeapStart();
}
위 함수는 보조 메서드를 통해서 힙의 첫 서술자를 얻을 수 있다.
7. 후면 버퍼의 크기를 설정하고, 후면 버퍼에 대한 렌더 대상 뷰를 생성한다.
자원 자체를 파이프라인에 직접 묶는게 아닌 자원 뷰(서술자)를 생성해서 그 뷰를 파이프라인 단계에 묶는 것이기 때문에 후면 버퍼를 파이프라인의 출력 병합기에 묶기 위해서는 후면 버퍼에 대한 RTV를 생성해야 한다. 우선적으로 교환 사슬에 있는 버퍼 자원을 얻는 것이다. IDXGISwapChain::GetBuffer 함수를 통해 버퍼 자원을 얻을 수 있다. 자원을 얻은 후 ID3D12Device::CreateRenderTargetView 함수를 통해 RTV를 생성할 수 있다.
CD3DX12_CPU_DESCRIPTOR_HANDLE rtvHeapHandle(mRtvHeap->GetCPUDescriptorHandleForHeapStart());
for (UINT i = 0; i < SwapChainBufferCount; i++)
{
ThrowIfFailed(mSwapChain->GetBuffer(i, IID_PPV_ARGS(&mSwapChainBuffer[i])));
md3dDevice->CreateRenderTargetView(mSwapChainBuffer[i].Get(), nullptr, rtvHeapHandle);
rtvHeapHandle.Offset(1, mRtvDescriptorSize);
}
다음과 같이 후면 버퍼의 RTV들을 RTV 크기에 맞게 RTV heap에 차례대로 채워 넣을 수 있다. 후면 버퍼는 교환 사슬을 생성할 때 함께 정보를 넣어서 생성되었기 때문에 그냥 교환 사슬의 GetBuffer를 통해 가져오기만 하면 되는데 깊이 스텐실 버퍼는 직접 CreateCommittedResource 함수를 통해 버퍼를 만들어야 한다.
8. 깊이·스텐실 버퍼를 생성하고, 그와 연관된 뷰를 생성한다.
깊이·스텐실 버퍼는 가까운 물체의 깊이 정보를 저장하는 2차원 텍스처이다. 텍스처는 GPU의 자원이므로 텍스처 자원을 서술하는 D3D12_RESOURCE_DESC 구조체를 채워야 한다. GPU의 자원들은 GPU의 힙에 존재한다. GPU 힙은 본질적으로 GPU 메모리의 블록인데 특정한 속성들을 가지고 있다. CreateCommittedResource 함수를 통해 자원을 만들 수 있다. 위 함수의 첫 번째 인자는 힙의 속성을 지정하는 것인데 힙 타입의 종류는 다음과 같다
- D3D12_HEAP_TYPE_DEFAULT : 기본 힙으로 CPU가 읽고 쓸 수 없다.
- D3D12_HEAP_TYPE_UPLOAD : 자료 올리기 힙으로 CPU에서 GPU로 자료를 올릴 수 있다.
- D3D12_HEAP_TYPE_READBACK : 다시 읽기 힙으로 CPU가 읽기만 가능하다.
또한 다섯 번째 인자는 자원 지우기에 최적화된 값을 나타내는 D3D12_CLEAR_VALUE 구조체이다. 최적화된 지우기 값과 부합하는 지우기 호출은 부합하지 않는 호출보다 빠를 수 있기 때문에 되도록이면 채우도록 하자.
주의할 점은 최적의 성능을 위해서는 자원들을 디폴트 힙에 넣는 것이 합당하다. 업로드 힙이나 리드 백 힙은 해당 기능이 필요할 때만 사용해야 한다. 따라서 정점 버퍼와 같이 정적인 버퍼는 업로드로 만들어서 디폴트 힙에 복사해야 한다.
D3D12_RESOURCE_DESC depthStencilDesc;
depthStencilDesc.Dimension = D3D12_RESOURCE_DIMENSION_TEXTURE2D;
depthStencilDesc.Alignment = 0;
depthStencilDesc.Width = mClientWidth;
depthStencilDesc.Height = mClientHeight;
depthStencilDesc.DepthOrArraySize = 1;
depthStencilDesc.MipLevels = 1;
depthStencilDesc.Format = mDepthStencilFormat;
depthStencilDesc.SampleDesc.Count = m4xMsaaState ? 4 : 1;
depthStencilDesc.SampleDesc.Quality = m4xMsaaState ? (m4xMsaaQuality - 1) : 0;
depthStencilDesc.Layout = D3D12_TEXTURE_LAYOUT_UNKNOWN;
depthStencilDesc.Flags = D3D12_RESOURCE_FLAG_ALLOW_DEPTH_STENCIL;
D3D12_CLEAR_VALUE optClear;
optClear.Format = mDepthStencilFormat;
optClear.DepthStencil.Depth = 1.0f;
optClear.DepthStencil.Stencil = 0;
ThrowIfFailed(md3dDevice->CreateCommittedResource(
&CD3DX12_HEAP_PROPERTIES(D3D12_HEAP_TYPE_DEFAULT),
D3D12_HEAP_FLAG_NONE,
&depthStencilDesc,
D3D12_RESOURCE_STATE_COMMON,
&optClear,
IID_PPV_ARGS(mDepthStencilBuffer.GetAddressOf())));
md3dDevice->CreateDepthStencilView(mDepthStencilBuffer.Get(), nullptr, DepthStencilView());
mCommandList->ResourceBarrier(1, &CD3DX12_RESOURCE_BARRIER::Transition(mDepthStencilBuffer.Get(),
D3D12_RESOURCE_STATE_COMMON, D3D12_RESOURCE_STATE_DEPTH_WRITE));
9. 뷰포트와 가위 판정용 사각형들을 설정한다.
D3D12_VIEWPORT 구조체를 채운 후 ID3D12CommandList::RSSetViewports 메서드를 이용해서 뷰포트를 설정한다. 하나의 렌더 대상에 여러 개의 뷰포트를 지정할 수 없고 여러 개의 렌더 대상을 만들어서 다중 뷰포트를 통해 다양한 장면을 동시에 렌더링할 수는 있다. 또한 명령 목록을 재설정하면 뷰포트들도 재설정해야 한다.
가위 직사각형 또한 D3D12_RECT를 채운 후 ID3D12CommandList::RSSetScissorRects 메서드를 이용해서 설정할 수 있다.
렌더링 파이프라인
렌더링 파이프라인이란 현재 가상 카메라에 비친 3차원 장면의 모습을 2차원 이미지로 생성하는데 필요한 일련의 단계들을 의미한다. 3차원 세계의 깊이와 부피를 2차원 모니터 화면에 나타내기 위해서는 몇 가지 고찰이 필요하다. 철로를 생각하면 두 레일은 멀리 갈수록 점점 가까워지다가 무한이 멀리 있는 하나의 소실점으로 수렴한다. 즉 사람이 깊이감을 느끼기 위해서는 물체의 크기가 깊이에 따라 감소하는 현상 즉, 멀리 있는 물체가 더 작게 보이는 현상이다. 그리고 불투명한 물체가 뒤에 있는 물체를 가리는 현상과 조명, 그림자 등 이들을 통해 마치 2차원 모니터가 3차원 세상을 그리는 것처럼 보일 것이다.
위 그림은 Direct3D 12의 렌더링 파이프라인을 구성하는 단계들과 관련 GPU 메모리 자원들을 도식화한 것이다. 복잡해 보이지만 앞으로 하나씩 살펴갈 예정이다. 파이프라인은 일종의 함수라고 생각할 수 있는데 현재 단계의 출력이 다음 단계의 입력이 되는 구조이다.
'DirectX12 > DirectX12 입문' 카테고리의 다른 글
[Frame Resource와 Render Item] (0) | 2023.09.19 |
---|---|
[Direct3D 렌더링] (0) | 2023.09.18 |
[시간 측정] (0) | 2023.09.14 |
[전체 화면 모드 전환] (0) | 2023.09.11 |