기록공간

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

DirectX/기초

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

입코딩 2020. 4. 30. 21:59
반응형

인덱스와 인덱스 버퍼

정점들과 마찬가지로, GPU가 인덱스의 배열에 접근할 수 있으려면 인덱스들을 버퍼 GPU자원(ID3D12Resource)에 넣어 두어야 한다. 인덱스들을 담는 버퍼를 인덱스 버퍼라고 부른다. d3dUtil:: CreateDefaultBuffer 함수(이전 장에서 설명했었음)는 void*를 통해 자료를 처리하므로, 인덱스 버퍼뿐만 아니라 모든 기본 버퍼를 생성할 수 있다. 

 

인덱스 버퍼를 파이프라인에 묶으려면 인덱스 버퍼 자원을 서술하는 인덱스 버퍼 뷰를 만들어야 한다. 정점 버퍼 뷰처럼 인덱스 버퍼 뷰에도 서술자 힙이 필요하지 않다. 인덱스 버퍼 뷰를 대표하는 형식은 구조체

D3D12_INDEX_BUFFER_VIEW이다.

typedef struct D3D12_INDEX_BUFFER_VIEW
{
    D3D12_GPU_VIRTUAL_ADDRESS BufferLocation; // 생성할 뷰의 대상이 되는 인덱스 버퍼 자원의 가상주소
    UINT SizeInBytes; // 인덱스 버퍼의 크기(바이트 수)
    DXGI_FORMAT Format; // 인덱스 형식
} D3D12_INDEX_BUFFER_VIEW;

BufferLocation은 ID3D12Resource::GetGPUVirtualAddress 메서드로 얻을 수 있다. 인덱스 형식은 16비트인 인덱스인 경우에는 DXGI_FORMAT_R16_UINT, 32비트 인덱스인 경우DXGI_FORMAT_R32_UINT를 지정해야한다. 범위가 16비트 내에서 허락하는 경우 DXGI_FORMAT_R16_UINT를 사용해야 메모리와 대역폭을 절약할 수 있다.

 

다른 자원들과 마찬가지로 인덱스 버퍼 또한 사용을 위해서는 파이프라인에 묶어야 한다. 인덱스 버퍼는 ID3D12CommandList::SetIndexBuffer 메서드를 통해서 입력 조립기 단계(IA)에 묶는다. 다음 코드는 정육면체의 삼각형들을 정의하는 인덱스 버퍼를 생성하고, 그에 대한 뷰를 생성한 후, 그것을 파이프라인에 묶는 방법을 보여준다.

std::uint16_t indices[] = 
{
    // 앞면
    0, 1, 2,
    0, 2, 3,
    
    // 뒷면
    4, 6, 5,
    4, 7, 6,
    
    // 왼쪽 면
    4, 5, 1,
    4, 1, 0,
    
    // 오른쪽 면
    3, 2, 6,
    3, 6, 7,
    
    // 윗면
    1, 5, 6,
    1, 6, 2,
    
    // 아랫면
    4, 0, 3,
    4, 3, 7
};

const UINT ibByteSize = 36 * sizeof(std::uint16_t);

ComPtr<ID3D12Resource> IndexBufferGPU = nullptr;
ComPtr<ID3D12Resource> IndexBufferUploader = nullptr;
IndexBufferGPU = d3dUtil::CreateDefaultBuffer(md3dDevice.Get(),
    mCommandList.Get(), indices, ibByteSize, IndexBufferUploader);

// 인덱스 버퍼 구조체 설정
D3D12_INDEX_BUFFER_VIEW ibv;
ibv.BufferLocation = IndexBufferGPU->GetGPUVirtualAddress();
ibv.Format = DXGI_FORMAT_R16_UINT;
ibv.SizeInBytes = ibByteSize;

mCommandList->IASetIndexBuffer(&ibv);

인덱스들을 이용해서 기본 도형을 그리려면 DrawInstanced 메서드가 아닌, ID3D12GraphicsCommandList:: DrawIndexedInstanced 메서드를 사용해야 한다.

