렌더링 파이프라인 과정에서 가장 먼저 하게되는 과정이 입력 조립기 과정이다.
우리가 하나의 물체를 만들기 위해선 가장 먼저 해야 할 일이 정점들의 정보를 받아서 삼각형으로 잇는 작업이 필요하다.
그래야 그 다음 작업들을 하며 하나씩 물체를 만들어 나갈 수 있기 때문이다.
오늘은 입력 조립기의 전반적인 과정과 내용을 한번 정리 해 볼 예정이다.
정점은 간단하게 말하면 하나의 점이지만 DirectX에서의 정점은 단순히 위치의 뜻만을 갖고있는건 아니다.
우리가 정점을 어떻게 사용하느냐에 따라서 다양한 값을 포함하고 있을 수 있다.
struct Vertex
{
Vec3 pos;
Vec2 uv;
Vec3 normal;
Vec3 tangent;
};
위 값은 우리 응용프로그램에서 사용하는 정점의 정보를 가진 구조체고
struct VS_IN
{
float3 pos : POSITION;
float2 uv : TEXCOORD;
float3 normal : NORMAL;
float3 tangent : TANGENT;
};
이건 쉐이더 프로그램에서 사용할 정점의 정보들이다.
이런 식으로 우리가 정점의 정보를 단 한 가지의 목적으로만 사용하는 게 아니라 사용할 목적에 따라서 다양한 값을 넣어서 사용할 수 있다.
이 정점을 어떻게 사용하는지는 조금 뒤에 알아보도록 하고 다른 내용을 먼저 다뤄보도록 하자.
입력 조립기에서는 정점의 정보를 받아서 하나 이상의 도형 즉, 하나 이상의 삼각형을 만드는게 목적이라고 했다.
그러면 그 삼각형은 어떻게 그릴까?
조금 이따가 설명할 Index와 이 위상구조의 조합으로 어떤 식으로 삼각형을 만들어 나갈지 알게된다.
typedef enum D3D_PRIMITIVE_TOPOLOGY {
D3D_PRIMITIVE_TOPOLOGY_UNDEFINED = 0,
D3D_PRIMITIVE_TOPOLOGY_POINTLIST = 1,
D3D_PRIMITIVE_TOPOLOGY_LINELIST = 2,
D3D_PRIMITIVE_TOPOLOGY_LINESTRIP = 3,
D3D_PRIMITIVE_TOPOLOGY_TRIANGLELIST = 4,
D3D_PRIMITIVE_TOPOLOGY_TRIANGLESTRIP = 5,
D3D_PRIMITIVE_TOPOLOGY_LINELIST_ADJ = 10,
D3D_PRIMITIVE_TOPOLOGY_LINESTRIP_ADJ = 11,
D3D_PRIMITIVE_TOPOLOGY_TRIANGLELIST_ADJ = 12,
D3D_PRIMITIVE_TOPOLOGY_TRIANGLESTRIP_ADJ = 13,
D3D_PRIMITIVE_TOPOLOGY_1_CONTROL_POINT_PATCHLIST = 33,
...
} ;
위 구조체는 Direct3D에서 사용되는 Promitive 열거형의 일부이다.
우리는 이 열거형 중 하나를 사용해서 삼각형을 그릴 정보를 GPU에게 알려주게 된다.
그 세팅을 해 주는게 IASetPromitiveTopology()함수이다.
이 곳에 열거형 중 하나를 선택해 입력하면 GPU는 그 정보를 토대로 정점들을 이어나간다.
_cmdList->IASetPromitiveTopology(D3D_PRIMITIVE_TOPOLOGY_TRIANGLELIST);
그럼 그 목록중 일부만 알아보도록 하자.
D3D_PRIMITIVE_TOPOLOGY_POINTLIST를 사용하면 개별적인 점의 형태로 정점을 그려낸다.
D3D_PRIMITIVE_TOPOLOGY_LINESTRIP을 사용하면 선 띠 또는 선분 띠로 설정되며 정점들은 차례로 연결된 선분들을 형성한다.
D3D_PRIMITIVE_TOPOLOGY_LINELIST를 선택하면 정점 두 개가 하나의 선분을 형성한다.
선 띠는 선분들이 자동으로 연결된다고 가정되는데 선 목록은 분리된 선분들을 만들어낸다.
D3D_PRIMITIVE_TOPOLOGY_TRIANGLESTRIP을 사용하면 삼각형 띠가 설정되며 모든 삼각형들이 연결되어 표현되며 결과적으로 n개의 정점으로 n - 2개의 삼각형이 만들어진다.
여기서 중요한건 짝수 번째 삼각형과 홀수 번째 삼각형의 감기는 순서가 다르다는 것인데 이 문제는 나중에 후면 선별 시 문제가 발생할 수 있기 때문에 GPU내부적으로 짝수 번째 삼각형의 처음 두 정점의 순서를 바꿔 감기는 순서가 동일하게 맞춘다.
D3D_PRIMITIVE_TOPOLOGY_TRIANGLELIST는 정점 세 개가 하나의 삼각형을 형성하는 형태이다.
출처 : 책 - DirectX 12를 이용한 게임 프로그래밍 입문
입력 조립기에선 정점을 이용해 도형을 만들고, 그 도형을 만드는 방식을 설정한다고 말했다.
그러면 그 도형을 만드는 순서 즉, 어느 정점에서 시작해서 어느 정점이 마지막이라는 그 순서는 어떻게 정할까?
그 그리는 순서를 정해주는게 인덱스(Index)다.
스쳐가듯 말 했는데 이 삼각형이 그려지는 순서는 아주 중요하다. 이 감기는 순서에 따라서 이 삼각형이 뒷면인지, 앞면인지를 판별하는데 만약 뒷면이면 레스터라이저 단계에서 해당 부분은 렌더하지 않는다. 그렇기 때문에 정점 뿐만 아니라 이 인덱스도 아주 중요한 요소라고 볼 수 있다.
그러면 이 인덱스는 왜 사용하는걸까?
그냥 정점들만 가지고 알아서 잘 만들면 되는거 아닌가?
물론 그럴 순 있지만 정점이라는 것 자체가 단순한 정보가 아니라고 말했었다.
정점은 우리가 3D 세계를 그려나가는데 필요한 많은 정보들이 담겨서 사용되기 때문에 적지않은 메모리를 사용한다.
뿐만아니라 렌더링 파이프라인 중 정점 쉐이더 단계에선 모든 정점에 대해서 계산을 수행하는데 굳이 필요없는 정점을 계산할 필요가 있을까?
그렇기 때문에 비교적 작은 값인 정수의 형태로 사용할 수 있는 인덱스를 사용해서 정점의 수를 줄이고 인덱스를 사용하는 것이다.
간단하게 봐 보자
인덱스 없이 사각형 하나를 만들기 위해선 정점 6개가 필요하다.
이렇게 정점 6개로 두 개의 삼각형을 만들고 그 두개를 이어서 사용한다.
하지만 그럴 필요가 있을까?
그 두 개의 삼각형 중 중첩되는 정점은 그냥 무시하고 인덱스로 이으면 다음과 같이 된다.
이렇게 정점 4개를 통해서 사각형 하나를 만들 수 있게 됐다.
물론 삼각형 띠의 형태를 이용하면 정점의 중복을 완화시킬 순 있지만 항상 가능한 건 아니기 때문에 훨씬 더 유연한 삼각형 목록을 이용해서 정점을 줄이는 것이다.
일단 코드를 간단하게 보기 전에 알아둬야 할 부분은 렌더링 과정은 모든 파이프라인 단계가 엮여서 동작하기 때문에 한 단계만 본다고 해서 어떻게 동작하는지 알아채긴 쉽지 않다. 하지만 조금이나마 알기 편하게 하기 위해 코드를 조금 남겨본다. 그냥 대충 이렇게 흘러가는구나 하고 보도록 하자.
우선 파이프라인에 어떤 데이터를 묶기 위해서는 Descriptor(View)의 형태로 데이터를 저장해서 묶어야 한다. 이건 파이프라인의 모든 단계가 동일하다.
정점에 대해 정의하고, Descriptor를 만드는 과정은 다음과 같다.
float w2 = 0.5f;
float h2 = 0.5f;
vector<Vertex> vec(4);
vec[0] = Vertex(Vec3(-w2, -h2, 0), Vec2(0.0f, 1.0f), Vec3(0.0f, 0.0f, -1.0f), Vec3(1.0f, 0.0f, 0.0f));
vec[1] = Vertex(Vec3(-w2, +h2, 0), Vec2(0.0f, 0.0f), Vec3(0.0f, 0.0f, -1.0f), Vec3(1.0f, 0.0f, 0.0f));
vec[2] = Vertex(Vec3(+w2, +h2, 0), Vec2(1.0f, 0.0f), Vec3(0.0f, 0.0f, -1.0f), Vec3(1.0f, 0.0f, 0.0f));
vec[3] = Vertex(Vec3(+w2, -h2, 0), Vec2(1.0f, 1.0f), Vec3(0.0f, 0.0f, -1.0f), Vec3(1.0f, 0.0f, 0.0f));
uint32 bufferSize = vec.size() * sizeof(Vertex);
D3D12_HEAP_PROPERTIES heapProperty = CD3DX12_HEAP_PROPERTIES(D3D12_HEAP_TYPE_UPLOAD);
// 타입을 UPLOAD로 했는데 정적 데이터는 Default로 하는게 성능적인 측면에서 좋지만
// 간단하게 테스트하기위해 업로드로 함.
D3D12_RESOURCE_DESC desc = CD3DX12_RESOURCE_DESC::Buffer(bufferSize);
DEVICE->CreateCommittedResource(
&heapProperty,
D3D12_HEAP_FLAG_NONE,
&desc,
D3D12_RESOURCE_STATE_GENERIC_READ,
nullptr,
IID_PPV_ARGS(&_vertexBuffer));
// 정점을 위한 버퍼를 만들어서 해당 메모리의 위치를 _vertexBuffer에 저장.
void* vertexDataBuffer = nullptr;
CD3DX12_RANGE readRange(0, 0);
_vertexBuffer->Map(0, &readRange, &vertexDataBuffer);
::memcpy(vertexDataBuffer, &buffer[0], bufferSize);
_vertexBuffer->Unmap(0, nullptr);
// 우리가 설정한 정점의 정보들을 _vertexBuffer에 복사.
_vertexBufferView.BufferLocation = _vertexBuffer->GetGPUVirtualAddress();
_vertexBufferView.StrideInBytes = sizeof(Vertex);
_vertexBufferView.SizeInBytes = bufferSize;
// _vertexBufferView에 정점에 대한 정보를 저장.
조금 넘어가는 이야기지만 원래 Direc3D 12에서 대부분의 Descriptor는 Descriptor Heap에 저장되어 사용된다.
하지만 정점과 인덱스는 예외로 Heap을 사용하지않고 D3D12_INPUT_ELEMENT_DESC과 D3D12_VERTEX_ELEMENT_DESC라는 구조체를 사용한다.
그럼 이제 인덱스를 알아보자
vector<uint32> idx(6);
idx[0] = 0; idx[1] = 1; idx[2] = 2;
idx[3] = 0; idx[4] = 2; idx[5] = 3;
uint32 bufferSize = idx.size() * sizeof(uint32);
D3D12_HEAP_PROPERTIES heapProperty = CD3DX12_HEAP_PROPERTIES(D3D12_HEAP_TYPE_UPLOAD);
D3D12_RESOURCE_DESC desc = CD3DX12_RESOURCE_DESC::Buffer(bufferSize);
DEVICE->CreateCommittedResource(
&heapProperty,
D3D12_HEAP_FLAG_NONE,
&desc,
D3D12_RESOURCE_STATE_GENERIC_READ,
nullptr,
IID_PPV_ARGS(&_indexBuffer));
void* indexDataBuffer = nullptr;
CD3DX12_RANGE readRange(0, 0);
_indexBuffer->Map(0, &readRange, &indexDataBuffer);
::memcpy(indexDataBuffer, &buffer[0], bufferSize);
_indexBuffer->Unmap(0, nullptr);
_indexBufferView.BufferLocation = _indexBuffer->GetGPUVirtualAddress();
_indexBufferView.Format = DXGI_FORMAT_R32_UINT;
_indexBufferView.SizeInBytes = bufferSize;
별로 다른 점은 없지만 인덱스에 각 정점의 번호를 넣어 순서를 알려준다. 그렇게 되면 GPU측에서 이 인덱스의 정보를 갖고 정점을 이어서 만들게 되며 인덱스도 동일하게 버퍼와 뷰를 만들게 된다.
이제 Pipeline에 해당 정보들을 묶기만 하면 정점과 인덱스를 사용할 준비가 완료된 것이다.
이제 입력 조립기 단계를 파이프라인에 묶는 법을 알아보자.
우선 이 단계에서 입력해야되는 내용은 우리가 쉐이더 파일에서 사용할 값과 동일하거나 그 이상이어야한다.
한마디로 데이터를 더 넘기는건 상관없지만 쉐이더 파일이서 요구하는 데이터는 무조건 넣어야한다. 그렇지 않으면 쉐이더 파일이 컴파일되지 않는다.
정점 이야기를 할 때 잠깐 봤던 쉐이더측 구조체를 먼저 보자.
struct VS_IN
{
float3 pos : POSITION;
float2 uv : TEXCOORD;
float3 normal : NORMAL;
float3 tangent : TANGENT;
};
변수 선언은 C++과 별 차이 없지만 :
뒤에 어떤게 있다는게 다른 점이다.
이 부분은 응용 프로그램과 쉐이더 사이의 연결고리라고 보면 되는데 Semantic이라고 부른다.
응용프로그램에서 PipelineState를 생성할 때 해당 시멘틱을 이용해서 데이터를 구분한다.
이제 응용프로그램을 보면 D3D12_INPUT_ELEMENT_DESC를 이용해서 쉐이더 측에서 필요한 데이터를 파이프라인에 묶어준다.
D3D12_INPUT_ELEMENT_DESC inputDesc[] =
{
{ "POSITION", 0, DXGI_FORMAT_R32G32B32_FLOAT, 0, 0, D3D12_INPUT_CLASSIFICATION_PER_VERTEX_DATA, 0 },
{ "TEXCOORD", 0, DXGI_FORMAT_R32G32_FLOAT, 0, 12, D3D12_INPUT_CLASSIFICATION_PER_VERTEX_DATA, 0 },
{ "NORMAL", 0, DXGI_FORMAT_R32G32B32_FLOAT, 0, 20, D3D12_INPUT_CLASSIFICATION_PER_VERTEX_DATA, 0 },
{ "TANGENT", 0, DXGI_FORMAT_R32G32B32_FLOAT, 0, 32, D3D12_INPUT_CLASSIFICATION_PER_VERTEX_DATA, 0 },
};
여기에 들어가는 인자들은 다음과 같이 정의되어 있다.
typedef struct D3D12_INPUT_ELEMENT_DESC {
LPCSTR SemanticName; //쉐이더에 정의된 시멘틱 이름
UINT SemanticIndex; //시멘틱 이름이 동일한게 있다면 인덱스로 구분한다. 하나만 있다면 0
DXGI_FORMAT Format; //데이터의 형식을 지정하는 DXGI_FORMAT 값
UINT InputSlot; // Vertex Buffer는 여러개 만들 수 있는데 해당 버퍼의 인덱스. 0~15까지 가능하다.
UINT AlignedByteOffset; // 아래 별도 설명
D3D12_INPUT_CLASSIFICATION InputSlotClass; // 인스턴싱을 하지 않을거면 D3D12_INPUT_CLASSIFICATION_PER_VERTEX_DATA를 사용
UINT InstanceDataStepRate; // 인스턴스 수. 안쓸거니까 0
} D3D12_INPUT_ELEMENT_DESC;
AlignedByteOffset는 쉐이더에서 사용하는 변수간의 메모리 시작점을 말한다고 볼 수 있다.
첫 번째 변수의 시작점은 0이고 첫 번째 변수인 pos의 자료형이 float3이니까 크기는 4x3 = 12다.
pos가 가진 메모리 크기는 12니까 0~12를 차지하고 그 다음 변수인 uv의 시작점은 12가 된다. 이 변수의 메모리 크기는 4x2 = 8이니까 12~20까지 차지한다고 볼 수 있다.
이런 식으로 쉐이더에서 사용하는 정점 구조체의 변수간의 메모리 시작점을 잡아주는거라고 볼 수 있다.
이제 이 값을 파이프라인에 묶어주면 된다.
D3D12_GRAPHICS_PIPELINE_STATE_DESC pipelineDesc = {};
pipelineDesc.InputLayout = { inputDesc, _countof(inputDesc) };
위와 같이 파이프라인 생성을 위한 구조체 중 InputLayout을 채워주면 파이프라인 중 첫 단계를 완료한 것이다.
참고
- 책
DirectX 12를 이용한 게임 프로그래밍 입문- 기타
Drawing!
Getting Started with the Input-Assembler Stage