개발하는 리프터 꽃게맨입니다.

[DX11 물방울책 요약 정리] Direct3D에서 그리기 본문

카테고리 없음

[DX11 물방울책 요약 정리] Direct3D에서 그리기

파워꽃게맨 2024. 9. 20. 16:19

주의

책이랑 다른 내용이 굉장히 많습니다.

현대 DX와 책과 다른 부분이 많고.. 책에는 설명이 난해하게 된 부분이 있어서 의역이 다수 존재합니다.

 

이전 챕터에서는 주로 렌더링 파이프라인의 개념적 및 수학적 측면에 초점을 맞췄다.

 

이번 챕터에스는 Direct3D 인터페이스와 메서드를 사용하여 렌더링 파이프라인을 구성하고

버텍스 쉐이더, 픽셀 쉐이더를 정의하고, 기하 데이터를 렌더링 파이프라인에 제출하여 그리는 바법에 중점을 둔다.

 

이 챕터를 끝내면, 색상 또는 와이어프레임 모드로 다양한 기하학적 도형을 그릴 수 있게 될 것이다.

 

Vertices와 입력 레이아웃

Driect3D의 버텍스는 위치 외에도 추가 데이터를 포함할 수 있다.

사용자 정의 버텍스를 위해서는 먼저 우리가 선택한 버텍스 데이터를 포함하는 구조체를 만들어야 한다.

 

아래는 우리가 정의할 수 있는 일반적인 형태의 버텍스를 보여준다.

 

이는 위치, 색상을 포함하는 버텍스

 

이는 위치, 법선, 텍스처 좌표 2개를 포함하는 버텍스이다.

 

우리가 관심있는 것은 버텍스를 쉐이더 프로그램에 입력으로 넣는 것이다.

 

그것을 시각적으로 표현하면 위와 같다.

 

쉐이더 프로그램이란, 우리가 실행하는 프로그램 외에 또 다른 프로그램이다.

우리가 실행하는 프로그램은 CPU에서 실행하는 프로그램이고

쉐이더 프로그램은 GPU에서 실행되는 작은 프로그램이다.

 

우리는 GPU라는 리소스를 사용하기 위해서 GPU만을 위한 특별한 프로그램을 새롭게 정의해야 하는데 그것이 쉐이더 프로그램이며, 이는 GPU에서 돌기 때문에 계산속도가 매우 빠르다.

 

이제, 다시 돌아와서

입력 레이아웃은 무엇이며 왜 필요한가?

 

입력 레이아웃은 DirectX에서 GPU에 전달되는 버텍스 데이터의 구조를 정의하는 객체이다. (물론 DX 에서만 사용되는 개념은 아님)

버텍스 구조체는 우리가 임의로 정의할 수 있는데

 

버텍스 구조체를 정의하는 것을 넘어서 쉐이더 프로그램이 해당 구성 요소를 어떻게 인식하고 처리할지 역시 정의해야한다. 이런 설명을 제공해주는 것이 입력 레이아웃이다.

입력 레이아웃을 정의함으로써 GPU가 우리의 버텍스의 데이터 구조를 이해할 수 있게 된다.


먼저, 입력 레이아웃을 만들어내는 함수를 보자.

HRESULT CreateInputLayout(
  [in]            const D3D11_INPUT_ELEMENT_DESC *pInputElementDescs,
  [in]            UINT                           NumElements,
  [in]            const void                     *pShaderBytecodeWithInputSignature,
  [in]            SIZE_T                         BytecodeLength,
  [out, optional] ID3D11InputLayout              **ppInputLayout
);

 

눈여겨 보아야할 것은 구조체 D3D11_INPUT_ELEMENT_DESC 이다.

 

우리는 D3D11_INPUT_ELEMENT_DESC 의 배열을 인풋 레이아웃을 만들어내는데 사용한다.

D3D11_INPUT_ELEMENT_DESC 배열의 각각의 요소는 버텍스 구조체의 멤버를 설명하고 대응한다.

 

따라서 이와 같은 버텍스가 존재한다면

	D3D11_INPUT_ELEMENT_DESC inputLayout[] =
	{
		{ "POSITION", 0, DXGI_FORMAT_R32G32B32_FLOAT, 0, 0, D3D11_INPUT_PER_VERTEX_DATA, 0 },
		{ "COLOR", 0, DXGI_FORMAT_R32G32B32A32_FLOAT, 0, 12, D3D11_INPUT_PER_VERTEX_DATA, 0 }
	};

 

이런 배열을 정의할 수 있다.

 

D3D11_INPUT_ELEMENT_DESC  역시 구조체인데.. 이 구조체의 멤버를 살펴보자.

1) SemanticName

이는 버텍스 멤버를 지칭하는 문자열이다.

위치는 POSITION, 법선은 NORMAL 등의 문자열을 사용하는 것이 일반적이다.

이하 시멘틱이라고 부른다.

 

과거에는 시멘틱의 이름을 임의로 정할 수 있었지만

현재 버전의 DX에서는 지정된 시멘틱만 사용할 수 있다.

 

버텍스 쉐이더의 최종 출력값은 SV_POSITION (동차 클립 공간 좌표

픽셀 쉐이더의 최종 출력값은 SV_TARGET (색상)

등을 사용한다.

 

 

시멘틱이 작동하는 방식은 위 그림을 보면 직관적으로 이해할 수 있을 것이다.

 

2) SemanticIndex

시멘틱에 부착하는 인덱스이다.

만약, 동일한 의미를 가진 여러 데이터가 있을 경우 인덱스를 지정하는데,

한 정점이 2개의 텍스처를 사용할 경우 TEXCOORD 시멘틱을 사용하는 멤버를 2개 가지고 있을 것이다.

이 경우 SemanticIndex를 지정하여, 동일한 시멘틱을 사용하더라도 인덱스를 통해 식별할 수 있기 된다.

 

인덱스를 따로 지정하지 않으면 0으로 설정되는데,

POSITION과 POSITION0은 같은 의미이다.

 

 3) Format

타입을 지정한다.

 

4) InputSlot

해당 요소가 들어갈 입력 슬롯을 정한다.

슬롯이라는 개념은 조금 생소한데, GPU가 그래픽 리소스를 수산하는 통로를 의미한다.

 

DirectX에는 0~15 번 슬롯 (총 16개의 슬롯)이 존재한다.

 

