← 개념 · 데모 가이드 | 구현 허브 | 1장. 기초 수학 | 행렬 — Mat4
IMPLEMENTATION · CHAPTER 1

행렬 — Mat4 — 한 줄 한 줄 직접 구현

벡터가 점 하나라면, 행렬은 공간 전체를 한 번에 바꾸는 규칙입니다. 이동·회전·크기·투영이 전부 4×4 행렬 하나로 표현되고, 정점 하나를 그 행렬에 곱하면 화면 위 어디로 갈지 결정됩니다. 이 페이지에서는 4×4 행렬을 float 16개로 저장하는 Mat4 구조체를 만들고, 항등행렬 · 행렬 곱 · 전치를 성분 하나까지 손으로 따라가며, 마지막으로 이 행렬을 GPU 상수 버퍼(cbuffer)에 올릴 때 왜 전치해야 하는지까지 파헤칩니다.

규약 이 가이드는 D3D 규약으로 통일합니다: 행벡터 v' = v * M(HLSL mul(v, M)), 행렬은 행 우선(row-major) 저장, 왼손 좌표계, 클립 공간 z 범위 [0, 1]. OpenGL/GLM(열벡터, M * v)과는 곱 순서가 반대이니 다른 자료의 곱을 그대로 옮기지 마세요.

무엇을, 왜 만드나

무엇을: 4×4 실수 행렬을 행 우선(row-major)으로 저장하는 구조체 Mat4와, 그 위에서 동작하는 항등행렬 생성 · 행렬 곱셈 · 전치 세 가지 연산을 만듭니다.

왜: 그래픽스에서 하나의 정점은 보통 모델→월드→뷰→투영이라는 여러 단계를 거칩니다. 각 단계가 행렬 하나이고, 이들을 미리 곱해 하나의 MVP 행렬로 합쳐 두면 정점마다 곱셈 한 번으로 끝납니다. 그래서 행렬 곱이 핵심입니다. 또 저장 순서를 행 우선으로 잡은 이유는 DirectXMath의 XMFLOAT4X4메모리 배치를 똑같이 맞춰, 나중에 상수 버퍼로 올릴 때 memcpy 한 번으로 재사용하기 위함입니다.

  1. 저장 구조float m[16] 하나로 4×4를 표현하고, (행 r, 열 c)m[r*4+c]로 접근합니다.
  2. 접근자 at(r,c) — 2차원 좌표를 1차원 인덱스로 바꿔 주는 통로. 읽기용/쓰기용 두 버전.
  3. 항등행렬 identity() — 대각선만 1, 나머지 0. "아무것도 안 바꾸는" 곱셈의 기준점.
  4. 행렬 곱 operator*C(r,c) = Σ A(r,k)·B(k,c). 3중 루프로 16개 성분을 채웁니다.
  5. 전치 transpose — 행과 열을 뒤바꿔 (r,c) ↔ (c,r). cbuffer 업로드용.
  6. cbuffer 업로드 — 전치한 행렬을 GPU로 복사하고, 전치를 빠뜨렸을 때 생기는 대표적 버그를 짚습니다.

1. 4×4를 float 16개로 — 저장 구조와 인덱싱

수학에서 4×4 행렬은 4행 4열, 총 16개의 수입니다. 하지만 C++에서 2차원 배열(float m[4][4]) 대신 1차원 배열 float m[16]을 씁니다. 메모리는 어차피 일렬로 이어진 바이트 뭉치라, 16칸을 한 줄로 늘어놓고 "몇 번째 칸이 몇 행 몇 열인지"를 우리가 정하면 됩니다. 그 규칙이 바로 행 우선입니다.

math/Mat4.h — 저장 구조와 접근자cpp
#pragma once
// 메모리: [row0(4개), row1(4개), row2(4개), row3(4개)]가 일렬로 붙어 있음.
// (행 r, 열 c) 성분의 위치 = m[r*4 + c]
struct Mat4 {
    float m[16] = { 0 };                          // 16칸 전부 0으로 시작
    float& at(int r, int c) { return m[r * 4 + c]; }             // 쓰기/읽기용
    const float& at(int r, int c) const { return m[r * 4 + c]; } // 읽기 전용
};

한 줄씩 해부

#pragma once — 이 헤더가 여러 .cpp에서 #include돼도 딱 한 번만 펼쳐지게 하는 중복 포함 방지 지시입니다. 같은 구조체를 두 번 정의하면 컴파일 오류가 나기 때문에 헤더 맨 위의 관례입니다.