void ID3D12GraphicsCommandList::DrawIndexedInstanced(
    UINT IndexCountPerInstance, // 그리기에 사용할 인덱스 개수
    UINT InstanceCount, // 그릴 인스턴스 개수, 보통은 1
    INT  StartIndexLocation, // 그리기 호출에서 사용할 첫 인덱스(0)
    UINT StartInstanceLocation // 고급 기법에 쓰임 (지금은 그냥 0)
    );

인덱스 버퍼 설정은 앞에서 살펴봤던 정점 버퍼 설정과 매우 유사하기 때문에 이해가 좀 더 쉬웠을 것이라 생각한다.

 

정점 쉐이더

다음은 간단한 정점 쉐이더의 구현이다.

cbuffer cbPerObject : register(b0)
{
    float4x4 gWorldViewProj;
};

void VS(float3 iPosL : POSITION,
    float4 iColor : COLOR,
    out float4 oPosH : SV_POSITION,
    out float4 oColor : COLOR)
{
    // world * view * projection을 거친 행렬을 기존 위치와 곱해(변환하여)
    // 현재 위치를 투영을 거친 최종 위치로 변환시킨다.
    oPosH = mul(float4(iPosL, 1.f), gWorldViewProj);
    
    // 정점 색상을 그대로 픽셀 쉐이더에 전달한다.
    oColor = iColor;
}

쉐이더는 HLSL(High Lever Shading Language)이라고 하는 언어로 작성한다. 이 언어는 C++과 비슷하기 때문에, C++ 프로그래머라면 쉽게 배울 수 있다. 일반적으로 쉐이더 소스 코드는 텍스트 파일로 작성하고 확장자는 .hlsl이다.

 

본질적으로 정점 쉐이더는 하나의 함수이다. 여기서는 VS라는 이름을 사용했지만, 유효한 함수 이름이면 어떤 것이든 정점 쉐이더의 이름으로 사용할 수 있다. VS에서 받는 매개변수에서 처음 둘은 입력 매개변수이고 나머지 둘은 출력 매개변수(out 키워드가 붙어있다)이다. HLSL에서는 참조 포인터가 따로 없으므로, 함수가 여러 개의 값을 돌려주려면 구조체를 사용하거나 이처럼 out이 지정된 출력 매개변수를 사용해야 한다. HLSL에서 함수는 항상 인라인화 된다.

 

앞에서 살펴봤던 D3D12_INPUT_ELEMENT_DESC로 Segment(의미소)를 설정해 배열로 설정하면, 그 배열에 의해 정점의 각 성분에 Segment가 부여된다. 이 Segment들은 정점 성분을 정점 쉐이더 입력 매개변수들에 대응시키는 역할을 한다. (: POSITION, : COLOR)

 

출력 매개변수에도 Segment가 부여되어 있다. (: SV_POSITION, : COLOR) 이들은 정점 쉐이더의 출력 파이프라인의 다음 단계(기하 쉐이더, 픽셀 쉐이더)의 해당 입력 매개변수에 대응시키는 역할을 한다. SV_POSITION이 특별한 Segment인 것을 주목하자. SV는 이것이 system value(시스템 값) Segment임을 뜻한다. 이 Segment는 해당 정점 쉐이더 출력 성분이 정점의 위치(투영을 거친 공간에서의)를 담고 있음을 나타낸다. GPU는 절단, 깊이 판정, 래스터화 등 다른 특성들에는 적용하지 않는 특별한 연산들을 위치에 적용하므로, 이처럼 SV_POSITION Segment를 지정해서 GPU에게 이것이 위치를 담은 출력 성분임을 알려주어야 한다. SV 값 Segment가 아닌 Segment에 대해서는 HLSL의 유효한 식별자이기만 하면 어떤 이름도 사용이 가능하다. (기하 쉐이더를 사용하지 않는다면 정점 쉐이더의 출력은 반드시 의미소가 SV_POSITION인, 투영 공간에서의 정점 위치이어야 한다. 기하 쉐이더를 사용하는 경우 투영 공간의 출력을 기하 쉐이더에서 미룰 수 있다.)

 