한 구조체의 멤버를 하나의 슬롯으로 넣을수도 있고, 서로 다른 슬롯으로 넣을수도 있다. 

일반적으로는 하나의 슬롯만 사용하는데 다수의 슬롯을 사용할 경우 추가적인 설정을 해야하기 때문에, 이는 현재 자세하게 다루지 않는다.

 

일반적으로 하나의 슬롯을 사용한다고 생각하자.

 

5) AlignedByteOffset

버텍스 구조체의 멤버의 오프셋을 의미한다.

하나의 슬롯만 사용한다고 가정한다.

예를 들어, 이 정점의 경우

Pos는 구조체의 시작과 일치하므로 0바이트 오프셋을 가지며

Normal요소는 12바이트 오프셋

Tex0 요소는 24바이트 오프셋

Tex1 요소는 32바이트 오프셋을 가진다.

 

여러 개의 슬롯을 사용한다면 오프셋의 설정방식은 조금 달라진다.

 

6. InputSlotCalss

현재는 D3D11_INPUT_PER_VERTEX_DATA를 지정하라. 다른 옵션은 인스턴싱이라는 고급 기술에 사용된다.

 

7. InstanceDataStepRate

현재는 0을 지정하라. 다른 값들은 인스턴싱이라는 고급 기술에만 사용된다.

 

이전의 두 가지 버텍스 구조체, Vertex1과 Vertex2에 대해, 해당하는 입력 레이아웃 Desc는 다음과 같다.

D3D11_INPUT_ELEMENT_DESC desc1[] =
{
    {"POSITION", 0, DXGI_FORMAT_R32G32B32_FLOAT, 0, 0, D3D11_INPUT_PER_VERTEX_DATA, 0},
    {"COLOR", 0, DXGI_FORMAT_R32G32B32A32_FLOAT, 0, 12, D3D11_INPUT_PER_VERTEX_DATA, 0}
};

 

D3D11_INPUT_ELEMENT_DESC desc2[] =
{
    {"POSITION", 0, DXGI_FORMAT_R32G32B32_FLOAT, 0, 0, D3D11_INPUT_PER_VERTEX_DATA, 0},
    {"NORMAL", 0, DXGI_FORMAT_R32G32B32_FLOAT, 0, 12, D3D11_INPUT_PER_VERTEX_DATA, 0},
    {"TEXCOORD", 0, DXGI_FORMAT_R32G32_FLOAT, 0, 24, D3D11_INPUT_PER_VERTEX_DATA, 0},
    {"TEXCOORD", 1, DXGI_FORMAT_R32G32_FLOAT, 0, 32, D3D11_INPUT_PER_VERTEX_DATA, 0}
};

 

이제 입력 요소 디스크립션을 정의했으니

다시 입력 레이아웃 함수로 돌아온다.

 

1. pInputElementDescs

여기에 방금 정의한 D3D11_INPUT_ELEMENT_DESC 구조체 배열의 포인터를 넣는다.

 

2. NumElements

D3D11_INPUT_ELEMENT_DESC 배열의 요소 개수이다.

ARRAYSIZE 라는 매크로를 사용하면 쉽게 설정할 수 있다.

 

3. pShaderBytecodeWithInputSingnaure

쉐이더의 입력 시그니처에 대한 쉐이더 바이트 코드의 포인터이다.

이를 쉽게 설명하면 쉐이더 프로그램의 main 함수 매개변수 정보에 대한 포인터라고 이해하면 된다.

 

쉐이더 코드는 .hlsl (high level shader language) 라는 특별한 텍스트 파일에 쓰여진다.

쉐이더 코드를 사용하기 위해서는 해당 코드를 컴파일한 후 그 결과를 저장하여, 이를 GPU에 로드해야 렌더링에 사용할 수 있다.

 

컴파일된 쉐이더 코드를 저장하는데 쓰이는 것이 ID3DBlob 이라는 인터페이스이다.

MSDC 문서에는, ID3DBlob이 버텍스, 인접성, 머테리얼 정보를 저장하는 버퍼로 사용되며, 각종 쉐이더를 컴파일한 이진 코드를 저장하고 반환하는데 사용된다라고 적혀있다.

 

여기서는 컴파일된 쉐이더 코드를 가지는 객체라고 보면 된다.

ID3DBlob을 통해서 쉐이더의 입력 시그니처의 바이트 코드의 포인터를 얻어낼 수 있다.

 

4. BytecodeLength

이는 이전 매개변수에서 전달한 쉐이더의 입력 시그니처 바이트 코드의 크기인데,

이 또한 ID3DBlob을 통해서 얻어낼 수 있다.

 

5. ppInputLayout

생성된 입력 레이아웃에 대한 포인터를 반환한다.


세 번째 매개변수에 대해 추가적인 설명이 필요하다.

버텍스 쉐이더는 버텍스 구조체의 멤버를 매개변수로 받는다.


쉐이더 함수의 매개변수들을 입력 시그니처라고 한다.

 

정의한 버텍스 구조체의 멤버와 버텍스 쉐이더의 입력 시그니처는 서로 매핑되어야 한다.

만약, 서로 다른 쉐이더의 입력 시그니처가 정확히 동일하다면, 입력 레이아웃을 공유하여 사용할 수 있다.

 

이 코드는 오류를 발생시킨다.

Vertex에 NORMAL에 해당하는 이름이 없기 때문이다.

 

 

이것은 실제로 유효한 코드이다.

XMFLOAT3과 int3은 서로 다르지 않냐라고 물을 수 있지만

Direct3D는 기본적으로 암묵적인 데이터 형변환을 허용하기 때문에 허용된다.

 

그러나 경고가 발생한다.

 

입력 시그니처는 쉐이더가 입력 레지스터에 저장된 데이터를 어떻게 해석할지를 정의한다.

 

다음 코드는 ID3D11Device::CreateInputLayout 메서드가 어떻게 호출되는지를 설명하는 예제다.

 

 

입력 레이아웃이 생성된 후에도 아직 디바이스에 바인딩되지 않은 상태이다. 마지막 단계는 원하는 입력 레이아웃을 디바이스에 바인딩하는 것으로, 다음 코드에서 보여준다.

 

입력 레이아웃은 바인딩되면 그 상태를 유지한다.

만약 다른 입력 레이아웃을 사용하고 싶다면, 아래 코드와 같이 도형을 그리기 전에 바인딩해줘야만 한다.

