그래픽스/DX12

D3D12 3D 프로그래밍 입문 - 기본 지식 (2)

게임 개발 2023. 12. 21. 12:36

 

교환 사슬과 페이지 전환

 

 애니메이션이 껌벅이는 현상을 피하려면 다음과 같은 방법을

사용하는 것이 최선이다.

우선, 애니메이션의 한 프레임을 전체 화면 바깥(off-screen) 텍스처에 그린다.

그런 텍스처를 후면 버퍼(back buffer)라고 부른다.

그 후면 버퍼를 하나의 완전한 프레임으로서 화면에 표시한다.

이렇게 하면 화면을 보는 사용자에게는 프레임이 그려지는 과정이 나타나지 않는다.

이중 버퍼링을 효율적으로 구현하려면 하드웨어로 관리되는 두 개의 텍스처 버퍼가 필요한데,

하나는 전면 버퍼(front buffer)이고 다른 하나는 앞에서 언급한 후면 버퍼이다.

화면에는 전면 버퍼에 담긴 이미지 자료가 표시된다.

전면 버퍼가 화면에 표시된 동안 애니메이션의 다음 프레임을 후면 버퍼에 그리고,

다 그려지면 전면 버퍼와 후면 버퍼의 역할을 맞바꾼다.

즉, 후면 버퍼가 새 전면 버퍼가 되어서 그 내용이 화면에 표시되고,

전면 버퍼가 새 후면 버퍼가 되어서 다음 프레임이 그려진다.

이처럼 후면 버포와 전면 버퍼의 역할을 교환해서 페이지가 전환되게 하는 것을

Direct3D에서는 제시(presenting)라고 부른다.

이러한 구조를 교환 사슬이라고 한다.

DX에서 교환 사슬을 대표하는 인터페이스는 IDXGISwapChain이다.

이 인터페이스는 전면 버퍼 텍스처와 후면 버퍼 텍스처를 담으며,

버퍼 크기 변경을 위한 메서드 (IDXGISwapChain::ResizeBuffers)와

버퍼의 제시를 위한 메서드 (IDXGISwapChain::Present)도 제공한다.

 

 

깊이 버퍼링(Depth Buffering)


 깊이 버퍼(depth buffer)는 이미지 자료를 담지 않는 텍스처의 한 예이다.

깊이 버퍼는 각 픽셀의 깊이 정보를 담는다.

픽셀의 깊이는 0.0에서 1.0까지의 값으로,

0.0은 시야 절두체(view frustum) 안에서 관찰자에 최대한 가까운 물체에 해당하고

1.0은 시야 절두체(view frustum) 안에서 관찰자와 최대한 먼 물체에 해당한다.

깊이 버퍼의 원소들과 후면 버퍼의 픽셀들은 일대일로 대응된다.

(즉, 후면 버퍼의 ij번째 원소는 깊이 버퍼의 ij의 원소에 대응된다.)

 

이게 무슨 뜻이냐면,

예를 들어서 후면 버퍼의 해상도가 1280 * 1024 라면,

깊이 버퍼는 1280 * 1024개의 원소들로 구성된다.

 

필자는 픽셀의 개수의 대응으로 생각했다.

각 픽셀마다 필요한 깊이 즉,

얼마나 카메라에서 먼가 가까운가에 대한 원근감 3D 입체감 표현을 가지는 것으로 받아들였다.

 

 

 

 

다음과 같이 다른 물체를 가리고 있다면,

한 물체의 픽셀들이 다른 물체보다 앞에 있는지 판정하기 위해,

Direct3D는 깊이 버퍼링 또는 z-버퍼링이라는 기법을 사용한다.

여기서 중요한 점은,

깊이 버퍼링을 이용하면 물체들을 그리는 순서와 무관하게 물체들이 제대로 가려진다는 것이다.

 

++참고++

깊이 문제를 해결하는 한 방법은 장면의 물체들을 먼 것에서
가까운 것 순서로 그리는 것이다.
그렇게 하면 가까운 물체가 먼 물체를 덮으므로 결과적으로 물체들이 제대로 가려진 모습이 된다.
이는 화가가 장면을 그리는 방식과 일치한다.
그러나 이 방법에는 두 가지의 문제가 있다.
하나는 물체들을 정렬해야 한다는 것이고(물체가 많으면 rendering 시간이 오래걸린다)
또 하나는 맞물린 형태의 물체들을 제대로 처리하지 못한다는 것이다.
반면 깊이 버퍼링을 사용하면 원근감에 대한 처리가 그래픽 하드웨어에서 자연스럽게 이뤄진다.
이러한 과정으로 인하여, 맞물린 물체들의 연산처리도 제대로 이뤄진다.

 

 

