Stacky_
매일 쌓이는 기록

Input Assembler(IA) 본문

Game/Graphics

Input Assembler(IA)

Stacky 2025. 6. 5. 04:44

개요

  • 입력 어셈블러(Input Assembler)는 렌더링 파이프라인의 시작 부분으로, CPU로부터 받은 데이터들을 GPU가 재구성하는 단계이다.
  • 이때, CPU가 전달하는 데이터는 정점(vertices), 인덱스(indices), 기본 형식(Primitives)이다.

정점(vertices)

  • 정점은 수학적으로, 삼각형의 꼭짓점 혹은 직선의 양 끝점을 의미한다.
  • 렌더링 파이프라인에서 정점은 도형을 이루는 기본 요소로, 하나의 점을 의미한다.
  • 정점은 하나의 구조체로 표현되며, 그 안에는 대표적으로 정점의 위치값과 색상값이 들어간다.
  • 정점 구조체는 Direct 3D에서 제공해 주는 것이 아닌, 프로그래머가 직접 정의하기 때문에 그 외 다른 데이터가 들어갈 수 있다.
// 정점 구조체 예시
struct VERTEX {
    FLOAT x, y, z;
    DirectX::XMFLOAT4 color;
 };
  • 실제 전달되는 주요 데이터는 이러한 정점 데이터들의 배열이다.
  • 이 데이터들은 메인 메모리에 있고, 실제 처리는 GPU에서 이루어지기 때문에, GPU 메모리로 옮기는 작업이 필요하다.
  • 여기에서 두 가지 문제점이 발생한다.
    1. GPU는 정점의 데이터 형식을 모른다.
    2. 매초 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가지가 있다.
    1. Point list
    2. Line list
    3. Line strip
    4. Triangle list
    5. 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