md3dImmediateContext->IASetInputLayout(mInputLayout1);
/* ...draw objects using input layout 1... */
md3dImmediateContext->IASetInputLayout(mInputLayout2);
/* ...draw objects using input layout 2... */

 

입력 레이아웃이 디바이스에 바인딩되면, 덮어쓰지 않는 한 변경되지 않는다.

 

Vertex Buffer

GPU에 버텍스 버퍼를 넘겨줘보자.

DX에서 ~~버퍼를 만들어야 한다면, 일반적으로 ID3D11Buffer 인터페이스를 사용해야 한다.

 

버텍스 버퍼에는 우리가 사용하고 싶은 버텍스가 저장되어 있다. 먼저 VertexBuffer를 바인딩하는 함수를 보고, 역으로 필요한 것을 찾아내려가 보자.

 

 

1. StartSlot

버텍스 데이터를 바인딜할 입력 슬롯 지정

이 입력 슬롯은 InputLayout과 일치해야 한다.

 

2. NumBuffers

바인딜할 버텍스 버퍼의 수를 지정

 

3. ppVertexBuffers

저정 데이터를 담고 있는 버텍스 버퍼들의 배열

 

4. pStrides

하나의 정점 데이터의 크기 (sizeof)

 

5. pOffset

버텍스를 읽기 시작할 위치 (일반적으로 0을 설정)

 

pStrides, pOffset이 포인터를 받는 이유는 다중 슬롯을 사용할 경우 이것에 대한 설정이 조금 달라지기 때문이다.

지금은 크게 신경쓰지 않도록한다.

 

다음 Buffer를 생성하는 함수를 보자.

 

D3D11_BUFFER_DESC와 D3D11_SUBRESOURCE_DATA를 설정해줘야 한다.

또또 살펴보자.

 

1. ByteWidth

버퍼의 총 크기

이는 버텍스 버퍼의 총 크기를 적으면 된다.

 

2. Usage

버퍼의 용도를 지정하는 값인데, GPU에서만 읽고 쓰기 가능한 플래그인

D3D11_USAGE_DEFAULT 를 설정하자.

나머지는 고급 설정이다.

 

3. BindFlags

버퍼가 어떤 파이프라인 단계에 바인딩될지를 결정하는 플래그이다.

D3D11_BIND_VERTEX_BUFFER << 버텍스 버퍼에서는 이걸 쓴다.

 

4. CPUAccessFlags

CPU 에서 접근하는 플래그. 설정하지 않는다.

 

5. MiscFlags

이 또한 고급 설정이므로 설정하지 않는다.

 

6. StructureByteSrtde

이 또한 고급 설정이므로 설정하지 않는다.

 

그 다음 구조체이다.

1. pSysMem

초기 데이터가 저장된 메모리의 포인터라 하는데

배열 포인터를 주면 된다.

 

이 포인터를 통해 생성하려는 리소스에 데이터를 복사한다.

 

2. SysMemPitch

설정하지 않는다. (고급 옵션)

 

3. SysMemSlicePitch

설정하지 않는다. (고급 옵션)

 

 

 

IASetVertexBuffers 메서드는 다양한 입력 슬롯에 버텍스 버퍼 배열을 설정할 수 있으므로 약간 복잡해 보일 수 있다. 그러나 대부분의 경우, 하나의 입력 슬롯만 사용한다.

 

버텍스 버퍼는 변경하지 않는 한 입력 슬롯에 계속 바인딩된 상태로 유지된다. 따라서, 하나의 슬롯에 두 개 이상의 버텍스 버퍼를 사용하는 경우 다음과 같이 구성할 수 있다.

 

버텍스 버퍼를 입력 슬롯에 설정하는 것은 그리기를 의미하지 않는다.

이는 단지 버텍스를 파이프라인에 공급할 준비를 하는 것이다.

 

버텍스를 실제로 그리기 위한 마지막 단계는 Draw 메서드를 사용하는 것이다.

이 두 매개변수는 각각

그릴 정점의 수

첫 번째 버텍스의 인덱스(즉, 오프셋)를 의미한다.

 

Index Buffer

인덱스 역시 GPU에 의해 접근되어야 하기 때문에, 인덱스 버퍼를 구성하여 파이프라인에 넘겨주어야 하며

인덱스 버퍼를 생성하는 것 또한 버텍스 버퍼를 만드는 것과 매우 유사하다.

 

따라서, 예제를 보면서 빠르게 익히도록 한다.

 

버텍스 버퍼 및 다른 Direct3D 리소스와 마찬가지로, 사용하기 전에 이를 파이프라인에 바인딩해야 한다.

인덱스 버퍼는 ID3D11DeviceContext::IASetIndexBuffer 메서드를 사용하여 IA 단계에 바인딩한다.

 

다음은 이 메서드를 호출하는 예제이다.

 

이 예제에서는 32비트 부호없는 정수를 사용하므로, DXGI_FORMAT_R32_UINT를 지정했다.

굳이 32비트까지의 인덱스가 필요없다면 16비트 부호 없는 정수를 사용할 수도 있다.

 

DXGI_FORMAT_R32_UINT를 지정한 경우 sizeof(UINT) * 24 이런 식으로 ByteWidth를 설정해야 하지만

DXGI_FORMAT_R16_UINT를 지정한 경우 sizeof(UINT) * 12 이런 식으로.. 데이터 크기를 고려하여 디스크립션을 설정해야한다.

 

DXGI_FORMAT_R16_UINT, DXGI_FORMAT_R32_UINT는 인덱스 버퍼에 대해 지원되는 유일한 형식이다.

 

세 번째 매개변수는 인덱스 버퍼의 시작점에서 입력 어셈블리가 데이터를 읽기 시작할 위치까지의 오프셋을 바이트 단위로 측정한 값이다.

 

인덱스 버퍼의 앞부분의 일부 데이터를 건너뛰고 싶을 때 이 옵션을 사용할 수 있다.

 

마지막으로, 인덱스를 사용할 떄는 Draw 메서드 대신 DrawIndexed 메서드를 사용해야 한다.

 

IndexCount는 그리기 호출에서 사용할 인덱스의 수이다. 인덱스 버퍼의 모든 인덱스를 사용할 필요는 없다.

StartIndexLocation은 인덱스 버퍼에서 인덱스를 읽기 시작할 위치를 나타내는 요소의 인덱스다.