float4() 메서드는 4차원 벡터를 생성해주는 기능을 한다. 정점의 위치는 방향을 나타내는 벡터가 아니므로 w원소 값은 1.f로 두었다. ( float4(iPosL, 1.f) ) gWorldViewProj는 상수 버퍼라고 부르는 버퍼에 들어 있는 것으로, 이것은 다음 절에서 살펴본다. mul은 HLSL 내장 함수로, 벡터 대 행렬 곱셈을 수행한다. 

 

다음은 입력, 출력 매개변수를 구조체화 시킨 코드이다. 하는 기능은 위와 동일하지만 반환 형식과 입력 서명에 구조체를 사용한다는 점이 다르다. 덕분에 매개변수 목록이 짧아졌다.

cbuffer cbPerObject : register(b0)
{
    float4x4 gWorldViewProj;
};

struct VertexIn
{
    float3 PosL : POSITION;
    float4 Color : COLOR;
};

struct VertexOut
{
    float4 PosH : SV_POSITION;
    float4 Color : COLOR;
};

VertexOut VS(VertexIn vIn)
{
    VetexOut vOut;

    vOut.PosH = mul(float4(vIn.PosL, 1.f), gWorldViewProj);
    
    vOut.Color = vIn.Color;
    
    return vOut;
}

 

입력 배치(Input Layout) 서술과 입력 서명(Input Description) 연결

앞서 말했듯, 파이프라인에 공급되는 정점들의 특성들과 정점 쉐이더의 매개변수들 사이에는 연관관계가 존재한다. 그러한 관계를 정의하는 것은 입력 배치 서술이다. 파이프라인에 공급될 정점들이 정점 쉐이더가 기대하는 모든 입력 제공하지 못하면 오류가 발생한다. 다음 코드에서 입력 서명은 정점 자료와 호환되지 않는다.

// C++ 코드
struct Vertex
{
    XMFLOAT3 Pos;
    XMFLOAT4 Color;
};

D3D12_INPUT_ELEMENT_DESC desc[] = 
{
   {"POSITION", 0, DXGI_FORMAT_R32G32B32_FLOAT, 0, 0,
    D3D12_INPUT_PER_VERTEX_DATA, 0},
   {"COLOR", 0, DXGI_FORMAT_R32G32B32A32_FLOAT, 0, 12,
    D3D12_INPUT_PER_VERTEX_DATA, 0}    
};

// 정점 쉐이더
struct VertexIn
{
    float3 PosL : POSITION;
    float4 Color : COLOR;
    float3 Noraml : NORMAL;
};

struct VertexOut
{
    float4 PosH : SV_POSITION;
    float4 Color : COLOR;
};

// ...

입력 배치 서술과 정점 쉐이더를 지정하면 Direct3D는 주어진 입력 배치 서술과 정점 쉐이더가 호환되는지 점검한다. 

 

정점 자료와 입력 서명이 정확히 일치할 필요는 없다. 중요한 건 정점 쉐이더가 기대하는 모든 정보를 정점 자료가 제공하느냐이다. 따라서, 정점 쉐이더가 사용하지 않는 추가적인 정보를 정점 자료가 제공하는 것은 오류가 아니다. 다음 코드는 호환이 된다.

// C++ 코드
struct Vertex
{
    XMFLOAT3 Pos;
    XMFLOAT4 Color;
    XMFLOAT3 Normal;
};

D3D12_INPUT_ELEMENT_DESC desc[] = 
{
   {"POSITION", 0, DXGI_FORMAT_R32G32B32_FLOAT, 0, 0,
    D3D12_INPUT_PER_VERTEX_DATA, 0},
   {"COLOR", 0, DXGI_FORMAT_R32G32B32A32_FLOAT, 0, 12,
    D3D12_INPUT_PER_VERTEX_DATA, 0},
   {"NORMAL", 0, DXGI_FORMAT_R32G32B32_FLOAT, 0, 18,
    D3D12_INPUT_PER_VERTEX_DATA, 0} 
};

