작성일 :

벡터에서 변환으로

그래픽스 수학 (1) - 벡터와 벡터 연산에서 벡터를 사용하여 3D 공간의 위치, 방향, 속도를 표현하는 방법을 다루었습니다.

벡터는 “어디에 있는가”, “어느 쪽을 향하는가”, “얼마나 빠르게 움직이는가”를 수치로 나타내는 도구, 즉 3D 세계의 현재 상태를 기술하는 수단이었습니다.


하지만 게임에서 오브젝트는 가만히 있지 않습니다. 캐릭터가 앞으로 이동하고, 문이 회전하며 열리고, 아이템이 커졌다 작아졌다 합니다.

벡터가 현재 상태를 표현한다면, 그 상태를 바꾸는 수단도 필요합니다. 이것이 변환(Transformation)이고, 세 가지로 나뉩니다.

위치를 바꾸는 이동(Translation), 방향을 바꾸는 회전(Rotation), 크기를 바꾸는 스케일(Scale)입니다.

이동은 벡터 덧셈, 스케일은 성분별 곱셈, 회전은 삼각함수를 이용한 좌표 변환으로 각각 처리할 수 있습니다.

그런데 세 가지를 별개의 연산으로 다루면, 이동+회전+스케일이 동시에 필요할 때마다 서로 다른 연산을 순서대로 수행해야 합니다.

변환의 종류가 늘어날수록 코드가 복잡해지고, GPU가 수만 개의 정점에 같은 변환을 반복 적용하는 상황에서도 연산이 통일되어 있지 않으면 병렬 처리 효율이 떨어집니다.


세 변환을 하나의 곱셈 연산으로 통일하는 도구가 행렬(Matrix)입니다.

행렬 하나에 이동, 회전, 스케일을 모두 담으면, 정점마다 행렬-벡터 곱셈 한 번으로 모든 변환이 적용됩니다.

이 글에서는 행렬의 개념, 4×4 행렬을 사용하는 이유, 각 변환의 행렬 표현, 그리고 Unity Transform 컴포넌트와 행렬의 연결을 다룹니다.


행렬이란

행렬은 숫자를 직사각형 모양으로 배열한 것입니다.

가로 줄을 행(row), 세로 줄을 열(column)이라 합니다. 행이 m개이고 열이 n개인 행렬을 m x n 행렬이라 합니다.


\[\begin{bmatrix} 1 & 2 & 3 \\ 4 & 5 & 6 \end{bmatrix}\]

위 행렬은 2개의 행과 3개의 열을 가진 2 × 3 행렬입니다. 첫 번째 행은 $(1, \; 2, \; 3)$, 두 번째 행은 $(4, \; 5, \; 6)$입니다.

3D 그래픽스에서 행렬은 변환을 표현하는 도구입니다.

벡터가 “위치”나 “방향”을 나타낸다면, 행렬은 “그 위치나 방향을 어떻게 바꿀 것인가”를 나타냅니다.

행렬과 벡터를 곱하면 벡터에 변환이 적용됩니다. 회전을 나타내는 행렬이라면, 곱셈의 결과는 원래 벡터를 회전시킨 새로운 벡터입니다.

