← 개념 · 데모 가이드 | 구현 허브 | 3장. 3D 그래픽스 | Model → World → View → Projection
IMPLEMENTATION · CHAPTER 3

Model → World → View → Projection — 한 줄 한 줄 직접 구현

3D 점 하나가 화면 위 픽셀이 되기까지, 좌표는 네 개의 공간을 지납니다. 로컬(모델) 공간 → 월드 공간 → 뷰(카메라) 공간 → 클립 공간이죠. 이 여행을 담당하는 세 행렬이 Model · View · Projection입니다. 이 페이지에서는 원본 큐브 예제의 정점 데이터·셰이더·렌더 루프를 한 줄, 한 성분씩 뜯어보며, 각 행렬이 수학적으로 무엇을 하는지기하학적으로 어떻게 상상하면 되는지코드의 어느 부분에 대응하는지를 3단으로 설명합니다. DirectXMath와 D3D11 API 함수 하나하나를 원본과 1:1로 짚어 드립니다.

규약 이 가이드 전체는 행 벡터(row-vector) 규약을 씁니다. 점을 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”가 표에 있는데 원본 코드엔 왜 world라는 변수가 안 보이나요? 이 예제에서는 큐브를 월드 원점에 그대로 두고 회전만 주기 때문에, model 행렬이 곧 “모델 → 월드” 변환 전체를 겸합니다. 즉 여기서는 Model = 모델→월드 행렬이라고 보면 됩니다. 물체를 월드의 다른 자리에 옮기고 싶으면 model에 이동(translation)을 더 곱하면 됩니다.

1. 로컬 좌표 — 큐브 정점 데이터

가장 먼저 필요한 것은 그릴 대상입니다. 여기서는 한 변이 1인 단위 큐브를, 인덱스 없이 삼각형 36개(= 12개 삼각형 × 3정점)의 위치만으로 정의합니다. GPU는 삼각형만 이해하므로, 사각형 면 하나를 두 개의 삼각형으로 쪼갭니다(6면 × 2 = 12삼각형).

  1. 정점 하나는 float 3개(x, y, z)로 표현한다.
  2. 면 하나 = 삼각형 2개 = 정점 6개. 6면이니 총 36정점.
  3. 모든 좌표는 -0.5 ~ +0.5 범위 → 원점 중심의 “한 변 1”짜리 큐브.
src/cube.h — 큐브 정점 (위치 xyz)cpp
#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[]를 하나씩 뜯어보면요:

숫자들은 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가 정점 셰이더에 하나씩 흘려보냅니다.

성능 인덱스를 안 쓰니 꼭짓점 8개면 될 것이 36개로 중복 저장됩니다(큐브의 각 꼭짓점이 여러 면에서 반복). 학습용으로는 단순해서 좋지만, 실전에서는 인덱스 버퍼(ID3D11Buffer + DXGI_FORMAT_R16_UINT)로 정점 8개 + 인덱스 36개만 저장해 대역폭과 정점 셰이더 호출을 줄입니다. 큰 메시일수록 이 차이가 커집니다.
함정 삼각형이 화면에 보이려면 정점을 도는 방향(winding order)이 백페이스 컬링 설정과 맞아야 합니다. D3D11 기본은 D3D11_CULL_BACK + 시계 방향(CW)이 앞면입니다. 큐브가 “안이 보이고 겉이 사라진다”면 컬링 방향이 뒤집힌 것이니, 래스터라이저 상태에서 FrontCounterClockwise를 뒤집거나 정점 순서를 바꿔야 합니다.

2. 셰이더 — 클립 공간으로 보내는 한 줄

정점 데이터를 GPU에 올렸으니, 각 정점을 어떻게 변환할지 알려줘야 합니다. 그 일을 하는 프로그램이 정점 셰이더(vertex shader)입니다. 여기서는 MVP를 미리 하나로 합쳐 상수 버퍼로 받아, 딱 한 번의 행렬 곱으로 로컬 좌표를 클립 좌표로 보냅니다.

  1. cbuffer로 CPU에서 보낸 mvp 행렬을 받는다.
  2. 입력 정점의 로컬 위치에 mul로 MVP를 곱한다.
  3. 결과를 SV_Position(클립 좌표)으로 내보낸다.
  4. 픽셀 셰이더는 단색 주황을 칠한다.
