이번에는 앞에서 배운 외적부터 Basis, Matrix까지 한 번에 연결해서 정리해본다.
각 개념을 따로 보면 서로 관련이 없어 보일 수 있지만, 게임에서는 다음과 같은 흐름으로 이어진다.
방향을 표현한다
↓
Vector
두 방향에 수직인 새로운 방향이 필요하다
↓
Cross Product
Forward / Right / Up 같은 기준축이 필요하다
↓
Basis
Basis를 기준으로 Local 방향과 좌표를 표현한다
↓
Coordinate System
Basis와 여러 변환을 한 번에 계산하고 싶다
↓
Matrix
외적(Cross Product)은 두 벡터로부터 두 벡터 모두에 수직인 새로운 벡터를 만드는 연산이다.
게임에서는 주로 다음과 같은 곳에서 사용한다.
예를 들어
X = (1, 0, 0)
Y = (0, 1, 0)
라면
X × Y = (0, 0, 1)
이 된다.
결과는 X와 Y 모두에 수직인 Z 방향이다.
외적에서 중요한 성질이 하나 있다.
A × B = -(B × A)
즉, 순서를 바꾸면 결과 방향이 반대가 된다.
게임의 3D 모델은 대부분 삼각형으로 이루어져 있다.
삼각형의 세 점이 다음과 같다고 하자.
P0 = (0, 0, 0)
P1 = (0, 3, 0)
P2 = (0, 0, 2)
우리가 알고 싶은 것은
이 삼각형 표면이 어느 방향을 보고 있는가?
이다.
점 하나만 가지고는 표면의 방향을 알 수 없기 때문에 먼저 삼각형 위의 두 변을 만든다.
E1 = P1 - P0
E2 = P2 - P0
계산하면
E1 = (0, 3, 0)
E2 = (0, 0, 2)
두 벡터 모두 삼각형 표면 위에 놓여 있다.
이제 두 벡터를 외적한다.
N = E1 × E2
결과는
N = (6, 0, 0)
이다.
방향만 필요하다면 Normalize한다.
N = (1, 0, 0)
따라서 이 정점 순서에서 계산한 삼각형의 Normal은 +X 방향이다.
Normal은 게임에서 굉장히 자주 사용된다.
빛이 표면에 얼마나 정면으로 들어오는가?
충돌한 물체를 어느 방향으로 밀어낼 것인가?
벽을 따라 어느 방향으로 이동시킬 것인가?
총알이 어느 방향으로 반사될 것인가?
같은 계산의 기준이 된다.
이번에는
E2 × E1
을 계산하면 결과가 반대가 된다.
(-6, 0, 0)
Normalize하면
(-1, 0, 0)
이다.
따라서 삼각형에서 정점 순서는 실제 의미를 가진다.
정점의 순서에 따라 Normal 방향이 달라지고, 렌더링에서도 앞면과 뒷면을 판정할 때 정점의 회전 방향인 Winding Order를 사용한다.
단, 시계 방향과 반시계 방향 중 무엇을 앞면으로 사용할지는 그래픽 API나 엔진 설정에 따라 달라질 수 있다.
외적은 방향만 만들어내는 것이 아니다.
|A × B|
의 크기는 A와 B가 만드는 평행사변형의 넓이다.
따라서 삼각형의 넓이는
TriangleArea = |A × B| / 2
로 구할 수 있다.
외적 결과의 크기가 거의 0이라면 두 변이 거의 같은 방향이라는 의미이므로, 삼각형의 넓이 역시 거의 0이다.
이런 삼각형을 Degenerate Triangle, 즉 퇴화 삼각형이라고 볼 수 있다.
Forward와 Right가 있다고 하자.
Forward = (1, 0, 0)
Right = (0, 1, 0)
W를 누르면 앞으로 이동하고 D를 누르면 오른쪽으로 이동한다고 하면
Move += Forward * InputY;
Move += Right * InputX;
처럼 표현할 수 있다.
W와 D를 동시에 눌렀다면
Move = Forward + Right
이므로
(1, 0, 0) + (0, 1, 0)
= (1, 1, 0)
이 된다.
이것이 선형 결합(Linear Combination)이다.
일반적으로
aA + bB
처럼 여러 벡터에 숫자를 곱해서 더하는 것을 선형 결합이라고 한다.
실제 캐릭터 이동에서는 (1,1,0)의 길이가 √2이기 때문에 대각선 이동이 더 빨라지지 않도록 입력 벡터를 Normalize하거나 길이를 제한하는 처리를 함께 사용하는 경우가 많다.
Forward와 Right만 가지고
aForward + bRight
를 만든다고 생각해보자.
Forward = (1, 0, 0)
Right = (0, 1, 0)
a와 b에 어떤 숫자를 넣더라도 결과의 Z값은 항상 0이다.
따라서 Forward와 Right만으로는 바닥 평면의 모든 방향은 만들 수 있지만 Up이나 Down 방향은 만들 수 없다.
이렇게 주어진 벡터들의 선형 결합으로 만들 수 있는 모든 범위를 Span이라고 한다.
게임 개발에서는 다음처럼 생각할 수 있다.
현재 가지고 있는 방향축들로 어느 공간까지 표현할 수 있는가?
Forward와 Right의 Span은 2차원 평면이고,
Forward, Right, Up 세 축이 서로 독립이라면 3차원 공간 전체를 표현할 수 있다.
다음과 같은 두 벡터가 있다고 하자.
Forward = (1, 0, 0)
Right = (2, 0, 0)
Right라는 이름을 붙였지만 실제로는 Forward의 두 배일 뿐이다.
따라서
aForward + bRight
를 아무리 계산해도 X축 밖으로 나갈 수 없다.
Right가 새로운 방향 정보를 전혀 제공하지 않는 것이다.
이런 상태를 선형 종속(Linear Dependence)이라고 한다.
반대로
Forward = (1, 0, 0)
Right = (0, 1, 0)
이라면 두 벡터는 서로 다른 방향을 제공하고, 한 벡터를 다른 벡터의 배수로 만들 수 없다.
이 둘은 선형 독립(Linear Independence)이다.
Basis는 공간 전체를 표현할 수 있는 선형 독립인 벡터들의 집합이다.
3D 게임에서 가장 익숙한 예는
Forward
Right
Up
이다.
세 벡터가 서로 독립이고 3차원 공간 전체를 표현할 수 있다면 하나의 Basis를 구성한다.
World의 기준축이 다음과 같다고 해보자.
World X = 동쪽
World Y = 북쪽
World Z = 위
캐릭터가 동쪽을 보고 있다면 Character Forward는 World X와 같다.
하지만 캐릭터가 90도 회전해서 북쪽을 바라보게 되면
Character Forward = World Y
가 된다.
여기서 중요한 점은 같은 숫자라도 어느 Basis를 기준으로 표현했는가에 따라 의미가 달라진다는 것이다.
캐릭터 Local 좌표에서
(1, 0, 0)
은
캐릭터 기준 앞으로 1
이라는 뜻이다.
캐릭터가 북쪽을 바라보고 있다면 이 Local 벡터는 World 좌표에서
(0, 1, 0)
이 될 수 있다.
이것이 Local Space와 World Space를 구분해야 하는 이유다.
Basis의 각 축이 서로 90도를 이루고 있다면 Orthogonal Basis라고 한다.
여기에 각 벡터의 길이까지 모두 1이라면 Orthonormal Basis가 된다.
캐릭터의
Forward
Right
Up
을 Orthonormal 상태로 유지하면 여러 계산이 간단해진다.
예를 들어 Velocity가 있고 이 속도 중 캐릭터 Forward 방향 성분만 알고 싶다고 하자.
float ForwardSpeed =
FVector::DotProduct(Velocity, Forward);
Forward가 단위 벡터라면 이 값은 Velocity가 Forward 방향으로 얼마나 향하고 있는지를 나타내는 부호 있는 속도 성분이 된다.
예를 들어
Velocity = 5Forward + 2Right
라면
Velocity · Forward = 5
이다.
따라서 캐릭터 기준 Forward 방향 속도 성분은 5다.
반대로 값이 -5라면 Forward 반대 방향, 즉 뒤쪽으로 5만큼 움직이고 있다는 뜻이다.
Orthonormal Basis에서는 이런 식으로 내적만 사용해서 특정 축 방향 성분을 쉽게 분리할 수 있다.
계산을 반복하다 보면 Basis가 조금씩 틀어질 수 있다.
예를 들어
Forward = (1, 0, 0)
Right = (1, 1, 0)
이라고 하자.
Right가 Forward 방향으로 기울어져 있기 때문에 두 축은 서로 수직이 아니다.
Right에서 Forward 방향 성분을 제거하면 된다.
Right' = Right - projForward(Right)
Forward가 단위 벡터라면
projForward(Right)
= (Right · Forward)Forward
이다.
현재 값에서는
Right · Forward = 1
이므로
Right'
= (1,1,0) - (1,0,0)
= (0,1,0)
이 된다.
필요하다면 마지막에 Right'도 Normalize한다.
이런 식으로 여러 벡터를 서로 수직인 축으로 만드는 과정을 Gram-Schmidt Orthogonalization이라고 한다.
카메라나 좌표축을 다시 정리하거나, 특정 표면을 기준으로 새로운 Basis를 구성할 때 사용할 수 있다.
게임 수학에서 Matrix는 회전, 스케일, 좌표 변환 같은 선형 변환을 표현하는 구조로 볼 수 있다.
열벡터(Column Vector)를 사용하는 방식으로 다음 행렬을 생각해보자.
[2 0]
M = [0 3]
그리고
[4]
V = [5]
가 있다고 하자.
행렬과 벡터를 곱하면
[2 0][4]
MV = [0 3][5]
각 결과는 행과 열의 내적으로 계산한다.
2×4 + 0×5 = 8
0×4 + 3×5 = 15
따라서
[ 8]
MV = [15]
이다.
위 행렬의 열벡터를 보면
E1 = (2, 0)
E2 = (0, 3)
이다.
입력 벡터가
V = (4, 5)
였으므로
MV = 4E1 + 5E2
라고 볼 수 있다.
실제로 계산하면
4(2,0) + 5(0,3)
= (8,0) + (0,15)
= (8,15)
이다.
즉 행렬 × 벡터 연산은 행렬의 열벡터들을 입력 벡터의 좌표값만큼 선형 결합하는 것으로 이해할 수 있다.
조금 더 정확하게 말하면, 행렬의 각 열은 표준 Basis 벡터가 이 변환을 거친 뒤 어디로 이동하는지를 나타낸다.
2차원 Identity Matrix는 다음과 같다.
[1 0]
I = [0 1]
열벡터를 보면
X = (1,0)
Y = (0,1)
이다.
벡터
V = (3,2)
를 곱하면
IV = 3X + 2Y
이므로
IV = (3,2)
가 된다.
입력 벡터가 전혀 변하지 않는다.
그래서 Identity Matrix는 곱해도 아무 변화가 없는 행렬이다.
다음 행렬을 보자.
[ 0 -1]
R = [ 1 0]
X축 벡터를 넣어보면
[1]
X = [0]
RX = (0,1)
이 된다.
즉 X축이 Y축으로 이동했다.
이번에는 Y축을 넣으면
RY = (-1,0)
이 된다.
따라서 이 행렬은 일반적인 2D Cartesian 좌표계에서 벡터를 반시계 방향으로 90도 회전시키는 행렬이다.
행렬의 열을 보면
첫 번째 열 = (0,1)
두 번째 열 = (-1,0)
인데,
이는 원래의 X Basis와 Y Basis가 변환 후 어디를 향하는지를 그대로 보여준다.
두 행렬이 있다고 하자.
[1 2]
A = [3 4]
[5 6]
B = [7 8]
AB를 계산한다.
첫 번째 원소는 A의 첫 번째 행과 B의 첫 번째 열의 내적이다.
1×5 + 2×7 = 19
두 번째 원소는
1×6 + 2×8 = 22
두 번째 행도 같은 방법으로 계산하면
3×5 + 4×7 = 43
3×6 + 4×8 = 50
따라서
[19 22]
AB = [43 50]
이다.
행렬곱이 가능하려면
A의 열 개수 = B의 행 개수
여야 한다.
행렬곱은 일반적으로
AB ≠ BA
이다.
따라서 변환을 합성할 때 순서가 중요하다.
현재처럼 열벡터 방식을 사용한다고 하면
ABv
는
v
↓
B
↓
A
순서로 적용된다.
즉,
ABv = A(Bv)
이므로 오른쪽 행렬부터 적용된다.
예를 들어
WorldPosition = Translation × Rotation × LocalPosition
형태라면 LocalPosition에 Rotation이 먼저 적용되고 그 결과에 Translation이 적용되는 식이다.
실제 엔진에서는 행벡터/열벡터 방식이나 행렬 저장 방식이 다를 수 있기 때문에 API의 convention을 확인해야 한다.
캐릭터의 Basis가
Forward
Right
Up
이라고 하자.
열벡터 방식에서는 이 세 벡터를 행렬의 열로 배치해
M = [Forward Right Up]
처럼 생각할 수 있다.
Local 방향
Vlocal = (x, y, z)
에 M을 곱하면
Vworld
= M Vlocal
= xForward
+ yRight
+ zUp
이 된다.
즉 Local 좌표에 적힌 숫자를 Character Basis를 기준으로 다시 조합해서 World 방향을 얻는 것이다.
여기서 중요한 점이 하나 있다.
방향 벡터와 위치는 다르다.
회전만 생각한다면 3×3 Matrix로 방향을 변환할 수 있지만, 오브젝트의 실제 위치를 Local에서 World로 변환하려면 Translation도 필요하다.
개념적으로는
Pworld = R Plocal + T
형태가 된다.
게임 그래픽스에서는 Rotation, Scale, Translation을 한 번에 다루기 위해 4×4 Homogeneous Matrix를 많이 사용한다.
Transpose는 행렬의 행과 열을 서로 바꾸는 연산이다.
[1 2 3]
A = [4 5 6]
이라면
[1 4]
A^T = [2 5]
[3 6]
이 된다.
특히 Orthonormal Rotation Matrix에서는 중요한 성질이 있다.
R^-1 = R^T
즉 회전 행렬이 Orthonormal하다면 비싼 일반 역행렬 계산 대신 Transpose로 역회전을 구할 수 있다.
Normal 변환에서도 Transpose가 등장하지만, 일반적인 변환에서 Normal에 단순히 Transpose만 적용하는 것은 아니다.
특히 Non-uniform Scale이 포함되어 있다면 Normal은 보통 변환 행렬의 Inverse Transpose를 사용해 변환한다.
N' = (M^-1)^T N
지금까지 배운 내용을 게임에서의 역할로 연결하면 다음과 같다.
| 개념 | 게임에서의 의미 |
|---|---|
| Cross Product | 표면 Normal 생성, 좌우 방향 판정 |
| Cross의 크기 | 평행사변형 넓이, 삼각형 넓이 |
| Linear Combination | 여러 방향을 조합해서 이동 방향 생성 |
| Span | 현재 축들로 표현 가능한 공간 |
| Linear Independence | 축들이 서로 중복된 정보를 가지지 않는지 확인 |
| Basis | 좌표계를 구성하는 기준축 |
| Orthogonal Basis | 기준축들이 서로 수직 |
| Orthonormal Basis | 서로 수직이고 길이도 1인 Basis |
| Gram-Schmidt | 틀어진 Basis를 다시 수직으로 정리 |
| Matrix | 선형 변환을 표현 |
| Matrix × Vector | 벡터에 변환 적용 |
| Matrix × Matrix | 여러 변환을 하나의 변환으로 합성 |
| Identity Matrix | 아무 변화도 주지 않는 변환 |
| Transpose | 행과 열 교환, Orthonormal 행렬의 역행렬 계산 등에 사용 |
처음에는 Cross, Basis, Matrix가 서로 다른 개념처럼 보였다.
하지만 게임에서는 결국 다음과 같이 연결된다.
Vector
↓
방향을 표현한다
Cross Product
↓
새로운 수직 방향을 만든다
Basis
↓
Forward / Right / Up 같은 좌표계의 기준축을 만든다
Matrix
↓
이 Basis와 변환을 이용해서 벡터와 좌표를 변환한다
특히 Local Space와 World Space를 이해할 때 Basis와 Matrix가 연결된다.
캐릭터 Local에서 (1,0,0)이라는 값은 단순히 World X축을 의미하는 것이 아니라
Character Forward 방향으로 1
이라는 뜻이다.
Matrix는 이 Local 좌표를 Character Basis를 이용해 선형 결합해서 실제 World 방향으로 변환한다.
결국 Basis는 좌표를 해석하기 위한 기준축이고, Matrix는 그 기준축과 변환을 계산으로 표현하는 방법이라고 볼 수 있다.