행렬: 크기 · 회전 · 이동을 한 번에 담는 표
Esc

    행렬: 크기 · 회전 · 이동을 한 번에 담는 표

    –Unity기초

    부모 오브젝트를 돌리고 키우면 자식들이 모두 같이 돌고 커집니다. 캐릭터 모델의 꼭짓점 수천 개도 매 프레임 캐릭터를 따라 자리를 옮깁니다. 엔진은 이 일을 행렬로 합니다. 행렬은 숫자를 가로세로 칸에 늘어놓은 표로, 칸이 가로 2, 세로 2 면 2×2 행렬이라고 부릅니다. 크기, 회전, 이동을 행렬 하나에 담아 두고, 점마다 그 행렬을 곱합니다.

    행렬은 “축이 어디로 가는지” 를 세로줄(열)마다 적어 둔 표입니다. 2×2 행렬의 첫째 열은 x 축 (1, 0) 이 옮겨 가는 자리, 둘째 열은 y 축 (0, 1) 이 옮겨 가는 자리입니다. 행렬 × (x, y) = x × 첫째 열 + y × 둘째 열 입니다. 크기 · 회전 · 이동을 하나로 합칠 때는 순서가 결과를 바꾸고, 유니티는 크기 → 회전 → 이동 순서(TRS)를 씁니다.

    행렬 × 벡터는 열을 섞는 것이다

    x 축은 오른쪽으로 1 칸인 화살표 (1, 0), y 축은 위로 1 칸인 화살표 (0, 1) 입니다. 벡터, 곧 방향과 길이를 가진 화살표 (2, 1) 은 “x 축 방향으로 2 칸, y 축 방향으로 1 칸” 입니다. 곧 2 × (1, 0) + 1 × (0, 1) 입니다.

    숫자 × 벡터는 칸마다 그 숫자를 곱하는 것이라 2 × (1, 0) = (2, 0) 입니다. 방향은 그대로, 길이만 2 배입니다. 벡터 + 벡터는 같은 칸끼리 더하는 것이라 (2, 0) + (0, 1) = (2, 1), 두 화살표를 이어 붙인 끝입니다.

    행렬은 이 두 축을 다른 화살표로 바꿔치기합니다. 첫째 열이 새 x 축, 둘째 열이 새 y 축입니다. 그래서 행렬 × (2, 1) 은 2 × 새 x 축 + 1 × 새 y 축 입니다. 숫자 (2, 1) 은 그대로 두고, 그 숫자로 섞는 축만 바뀌는 것입니다.

    두 행렬을 표로 적으면 이렇습니다. 세로로 읽은 줄이 열입니다.

    x 두 배          90° 회전
    | 2  0 |         | 0  −1 |
    | 0  1 |         | 1   0 |
    행렬 새 x 축 (첫째 열) 새 y 축 (둘째 열) (2, 1) 이 가는 곳
    x 두 배 (2, 0) (0, 1) (4, 1)
    90° 회전 (0, 1) (−1, 0) (−1, 2)

    x 두 배는 2 × (2, 0) + 1 × (0, 1) = (4, 1), 90° 회전은 2 × (0, 1) + 1 × (−1, 0) = (−1, 2) 입니다. 90° 회전 행렬의 열을 보면, x 축 (1, 0) 이 위 (0, 1) 로, y 축 (0, 1) 이 왼쪽 (−1, 0) 으로 갑니다. 둘 다 반시계 방향으로 90° 돈 것입니다.

    90° 가 아닌 각도도 같은 방법으로 만듭니다. 길이 1 인 화살표를 각 θ 만큼 돌리면 끝이 (cos θ, sin θ) 에 옵니다. cos 는 그때의 가로, sin 은 세로입니다. 그래서 θ 만큼 돌리는 행렬의 첫째 열은 x 축을 돌린 (cos θ, sin θ), 둘째 열은 y 축을 돌린 (−sin θ, cos θ) 입니다. θ = 90° 면 cos 가 0, sin 이 1 이라 위 표의 (0, 1), (−1, 0) 이 그대로 나옵니다. 유니티 2D 에서 Rotation 의 z 를 90 으로 둔 것이 이 회전입니다.

    교과서에서는 이 계산을 행 곱하기 열로 가르칩니다. 표의 가로 한 줄이 행, 세로 한 줄이 열입니다. 행렬의 윗줄(첫째 행)을 (x, y) 와 짝지어 곱해 더하면 결과의 첫 숫자, 아랫줄을 그렇게 하면 둘째 숫자입니다.

    | a  b |   | x |   | a × x + b × y |
    | c  d | × | y | = | c × x + d × y |

    90° 회전에 (2, 1) 을 넣으면 (0 × 2 + (−1) × 1, 1 × 2 + 0 × 1) = (−1, 2) 로, 열을 섞은 결과와 같습니다. 계산은 행으로 하고, 뜻은 열로 읽는다고 기억하면 됩니다.

    순서를 바꾸면 결과가 바뀐다

    크기를 바꾸는 행렬 S(x 두 배)와 회전 행렬 R(90°)을 둘 다 쓰면, 어느 것을 먼저 하느냐에 따라 결과가 다릅니다. (1, 1) 에 두 순서로 적용한 결과입니다.

    순서 1 단계 2 단계
    크기 → 회전 S (1, 1) = (2, 1) R (2, 1) = (−1, 2)
    회전 → 크기 R (1, 1) = (−1, 1) S (−1, 1) = (−2, 1)

    행렬 곱은 순서를 바꾸면 값이 달라집니다. 수학에서는 AB ≠ BA 라고 씁니다. 보통 숫자 곱셈(2 × 3 = 3 × 2)과 가장 다른 점입니다. 행렬을 곱해서 하나로 합칠 때는 오른쪽에 있는 것이 먼저 적용됩니다. R × S × v 는 R × (S × v), 곧 v 에 가장 가까이 붙은 S 가 먼저 v 를 받고 그 결과에 R 을 곱한 것입니다.

    두 행렬을 미리 곱해 둔 R × S 도 행렬 하나이고, 그 열은 S 의 열을 R 에 넣은 결과입니다. R × (2, 0) = (0, 2), R × (0, 1) = (−1, 0) 입니다. 여기에 (1, 1) 을 넣으면 1 × (0, 2) + 1 × (−1, 0) = (−1, 2) 로, 두 번 나눠 곱한 결과와 같습니다.

    키우기, 돌리기, 옮기기처럼 점을 다른 자리로 옮기는 규칙을 변환이라고 하는데, 여러 변환을 행렬 하나로 접어 둘 수 있다는 것이 행렬을 쓰는 가장 큰 이유입니다. 꼭짓점이 1000 개라도 행렬을 미리 합쳐 두면 점마다 곱셈 한 번이면 됩니다.

    크기를 회전보다 먼저 하는 이유는 위 결과에 있습니다. 물체를 자기 모양 그대로 키우고 싶다면 아직 돌기 전에 키워야 합니다. 돈 다음에 키우면 물체의 축이 아니라 월드의 축 방향으로 늘어나 모양이 비틀립니다. x, y, z 를 똑같이 키우는 크기(균일한 크기)라면 어느 쪽이 먼저든 결과가 같습니다.

    이동은 2×2 로는 못 한다

    2×2 행렬에 (0, 0) 을 넣으면 어떤 행렬이든 (0, 0) 이 나옵니다. 열을 0 배씩 섞기 때문입니다. 모든 좌표를 재기 시작하는 자리 (0, 0) 을 원점이라고 하는데, 원점이 움직이지 않으니 옮기기(이동)는 2×2 로 표현할 수 없습니다.

    그래서 숫자 하나를 덧붙입니다. 2D 점은 (x, y, 1), 방향은 (x, y, 0) 으로 쓰고 3×3 행렬을 씁니다. 셋째 열에 이동량을 넣으면 그 열이 덧붙인 숫자만큼 섞입니다. 점은 1 배라 이동하고, 방향은 0 배라 이동하지 않습니다.

    (3, 1) 만큼 옮기는 행렬입니다. 앞의 두 열은 x 축과 y 축을 그대로 두고, 셋째 열이 이동량 (3, 1) 을 들고 있습니다.

    | 1  0  3 |
    | 0  1  1 |
    | 0  0  1 |

    같은 (2, 1) 이라도 점으로 넣으면 옮겨지고, 방향으로 넣으면 그대로입니다.

    유니티에서 로컬 점을 월드 점으로 옮기는 TransformPoint 는 이동까지 적용하고, 로컬 벡터를 옮기는 TransformVector 는 이동을 빼는 것이 이 덧붙인 숫자 하나의 차이입니다. 3D 에서는 같은 이유로 (x, y, z, 1) 과 4×4 행렬을 씁니다. 이렇게 숫자를 하나 덧붙여 쓰는 방식을 동차좌표라고 부릅니다.

    TRS: 크기 → 회전 → 이동

    유니티 Inspector 의 Transform 에 있는 Position · Rotation · Scale 세 칸이 위치(Translation) · 회전(Rotation) · 크기(Scale) 입니다. 엔진은 이것을 TRS 행렬 하나로 합칩니다. M = T × R × S 라서 오른쪽부터, 곧 크기 → 회전 → 이동 순서로 적용됩니다.

    크기 x 두 배, 회전 90°, 이동 (3, 1) 인 오브젝트의 로컬 점 (1, 1) 은 S, R, T 를 차례로 지나 월드 자리에 갑니다. 로컬 점은 오브젝트 자신을 기준으로 적은 자리이고, 월드 자리는 씬 전체를 기준으로 적은 자리입니다.

    이동을 마지막에 하는 이유도 보입니다. 회전은 원점을 중심으로 돌기 때문에, 먼저 옮겨 버리면 오브젝트가 제자리에서 돌지 않고 원점을 중심으로 크게 휘돕니다.

    코드에서는 이렇게 씁니다.

    Matrix4x4 m = Matrix4x4.TRS(position, rotation, scale);
    
    // 점: 크기 · 회전 · 이동 전부
    Vector3 worldPoint = m.MultiplyPoint3x4(localPoint);
    
    // 방향: 크기와 회전만. 이동은 빼고
    Vector3 worldDir = m.MultiplyVector(localDir);

    transform.localToWorldMatrix 는 부모까지 모두 곱해 둔 그 오브젝트의 월드 행렬입니다. 부모가 없으면 자기 TRS 와 같습니다. transform.TransformPoint 는 이 행렬을 곱하는 일을 대신 해 줍니다.

    부모와 자식은 행렬을 곱해 이어진다

    자식의 월드 행렬은 부모의 월드 행렬 × 자식의 TRS 행렬입니다. 오른쪽이 먼저라서, 자식의 점은 먼저 자식의 크기 · 회전 · 이동을 거치고, 그 결과가 다시 부모의 것을 거칩니다. 그래서 부모를 돌리면 자식이 따라 돌고, 부모를 키우면 자식의 위치와 크기도 따라 커집니다.

    부모 자식 (로컬) 자식의 월드 결과
    크기 2 위치 (1, 0, 0) 위치 (2, 0, 0)
    y 로 90° 회전 위치 (1, 0, 0) 위치 (0, 0, −1)
    위치 (5, 0, 0) 위치 (1, 0, 0) 위치 (6, 0, 0)

    유니티 3D 에서 y 축으로 +90° 는 위에서 내려다볼 때 시계 방향입니다. 그래서 +x 쪽에 있던 자식이 −z 쪽으로 갑니다.

    조심할 것이 둘 있습니다.

    • 부모를 한 방향으로만 늘리고 자식을 돌리면 자식이 비틀립니다. 자식이 돈 다음에 부모의 늘이기가 적용되어, 위에서 본 “회전 → 크기” 와 같은 일이 생깁니다. 자식이 도는 오브젝트는 부모의 크기를 x, y, z 똑같이 둡니다
    • 크기를 0 으로 만들면 되돌릴 수 없습니다. 모든 점이 한 점으로 뭉쳐서, 거꾸로 “월드 점이 로컬의 어디였나” 를 구하는 계산(역행렬, 행렬이 한 일을 되돌리는 행렬)이 정의되지 않습니다. 안 보이게 하려면 크기 대신 SetActive(false) 나 렌더러를 끕니다

    정리

    • 행렬은 숫자를 가로세로로 늘어놓은 표이고, 각 열은 축이 옮겨 가는 자리입니다
    • 행렬 × 벡터는 그 열들을 벡터의 숫자만큼 섞는 것입니다. 계산은 행 곱하기 열로 하고, 뜻은 열로 읽습니다
    • θ 회전 행렬의 열은 (cos θ, sin θ) 와 (−sin θ, cos θ) 입니다
    • 행렬 곱은 순서를 바꾸면 결과가 달라집니다. 합친 행렬은 오른쪽부터 적용되고, 여러 변환을 행렬 하나로 접어 둘 수 있습니다
    • 이동은 2×2(3D 는 3×3)로 못 해서 숫자 하나를 덧붙입니다(동차좌표). 점은 1, 방향은 0 이라 점만 이동합니다
    • 유니티는 TRS, 곧 크기 → 회전 → 이동 순서로 합칩니다. 자기 모양대로 키우고, 제자리에서 돌린 뒤 옮기기 위해서입니다
    • 자식의 월드 행렬은 부모의 월드 행렬 × 자식의 TRS 입니다
    • 부모의 크기는 축마다 같게 두고, 크기 0 은 피합니다