← 개념 · 데모 가이드 | 구현 허브 | 3장. 3D 그래픽스 | 삼각형과 메시
IMPLEMENTATION · CHAPTER 3

삼각형과 메시 — 한 줄 한 줄 직접 구현

화면에 보이는 3D 모델은 결국 수많은 삼각형의 집합입니다. 이 페이지에서는 정점 하나에 위치·법선·UV를 담는 Vertex 구조체를 정의하고, 버텍스 버퍼인덱스 버퍼로 정점을 GPU에 올려 재사용하고, 이를 관리하는 Mesh 클래스와 .obj 파일을 읽는 최소 로더를 만듭니다. 코드 한 줄, 구조체 멤버 하나, D3D API 인자 하나까지 왜 그렇게 쓰는지를 끝까지 파고듭니다.

주의 이 페이지의 코드는 3장 본문삼각형과 메시 섹션과 동일한 로직입니다. 로직은 그대로 두고 설명만 대폭 확장했습니다. 창 생성·디바이스/스왑체인 같은 보일러플레이트는 개발 환경에서 만든 것을 전제로 합니다.

1. 왜 인덱스 버퍼인가 — 정점 재사용의 원리

가장 단순한 렌더링은 "삼각형마다 정점 3개를 나열"하는 방식입니다. 하지만 실제 모델에서는 하나의 정점을 여러 삼각형이 공유합니다. 예를 들어 정육면체는 꼭짓점이 8개뿐인데, 삼각형은 12개(면 6개 × 2)입니다. 정점을 매번 펼쳐 쓰면 12 × 3 = 36개의 정점을 저장해야 하지만, 인덱스 버퍼를 쓰면 정점 8개 + 인덱스 36개만 있으면 됩니다.

정점 하나가 위치(12바이트) + 법선(12바이트) + UV(8바이트) = 32바이트인데 인덱스는 4바이트에 불과합니다. 즉 공유 정점을 인덱스로 가리키면 메모리를 크게 아끼고, GPU의 정점 캐시 적중률도 올라가 처리 속도가 빨라집니다.

  1. Vertex = 위치(float3) + 법선(float3) + UV(float2).
  2. Mesh가 버텍스/인덱스 버퍼를 생성하고 D3D11_INPUT_ELEMENT_DESC로 3개 속성을 서술.
  3. draw()context->DrawIndexed로 인덱스 기반 렌더.
  4. OBJ 로더가 v / vt / vn / f 줄을 파싱하고 다각형을 삼각형으로 분할.
비유 인덱스 버퍼는 "주소록"과 같습니다. 전화번호(정점 데이터)는 한 번만 적어두고, 여러 곳에서는 "3번 사람에게 전화"처럼 번호(인덱스)만 참조합니다. 같은 사람 번호를 곳곳에 중복해 적을 필요가 없습니다.

2. Vertex 구조체 · 입력 레이아웃 · Mesh 클래스

먼저 정점 하나의 메모리 배치를 정하는 Vertex 구조체와, GPU에게 "이 바이트 뭉치를 이렇게 해석하라"고 알려주는 입력 레이아웃, 그리고 두 버퍼의 생성·수명을 책임지는 Mesh 클래스를 봅니다.

src/Mesh.h — Vertex + Meshcpp
#pragma once
#include <d3d11.h>
#include <DirectXMath.h>
#include <wrl/client.h>
#include <vector>

using Microsoft::WRL::ComPtr;
using namespace DirectX;

struct Vertex {
    XMFLOAT3 pos;
    XMFLOAT3 normal;
    XMFLOAT2 uv;
};

// 입력 레이아웃 서술 (Mesh 밖에서 셰이더와 함께 사용)
static const D3D11_INPUT_ELEMENT_DESC kVertexLayout[] = {
    { "POSITION", 0, DXGI_FORMAT_R32G32B32_FLOAT, 0, offsetof(Vertex, pos),    D3D11_INPUT_PER_VERTEX_DATA, 0 },
    { "NORMAL",   0, DXGI_FORMAT_R32G32B32_FLOAT, 0, offsetof(Vertex, normal), D3D11_INPUT_PER_VERTEX_DATA, 0 },
    { "TEXCOORD", 0, DXGI_FORMAT_R32G32_FLOAT,    0, offsetof(Vertex, uv),     D3D11_INPUT_PER_VERTEX_DATA, 0 },
};

