Stacky_
매일 쌓이는 기록
Input Assembler(IA) 본문
개요
- 입력 어셈블러(Input Assembler)는 렌더링 파이프라인의 시작 부분으로, CPU로부터 받은 데이터들을 GPU가 재구성하는 단계이다.
- 이때, CPU가 전달하는 데이터는 정점(vertices), 인덱스(indices), 기본 형식(Primitives)이다.
정점(vertices)
- 정점은 수학적으로, 삼각형의 꼭짓점 혹은 직선의 양 끝점을 의미한다.
- 렌더링 파이프라인에서 정점은 도형을 이루는 기본 요소로, 하나의 점을 의미한다.
- 정점은 하나의 구조체로 표현되며, 그 안에는 대표적으로 정점의 위치값과 색상값이 들어간다.
- 정점 구조체는 Direct 3D에서 제공해 주는 것이 아닌, 프로그래머가 직접 정의하기 때문에 그 외 다른 데이터가 들어갈 수 있다.
// 정점 구조체 예시
struct VERTEX {
FLOAT x, y, z;
DirectX::XMFLOAT4 color;
};
- 실제 전달되는 주요 데이터는 이러한 정점 데이터들의 배열이다.
- 이 데이터들은 메인 메모리에 있고, 실제 처리는 GPU에서 이루어지기 때문에, GPU 메모리로 옮기는 작업이 필요하다.
- 여기에서 두 가지 문제점이 발생한다.
- GPU는 정점의 데이터 형식을 모른다.
- 매초 60번 이상은 전송해야 한다.
- 먼저 첫 번째 문제점을 살펴보자
GPU는 정점의 데이터 형식을 모른다.
- 정점 구조체는 메인 메모리에서만 읽을 수 있기 때문에, 단순히 데이터를 GPU에게 넘겨주면 GPU는 그 데이터가 어떠한 맥락을 가지고 있는지 알 수 없다.
- 때문에, GPU에게 "정점은 이러한 구조로 되어 있다"라는 것을 알려주어야 하는데, 그 역할을 하는 것이 바로 입력 레이아웃(Input Layout)이다.
매초 60번 이상은 전송해야 한다.
- 만일 게임을 제작한다고 한다면, 적어도 매초 60번 이상은 메인 메모리에서 GPU로 전송해야 한다.
- 그냥 단순 무식하게 모든 정점을 매번 업데이트하는 것은 명백한 공간, 연산 낭비이다.
- 때문에, 이러한 경우에 지역성을 활용해, 버퍼 - 정점 버퍼(vertices buffer)를 사용한다.
- 정점 버퍼는 GPU 메모리에 위치해 있고, CPU는 이를 COM 객체를 통해 접근할 수 있다.
- CPU에서 정점 데이터를 보낼 때, 정점 버퍼로 전송한다.
- 대신, 업데이트된 정점 데이터만 전송하게 해서 낭비를 줄인다.
인덱스(indices)
- 모든 3D 모델들은 폴리곤이라는 작은 삼각형으로 이루어져 있다.
- GPU는 각 정점들의 위치를 파악하고 이를 삼각형으로 구성하여 렌더링 한다.
- 이때, 문제점은 인접한 삼각형들은 같은 정점을 공유하게 된다는 것이다.
- 3D 모델은 그 구조와 형태가 복잡할수록 수많은 정점데이터들 - 폴리곤이 그려져야 한다.
- 그런데, 단순히 정점데이터들을 보내게 되면 중복되는 정점 데이터들이 많아져 메모리를 낭비할 뿐만 아니라, 데이터 전송에도 낭비가 발생한다.
- 이를 해결하기 위해 각 정점에 대한 순서(인덱스)를 함께 보내어 낭비를 줄인다.
struct Vertex[] = { v0, v1, v2, v3 };
struct Index[] = { 0, 1, 2, 0, 2, 3 };
- 만일 그림에서 보이는 a를 코드로 개념적으로 옮기면 이렇게 될 것이다.
- 실제 전달되는 정점은 4개이지만, 이들의 연결을 인덱스를 통해 알려주어 실제 표현할 수 있는 도형의 폭이 넓어진다.
📢 인덱스도, 정점과 동일하게 GPU 메모리에 있는 버퍼를 통해 전달된다.
기본 형식(primitives)
- CPU에서 정점과 인덱스를 전송한다고 해도, 정점과 인덱스가 구체적으로 무엇을 그리는지 알 수 없다.
- 가령, 앞서 보여준 코드는 삼각형 두 개를 그린다는 가정하에 작성된 코드이지만, 실제 GPU는 그 맥락을 알 수 없다.
- 정점 4개를 통해 직선 6개를 그려야 하는지, 삼각형 2개를 그려야 하는지 GPU는 알 수 없다.
- 때문에, GPU에게 정점과 인덱스를 통해 어떻게 그릴지(해석할지)를 알려주어야 하는데, 이러한 역할을 하는 데이터가 기본 형식이다.
- 기본 형식에는 일반적으로 5가지가 있다.
- Point list
- Line list
- Line strip
- Triangle list
- Triangle strip
- list들은 단순한 목록이다.
- 가령, Point list는 각 정점들을 단순한 점으로 해석하도록 하는 것이고, Line list는 두 개의 정점을 인덱스에 따라 하나의 직선으로 형성하는 것이다.
- 반면, strip들은 조금은 다른 형태의 목록이다.
- Line strip은 먼저 두 개의 정점으로 직선을 하나 형성한 다음, 그다음 인덱스의 정점을 그대로 이어서 직선을 생성한다.
- Triangle strip 역시 동일하게, 세 개의 정점으로 삼각형 하나를 형성한 다음, 그다음 인덱스의 정점을 그대로 이어 삼각형을 형성한다.
실제 코드 1 - DirectX 11 초기화
- 위의 개념을 바탕으로 DirectX 11로 코드를 작성하면 이렇다.
ID3D11Buffer* pVBuffer;
ID3D11InputLayout* pLayout;
ID3D11VertexShader;
ID3D11PixelShader* pPS;
// 처음 보는 것들
IDXGISwapChain* swapchain;
ID3D11Device* dev;
ID3D11DeviceContext* devcon;
ID3D11RenderTargetView* backbuffer;
ID3D11VertexShader* pVs;
ID3D11PixelShader* pPs;
struct VERTEX {
FLOAT X, Y, Z;
DirectX::XMFLOAT4 color;
};
- 먼저 정점에 대한 데이터와, 정점 버퍼, 입력 레이아웃에 대해서 전역적으로 선언한다.
- "처음 보는 것들"이라고 구성되어 있는 것들은 GPU 하드웨어에 대한 것들이다.
IDXGISwapChain
: 스왑 체인을 다룰 수 있는 인터페이스ID3D11Device
: GPU 장치에 대한 인터피이스로, COM 객체로 접근한다.ID3D11DeviceContext
: GPU에 대한 맥락(context)이다. 렌더링 방식을 설정한다.ID3D11RenderTargetView
: back buffer에 넣을 이미지 데이터이다.
- 여기에서 중요한 개념은 스왑 체인(Swap chain)이다.
- GPU가 렌더링 연산을 완료하면, 모니터에게 넘겨주고, 모니터는 GPU의 연산 결과를 화면에 그린다.
- 여기에서 문제가 되는 것은 GPU의 속도와 모니터의 속도가 엄청나게 차이가 난다는 점이다.
- 일반적으로 모니터의 속도가 60Hz(60 FPS) 면, GPU는 아무리 못해도 1 GHz가 넘는다.
- 즉, 모니터가 전달받은 데이터를 모두 그리기도 전에 GPU가 모니터 버퍼에 업그레이드를 해버린다.
- 이렇게 되면, 실제로 화면 위쪽은 이전 프레임이 그려지고 화면 아래쪽은 다음 프레임이 그려진다.
- 이를 방지하기 위해서 스왑 체인이 있다.
- 기본적으로 프론트 버퍼(front buffer), 백 버퍼(back buffer)가 있다.(이 두 버퍼 외 다른 버퍼가 추가적으로 있을 수 있다)
- GPU는 자신의 속도에 맞추어 백 버퍼에만 렌더링 결과를 업데이트하고, 모니터 역시 자신의 속도에 맞추어 프론트 버퍼만 참조하여 출력한다.
- 이때 모니터가 프레임 하나를 출력 완료하면, 버퍼끼리의 스왑이 일어난다.
- 백 버퍼를 통째로 프론트 버퍼로 옮기는 것은 그만큼 또 시간이 소요되기 때문에, 물리적으로 바꾸는 것이 아니라 서로의 참조값만 바꾸도록 한다.
- 즉, 모니터는 GPU가 업로드하던 백 버퍼를 참조하고 GPU는 모니터가 참조하던 프론트 버퍼에 업로드하기 시작한다.
LRESULT CALLBACK WindowProc(HWND hWnd,
UINT message,
WPARAM wParam,
LPARAM lParam);
int WINAPI WinMain( HINSTANCE hInstance,
HINSTANCE hPervInstance,
LPSTR lpCmdLine,
int nCmdShow ) {
HWND hWnd;
WNDCLASSEX wc;
::ZeroMemory(&wc, sizeof(WNDCLASSEX));
wc.cbSize = sizeof(WNDCLASSEX);
wc.style = CS_HREDRAW | CS_VREDRAW;
wc.lpfnWndProc = WindowProc;
wc.hInstance = hInstance;
wc.hCursor = LoadCursor(NULL, IDC_ARROW);
wc.lpszClassName = TEXT("WindowClass1");
RECT wr = { 0,0,SCREEN_WIDTH,SCREEN_HEIGHT };
AdjustWindowRect(&wr, WS_OVERLAPPEDWINDOW, FALSE);
::RegisterClassEx(&wc);
hWnd = CreateWindowEx(NULL,
TEXT("WindowClass1"),
TEXT("TEST"),
WS_OVERLAPPED | WS_CAPTION | WS_SYSMENU | WS_MINIMIZEBOX,
300,
300,
wr.right - wr.left,
wr.bottom - wr.top,
NULL,
NULL,
hInstance,
NULL );
::ShowWindow(hWnd, nCmdShow);
InitD3D(hWnd);
MSG msg = { 0, };
while (TRUE) {
if (::PeekMessage(&msg, NULL, 0, 0, PM_REMOVE)) {
::TranslateMessage(&msg);
::DispatchMessage(&msg);
if (msg.message == WM_QUIT)
break;
}
else
RenderFrame();
}
return msg.wParam;
}
LRESULT CALLBACK WindowProc(HWND hWnd, UINT message, WPARAM wParam, LPARAM lParam) {
switch (message) {
case WM_DESTROY: {
CleanD3D();
::PostQuitMessage(0);
return 0;
}break;
}
::DefWindowProc(hWnd, message, wParam, lParam);
}
- 전역 변수들을 사용하기 앞서 Main 함수를 구성해 실제 윈도우 창이 나오도록 할 필요가 있다.
- 여기에 나오는 함수들은 모두 Win32 API이기 때문에, "창을 출력하고 창에 대한 이벤트를 감지한다" 정도로 이해하면 된다.
void InitD3D(HWND hWnd);
- 프로시저 함수인
InitD3D
를 통해 DirectX의 기본 요소들 - 앞서 전역으로 선언한 스왑 체인과 GPU, GPU context, 백 버퍼를 생성하고 등록한다.
DXGI_SWAP_CHAIN_DESC scd;
ZeroMemory(&scd, sizeof(DXGI_SWAP_CHAIN_DESC));
scd.BufferCount = 1;
scd.BufferDesc.Format = DXGI_FORMAT_R8G8B8A8_UNORM;
scd.BufferDesc.Width = SCREEN_WIDTH;
scd.BufferDesc.Height = SCREEN_HEIGHT;
scd.BufferUsage = DXGI_USAGE_RENDER_TARGET_OUTPUT;
scd.OutputWindow = hWnd;
scd.SampleDesc.Count = 4;
scd.Windowed = TRUE;
scd.Flags = DXGI_SWAP_CHAIN_FLAG_ALLOW_MODE_SWITCH;
D3D11CreateDeviceAndSwapChain(NULL,
D3D_DRIVER_TYPE_HARDWARE,
NULL,
NULL,
NULL,
NULL,
D3D11_SDK_VERSION,
&scd,
&swapchain,
&dev,
NULL,
&devcon);
- 어미에
_DESC(descriptor)
가 있는 구조체들은 해당 자료에 대한 메타 데이터이다. - 즉, 생성하기 전에 어떻게 구성할 것인가를 설정할 수 있게 하는 구조체로 이를 바탕으로 실제 객체가 생성된다.
- 여기에서는 스왑 체인에 대한 메타 데이터를 구성하고 이를 바탕으로 스왑 체인 COM 객체를 생성한다.
ID3D11Texture2D* pBackBuffer;
swapchain->GetBuffer(0, __uuidof(ID3D11Texture2D), (LPVOID*)&pBackBuffer);
dev->CreateRenderTargetView(pBackBuffer, NULL, &backbuffer);
pBackBuffer->Release();
devcon->OMSetRenderTargets(1, &backbuffer, NULL);
- 이제 여기에서 백 버퍼를 등록한다.
- 생성한 스왑 체인으로부터 백 버퍼를 가져오고(보통 백 버퍼의 인덱스는 0이다), GPU가 백 버퍼에 이미지들을 업데이트할 수 있도록 설정한다.
D3D11_VIEWPORT viewport;
ZeroMemory(&viewport, sizeof(D3D11_VIEWPORT));
viewport.TopLeftX = 0;
viewport.TopLeftY = 0;
viewport.Width = SCREEN_WIDTH;
viewport.Height = SCREEN_HEIGHT;
devcon->RSSetViewports(1, &viewport);
InitPipeline();
InitGraphics();
- 여기서 끝이면 될 것 같지만, 마지막으로 뷰 포트(view port)를 설정해야 한다.
- 뷰 포트는 화면에 대해서 정규화한 좌표를 의미한다.
- 예를 들어, 화면의 실제 크기가 800*600이라고 하면 (0, 0) 위치가 (-1, -1)이 되도록 하고, (800, 600) 위치가 (1, 1)이 되도록 한다.
- 이렇게 함으로써, 어떤 크기의 화면에서도 뷰 포트 좌표로 변환되기 때문에 각 화면의 크기와 해상도에 연연하지 않고 구현할 수 있다.
- 마지막으로, 파이프라인을 초기화하고 그래픽을 초기화하는 프로시저를 호출하면 된다.
실제 코드 2 - 파이프라인 초기화
void InitPipeline(void) {
ID3D10Blob* VS, * PS;
ID3DBlob* errorBlob;
D3DCompileFromFile(L"shaders.shader", 0, 0, "VShader", "vs_4_0", 0, 0, &VS, &errorBlob);
D3DCompileFromFile(L"shaders.shader", 0, 0, "PShader", "ps_4_0", 0, 0, &PS, &errorBlob);
dev->CreateVertexShader(VS->GetBufferPointer(), VS->GetBufferSize(), NULL, &pVs);
dev->CreatePixelShader(PS->GetBufferPointer(), PS->GetBufferSize(), NULL, &pPs);
devcon->VSSetShader(pVs, 0, 0);
devcon->PSSetShader(pPs, 0, 0);
D3D11_INPUT_ELEMENT_DESC ied[] = {
{"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},
};
dev->CreateInputLayout(ied, 2, VS->GetBufferPointer(), VS->GetBufferSize(), &pLayout);
devcon->IASetInputLayout(pLayout);
}
- IA 단계에서 CPU가 전달해야 할 것은 정점, 인덱스, 기본 형식뿐만 아니라, 셰이더 바이너리 코드도 전달해야 한다.
- 렌더링 파이프라인의 구성요소는 셰이더(shader)인데, 이는 DirectX에 정의되어 있지 않고 프로그래머가 셰이더 파일(. shader)을 통해 전달해야 한다.
- 단, GPU가 굳이 컴파일할 필요 없이 CPU에서 셰이더 파일을 컴파일한 뒤에 전달한다.
struct VOut {
float4 position : SV_POSITION;
float4 color : COLOR;
};
VOut VShader(float4 position : POSITION, float4 color : COLOR) {
VOut output;
output.position = position;
output.color = color;
return output;
}
float4 PShader(float4 position : SV_POSITION, float4 color : COLOR) : SV_TARGET {
return color;
}
- Shader.shader 파일을 위와 같이 구성하면, "전달한 정점들을 있는 그대로 출력하라"는 의미가 된다.
- CPU는 이 파일을
D3DCompileFromFile
을 통해 컴파일하고, 그 결과를 VS(Vertex shader)와 PS(Pixel shader)에게 전달한다.- VS와 PS는 IA 이후에 나오는 과정으로, 여기서는 그냥 넘어가겠다.
- 셰이더를 구성하고 나면, 입력 레이아웃을 구성한다.
실제 코드 3 - 정점 입력
InitGraphic
을 통해서 정점 데이터를 넣어보자.- 참고 자료 대로, 삼각형을 그리는 코드를 작성했다.
void InitGraphics(void) {
VERTEX input[] = {
{0.0f, 0.5f, 0.0f, DirectX::XMFLOAT4(1.0f, 0.0f, 0.0f, 1.0f)},
{0.45f, -0.5, 0.0f, DirectX::XMFLOAT4(0.0f, 1.0f, 0.0f, 1.0f)},
{-0.45f, -0.5f, 0.0f, DirectX::XMFLOAT4(0.0f, 0.0f, 1.0f, 1.0f)}
};
D3D11_BUFFER_DESC bd;
ZeroMemory(&bd, sizeof(bd));
bd.Usage = D3D11_USAGE_DYNAMIC;
bd.ByteWidth = sizeof(VERTEX) * 3;
bd.BindFlags = D3D11_BIND_VERTEX_BUFFER;
bd.CPUAccessFlags = D3D11_CPU_ACCESS_WRITE;
dev->CreateBuffer(&bd, NULL, &pVBuffer);
D3D11_MAPPED_SUBRESOURCE ms;
devcon->Map(pVBuffer, NULL, D3D11_MAP_WRITE_DISCARD, NULL, &ms);
memcpy(ms.pData, input, sizeof(input));
devcon->Unmap(pVBuffer, NULL);
}
- 무지개 색으로 출력되는 삼각형 정점들을 선언하고, 이를 정점 버퍼에 넣는다.
- 여기에서는 인덱스가 굳이 필요 없기 때문에, 별다른 인덱스 버퍼를 생성하지 않는다.
실제 코드 4 - 프레임마다 렌더링, 해제하기
- 마지막으로 코드들이 매 프레임마다 실행될 수 있도록
RenderFrame
프로시저를 구현해 보자.
void RenderFrame(void) {
DirectX::XMFLOAT4 color(0.0f, 0.2f, 0.4f, 1.0f);
devcon->ClearRenderTargetView(backbuffer, &color.x);
UINT stride = sizeof(VERTEX);
UINT offset = 0;
devcon->IASetVertexBuffers(0, 1, &pVBuffer, &stride, &offset);
devcon->IASetPrimitiveTopology(D3D10_PRIMITIVE_TOPOLOGY_TRIANGLELIST);
devcon->Draw(3, 0);
swapchain->Present(0, 0);
}
- 참고 자료와 동일하게 배경을 파란색으로 두고, 그 위에 삼각형을 그리도록 했다.
- IA 과정에서의 정점 벡터가 무엇인지 알려주고, 정점 백터를 읽도록 한다.
- 그리고 기본 형식을 전달한다.
void CleanD3D(void) {
swapchain->SetFullscreenState(FALSE, NULL);
pLayout->Release();
pVs->Release();
pPs->Release();
pVBuffer->Release();
swapchain->Release();
backbuffer->Release();
dev->Release();
devcon->Release();
}
- 해제하는 코드는 단순하다.
- 지금까지 사용한 모든 자원을
Release
한다.
참고한 자료
'Game > Graphics' 카테고리의 다른 글
HLSL 기초 (0) | 2025.06.14 |
---|---|
Visual Studio에서 DirectX Tool Kit 라이브러리 적용 (0) | 2025.06.10 |