BaseVertexLocation 이 그리기 호출에서 사용할 인덱스에 추가될 정수 값으로, 버텍스를 가져오기 전에 계산된다.

 

이 매개변수를 설명하기 위해 다음 상황을 생각해보자.

구체, 상자, 실린더 세 개의 객체가 있다고 가정하자. 처음에는 각 객체가 자신의 버텍스 버퍼와 인덱스 버퍼를 가지고 있다. 각 로컬 인덱스 버퍼의 인덱스는 해당하는 로컬 버텍스 버퍼에 상대적이다.

이제, 구체, 상자, 실린더의 버텍스와 인덱스를 하나의 글로벌 버텍스와 인덱스 버퍼로 결합한다고 가정하자.

 

버텍스와 인덱스 버퍼를 결합하는 이유는, 버텍스와 인덱스 버퍼를 변경할 때 약간의 API 오버헤드가 발생하기 때문이다. 이는 대부분 성능에 큰 영향을 주지 않지만, 많은 작은 버텍스와 인덱스 버퍼를 가지고 있다면, 성능 향상을 위해 이를 결합하는 것이 유리할 수 있다.

 

이 결합 후, 인덱스는 더 이상 올바르지 않다. 인덱스는 글로벌 버텍스 버퍼가 아닌 해당 로컬 버텍스 버퍼에 상대적인 위치를 저장하고 있기 때문에, 인덱스가 글로벌 버텍스 버퍼에 올바르게 인덱싱되도록 재계산되어야 한다.

 

 

따라서, 인덱스를 업데이트하려면 모든 상자 인덱스에 firstBoxVertexPos를 추가해야 한다.

마찬가지로, 모든 실린더 인덱스에 firstCylVertexPos를 추가해야 한다.

 

구체는 첫 번째로 렌더링하기 때문에 기본 버텍스 위치를 설정할 필요가 없다.

 

글로벌 버텍스 버퍼에서  객체의 첫 번째 버텍스의 상대적인 위치를 base vertex location 이라고 부르자.

일반적으로 객체의 새로운 인덱스는 각 인덱스에 기본 버텍스 위치(base vertex location) 을 더해서 계산된다.

 

새로운 인덱스를 직접 계산하는 대신, DrawIndexed의 세 번째 매개변수로 기본 버텍스 위치를 전달하여 Direct3D가 이를 처리하도록 할 수 있다.

 

그런 다음, 다음 3가지 호출을 통해 구체, 상자, 실린더를 하나 씩 그릴 수 있다.

md3dImmediateContext->DrawIndexed(numSphereIndices, 0, 0);
md3dImmediateContext->DrawIndexed(numBoxIndices, firstBoxIndex, firstBoxVertexPos);
md3dImmediateContext->DrawIndexed(numCylIndices, firstCylIndex, firstCylVertexPos);

 

 

Vertex Shader 예제

 

쉐이더는 HLSL(고수준 쉐이딩 언어)라는 언어로 작성되며, 이 언어의 문법은 C++과 유사하기 때문에 배우기 쉽다.

 

과거에는 Effects Framework(.fx 파일)로 Effect 파일을 지원했으나, Mircrosoft는 이 기능을 더 이상 지원하지 않기로 했다. 그래서 Effects 를 사용하고 싶다면, 추가적인 라이브러리를 사용하여 추가할 수 있다.

 

그러나 최신 DirectX 개발에서는 쉐이더 파일을 개별로 관리하고, 수동으로 파이프라인을 구성하는 방식을 더 권장한다.

 

 

쉐이더 코드는 .hlsl 확장자를 가진 텍스트 기반 파일에 작성된다.

진입점은 기본적으로 main을 사용하지만

버텍스 쉐이더면 VS

픽셀 쉐이더면 PS를 사용하는 것이 관용적이다.

 

속성 페이지에서 쉐이더 파일에 대한 속성을 추가적으로 정할수도 있다.

 

 

이 버텍스 쉐이더는 4개의 매개변수를 가지고 있으며

첫 두 개는 입력 매개변수

마지막 두 개는 출력 매개변수이다.

 

HLSL은 참조나 포인터를 지원하지 않기 때문에, 함수에서 여러 값을 반환하려면 구조체나 out 매개변수를 사용해야 한다.

 

일반적으로는 구조체를 사용한다. 그 편이 가독성이 좋기 때문

 

첫 두 입력은 입력 시그니처이며, 이는 사용자 정의 버텍스 구조체의 데이터 멤버에 해당한다.

입력 시그니처 옆에있는 시맨틱스 "POSITION", "COLOR" 등은 버텍스 쉐이더 입력 매개변수를 매핑하는 데 사용된다.

 

SV는 System Value의 준말이며, 시스템 값 등으로 부른다.

SV_POSITION은 정점 위치를 나타내는 버텍스 쉐이더의 출력 요소이다.

시스템 값은 다른 매개변수와는 다르게 특별 취급된다. 시스템 값은 그래픽 파이프라인에서 중요한역할을 하는 값으로, GPU가 그 값을 어떻게 처리할지 미리 정의되어 있다.

SV_POSITION의 경우 클립 공간 변환된 정점을 위치와 관련된 특수한 시멘틱이다.

이는 후에 래스터라이저가 화면 상의 위치를 계산할 때 사용된다.

 

 시스템 값이 아닌 출력 매개변수의 시멘틱은 유효한 미리 정의된 이름(COLOR0, TEXCOORD0 등)이면 사용할 수 있다.

 

첫 번째 줄에서는 버텍스 위치를 로컬 공간에서 동차 클립 공간으로 변환하기 위해 4x4 행렬인 gWorldViewProj와 곱한다.

float4는 4D 벡터를 생성하며 XMFLOAT4와 동작이 동일하다.

 

버텍스의 위치는 점이기 때문에 w = 1 로 설정된다.

 

float2 와 float3는 각각 2d 벡터, 3d 벡터를 나타낸다.

HLSL에서 사용되는 특별한 데이터 형이다.

 

행렬 변수 gWorldViewProj는 콘스턴트 버퍼에 존재하며, 콘스턴트 버퍼는 다음장에 자세하게 다룬다.

간단하게만 짚고 넘어간다.

콘스턴트 버퍼는 CPU에서 생성된 데이터를 GPU에 전달하기 위한 버퍼이다.

