← 개념 · 데모 가이드 | 구현 허브 | 1장. 기초 수학 | 좌표계와 변환
IMPLEMENTATION · CHAPTER 1

좌표계와 변환 — 한 줄 한 줄 직접 구현

3D 물체를 화면 위에서 옮기고(translate) · 키우고(scale) · 돌리는(rotate) 일은 전부 4×4 행렬 하나로 표현됩니다. 이 페이지에서는 이동·크기·회전 행렬을 직접 손으로 만들고, 이 행렬들을 어떤 순서로 곱하느냐가 결과를 완전히 바꾼다는 사실을 확인합니다. 마지막으로 점과 방향을 구분하는 동차좌표(homogeneous coordinate) w의 의미를 성분 하나까지 파고듭니다.

규약 이 가이드는 D3D 규약입니다: 행벡터 v' = v * M(HLSL mul(v, M)), 행렬은 행 우선 저장(m[r*4+c]), 왼손 좌표계. 행벡터에서는 가장 왼쪽 행렬이 가장 먼저 적용됩니다. 이 한 가지 사실이 아래 모든 배치와 곱 순서를 결정하니 꼭 붙잡고 읽어 주세요. OpenGL/GLM(열벡터)과는 곱 순서가 정확히 반대입니다.

이 페이지에서 만드는 것

  1. 이동 행렬 makeTranslate — 물체를 공간에서 평행 이동.
  2. 크기 행렬 makeScale — 축별 배율로 늘리거나 줄임.
  3. 회전 행렬 makeRotate — 임의의 축을 중심으로 로드리게스 공식으로 회전.
  4. 곱 순서 실험R*TT*R이 왜 다른 결과를 내는지.
  5. 동차좌표w=1(점)과 w=0(방향)의 차이, mul(Vec4, Mat4) 성분별 해부.
  6. 표준 조립 — 실무의 model = S*R*T와 DirectXMath 대응.

0. 변환 행렬이란 무엇인가 — 왜 4×4인가

벡터 (x, y, z)에 3×3 행렬을 곱하면 크기·회전·전단은 표현할 수 있습니다. 하지만 이동은 표현할 수 없습니다. 이동은 "모든 성분에 상수를 더하는" 연산인데, 행렬 곱은 근본적으로 곱셈과 덧셈의 조합(선형 결합)일 뿐 "무조건 상수를 더하는" 항이 없기 때문입니다.

그래서 그래픽스는 차원을 하나 늘려 4번째 성분 w를 도입합니다. 점을 (x, y, z, 1)로 적으면, 행렬의 마지막 행에 넣어 둔 이동량이 w(=1)와 곱해져 결과에 더해지는 효과가 생깁니다. 이 트릭 덕분에 이동·크기·회전을 모두 하나의 4×4 행렬 곱으로 통일할 수 있습니다. 이것이 4×4를 쓰는 이유이고, 아래 모든 행렬이 Mat4인 이유입니다.

1. 이동 행렬 makeTranslate

무엇을 만드나: 물체를 (t.x, t.y, t.z)만큼 평행 이동시키는 행렬입니다. 왜 필요한가: 모델을 월드의 원하는 위치에 배치하거나(월드 변환), 카메라를 이동시키는 등 거의 모든 변환 파이프라인의 기본 벽돌입니다.

  1. 항등 행렬로 시작해 "아무것도 하지 않는" 상태를 만든다.
  2. 이동량을 마지막 행(3번 행)의 x·y·z 자리에 넣는다.
  3. 반환하면 곱했을 때 좌표에 이동량이 더해진다.
math/Transform.hcpp
#pragma once
#include "Mat4.h"
#include "Vec3.h"
#include <cmath>

// 이동: 행벡터 규약이라 마지막 행(3번)에 이동량을 넣는다.
inline Mat4 makeTranslate(const Vec3& t) {
    Mat4 r = Mat4::identity();
    r.at(3, 0) = t.x;   // 마지막 행, 0번 열
    r.at(3, 1) = t.y;   // 마지막 행, 1번 열
    r.at(3, 2) = t.z;   // 마지막 행, 2번 열
    return r;
}