struct Mat4 { ... };structclass와 거의 같지만 기본 접근이 public이라, 수학용 값 타입처럼 멤버를 자유롭게 읽고 쓰는 자료구조에 적합합니다.

float m[16] = { 0 }; — 4×4=16개의 실수를 담는 배열입니다. = { 0 }집합 초기화(aggregate initialization)로, 첫 원소를 0으로 두면 나머지 15칸도 자동으로 0이 됩니다. 즉 Mat4를 만들면 영행렬(모두 0)로 시작합니다. 이 "기본이 0"이라는 사실은 뒤에서 함정으로 다시 등장하니 기억해 두세요.

함정 = { 0 }를 빼면 m초기화되지 않은 쓰레기 값으로 남습니다. 행렬 연산 결과가 실행할 때마다 달라지는 유령 버그의 원인이 되니, 값 타입 구조체는 항상 기본값을 명시하세요.

인덱싱 m[r*4 + c]가 왜 이 공식인가

행 우선이란 "한 행을 통째로 먼저 저장하고, 그다음 행을 이어 붙인다"는 뜻입니다. 그러면 r번째 행은 앞에 이미 r개의 행(각 4칸)이 놓여 있으니 r*4칸을 건너뛴 자리에서 시작하고, 그 행 안에서 c번째 열이니 +c를 더합니다. 그래서 m[r*4 + c]입니다.

메모리 한 줄을 그림글로 그리면 이렇습니다(인덱스 0~15):

행 우선(row-major) 메모리 배치memory
index :  0    1    2    3  | 4    5    6    7  | 8    9   10   11 | 12   13   14   15
(r,c) : (0,0)(0,1)(0,2)(0,3)|(1,0)(1,1)(1,2)(1,3)|(2,0)(2,1)(2,2)(2,3)|(3,0)(3,1)(3,2)(3,3)
행    :  <-------- row0 ------->|<-------- row1 ------->|<-------- row2 ------->|<-------- row3 ------->

예) at(2,1) -> m[2*4 + 1] = m[9]   // 3번째 행(r=2), 2번째 열(c=1)
    at(0,3) -> m[0*4 + 3] = m[3]
    at(3,3) -> m[3*4 + 3] = m[15]  // 오른쪽 맨 아래 구석
행 우선 vs 열 우선 같은 16개 수라도 메모리에 늘어놓는 순서가 다릅니다. 행 우선(우리 방식)은 row0, row1, row2, row3 순. 열 우선(column-major)col0, col1, col2, col3 순으로, 같은 논리적 성분 (r,c)m[c*4 + r]에 놓입니다. 수학적으로 같은 행렬이라도 디스크/버스로 보낼 때의 바이트 순서가 달라, GPU에 넘길 때 이 차이가 화면 결과를 바꿉니다(6번 항목).

접근자 at이 두 개인 이유 — const 오버로드

float& at(int r, int c)는 성분에 대한 참조(&)를 돌려줍니다. 참조를 돌려주니 M.at(0,0) = 1;처럼 왼쪽에 두고 값을 대입할 수 있습니다(쓰기 가능).

두 번째 const float& at(int r, int c) const는 함수 끝에 const가 붙어 "이 함수는 Mat4 자신을 건드리지 않는다"고 약속합니다. 그래서 const Mat4&로 받은 읽기 전용 행렬에서도 성분을 꺼내 볼 수 있습니다. 반환도 const float&라 실수로 값을 바꿀 수 없습니다. 아래 곱셈 함수는 인자를 const A, const B로 받는데, 이 const 버전이 없으면 A.at(r,k) 같은 읽기조차 컴파일되지 않습니다. 그래서 쓰기용 · 읽기용 두 벌을 둡니다.

참조로 돌려주는 또 다른 이점은 복사 비용 0입니다. float 하나야 작지만, 배열 원소의 실제 위치를 가리키므로 대입이 곧바로 원본에 반영되는 것이 핵심입니다.

2. 항등행렬 identity() — "아무것도 안 바꾸는" 기준점

항등행렬(identity matrix)은 대각선만 1이고 나머지는 0인 행렬입니다. 숫자에서 1을 곱하면 그대로이듯, 어떤 행렬 M이나 벡터 v에 항등행렬을 곱해도 값이 그대로입니다(M·I = M, v·I = v). 그래서 변환을 아직 아무것도 안 한 초기 상태, 여러 변환을 누적할 때의 출발점으로 씁니다.

