삼각형과 메시 — 한 줄 한 줄 직접 구현
화면에 보이는 3D 모델은 결국 수많은 삼각형의 집합입니다.
이 페이지에서는 정점 하나에 위치·법선·UV를 담는 Vertex 구조체를 정의하고,
버텍스 버퍼와 인덱스 버퍼로 정점을 GPU에 올려 재사용하고,
이를 관리하는 Mesh 클래스와 .obj 파일을 읽는 최소 로더를 만듭니다.
코드 한 줄, 구조체 멤버 하나, D3D API 인자 하나까지 왜 그렇게 쓰는지를 끝까지 파고듭니다.
1. 왜 인덱스 버퍼인가 — 정점 재사용의 원리
가장 단순한 렌더링은 "삼각형마다 정점 3개를 나열"하는 방식입니다.
하지만 실제 모델에서는 하나의 정점을 여러 삼각형이 공유합니다.
예를 들어 정육면체는 꼭짓점이 8개뿐인데, 삼각형은 12개(면 6개 × 2)입니다.
정점을 매번 펼쳐 쓰면 12 × 3 = 36개의 정점을 저장해야 하지만,
인덱스 버퍼를 쓰면 정점 8개 + 인덱스 36개만 있으면 됩니다.
정점 하나가 위치(12바이트) + 법선(12바이트) + UV(8바이트) = 32바이트인데 인덱스는 4바이트에 불과합니다. 즉 공유 정점을 인덱스로 가리키면 메모리를 크게 아끼고, GPU의 정점 캐시 적중률도 올라가 처리 속도가 빨라집니다.
Vertex= 위치(float3) + 법선(float3) + UV(float2).Mesh가 버텍스/인덱스 버퍼를 생성하고D3D11_INPUT_ELEMENT_DESC로 3개 속성을 서술.draw()는context->DrawIndexed로 인덱스 기반 렌더.- OBJ 로더가
v / vt / vn / f줄을 파싱하고 다각형을 삼각형으로 분할.
2. Vertex 구조체 · 입력 레이아웃 · Mesh 클래스
먼저 정점 하나의 메모리 배치를 정하는 Vertex 구조체와,
GPU에게 "이 바이트 뭉치를 이렇게 해석하라"고 알려주는 입력 레이아웃,
그리고 두 버퍼의 생성·수명을 책임지는 Mesh 클래스를 봅니다.
#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 구조체가 두 번 정의돼 링크 오류가 나는데, 이를 막습니다.
<d3d11.h>—ID3D11Device,ID3D11Buffer,D3D11_BUFFER_DESC등 D3D11 핵심 타입.<DirectXMath.h>—XMFLOAT3/XMFLOAT2같은 저장용 벡터 타입과 SIMD 수학 함수.<wrl/client.h>—Microsoft::WRL::ComPtr. COM 객체의 참조 카운트를 자동 관리하는 스마트 포인터.<vector>— 정점/인덱스 배열을 담을std::vector.
using Microsoft::WRL::ComPtr;와 using namespace DirectX;는
매번 Microsoft::WRL::ComPtr나 DirectX::XMFLOAT3처럼 길게 쓰지 않도록 이름을 짧게 끌어옵니다.
함정 헤더 파일에서 using namespace를 전역에 쓰는 것은 실무에서는 권장되지 않습니다
(이 헤더를 포함한 모든 파일이 DirectX 네임스페이스에 오염됩니다). 학습용이라 편의상 그대로 두었습니다.
2-2. Vertex 구조체 — 메모리 배치가 곧 계약
무엇을 만드나: GPU에 올릴 정점 하나의 데이터 묶음입니다.
왜 필요한가: 삼각형을 그리려면 각 꼭짓점의 위치가 필요하고,
조명 계산에는 법선(normal), 텍스처를 붙이려면 좌표(uv)가 필요합니다.
이 셋을 한 정점에 묶어야 GPU가 "정점 하나 = 32바이트"로 규칙적으로 읽을 수 있습니다.
| 멤버 | 타입 | 바이트 | 오프셋 | 의미 |
|---|---|---|---|---|
pos | XMFLOAT3 | 12 | 0 | 모델 공간 위치 (x, y, z) |
normal | XMFLOAT3 | 12 | 12 | 표면 법선 (조명용) |
uv | XMFLOAT2 | 8 | 24 | 텍스처 좌표 (u, v) |
| 합계 | 32 | — | 정점 1개 = 32바이트 | |
XMFLOAT3은 단순히 float x, y, z;를 담은 구조체입니다.
DirectXMath에는 계산용 타입인 XMVECTOR(SIMD 레지스터, 16바이트 정렬 필수)도 있지만,
버퍼에 저장할 때는 반드시 XMFLOAT*를 씁니다.
XMVECTOR는 정렬 요구 때문에 배열/구조체 멤버로 쓰면 낭비가 생기고,
GPU가 기대하는 float32 3개의 빈틈 없는 배치(packed)와 어긋날 수 있기 때문입니다.
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으로 받는 이름. 셰이더 입력과 짝을 맞추는 열쇠. |
| SemanticIndex | 0 | 같은 이름이 여러 개일 때 구분(예: TEXCOORD0, TEXCOORD1). |
| Format | R32G32B32_FLOAT | 32비트 float 3개 = float3. 위치가 x, y, z 3성분이므로. |
| InputSlot | 0 | 몇 번 버텍스 버퍼에서 읽을지. 여기선 버퍼 하나에 다 담아 0번. |
| AlignedByteOffset | offsetof(Vertex, pos) | 정점 시작부터 이 속성까지의 바이트 거리. pos는 0. |
| InputSlotClass | PER_VERTEX_DATA | 정점마다 바뀌는 데이터(인스턴싱이면 PER_INSTANCE). |
| InstanceDataStepRate | 0 | 인스턴스 데이터가 아닐 때는 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 입력 예시입니다.
// 입력 레이아웃(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;
}
이 셰이더는 로직 확장을 위한 짝 맞춤 예시입니다(원본 메시 코드의 일부는 아니지만, 레이아웃이 무엇과 연결되는지 보여줍니다). 핵심은 세 가지입니다.
- 시맨틱 일치:
: POSITION,: NORMAL,: TEXCOORD가kVertexLayout의 이름과 정확히 같습니다. - 행벡터 규약: 위치를
float4(pos, 1.0)로 만들고mul(v, M)순서로 곱합니다. 우리 프로젝트는 v' = v·M 규약이라 벡터가 왼쪽입니다. row_major: C++에서XMMATRIX(행 우선)를XMMatrixTranspose후 업로드하고, HLSL은row_major로 받아 원래 행 우선 해석을 유지합니다.
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)
ID3D11Device* device— 버퍼 같은 리소스를 생성하는 팩토리 객체. 원시 포인터로 받아 소유권을 갖지 않음(호출부가 관리).const std::vector<Vertex>& verts— 참조(&)로 받아 복사 비용 0. 큰 배열을 값으로 받으면 전체가 복사됩니다.const는 "읽기만 하고 수정 안 함"을 컴파일러/독자에게 보장.const std::vector<unsigned int>& indices— 마찬가지. 인덱스는 32비트 부호 없는 정수.
초기화 리스트 : count_((UINT)indices.size())는 생성자 본문 실행 전에
멤버 count_를 인덱스 개수로 초기화합니다. 나중에 DrawIndexed에 넘길 "그릴 인덱스 수"입니다.
size()는 size_t(64비트)를 반환하는데 D3D API는 UINT(32비트)를 원하므로 (UINT)로 명시 캐스팅합니다.
버텍스 버퍼 만들기 — 필드 하나씩
D3D11_BUFFER_DESC vbDesc{}; — 중괄호 {}로 값 초기화해 모든 필드를 0으로 시작합니다.
이걸 빼먹으면 스택의 쓰레기 값이 남아 CPUAccessFlags 같은 필드에 임의 값이 들어가 생성이 실패할 수 있습니다.
ByteWidth = (UINT)(verts.size() * sizeof(Vertex))— 버퍼 전체 크기. 정점 수 × 정점당 32바이트. 정점 100개면 3200바이트.Usage = D3D11_USAGE_IMMUTABLE— 생성 후 변경 불가. 정적 메시라 한 번 올리면 끝이라 GPU가 가장 빠른 메모리에 배치할 수 있음.BindFlags = D3D11_BIND_VERTEX_BUFFER— 이 버퍼를 정점 소스로 파이프라인에 묶겠다는 선언.
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에 &로 다시 받으면 누수 없이 교체됩니다.
인덱스 버퍼 만들기 — 버텍스와 무엇이 다른가
구조는 버텍스 버퍼와 판박이입니다. 차이는 딱 두 곳입니다.
ByteWidth = indices.size() * sizeof(unsigned int)— 인덱스당 4바이트. 인덱스 36개면 144바이트.BindFlags = D3D11_BIND_INDEX_BUFFER— 이 버퍼를 인덱스 소스로 묶겠다는 선언.
나머지(IMMUTABLE, 초기 데이터 indices.data(), CreateBuffer)는 동일합니다.
인덱스가 unsigned int(32비트)이므로 나중에 draw()에서 DXGI_FORMAT_R32_UINT로 짝을 맞춰야 합니다.
2-5. draw() — 매 프레임 파이프라인에 묶고 그리기
무엇을 하나: 만들어 둔 두 버퍼를 입력 어셈블러(IA) 단계에 바인딩하고 DrawIndexed로 렌더 명령을 냅니다.
const 멤버 함수라 Mesh 내부 상태를 바꾸지 않습니다(그리기는 읽기 전용 동작).
UINT stride = sizeof(Vertex)— 보폭: 정점 하나에서 다음 정점까지의 바이트 거리(32). GPU가 이 값만큼 건너뛰며 정점을 읽습니다.UINT offset = 0— 버퍼 시작에서 몇 바이트 뒤부터 읽을지. 처음부터라 0.
ctx->IASetVertexBuffers(0, 1, vbo_.GetAddressOf(), &stride, &offset) — 인자를 보면:
0— 시작 슬롯(입력 슬롯 0번).1— 묶을 버퍼 개수(하나).vbo_.GetAddressOf()— 버퍼 배열의 주소(ID3D11Buffer* const*).ComPtr을 Release하지 않고 주소만 얻는 안전한 방법.&stride— 슬롯별 보폭 배열의 주소.&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은 버퍼 시작 오프셋.
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)가 실제 드로우 콜입니다.
count_— 그릴 인덱스 개수(= 생성자에서 저장한 인덱스 배열 크기). 삼각형 수 × 3.0— StartIndexLocation: 인덱스 버퍼에서 몇 번째부터 읽을지(처음부터).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 pos | DirectXMath 저장 타입 | 버퍼 저장용 float3 (계산은 XMVECTOR로 로드) |
offsetof(Vertex, uv) | C 표준 매크로 | 멤버 바이트 오프셋을 컴파일 타임 계산 |
CreateBuffer(VB) | ID3D11Device | GPU 정점 버퍼 생성 |
IASetVertexBuffers | IA 단계 | 정점 소스 바인딩 |
IASetIndexBuffer | IA 단계 | 인덱스 소스 바인딩 (+ 포맷) |
DrawIndexed | 드로우 콜 | 인덱스 기반 삼각형 렌더 |
ComPtr | WRL | COM 참조 카운트 자동 관리(RAII) |
3. 최소 OBJ 로더 — 텍스트를 정점으로
.obj는 사람이 읽을 수 있는 텍스트 3D 포맷입니다.
v(위치), vt(텍스처 좌표), vn(법선) 줄로 데이터를 나열하고,
f(면) 줄이 이 데이터를 인덱스로 조합해 다각형을 정의합니다.
아래 로더는 이 네 태그를 파싱해 Vertex 배열과 인덱스 배열을 만듭니다.
#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가 실제로 어떻게 생겼는지 봅시다. 삼각형 하나짜리 파일 예:
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):
static— 이.cpp파일 안에서만 보이는 내부 링크 함수. 다른 파일과 이름 충돌을 막습니다.const std::string& tok— 입력 토큰. 참조라 복사 없음,const라 수정 안 함.int& vi, int& ti, int& ni— 참조로 받은 출력 인자. 함수가 이 세 변수를 직접 채웁니다(반환값이 하나뿐인 C++에서 여러 값을 돌려주는 흔한 방법).
vi = ti = ni = 0; — 먼저 셋을 0으로 초기화합니다. 0은 "없음"을 뜻하는 감시값(sentinel)입니다.
OBJ 인덱스는 1부터 시작하거나 음수라서 0은 절대 유효한 인덱스가 아니므로 "생략됨" 표시로 안전하게 쓸 수 있습니다.
std::stringstream ss(tok); — 토큰을 스트림에 담아 getline으로 잘라냅니다.
std::getline(ss, a, '/')는 /를 구분자로 다음 조각을 a에 담습니다.
세 번 호출해 a(위치), b(UV), c(법선)를 얻습니다.
| 입력 토큰 | a | b | c | 결과 (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"가 됩니다.
tag == "v"—ss >> p.x >> p.y >> p.z로 float 3개를 읽어positions에 추가. 스트림이 남은 세 숫자를 순서대로 파싱합니다.tag == "vt"— UV 2개를 읽되t.y = 1.0f - t.y로 V를 뒤집습니다(아래 상세).tag == "vn"— 법선 3개를 읽어normals에 추가.tag == "f"— 면을 삼각형으로 분해(아래 상세).
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
| \ |
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] } 삼각형을 만듭니다.
- 토큰 3개(삼각형):
i=1한 번만 → 삼각형 (0,1,2) 하나. - 토큰 4개(사각형):
i=1(0,1,2),i=2(0,2,3) → 삼각형 두 개. - 토큰
n개: 삼각형n-2개.
toks[0]이 항상 들어가는 게 팬의 핵심입니다. i+1 < toks.size() 조건은
마지막 유효한 쌍 (toks[n-2], toks[n-1])까지만 돌게 해 배열 범위를 넘지 않도록 합니다.
토큰 → Vertex 조립과 인덱스 발급
각 삼각형의 세 토큰을 for (const auto& s : tri)로 돌며 정점을 만듭니다.
const auto&는 각 토큰을 복사 없이 참조로 받는 범위 기반 for입니다.
parseFaceVert(s, vi, ti, ni)로 토큰을 세 인덱스로 분해.Vertex vx{};로 정점을 0으로 초기화(UV/법선이 없어도 안전한 기본값).- 위치·UV·법선을 원본 배열에서 꺼내 채움.
- 인덱스를 발급하고 정점을 배열에 추가.
핵심은 1-based / 음수 인덱스 변환입니다.
vx.pos = positions[vi > 0 ? vi - 1 : positions.size() + vi];를 뜯어보면:
vi > 0일 때(양수, 흔한 경우) →vi - 1. OBJ는 1부터, C++ 배열은 0부터라 1을 빼서 맞춥니다. OBJ의1→ 배열[0].vi < 0일 때(음수, 상대 인덱스) →positions.size() + vi. OBJ의-1은 "가장 최근 정점"을 뜻해, 크기에 음수를 더하면 끝에서부터 셉니다. 정점이 10개면-1→10 + (-1) = 9→ 마지막.
UV와 법선은 있을 때만 채웁니다: if (ti != 0), if (ni != 0).
parseFaceVert가 생략된 필드를 0으로 남겼으니, 0이면 건너뛰어 vx{}의 기본값(0벡터)을 유지합니다.
같은 ?: 삼항 연산으로 1-based/음수를 변환합니다.
마지막 두 줄이 이 로더의 단순함이 드러나는 곳입니다:
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;로 성공을 알립니다.
tinyobjloader나 assimp를 씁니다.
0-based 배열로 접근할 때 -1을 잊으면
정점이 한 칸씩 밀려 메시가 어긋나거나, 첫 정점을 참조하려다 인덱스가 음수가 됩니다.
"v/vt/vn" 문자열을 키로 하는 std::unordered_map을 두고,
이미 본 조합이면 기존 인덱스를 재사용하도록 바꿉니다.
정육면체 기준 정점을 36개 → 24개(또는 그 이하)로 줄일 수 있습니다.
3-4. 정점 법선 계산 — 법선이 없는 OBJ 대비(보강 예시)
법선이 빠진 OBJ를 대비해, 위치만으로 법선을 만드는 방법을 짚습니다(원본 로더에는 없지만 자주 필요합니다). 삼각형의 법선은 두 변의 외적(cross product)으로 구합니다.
// 삼각형 (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)가 바깥쪽 법선을 냅니다.
XMLoadFloat3(&a.pos)— 저장 타입XMFLOAT3을 계산용XMVECTOR(SIMD)로 로드. 인자는const XMFLOAT3*.XMVectorSubtract(vb, va)— 변 벡터 b−a(a에서 b로 향함). 성분별 뺄셈.XMVector3Cross(e1, e2)— 두 변의 외적. 결과는 두 변이 만드는 평면에 수직인 벡터 = 면 법선 방향.XMVector3Normalize(...)— 길이를 1로 만들어 단위 법선으로. 조명 계산은 단위 벡터를 전제로 합니다.XMStoreFloat3(&n, faceN)— 다시 저장 타입으로 내려 정점에 기록.
외적의 성분이 어디서 오는지 손으로 확인해 봅시다.
e1 = (e1x, e1y, e1z), e2 = (e2x, e2y, e2z)일 때
e1 × e2는 다음과 같습니다(각 성분이 "나머지 두 축의 엇갈린 곱의 차"):
(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.y | D3D 텍스처 규약 | V 원점(좌상단)에 맞춰 UV 뒤집기 |
positions[vi-1] | OBJ 1-based → 0-based | 인덱스 규약 변환 |
XMLoadFloat3 | DirectXMath | 저장 타입 → SIMD 계산 타입 |
XMVector3Cross | DirectXMath | 외적으로 면 법선 방향 계산 |
XMVector3Normalize | DirectXMath | 단위 벡터화(조명 전제) |
XMStoreFloat3 | DirectXMath | SIMD 결과 → 저장 타입 |
4. 전체 흐름 한눈에
지금까지 만든 조각을 실제 프레임에서 어떻게 쓰는지 순서로 정리합니다.
loadObj("model.obj", verts, indices)— 파일에서 정점/인덱스 배열을 채운다.Mesh mesh(device, verts, indices)— 배열을 GPU 버텍스/인덱스 버퍼로 올린다(한 번만).- 초기화 때
kVertexLayout과 컴파일된 정점 셰이더로CreateInputLayout→IASetInputLayout. - 매 프레임: 셰이더/상수버퍼/입력 레이아웃 바인딩 후
mesh.draw(ctx)호출.
이 페이지의 핵심을 다시 요약하면: 정점 데이터의 메모리 배치(Vertex) → GPU가 그것을 해석하는 규칙(kVertexLayout) → 데이터를 올리는 버퍼(Mesh) → 인덱스로 그리기(DrawIndexed),
그리고 파일에서 이 데이터를 얻는 OBJ 파싱입니다. 다음 장에서는 이렇게 그린 삼각형들이 앞뒤로 겹칠 때 올바르게 가리도록 하는 깊이 버퍼를 다룹니다.