Model → World → View → Projection — 한 줄 한 줄 직접 구현
3D 점 하나가 화면 위 픽셀이 되기까지, 좌표는 네 개의 공간을 지납니다. 로컬(모델) 공간 → 월드 공간 → 뷰(카메라) 공간 → 클립 공간이죠. 이 여행을 담당하는 세 행렬이 Model · View · Projection입니다. 이 페이지에서는 원본 큐브 예제의 정점 데이터·셰이더·렌더 루프를 한 줄, 한 성분씩 뜯어보며, 각 행렬이 수학적으로 무엇을 하는지 → 기하학적으로 어떻게 상상하면 되는지 → 코드의 어느 부분에 대응하는지를 3단으로 설명합니다. DirectXMath와 D3D11 API 함수 하나하나를 원본과 1:1로 짚어 드립니다.
v' = v · M처럼 행렬 왼쪽에 벡터를 두고 곱합니다.
행렬은 행 우선(row-major)으로 메모리에 저장하고(m[r*4+c]),
좌표계는 왼손 좌표계(LH), 클립 공간 z 범위는 [0, 1],
텍스처 V축은 위→아래가 증가 방향입니다.
DirectXMath가 정확히 이 규약(행 벡터)을 따르므로 잘 맞습니다.
실무에서는 직접 행렬을 짜기보다 DirectXMath 같은 검증된 라이브러리를 쓰는 것이 정석이며,
아래에서 성분을 직접 펼쳐 보는 것은 원리를 이해하기 위한 학습용입니다.
0. 큰 그림 — 좌표는 왜 네 번 변신하나요?
게임 속 큐브 하나를 생각해 봅시다. 이 큐브를 만든 사람(모델러)은
큐브를 원점 (0,0,0)을 중심으로 편하게 만들어 둡니다.
이렇게 “물체 자기 자신을 기준으로 한” 좌표를 로컬 좌표(local / object space)라고 부릅니다.
원본의 kCubeVertices 배열에 들어 있는 -0.5 ~ +0.5 값들이 바로 로컬 좌표예요.
하지만 게임 월드에는 큐브가 여러 개 있고, 각자 다른 위치·회전·크기를 가집니다. 그래서 로컬 좌표를 월드 어딘가에 배치해야 합니다. 이 배치를 하는 것이 모델 행렬(Model)입니다. 그 결과가 월드 좌표(world space)죠. “모델 행렬이 물체를 월드에 놓는다”가 핵심 문장입니다.
다음으로, 우리는 이 월드를 카메라의 눈으로 봐야 합니다. 카메라가 오른쪽으로 움직이면 화면 속 세계는 왼쪽으로 밀려나 보이죠? 즉 “카메라를 옮긴다”는 “세계 전체를 그 반대로 옮긴다”와 같습니다. 이 반대 이동을 담당하는 것이 뷰 행렬(View)이고, 결과는 뷰 좌표(view / camera space)입니다. 뷰 공간에서는 카메라가 원점에 앉아 정면(+Z, 왼손 좌표계)을 바라봅니다.
마지막으로 3D를 2D 화면에 그리려면 원근(perspective)을 넣어야 합니다. 멀리 있는 것은 작게, 가까운 것은 크게요. 이걸 만드는 것이 투영 행렬(Projection)이고, 결과는 클립 좌표(clip space)입니다. GPU는 이 클립 좌표를 받아 화면 픽셀로 마무리(rasterization)합니다.
| 공간 | 무엇을 기준으로? | 이 공간으로 보내는 행렬 | 예제 속 값 |
|---|---|---|---|
| 로컬(모델) | 물체 자신의 원점 | — (시작점) | kCubeVertices (-0.5~0.5) |
| 월드 | 세계 전체의 원점 | Model (model) | 회전만 적용 (제자리 회전) |
| 뷰(카메라) | 카메라의 눈 | View (view) | +Z로 3만큼 밀기 |
| 클립 | 정규화된 절두체 | Projection (proj) | FOV 45°, 원근 |
행 벡터 규약에서는 이 변환들을 적용하는 순서대로 왼쪽에서 오른쪽으로 곱합니다.
즉 v' = v · Model · View · Projection. 그래서 CPU에서 합칠 때도
mvp = model * view * proj 순서가 됩니다. (열 벡터 규약을 쓰는 OpenGL 튜토리얼과 순서가 반대라는 점을 꼭 기억하세요.)
world라는 변수가 안 보이나요?
이 예제에서는 큐브를 월드 원점에 그대로 두고 회전만 주기 때문에,
model 행렬이 곧 “모델 → 월드” 변환 전체를 겸합니다.
즉 여기서는 Model = 모델→월드 행렬이라고 보면 됩니다.
물체를 월드의 다른 자리에 옮기고 싶으면 model에 이동(translation)을 더 곱하면 됩니다.
1. 로컬 좌표 — 큐브 정점 데이터
가장 먼저 필요한 것은 그릴 대상입니다. 여기서는 한 변이 1인 단위 큐브를, 인덱스 없이 삼각형 36개(= 12개 삼각형 × 3정점)의 위치만으로 정의합니다. GPU는 삼각형만 이해하므로, 사각형 면 하나를 두 개의 삼각형으로 쪼갭니다(6면 × 2 = 12삼각형).
- 정점 하나는
float3개(x, y, z)로 표현한다. - 면 하나 = 삼각형 2개 = 정점 6개. 6면이니 총 36정점.
- 모든 좌표는
-0.5 ~ +0.5범위 → 원점 중심의 “한 변 1”짜리 큐브.
#pragma once
// 단위 큐브: 각 면 2삼각형 × 6면 = 36정점.
// 인덱스를 쓰지 않는 간단 버전(중복 정점 허용).
static const float kCubeVertices[] = {
// 뒷면 (z = -0.5)
-0.5f,-0.5f,-0.5f, 0.5f,-0.5f,-0.5f, 0.5f, 0.5f,-0.5f,
0.5f, 0.5f,-0.5f, -0.5f, 0.5f,-0.5f, -0.5f,-0.5f,-0.5f,
// 앞면 (z = +0.5)
-0.5f,-0.5f, 0.5f, 0.5f,-0.5f, 0.5f, 0.5f, 0.5f, 0.5f,
0.5f, 0.5f, 0.5f, -0.5f, 0.5f, 0.5f, -0.5f,-0.5f, 0.5f,
// 왼쪽 면 (x = -0.5)
-0.5f, 0.5f, 0.5f, -0.5f, 0.5f,-0.5f, -0.5f,-0.5f,-0.5f,
-0.5f,-0.5f,-0.5f, -0.5f,-0.5f, 0.5f, -0.5f, 0.5f, 0.5f,
// 오른쪽 면 (x = +0.5)
0.5f, 0.5f, 0.5f, 0.5f, 0.5f,-0.5f, 0.5f,-0.5f,-0.5f,
0.5f,-0.5f,-0.5f, 0.5f,-0.5f, 0.5f, 0.5f, 0.5f, 0.5f,
// 아랫면 (y = -0.5)
-0.5f,-0.5f,-0.5f, 0.5f,-0.5f,-0.5f, 0.5f,-0.5f, 0.5f,
0.5f,-0.5f, 0.5f, -0.5f,-0.5f, 0.5f, -0.5f,-0.5f,-0.5f,
// 윗면 (y = +0.5)
-0.5f, 0.5f,-0.5f, 0.5f, 0.5f,-0.5f, 0.5f, 0.5f, 0.5f,
0.5f, 0.5f, 0.5f, -0.5f, 0.5f, 0.5f, -0.5f, 0.5f,-0.5f,
};
줄 단위 해부
#pragma once는 이 헤더가 여러 번 #include되어도 컴파일러가 딱 한 번만 읽게 하는
중복 포함 방지 지시입니다. 옛날식 #ifndef ... #define ... #endif 가드와 같은 목적이며, 훨씬 짧습니다.
static const float kCubeVertices[]를 하나씩 뜯어보면요:
static— 이 배열의 이름(링크)을 이 .cpp 파일(번역 단위) 안으로 한정합니다. 헤더에 데이터를 정의할 때static을 붙이면, 여러 .cpp가 이 헤더를 포함해도 “같은 심볼이 중복 정의됐다”는 링커 오류(ODR 위반)를 피합니다.const— “읽기 전용”. 정점 데이터는 프로그램 도중 바뀌지 않으므로 상수로 두는 게 안전합니다. 나중에D3D11_USAGE_IMMUTABLE(불변 버퍼)로 GPU에 올릴 것이라 의미가 잘 맞습니다.float— GPU가 가장 잘 다루는 32비트 부동소수점. 위치 하나에 float 3개면 12바이트.[]— 크기를 비워 두면 컴파일러가 초기화 목록의 원소 개수로 자동 계산합니다. 여기선 36정점 × 3 = 108개의 float이 되죠.
숫자들은 3개씩 한 정점, 3정점(9개 float)이 한 삼각형, 두 삼각형이 한 면입니다.
예를 들어 뒷면 첫 줄 -0.5,-0.5,-0.5 / 0.5,-0.5,-0.5 / 0.5,0.5,-0.5은
z = -0.5 평면 위에서 왼쪽아래 → 오른쪽아래 → 오른쪽위를 잇는 삼각형입니다.
수학 → 기하 직관 → 코드 매핑
수학: 각 정점은 로컬 공간의 3차원 점 (x, y, z)입니다. 아직 어떤 행렬도 곱하지 않은 “원재료”죠.
기하 직관: 큐브의 중심을 원점에 놓고, 여덟 꼭짓점이 각 축 방향으로 ±0.5만큼 뻗어 있다고 상상하세요.
한 변의 길이는 0.5 - (-0.5) = 1이라 “단위 큐브”입니다.
원점 중심이라 나중에 회전(모델 행렬)을 걸면 제자리에서 빙글빙글 돕니다.
만약 중심을 원점에서 벗어나게 만들었다면, 회전할 때 궤도를 그리며 크게 돌았을 겁니다.
코드 매핑: 이 배열은 곧 D3D11_SUBRESOURCE_DATA에 실려
CreateBuffer로 GPU 메모리(버텍스 버퍼)에 복사됩니다. 그 뒤로는 GPU가 정점 셰이더에 하나씩 흘려보냅니다.
ID3D11Buffer + DXGI_FORMAT_R16_UINT)로
정점 8개 + 인덱스 36개만 저장해 대역폭과 정점 셰이더 호출을 줄입니다.
큰 메시일수록 이 차이가 커집니다.
D3D11_CULL_BACK + 시계 방향(CW)이 앞면입니다.
큐브가 “안이 보이고 겉이 사라진다”면 컬링 방향이 뒤집힌 것이니,
래스터라이저 상태에서 FrontCounterClockwise를 뒤집거나 정점 순서를 바꿔야 합니다.
2. 셰이더 — 클립 공간으로 보내는 한 줄
정점 데이터를 GPU에 올렸으니, 각 정점을 어떻게 변환할지 알려줘야 합니다. 그 일을 하는 프로그램이 정점 셰이더(vertex shader)입니다. 여기서는 MVP를 미리 하나로 합쳐 상수 버퍼로 받아, 딱 한 번의 행렬 곱으로 로컬 좌표를 클립 좌표로 보냅니다.
cbuffer로 CPU에서 보낸mvp행렬을 받는다.- 입력 정점의 로컬 위치에
mul로 MVP를 곱한다. - 결과를
SV_Position(클립 좌표)으로 내보낸다. - 픽셀 셰이더는 단색 주황을 칠한다.
// ── 정점 셰이더 (VSMain) ──
cbuffer Transforms : register(b0)
{
float4x4 mvp; // CPU에서 model*view*proj를 합쳐 transpose 해 넘김
};
struct VSIn { float3 pos : POSITION; }; // 로컬 좌표
struct VSOut { float4 pos : SV_Position; };
VSOut VSMain(VSIn input)
{
VSOut o;
// 하나로 합친 MVP를 곱해 클립 공간으로
o.pos = mul(mvp, float4(input.pos, 1.0));
return o;
}
// ── 픽셀 셰이더 (PSMain) ──
float4 PSMain(VSOut input) : SV_Target
{
return float4(0.85, 0.55, 0.25, 1.0); // 단색 주황
}
줄 단위 해부 — cbuffer와 register
cbuffer Transforms : register(b0)는 상수 버퍼(constant buffer)를 선언합니다.
상수 버퍼는 “한 번의 그리기(draw) 동안 모든 정점이 공유하는 값”을 담는 GPU 메모리 블록입니다.
MVP 행렬은 정점마다 바뀌지 않고 프레임 전체가 같은 값을 쓰니, 딱 상수 버퍼감이죠.
Transforms— 버퍼 블록의 이름표(가독성용). HLSL 안에서 이 이름으로 다시 참조하지는 않습니다.: register(b0)— 이 상수 버퍼를 b(uffer) 슬롯 0번에 바인딩하라는 뜻. CPU 쪽VSSetConstantBuffers(0, 1, ...)의 첫 인자0과 정확히 짝이 맞아야 합니다. 번호가 어긋나면 셰이더가 엉뚱한(혹은 비어 있는) 버퍼를 읽어 물체가 사라집니다.float4x4 mvp;— 4×4 행렬 하나. float 16개 = 64바이트. 뒤에서 볼 16바이트 정렬 규칙에 딱 맞습니다.
줄 단위 해부 — 세만틱(semantic)
struct VSIn { float3 pos : POSITION; };에서 : POSITION은 세만틱이라 부르는 “꼬리표”입니다.
GPU는 변수 이름(pos)이 아니라 이 꼬리표를 보고 “아, 이 float3가 위치 데이터구나” 하고 매칭합니다.
CPU 쪽 입력 레이아웃의 "POSITION" 문자열과 이 세만틱이 짝이 맞아야 정점 데이터가 올바르게 흘러들어옵니다.
struct VSOut { float4 pos : SV_Position; };의 SV_Position은 시스템 값 세만틱(System-Value)입니다.
SV_ 접두어가 붙으면 “이건 그냥 데이터가 아니라 파이프라인이 특별하게 다루는 값”이라는 뜻이에요.
SV_Position은 “이 정점의 클립 공간 위치”를 의미하며, 래스터라이저가 이 값을 받아
원근 나눗셈과 뷰포트 변환을 거쳐 화면 픽셀 좌표로 바꿉니다. 반드시 float4여야 합니다.
픽셀 셰이더의 : SV_Target은 “이 float4가 렌더 타겟(백버퍼)에 쓸 최종 색”이라는 시스템 값 세만틱입니다.
RGBA 순서이고 각 성분은 보통 0~1 범위입니다. (0.85, 0.55, 0.25, 1.0)은 알파 1(불투명)인 주황색이죠.
핵심 한 줄: mul(mvp, float4(input.pos, 1.0))
수학: 3D 점을 행렬로 변환하려면 좌표를 4차원 동차좌표(homogeneous coordinate)로 올려야 합니다.
점(위치)은 마지막 성분 w = 1을 붙입니다. 그래서 float4(input.pos, 1.0)은
(x, y, z, 1)이 됩니다. 방향 벡터라면 w = 0을 붙여 이동에 영향받지 않게 하지만,
여기서는 “점”이라 1입니다.
w=1을 붙이면
이동까지 곱셈 한 번으로 처리됩니다. 나아가 w를 나누는 트릭이 바로 원근을 만듭니다(뒤에서 상세).
기하 직관: 이 한 줄은 로컬 공간의 점을 Model → View → Projection을 한 번에 통과시켜
클립 공간의 점으로 “순간이동”시킵니다. 세 번 곱하는 대신, CPU에서 세 행렬을 미리 하나(mvp)로 합쳐 두었기에
정점당 행렬 곱 한 번이면 끝납니다.
코드 매핑: HLSL mul(A, B)는 “A 곱하기 B”를 그대로 계산합니다.
여기서 mul(mvp, v)는 mvp를 열 벡터 v에 곱하는 형태로 동작합니다.
우리 규약은 행 벡터(v' = v · M)인데 왜 순서가 반대로 보일까요? 바로 CPU에서 넘기기 전에 transpose를 했기 때문입니다.
전치한 행렬에 mul(M^T, v)를 하면 수학적으로 v · M과 같은 결과가 나옵니다.
(이 관계는 §5의 transpose 함정 callout에서 다시 정확히 설명합니다.)
mul의 인자 순서와 transpose 여부는 한 세트입니다.
“셰이더에서 mul(mvp, v)를 쓴다 → CPU에서 XMMatrixTranspose로 전치해서 넘긴다”가
이 예제의 조합입니다. 셰이더를 mul(v, mvp)로 바꾸면 CPU의 transpose를 빼야 짝이 맞습니다.
둘 중 하나만 바꾸면 물체가 찌그러지거나 사라집니다.
3. C++ 셋업 — 버퍼·셰이더·입력 레이아웃
이제 CPU 쪽에서 GPU에 자원을 만들어 올릴 차례입니다. 버텍스 버퍼, 셰이더 객체, 입력 레이아웃, 상수 버퍼를 차례로 생성합니다. 아래 발췌에서 창 생성·디바이스/스왑체인 보일러플레이트는 개발 환경 편에서 만든 것을 그대로 쓴다고 가정합니다.
CreateBuffer로 정점 데이터를 GPU에 올린다(불변 버퍼).D3DCompile결과 바이트코드로 정점/픽셀 셰이더 객체를 만든다.- 입력 레이아웃으로 “정점 바이트를 어떻게 해석할지” 정의한다.
- 매 프레임 갱신할 상수 버퍼를
DYNAMIC으로 만든다.
#include <d3d11.h>
#include <d3dcompiler.h>
#include <DirectXMath.h>
#include <wrl/client.h>
using Microsoft::WRL::ComPtr;
using namespace DirectX;
// 최소 셰이더 컴파일 헬퍼 (전체 클래스는 4장 참고)
// D3DCompile로 HLSL 문자열 → 바이트코드
ComPtr<ID3DBlob> compileShader(const char* src, const char* entry, const char* target);
// 상수 버퍼에 넘길 구조체 (16바이트 정렬 필수)
struct CBTransforms { XMFLOAT4X4 mvp; };
int main() {
// ... Win32 창 + D3D11 디바이스/스왑체인/RTV (개발 환경 참고) ...
ComPtr<ID3D11Device> device;
ComPtr<ID3D11DeviceContext> context;
ComPtr<IDXGISwapChain> swapChain;
ComPtr<ID3D11RenderTargetView> rtv;
// (디바이스/스왑체인/RTV는 개발 환경에서 생성했다고 가정)
// 1) 버텍스 버퍼 생성 후 정점 업로드
D3D11_BUFFER_DESC vbDesc{};
vbDesc.ByteWidth = sizeof(kCubeVertices);
vbDesc.Usage = D3D11_USAGE_IMMUTABLE;
vbDesc.BindFlags = D3D11_BIND_VERTEX_BUFFER;
D3D11_SUBRESOURCE_DATA vbInit{ kCubeVertices, 0, 0 };
ComPtr<ID3D11Buffer> vbo;
device->CreateBuffer(&vbDesc, &vbInit, &vbo);
// 2) 셰이더 컴파일 + 셰이더 객체 생성
ComPtr<ID3DBlob> vsBlob = compileShader(kShaderSrc, "VSMain", "vs_5_0");
ComPtr<ID3DBlob> psBlob = compileShader(kShaderSrc, "PSMain", "ps_5_0");
ComPtr<ID3D11VertexShader> vs;
ComPtr<ID3D11PixelShader> ps;
device->CreateVertexShader(vsBlob->GetBufferPointer(), vsBlob->GetBufferSize(), nullptr, &vs);
device->CreatePixelShader (psBlob->GetBufferPointer(), psBlob->GetBufferSize(), nullptr, &ps);
// 입력 레이아웃: POSITION = float3, offset 0
D3D11_INPUT_ELEMENT_DESC layout[] = {
{ "POSITION", 0, DXGI_FORMAT_R32G32B32_FLOAT, 0, 0, D3D11_INPUT_PER_VERTEX_DATA, 0 },
};
ComPtr<ID3D11InputLayout> inputLayout;
device->CreateInputLayout(layout, 1, vsBlob->GetBufferPointer(),
vsBlob->GetBufferSize(), &inputLayout);
// 3) 상수 버퍼 생성 (매 프레임 갱신하므로 DYNAMIC + CPU write)
D3D11_BUFFER_DESC cbDesc{};
cbDesc.ByteWidth = sizeof(CBTransforms);
cbDesc.Usage = D3D11_USAGE_DYNAMIC;
cbDesc.BindFlags = D3D11_BIND_CONSTANT_BUFFER;
cbDesc.CPUAccessFlags = D3D11_CPU_ACCESS_WRITE;
ComPtr<ID3D11Buffer> cbuf;
device->CreateBuffer(&cbDesc, nullptr, &cbuf);
// 깊이 버퍼/스텐실 상태는 §4에서 (큐브가 앞뒤로 겹치므로 필요)
ComPtr<ID3D11DepthStencilView> dsv; // §4에서 생성
ComPtr<ID3D11DepthStencilState> dsState; // §4에서 생성
// 뷰포트
D3D11_VIEWPORT vp{ 0, 0, 1280.0f, 720.0f, 0.0f, 1.0f };
context->RSSetViewports(1, &vp);
인클루드와 도우미들
<d3d11.h>— 디바이스·컨텍스트·버퍼 등 D3D11 핵심 API.<d3dcompiler.h>— 런타임에 HLSL 문자열을 바이트코드로 컴파일하는D3DCompile.<DirectXMath.h>— SIMD 최적화된 벡터/행렬 수학. 우리 규약(행 벡터)과 일치.<wrl/client.h>—ComPtr스마트 포인터. COM 객체의 참조 카운트를 자동 관리해Release()를 깜빡할 일을 없애 줍니다.
using Microsoft::WRL::ComPtr;와 using namespace DirectX;는
긴 네임스페이스를 매번 안 적어도 되게 해 줍니다. ComPtr<ID3D11Buffer>처럼 쓸 수 있죠.
struct CBTransforms { XMFLOAT4X4 mvp; }; — 상수 버퍼에 정확히 이 바이트 배치로 올릴 CPU 구조체입니다.
XMFLOAT4X4는 float 16개(64바이트)를 담는 저장용 타입입니다.
64는 16의 배수라 상수 버퍼 정렬 규칙을 자동으로 만족합니다(뒤 함정 참고).
1) 버텍스 버퍼 — CreateBuffer
D3D11_BUFFER_DESC vbDesc{};의 {}는 값 초기화(value-initialization)로
구조체의 모든 멤버를 0으로 밀어 줍니다. C 구조체를 memset 없이 깔끔하게 0으로 시작하는 관용구예요.
쓰레기 값이 남아 API가 오작동하는 것을 막습니다.
ByteWidth = sizeof(kCubeVertices)— 버퍼 크기. 배열 전체 바이트 수(108 float × 4 = 432바이트).sizeof가 배열이면 원소 개수 × 원소 크기를 정확히 돌려줍니다(포인터로 붕괴되지 않은 진짜 배열이라 가능).Usage = D3D11_USAGE_IMMUTABLE— “만든 뒤 절대 안 바뀜”. 가장 빠른 GPU 전용 메모리에 놓이고, 생성 시점에 초기 데이터를 반드시 함께 줘야 합니다.BindFlags = D3D11_BIND_VERTEX_BUFFER— 이 버퍼를 정점 입력 용도로 쓸 것이라 표시.
D3D11_SUBRESOURCE_DATA vbInit{ kCubeVertices, 0, 0 };는 초기 데이터입니다.
첫 필드 pSysMem은 “복사해 올릴 CPU 메모리의 시작 주소”(= 배열 포인터), 나머지 두 값은
2D/3D 텍스처에서만 쓰는 행/깊이 피치라 버퍼에서는 0입니다.
device->CreateBuffer(&vbDesc, &vbInit, &vbo) — 서술자와 초기 데이터를 넘겨 GPU 버퍼를 만들고,
결과 인터페이스 포인터를 vbo에 채웁니다. &vbo는 ComPtr이 내부적으로
받을 주소를 넘기는 방식입니다.
2) 셰이더 객체 — 바이트코드에서 만들기
compileShader(kShaderSrc, "VSMain", "vs_5_0")는 §2의 HLSL 문자열을 컴파일해
바이트코드 blob(ID3DBlob)을 돌려줍니다.
두 번째 인자 "VSMain"은 진입 함수 이름(셰이더의 main에 해당),
세 번째 "vs_5_0"은 셰이더 모델/프로파일(vs = vertex shader, 5.0 = Shader Model 5)입니다.
픽셀 셰이더는 "PSMain", "ps_5_0"으로 같은 소스에서 다른 함수를 뽑아냅니다.
CreateVertexShader(포인터, 크기, nullptr, &vs) — 컴파일된 바이트코드에서 실제 셰이더 객체를 만듭니다.
GetBufferPointer()/GetBufferSize()는 blob 안의 바이트코드 시작 주소와 길이입니다.
세 번째 인자 nullptr은 클래스 링키지(고급 기능)를 안 쓴다는 뜻입니다.
입력 레이아웃 — 바이트를 정점으로 해석하는 설명서
버텍스 버퍼는 GPU 입장에서 “그냥 바이트 덩어리”입니다.
“처음 12바이트가 POSITION float3, 그다음이…” 하는 해석 규칙을 알려주는 것이 입력 레이아웃입니다.
D3D11_INPUT_ELEMENT_DESC 한 줄을 필드별로 보면:
"POSITION"— 세만틱 이름. §2 셰이더의: POSITION과 문자열이 정확히 일치해야 연결됩니다.0— 세만틱 인덱스.POSITION0,POSITION1처럼 같은 세만틱을 여러 개 쓸 때 구분용. 여기선 0.DXGI_FORMAT_R32G32B32_FLOAT— “32비트 float 3개” = float3. 정점당 12바이트를 이 형식으로 읽습니다.0— 입력 슬롯 번호. 버텍스 버퍼를 여러 개 묶을 때 쓰는데, 여기선 0번 슬롯 하나뿐.0— 바이트 오프셋. 이 속성이 정점 시작에서 몇 바이트 뒤에 있는지. POSITION은 맨 앞이라 0. (D3D11_APPEND_ALIGNED_ELEMENT를 쓰면 자동 누적 계산도 가능합니다.)D3D11_INPUT_PER_VERTEX_DATA— 정점마다 하나씩 읽는 데이터(인스턴싱이면 per-instance).- 마지막
0— 인스턴스 데이터 스텝(per-vertex라 0).
CreateInputLayout(layout, 1, 바이트코드, 크기, &inputLayout)은
이 레이아웃이 정점 셰이더 입력 시그니처와 맞는지 검증하려고 정점 셰이더 바이트코드를 함께 받습니다.
두 번째 인자 1은 레이아웃 원소 개수(여기선 POSITION 하나)입니다.
3) 상수 버퍼 — 매 프레임 갱신용
MVP는 매 프레임 바뀌므로, 버텍스 버퍼와 달리 CPU가 자주 덮어쓸 수 있는 설정으로 만듭니다.
ByteWidth = sizeof(CBTransforms)— 64바이트(XMFLOAT4X4하나). 16의 배수라 OK.Usage = D3D11_USAGE_DYNAMIC— CPU가 자주 쓰고 GPU가 읽는 버퍼.BindFlags = D3D11_BIND_CONSTANT_BUFFER— 상수 버퍼 용도.CPUAccessFlags = D3D11_CPU_ACCESS_WRITE— CPU가Map으로 쓸 수 있게 허용.
CreateBuffer(&cbDesc, nullptr, &cbuf)에서 두 번째 인자가 nullptr인 이유는
매 프레임 어차피 새로 채울 것이라 초기 데이터가 필요 없기 때문입니다.
뷰포트
D3D11_VIEWPORT vp{ 0, 0, 1280.0f, 720.0f, 0.0f, 1.0f }는
클립→화면 변환의 최종 목적지를 정의합니다: 좌상단 (0,0)에서 폭 1280·높이 720픽셀,
깊이는 0.0~1.0 범위입니다. 이 깊이 범위 [0,1]이 우리 규약의 클립 z 범위와 일치합니다(D3D 표준).
RSSetViewports(1, &vp)로 래스터라이저 단계에 등록합니다.
4. 렌더 루프 — 세 행렬을 만들어 업로드
드디어 이 페이지의 심장입니다. 매 프레임 Model·View·Projection 세 행렬을 만들고,
하나로 합쳐(mvp), transpose해서 상수 버퍼로 올린 뒤, 파이프라인을 바인딩하고 그립니다.
MSG msg{};
while (msg.message != WM_QUIT) {
if (PeekMessage(&msg, nullptr, 0, 0, PM_REMOVE)) {
TranslateMessage(&msg); DispatchMessage(&msg); continue;
}
const float clearColor[4] = { 0.06f, 0.07f, 0.10f, 1.0f };
context->ClearRenderTargetView(rtv.Get(), clearColor);
context->ClearDepthStencilView(dsv.Get(), D3D11_CLEAR_DEPTH, 1.0f, 0);
context->OMSetRenderTargets(1, rtv.GetAddressOf(), dsv.Get());
context->OMSetDepthStencilState(dsState.Get(), 0);
// 매 프레임 세 행렬을 새로 만든다 (DirectXMath = 행 벡터)
float t = getTimeSeconds();
XMMATRIX model = XMMatrixRotationRollPitchYaw(t * 0.4f, t, t * 0.2f);
// 카메라를 z축 +3만큼 뒤로 = 세계를 -3만큼 당김 (왼손 좌표계)
XMMATRIX view = XMMatrixTranslation(0.0f, 0.0f, 3.0f);
XMMATRIX proj = XMMatrixPerspectiveFovLH(XMConvertToRadians(45.0f),
1280.0f / 720.0f, 0.1f, 100.0f);
// 행 벡터라 model*view*proj 순서. 셰이더로 넘기기 전 transpose.
XMMATRIX mvp = model * view * proj;
D3D11_MAPPED_SUBRESOURCE mapped;
context->Map(cbuf.Get(), 0, D3D11_MAP_WRITE_DISCARD, 0, &mapped);
XMStoreFloat4x4(&((CBTransforms*)mapped.pData)->mvp, XMMatrixTranspose(mvp));
context->Unmap(cbuf.Get(), 0);
// 파이프라인 바인딩
UINT stride = 3 * sizeof(float), offset = 0;
context->IASetInputLayout(inputLayout.Get());
context->IASetVertexBuffers(0, 1, vbo.GetAddressOf(), &stride, &offset);
context->IASetPrimitiveTopology(D3D11_PRIMITIVE_TOPOLOGY_TRIANGLELIST);
context->VSSetShader(vs.Get(), nullptr, 0);
context->PSSetShader(ps.Get(), nullptr, 0);
context->VSSetConstantBuffers(0, 1, cbuf.GetAddressOf());
context->Draw(36, 0); // 4) 그리기 (정점 36개)
swapChain->Present(1, 0);
}
// ... ComPtr가 자동 해제 ...
}
메시지 펌프와 화면 지우기
while (msg.message != WM_QUIT) 루프 안에서 PeekMessage(..., PM_REMOVE)는
Windows 메시지 큐를 막지 않고(non-blocking) 확인합니다. 메시지가 있으면 처리(continue),
없으면 아래로 내려가 렌더링합니다. 이렇게 해야 창을 조작하면서도 매 프레임 그림을 그릴 수 있습니다.
ClearRenderTargetView(rtv, clearColor)는 백버퍼를 어두운 남색으로 칠해 지웁니다.
ClearDepthStencilView(dsv, D3D11_CLEAR_DEPTH, 1.0f, 0)는 깊이 버퍼를 1.0(가장 먼 값)으로 초기화합니다.
깊이 [0,1] 규약에서 1.0은 “무한히 멀다”는 뜻이라, 첫 픽셀은 무엇이든 이보다 가까워 통과합니다.
OMSetRenderTargets는 “그림을 어디에 그릴지(백버퍼)와 깊이 판정을 어디서 할지(깊이 버퍼)”를 출력 병합기(OM)에 묶습니다.
GetAddressOf()는 ComPtr이 들고 있는 원시 포인터의 주소를 넘기는 방법이라,
API가 “포인터 배열”을 요구할 때 씁니다(Get()은 포인터 자체를 반환).
Model 행렬 — XMMatrixRotationRollPitchYaw
무엇을 만드나: 큐브를 제자리에서 회전시키는 모델 행렬입니다.
t가 시간(초)이라, 매 프레임 각도가 커지며 큐브가 부드럽게 돕니다.
수학: 인자는 순서대로 (pitch, yaw, roll) = (X축 회전, Y축 회전, Z축 회전) 라디안입니다.
세 축 회전을 합성한 하나의 회전 행렬을 만듭니다. t*0.4, t, t*0.2로
축마다 회전 속도를 다르게 줘서 단조롭지 않게 굴립니다.
기하 직관: pitch는 “고개를 위아래로 끄덕(X축 중심)”, yaw는 “좌우로 도리도리(Y축 중심)”, roll은 “머리를 갸웃(Z축 중심)”이라고 상상하세요. 큐브 중심이 원점이라 궤도를 그리지 않고 제자리에서 회전합니다.
손계산 예: t = 0이면 세 각이 모두 0이라 단위 행렬(회전 없음)이 됩니다.
큐브는 처음엔 정면을 보다가 시간이 흐르며 기울기 시작합니다.
또 t가 π(≈3.14초)일 때 yaw가 180°라 큐브의 앞뒤가 뒤바뀝니다.
코드 매핑: DirectXMath 함수 하나로 세 축 회전을 다 처리합니다. 직접 짠다면 X·Y·Z 회전 행렬 세 개를 곱해야 하는데, 라이브러리가 최적화된 형태로 대신 해 줍니다.
View 행렬 — XMMatrixTranslation(0, 0, 3)
무엇을 만드나: 카메라 기준으로 세계를 옮기는 뷰 행렬입니다. 여기서는 회전 없이 +Z 방향으로 3만큼 이동만 합니다.
수학: 이동 행렬은 4×4 행렬의 마지막 행(행 우선·행 벡터 규약)에 이동량 (0, 0, 3)을 넣은 형태입니다.
점 (x, y, z, 1)에 곱하면 (x, y, z+3, 1)이 됩니다. z에 +3이 더해지죠.
손계산 예: 큐브 앞면의 한 점 (0, 0, 0.5)는 뷰 변환 뒤 (0, 0, 3.5)가 됩니다.
near 평면(0.1)보다 멀고 far(100)보다 가까우니 절두체 안이라 화면에 보입니다.
만약 이동을 안 줬다면 큐브가 카메라(원점)와 겹쳐 있어 near 평면에 잘려 아무것도 안 보였을 겁니다.
코드 매핑: 실전 카메라(위치·회전 자유)라면 XMMatrixLookAtLH(eye, target, up)을 씁니다
(다음 페이지 “카메라”에서 상세). 이 예제는 “고정 카메라”라 이동 하나면 충분합니다.
Projection 행렬 — XMMatrixPerspectiveFovLH
무엇을 만드나: 3D를 원근감 있게 클립 공간으로 보내는 투영 행렬. LH = 왼손 좌표계용입니다.
인자 4개를 하나씩 볼게요:
XMConvertToRadians(45.0f)— 수직 시야각(FOV). 45°는 사람이 자연스럽게 느끼는 화각입니다. DirectXMath는 라디안을 받으므로 도(degree)를 변환해 넘깁니다. 값이 클수록 “광각 렌즈”처럼 더 넓게, 원근 왜곡이 커집니다.1280.0f / 720.0f— 종횡비(aspect ratio) = 화면 폭 ÷ 높이 ≈ 1.778. 이걸 정확히 주지 않으면 큐브가 가로/세로로 늘어나 보입니다. 반드시float나눗셈이어야 합니다(정수1280/720은 1이 되어 버림).0.1f— near 평면. 이보다 가까운 것은 잘려 안 보입니다.100.0f— far 평면. 이보다 먼 것은 안 보입니다.
w 성분에 뷰 공간 z를 넣는 데 있습니다.
정점 셰이더가 내보낸 클립 좌표 (x, y, z, w)를, GPU가 래스터화 직전에
x, y, z를 각각 w로 나눕니다(원근 나눗셈, perspective divide).
멀리 있는 점일수록 w가 커서 나눗셈 결과가 작아지고 → 화면 중앙 쪽으로 모여 작게 보입니다.
이것이 “멀면 작게”의 수학적 정체입니다.
기하 직관: 카메라 앞에 놓인 절두체(frustum)—앞이 좁고 뒤가 넓은 잘린 피라미드—를 상상하세요. near·far·FOV가 이 피라미드의 크기를 정합니다. 투영 행렬은 이 피라미드를 정규화된 상자(x,y ∈ [-1,1], z ∈ [0,1])로 “쫙 펴” 담습니다. 그 과정에서 원근이 생깁니다.
코드 매핑: ...LH 접미어가 왼손 좌표계 + z 범위 [0,1]을 만들어 우리 규약과 정확히 일치시킵니다.
OpenGL식 ...RH(오른손, z ∈ [-1,1])를 쓰면 깊이가 반대로 뒤집혀 물체가 사라지니 주의하세요.
합성과 순서 — model * view * proj
수학: 행 벡터 규약에서 점의 변환은 v' = v · M · V · P입니다.
결합법칙 덕분에 세 행렬을 미리 하나로 곱해도 결과가 같습니다: MVP = M · V · P.
코드의 model * view * proj가 바로 이것입니다.
proj * view * model로 곱하면(열 벡터 튜토리얼을 그대로 베끼면)
완전히 다른 행렬이 나옵니다. 행렬 곱은 교환법칙이 성립하지 않기 때문입니다.
행 벡터 규약에서는 “먼저 적용할 변환이 왼쪽”—Model부터 씁니다.
업로드 — Map / transpose / Unmap
Map(cbuf, 0, D3D11_MAP_WRITE_DISCARD, 0, &mapped)는 GPU 버퍼를 CPU 주소 공간에 잠깐 매핑해
쓸 수 있는 포인터(mapped.pData)를 줍니다. WRITE_DISCARD는
“이전 내용은 버리고 새 메모리를 달라”는 뜻이라 GPU가 아직 그 버퍼를 읽는 중이어도 멈추지 않습니다(퍼포먼스 핵심).
XMStoreFloat4x4(&((CBTransforms*)mapped.pData)->mvp, XMMatrixTranspose(mvp))는 두 가지를 한 번에 합니다:
XMMatrixTranspose(mvp)— 행렬을 전치(행↔열 교환)합니다. §2에서 예고한 그 transpose입니다.XMStoreFloat4x4— SIMD 레지스터에 있던XMMATRIX를 메모리 저장용XMFLOAT4X4로 복사해 매핑된 상수 버퍼에 씁니다.(CBTransforms*)mapped.pData로 형변환해mvp멤버 위치를 정확히 가리킵니다.
Unmap(cbuf, 0)은 매핑을 해제해 GPU가 다시 그 버퍼를 쓸 수 있게 돌려줍니다.
Map/Unmap은 반드시 짝으로 호출해야 합니다.
XMMatrixTranspose를 하세요.
DirectXMath는 행 우선(row-major)으로 저장하지만 HLSL의 float4x4는 기본이 열 우선(column-major)이라,
transpose 없이 넘기면 셰이더가 행/열을 뒤바꿔 읽어 mul 결과가 엉뚱해지고 물체가 찌그러지거나 사라집니다.
대안: 셰이더 쪽에서 mul(v, mvp)로 순서를 바꾸거나, HLSL 선언에 row_major float4x4 mvp;를 붙이면
CPU transpose를 생략할 수 있습니다. (이 예제는 “셰이더 mul(mvp,v) + CPU transpose” 조합입니다.)
ByteWidth는 16바이트의 배수여야 합니다.
float4x4는 64바이트라 딱 맞지만, 작은 스칼라를 섞을 때는 패딩을 신경 써야
값이 조용히 어긋나지 않습니다. 예를 들어 float4x4 mvp; float time;를 담으면
HLSL은 다음 필드를 16바이트 경계에 정렬하므로 CPU 구조체에도 float pad[3]; 같은 패딩을 맞춰야 합니다.
D3D11_USAGE_DYNAMIC + Map(WRITE_DISCARD)가 표준입니다.
드라이버가 이전 버퍼를 버리고 새 메모리를 줘서 GPU 파이프라인이 멈추지(stall) 않습니다.
거의 안 바뀌는 데이터라면 UpdateSubresource가 더 간단합니다.
파이프라인 바인딩과 그리기
UINT stride = 3 * sizeof(float) — 한 정점의 바이트 크기(12바이트). GPU가 버퍼에서 정점을 몇 바이트씩 건너뛸지 알려줍니다.
offset = 0은 버퍼 시작부터 읽으라는 뜻입니다.
IASetInputLayout— 입력 조립기(IA)에 “바이트 해석 규칙”을 건다.IASetVertexBuffers(0, 1, ..., &stride, &offset)— 0번 슬롯에 버텍스 버퍼 1개를 건다. stride/offset도 함께.IASetPrimitiveTopology(...TRIANGLELIST)— “정점 3개마다 삼각형 하나”로 해석하라. (다른 옵션: 스트립·라인 등)VSSetShader/PSSetShader— 정점/픽셀 셰이더를 파이프라인에 세팅.VSSetConstantBuffers(0, 1, ...)— 정점 셰이더의 b0 슬롯에 상수 버퍼를 건다. 이 첫 인자 0이 §2의register(b0)와 짝이 맞아야 합니다.
Draw(36, 0) — 정점 0번부터 36개를 그립니다(인덱스 없는 큐브라 36 = 총 정점 수).
이 호출이 파이프라인을 돌려 큐브가 백버퍼에 그려집니다.
Present(1, 0) — 다 그린 백버퍼를 화면에 내보냅니다. 첫 인자 1은 수직 동기화(VSync) 1프레임 대기라
화면 찢김(tearing)을 막습니다(0이면 즉시, 최대 FPS).
루프가 끝나면 ComPtr들이 자동으로 Release()를 호출해 모든 D3D 자원을 해제합니다.
수동 해제 코드가 필요 없는 것이 ComPtr의 큰 장점입니다.
5. DirectXMath / D3D11 API 대응 정리
이 페이지에서 쓴 라이브러리 함수와 “그것이 수학적으로 하는 일”을 한눈에 정리합니다. 직접 구현한다면 오른쪽 열을 손으로 짜야 하지만, 실무에서는 왼쪽(DirectXMath)을 쓰는 것이 정석입니다.
| DirectXMath / D3D11 | 하는 일 (수학·역할) |
|---|---|
XMMatrixRotationRollPitchYaw(p,y,r) | X·Y·Z 세 축 회전을 합성한 회전 행렬 → Model |
XMMatrixTranslation(x,y,z) | 이동 행렬 → 여기서는 뷰(카메라 역이동)로 사용 |
XMMatrixPerspectiveFovLH(fov,ar,n,f) | 왼손 좌표계·z[0,1] 원근 투영 행렬 → Projection |
XMConvertToRadians(deg) | 도 → 라디안 변환(FOV 인자용) |
연산자 * (XMMATRIX) | 행렬 곱. 행 벡터라 model*view*proj 순서 |
XMMatrixTranspose(m) | 행↔열 교환. HLSL 열 우선 기본과 맞추려 업로드 전 필수 |
XMStoreFloat4x4(&dst, m) | SIMD XMMATRIX → 저장용 XMFLOAT4X4 복사 |
XMLoadFloat4x4 / XMLoadFloat3 | 저장용 타입 → SIMD 레지스터로 로드(반대 방향) |
ID3D11Device::CreateBuffer | 버텍스/상수 버퍼 등 GPU 메모리 자원 생성 |
CreateInputLayout | 정점 바이트 → 셰이더 입력 해석 규칙 등록·검증 |
Map / Unmap (WRITE_DISCARD) | 동적 상수 버퍼를 stall 없이 CPU에서 갱신 |
mul(A, B) (HLSL) | 행렬·벡터 곱. transpose 여부와 인자 순서가 한 세트 |
model → view → proj,
② 업로드 전 transpose는 셰이더 mul(mvp,v) 조합과 한 세트,
③ 원근은 투영 행렬이 심어 둔 w를 GPU가 나눠서 만든다.
이 셋만 몸에 익히면 3D 파이프라인의 좌표 여행 전체가 손에 잡힙니다.