예를 들어, 캐릭터의 월드 행렬, 카메라의 뷰 행렬이 대표적인 예시이다. 

 

쉐이더 프로그램으로 매개변수를 넘겨주는 것은 어렵기 때문에, 콘스턴트 버퍼에 쉐이더 프로그램으로 넘기고자하는 변수들을 채우는 것이다.

 

내장 함수 mul은 벡터와 행렬의 곱셈에 사용된다.

mul함수는 다양한 크기의 행렬 곱셈에 대해 오버로드 되어있다.

 

쉐이더 본문의 마지막 줄에서는 입력 색상을 출력 매개변수로 복사하여, 색상이 파이프라인의 다음 단계로 전달되도록 한다.

 

이전의 버텍스 쉐이더를 긴 매개변수 목록 대신 반환 타입과 입력 시그니처에 구조체를 사용하는 방식으로 다시 작성할 수 있다.

 

지오메트리 쉐이더가 없는 경우, 버텍스 쉐이더는 투영 변환을 수행해야 한다. 이는 하드웨어가 버텍스 쉐이더를 통과한 후 버텍스가 동차 클립 공간에 존재하는 것을 기대하기 때문이다.

 

지오메트리 쉐이더가 있는 경우, 투영 작업은 지오메트리 쉐이더로 넘길 수 있다.

 

버텍스 쉐이더 또는 지오메트리 쉐이더는 원근 분할(동차 나누기)를 수행하지 않는다.

원근 분할은 Rasterization Stage에서 수행한다.

 

Constant Buffers

이전 섹션에서 아래와 같은 코드가 있었다.

 

이 코드는 ConstantBuffer라는 이름의 컨스턴트 버퍼 객체를 정의한다.

컨스턴트 버퍼 객체의 꼴은 구조체와 유사하며

상수 버퍼를 바인딩할 GPU의 레지스터 슬롯을 명시할 수도 있다.

위 예제에서는 b0 레지스터 슬롯에 상수버퍼를 넣을 것이라고 명시하였다.

굳이 설정하지 않더라도 컴파일러가 자동으로 설정을 해준다.

하나의 컨스턴트 버퍼만 사용할 때는 레지스터를 설정안해도 상관은 없지만, 다수의 컨스턴트 버퍼를 사용할 경우 레지스터를 명시적으로 설정해주지 않으면, 가독성이 매우 떨어진다. 그러므로 레지스터 슬롯은 명시적으로 설정해주는 것이 좋다.

컨스턴트 버퍼의 경우 최대 15개의 슬롯이 존재하며 접두어로 b를 사용한다. 

 

컨스턴트 버퍼는 쉐이더가 접근할 수 있는 다양한 변수를 저장하는 데이터 블록이며,

CPU에서 생성된 데이터를 GPU로 전달하는 데 사용된다.

 

이 예제에서 World View Projection 매트릭스는  각각 월드, 뷰, 원근 투영 행렬을 의미한다.

상수 버퍼는 한 번 채워지면 다른 상수 버퍼 데이터로 덮어질 때까지 그 내용을 유지한다.

 

따라서, 어떤 오브젝트가 상수 버퍼를 정의하면, 상수버퍼를 굳이 추가적으로 정의할 필요없이 동일한 데이터로 버텍스들을 업데이트할 수 있다.

 

그러나 객체가 달라지면, 월드, 뷰, 원근 투영 행렬도 달라지기 때문에 다른 객체를 그릴때는 적절하게 컨스턴트 버퍼를 업데이트하는 것이 중요하다.

 

상수 버퍼에 권장되는 가이드라인이 있다.

일반적으로 상수 버퍼는 그 내용물을 업데이트해야 하는 빈도에 따라 생성하는 것이 좋다.

 

이 예제에서는 3개의 상수 버퍼를 사용한다.

첫 번째 상수 버퍼는 월드, 뷰, 원근 투영 행렬을 저장한다.

이 변수는 객체마다 다르기에, 객체마다 업데이트해야 한다.

프레임 당 100개의 객체를 렌더링한다고 치면, 이 상수버퍼는 프레임당 100번 업데이트하게 된다.

 

두 번째 상수 버퍼는 씬의 조명 변수를 저장한다.

조명이 애니메이션되어 매 프레임마다 한 번씩 업데이트해야 한다고 가정한다.

보통 조명은 씬의 모든 오브젝트의 업데이트가 완료되어야 하기에, 조명은 애니메이션되어 매 프레임마다 한 번씩 업데이트하게 된다.

 

세 번째 상수 버퍼는 안개를 제어하는 데 사용되는 변수를 저장한다.

여기서는 씬의 안개가 거의 변하지 않는다고 가정한다.

 

상수 버퍼를 여러 개로 나누는 이유는 효율성 때문이다.

상수 버퍼가 업데이트될 때, 모든 변수가 함께 업데이트되기 때문에 불필요한 업데이트를 최소화하는 것이 효율적이다.

 

상수 버퍼의 네이밍도 업데이트 빈도에 따라

cbPerObject, cbPerFrame, cbPerRarly 등으로 네이밍하는 것이 좋다.

더하여 hlsl 파일 내에서 컨스턴트 버퍼의 멤버들은 전역적으로 사용할 수 있으므로 g 접두사를 붙여주도록 하자.

 

더하여.. 다수의 컨스턴트 버퍼를 사용한다면 레지스터 슬롯을 명시적으로 정의하는 것이 좋다.

 

픽셀 쉐이더

RS 단계에서 VS, GS 단계에서 출력된 버텍스 속성이 보간된다.

이후 값은 픽셀 쉐이더의 입력으로 전달된다.

 

GS가 없다고 가정하면, 위 그림은 지금까지 버텍스 데이터가 거치는 경로를 보여준다.

픽셀 쉐이더는 각 픽셀 프래그먼트에 대해 실행되는 함수이다. 픽셀 쉐이더의 입력이 주어지면, 픽셀 쉐이더의 역할은 픽셀 프래그먼트에 대한 색상 값을 게산하는 것이다. 픽셀 프래그먼트는 생존하지 못하고 백 버퍼에 도달하지 못할 수 있다. 예를 들어, 픽셀쉐이더에서 클리핑될 수 있고 더 작은 깊이 값을 갖진 다른 픽셀 프래그먼트에 의해 가려질 수 있다.

