기록공간

[DirectX 12] Direct3D 그리기 연산(정육면체) - 1 본문

DirectX/기초

[DirectX 12] Direct3D 그리기 연산(정육면체) - 1

입코딩 2020. 4. 28. 19:19
반응형

이번 장에서는 그리는 과정에 필요한 Direct3D API의 여러 인터페이스와 메서드를 중점적으로 살펴본다. 

(코드는 https://github.com/d3dcoder/d3d12book에서 받을 수 있다. (chapter 6))

 

정점과 입력 배치

Direct3D는 정점의 위치 이외의 추가적인 자료를 부여할 수 있다. 원하는 자료를 가진 정점 형식을 만들려면 우선 그러한 자료(벡터 형식)를 담을 구조체를 정의해야 한다. 다음은 서로 다른 두 가지 정점 형식의 예이다. 하나는 위치의 색상으로 구성되고 또 하나는 위치, 법선, 그리고 두 개의 텍스처 좌표로 구성된다.

struct Vertex1
{
    XMFLOAT3 Pos;
    XMFLOAT3 Color;
};
struct Vertex2
{
    XMFLOAT3 Pos;
    XMFLOAT3 Normal;
    XMFLOAT2 Tex0;
    XMFLOAT2 Tex1;
};

그다음에는 정점 구조체의 각 성분으로 무엇을 해야 하는지를 Direct3D에게 알려주어야 한다. 그것을 위한 수단으로 쓰이는 것이 입력 배치 서술(input layout description)이다. 

typedef struct D3D12_INPUT_LAYOUT_DESC
{
    const D3D12_INPUT_ELEMENT_DESC *pInputElementDescs;
    UINT NumElements;
} D3D12_INPUT_LAYOUT_DESC;

이 구조체는 그냥 D3D12_INPUT_ELEMENT_DESC 형식의 원소들을 담을 배열과 그 원소들의 개수를 가지고 있다.

 

D3D12_INPUT_ELEMENT_DESC 배열의 각 원소는 정점 구조체의 각 성분을 서술한다. 이 배열의 원소들과 정점 구조체의 성분들은 일대일로 대응되어야 한다. 예를 들어, 정점 구조체의 성분이 두 개이면 그에 해당하는 D3D12_INPUT_ELEMENT_DESC 배열에는 원소가 두 개 있어야 한다. 

typedef struct D3D12_INPUT_ELEMENT_DESC
{
    LPCSTR SementicName;
    UINT SemanticIndex;
    DXGI_FORMAT Format;
    UINT InputSlot;
    UINT AlignedByteOffset;
    D3D12_INPUT_CLASSIFICATION InputSlogClass;
    UINT InstanceDataStepRate;
} D3D12_INPUT_ELEMENT_DESC;

1. SementicName

성분에 부여된 문자열 이름이다. 이것은 정점 셰이더 의미소(Sementic) 이름으로 쓰이므로, 반드시 유효한 변수 이름이어야 한다. 의미소는 정점 구조체의 성분을 정점 셰이더 Input Description과 대응시키는 역할을 한다.

 

아래의 빨간 글씨가 정점 셰이더의 시멘틱(Sementic) 이름이다. 위에서는 이 이름에 맞게 SementicName을 부여한 원소들을 정의한 코드이다. 

 

2. SementicIndex

시멘틱에 부여된 인덱스이다. 이러한 인덱스가 필요한 이유는 위의 정점 구조체 Vertex2 처럼 텍스처 좌표가 여러 개 있을 수 있는데, 각 텍스처 좌표에 개별적인 시멘틱 이름을 붙이는 대신에 인덱스를 통해서 구별한다. 셰이더 코드에서 인덱스가 지정되지 않은 시멘틱은 인덱스가 0인 시멘틱으로 간주한다.

struct Vertex2
{
    XMFLOAT3 Pos;
    XMFLOAT3 Normal;
    XMFLOAT2 Tex0;
    XMFLOAT2 Tex1;
};

위 구조체에서 Tex0, Tex1 원소의 SementicName은 둘 다 TEXCOORD일 것이고 SementicIndex는 각각 0, 1이 된다. 정점 셰이더에서는 TEXCOORD0, TEXCOORD1로 표현된다. 

 

3. Format

선언하려는 원소의 정점 성분의 자료 형식을 Direct3D에게 알려주는 역할을 한다. 다음은 흔히 쓰이는 형식들이다.

DXGI_FORMAT_R32_FLOAT          // 1차원 32비트 float 스칼라값
DXGI_FORMAT_R32G32_FLOAT       // 2차원 32비트 float 벡터
DXGI_FORMAT_R32G32B32_FLOAT    // 3차원 32비트 float 벡터
DXGI_FORMAT_R32G32B32A32_FLOAT // 4차원 32비트 float 벡터

DXGI_FORMAT_R8_UINT            // 1차원 8비트 unsigned int 스칼라값
DXGI_FORMAT_R16B16_SINT        // 2차원 16비트 int 벡터
DXGI_FORMAT_R32GB32B32_UINT    // 3차원 32비트 unsigned int 벡터
DXGI_FORMAT_R8G8B8A8_SINT      // 4차원 8비트 int 벡터
DXGI_FORMAT_R8G8B8A8_UINT      // 4차원 8비트 unsigned int 벡터

 

4. InputSlot

이 성분의 자료를 가져올 정점 버퍼 슬롯의 인덱스이다. Direct3D에서는 총 16개의 정점 버퍼 슬롯(0 ~ 15)을 통해서 정점 자료를 공급할 수 있다.

 

5. AlignedByteOffset

지정된 입력 슬롯에서, C++ 정점 구조체의 시작 위치와 이 정점 성분의 시작 위치 사이의 거리를 나타내는 오프셋(바이트 단위)이다. 

struct Vertex1
{
    XMFLOAT3 Pos;
    XMFLOAT3 Color;
};

위 정점 구조체에서 Pos 성분의 오프셋은 0바이트이다. 구조체의 시작 위치와 성분의 시작 위치가 일치하기 때문이다. Color 성분은 Pos 성분의 바이트들을 지나친 위치에서 시작하므로 오프셋이 12바이트(4*3=12) 이다. 이를 토대로 Vertex2의 오프셋을 정하면 다음과 같다.

struct Vertex2
{
    XMFLOAT3 Pos;    // 0
    XMFLOAT3 Normal; // 12
    XMFLOAT2 Tex0;   // 24
    XMFLOAT2 Tex1;   // 32
};

 

6. InputSlogClass

일단은 D3D12_INPUT_PER_VERTEX_DATA를 지정한다. 다른 값은 고급 기법에 쓰인다.

 

7. InstanceDataStepRate

일단은 0을 지정한다. 다른 값은 고급 기법에 쓰인다.

 

위 정보들을 토대로 Vertex1과 Vertex2에 대해서는 각각 다음과 같은 Input Layout DESC 배열들을 사용하면 된다.

D3D12_INPUT_ELEMENT_DESC desc1[] =
{
    { "POSITION", 0, DXGI_FORMAT_R32G32B32_FLOAT, 0, 0, D3D12_INPUT_CLASSIFICATION_PER_VERTEX_DATA, 0 },
    { "COLOR", 0, DXGI_FORMAT_R32G32B32A32_FLOAT, 0, 12, D3D12_INPUT_CLASSIFICATION_PER_VERTEX_DATA, 0 }
};

D3D12_INPUT_ELEMENT_DESC desc1[] =
{
    { "POSITION", 0, DXGI_FORMAT_R32G32B32_FLOAT, 0, 0, D3D12_INPUT_CLASSIFICATION_PER_VERTEX_DATA, 0 },
    { "NORMAL", 0, DXGI_FORMAT_R32G32B32A32_FLOAT, 0, 12, D3D12_INPUT_CLASSIFICATION_PER_VERTEX_DATA, 0 },
    { "TEXCOORD", 0, DXGI_FORMAT_R32G32_FLOAT, 0, 24, D3D12_INPUT_CLASSIFICATION_PER_VERTEX_DATA, 0 },
    { "TEXCOORD", 1, DXGI_FORMAT_R32G32_FLOAT, 0, 32, D3D12_INPUT_CLASSIFICATION_PER_VERTEX_DATA, 0 },
};

 

정점 버퍼

GPU가 정점들의 배열에 접근하려면, 그 정점들을 버퍼(buffer)라고 부르는 GPU 자원(ID3D12Resource)에 넣어 두어야 한다. 정점들을 저장하는 버퍼를 정점 버퍼(vertex buffer)라고 부른다. 버퍼는 텍스처보다 단순한 자원이다. 응용 프로그램에서 정점 같은 자료 원소들의 배열을 GPU에 제공해야 할 때에는 항상 버퍼를 사용한다. 

 

정점 버퍼를 생성하려면 버퍼 자원을 서술하는 D3D12_RESOURCE_DESC를 채우고 ID3D12Device:: CreateCommittedResource 메서드를 호출해서 ID3D12Resource 객체를 생성한다. ID3D12Resource는 Direct3D 12의 모든 자원을 대신한다. Direct3D 12는 D3D12_RESOURCE_DESC를 상속해서 편의용 생성자들과 메서드들을 추가한 C++ 래퍼 클래스 CD3DX12_RESOURCE_DESC를 제공한다. 이를 이용하면 D3D12_RESOURCE_DESC 구조체 인스턴스를 간단히 생성할 수 있다.

static inline CD3DX12_RESOURCE_DESC Buffer(
    UINT64 width,
    D3D12_RESOURCE_FLAGS flags = D3D12_RESOURCE_FLAG_NONE,
    UINT64 alignment = 0)
{
    return CD3DX12_RESOURCE_DESC( D3D12_RESOURCE_DIMENSION_BUFFER,
        alignment, width, 1, 1, 1,
        DXGI_FORMAT_UNKNOWN, 1, 0,
        D3D12_TEXTURE_LAYOUT_ROW_MAJOR, flags);
}

범용 GPU 자원으로서의 버퍼에서 너비(width)는 가로 길이가 아니라 버퍼의 바이트 개수를 뜻한다. 예를 들어 float 64개를 담는 버퍼의 너비는 64 * sizeof(float)이다.

 

정적 기하구조(정점이 변하지 않는 3D 모델)를 그릴 때에는 최적의 성능을 위해 정점 버퍼들을 기본 힙(D3D12_HEAP_TYPE_DEFAULT)에 넣는다. 일반적으로 게임의 기하구조들은 대부분 정적이다. 정적 기하구조는 버퍼를 초기화한 이후 GPU만 버퍼의 정점들을 읽으므로, 기본 힙에 넣는 것이 합당하다. CPU는 기본 힙에 있는 정점 버퍼를 수정할 수 없다. 그러면 어떻게 응용 프로그램은 정점 버퍼를 초기화하는 것일까?

 

실제 정점 버퍼 자원을 생성하는 것과 더불어, 응용 프로그램은 D3D12_HEAP_TYPE_UPLOAD 형식의 힙에 임시 업로드용 버퍼 자원을 생성해야 한다. CPU 메모리에서 GPU 메모리로 자료를 복사하려면 업로드 힙에 자원을 맡겨야 한다. 업로드 버퍼를 생성한 다음 정점 자료를 업로드 버퍼에 복사하고, 업로드 버퍼의 정점 자료를 실제 정점 버퍼로 복사한다.

 

기본 버퍼(D3D12_HEAP_TYPE_DEFAULT)의 자료를 초기화하려면 항상 임시 업로드 버퍼가 필요하므로, 프레임 워크에서는 편의용 함수를 제공한다. 이 함수를 이용하면 기본 버퍼가 필요할 때마다 같은 코드를 반복하지 않아도 된다.

Microsoft::WRL::ComPtr<ID3D12Resource> d3dUtil::CreateDefaultBuffer(
    ID3D12Device* device,
    ID3D12GraphicsCommandList* cmdList,
    const void* initData,
    UINT64 byteSize,
    Microsoft::WRL::ComPtr<ID3D12Resource>& uploadBuffer)
{
    ComPtr<ID3D12Resource> defaultBuffer;

    // 실제 기본 버퍼 자원을 생성한다.
    ThrowIfFailed(device->CreateCommittedResource(
        &CD3DX12_HEAP_PROPERTIES(D3D12_HEAP_TYPE_DEFAULT),
        D3D12_HEAP_FLAG_NONE,
        &CD3DX12_RESOURCE_DESC::Buffer(byteSize),
		D3D12_RESOURCE_STATE_COMMON,
        nullptr,
        IID_PPV_ARGS(defaultBuffer.GetAddressOf())));

    // CPU 메모리의 자료를 기본 버퍼에 복사하려면
    // 임시 업로드 힙을 만들어야 한다. 
    ThrowIfFailed(device->CreateCommittedResource(
        &CD3DX12_HEAP_PROPERTIES(D3D12_HEAP_TYPE_UPLOAD),
		D3D12_HEAP_FLAG_NONE,
        &CD3DX12_RESOURCE_DESC::Buffer(byteSize),
		D3D12_RESOURCE_STATE_GENERIC_READ,
        nullptr,
        IID_PPV_ARGS(uploadBuffer.GetAddressOf())));


    // 기본 버퍼에 복사할 자료를 서술한다.
    D3D12_SUBRESOURCE_DATA subResourceData = {};
    subResourceData.pData = initData;
    subResourceData.RowPitch = byteSize;
    subResourceData.SlicePitch = subResourceData.RowPitch;

    // 기본 버퍼 자원으로의 자료 복사를 요청한다.
    // 보조 함수 UpdateSubresources는 CPU 메모리를 임시 업로드 힙에 복사하고,
    // ID3D12CommandList::CopySubresourceRegion을 이용해서 임시 업로드 힙의 자료를 
    // mBuffer에 복사한다.
	cmdList->ResourceBarrier(1, &CD3DX12_RESOURCE_BARRIER::Transition(defaultBuffer.Get(), 
		D3D12_RESOURCE_STATE_COMMON, D3D12_RESOURCE_STATE_COPY_DEST));
    UpdateSubresources<1>(cmdList, defaultBuffer.Get(), uploadBuffer.Get(), 0, 0, 1, &subResourceData);
	cmdList->ResourceBarrier(1, &CD3DX12_RESOURCE_BARRIER::Transition(defaultBuffer.Get(),
		D3D12_RESOURCE_STATE_COPY_DEST, D3D12_RESOURCE_STATE_GENERIC_READ));

    // 주의 : 위의 함수 호출 이후에도 uploadBuffer를 계속 유지해야 한다.
    // 실제로 복사를 수행하는 명령 목록이 아직 실행되지 않았기 때문이다.
    // 복사 완료되었음이 확실해진 후에 호출자가 uploadBuffer를 해제하면 된다.

    return defaultBuffer;
}

D3D12_SUBRESOURCE_DATA 구조체는 다음과 같이 정의되어 있다.

typedef struct D3D12_SUBRESOURCE_DATA
{
    const void* pData;
    LONG_PTR RowPitch;
    LONG_PTR SlicePitch;
} D3D12_SUBRESOURCE_DATA;

1. pData

버퍼 초기화용 자료를 담은 시스템 메모리 배열을 가리키는 포인터이다. 버퍼에 n개의 정점을 담을 수 있다고 할 때, 버퍼 전체를 초기화하려면 해당 시스템 메모리 배열에 적어도 n개의 정점이 있어야 한다.

 

2. RowPitch, SlicePitch

버퍼의 경우, 복사할 자료의 크기(바이트 수)

 

다음 코드는 정육면체의 정점 여덟 개를 저장하는 기본 버퍼를 생성하는 방법을 보여준다. 각 정점은 각자 다른 색을 부여하였다.

Vertex vertices[] =
    {
		{ XMFLOAT3(-1.0f, -1.0f, -1.0f), XMFLOAT4(Colors::White) },
		{ XMFLOAT3(-1.0f, +1.0f, -1.0f), XMFLOAT4(Colors::Black) },
		{ XMFLOAT3(+1.0f, +1.0f, -1.0f), XMFLOAT4(Colors::Red) },
		{ XMFLOAT3(+1.0f, -1.0f, -1.0f), XMFLOAT4(Colors::Green) },
		{ XMFLOAT3(-1.0f, -1.0f, +1.0f), XMFLOAT4(Colors::Blue) },
		{ XMFLOAT3(-1.0f, +1.0f, +1.0f), XMFLOAT4(Colors::Yellow) },
		{ XMFLOAT3(+1.0f, +1.0f, +1.0f), XMFLOAT4(Colors::Cyan) },
		{ XMFLOAT3(+1.0f, -1.0f, +1.0f), XMFLOAT4(Colors::Magenta) }
    };
    
const UINT64 vbByteSize = 8 * sizeof(Vertex);

// 기본 버퍼와 업로드 버퍼 생성
ComPtr<ID3D12Resource> VertexBufferGPU = nullptr;
ComPtr<ID3D12Resource> VertexBufferUploader = nullptr;
VertexBufferGPU = d3dUtil::CreateDefaultBuffer(md3dDevice.Get(),
    mCommandList.Get(), vertices, vbByteSize, VertexBufferUploader);

 이 코드에 쓰인 Vertex 형식의 정의는 다음과 같다.

struct Vertex
{
    XMFLOAT3 Pos;
    XMFLOAT3 Color;
};

정점 버퍼를 파이프라인에 묶으려면 정점 버퍼 자원을 서술하는 정점 버퍼 뷰를 만들어야 한다. RTV(Render Target View)와는 다르게, 정점 버퍼 뷰에는 서술자 힙이 필요하지 않다. 정점 버퍼 뷰를 대표하는 형식은 D3D12_VERTEX_BUFFER_VIEW_DESC 구조체이다.

typedef struct D3D12_VERTEX_BUFFER_VIEW
{
    D3D12_GPU_VIRTUAL_ADDRESS BufferLocation; // 생성할 뷰의 대상이 되는 정점 버퍼 자원의 가상주소
    UINT SizeInBytes;  // BufferLocation에서 시작하는 정점 버퍼의 크기 (바이트 수)
    UINT StrideInBytes; // 버퍼에 담긴 한 정점 원소의 크기 (바이트 수)
} D3D12_VERTEX_BUFFER_VIEW;

BufferLocation은 ID3D12Resource::GetGPUVirtualAddress 메서드로 얻을 수 있다.

 

정점 버퍼를 생성하고 그에 대한 뷰까지 생성했다면, 이제 정점 버퍼를 파이프라인의 한 입력 슬롯에 묶을 수 있다. 그러면 정점들이 파이프라인 입력 조립기 단계로 공급된다. 다음은 정점 버퍼를 파이프라인에 묶는 메서드이다.

void STDMETHODCALLTYPE IASetVertexBuffers( 
      UINT StartSlot, 
      UINT NumViews,
      const D3D12_VERTEX_BUFFER_VIEW *pViews);

1. StartSlot

시작 슬롯, 즉 첫째 정점 버퍼를 묶을 입력 슬롯의 인덱스. 입력 슬롯은 총 16개이다. (0 ~ 15)

 

2. NumBuffers

입력 슬롯들에 묶을 정점 버퍼 개수. 시작 슬롯의 인덱스가 k이고 묶을 버퍼가 n개이면, 버퍼들은 입력 슬롯 I(k), I(k+1), ..., I(k+n-1)에 묶이게 된다.

 

3. pViews

정점 버퍼 뷰 배열의 첫 원소를 가리키는 포인터

 

다음은 이 메서드의 호출 예이다.

D3D12_VERTEX_BUFFER_VIEW vbv;
vbv.BufferLocation = VertexBufferGPU->GetGPUVirtualAddress();
vbv.SizeInBytes = 8 * sizeof(Vertex);
vbv.StrideInBytes = sizeof(Vertex);

D3D12_VERTEX_BUFFER_VIEW vertexBuffers[1] = {vbv};
mCommandList->IASetVertexBuffers(0, 1, vertexBuffers);

 

일단 입력 슬롯에 묶은 정점 버퍼는 다시 변경하지 않는 한 계속 입력 슬롯에 묶여 있다. 따라서 정점 버퍼를 여러 개 사용하는 경우 코드의 전반적인 구조를 다음과 같이 짜면 될 것이다.

ID3D12Resource* mVB1; // Vertex1 형식의 정점들을 담는 정점 버퍼
ID3D12Resource* mVB2; // Vertex2 ..

D3D12_VERTEX_BUFFER_VIEW_DESC mVBView1; // mVB1에 대한 뷰
D3D12_VERTEX_BUFFER_VIEW_DESC mVBView2; // mVB2 ..

/* 정점 버퍼들과 뷰들을 생성 */

mCommandList->IASetVertexBuffers(0, 1, &mVBView1);
/* 정점 버퍼 1을 이용하여 물체들을 그린다 */

mCommandList->IASetVertexBuffers(0, 1, &mVBView2);
/* 정점 버퍼 2를 이용하여 물체들을 그린다 */

정점 버퍼를 입력 슬롯에 설정하다고 해서 버퍼 정점들이 그려지는 것은 아니다. 단지 그 정점들을 파이프라인에 공급할 준비가 된 것일 뿐이다. 정점들을 실제로 그리려면 다음 메서드를 호출해야 한다.

void ID3D12CommandList::DrawInstanced(
   UINT VertexCountPerInstance,
   UINT InstanceCount,
   UINT StartVertexLocation,
   UINT StartInstanceLocation);

 

그런데 DrawInstanced 메서드를 보면 주어진 정점들로 그릴 기본 도형이 어떤 종류인지에 관한 매개변수는 없다. 지정된 정점들을 Direct3D가 점들로 취급할지 아니면 선 목록이나 삼각형 목록 등으로 취급할지는 ID3D12GraphicsCommandList::IASetPrimitiveTopology 메서드로 설정하는 기본도형 위상 구조 상태가 결정한다. 다음은 이 메서드 호출의 예이다.

cmbList->IASetPrimitiveTopology(D3D_PRIMITIVE_TOPOLOGY_TRIANGLELIST);
반응형
Comments