class Mesh {
public:
    Mesh(ID3D11Device* device,
         const std::vector<Vertex>& verts,
         const std::vector<unsigned int>& indices)
        : count_((UINT)indices.size())
    {
        // 버텍스 버퍼
        D3D11_BUFFER_DESC vbDesc{};
        vbDesc.ByteWidth = (UINT)(verts.size() * sizeof(Vertex));
        vbDesc.Usage     = D3D11_USAGE_IMMUTABLE;
        vbDesc.BindFlags = D3D11_BIND_VERTEX_BUFFER;
        D3D11_SUBRESOURCE_DATA vbInit{ verts.data(), 0, 0 };
        device->CreateBuffer(&vbDesc, &vbInit, &vbo_);

        // 인덱스 버퍼
        D3D11_BUFFER_DESC ibDesc{};
        ibDesc.ByteWidth = (UINT)(indices.size() * sizeof(unsigned int));
        ibDesc.Usage     = D3D11_USAGE_IMMUTABLE;
        ibDesc.BindFlags = D3D11_BIND_INDEX_BUFFER;
        D3D11_SUBRESOURCE_DATA ibInit{ indices.data(), 0, 0 };
        device->CreateBuffer(&ibDesc, &ibInit, &ibo_);
    }

    // 입력 레이아웃/셰이더는 호출부에서 미리 바인딩했다고 가정
    void draw(ID3D11DeviceContext* ctx) const {
        UINT stride = sizeof(Vertex), offset = 0;
        ctx->IASetVertexBuffers(0, 1, vbo_.GetAddressOf(), &stride, &offset);
        // 32비트 인덱스라 DXGI_FORMAT_R32_UINT
        ctx->IASetIndexBuffer(ibo_.Get(), DXGI_FORMAT_R32_UINT, 0);
        ctx->IASetPrimitiveTopology(D3D11_PRIMITIVE_TOPOLOGY_TRIANGLELIST);
        ctx->DrawIndexed(count_, 0, 0);
    }

private:
    ComPtr<ID3D11Buffer> vbo_, ibo_;
    UINT count_ = 0;
    // ComPtr 소멸자가 자동으로 Release 호출
};

2-1. 헤더와 using — 무엇을 끌어오는가

#pragma once는 이 헤더가 한 번만 포함되도록 하는 컴파일러 지시입니다. 같은 .h가 여러 .cpp에서 중복 포함되면 Vertex 구조체가 두 번 정의돼 링크 오류가 나는데, 이를 막습니다.

using Microsoft::WRL::ComPtr;using namespace DirectX;는 매번 Microsoft::WRL::ComPtrDirectX::XMFLOAT3처럼 길게 쓰지 않도록 이름을 짧게 끌어옵니다. 함정 헤더 파일에서 using namespace를 전역에 쓰는 것은 실무에서는 권장되지 않습니다 (이 헤더를 포함한 모든 파일이 DirectX 네임스페이스에 오염됩니다). 학습용이라 편의상 그대로 두었습니다.

2-2. Vertex 구조체 — 메모리 배치가 곧 계약

무엇을 만드나: GPU에 올릴 정점 하나의 데이터 묶음입니다. 왜 필요한가: 삼각형을 그리려면 각 꼭짓점의 위치가 필요하고, 조명 계산에는 법선(normal), 텍스처를 붙이려면 좌표(uv)가 필요합니다. 이 셋을 한 정점에 묶어야 GPU가 "정점 하나 = 32바이트"로 규칙적으로 읽을 수 있습니다.

멤버타입바이트오프셋의미
posXMFLOAT3120모델 공간 위치 (x, y, z)
normalXMFLOAT31212표면 법선 (조명용)
uvXMFLOAT2824텍스처 좌표 (u, v)
합계32정점 1개 = 32바이트

XMFLOAT3은 단순히 float x, y, z;를 담은 구조체입니다. DirectXMath에는 계산용 타입인 XMVECTOR(SIMD 레지스터, 16바이트 정렬 필수)도 있지만, 버퍼에 저장할 때는 반드시 XMFLOAT*를 씁니다. XMVECTOR는 정렬 요구 때문에 배열/구조체 멤버로 쓰면 낭비가 생기고, GPU가 기대하는 float32 3개의 빈틈 없는 배치(packed)와 어긋날 수 있기 때문입니다.

함정 C++ 구조체는 멤버 정렬 규칙 때문에 패딩(빈 바이트)이 끼어들 수 있습니다. 여기서는 float(4바이트) 멤버만 있어 자연히 4의 배수로 딱 맞아 패딩이 없습니다(총 32바이트). 하지만 멤버 순서를 바꾸거나 double/bool을 섞으면 패딩이 생겨 GPU 레이아웃과 어긋납니다. 그래서 오프셋을 손으로 쓰지 않고 offsetof로 컴파일러가 계산하게 맡깁니다(아래 참조).

2-3. 입력 레이아웃 kVertexLayout — GPU에게 바이트 해석법 알려주기

무엇을 만드나: "버텍스 버퍼의 32바이트를 어떻게 나눠 셰이더 입력으로 넣을지"를 서술하는 표입니다. GPU 입장에서 버텍스 버퍼는 그냥 바이트 덩어리일 뿐이라, 어디부터 위치이고 어디부터 법선인지 알려줘야 합니다. 이 배열로 나중에 ID3D11InputLayout 객체를 만들고, 그 객체가 정점 셰이더의 입력과 버퍼를 연결합니다.