셰이더 (인라인 문자열) — 최소 MVPHLSL
// ── 정점 셰이더 (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 행렬은 정점마다 바뀌지 않고 프레임 전체가 같은 값을 쓰니, 딱 상수 버퍼감이죠.

줄 단위 해부 — 세만틱(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입니다.

왜 4차원인가? 3×3 행렬로는 회전·크기는 표현해도 이동(translation)은 표현할 수 없습니다(원점은 항상 원점으로 감). 한 차원을 더 얹은 4×4 행렬의 마지막 행에 이동량을 넣고, 점에 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에 자원을 만들어 올릴 차례입니다. 버텍스 버퍼, 셰이더 객체, 입력 레이아웃, 상수 버퍼를 차례로 생성합니다. 아래 발췌에서 창 생성·디바이스/스왑체인 보일러플레이트는 개발 환경 편에서 만든 것을 그대로 쓴다고 가정합니다.

  1. CreateBuffer로 정점 데이터를 GPU에 올린다(불변 버퍼).
  2. D3DCompile 결과 바이트코드로 정점/픽셀 셰이더 객체를 만든다.
  3. 입력 레이아웃으로 “정점 바이트를 어떻게 해석할지” 정의한다.
  4. 매 프레임 갱신할 상수 버퍼를 DYNAMIC으로 만든다.
src/main.cpp — 셋업 + 렌더 루프 (발췌)cpp
#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);

인클루드와 도우미들

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가 오작동하는 것을 막습니다.

D3D11_SUBRESOURCE_DATA vbInit{ kCubeVertices, 0, 0 };는 초기 데이터입니다. 첫 필드 pSysMem은 “복사해 올릴 CPU 메모리의 시작 주소”(= 배열 포인터), 나머지 두 값은 2D/3D 텍스처에서만 쓰는 행/깊이 피치라 버퍼에서는 0입니다.

device->CreateBuffer(&vbDesc, &vbInit, &vbo) — 서술자와 초기 데이터를 넘겨 GPU 버퍼를 만들고, 결과 인터페이스 포인터를 vbo에 채웁니다. &vboComPtr이 내부적으로 받을 주소를 넘기는 방식입니다.

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 한 줄을 필드별로 보면:

CreateInputLayout(layout, 1, 바이트코드, 크기, &inputLayout)은 이 레이아웃이 정점 셰이더 입력 시그니처와 맞는지 검증하려고 정점 셰이더 바이트코드를 함께 받습니다. 두 번째 인자 1은 레이아웃 원소 개수(여기선 POSITION 하나)입니다.

3) 상수 버퍼 — 매 프레임 갱신용

MVP는 매 프레임 바뀌므로, 버텍스 버퍼와 달리 CPU가 자주 덮어쓸 수 있는 설정으로 만듭니다.

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해서 상수 버퍼로 올린 뒤, 파이프라인을 바인딩하고 그립니다.

src/main.cpp — 렌더 루프 (계속)cpp
    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만큼 이동만 합니다.

핵심 직관: 카메라를 뒤로 = 세계를 앞으로 우리는 카메라를 큐브에서 뒤로 3만큼 떨어뜨리고 싶습니다. 그런데 뷰 행렬은 “카메라 이동의 ”이라, 카메라를 -Z로 3 물리는 대신 세계 전체를 +Z로 3 밀어 같은 효과를 냅니다. 왼손 좌표계에서 카메라는 +Z를 바라보므로, 세계를 +Z로 밀면 큐브가 “카메라 앞쪽 멀리”로 가서 화면에 담깁니다.

수학: 이동 행렬은 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개를 하나씩 볼게요:

원근은 어떻게 만들어지나 (w 나눗셈) 투영 행렬의 마법은 결과 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))는 두 가지를 한 번에 합니다:

  1. XMMatrixTranspose(mvp) — 행렬을 전치(행↔열 교환)합니다. §2에서 예고한 그 transpose입니다.
  2. 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” 조합입니다.)
함정 상수 버퍼의 ByteWidth16바이트의 배수여야 합니다. 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은 버퍼 시작부터 읽으라는 뜻입니다.

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 파이프라인의 좌표 여행 전체가 손에 잡힙니다.