시그니처 한 조각씩

inline: 이 함수는 헤더에 정의되어 여러 .cpp에서 #include됩니다. inline이 없으면 같은 함수 정의가 여러 번 링크되어 중복 정의(ODR 위반) 오류가 납니다. inline은 "이 정의가 여러 번역 단위에 나타나도 하나로 합쳐 달라"고 링커에 알려 줍니다.

const Vec3& t: 참조(&)로 받아 Vec3 세 개 float를 복사하는 비용을 피하고, const를 붙여 "이 함수는 입력 t를 절대 바꾸지 않는다"를 컴파일러와 독자 모두에게 보장합니다. 반환 타입은 값 타입 Mat4 — 새로 만든 행렬을 통째로 돌려줍니다.

마지막 행에 이동량이 들어가는가 (핵심)

행벡터 규약에서 결과는 v' = v * M으로 계산됩니다. v = (x, y, z, 1)라 하면, 결과의 각 성분은 v의 성분들과 M을 곱해 더한 값입니다. 예를 들어 결과의 x 성분은:

x' = x*M(0,0) + y*M(1,0) + z*M(2,0) + w*M(3,0)

여기서 w = 1이므로 마지막 항 w*M(3,0)은 그대로 M(3,0)이 됩니다. 즉 3번 행에 넣어 둔 값이 언제나 결과에 더해집니다. 이것이 바로 우리가 원하는 "무조건 상수를 더하는" 이동 효과입니다. M(3,0)=t.x로 두었으니 x' = x + t.x가 됩니다. 같은 논리로 y' = y + t.y, z' = z + t.z가 되죠.

반대로 열벡터 규약(GLM)에서는 v' = M * v라서 이동량이 마지막 열에 들어갑니다. 즉 우리 코드의 at(3,0)=t.x는 GLM에서 [0][3]=t.x에 해당합니다. 행/열이 전치(transpose) 관계라는 것이 여기서부터 갈립니다.

함정 이동량을 실수로 마지막 열(at(0,3), at(1,3), at(2,3))에 넣으면, 행벡터 규약에서는 엉뚱하게 w'에 값이 새어 들어가 원근 분할이 망가집니다. 행벡터를 쓴다면 이동량은 반드시 3번 행입니다.

손계산으로 확인

makeTranslate({10, 0, 0})M(3,0)=10인 행렬입니다. 점 (1, 2, 3, 1)을 곱하면 x' = 1 + 1*10 = 11, y·z는 그대로라서 결과는 (11, 2, 3, 1). 정확히 x축으로 10만큼 옮겨졌습니다.

2. 크기 행렬 makeScale

무엇을 만드나: 각 축 방향으로 (s.x, s.y, s.z) 배율로 늘리거나 줄이는 행렬입니다. 왜 필요한가: 모델의 크기를 조정하거나(2배 확대 등), 비균일 스케일로 납작하게 만드는 등에 쓰입니다.

math/Transform.hcpp
// 크기: 대각선에 축별 배율을 넣는다.
inline Mat4 makeScale(const Vec3& s) {
    Mat4 r = Mat4::identity();
    r.at(0, 0) = s.x;   // x 성분에 곱해질 배율
    r.at(1, 1) = s.y;   // y 성분에 곱해질 배율
    r.at(2, 2) = s.z;   // z 성분에 곱해질 배율
    return r;
}

대각선인가

다시 x' = x*M(0,0) + y*M(1,0) + z*M(2,0) + w*M(3,0)을 봅시다. 우리가 원하는 것은 x'가 오직 x에만 배율을 곱한 값, 즉 x' = x * s.x가 되는 것입니다. 그러려면 x 항의 계수 M(0,0)s.x로 두고 다른 항의 계수는 0이어야 합니다. 항등 행렬은 대각선만 1이고 나머지는 0이므로, 대각선 값 M(0,0)s.x로 바꾸기만 하면 x' = x * s.x가 딱 나옵니다. 그래서 대각선 (0,0), (1,1), (2,2)에 각각 배율을 넣는 것입니다.