math/Mat4.h — 항등행렬cpp
    static Mat4 identity() {
        Mat4 r;                 // 모든 성분 0 (집합 초기화 덕분)
        r.at(0,0) = 1;          // 대각선만 1로
        r.at(1,1) = 1;
        r.at(2,2) = 1;
        r.at(3,3) = 1;
        return r;
    }

한 줄씩 해부

static Mat4 identity()static이라 특정 인스턴스가 아니라 타입에 속한 함수입니다. 그래서 객체 없이 Mat4::identity()로 호출해 새 행렬을 찍어내는 팩토리처럼 씁니다.

Mat4 r; — 앞서 본 = { 0 } 덕분에 이 순간 r은 완전한 영행렬입니다. 그래서 뒤이어 대각선 4칸만 1로 덮으면 나머지는 이미 0이라 손댈 필요가 없습니다.

r.at(0,0)=1; r.at(1,1)=1; r.at(2,2)=1; r.at(3,3)=1;(0,0),(1,1),(2,2),(3,3)주대각선입니다. 이 네 자리는 메모리로는 m[0], m[5], m[10], m[15](각각 r*4+c0, 5, 10, 15)에 해당합니다. at가 참조를 돌려주니 = 1 대입이 곧바로 배열에 꽂힙니다.

완성된 항등행렬(4×4 논리 뷰)matrix
| 1  0  0  0 |
| 0  1  0  0 |
| 0  0  1  0 |
| 0  0  0  1 |
함정 변환을 누적할 때 출발 행렬을 영행렬(Mat4{})로 두면 안 됩니다. 영행렬에 무엇을 곱해도 0이 되어, 정점이 전부 원점(0,0,0)으로 뭉개지고 화면이 새까맣게 나옵니다. 누적의 시작은 반드시 Mat4::identity()입니다.

3. 행렬 곱 operator* — 그래픽스의 심장

행렬 곱 C = A * B는 "두 변환을 하나로 합치는" 연산입니다. 예를 들어 A가 회전, B가 투영이라면 A*B는 "회전하고 나서 투영하는" 단일 행렬이 됩니다. 정점 수백만 개에 두 변환을 따로 적용하는 대신 미리 한 번 곱해 두면 정점당 곱셈이 절반으로 줍니다. 이게 MVP를 미리 만드는 이유입니다.

수학적 의미 → 직관 → 코드

① 수학: 결과 행렬의 (r,c) 성분은 정해진 공식으로 계산됩니다.

행렬 곱의 정의math
C(r,c) = A(r,0)·B(0,c) + A(r,1)·B(1,c) + A(r,2)·B(2,c) + A(r,3)·B(3,c)
       = Σ (k=0..3)  A(r,k) · B(k,c)

② 직관(그림글): C(r,c)를 구하려면 Ar번째 가로줄(행)Bc번째 세로줄(열)을 겹쳐 놓고, 같은 위치끼리 곱해서 4개를 전부 더합니다. "A의 한 행이 B의 한 열 위를 훑고 지나가며 내적을 만든다"고 상상하면 됩니다.

(r,c) = A의 r행 · B의 c열 (내적)diagram
          B의 c열 (세로)
             ↓
          [ B(0,c) ]
   A의 r행  [ B(1,c) ]
[A(r,0) A(r,1) A(r,2) A(r,3)]  ·  [ B(2,c) ]  =  C(r,c)
             (가로)              [ B(3,c) ]

C(r,c) = A(r,0)·B(0,c) + A(r,1)·B(1,c) + A(r,2)·B(2,c) + A(r,3)·B(3,c)

③ 코드: 이 공식을 그대로 3중 루프로 옮깁니다.

math/Mat4.h — 행렬 곱cpp
// C = A * B,  C(r,c) = Σ A(r,k)·B(k,c)
inline Mat4 operator*(const Mat4& A, const Mat4& B) {
    Mat4 C;                                   // 결과: 처음엔 영행렬
    for (int c = 0; c < 4; ++c)               // 결과의 열을 하나씩
        for (int r = 0; r < 4; ++r) {         // 그 열의 행을 하나씩 -> (r,c) 한 칸
            float sum = 0;                    // 이 칸의 누적합
            for (int k = 0; k < 4; ++k)       // 내적: A의 r행 × B의 c열
                sum += A.at(r,k) * B.at(k,c);
            C.at(r,c) = sum;                  // 완성된 (r,c) 성분 기록
        }
    return C;
}