각 원소는 D3D11_INPUT_ELEMENT_DESC 구조체이며 7개 필드로 이뤄집니다. 첫 줄 { "POSITION", 0, DXGI_FORMAT_R32G32B32_FLOAT, 0, offsetof(Vertex, pos), D3D11_INPUT_PER_VERTEX_DATA, 0 }을 필드별로 해부하면:

필드의미
SemanticName"POSITION"HLSL에서 : POSITION으로 받는 이름. 셰이더 입력과 짝을 맞추는 열쇠.
SemanticIndex0같은 이름이 여러 개일 때 구분(예: TEXCOORD0, TEXCOORD1).
FormatR32G32B32_FLOAT32비트 float 3개 = float3. 위치가 x, y, z 3성분이므로.
InputSlot0몇 번 버텍스 버퍼에서 읽을지. 여기선 버퍼 하나에 다 담아 0번.
AlignedByteOffsetoffsetof(Vertex, pos)정점 시작부터 이 속성까지의 바이트 거리. pos는 0.
InputSlotClassPER_VERTEX_DATA정점마다 바뀌는 데이터(인스턴싱이면 PER_INSTANCE).
InstanceDataStepRate0인스턴스 데이터가 아닐 때는 0.

offsetof(Vertex, pos)는 매크로로, 구조체 시작부터 해당 멤버까지의 바이트 오프셋을 컴파일 타임에 계산합니다. pos는 0, normal은 12, uv는 24가 됩니다. 이 숫자를 직접 0, 12, 24로 하드코딩할 수도 있지만, 멤버 순서를 바꾸거나 타입을 수정하면 바로 어긋나는 버그의 온상입니다. offsetof를 쓰면 구조체 정의가 곧 진실의 원천(single source of truth)이 됩니다.

두 번째 줄 "NORMAL"float3(R32G32B32_FLOAT)이고 오프셋만 offsetof(Vertex, normal) = 12로 다릅니다. 세 번째 "TEXCOORD"는 UV가 2성분이라 포맷이 R32G32_FLOAT(float 2개)이고 오프셋은 24입니다. 함정 UV에 R32G32B32_FLOAT를 잘못 쓰면 GPU가 8바이트가 아닌 12바이트를 읽으려 해 다음 정점의 첫 4바이트까지 삼켜 모든 정점이 어긋납니다.

셰이더와의 계약 정점 셰이더의 입력 구조체가 float3 pos : POSITION; float3 nrm : NORMAL; float2 uv : TEXCOORD; 라면, 위 레이아웃의 세 시맨틱 이름과 정확히 일치해야 합니다. 아래는 이 레이아웃과 짝이 맞는 최소 HLSL 입력 예시입니다.
shaders/basic.hlsl — 입력 구조체(레이아웃과 짝)hlsl
// 입력 레이아웃(kVertexLayout)의 시맨틱 이름과 1:1로 대응해야 한다.
struct VSInput {
    float3 pos    : POSITION;   // kVertexLayout[0] "POSITION"
    float3 normal : NORMAL;     // kVertexLayout[1] "NORMAL"
    float2 uv     : TEXCOORD;   // kVertexLayout[2] "TEXCOORD"
};

cbuffer PerObject : register(b0) {
    row_major float4x4 uMVP;    // 행 우선 저장. C++에서 transpose 후 업로드
};

struct VSOutput {
    float4 pos    : SV_Position; // 클립 공간 위치(래스터라이저 필수 출력)
    float3 normal : NORMAL;
    float2 uv     : TEXCOORD;
};

VSOutput VSMain(VSInput input) {
    VSOutput o;
    // 행벡터 규약: v' = v * M  →  HLSL은 mul(v, M)
    o.pos    = mul(float4(input.pos, 1.0), uMVP);
    o.normal = input.normal;
    o.uv     = input.uv;
    return o;
}

이 셰이더는 로직 확장을 위한 짝 맞춤 예시입니다(원본 메시 코드의 일부는 아니지만, 레이아웃이 무엇과 연결되는지 보여줍니다). 핵심은 세 가지입니다.

함정 float4(input.pos, 1.0)에서 w=1은 "이것은 점이다"라는 뜻입니다(방향 벡터라면 w=0). w=1이라야 행렬의 이동(translation) 성분이 위치에 더해집니다. w=0으로 잘못 넣으면 물체가 원점에 붙어버립니다.

2-4. Mesh 생성자 — 두 버퍼를 GPU에 올리기

무엇을 만드나: CPU 쪽 std::vector<Vertex>와 인덱스 배열을 받아, GPU 메모리 위의 버텍스 버퍼(vbo_)인덱스 버퍼(ibo_)를 만듭니다.

생성자 시그니처부터 봅니다. Mesh(ID3D11Device* device, const std::vector<Vertex>& verts, const std::vector<unsigned int>& indices)