또는 픽셀 프래그먼트가 스텐실 버퍼 테스트와 같은 후속 파이프라인 테스트에 의해 폐기될 수 있다.

 

하드웨어 최적화로 인해 픽셀 프래그먼트가 픽셀 쉐이더에 도달하기 전 파이프라인에서 거부될 수  있다.

(ex. early-z rejection)

 

이는 깊이 테스트가 먼저 수행되고, 픽셀 프래그먼트가 깊이 테스트에 의해 가려진 것으로 판단되면 픽셀 쉐이더가 건너뛰어지는 경우이다.

 

그러나 early-z rejection 최적화를 비활성화할 수 있는 경우도 있다.

예를 들어, 픽셀 쉐이더가 픽셀의 깊이를 수정하는 경우, 픽셀 쉐이더가 실행되어야 한다. 왜냐하면 픽셀 쉐이더가 깊이를 변경한다면, 그 이전에 픽셀의 깊이가 무엇인지 알 수 없기 때문이다. 

 

백 버퍼의 한 픽셀에는 여러 개의 픽셀 프래그먼트 후보가 있을 수 있다.

이것이 픽셀 프래그먼트와 픽셀의 차이점을 나타내며, 두 용어가 때로는 혼용되지만, 문맥상 그 의미가 명확해진다.

 


이 사진을 다시 보자.

각 버텍스 요소는 D3D11_INPUT_ELEMENT_DESC 배열에 의해 지정된 시맨틱과 연결되어 있다.

각 버텍스 쉐이더의 매개변수도 시맨틱과 연결되어 있다.

시맨틱은 버텍스 요소와 버텍스 쉐이더의 매개변수를 매칭하는데 사용된다.

 

마찬가지로, 픽셀 쉐이더 역시 정확히 매개변수를 전달받기 위해서 시맨틱을 이용한다.

이 시멘틱은 버텍스 쉐이더의 출력 시맨틱과 동일해야 한다.

 

아래 코드는 제시된 버텍스 쉐이더에 대응하는 간단한 픽셀 쉐이더이다.

 

이 예제에서, 픽셀 쉐이더는 보간된 색상 값을 단순히 반환한다.

픽셀 쉐이더의 입력이 버텍스 출력과 정확히 일치해야 한다는 점에 유의하라. 이는 필수 조건이다.

 

픽셀 쉐이더는 4D 색상 값을 반환한다.

함수 옆에는 : SV_Target 시맨틱이 붙어서 나오는데

이는 PS의 리턴값과 매핑되는 시맨틱이다.

 

이전의 버텍스와 픽셀 쉐이더를 입력/출력 구조체를 사용하여 동일하게 다시 작성할 수 있다.

 

표기법은 입력/출력 구조체의 멤버에 시맨틱스를 첨부하고, 출력 매개변수 대신 리턴 구문을 사용하여 출력을 처리하면, 훨씬 가독성이 좋아진다.

 

Render States

Driect3D는 기본적으로 State Machine이다. 상태는 우리가 변경할 때까지 현재 상태를 유지한다.

예를 들어, 앞에서 본 것 처럼, 파이프라인의 IA 단계에 바인딩된 입력 레이아웃, 버텍스 버퍼, 인덱스 버퍼는 다른 것으로 바인딩할 때까지 그 자리에 유지된다. 마찬가지로, 현재 설정된 기하 토폴로지도 변경될 때까지 유효하다.

 

Render States는 그래픽 파이프라인에서 특정 렌더링 동작을 제어하는 설정을 의미한다.

대표적으로 Resterizer state, Blending state, DepthStencil state 등이 존재한다.

 

각각의 파이프라인에서 처리되는 렌더링 동작은 매우 많은데,

Direct3D에는 이런 설정을 캡슐화하여 하나의 객체로 관리한다.

그러한 캡슐화된 객체를 State Group 상태 그룹이라고 한다.

 

이와 관련된 인터페이스로는

ID3D11RasterizerState

ID3D11BlendState

ID3D11DepthStencilState

 

총 3개가 존재하는데, RasterizerState만 다뤄보도록 하겠다. (나머지는 논외)

RasterizerState는 RS 단계에서 사용되는 렌더 상태를 관리하는 상태 그룹이다.

 

그러면 상태 그룹을 설정하는 RSSetState함수에 대해서 살펴보자. 

 

ID3D11RasterizerState를 알아보자.

 

음.. D3D11_RASTERIZER_DESC 구조체에 대해서 알아보자.

 

이 멤버 대부분은 고급 기능이거나 자주 사용되지 않는다.

첫 3가지만 알아보도록 한다.

1. FillMode

와이어프레임 렌더링을 위해 D3D11_FILL_WIREFRAME을 지정하거나

솔리드 렌더링을 위해 D3D11_FILL_SOLID을 지정하라

기본값은 솔리드 렌더링이다.

 

2. CullMode

컬링을 비활성화하려면 D3D11_CULL_NONE

뒤쪽을 향한 삼각형을 컬링하려면 D3D11_CULL_BACK

앞쪽을 향한 삼각형을 컬링하려면 D3D11_CULL_FRONT

기본적으로는 D3D11_CULL_BACK이다.

 

3. FrontCounterClockwise

카메라를 기준으로 시계 방향으로 배열된 삼각형을 앞쪽으로 간주하고, 반시계 방향으로 배열된 삼각형을 뒤쪽으로 간주하려면 false를 지정하라.

기본적으로는 false로 설정되어있다.

 

ID3D11RasterizerState 객체가 생성되면, 이 새로운 상태 블록으로 디바이스를 업데이트할 수 있다.

 

애플리케이션의 경우, 여러 개의 다른 ID3D11RasterizerState 객체가 필요할 수 있다.

따라서 초기화 시점에 모두 생성한 후, 애플리케이션 업데이트/그리기 코드에서 필요에 따라 전환하면 된다.

 

예를 들어, 두 개의 객체가 있고, 첫 번째 객체를 와이어프레임 모드로, 두 번째 객체를 솔리드 모드로 그리려고 한다면,

2개의 ID3D11RasterizerState 객체를 생성하고, 객체를 그릴 때 이들 간에 전환하면 된다.

 

Direct3D는 상태를 이전 설정으로 복원하지 않는다는 점에 유의해야 한다.

 

따라서 객체를 그릴 때 필요한 상태를 항상 설정해야 한다.