한 줄씩 해부

inline Mat4 operator*(const Mat4& A, const Mat4& B)operator*오버로딩A * B라고 쓰면 이 함수가 불리게 만듭니다. 함수 이름을 multiply(A,B)로 둘 수도 있지만, world * view * proj처럼 수식 그대로 자연스럽게 읽히는 코드를 위해 연산자로 정의합니다. 인자를 const Mat4&(읽기 전용 참조)로 받는 이유는 64바이트짜리 행렬을 복사하지 않고 원본을 안전하게 참조하기 위함입니다. inline은 헤더에 정의를 두어 여러 파일에서 포함해도 중복 정의 오류가 나지 않게 하는 표시입니다.

Mat4 C; — 결과 행렬. 집합 초기화로 0에서 시작하므로, 아래에서 16칸을 모두 덮어씁니다.

바깥 두 루프 for(c) for(r) — 결과 C16개 칸 (r,c)을 하나씩 돌기 위한 이중 루프입니다. 여기서는 바깥이 열(c), 안쪽이 행(r) 순서입니다. 논리적 결과는 순서와 무관합니다 — 어차피 16칸을 전부 채우니까요. 다만 뒤에 나올 cbuffer가 열 우선으로 해석하는 것과 시각을 맞추려고 "열을 먼저 고르고 그 안의 행을 훑는" 순서로 적어 두면 열 단위로 결과를 떠올리기 편합니다.

안쪽 루프 for(k)sum — 고른 한 칸 (r,c)의 값을 만드는 내적입니다. k=0,1,2,3을 돌며 A.at(r,k) * B.at(k,c)를 더합니다. 여기서 반복 변수 kA에서는 열 번호로, B에서는 행 번호로 동시에 쓰이는 것이 핵심입니다. 두 번째 인덱스(k)가 겹쳐 돌아가며 "A의 r행"과 "B의 c열"을 짝지어 줍니다.

그래서 4×4 곱은 성분 16개 × 각 4번 곱셈 = 총 64번의 곱셈과 48번의 덧셈으로 이뤄집니다. "16개 곱셈의 합"이라는 말은 한 칸을 기준으로 볼 때 곱셈이 4개 더해진다는 뜻이고, 행렬 전체로는 16칸 × 4 = 64회가 됩니다.

손으로 한 칸 계산해 보기

간단한 예로 A를 항등행렬, B를 임의 행렬로 두고 C(0,0)을 계산해 봅시다. A의 0행은 [1, 0, 0, 0]이므로:

C(0,0) 손계산 (A = 항등)math
C(0,0) = A(0,0)·B(0,0) + A(0,1)·B(1,0) + A(0,2)·B(2,0) + A(0,3)·B(3,0)
       =   1·B(0,0)    +   0·B(1,0)    +   0·B(2,0)    +   0·B(3,0)
       =   B(0,0)

// 항등행렬은 1이 있는 자리 하나만 살아남으므로 결국 C = B.  I * B = B 를 성분으로 확인.

이렇게 항등행렬의 0행 [1,0,0,0]B의 0행만 그대로 골라냅니다. 다른 행도 마찬가지라 C = A*B = I*B = B가 성분 수준에서 확인됩니다. 항등행렬이 "아무것도 안 바꾼다"는 성질의 실체입니다.

함정 행렬 곱은 교환법칙이 성립하지 않습니다: A*B ≠ B*A. 위 3중 루프에서 A.at(r,k)B.at(k,c)의 역할(행/열)이 비대칭인 것을 보면 알 수 있습니다. 변환 순서가 결과를 바꾸므로(회전 후 이동 ≠ 이동 후 회전) 곱하는 순서를 절대 뒤집지 마세요.
성능 이 소박한 3중 루프는 학습용으로 완벽하지만 SIMD를 안 씁니다. 실무에서는 4개 float을 한 번에 처리하는 SSE/AVX로 4~8배 빨라지며, 그것을 대신 해 주는 것이 DirectXMath의 XMMATRIX입니다(맨 아래).

4. 전치 transpose — 행과 열 뒤집기