초기화 리스트 : count_((UINT)indices.size())는 생성자 본문 실행 전에 멤버 count_인덱스 개수로 초기화합니다. 나중에 DrawIndexed에 넘길 "그릴 인덱스 수"입니다. size()size_t(64비트)를 반환하는데 D3D API는 UINT(32비트)를 원하므로 (UINT)로 명시 캐스팅합니다.

버텍스 버퍼 만들기 — 필드 하나씩

D3D11_BUFFER_DESC vbDesc{};중괄호 {}로 값 초기화해 모든 필드를 0으로 시작합니다. 이걸 빼먹으면 스택의 쓰레기 값이 남아 CPUAccessFlags 같은 필드에 임의 값이 들어가 생성이 실패할 수 있습니다.

D3D11_SUBRESOURCE_DATA vbInit{ verts.data(), 0, 0 };초기 데이터입니다. 첫 필드 pSysMem = verts.data()가 CPU 쪽 실제 데이터 포인터, 나머지 두 개(SysMemPitch, SysMemSlicePitch)는 2D/3D 텍스처용 행 간격이라 버퍼에는 의미 없어 0입니다.

device->CreateBuffer(&vbDesc, &vbInit, &vbo_)가 실제 생성입니다. 세 인자는 서술(desc) 주소, 초기 데이터 주소, 결과를 받을 ID3D11Buffer**입니다. IMMUTABLE 버퍼는 초기 데이터가 필수라 두 번째 인자를 nullptr로 주면 실패합니다.

함정 vbo_ComPtr<ID3D11Buffer>입니다. &vbo_ComPtr::operator&를 통해 내부 원시 포인터 슬롯의 주소(ID3D11Buffer**)를 반환하며, 기존 포인터를 먼저 Release합니다. 그래서 이미 무언가 들고 있던 ComPtr&로 다시 받으면 누수 없이 교체됩니다.

인덱스 버퍼 만들기 — 버텍스와 무엇이 다른가

구조는 버텍스 버퍼와 판박이입니다. 차이는 딱 두 곳입니다.

나머지(IMMUTABLE, 초기 데이터 indices.data(), CreateBuffer)는 동일합니다. 인덱스가 unsigned int(32비트)이므로 나중에 draw()에서 DXGI_FORMAT_R32_UINT로 짝을 맞춰야 합니다.

2-5. draw() — 매 프레임 파이프라인에 묶고 그리기

무엇을 하나: 만들어 둔 두 버퍼를 입력 어셈블러(IA) 단계에 바인딩하고 DrawIndexed로 렌더 명령을 냅니다. const 멤버 함수라 Mesh 내부 상태를 바꾸지 않습니다(그리기는 읽기 전용 동작).

ctx->IASetVertexBuffers(0, 1, vbo_.GetAddressOf(), &stride, &offset) — 인자를 보면:

  1. 0 — 시작 슬롯(입력 슬롯 0번).
  2. 1 — 묶을 버퍼 개수(하나).
  3. vbo_.GetAddressOf() — 버퍼 배열의 주소(ID3D11Buffer* const*). ComPtr을 Release하지 않고 주소만 얻는 안전한 방법.
  4. &stride — 슬롯별 보폭 배열의 주소.
  5. &offset — 슬롯별 시작 오프셋 배열의 주소.
함정 vbo_.GetAddressOf()&vbo_는 다릅니다. GetAddressOf()기존 포인터를 유지한 채 주소만 주고, &vbo_(operator&)는 먼저 Release합니다. 바인딩할 때 실수로 &vbo_를 쓰면 버퍼가 해제되어 아무것도 안 그려집니다.

ctx->IASetIndexBuffer(ibo_.Get(), DXGI_FORMAT_R32_UINT, 0) — 인덱스 버퍼를 묶습니다. 두 번째 인자 포맷이 핵심입니다. 우리는 unsigned int(32비트) 인덱스를 만들었으니 DXGI_FORMAT_R32_UINT로 짝을 맞춥니다. 만약 DXGI_FORMAT_R16_UINT(16비트)로 잘못 쓰면 GPU가 인덱스 하나를 2바이트로 읽어 인덱스가 완전히 뒤섞입니다. 세 번째 0은 버퍼 시작 오프셋.

성능 정점이 65,536개 이하로 확실하면 16비트 인덱스(unsigned short + R16_UINT)가 인덱스 버퍼 대역폭을 절반으로 줄여 유리합니다. 그 이상이면 32비트가 필요합니다. 여기서는 단순함을 위해 항상 32비트를 씁니다.

ctx->IASetPrimitiveTopology(D3D11_PRIMITIVE_TOPOLOGY_TRIANGLELIST) — 정점을 3개씩 묶어 독립 삼각형으로 해석하라는 지시입니다. 인덱스가 [0,1,2, 2,3,0]이면 삼각형 두 개(0-1-2, 2-3-0)가 됩니다. TRIANGLESTRIP이면 인덱스를 이어 붙여 해석하는 다른 규칙이 되므로 반드시 리스트로 맞춰야 합니다.