// 정점 쉐이더
struct VertexIn
{
    float3 PosL : POSITION;
    float4 Color : COLOR;
};

struct VertexOut
{
    float4 PosH : SV_POSITION;
    float4 Color : COLOR;
};

// ...

 

픽셀 쉐이더

정점 쉐이더가 출력한 정점 특성들은 래스터화 단계에서 삼각형의 픽셀들을 따라 보간 되며, 보간 된 결과는 픽셀 쉐이더에 입력된다. (기하 쉐이더 과정이 생략된다고 가정했을 때)

 

정점 쉐이더처럼 픽셀 쉐이더 또한 본질적으로 하나의 함수이나, 다른 점이 있다면 픽셀 쉐이더는 픽셀 단편마다 실행된다. 픽셀 쉐이더는 주어진 입력으로부터 픽셀 단편의 색상을 계산하는 것이다.

 

그런데 픽셀 단편이 도중에 취소되어서 후면 버퍼까지 도달하지 못할 수도 있음을 주목하자. 예를 들면 픽셀 쉐이더에서 절단될 수도 있고(HLSL은 픽셀을 폐기하는 clip 함수를 제공한다), 깊이 값이 더 작은 다른 픽셀 단편에 가려질 수도 있고, 스텐실 판정 등 파이프라인의 이후 단계에서 적용되는 판정에 의해 폐기될 수도 있다. 따라서, 후면 버퍼의 한 픽셀에는 최종적으로 그 픽셀이 될 수 있는 '후보'로서의 픽셀 단편들이 여러 개 존재할 수 있다

 

다음 코드는 정점 쉐이더에 대응되는 간단한 픽셀 쉐이더이다.

cbuffer cbPerObject : register(b0)
{
    float4x4 gWorldViewProj;
};

// 정점 쉐이더
void VS(float3 iPosL : POSITION,
    float4 iColor : COLOR,
    out float4 oPosH : SV_POSITION,
    out float4 oColor : COLOR)
{
    oPosH = mul(float4(iPosL, 1.f), gWorldViewProj);
    
    oColor = iColor;
}

// 픽셀 쉐이더
float4 PS(float4 posH : SV_POSITION, float4 color : COLOR) : SV_TARGET
{
    return color;
}

이 코드에서 픽셀 쉐이더는 그냥 보간 된 색상 값을 돌려준다. 픽셀 쉐이더의 입력이 정점 쉐이더의 출력과 정확히 일치함을 주목하자. 이는 필수 조건이다. 

 

함수의 매개변수 목록 다음에 있는 SV_TARGET이라는 Segment는 이 함수의 반환 값이 형식이 렌더 타깃의 형식과 일치해야 함을 뜻한다.

 

다음과 같이 입력 구조체들과 출력 구조체들을 이용하도록 구현할 수도 있다. 이전과는 달리 Segment들이 매개변수들이 아니라 입출력 구조체의 멤버들에 지정되었고, 정점 쉐이더는 출력 매개변수 대신 return 문으로 결과를 출력한다.

cbuffer cbPerObject : register(b0)
{
    float4x4 gWorldViewProj;
};

struct VertexIn
{
    float3 PosL : POSITION;
    float4 Color : COLOR;
};

struct VertexOut
{
    float4 PosH : SV_POSITION;
    float4 Color : COLOR;
};

VertexOut VS(VertexIn vIn)
{
    VetexOut vOut;

    vOut.PosH = mul(float4(vIn.PosL, 1.f), gWorldViewProj);
    
    vOut.Color = vIn.Color;
    
    return vOut;
}

float4 PS(VertexOut pIn) : SV_TARGET
{
    return pIn.Color;
}

 

반응형
Comments