전치(transpose)는 행렬을 주대각선 기준으로 대칭 이동시켜 (r,c)(c,r)을 맞바꾸는 연산입니다. Mᵀ(r,c) = M(c,r). 순수 수학에서도 자주 쓰지만, 우리 파이프라인에서 전치가 등장하는 진짜 이유는 GPU로 행렬을 올릴 때 저장 순서를 맞추기 위해서입니다(6번 항목에서 상세히).

math/Mat4.h — 전치cpp
// 전치: (r,c) ↔ (c,r).  cbuffer 업로드 시 HLSL의 열 우선 관례에 맞추려고 사용.
inline Mat4 transpose(const Mat4& M) {
    Mat4 r;
    for (int i = 0; i < 4; ++i)
        for (int j = 0; j < 4; ++j)
            r.at(i,j) = M.at(j,i);     // 인덱스를 뒤집어 대입
    return r;
}

한 줄씩 해부

inline Mat4 transpose(const Mat4& M) — 원본 M은 읽기만 하고(const&), 새 행렬 r을 만들어 돌려줍니다(원본을 제자리에서 바꾸지 않음).

이중 루프의 핵심은 대입 한 줄 r.at(i,j) = M.at(j,i);입니다. 왼쪽은 (i,j), 오른쪽은 인덱스가 뒤집힌 (j,i)입니다. 즉 결과의 (i,j) 자리에 원본의 (j,i) 값을 가져다 놓습니다. 이 한 줄이 곧 Mᵀ(i,j)=M(j,i)의 코드 번역입니다.

메모리로 보면: 원본에서 m[j*4+i]에 있던 값이 결과에서 m[i*4+j]로 옮겨 갑니다. 주대각선(i==j)은 자기 자신이라 안 움직이고, 나머지는 대각선을 기준으로 짝을 지어 자리를 바꿉니다.

전치 예시 (성분 위치 이동)matrix
        원본 M                     전치 Mᵀ
| a  b  c  d |            | a  e  i  m |
| e  f  g  h |    ->      | b  f  j  n |
| i  j  k  l |            | c  g  k  o |
| m  n  o  p |            | d  h  l  p |
// b(0,1)↔e(1,0),  c(0,2)↔i(2,0),  g(1,2)↔j(2,1) ...  대각 a,f,k,p는 그대로

5. 상수 버퍼(cbuffer) 업로드 — 그리고 전치가 필요한 진짜 이유

CPU에서 만든 MVP 행렬을 셰이더가 쓰려면 상수 버퍼(constant buffer, cbuffer)라는 GPU 메모리로 복사해야 합니다. 행렬은 그냥 float 16개이니 memcpy로 통째로 밀어 넣습니다. 문제는 CPU와 HLSL의 저장 순서 해석이 다르다는 점입니다.

renderer.cpp — MVP를 cbuffer로 업로드cpp
struct CBTransform { float mvp[16]; };      // cbuffer와 1:1 대응하는 CPU 구조체

Mat4 mvp = world * view * proj;             // 우리 규약: 행벡터라 v' = v*(W*V*P)

// HLSL의 cbuffer float4x4는 기본적으로 '열 우선'으로 해석한다.
// 우리는 '행 우선' 저장이므로, 그대로 올리면 GPU가 전치된 걸로 읽는다 -> 미리 전치해서 상쇄.
Mat4 t = transpose(mvp);

CBTransform cb;
std::memcpy(cb.mvp, t.m, sizeof(cb.mvp));   // float 16개를 그대로 복사

context->UpdateSubresource(constantBuffer.Get(), 0, nullptr, &cb, 0, 0);
context->VSSetConstantBuffers(0, 1, constantBuffer.GetAddressOf());

한 줄씩 해부

struct CBTransform { float mvp[16]; }; — 셰이더 쪽 cbuffer의 float4x4바이트 레이아웃이 정확히 같은 CPU 구조체입니다. 여기 담긴 순서 그대로 GPU 메모리에 놓입니다.

Mat4 mvp = world * view * proj; — 앞의 곱셈 연산자로 세 변환을 하나로 합칩니다. 행벡터 규약이라 정점은 셰이더에서 mul(v, mvp)(즉 v * mvp)로 곱해집니다.

Mat4 t = transpose(mvp);핵심 한 줄. 우리는 mvp행 우선으로 저장했는데, HLSL cbuffer의 float4x4열 우선으로 읽습니다. 같은 16개 바이트를 서로 다른 순서로 해석하면 결과가 전치된 행렬이 됩니다. 그래서 미리 한 번 전치해서 올리면 GPU 쪽의 전치 해석과 서로 상쇄되어 우리가 의도한 행렬이 그대로 적용됩니다.