마지막으로 ctx->DrawIndexed(count_, 0, 0)가 실제 드로우 콜입니다.

  1. count_ — 그릴 인덱스 개수(= 생성자에서 저장한 인덱스 배열 크기). 삼각형 수 × 3.
  2. 0 — StartIndexLocation: 인덱스 버퍼에서 몇 번째부터 읽을지(처음부터).
  3. 0 — BaseVertexLocation: 각 인덱스에 더할 정점 오프셋(0이면 그대로).

GPU는 인덱스를 순서대로 읽어(0, 1, 2, ...) 각 인덱스가 가리키는 정점을 버텍스 버퍼에서 꺼내 정점 셰이더로 보냅니다. 같은 인덱스가 반복되면 정점 캐시 덕분에 셰이더를 다시 안 돌리고 재사용해 빨라집니다 — 이것이 인덱스 버퍼의 성능 이점입니다.

2-6. private 멤버와 자동 해제

ComPtr<ID3D11Buffer> vbo_, ibo_; — 두 버퍼를 스마트 포인터로 보유합니다. ComPtr은 COM의 참조 카운트를 관리해, Mesh가 소멸될 때 소멸자가 자동으로 Release()를 호출합니다. 즉 수동 ->Release()가 필요 없고, 예외가 나도 누수가 없습니다(RAII). UINT count_ = 0;은 인덱스 수의 클래스 내 기본값으로, 생성자 초기화 리스트가 이를 덮어씁니다.

2-7. DirectXMath / D3D API 대응 요약

이 코드대응 개념 / API역할
XMFLOAT3 posDirectXMath 저장 타입버퍼 저장용 float3 (계산은 XMVECTOR로 로드)
offsetof(Vertex, uv)C 표준 매크로멤버 바이트 오프셋을 컴파일 타임 계산
CreateBuffer(VB)ID3D11DeviceGPU 정점 버퍼 생성
IASetVertexBuffersIA 단계정점 소스 바인딩
IASetIndexBufferIA 단계인덱스 소스 바인딩 (+ 포맷)
DrawIndexed드로우 콜인덱스 기반 삼각형 렌더
ComPtrWRLCOM 참조 카운트 자동 관리(RAII)

3. 최소 OBJ 로더 — 텍스트를 정점으로

.obj는 사람이 읽을 수 있는 텍스트 3D 포맷입니다. v(위치), vt(텍스처 좌표), vn(법선) 줄로 데이터를 나열하고, f(면) 줄이 이 데이터를 인덱스로 조합해 다각형을 정의합니다. 아래 로더는 이 네 태그를 파싱해 Vertex 배열과 인덱스 배열을 만듭니다.

src/ObjLoader.cpp — 최소 OBJ 로더cpp
#include "Mesh.h"
#include <fstream>
#include <sstream>
#include <string>
#include <vector>

using namespace DirectX;

// f 줄의 "v/vt/vn" 한 토큰을 파싱. 인덱스는 1-based, 음수 가능.
// vt/vn은 생략될 수 있다("v//vn", "v/vt", "v").
static void parseFaceVert(const std::string& tok,
                          int& vi, int& ti, int& ni) {
    vi = ti = ni = 0;
    std::stringstream ss(tok);
    std::string a, b, c;
    std::getline(ss, a, '/');
    std::getline(ss, b, '/');
    std::getline(ss, c, '/');
    if (!a.empty()) vi = std::stoi(a);
    if (!b.empty()) ti = std::stoi(b);
    if (!c.empty()) ni = std::stoi(c);
}

// 매우 단순한 로더: 중복 정점을 그대로 펼쳐 인덱스를 순차 발급.
bool loadObj(const std::string& path,
             std::vector<Vertex>& outVerts,
             std::vector<unsigned int>& outIndices) {
    std::ifstream file(path);
    if (!file) return false;

    std::vector<XMFLOAT3> positions;
    std::vector<XMFLOAT2> uvs;
    std::vector<XMFLOAT3> normals;

    std::string line;
    while (std::getline(file, line)) {
        std::stringstream ss(line);
        std::string tag;
        ss >> tag;

        if (tag == "v") {
            XMFLOAT3 p; ss >> p.x >> p.y >> p.z; positions.push_back(p);
        } else if (tag == "vt") {
            XMFLOAT2 t; ss >> t.x >> t.y;
            t.y = 1.0f - t.y;   // D3D 텍스처 V 원점은 좌상단 → V 뒤집기
            uvs.push_back(t);
        } else if (tag == "vn") {
            XMFLOAT3 n; ss >> n.x >> n.y >> n.z; normals.push_back(n);
        } else if (tag == "f") {
            // 다각형의 모든 정점 토큰을 모은다
            std::vector<std::string> toks;
            std::string t;
            while (ss >> t) toks.push_back(t);

            // 팬(fan) 삼각화: (0, i, i+1)
            for (size_t i = 1; i + 1 < toks.size(); ++i) {
                const std::string tri[3] = { toks[0], toks[i], toks[i + 1] };
                for (const auto& s : tri) {
                    int vi, ti, ni; parseFaceVert(s, vi, ti, ni);
                    Vertex vx{};
                    // 음수 인덱스는 끝에서부터, 양수는 1-based
                    vx.pos = positions[vi > 0 ? vi - 1 : positions.size() + vi];
                    if (ti != 0)
                        vx.uv = uvs[ti > 0 ? ti - 1 : uvs.size() + ti];
                    if (ni != 0)
                        vx.normal = normals[ni > 0 ? ni - 1 : normals.size() + ni];
                    outIndices.push_back((unsigned int)outVerts.size());
                    outVerts.push_back(vx);
                }
            }
        }
        // 그 외 태그(#, o, g, s, mtllib 등)는 무시
    }
    return true;
}