\[\underbrace{\begin{bmatrix} a & b & c \\ d & e & f \\ g & h & i \end{bmatrix}}_{\text{변환 행렬}} \underbrace{\begin{bmatrix} x \\ y \\ z \end{bmatrix}}_{\text{원래 벡터}} = \underbrace{\begin{bmatrix} x' \\ y' \\ z' \end{bmatrix}}_{\text{변환된 벡터}}\]


곱셈 규칙은 간단합니다. 행렬의 n번째 행과 입력 벡터를 내적(Dot Product)한 값이 결과 벡터의 n번째 성분이 됩니다.

\[\begin{aligned} x' &= ax + by + cz \quad \text{(1행} \cdot \text{벡터)} \\ y' &= dx + ey + fz \quad \text{(2행} \cdot \text{벡터)} \\ z' &= gx + hy + iz \quad \text{(3행} \cdot \text{벡터)} \end{aligned}\]


회전과 스케일은 3x3 행렬로 표현할 수 있지만, 이동은 다릅니다. 3x3 곱셈은 원점을 움직일 수 없어서, 이동까지 포함하려면 4x4 행렬이 필요합니다.


왜 4x4 행렬인가 — 동차 좌표

3x3 행렬과 벡터의 곱셈은 선형 변환(linear transformation)에 해당하며, 선형 변환은 항상 원점을 원점에 그대로 둡니다.

회전과 스케일은 원점을 기준으로 작동하므로 3x3 행렬로 표현할 수 있지만, 이동(translation)은 원점 자체를 옮기는 변환이라 3x3 행렬로는 불가능합니다.


원점 (0, 0, 0)을 곱해 보면 이를 확인할 수 있습니다.

\[\begin{bmatrix} a & b & c \\ d & e & f \\ g & h & i \end{bmatrix} \begin{bmatrix} 0 \\ 0 \\ 0 \end{bmatrix} = \begin{bmatrix} 0 \\ 0 \\ 0 \end{bmatrix}\]

행렬의 값이 무엇이든 결과는 항상 (0, 0, 0)이므로, 이동을 처리하려면 곱셈과 별도로 덧셈이 필요합니다.

\[\mathbf{p'} = R \cdot \mathbf{p} + \mathbf{t}\]

수식에서 보듯이 곱셈과 덧셈이 섞여 있어, 하나의 연산으로 통일할 수 없습니다.


차원을 하나 늘리면 이 문제를 해결할 수 있습니다. 3D 좌표 (x, y, z)에 네 번째 성분 w를 추가하면 행렬도 4x4로 확장되고, 늘어난 열에 이동량을 배치하여 곱셈만으로 이동을 처리할 수 있습니다.

이렇게 확장한 좌표 (x, y, z, w)를 동차 좌표(Homogeneous Coordinates)라 하며, w의 값에 따라 이동의 적용 여부가 달라집니다.

\[\text{3D 좌표:} \quad (x, \; y, \; z) \qquad \Longrightarrow \qquad \text{동차 좌표:} \quad (x, \; y, \; z, \; w)\] \[\begin{aligned} \text{위치(점):} \quad & (x, \; y, \; z, \; 1) & & \leftarrow \; \text{이동의 영향을 받음} \\ \text{방향(벡터):} \quad & (x, \; y, \; z, \; 0) & & \leftarrow \; \text{이동의 영향을 받지 않음} \end{aligned}\]


방향은 위치가 아니므로 이동의 영향을 받으면 안 됩니다. 오브젝트를 (10, 0, 0)으로 이동시켜도 “위를 향한다”는 방향 (0, 1, 0)은 그대로여야 합니다. w = 0이 이를 보장합니다.

이동, 회전, 스케일을 하나의 4x4 행렬로 표현하면 다음과 같습니다.

\[\begin{bmatrix} \cdot & \cdot & \cdot & t_x \\ \cdot & \cdot & \cdot & t_y \\ \cdot & \cdot & \cdot & t_z \\ 0 & 0 & 0 & 1 \end{bmatrix} \begin{bmatrix} x \\ y \\ z \\ 1 \end{bmatrix} = \begin{bmatrix} x' \\ y' \\ z' \\ 1 \end{bmatrix}\]

왼쪽 위 3x3 영역(·)에 회전과 스케일이, 네 번째 열에 이동량 $(t_x, t_y, t_z)$가 배치됩니다. 하나의 곱셈으로 세 변환이 모두 적용됩니다.


GPU가 수만 개의 정점을 처리할 때도 같은 구조입니다.

각 정점을 동차 좌표 (x, y, z, 1)로 표현하고 동일한 4x4 행렬과 곱하면, 모든 정점에 같은 변환이 적용됩니다.

연산이 곱셈 하나로 통일되어 있어, GPU의 SIMD(Single Instruction, Multiple Data) 유닛이 하나의 명령으로 여러 정점을 동시에 처리할 수 있습니다.


이동 행렬 (Translation)

각 변환 행렬의 구조를 이동부터 살펴봅니다.


이동 행렬은 오브젝트의 위치를 바꾸는 변환입니다. 이동량 $(t_x, \; t_y, \; t_z)$가 앞서 본 네 번째 열에 들어갑니다.

\[T = \begin{bmatrix} 1 & 0 & 0 & t_x \\ 0 & 1 & 0 & t_y \\ 0 & 0 & 1 & t_z \\ 0 & 0 & 0 & 1 \end{bmatrix}\]

\(t_x\) = x축 이동량, \(t_y\) = y축 이동량, \(t_z\) = z축 이동량


위치 벡터 (x, y, z, 1)에 이 행렬을 곱하면, 각 성분에 이동량이 더해집니다.

\[\begin{bmatrix} 1 & 0 & 0 & t_x \\ 0 & 1 & 0 & t_y \\ 0 & 0 & 1 & t_z \\ 0 & 0 & 0 & 1 \end{bmatrix} \begin{bmatrix} x \\ y \\ z \\ 1 \end{bmatrix} = \begin{bmatrix} x + t_x \\ y + t_y \\ z + t_z \\ 1 \end{bmatrix}\]


첫 번째 성분을 직접 계산하면 $1 \cdot x + 0 \cdot y + 0 \cdot z + t_x \cdot w = x + t_x$입니다. 마지막 항 $t_x \cdot w$가 핵심으로, w = 1이면 이동량이 더해지고 w = 0이면 사라집니다.

방향 벡터(w = 0)로 확인하면 다음과 같습니다.

\[\begin{bmatrix} 1 & 0 & 0 & t_x \\ 0 & 1 & 0 & t_y \\ 0 & 0 & 1 & t_z \\ 0 & 0 & 0 & 1 \end{bmatrix} \begin{bmatrix} x \\ y \\ z \\ 0 \end{bmatrix} = \begin{bmatrix} x \\ y \\ z \\ 0 \end{bmatrix}\]

$t_x \cdot 0 = 0$이므로 이동 성분이 사라지고, 방향 벡터는 변하지 않습니다. 위치와 방향의 차이가 w 값 하나로 구분됩니다.


회전 행렬 (Rotation)

이동 행렬이 위치를 바꾸었다면, 회전 행렬은 방향을 바꿉니다. 3D 공간에서 회전은 특정 축을 중심으로 일어나며, X축, Y축, Z축 각각의 회전 행렬이 있습니다. 회전각은 $\theta$(세타)로 표기합니다.

X축 회전

X축을 중심으로 $\theta$만큼 회전하는 행렬입니다. X 성분은 변하지 않고, Y와 Z 성분이 $\cos\theta$와 $\sin\theta$의 조합으로 바뀝니다.

\[R_x(\theta) = \begin{bmatrix} 1 & 0 & 0 & 0 \\ 0 & \cos\theta & -\sin\theta & 0 \\ 0 & \sin\theta & \cos\theta & 0 \\ 0 & 0 & 0 & 1 \end{bmatrix}\]

Y축 회전

Y축을 중심으로 $\theta$만큼 회전하는 행렬입니다. Y 성분은 변하지 않고, X와 Z 성분이 바뀝니다.

\[R_y(\theta) = \begin{bmatrix} \cos\theta & 0 & \sin\theta & 0 \\ 0 & 1 & 0 & 0 \\ -\sin\theta & 0 & \cos\theta & 0 \\ 0 & 0 & 0 & 1 \end{bmatrix}\]


$\theta = 90°$($\cos 90° = 0$, $\sin 90° = 1$)를 대입하여 (5, 2, 3) 위치의 정점을 변환하면 다음과 같습니다.

\[\begin{bmatrix} 0 & 0 & 1 & 0 \\ 0 & 1 & 0 & 0 \\ -1 & 0 & 0 & 0 \\ 0 & 0 & 0 & 1 \end{bmatrix} \begin{bmatrix} 5 \\ 2 \\ 3 \\ 1 \end{bmatrix} = \begin{bmatrix} 3 \\ 2 \\ -5 \\ 1 \end{bmatrix}\] \[\begin{aligned} x' &= 0 \times 5 + 0 \times 2 + 1 \times 3 = 3 \quad \text{(원래 Z 성분)} \\ y' &= 2 \quad \text{(변하지 않음)} \\ z' &= -1 \times 5 + 0 \times 2 + 0 \times 3 = -5 \quad \text{(원래 X 성분의 부호 반전)} \end{aligned}\]

Y 성분(높이)은 그대로 유지되고, X와 Z 성분(수평 위치)이 바뀝니다. 게임에서 캐릭터를 Y축 기준으로 회전시키면 좌우로 도는 동작이 됩니다.

Z축 회전

Z축을 중심으로 $\theta$만큼 회전하는 행렬입니다. Z 성분은 변하지 않고, X와 Y 성분이 바뀝니다.

\[R_z(\theta) = \begin{bmatrix} \cos\theta & -\sin\theta & 0 & 0 \\ \sin\theta & \cos\theta & 0 & 0 \\ 0 & 0 & 1 & 0 \\ 0 & 0 & 0 & 1 \end{bmatrix}\]


세 행렬에 공통 패턴이 있습니다. 회전 축의 성분은 변하지 않으므로 해당 행과 열은 단위 행렬(대각선이 1, 나머지가 0)과 동일하고, 실제로 변하는 나머지 2×2 부분에만 $\cos\theta$와 $\sin\theta$가 배치됩니다.

오일러 각과 짐벌 락

3D 공간에서 원하는 방향을 만들기 위해, X축, Y축, Z축 회전을 순서대로 적용하는 방식을 오일러 각(Euler Angles)이라 합니다. Unity의 Transform 인스펙터에서 Rotation 항목에 표시되는 (x, y, z) 값이 오일러 각입니다.


오일러 각은 직관적이지만 구조적 한계가 있습니다. 세 축의 회전을 순서대로 적용하는 과정에서, 특정 조건에서 축 하나가 다른 축과 겹치면서 자유도가 하나 줄어드는 현상이 발생합니다. 이 현상이 짐벌 락(Gimbal Lock)입니다.

일반 상태 (3 자유도) X Y Z 세 회전축이 직교 → 3 자유도 중간 축 회전이 ±90°에 도달하면 짐벌 락 (2 자유도) Y Z X X축과 Z축의 회전축이 정렬 → 독립 자유도 1개 상실 짐벌 장치에서의 비유


예를 들어, 중간 축(Y)의 회전이 90°에 가까워지면 X축 회전과 Z축 회전의 회전축이 정렬되어 같은 효과를 내게 됩니다. 카메라가 정면을 바라보다가 완전히 위를 향하면, 좌우 회전과 기울이기가 구분되지 않는 상황이 짐벌 락입니다. 이 상태에서는 원하는 방향으로의 부드러운 회전이 불가능합니다.

이 문제를 해결하기 위해 쿼터니언(Quaternion)이라는 수학적 표현이 사용됩니다. 쿼터니언은 4개의 성분 (x, y, z, w)으로 3D 회전을 표현하며, 짐벌 락이 발생하지 않고 두 회전 사이의 부드러운 보간(Slerp)도 가능합니다.


Unity의 transform.rotation은 내부적으로 쿼터니언을 사용합니다. 인스펙터에 표시되는 오일러 각 값은 사람이 읽기 쉽도록 변환하여 보여주는 것이고, 실제 데이터는 쿼터니언으로 저장됩니다.


스케일 행렬 (Scale)

이동과 회전에 이어, 세 번째 변환인 스케일은 오브젝트의 크기를 바꾸는 변환입니다. 각 축의 스케일 값 $(s_x, \; s_y, \; s_z)$를 4×4 행렬의 대각 성분에 배치합니다.


\[S = \begin{bmatrix} s_x & 0 & 0 & 0 \\ 0 & s_y & 0 & 0 \\ 0 & 0 & s_z & 0 \\ 0 & 0 & 0 & 1 \end{bmatrix}\]

\(s_x\) = x축 스케일, \(s_y\) = y축 스케일, \(s_z\) = z축 스케일


이 행렬을 벡터 (x, y, z, 1)에 곱하면, 각 성분이 해당 축의 스케일 값으로 곱해집니다.

\[\begin{bmatrix} s_x & 0 & 0 & 0 \\ 0 & s_y & 0 & 0 \\ 0 & 0 & s_z & 0 \\ 0 & 0 & 0 & 1 \end{bmatrix} \begin{bmatrix} x \\ y \\ z \\ 1 \end{bmatrix} = \begin{bmatrix} s_x \cdot x \\ s_y \cdot y \\ s_z \cdot z \\ 1 \end{bmatrix}\]


$s_x = s_y = s_z = 2$이면 오브젝트가 X, Y, Z 모든 축에서 2배로 커집니다. 이처럼 모든 축의 스케일 값이 동일한 경우를 균일 스케일(Uniform Scale)이라 합니다.

비균일 스케일과 부작용

비균일 스케일(Non-uniform Scale)은 축마다 스케일 값이 다른 변환입니다. (2, 1, 1)이면 X축으로만 2배 늘어납니다.

균일 스케일 (2, 2, 2) 모든 방향으로 동일하게 확대 비균일 스케일 (2, 1, 1) X축만 늘어남 비율이 유지됨 비율이 왜곡됨


비균일 스케일은 편리하지만, 렌더링과 물리 양쪽에서 부작용을 일으킵니다.

첫째, 법선 벡터가 왜곡됩니다.

메쉬가 축마다 다른 비율로 늘어나면 표면의 기울기가 바뀌는데, 법선에 같은 스케일 행렬을 그대로 적용하면 바뀐 기울기를 반영하지 못해 법선이 표면에 수직이 아닌 방향을 가리킵니다.

올바른 법선을 구하려면, 스케일 행렬의 역행렬(변환을 되돌리는 행렬)을 구한 뒤 행과 열을 뒤바꾼 역전치 행렬(Inverse Transpose)을 법선에 곱해야 합니다. 이 추가 연산이 성능 비용으로 이어집니다.


둘째, 물리 엔진의 충돌 판정이 부정확해질 수 있습니다.

구(Sphere) 콜라이더에 비균일 스케일을 적용하면 형상이 타원이 되어야 하지만, Unity의 SphereCollider는 타원을 지원하지 않아 실제 메쉬와 콜라이더가 어긋납니다.


셋째, 부모 오브젝트에 비균일 스케일이 적용된 상태에서 자식을 회전시키면 전단(Shearing)이 나타날 수 있습니다.

직사각형이 평행사변형처럼 비틀리는 변형으로, 비균일 스케일 행렬과 회전 행렬을 곱하면 축이 직교하지 않게 되면서 발생합니다.


모바일에서는 역전치 행렬 계산 등 추가 연산의 성능 부담이 크므로, 가능하면 균일 스케일을 사용하고 형상을 바꿔야 할 때는 메쉬 자체를 수정하는 편이 낫습니다.


변환의 합성 — 행렬 곱셈

지금까지 이동, 회전, 스케일 행렬을 각각 살펴보았는데, 실제 게임에서는 세 변환이 동시에 필요합니다. 캐릭터 하나만 봐도 어딘가에 서 있고, 어딘가를 바라보며, 일정한 크기를 갖습니다.

행렬 곱셈은 이 세 개별 행렬을 하나의 4×4 행렬 $M$으로 합성할 수 있게 해 줍니다.


\[M = \underbrace{\begin{bmatrix} 1 & 0 & 0 & t_x \\ 0 & 1 & 0 & t_y \\ 0 & 0 & 1 & t_z \\ 0 & 0 & 0 & 1 \end{bmatrix}}_{T} \times \underbrace{\begin{bmatrix} \cdot & \cdot & \cdot & 0 \\ \cdot & \cdot & \cdot & 0 \\ \cdot & \cdot & \cdot & 0 \\ 0 & 0 & 0 & 1 \end{bmatrix}}_{R} \times \underbrace{\begin{bmatrix} s_x & 0 & 0 & 0 \\ 0 & s_y & 0 & 0 \\ 0 & 0 & s_z & 0 \\ 0 & 0 & 0 & 1 \end{bmatrix}}_{S}\]

이처럼 개별 변환을 먼저 정의한 뒤 곱셈으로 합성하는 구조 덕분에, 변환의 종류나 개수가 달라져도 최종 결과는 언제나 하나의 $M$으로 귀결됩니다.

행렬 곱셈의 비교환성

숫자 곱셈에서는 $3 \times 5 = 5 \times 3$이지만, 행렬 곱셈에서는 $A \times B \neq B \times A$입니다.

곱하는 순서가 바뀌면 결과도 달라집니다.

오브젝트가 원점 (0, 0, 0)에 있을 때, 앞서 본 Y축 회전 행렬($\theta = +90°$)과 (5, 0, 0) 이동을 어떤 순서로 적용하느냐에 따라 결과가 완전히 달라집니다. $R_y(+90°)$는 +X 방향을 -Z 방향으로 회전시키는 행렬입니다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
행렬 곱셈의 비교환성 — 이동 후 회전 vs 회전 후 이동
(오브젝트 초기 위치: 원점, 회전: Ry(+90°) — +X를 -Z로 회전)

이동 후 회전:
  1) 오브젝트를 (5, 0, 0)으로 이동
  2) 원점 기준 Ry(+90°) 적용 → +X 방향이 -Z 방향으로 회전
  → 오브젝트는 (0, 0, -5) 위치에 놓임

회전 후 이동:
  1) 원점 기준 Ry(+90°) 적용 (원점이므로 위치 변화 없음)
  2) (5, 0, 0)으로 이동
  → 오브젝트는 (5, 0, 0) 위치에 놓임

→ 같은 변환이라도 적용 순서에 따라 결과가 전혀 다름
위에서 내려다본 XZ 평면 (Y축은 화면 수직 방향, ⊙ 표시) 이동 → 회전 X Z 원점 ① 이동 (+5,0,0) (5,0,0) ② Ry(+90°) (0, 0, −5) 회전 → 이동 X Z 원점 ① Ry(+90°) (위치 변화 없음) ② 이동 (+5,0,0) (5, 0, 0) 같은 변환이라도 적용 순서에 따라 결과가 전혀 다름

TRS 순서

행렬 곱셈의 순서가 결과를 바꾸기 때문에, 3D 그래픽스에서는 변환을 적용하는 표준 순서가 정해져 있습니다.

Scale → Rotate → Translate 순서입니다.

오브젝트가 아직 원점에 있을 때 크기를 조절하고, 원점 기준으로 회전하여 방향을 잡은 뒤, 최종 위치로 옮깁니다.

스케일과 회전은 원점을 기준으로 작동하는 변환이므로, 이동 전에 적용해야 위치를 건드리지 않고 형태와 방향만 바꿀 수 있습니다.

이 적용 순서를 행렬 곱셈으로 표기하면 $M = T \times R \times S$입니다.

TRS 순서라는 이름은 이 행렬 표기에서 T, R, S가 왼쪽부터 나열되는 순서에서 유래합니다.


다만, 표기 순서와 적용 순서는 반대입니다. $T \times R \times S$에서 왼쪽에 있는 T가 먼저 적용되는 것이 아니라, 행렬 곱셈에서는 오른쪽 행렬이 먼저 적용됩니다. 벡터 $\mathbf{v}$에 가장 가까운 $S$가 첫 번째로 곱해지고, 그 결과에 $R$, 마지막으로 $T$가 곱해집니다.

\[\begin{aligned} \mathbf{v'} &= M \times \mathbf{v} \\ &= T \times R \times S \times \mathbf{v} \\ &= T \times (R \times (S \times \mathbf{v})) \end{aligned}\]
T (이동) R (회전) S (스케일) × × ← 적용 순서 (오른쪽이 먼저)


앞의 비교환성 예시에서 확인한 것처럼, TRS 순서를 지키지 않으면 결과가 달라집니다.

이동을 먼저 적용하면 오브젝트가 원점에서 이미 멀어진 상태이므로, 이후의 회전이 제자리에서 도는 자전이 아니라 원점을 중심으로 도는 공전이 됩니다.

(원점, 기본 크기) ① Scale (원점, 크기 변화) ② Rotate (원점, 방향 변화) ③ Translate (최종 위치에 배치) 원점에서 형태(S)와 방향(R)을 먼저 잡은 뒤, 마지막에 위치(T)를 지정


Unity의 Transform 컴포넌트는 position, rotation, localScale을 내부적으로 이 TRS 순서로 합성하여 localToWorldMatrix를 만듭니다.


Unity의 Transform과 행렬

Unity에서 모든 게임 오브젝트는 Transform 컴포넌트를 가집니다.

Transform은 오브젝트의 위치(position), 회전(rotation), 크기(localScale)를 저장하고, 이 세 값을 내부적으로 4x4 행렬로 합성하여 좌표 변환에 사용합니다.


Transform 위치 position Vector3 월드 공간 위치 localPosition Vector3 부모 기준 로컬 위치 회전 rotation Quaternion 월드 공간 회전 localRotation Quaternion 부모 기준 로컬 회전 eulerAngles Vector3 오일러 각 (읽기/쓰기용) 스케일 localScale Vector3 부모 기준 스케일 lossyScale Vector3 월드 공간 스케일 (읽기 전용) 변환 행렬 localToWorldMatrix Matrix4x4 로컬 → 월드 변환 행렬 worldToLocalMatrix Matrix4x4 월드 → 로컬 변환 행렬

localToWorldMatrix

localToWorldMatrix는 오브젝트의 로컬 공간 좌표를 월드 공간 좌표로 변환하는 4x4 행렬입니다.

부모가 없는 루트 오브젝트의 경우, position, rotation, localScale을 TRS 순서로 합성한 결과와 같습니다. (부모가 있는 경우는 부모-자식 관계와 행렬 계층에서 다룹니다.)

\[\text{localToWorldMatrix} = T \times R \times S\]

$T$ = 이동 행렬 (position 기반), $R$ = 회전 행렬 (rotation 기반), $S$ = 스케일 행렬 (localScale 기반)


메쉬의 정점은 로컬 공간에 정의되어 있으므로, localToWorldMatrix를 곱해야 월드 공간 좌표가 됩니다.

예를 들어 캐릭터 모델의 코 끝이 로컬 좌표 (0, 1.7, 0.1)일 때, 이 정점을 동차 좌표 (0, 1.7, 0.1, 1)로 표현한 뒤 localToWorldMatrix를 곱하면 월드 좌표를 얻습니다. (w = 1은 위치를 뜻하며, 이동 변환이 적용되도록 합니다.)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
로컬 좌표 → 월드 좌표 변환

캐릭터의 Transform:
  position   = (10, 0, 5)
  rotation   = Y축 90도
  localScale = (1, 1, 1)

로컬 좌표:  (0, 1.7, 0.1)

월드 좌표 = localToWorldMatrix × (0, 1.7, 0.1, 1)

→ 스케일 적용:   (0, 1.7, 0.1)     (스케일 1이므로 변화 없음)
→ Y축 90도 회전: (0.1, 1.7, 0)     (x'=z, z'=-x 이므로 x'=0.1, z'=0)
→ 이동 적용:     (10.1, 1.7, 5.0)  (position 더함)


지금까지 다룬 로컬 → 월드 변환은, GPU가 정점을 화면에 그리기까지 거치는 여러 좌표 변환 중 첫 번째입니다.

GPU는 화면에 그리기 전에 클리핑(Clipping)이라는 단계를 거칩니다. GPU는 메쉬를 삼각형 단위로 처리하는데, 프러스텀 컬링이 오브젝트 전체를 대상으로 “화면에 보이는가”를 판정하는 CPU 단계라면, 클리핑은 이 삼각형 하나하나가 화면 안에 있는지를 검사하는 GPU 단계입니다.

화면 경계에 걸쳐 있는 삼각형은 경계선에서 잘라 보이는 부분만 남기고, 완전히 밖에 있는 삼각형은 폐기합니다. 클립 공간(Clip Space)은 이 클리핑 판정이 이루어지는 좌표 공간입니다.

버텍스 셰이더가 로컬 공간에서 클립 공간까지의 변환을 한 번에 수행하며, Unity 셰이더에서는 UnityObjectToClipPos(v.vertex) 함수가 이를 담당합니다.


내부적으로는 세 단계의 행렬이 순서대로 정점에 적용됩니다.

먼저 Model 행렬이 로컬 공간을 월드 공간으로, 다음으로 View 행렬이 월드 공간을 카메라 기준 좌표로, 마지막으로 Projection 행렬이 카메라 좌표를 클립 공간으로 변환합니다.

이 중 Model 행렬이 바로 localToWorldMatrix이며, 나머지 View 행렬과 Projection 행렬을 포함한 전체 좌표 공간 전환 과정은 그래픽스 수학 (3) - 좌표 공간의 전환에서 이어집니다.

worldToLocalMatrix

worldToLocalMatrix는 localToWorldMatrix의 역행렬(Inverse Matrix)으로, 월드 공간의 좌표를 오브젝트의 로컬 공간 좌표로 변환합니다.

역행렬은 원래 변환을 되돌리는 행렬입니다. 이동 (10, 0, 5)의 역행렬은 이동 (-10, 0, -5)이고, 90도 회전의 역행렬은 -90도 회전이며, 스케일 2의 역행렬은 스케일 0.5입니다.

행렬 $A$에 역행렬 $A^{-1}$을 곱하면 단위 행렬(Identity Matrix), 즉 아무 변환도 하지 않는 행렬이 됩니다.

\[\text{worldToLocalMatrix} = \text{localToWorldMatrix}^{-1}\]


앞의 예시에서 사용한 캐릭터(position = (10, 0, 5), rotation = Y축 90도, localScale = (1, 1, 1))를 그대로 사용하면,

월드 좌표 $(15, \; 3, \; 8)$에 있는 점을 이 캐릭터의 로컬 공간 좌표로 변환할 수 있습니다.

\[\text{로컬 좌표} = \text{worldToLocalMatrix} \times \begin{bmatrix} 15 \\ 3 \\ 8 \\ 1 \end{bmatrix}\]

worldToLocalMatrix는 월드 공간의 데이터를 오브젝트 기준으로 해석할 때 사용됩니다.

예를 들어 적이 플레이어의 앞에 있는지 뒤에 있는지 알고 싶을 때, 월드 좌표는 월드 축 기준이라 적의 좌표만 봐서는 플레이어 기준의 앞/뒤가 바로 드러나지 않습니다.

이때 적의 월드 좌표를 플레이어의 worldToLocalMatrix로 변환하면, 플레이어의 로컬 공간에서 +z는 항상 정면, +x는 항상 오른쪽이므로 변환된 좌표의 z 부호만으로 앞/뒤, x 부호만으로 좌/우를 바로 판단할 수 있습니다.

부모-자식 관계와 행렬 계층

Unity에서 오브젝트가 부모-자식 관계(hierarchy)를 가지면, 자식의 로컬 TRS 행렬은 부모를 기준으로 한 상대적 변환이므로, 자식의 월드 좌표를 구하려면 부모의 localToWorldMatrix까지 곱해야 합니다.

즉, 자식의 localToWorldMatrix는 부모의 localToWorldMatrix에 자신의 로컬 TRS 행렬을 곱한 결과입니다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
부모-자식 행렬 계층

자식의 월드 행렬 = 부모의 월드 행렬 × 자식의 로컬 행렬

예: 탱크의 포탑
  탱크 (부모): position (100, 0, 50), rotation Y축 30도
  포탑 (자식): localPosition (0, 2, 0), localRotation Y축 15도

  포탑의 월드 행렬
    = 탱크의 localToWorldMatrix × 포탑의 로컬 TRS 행렬

  → 포탑은 탱크의 위에 위치하면서
     탱크의 방향(30도) + 자신의 추가 회전(15도) = 45도를 바라봄
    (같은 축 회전이라 각도가 단순 덧셈됨)


자식의 월드 행렬은 부모의 월드 행렬에 자신의 로컬 TRS 행렬을 곱한 결과이므로, 부모를 움직이면 자식도 함께 움직입니다.

캐릭터의 손에 무기를 붙이거나 차량의 바퀴를 차체에 연결할 수 있는 것도 이 행렬 계층 덕분입니다.


계층이 깊어지면 최종 월드 행렬을 구하기 위해 곱해야 하는 행렬의 수가 늘어납니다.

Unity에서 Transform의 position이나 rotation을 변경하면, 해당 오브젝트의 변환 행렬이 즉시 재계산되고, 모든 자식의 월드 행렬도 재귀적으로 다시 계산됩니다.

정적인 오브젝트는 변경이 발생하지 않으므로 재계산 비용이 없지만, 부모의 Transform을 매 프레임 변경하면 그 아래 모든 자식의 월드 행렬이 매 프레임 다시 계산됩니다.

계층이 깊고 자식이 많은 구조에서 부모가 매 프레임 변하면 CPU 부하가 커지므로, 자주 움직이는 오브젝트는 계층을 얕게 유지하거나 불필요한 중간 노드를 줄이는 것이 좋습니다.

Transform 변경 전파의 구체적인 비용 구조와 최적화 방법은 Unity 엔진 핵심 (2) - Transform 계층과 씬 그래프에서 확인할 수 있습니다.


마무리

벡터가 위치와 방향을 표현한다면, 행렬은 이동·회전·스케일을 하나의 곱셈 연산으로 통일하는 변환 도구입니다.


  • 3x3 행렬은 원점을 움직일 수 없으므로 이동을 표현하지 못합니다. 동차 좌표 (x, y, z, w)를 도입하여 4x4 행렬로 확장하면, 이동·회전·스케일을 하나의 행렬-벡터 곱셈으로 처리할 수 있습니다.
  • w = 1은 위치(이동 적용), w = 0은 방향(이동 무시)으로 구분됩니다.
  • 이동은 4x4 행렬의 네 번째 열에, 회전은 왼쪽 위 3x3 영역에 cos/sin 값으로, 스케일은 대각 성분에 배치됩니다.
  • 행렬 곱셈은 비교환적이므로 적용 순서가 결과를 바꿉니다. 3D 그래픽스에서는 Scale → Rotate → Translate 순서(TRS)로 적용하여, 원점에서 형태와 방향을 잡은 뒤 최종 위치로 옮깁니다.
  • 오일러 각은 짐벌 락이 발생할 수 있으므로, Unity는 내부적으로 쿼터니언을 사용합니다.
  • 비균일 스케일은 법선 왜곡, 물리 충돌 부정확, 전단 현상을 일으킵니다. 모바일에서는 역전치 행렬 계산의 성능 부담이 크므로, 균일 스케일을 우선 사용합니다.
  • Unity Transform의 localToWorldMatrix는 position, rotation, localScale을 TRS 순서로 합성한 4x4 행렬이며, 버텍스 셰이더의 Model 행렬로 사용됩니다.
  • 부모-자식 관계에서 자식의 월드 행렬은 부모의 월드 행렬에 자식의 로컬 TRS 행렬을 곱한 결과입니다. 부모를 움직이면 자식도 함께 움직이며, 계층이 깊고 자주 변하면 CPU 부하가 커집니다.

여기서 다룬 TRS 합성은 오브젝트의 로컬 공간을 월드 공간으로 옮기는 첫 번째 단계입니다.

정점은 월드 공간 이후에도 카메라 공간, 클립 공간 등 여러 좌표 공간을 거치며, 각 단계마다 서로 다른 변환 행렬이 적용됩니다. 그래픽스 수학 (3) - 좌표 공간의 전환에서 이 좌표 공간 전환의 전체 과정을 이어갑니다.


관련 글

시리즈

전체 시리즈

Tags: Unity, 그래픽스, 모바일, 수학, 행렬

Categories: ,