std::memcpy(cb.mvp, t.m, sizeof(cb.mvp)); — 전치한 행렬의 배열 t.m 16개를 구조체로 복사합니다. sizeof(cb.mvp)16*4=64바이트. 행 우선으로 저장을 맞춰 뒀기에 필드별로 옮기지 않고 한 번에 복사됩니다.

UpdateSubresource(...) — CPU의 cb를 GPU 상수 버퍼로 전송합니다. VSSetConstantBuffers(0, 1, ...)는 그 버퍼를 버텍스 셰이더의 슬롯 0번에 바인딩해, 셰이더가 register(b0)로 접근할 수 있게 합니다.

함정 D3D에서 화면이 뒤틀리는 원인 1위가 이 전치 누락입니다. HLSL cbuffer float4x4는 기본적으로 열 우선 해석 → 행 우선 행렬을 그대로 올리면 전치된 변환이 적용됩니다. 해법은 둘 중 하나:
(1) CPU에서 transpose 후 업로드 + 셰이더에서 mul(v, M) — 이 가이드의 방식.
(2) 셰이더 선언을 row_major float4x4로 바꿔 GPU가 행 우선으로 읽게 하기(그러면 CPU 전치 불필요).
두 방법을 동시에 쓰면 전치가 두 번 되어 원위치로 돌아오니, 하나만 고르세요.

6. DirectXMath 버전 — 실무 표준

학습을 위해 직접 만들었지만, 실무에서는 SIMD로 최적화된 DirectXMath를 씁니다. 아래는 위 코드와 1:1로 대응합니다.

DirectXMath로 동일 작업cpp
#include <DirectXMath.h>
using namespace DirectX;

XMMATRIX mvp = XMMatrixIdentity();          // 항등행렬 (우리 identity()에 대응)
// ... 여기서 world/view/proj를 곱해 mvp를 구성 ...

XMFLOAT4X4 mvpStore;                          // 저장용 4x4 (구조체 멤버로 안전)
XMStoreFloat4x4(&mvpStore, XMMatrixTranspose(mvp));  // 전치 후 메모리에 저장

CBTransform cb;
memcpy(cb.mvp, &mvpStore, sizeof(cb.mvp));    // 그대로 cbuffer로
UpdateSubresource(/* ... */);

원본 ↔ DirectXMath 1:1 대응

직접 구현(Mat4)DirectXMath설명
Mat4(계산용)XMMATRIXSIMD 레지스터 4개로 표현되는 연산용 행렬
Mat4(저장용)XMFLOAT4X4단순 float 16개. 구조체 멤버/파일 저장용
Mat4::identity()XMMatrixIdentity()항등행렬 생성
A * BXMMatrixMultiply(A, B) 또는 A * B행렬 곱(SIMD)
transpose(M)XMMatrixTranspose(M)전치
memcpy(cb.mvp, t.m, ...)XMStoreFloat4x4(&store, M)연산용 → 저장용으로 내려쓰기
(반대 방향)XMLoadFloat4x4(&store)저장용 → 연산용으로 올려읽기
왜 XMMATRIX와 XMFLOAT4X4 두 개인가 XMMATRIX는 SIMD 레지스터라 16바이트 정렬(alignment)이 필요합니다. 이걸 클래스 멤버 변수로 그냥 두면 정렬이 깨져 크래시가 날 수 있습니다. 그래서 계산할 때만 XMMATRIX, 저장(멤버/버퍼)은 정렬 제약 없는 XMFLOAT4X4로 나눠 쓰고, 둘 사이는 XMLoadFloat4x4(올려읽기) / XMStoreFloat4x4(내려쓰기)로 오갑니다.
함정 항등행렬은 반드시 XMMatrixIdentity()로 만드세요. XMMATRIX{}(기본 생성)는 0행렬이라, 이걸 MVP로 쓰면 앞서 본 대로 모든 정점이 원점으로 뭉개져 화면이 새까맣게 나옵니다. 우리 Mat4{}가 영행렬인 것과 완전히 같은 함정입니다.
성능 XMMATRIX 곱셈·전치는 SSE/AVX로 4개 float을 한 번에 처리해 우리 3중 루프보다 훨씬 빠릅니다. 직접 구현은 원리 이해용, 실제 렌더 루프의 행렬 연산은 DirectXMath에 맡기는 것이 정석입니다.

정리