3-1. OBJ 파일 형식 미리보기

로더를 읽기 전에 .obj가 실제로 어떻게 생겼는지 봅시다. 삼각형 하나짜리 파일 예:

triangle.obj — 예시text
v 0.0 1.0 0.0
v -1.0 -1.0 0.0
v 1.0 -1.0 0.0
vt 0.5 1.0
vt 0.0 0.0
vt 1.0 0.0
vn 0.0 0.0 1.0
f 1/1/1 2/2/1 3/3/1

v 세 줄은 위치 3개, vt는 UV 3개, vn은 법선 1개(세 정점이 공유), f 줄은 "정점1은 위치1·UV1·법선1, 정점2는 위치2·UV2·법선1..."을 뜻합니다. 각 토큰 1/1/1의 형식은 위치/UV/법선이며 1부터 시작하는 인덱스입니다.

3-2. parseFaceVert"v/vt/vn" 토큰 하나 쪼개기

무엇을 하나: f 줄의 토큰 하나("3/5/2" 같은)를 받아 세 정수 vi, ti, ni로 분리합니다. OBJ는 v//vn(UV 없음), v/vt(법선 없음), v(위치만)처럼 일부가 생략될 수 있어 견고하게 처리해야 합니다.

시그니처 static void parseFaceVert(const std::string& tok, int& vi, int& ti, int& ni):

vi = ti = ni = 0; — 먼저 셋을 0으로 초기화합니다. 0은 "없음"을 뜻하는 감시값(sentinel)입니다. OBJ 인덱스는 1부터 시작하거나 음수라서 0은 절대 유효한 인덱스가 아니므로 "생략됨" 표시로 안전하게 쓸 수 있습니다.

std::stringstream ss(tok); — 토큰을 스트림에 담아 getline으로 잘라냅니다. std::getline(ss, a, '/')/를 구분자로 다음 조각을 a에 담습니다. 세 번 호출해 a(위치), b(UV), c(법선)를 얻습니다.