디바이스의 현재 상태에 대한 잘못된 가정은 잘못된 출력으로 이어질 것이다.

 

각 상태 블록에는 기본 상태가 있다. RSSetState 메서드에 null을 전달하여 기본 상태로 되돌릴 수 있다.

// Restore default state.
md3dImmediateContext->RSSetState( 0 );

 

일반적으로 애플리케이션은 런타임에 추가적인 레더 상태 그룹을 생성할 필요가 없다.

따라서 애플리케이션은 초기화 시점에 필요한 모든 렌더 상태 그룹을 정의하고 생성할 수 있다.

 

게다가, 렌더 상태 그룹은 런타임에 수정될 필요가 없기 떄문에, 렌더링 코드에서 이들에 대한 전역 읽기 전용 접근을 제공할 수 있다.

 

예를 들어, 모든 렌더 상태 그룹 객체를 static 클래스에 넣을 수 있다.

이렇게 하면 중복된 렌더 상태 그룹 객체를 생성하지 않으며, 렌더링 코드의 여러 부분이 렌더 상태 그룹 객체를 공유할 수 있다.

 

Effect

Effect Framework는 현재 공식적인 지원이 중단되었고 더 이상 사용을 권장하지 않기 때문에 스킵하도록 한다.

 

쉐이더 컴파일

쉐이더 컴파일에 대해서는 앞서 조금 언급했다.

쉐이더 코드 파일만 가지고는 당장 사용할 수 없고, 직접 컴파일하여 GPU에 컴파일된 코드를 넘겨줘야만 한다.

먼저 쉐이더 파일을 컴파일하는 방법부터 알아보자.

 

 

1. pSrcData

컴파일할 소스 코드 포인터

ASCII HLSL 코드의 포인터를 달라고 명시되어 있음

 

2. SrcDataSize

컴파일할 소스 코드 문자열의 크기

 

3. pSourceName

오류 메시지를 지정하는 문자열 (선택적)

 

4. pDefines

쉐이더 매크로를 정의하는 선택적 D3D_SHADER_MACRO 구조체 (선택적)

 

5. pInclude

쉐이더 파일에 #include 가 포함된 경우 이를 처리하기 위한 ID3DInclude 포인터

 

6. pEntrypoint

쉐이더 코드에서 실행이 시작되는 함수의 이름을 나타냄

fx 를 사용하는 경우 NULL로 설정해도 괜찮지만, 다른 경우에는 반드시 진입점 함수의 이름을 명ㅇ시해야 

 

7. pTarget

쉐이더 모델

컴파일할 쉐이더의 모델 스펙을 잘 판단하고 지정해야함

 

8. Flags1

컴파일 플래그 MSDN 참조

 

9. Flags2

컴파일 플래스그 MSDN 참조

 

10. ppCode

컴파일할 코드를 반환하는 ID3DBlob

 

11. ppErrorMsgs

오류 메시지를 반환하는 ID3DBlob

 

이 함수를 사용하여 쉐이더를 컴파일한다.

 

void Renderer::LoadAndCopileShaderFromFile(
	const std::wstring& inFilename,
	const std::string& inEntryPoint,
	const std::string& inTarget,
	ID3DBlob** outInBlob)
{
	std::ifstream file(inFilename);
	VERTIFY(file.is_open(), L"쉐이더 파일 로드 실패");

	std::stringstream buffer = {};
	buffer << file.rdbuf();

	std::string shaderCode = buffer.str();

	UINT compileFlags = 0;
#if defined(DEBUG) || defined(_DEBUG)
	compileFlags |= D3DCOMPILE_DEBUG | D3DCOMPILE_SKIP_OPTIMIZATION;
#else
	compileFlags |= D3DCOMPILE_OPTIMIZATION_LEVEL3;
#endif

	ComPtr<ID3DBlob> errorBlob = nullptr;
	HRESULT hr = D3DCompile(
		shaderCode.c_str(),
		shaderCode.size(),
		nullptr,
		nullptr,
		D3D_COMPILE_STANDARD_FILE_INCLUDE,
		inEntryPoint.c_str(),
		inTarget.c_str(),
		compileFlags,
		0,
		outInBlob,
		errorBlob.GetAddressOf());

	CHECK_FAILED_MESSAGE(hr, MA::StringToWstring((char*)errorBlob->GetBufferPointer()).c_str());
}

 

예시

 

애플리케이션과 쉐이더 프로그램 간의 통신

 

C++ 애플리케이션 코드는 상수 버퍼의 업데이트를 통해서 쉐이더 프로그램과 통신할 수 있다.

 

이와 같은 상수버퍼가 정의되어 있다고 해보자.

UpdateSubresource 이 함수를 통해서 상수 버퍼의 데이터를 CPU에서 GPU로 업데이트할 수 있으며,

VSSetConstantBuffers 이 함수를 통해서 Vertex Shader 단계에서 상수 버퍼를 쉐이더 프로그램에 바인딩할 수 있다.

 

 

1. pDstResource

데이터를 업데이트할 대상 리소스

텍스처, 버퍼와 같은 리소스를 가리키나, 상수 버퍼를 업데이트할 것이기 때문에 ID3D11Buffer 을 사용하면 될 것이다.

 

2. DstSubresource

업데이트할 서브리소스의 인덱스다.

 

3. pDstBox

데이터를 업데이트할 리소스 내의 영역을 지정하는 D3D11_BOX

지정하지 않으면 전체 리소스가 업데이트됨. NULL 가능

 

4. pSrcData

업데이트할 소스 데이터의 포인터이다.

상수 버퍼의 내용을 해당 데이터로 변경한다.

 

5. SrcRowPitch

한 행 마다의 데이터 크기를 나타낸다.

 

6. SrcDepthPitch

한 슬라이스 마다의 데이터 크기를 나타낸다.

 

해당 함수는 비단 상수 버퍼의 업데이트에서만 사용되는 것이 아니고

버퍼나 리소스를 업데이트할 때 고루 쓰인다.

	// 상수 버퍼 업데이트
	m_deviceContext->UpdateSubresource(m_constantBuffer.Get(), 0, nullptr, &cb, 0, 0);

 

VSSetConstantBuffers 는 버텍스 쉐이더 단계에 상수 버퍼를 설정하는 역할을 한다.

 

1. StartSlot

상수 버퍼를 바인딩할 첫 번째 슬롯의 인덱스이다.