깊이 버퍼링의 작동 방식을 예를 통해서 알아보자면,

관찰자 측면에서 보는 3차원 정면과 그 장면의 2차원 측면도가 있다.

측면도를 보면 확실하게 알 수 있지만,

서로 다른 물체에서 비롯된 세 픽셀이 시야 창 (view window)의 한 픽셀 P를 두고 경쟁한다.

( = 카메라(관찰자)와 가장 가까운 픽셀이 다른 픽셀들을 가리므로
사람은 그 픽셀이 P에 그려져야 제대로 된 결과가 나옴을 알고 있지만,

컴퓨터는 이러한 과정에 맞게 알려줘야지 알 수 있다.)

 

 

++ 추가 그림 설명 ++
3차원 장면으로부터 생성된 2차원 이미지(후면 버퍼)에 대응되는 시야 창.
픽셀 P에 서로 다른 세 개의 픽셀이 투영된다.
사람은 P1이 P에 먼저 기록되어야 함을 직감적으로 안다.
P1이 관찰자에 가장 가까운, 그리고 다른 두 픽셀을 가리는 픽셀이기 때문이다.
컴퓨터는 깊이 버퍼링 알고리즘을 통해서 이를 기계적으로 판정한다.
이 그림들은 깊이 값들을 해당 3차원 장면에 상대적으로 나타냈지만,
실제로 깊이 버퍼에 저장되는 깊이 값들은 [0.0 1.0] 구간으로 정규화된 값들이다.

 

 

응용 프로그램은 그 어떤 렌더링도 수행하기 전에

먼저 후면 버퍼를 기본 색상(검은색 이나 흰색 등)으로 지운다.

이때 깊이 버퍼도 기본값으로 주어진다.

일반적으로 한 픽셀이 가질 수 있는 최대 깊이인 1.0을 기본값으로 한다.

이제 물체들을 원기둥, 구, 사각형 순서로 렌더링한다고 하자.

아래 표에서는 물체들이 렌더링됨에 따라 시야 창의 픽셀 P와 그 깊이 값 d가 갱신되는 과정을 요약한 것이다.

시야 창의 다른 픽셀들도 마찬가지 과정을 거친다.

 

연산 P d 설명
지우기 검은색
(혹은 흰색)
1.0 픽셀과 해당 깊이 항목이 초기화된다.
원기둥 그리기 P3 d3 d3 < d = 1.0  이므로 깊이 판정이 성공하며,
그래서 버퍼가 P = P3 , d = d3으로 갱신된다.
구 그리기 P1 d1 d1 <= d = d3 이므로 깊이 판정에 성공하며,
그래서 버퍼가 P = P1, d = d1이 된다.
사각형 그리기 P1 d1 d2 > d = d1 이므로 깊이 판정에 실패하며,
버퍼는 갱신되지 않는다.

 

 

이 예에서 보듯이, 픽셀과 해당 깊이 값은

그 깊이 값이 깊이 버퍼에 이미 들어 있던 값보다 작은 경우에만 후면 버퍼와 깊이 버퍼에 기록된다.

이런 방식에서는 항상 관찰자에 가장 가까운 픽셀이 렌더링된다.

 

 

깊이 버퍼링 정리

 

 앞선 내용을 정리하자면, 깊이 버퍼링 알고리즘은 렌더링되는 각 픽셀의 깊이 값을 계산해서

깊이 판정을 수행함으로써 작동하게 된다.

깊이 판정은 후면 버퍼의 특정 픽셀 위치에 기록될 픽셀들의 깊이들을 비교한다.

깊이 값을 비교했을 때 관찰자에게 가장 가까운 픽셀이 선택되어 후면 버퍼에 기록된다.

다른 물체를 가리는 물체의 픽셀이 바로 관찰자에게 가장 가까운 픽셀이라는 점을 생각하면

이 알고리즘이 합당하다는 것을 알 수 있다.

 

여태 내가 이해한 D3D12

 

1. 물체를 그리기 위해 법선매핑을 가진 자료를 3차원 텍스처 형식 자료에 넣어준다.

텍스처에는 밉맵 수준들이 존재할 수 있다

-> 원근감 및 사실적인 렌더링을 위해 중복된 픽셀을 작게 그려나가고, 해상도를 낮춰준다.
이는 메모리를 더욱 잡아먹는 방식이지만 그렇지 않은 것 보단 사실적인 렌더링을 가능케 해준다.

 

텍스처 형식에는 RGBA값을 담을 수 있다.

 