at(3,3)은 항등 그대로 1로 남습니다. 즉 w는 건드리지 않으므로 점의 동차 성분은 1로 유지됩니다. 크기 변환은 이동과 달리 원점을 기준으로 작동한다는 점도 기억하세요 — 원점에서 멀리 있는 물체는 확대 시 원점에서 더 멀어집니다.

3. 회전 행렬 makeRotate — 로드리게스 공식

무엇을 만드나: 임의의 축 axis를 중심으로 angle(라디안)만큼 회전하는 행렬입니다. 왜 필요한가: 물체를 자기 축으로 돌리거나, 카메라 시선을 회전하는 등 회전은 변환의 3대 요소 중 하나입니다. 임의 축 회전은 로드리게스 회전 공식(Rodrigues' rotation formula)으로 유도됩니다.

  1. 축을 정규화한다(단위 길이여야 공식이 성립).
  2. c=cos θ, s=sin θ, t=1-cos θ를 미리 계산한다.
  3. 9개 성분에 로드리게스 공식을 채운다(행벡터 배치 = 열벡터용의 전치).
math/Transform.hcpp
// 임의 축 회전 (로드리게스), 행벡터 규약(v*R) 배치 = 열벡터용의 전치.
inline Mat4 makeRotate(const Vec3& axis, float angle) {
    Vec3 a = normalize(axis);
    float c = cos(angle), s = sin(angle), t = 1 - c;
    Mat4 r = Mat4::identity();
    r.at(0,0) = t*a.x*a.x + c;    r.at(0,1) = t*a.x*a.y + s*a.z; r.at(0,2) = t*a.x*a.z - s*a.y;
    r.at(1,0) = t*a.x*a.y - s*a.z; r.at(1,1) = t*a.y*a.y + c;    r.at(1,2) = t*a.y*a.z + s*a.x;
    r.at(2,0) = t*a.x*a.z + s*a.y; r.at(2,1) = t*a.y*a.z - s*a.x; r.at(2,2) = t*a.z*a.z + c;
    return r;
}

왜 축을 normalize하는가

로드리게스 공식은 축이 단위 벡터(길이 1)라는 전제 아래 유도됩니다. 예를 들어 대각선 항 t*a.x*a.x + c에서, a가 단위 벡터가 아니면 a.x*a.x가 실제 방향 성분의 제곱을 벗어나 회전이 왜곡·확대됩니다. 그래서 첫 줄에서 반드시 normalize(axis)로 길이를 1로 맞춥니다. 호출자가 {0, 0, 5}처럼 길이 5인 축을 넘겨도 내부에서 {0, 0, 1}로 정규화되어 안전합니다.

c, s, t 세 상수의 의미

c = cos θ, s = sin θ는 회전각의 코사인·사인입니다. t = 1 - cos θ는 "1에서 코사인을 뺀 값"으로, 로드리게스 공식에서 축 방향으로의 성분을 다루는 계수입니다. 직관적으로: 벡터를 축 둘레로 돌릴 때, 축에 평행한 성분은 그대로 남고, 축에 수직인 성분만 회전합니다. t는 "축 방향으로 얼마나 투영해 되돌려 놓을지"를 조절하는 항이라, 축 성분들의 곱(a.x*a.x 등)과 함께 나타납니다. 미리 한 번씩만 계산해 두고 9번 재사용하므로 cos/sin 호출도 각 1회로 절약됩니다.

9개 성분 하나씩 — 어디서 오는가

로드리게스 공식의 각 성분은 세 부분의 합입니다: (1) 축 방향 성분 t·aᵢ·aⱼ, (2) 대각선의 코사인 항 c(같은 축끼리일 때만), (3) 사인 항 ±s·aₖ(외적 성분, 서로 다른 축일 때).

성분수식구성 요소
at(0,0)t*a.x*a.x + c축 항 t·aₓ² + 대각선 코사인 c
at(1,1)t*a.y*a.y + c축 항 t·a_y² + 대각선 코사인 c
at(2,2)t*a.z*a.z + c축 항 t·a_z² + 대각선 코사인 c
at(0,1)t*a.x*a.y + s*a.z축 항 t·aₓa_y + 사인 항 +s·a_z
at(1,0)t*a.x*a.y - s*a.z같은 축 항, 사인 항 부호만 반대 -s·a_z
at(0,2)t*a.x*a.z - s*a.y축 항 t·aₓa_z + 사인 항 -s·a_y
at(2,0)t*a.x*a.z + s*a.y같은 축 항, 사인 항 부호만 반대 +s·a_y
at(1,2)t*a.y*a.z + s*a.x축 항 t·a_ya_z + 사인 항 +s·aₓ
at(2,1)t*a.y*a.z - s*a.x같은 축 항, 사인 항 부호만 반대 -s·aₓ

대각선(0,0 / 1,1 / 2,2)에만 +c가 붙는 이유: 대각선은 "같은 축끼리"의 계수라 코사인만큼 원래 성분을 유지해야 하기 때문입니다. 비대각선은 서로 다른 축이 섞이는 자리라, 축 곱 항 t·aᵢaⱼ외적에서 나온 사인 항 ±s·aₖ이 더해집니다. 이 사인 항이 바로 "축에 수직인 평면에서 벡터를 실제로 돌려 주는" 부분입니다.

부호가 대칭 위치에서 반대인 것(at(0,1)+s·a_z, at(1,0)-s·a_z)도 외적의 반대칭성에서 옵니다. 외적 a × v는 성분을 바꾸면 부호가 뒤집히는데, 이 성질이 회전 행렬의 비대각 성분에 그대로 반영됩니다.

주의 이 배치는 행벡터(v*R)용입니다. 교과서(대개 열벡터 R*v 기준)의 회전 행렬과 비교하면 비대각 성분의 사인 항 부호가 전치(transpose)된 형태로 보입니다. 즉 우리 at(0,1)은 열벡터 표준의 at(1,0) 자리와 같습니다. 다른 자료를 그대로 옮기면 회전 방향이 반대(시계↔반시계)로 돌 수 있으니 규약을 통일하세요.

4. 곱 순서가 결과를 바꾼다 — R*T vs T*R

변환의 가장 흔한 함정입니다. 행렬 곱은 교환법칙이 성립하지 않습니다(A*B ≠ B*A). 따라서 "회전 후 이동"과 "이동 후 회전"은 완전히 다른 결과를 냅니다.

순서 실험cpp
Mat4 T = makeTranslate({5, 0, 0});
Mat4 R = makeRotate({0, 0, 1}, 1.5708f);   // z축 90도(π/2)

Mat4 RT = R * T;   // 먼저 R, 그 다음 T  → 제자리서 돌고 옆으로
Mat4 TR = T * R;   // 먼저 T, 그 다음 R  → 원점 중심으로 궤도를 돎

행벡터에서 "왼쪽이 먼저 적용"인 이유

결과는 v' = v * (R * T)입니다. 결합법칙으로 묶으면 v' = (v * R) * T가 됩니다. 즉 벡터에 가장 먼저 닿는 것은 왼쪽 행렬 R입니다. 벡터가 왼쪽에 있으니 바로 옆(왼쪽) 행렬부터 순서대로 적용되는 것이죠. 그래서 R*T는 "먼저 회전(R), 그 다음 이동(T)"입니다.

적용 순서(행벡터)기하학적 결과
R * T회전 → 이동물체가 제자리에서 돌고 나서 옆으로 5만큼 밀림
T * R이동 → 회전먼저 5만큼 밀린 뒤 원점 기준으로 회전 = 원점 둘레를 도는 궤도

직관: 회전은 언제나 원점을 중심으로 일어납니다. R*T는 물체가 아직 원점에 있을 때 돌리므로 자기 자리에서 방향만 바뀌고, 그 다음 이동으로 옮겨집니다. T*R은 먼저 원점에서 5만큼 떨어뜨린 뒤 돌리므로, 반지름 5짜리 원을 그리며 원점 둘레를 도는 것처럼 보입니다.

함정 GLM/OpenGL(열벡터)과 곱 순서가 정확히 반대입니다. GLM에서는 v' = M * v라서 "먼저 적용할 변환을 오른쪽에" 두어 T * R이 "먼저 회전 후 이동"이 됩니다. 우리(행벡터)는 "먼저 적용할 변환을 왼쪽에" 두어 R * T가 "먼저 회전 후 이동"입니다. 인터넷 예제를 코드 순서만 그대로 베끼면 회전 중심과 이동이 뒤엉킵니다.

5. 동차좌표: 점(w=1) vs 방향(w=0)

무엇을 만드나: 4차원 벡터 Vec4와, 그것에 행렬을 곱하는 mul(Vec4, Mat4). 왜 필요한가: 같은 (x,y,z)라도 그것이 위치인지 방향인지에 따라 이동에 반응해야 할지 말지가 달라집니다. 이 구분을 w 한 성분으로 처리합니다.

math/Transform.hcpp
struct Vec4 { float x, y, z, w; };

// v' = v * M  (HLSL의 mul(v, M)와 동일한 행벡터 곱)
inline Vec4 mul(const Vec4& v, const Mat4& M) {
    return {
        v.x*M.at(0,0) + v.y*M.at(1,0) + v.z*M.at(2,0) + v.w*M.at(3,0),  // x'
        v.x*M.at(0,1) + v.y*M.at(1,1) + v.z*M.at(2,1) + v.w*M.at(3,1),  // y'
        v.x*M.at(0,2) + v.y*M.at(1,2) + v.z*M.at(2,2) + v.w*M.at(3,2),  // z'
        v.x*M.at(0,3) + v.y*M.at(1,3) + v.z*M.at(2,3) + v.w*M.at(3,3)   // w'
    };
}

출력 성분 4개, 어느 행·열의 곱인가

행벡터 곱에서 결과의 k번째 성분은 "입력 벡터 전체"와 "행렬의 k번째 열"의 내적입니다. 즉 입력 성분 v.x, v.y, v.z, v.w가 각각 행렬의 0·1·2·3번 행에서 같은 k번 열 값과 곱해집니다.

출력사용하는 행렬 열전개
x'0번 열v.x·M(0,0) + v.y·M(1,0) + v.z·M(2,0) + v.w·M(3,0)
y'1번 열v.x·M(0,1) + v.y·M(1,1) + v.z·M(2,1) + v.w·M(3,1)
z'2번 열v.x·M(0,2) + v.y·M(1,2) + v.z·M(2,2) + v.w·M(3,2)
w'3번 열v.x·M(0,3) + v.y·M(1,3) + v.z·M(2,3) + v.w·M(3,3)

핵심은 각 항의 마지막 성분 v.w * M(3, k)입니다. 앞서 이동 행렬은 이동량을 3번 행 M(3,0), M(3,1), M(3,2)에 넣었습니다. 따라서 이 이동량이 결과에 더해질지 여부는 오직 v.w가 결정합니다.

w=1(점)과 w=0(방향)의 결정적 차이

Mat4 T = makeTranslate({10, 0, 0});

Vec4 point{1, 2, 3, 1};   // w=1 → 위치. 이동 영향받음
Vec4 dir  {1, 0, 0, 0};   // w=0 → 방향. 이동 무시

Vec4 p2 = mul(point, T);  // (1+1*10, 2, 3, 1) = (11, 2, 3, 1)
Vec4 d2 = mul(dir,   T);  // (1+0*10, 0, 0, 0) = ( 1, 0, 0, 0)

점의 x'v.x + v.w*M(3,0) = 1 + 1*10 = 11이 되어 이동합니다. 방향의 x'v.x + v.w*M(3,0) = 1 + 0*10 = 1이라 이동량이 0으로 곱해져 사라집니다. 같은 행렬, 같은 (1,0,0)인데 w 한 성분 차이로 "옮겨지는 위치"와 "옮겨지지 않는 방향"이 갈립니다. 이것이 동차좌표의 핵심 발상입니다.

함정 법선·시선·빛 방향 등 방향 벡터는 반드시 w = 0으로 두어야 합니다. 실수로 w = 1로 두면 방향에 카메라·모델의 이동량이 더해져, 물체가 화면에서 움직일 때마다 조명 방향이 흔들리는 버그가 생깁니다. "위치는 1, 방향은 0" — 이 규칙을 기계적으로 외우세요.

6. 표준 조립 — model = S * R * T

실무에서 하나의 물체에 크기·회전·이동을 모두 적용할 때는 세 행렬을 곱해 모델 행렬 하나로 만듭니다. 행벡터 규약에서 자연스러운 순서는 크기 → 회전 → 이동, 즉 S * R * T입니다.

Mat4 S = makeScale({2, 2, 2});
Mat4 R = makeRotate({0, 0, 1}, 1.5708f);
Mat4 T = makeTranslate({5, 0, 0});

Mat4 model = S * R * T;   // 적용 순서: 크기 → 회전 → 이동

v' = v * (S*R*T) = ((v*S)*R)*T로 풀리므로 왼쪽부터 S(크기) → R(회전) → T(이동) 순서로 적용됩니다. 이 순서가 옳은 이유: 먼저 원점에서 크기를 키우고, 원점 중심으로 회전한 뒤, 마지막에 원하는 위치로 옮겨야 물체가 자기 중심으로 커지고 돌고 나서 배치됩니다. 이동을 먼저 하면 회전·크기가 원점이 아닌 엉뚱한 곳을 기준으로 작동해 결과가 뒤틀립니다.

DirectXMath 버전 — 실무 표준

DirectXMathcpp
#include <DirectXMath.h>
using namespace DirectX;

XMMATRIX S = XMMatrixScaling(2, 2, 2);
XMMATRIX R = XMMatrixRotationZ(XMConvertToRadians(90));
XMMATRIX T = XMMatrixTranslation(5, 0, 0);

XMMATRIX model = S * R * T;   // D3D도 행벡터 → S*R*T (GLM의 T*R*S와 좌우 반대)

// 임의 축 회전 / 오일러 각
XMMATRIX Rax  = XMMatrixRotationAxis(XMVectorSet(0,0,1,0), XMConvertToRadians(90));
XMMATRIX Reul = XMMatrixRotationRollPitchYaw(pitch, yaw, roll);

DirectXMath 함수는 우리가 손으로 만든 것과 1:1로 대응합니다:

직접 구현DirectXMath대응
makeScaleXMMatrixScaling대각선 배율 행렬
makeRotate(axis, θ)XMMatrixRotationAxis임의 축 로드리게스 회전
(z축 회전)XMMatrixRotationZ축을 z로 고정한 회전
makeTranslateXMMatrixTranslation3번 행에 이동량
mul(Vec4, Mat4)XMVector4Transform행벡터 v*M 변환
함정 DirectXMath의 곱 순서는 GLM과 정확히 반대입니다. 인터넷의 OpenGL/GLM 예제가 쓰는 model = T * R * S를 D3D에 그대로 옮기면 회전 중심과 이동이 엉킵니다. D3D는 행벡터라 "먼저 적용할 변환을 왼쪽에" = S * R * T입니다. 이 한 줄만 반대로 써도 물체가 궤도를 도는 버그가 납니다.
성능 같은 model 행렬로 정점을 수만 개 변환한다면, 세 행렬 곱 S*R*T는 CPU에서 한 번만 미리 계산해 두고 결과 행렬만 셰이더로 올리세요. 정점마다 S*R*T를 다시 곱하는 것은 큰 낭비입니다. 또한 cbuffer로 올릴 때는 (HLSL 기본 열 우선 해석 때문에) 필요 시 transpose를 잊지 마세요.