입력 토큰abc결과 (vi, ti, ni)
3/5/2"3""5""2"(3, 5, 2)
3//2"3""""2"(3, 0, 2)
3/5"3""5"""(3, 5, 0)
3"3"""""(3, 0, 0)

마지막 세 줄 if (!a.empty()) vi = std::stoi(a);는 조각이 비어있지 않을 때만 정수로 변환합니다. std::stoi("string to int")가 "3"3으로 바꿉니다. 빈 문자열에 stoi를 부르면 예외가 나므로, empty() 검사가 생략된 필드를 안전하게 넘기는 열쇠입니다.

함정 3//2에서 가운데가 비어도 getline빈 조각을 정확히 반환하는 게 핵심입니다. 만약 스트림 연산자 ss >> b로 읽었다면 공백만 건너뛰어 /를 처리하지 못하고 2를 UV로 오독했을 것입니다. 그래서 여기서는 getline(..., '/')를 씁니다.

3-3. loadObj — 파일 한 줄씩 읽어 배열 채우기

무엇을 하나: 파일 경로를 받아 정점/인덱스 배열을 채우고 성공 여부를 bool로 돌려줍니다. 출력 두 개(outVerts, outIndices)도 참조 출력 인자입니다.

std::ifstream file(path);로 파일을 열고, if (!file) return false;열기 실패를 즉시 처리합니다. 경로가 틀리거나 파일이 없으면 file이 falsy가 되어 바로 빠져나옵니다.

positions, uvs, normals임시 원본 저장소 세 개입니다. OBJ는 위치·UV·법선을 따로따로 나열하고 f 줄에서 인덱스로 조합하므로, 먼저 이 셋을 순서대로 모아두고 f를 만날 때 조립합니다.

줄 태그 판별

while (std::getline(file, line))한 줄씩 읽습니다. 각 줄을 std::stringstream ss(line)에 담고 ss >> tag첫 단어(태그)를 뽑습니다. >> 연산자는 공백을 건너뛰고 한 단어를 읽으므로, "v 0.0 1.0 0.0"에서 tag"v"가 됩니다.

함정 V 좌표 뒤집기 OBJ/대부분 이미지 편집기는 텍스처 V 원점이 좌하단(아래→위 증가)인데, Direct3D의 텍스처 좌표는 V 원점이 좌상단(위→아래 증가)입니다. 그래서 t.y = 1.0f - t.y로 뒤집지 않으면 텍스처가 위아래로 뒤집혀 붙습니다. 우리 프로젝트 규약이 "텍스처 V 위→아래"라 이 한 줄이 필수입니다. (예: OBJ의 vt 0.3 0.8 → 저장값 (0.3, 0.2))

면(f) 파싱과 팬 삼각화

f 줄은 정점이 3개(삼각형)일 수도, 4개(사각형)나 그 이상일 수도 있습니다. GPU는 삼각형만 그리므로 다각형을 삼각형들로 쪼개야 합니다.

먼저 while (ss >> t) toks.push_back(t);면의 모든 정점 토큰toks에 모읍니다. 사각형이면 toks에 토큰 4개가 담깁니다.

팬(fan) 삼각화는 다각형을 첫 정점을 공통 축으로 삼아 부채꼴처럼 삼각형을 만듭니다:

팬 삼각화 — 사각형(0,1,2,3)의 경우text
정점:  0 --- 1
       |  \  |
       3 --- 2

삼각형 1: (0, 1, 2)   ← i=1
삼각형 2: (0, 2, 3)   ← i=2
공통 꼭짓점 = 0 (부채의 중심)

코드로는 for (size_t i = 1; i + 1 < toks.size(); ++i)입니다. i는 1부터 시작해 i+1 < 크기일 때까지 돌며, 매 반복에서 tri[3] = { toks[0], toks[i], toks[i + 1] } 삼각형을 만듭니다.

toks[0]이 항상 들어가는 게 팬의 핵심입니다. i+1 < toks.size() 조건은 마지막 유효한 쌍 (toks[n-2], toks[n-1])까지만 돌게 해 배열 범위를 넘지 않도록 합니다.

주의 팬 삼각화는 볼록(convex) 다각형에서만 올바릅니다. 오목(concave) 다각형이면 삼각형이 도형 바깥으로 삐져나올 수 있습니다. 다행히 대부분의 OBJ 면은 삼각형/사각형이고 볼록이라 실무에서도 이 방식이 흔합니다.

토큰 → Vertex 조립과 인덱스 발급

각 삼각형의 세 토큰을 for (const auto& s : tri)로 돌며 정점을 만듭니다. const auto&는 각 토큰을 복사 없이 참조로 받는 범위 기반 for입니다.

  1. parseFaceVert(s, vi, ti, ni)로 토큰을 세 인덱스로 분해.
  2. Vertex vx{};로 정점을 0으로 초기화(UV/법선이 없어도 안전한 기본값).
  3. 위치·UV·법선을 원본 배열에서 꺼내 채움.
  4. 인덱스를 발급하고 정점을 배열에 추가.

핵심은 1-based / 음수 인덱스 변환입니다. vx.pos = positions[vi > 0 ? vi - 1 : positions.size() + vi];를 뜯어보면:

UV와 법선은 있을 때만 채웁니다: if (ti != 0), if (ni != 0). parseFaceVert가 생략된 필드를 0으로 남겼으니, 0이면 건너뛰어 vx{}의 기본값(0벡터)을 유지합니다. 같은 ?: 삼항 연산으로 1-based/음수를 변환합니다.

마지막 두 줄이 이 로더의 단순함이 드러나는 곳입니다:

순차 인덱스 발급cpp
outIndices.push_back((unsigned int)outVerts.size());
outVerts.push_back(vx);

정점을 추가하기 직전에 현재 정점 개수를 인덱스로 씁니다. outVerts가 비어있으면 size()=0을 인덱스로 넣고 정점을 추가 → 그 정점은 0번. 다음엔 size()=1을 넣고 추가 → 1번. 즉 인덱스가 0, 1, 2, 3, ...으로 순차 발급됩니다. 결과적으로 이 로더는 정점을 전혀 공유하지 않고 모두 펼칩니다(인덱스 버퍼의 이점을 살리지 못함 — 아래 주의 참조).

ss >> tag가 매칭 안 되는 줄(# 주석, o/g 그룹, s 스무딩, mtllib 등)은 어떤 if에도 안 걸려 조용히 무시됩니다. 최소 로더의 의도된 동작입니다. 마지막 return true;로 성공을 알립니다.

주의 이 로더는 학습용 최소 버전입니다. 법선이 없는 OBJ라면 삼각형별로 법선을 계산해 채워야 하고, 대형 모델에서는 정점 중복 제거(해시맵 기반)를 넣어야 버텍스 버퍼 크기가 폭발하지 않습니다. 실무에서는 tinyobjloaderassimp를 씁니다.
함정 OBJ 인덱스는 1부터 시작합니다. 0-based 배열로 접근할 때 -1을 잊으면 정점이 한 칸씩 밀려 메시가 어긋나거나, 첫 정점을 참조하려다 인덱스가 음수가 됩니다.
성능 "정점을 모두 펼치는" 방식은 정점 캐시 이점을 못 살립니다. 개선하려면 "v/vt/vn" 문자열을 키로 하는 std::unordered_map을 두고, 이미 본 조합이면 기존 인덱스를 재사용하도록 바꿉니다. 정육면체 기준 정점을 36개 → 24개(또는 그 이하)로 줄일 수 있습니다.

3-4. 정점 법선 계산 — 법선이 없는 OBJ 대비(보강 예시)

법선이 빠진 OBJ를 대비해, 위치만으로 법선을 만드는 방법을 짚습니다(원본 로더에는 없지만 자주 필요합니다). 삼각형의 법선은 두 변의 외적(cross product)으로 구합니다.

src/ObjLoader.cpp — 면 법선 계산(보강)cpp
// 삼각형 (a, b, c)의 면 법선 = normalize((b - a) × (c - a))
XMVECTOR va = XMLoadFloat3(&a.pos);
XMVECTOR vb = XMLoadFloat3(&b.pos);
XMVECTOR vc = XMLoadFloat3(&c.pos);
XMVECTOR faceN = XMVector3Normalize(
    XMVector3Cross(XMVectorSubtract(vb, va),
                   XMVectorSubtract(vc, va)));
XMFLOAT3 n; XMStoreFloat3(&n, faceN);
a.normal = b.normal = c.normal = n;

단계별로 봅니다. 왼손 좌표계 + 시계방향(CW) 앞면 규약을 전제로, 변 순서 (b-a) × (c-a)바깥쪽 법선을 냅니다.

외적의 성분이 어디서 오는지 손으로 확인해 봅시다. e1 = (e1x, e1y, e1z), e2 = (e2x, e2y, e2z)일 때 e1 × e2는 다음과 같습니다(각 성분이 "나머지 두 축의 엇갈린 곱의 차"):

외적 성분 공식text
(e1 × e2).x = e1y * e2z  -  e1z * e2y
(e1 × e2).y = e1z * e2x  -  e1x * e2z
(e1 × e2).z = e1x * e2y  -  e1y * e2x

구체 예: a=(0,0,0), b=(1,0,0), c=(0,1,0)인 삼각형이면 e1 = b−a = (1,0,0), e2 = c−a = (0,1,0)입니다. 공식에 넣으면 x = 0·0 − 0·1 = 0, y = 0·0 − 1·0 = 0, z = 1·1 − 0·0 = 1(0, 0, 1). xy평면 삼각형의 법선이 정확히 +z를 향합니다(왼손계에서 화면 밖으로). 이미 길이가 1이라 정규화해도 그대로입니다.

함정 외적은 순서에 민감합니다. (c-a) × (b-a)로 바꾸면 부호가 뒤집혀 법선이 반대쪽(안쪽)을 향합니다. 그러면 조명이 뒤집혀 앞면이 어둡고 뒷면이 밝아집니다. 와인딩(정점 나열 순서)과 외적 순서를 일관되게 맞춰야 합니다.

3-5. DirectXMath / D3D API 대응 요약

이 코드대응 API역할
t.y = 1.0f - t.yD3D 텍스처 규약V 원점(좌상단)에 맞춰 UV 뒤집기
positions[vi-1]OBJ 1-based → 0-based인덱스 규약 변환
XMLoadFloat3DirectXMath저장 타입 → SIMD 계산 타입
XMVector3CrossDirectXMath외적으로 면 법선 방향 계산
XMVector3NormalizeDirectXMath단위 벡터화(조명 전제)
XMStoreFloat3DirectXMathSIMD 결과 → 저장 타입

4. 전체 흐름 한눈에

지금까지 만든 조각을 실제 프레임에서 어떻게 쓰는지 순서로 정리합니다.

  1. loadObj("model.obj", verts, indices) — 파일에서 정점/인덱스 배열을 채운다.
  2. Mesh mesh(device, verts, indices) — 배열을 GPU 버텍스/인덱스 버퍼로 올린다(한 번만).
  3. 초기화 때 kVertexLayout과 컴파일된 정점 셰이더로 CreateInputLayoutIASetInputLayout.
  4. 매 프레임: 셰이더/상수버퍼/입력 레이아웃 바인딩 후 mesh.draw(ctx) 호출.

이 페이지의 핵심을 다시 요약하면: 정점 데이터의 메모리 배치(Vertex) → GPU가 그것을 해석하는 규칙(kVertexLayout) → 데이터를 올리는 버퍼(Mesh) → 인덱스로 그리기(DrawIndexed), 그리고 파일에서 이 데이터를 얻는 OBJ 파싱입니다. 다음 장에서는 이렇게 그린 삼각형들이 앞뒤로 겹칠 때 올바르게 가리도록 하는 깊이 버퍼를 다룹니다.