2. 버퍼는 전면 버퍼와 후면 버퍼로 계속해서 장면 전환을 이어나간다.
때문에 프레임이 끊기는 것과 같은 현상을 개선시키고 더욱 자연스러운 렌더러를 그려낸다.

 

3. 깊이 버퍼링으로 원근감을 그려낸다. 법선매핑으로 명암이 생겼다면,
깊이 버퍼링으로 앞과 뒤를 나타낸다.

가까이 있는 것을 그려내고 그 뒤에 있는 겹치는 부분은 그려내지 않는다.

후면 버퍼의 픽셀 개수와 깊이 버퍼의 원소의 개수는 같다.

깊이 버퍼링 혹은 Z-버퍼링이라는 기법으로 불린다.

 

 

 

깊이 버퍼는 하나의 텍스처이므로, 생성 시 특정한 자료 원소 형식을 지정할 필요가 있다.

깊이 버퍼링을 위한 텍스처 자료 원소 형식으로는 다음과 같은 것들이 있다.

 

 

  1. DXGI_FORMAT_D32_FLOAT_S8X24_UINT : 
    각 텍셀은 32비트 부동소수점 깊이 값과
    [0, 255] 구간으로 사상되는 부호 없는 8비트 정수 스텐실 값(스텐실 버퍼에 쓰임),
    그리고 다른 용도 없이 채움(padding) 용으로만 쓰이는 24비트로 구성된다.

  2. DXGI_FORMAT_D32_FLOAT :
    각 텍셀은 하나의 32비트 부동소수점 깊이 값이다.

  3. DXGI_FORMAT_D24_UNORM_S8_UINT :
    각 텍셀은 [0,1] 구간으로 사상되는 부호 없는 24비트 깊이 값 하나와
    [0, 255] 구간으로 사상되는 부호 없는 8비트 정수 스텐실 값으로 구성된다.

  4. DXGI_FORMAT_D16_UNORM :
    각 텍셀은 [0,1] 구간으로 사상되는 부호 없는 16비트 깊이 값이다.

 

++ 참고 ++

응용 프로그램이 스텐실 버퍼를 반드시 사용해야 하는 것은 아니나,
만일 사용한다면 스텐실 버퍼는 항상 깊이 버퍼와 같은 텍스처에 포함된다. EX) 32비트 형식

     DXGI_FORMAT_D24_UNORM_S8_UINT

은 그 중 24비트를 깊이 버퍼에, 나머지 8비트를 스텐실 버퍼에 사용한다.
이 때문에 깊이 버퍼보다는 깊이 스텐실 버퍼라는 용어가 더 정확하다.

 

 

 

자원과 서술자

 

렌더링 과정에서 GPU는 지원들에 자료를 기록하거나

(이를테면 후면 버퍼나 깊이 스텐실 버퍼가 그런 종류의 자원이다)

자원들에서 자료를 읽어 들인다
(EX . 이를테면 표면의 모습을 서술하는 텍스처나 기하구조의 3차원 위치들을 담은 버퍼가 그런 종류의 자원이다.)

그리기 명령을 제출하기 전에,

먼저 해당 그리기 호출이 참조할 자원들을 렌더링 파이프라인에 묶는(Bind)다.

이러한 bind 과정을 "link(연결)한다" 또는 "바인딩 한다"라고 말한다.

그리기 호출마다 달라지는 자원들도 있음,

따라서 필요하다면 그리기 호출마다 그런 자원들의 바인딩을 갱신해야 한다.

 

그런데 이러한 GPU 자원들이 파이프라인에 직접적으로 바인딩되는 것은 아니며,

실제로 파이프라인에 묶이는 것은 해당 차원을 참조하는 서술자(descriptor) 객체이다.

서술자 객체(descriptor)은 자원을 GPU에게 서술해주는 경량의 자료구조라고 할 수 있다.

본질적으로 이는 하나의 간접층 (level of indirection)이다.

CPU는 자원 서술자를 통해서 자원의 실제 자료에 접근하며,

그 자료를 사용하는 데 필요한 정보 역시 자원 서술자로부터 얻는다.

그리기 호출이 참조할 서술자들을 명시하면 해당 자원들이 렌더링 파이프라인에 바인드된다.

 

 이처럼 서술자들을 거치는 추가적인 간접층을 두는 이유는,

GPU 자원이라는 것이 사실상 범용적인 메모리 조각이기 때문이다.

자원은 범용적이므로,

같은 자원을 렌더링 파이프라인의 서로 다른 단계(stage)들에서 사용할 수 있다.

흔한 예로는 텍스처를 렌더 대상으로도 사용하고(즉, Direct3D가 장면을 텍스처에 그리는 것)

그 이후 단계에서 셰이더 자원으로 사용하는