상수 버퍼는 여러 슬롯에 바인딩될 수 있는데

여기서 말하는 슬롯은 레지스터 슬롯을 의미한다.

 

2. NumBuffers

버퍼들의 개수

 

3. ppConstantBuffers

콘스턴트 버퍼들의 포인터

 

 

콘스턴트 버퍼를 업데이트하고 렌더링 파이프라인데 바인딩하는 코드

 

이후.. 프리미티브한 기하를 생성하는 부분은 생략

 

 

동적 버텍스 버퍼

지금까지 우리는 초기화 시 고정되는 정적 버퍼를 사용했다.

반면, 동적 버퍼는 그 내용이 일반적으로 프레임마다 변경된다. 동적 버퍼는 주로 애니메이션을 필요로 할 떄 사용된다.

 

예를 들어, 파도 시뮬레이션을 하고 있다고 가정하자. 우리는 파동 방정식을 풀어 f(x, z, t) 함수의 해를 구할 수 있다. 이 함수는 t 시점에서 xz-평면의 각 지점에서의 파도 높이를 나타낸다. 이 함수를 사용해 파도를 그리려면, 봉우리와 계속을 그릴 때와 같이 삼각형 그리드 메시를 사용하고, 각 그리드 지점에 f(x, z, t) 를 적용해 파도의 높이를 얻을 수 있다.

 

이 함수는 시간 t에도 의존하므로, 매 1/30초마다 이 함수를 그리드 지점에 다시 적용하여 부드러운 애니메이션을 얻어야 한다. 따라서 시간이 지남에 따라 삼각형 그리드 메시의 정점 높이를 업데이트하기 위해 동적 정점 버퍼가 필요하다.

 

동적 정점 버퍼가 필요한 또 다른 상황은 복잡한 물리학 및 충돌 감지가 포함된 파티클 시스템이다. 각 프레임에서 CPU를 사용해 파티클의 물리학과 충돌 감지를 수행하여 파티클의 새로운 위치를 계산해야 한다. 파티클의 위치가 매 프레임마다 변경되 기 때문에 각 프레임에서 파티클의 위치를 그리기 위해 동적 정점 버퍼가 필요하다.

 

SUMMARY

1. Direct3D에서 정점은 공간적 위치 외에도 추가적인 데이터를 포함할 수 있다.

사용자 정의 정점 형식을 만들기 위해, 먼저 선택한 정점 데이터를 저장할 구조체를 정의한다. 그런 다음, 정점 구조체를 Direct3D에 설명하기 위해 각 정점 구성 요소마다 D3D11_INPUT_ELEMENT_DESC 요소로 이루어진 배열을 정의하고, 이 배열 설명을 사용해 ID3D11InputLayout 객체를 ID3D11Device::CreateInputLayout으로 생성한다. 그리고 ID3D11DeviceContext::IASetInputLayout 메서드를 통해 IA 스테이지에 입력 레이아웃을 바인딩할 수 있다.

 

2. GPU가 정점/인덱스 배열에 접근할 수 있게 하려면, 그들은 버퍼라고 불리는 특별한 리소스 구조에 저장되어야 한다. 이 버퍼는 ID3D11Buffer 인터페이스로 표현된다. 정점을 저장하는 버퍼는 정점 버퍼라고 하며, 인덱스를 저장하는 버퍼는 인덱스 버퍼라고 한다.

 

3. 정점 셰이더는 HLSL로 작성된 프로그램으로, GPU에서 실행되며 정점을 입력받아 정점을 출력한다. 그려지는 모든 정점은 정점 셰이더를 거친다. 이를 통해 프로그래머는 다양한 렌더링 효과를 얻기 위해 각 정점마다 특수 작업을 수행할 수 있다. 정점 셰이더에서 출력된 값은 파이프라인의 다음 단계로 전달된다.

 

4. 컨스턴트 버퍼는 C++ 애플리케이션 코드와 셰이더 프로그램 모두에서 접근할 수 있는 변수를 저장하는 데이터 블록이다. 이러한 방식으로 C++ 애플리케이션은 셰이더와 통신하며, 셰이더가 사용하는 상수 버퍼 내 값을 업데이트할 수 있다.  일반적인 권장 사항은 콘텐츠를 얼마나 자주 업데이트할 필요가 있는지에 따라 상수 버퍼를 생성하는 것이다. 상수 버퍼를 나누는 이유는 효율성 때문이다. 상수 버퍼가 업데이트될 때, 모든 변수가 업데이트되어야 하므로, 업데이트 빈도에 따라 그룹화하여 중복 업데이트를 최소화하는 것이 효율적이다.

 

5. 픽셀 셰이더는 HLSL로 작성된 프로그램으로, GPU에서 실행되며 보간된 정점 데이터를 입력받아 색상 값을 출력한다. 하드웨어 최적화로 인해 픽셀 셰이더에 도달하기 전에 픽셀 프래그먼트가 파이프라인에서 거부될 수 있다. 픽셀 셰이더를 통해 프로그래머는 각 픽셀마다 특수한 작업을 수행하여 다양한 렌더링 효과를 얻을 수 있다. 픽셀 셰이더에서 출력된 값은 파이프라인의 다음 단계로 전달된다.

 

6. 렌더 상태는 장치가 유지하는 상태로, 기하 구조가 렌더링되는 방식에 영향을 미친다. 렌더 상태는 변경될 때까지 유효하며, 현재 값은 이후의 모든 그리기 작업에 적용된다. 모든 렌더 상태는 초기 기본 상태를 가지고 있다. Direct3D는 렌더 상태를 세 가지 상태 블록으로 나눈다. 래스터라이저 상태(ID3D11RasterizerState), 블렌드 상태(ID3D11BlendState), 그리고 깊이/스텐실 상태(ID3D11DepthStencilState). 렌더 상태는 C++ 애플리케이션 레벨이나 이펙트 파일에서 생성하고 설정할 수 있다.

 

7. 동적 버퍼는 버퍼의 내용이 런타임에서 자주 업데이트되어야 할 때 사용된다. 동적 버퍼는 D3D11_USAGE_DYNAMIC 사용 플래그와 D3D11_CPU_ACCESS_WRITE CPU 접근 플래그로 생성되어야 한다. 버퍼를 업데이트하기 위해 ID3D11DeviceContext::Map 및 ID3D11DeviceContext::Unmap 메서드를 사용한다.