(즉, 텍스처에서 표본들을 추출해서 셰이더의 입력 자료로도 사용하는)것이다.

자원 자체는 자신 스스로를 렌더 대상으로 쓰여야 하는지

아니면 depth-stencil buffer나 셰이더 자원으로 쓰여야 하는지에 대해 규정하지 않는다.

또한, 자원의 일부 영역만 렌더링 파이프라인에 묶어 쓸 때가 있는데

그러한 경우에도 자원 자체에는 특정 부분 영역에 대한 정보를 담고있지는 않다.

더 나아가서, 애초에 자원을 형식이 없는 자원으로도 생성할 수도 있다.

그런 경우에는 GPU가 자원의 형식을 알지 못한다.

 

 이런 문재를 해결해주는 것이 서술자이다.

서술자는 자원 자료를 지정해주는 수단일 뿐만아니라,

자원을 GPU에 서술하는 수단이기도 하다.

서술자는 Direct3D에게 자원의 사용법을

(재차 말하지만 자원을 파이프라인의 어떤 단계에 묶여야 하는지를) 말해준다.

그리고 무형식으로 생성된 자원의 경우에는,

그 자원을 참조하는 서술자를 생성할 때 그 자원의 구체적인 형식을 명시할 수 있다.

 

++ 참고 ++
view는 서술자와 동의어로 쓰인다.
view라는 용어는 Direct3D의 이전 버전들에 쓰였으며,
Direct3D12의 일부에도 여전히 쓰이곤 한다.
예를 들어 상수 버퍼 뷰와 상수 버퍼 서술자는 같은 의미를 지닌다.

 

서술자는 자원의 사용법에 따라 여러 종류(형식)가 있다.

다음과 같은 종류의 서술자들을 예시로 들 수 있다.

 

  1. CBW/SRV/UAV 서술자들은 각각
    상수 버퍼 (Contant Buffer (View)), 셰이더 자원(Shader Resource (View)),
    순서 없는 접근(Unordered Access View)을 서술한다.

  2. 표본추출기 서술자는 텍스처 적용에 쓰이는 표본추출기(sampler) 자원을 서술한다.
  3. RTV 서술자는 렌더 대상(render target) 자원을 서술한다.

  4. DSV 서술자는 Depth/Stencil buffer (View) 자원을 서술한다.

 서술자 힙(descriptor heap)은 서술자들의 배열이다.

응용 프로그램이 사용하는 서술자들이 저장되는 곳이 바로 서술자 힙이다.

서술자 종류마다 개별적인 서술자 힙이 필요하다.

같은 종류의 서술자들은 같은 서술자 힙에 저장된다.

또한, 한 종류의 서술자에 대해 여러 개의 힙을 둘 수도 있다.

 

 하나의 자원을 참조하는 서술자가 하나뿐이어야 하는 것은 아니다.

예를 들어 한 자원의 여러 부분 영역을 여러 서술자가 참조할 수 있다.

또한, 앞서 언급했듯 하나의 자원을 렌더링 파이프라인의 여러 단계에 바인딩할 수 있는데,

단계마다 개별적인 서술자가 필요하다.

하나의 텍스처를 렌더 대상이자 셰이더 자원으로 사용하는 예의 경우에는

두 개의 서술자, 즉 RTV 형식의 서술자와 SRV 형식의 서술자를 만들어야 한다.

마찬가지로, 무형식 자원을 만든 경우에는

텍스처의 원소를 이를테면 부동소수점 값으로 사용할 수도 있고 정수로 사용할 수도 있는데,

그러려면 두 개의 서술자, 즉 부동소수점 형식을 지정하는 서술자와 정수 형식을 지정하는 서술자가 필요하다.

 

 서술자들은 응용 프로그램 초기화 시점에서 생성해야 한다.

이는 그때 일정 정도의 형식 점검과 유효성 검증이 일어나기 때문이며,

또한 초기화 시점에서 생성하는 것이 실제 실행 시점에서 생성하는 것보다 낫기 때문이기도 하다.

 

++ 참고 ++

DirectX SDK 문서화에는
"형식이 완전히 지정된 자원은 해당 형식만으로 사용할 수 있다
그러면 런타임은 자원에 대한 접근을 최적화할 수 있게 된다" 라는 뜻의 문구가 나온다.

따라서 무형식 자원은 그런 자원이 제공하는 유연성
(자료를 여러 뷰들을 통해서 다양한 방식으로 해석할 수 있는 능력)이 꼭 필요할 때에만 만들어야 한다.
그렇지 않은 경우에는 형식을 완전히 지정해서 자원을 만들어야 한다.