Unity 엔진 핵심 (2) - Transform 계층과 씬 그래프 - soo:bak
작성일 :
GameObject에서 Transform으로
Unity 엔진 핵심 (1) - GameObject와 Component에서는 Unity가 GameObject에 Component를 붙이는 컴포지션 구조를 사용한다는 점을 살펴봤습니다. 그중 Transform은 모든 GameObject가 하나씩 갖는 필수 컴포넌트입니다.
Transform은 위치, 회전, 스케일만 저장하는 컴포넌트가 아닙니다. 부모-자식 관계를 구성해 부모의 변환을 자식에게 전파하고, 이 관계가 모여 씬 전체의 계층 구조를 이룹니다.
Transform의 비용은 그 계층 구조에서 비롯됩니다. position을 한 번 바꾸면 자신의 행렬이 다시 계산되고, 자식의 월드 좌표가 갱신되며, 물리와 렌더링 시스템에도 변경이 전달됩니다. 계층이 깊고 자식이 많을수록 이 비용은 커집니다.
이 글에서는 Transform의 위치, 회전, 스케일에서 시작해 로컬 좌표와 월드 좌표, 부모-자식 관계, 씬 그래프, Transform 변경 비용과 최적화 방법을 차례로 정리합니다.
Transform 컴포넌트
Transform은 GameObject가 씬 안에서 어디에 있고, 어떤 방향을 바라보며, 얼마나 큰지를 나타내는 컴포넌트입니다. 이 세 가지 정보를 Unity가 각각 어떤 값으로 저장하는지 알아야 회전과 스케일을 정확히 다룰 수 있습니다.
세 가지 속성
Transform이 담는 정보는 세 가지로 나뉩니다. 위치(position), 회전(rotation), 스케일(localScale)입니다.
position은 오브젝트의 월드 위치입니다. Vector3(0, 0, 0)이면 씬의 원점에 있고, Vector3(3, 1, -2)이면 원점에서 x축으로 3, y축으로 1, z축으로 -2만큼 이동한 위치에 있습니다.
rotation은 오브젝트가 어느 방향을 바라보는지를 담는 회전 상태입니다. 이때 회전을 표현하는 방법은 크게 두 가지입니다. 하나는 x, y, z 세 축의 회전을 도 단위로 적는 오일러 각도(Euler Angles)로, Inspector에 표시되어 사람이 읽고 입력하기에 직관적입니다. 다른 하나는 회전을 네 개의 float 값으로 나타내는 쿼터니언(Quaternion)으로, Unity가 내부적으로 rotation을 저장하는 형식입니다. 위 표에서 position과 localScale은 값이 세 개인데 rotation만 네 개(x, y, z, w)인 것은, 그 네 값이 바로 이 쿼터니언이기 때문입니다.
Unity가 직관적인 오일러 각도 대신 쿼터니언을 저장 형식으로 택한 이유는 짐벌 락(Gimbal Lock) 때문입니다. 오일러 각도는 세 축의 회전을 차례로 적용하는 방식이라, 특정 각도에서 두 축이 같은 방향으로 포개지면 한 축의 회전을 따로 표현할 수 없게 됩니다. 한편 쿼터니언이 표현하는 회전은 하나의 회전축을 중심으로 도는 단일 회전입니다. 세 축을 차례로 적용하는 단계가 없으므로, 두 축이 겹치는 상황도 생기지 않습니다. 두 회전 사이를 부드럽게 잇는 보간에도 쿼터니언이 유리해서, Slerp(Spherical Linear Interpolation)를 쓰면 구면을 따라 회전이 고르게 이어집니다.
짐벌 락이 생기는 메커니즘과 쿼터니언의 수학적 배경은 그래픽스 수학 (2) - 행렬과 변환에서 자세히 다룹니다.
localScale은 부모 기준으로 오브젝트의 크기를 얼마나 키우거나 줄일지 나타내는 값입니다. Vector3(1, 1, 1)이면 원본 크기이고, Vector3(2, 1, 1)이면 x축 방향으로 두 배 커집니다.
localScale이 부모를 기준으로 한 값이라면, 월드 기준의 크기는 lossyScale로 읽습니다. 단, 이 값은 읽기 전용이며 늘 정확하지는 않습니다.
부모의 스케일이 축마다 다르고(예: x=2, y=1) 그 자식이 회전하면, 스케일 축과 회전 축이 섞이면서 형태가 비스듬히 일그러지는 기울어짐(skew)이 생깁니다. 이렇게 기울어진 변환은 x, y, z 세 개의 크기 값만으로는 정확히 표현할 수 없습니다. lossyScale이 이름 그대로 손실(lossy)이 있는 근사값인 것은 이 때문입니다.
로컬 좌표 vs 월드 좌표
Transform의 위치나 회전 값에는 ‘어느 기준에서 본 값인가’가 늘 따라붙습니다. 같은 좌표라도 그 기준이 무엇이냐에 따라 실제로 가리키는 지점이 달라지고, 기준을 혼동하면 오브젝트가 의도와 다른 위치에 놓입니다. 그래서 Unity는 기준이 다른 값을 서로 다른 속성으로 나누어 제공합니다.
두 가지 좌표 체계
오브젝트의 위치와 회전은 두 기준으로 표현됩니다. 부모를 기준으로 하는 로컬 좌표(Local Coordinates)와 씬 전체의 원점을 기준으로 하는 월드 좌표(World Coordinates)입니다.
localPosition은 부모를 기준으로 한 위치입니다. 부모가 (10, 0, 0)에 있고 자식의 localPosition이 (0, 3, 0)이라면, 자식은 부모에서 y축으로 3만큼 떨어진 자리에 놓입니다.
position은 씬의 월드 원점을 기준으로 한 위치입니다. 방금 예시에서 자식의 월드 위치는 (10, 3, 0)이 됩니다. 부모에 회전이나 스케일이 없다면, 부모의 월드 위치에 localPosition을 그대로 더해 자식의 월드 위치를 구할 수 있습니다.
부모에 회전이나 스케일이 있으면 이 덧셈만으로는 자식의 월드 위치를 구할 수 없습니다. 부모가 회전하면 부모로부터 떨어진 방향이 그만큼 돌아가고, 부모가 커지거나 작아지면 떨어진 거리도 함께 달라지기 때문입니다. 그래서 이때는 부모의 회전과 스케일까지 반영하는 계산이 필요하며, Unity는 이를 행렬 곱셈으로 처리합니다.
회전도 위치와 마찬가지로 두 기준으로 나뉩니다. localRotation은 부모를 기준으로 한 회전이고, rotation은 월드를 기준으로 한 회전입니다. 부모에 회전이 없다면 두 값은 일치합니다.
부모가 y축으로 30도 회전한 상태에서 자식의 localRotation이 y축 45도라면, 자식의 월드 회전은 y축 75도가 됩니다. 이렇게 한 축만 다룰 때는 두 각을 더한 값처럼 보이지만, 서로 다른 축의 회전이 섞이면 각도를 더하는 것만으로는 합쳐진 회전을 얻을 수 없습니다. 그래서 회전을 합성할 때는 쿼터니언 곱셈을 사용합니다.
부모 변환의 전파
로컬 좌표와 월드 좌표는 부모 Transform을 통해 연결됩니다. 부모가 이동하면 자식의 localPosition은 그대로지만, 자식의 월드 position은 부모 이동량만큼 바뀝니다.
부모 이동에 따른 자식의 월드 좌표 변화
| 항목 | 변경 전 | 변경 후 | 비고 |
|---|---|---|---|
| 부모 월드 위치 | (10, 0, 0) | (20, 0, 0) | 변경 |
| 자식 로컬 위치 | (0, 3, 0) | (0, 3, 0) | 변하지 않음 |
| 자식 월드 위치 | (10, 3, 0) | (20, 3, 0) | 자동으로 재계산 |
부모의 회전은 자식의 방향뿐 아니라 부모 기준으로 놓인 자식의 위치 오프셋도 함께 회전시킵니다. 그래서 자식은 부모를 기준으로 다른 월드 위치에 놓이고, 자식이 바라보는 방향도 부모의 회전만큼 함께 바뀝니다.
스케일도 같은 방식으로 자식에게 전파됩니다. 부모의 localScale이 (2, 2, 2)이면 자식의 크기뿐 아니라 부모로부터 떨어진 거리까지 함께 2배가 됩니다. 세 축의 배율이 같은 이런 균등(Uniform) 스케일에서는 형태의 비율이 그대로 유지되므로, 오브젝트는 모양은 그대로인 채 크기만 달라집니다.
주의할 것은 축마다 배율이 다른 비균등(Non-uniform) 스케일입니다. x축만 2배로 늘리고 y, z축은 그대로 두면 정육면체는 한쪽으로 늘어난 직육면체가 되고, 구는 타원체가 됩니다. 메시의 겉모습이 이렇게 일그러지는 것까지는 의도한 결과일 수 있지만, 비균등 스케일의 영향은 눈에 보이는 형태에서 그치지 않습니다.
대표적으로 문제가 되는 곳이 충돌체입니다. SphereCollider나 CapsuleCollider는 반지름과 높이 같은 몇 개의 값만으로 정의되어, 어떤 스케일을 적용해도 구나 캡슐 형태를 벗어나지 못합니다. 비균등 스케일이 걸리면 Unity는 이 충돌체의 크기만 축 배율에 맞춰 조정할 뿐 타원체로 일그러뜨리지는 못하므로, 한쪽으로 늘어난 메시와 여전히 구나 캡슐인 충돌체 사이에서 모양이 어긋납니다.
비균등 스케일은 조명 계산에도 영향을 줍니다. 표면에 수직이던 법선(노멀)이 비균등 스케일을 거치면서 비스듬히 틀어지고, 그만큼 빛과 이루는 각도가 달라져 음영이 잘못 계산됩니다. 다만 이 왜곡은 법선을 역전치 행렬(Inverse Transpose Matrix)로 변환하면 바로잡을 수 있고, Unity의 기본 셰이더가 이 보정을 자동으로 처리합니다. 따라서 셰이더를 직접 작성하지 않는 한 조명에서 비균등 스케일이 문제가 되는 일은 드뭅니다.
반면 충돌체의 모양 어긋남은 이렇게 자동으로 보정되지 않습니다. 비균등 스케일을 적용한 오브젝트는 눈에 보이는 형태와 다른 자리에서 부딪히거나 빗나가, 충돌이 의도대로 일어나지 않습니다. 따라서 비균등 스케일은 꼭 필요할 때만 쓰고, 그렇지 않다면 균등 스케일을 유지하거나 원하는 비율을 메시 자체에 반영해 두기를 권장합니다.
부모-자식 관계
부모-자식 관계에서는 부모가 움직이면 자식도 그 변환을 그대로 따라갑니다. 여러 오브젝트를 한 부모 아래에 모으면, 부모 하나만 움직여 전체를 함께 다룰 수 있습니다. 이런 관계는 씬을 만들 때 미리 정해 두기도 하고, 게임이 실행되는 도중에 새로 생기기도 합니다.
미리 정해 두는 관계는 씬을 짜는 단계에서 에디터로 묶어 둡니다. Hierarchy 창에서 한 오브젝트를 다른 오브젝트 위로 끌어다 놓으면 곧바로 부모-자식으로 이어지고, 바뀐 계층이 화면에 그대로 나타납니다.
반면 실행 도중에 생기는 관계는 미리 묶어 둘 수 없습니다. 예를 들어, 캐릭터가 바닥의 무기를 주워 손에 쥐면, 그때부터 무기는 손을 따라 움직여야 합니다. 무엇을 언제 줍는지는 플레이 도중에야 정해지므로, 이 연결은 코드로 그때그때 구성해야 합니다. 이때 사용하는 메서드가 Transform의 SetParent입니다. SetParent를 호출할 때 내부에서 어떤 일이 처리되고, 부모가 바뀐 뒤 좌표가 어떻게 정리되는지는 이어지는 절에서 살펴봅니다.
SetParent
자식이 될 오브젝트의 Transform에서 SetParent를 호출하고, 새 부모가 될 Transform을 인자로 넘깁니다. 부모 하나만 받는 Transform.SetParent(Transform parent)가 가장 단순한 형태입니다.
SetParent 한 줄은 내부에서 세 단계로 처리됩니다. 계층을 바꾸고, 좌표를 다시 계산한 뒤, Transform이 바뀌었다는 사실을 관련 시스템에 알립니다.
먼저 무기는 Transform 트리에서 손 아래로 옮겨집니다. 이 순간부터 무기는 손의 자식이 되고, 손에 적용되는 이동, 회전, 스케일이 무기에도 이어집니다.
다음으로 Unity는 무기의 좌표를 새 부모 기준으로 다시 정리합니다. 부모가 달라지면 같은 로컬 값도 다른 월드 위치를 뜻할 수 있으므로, 무기를 어디에 둘지에 맞춰 로컬 변환을 다시 계산해야 합니다.
마지막으로 Transform 변경이 관련 시스템에 전파됩니다. 물리 시스템은 충돌체의 위치와 바운딩 정보를 다시 보고, 렌더링 시스템은 그릴 범위를 갱신합니다. 애니메이션이나 파티클처럼 Transform을 참조하던 쪽도 같은 변경을 받습니다.
여기서 계층을 옮기고 변경을 알리는 과정은 언제나 같습니다. 차이는 중간의 좌표 재계산에서 생깁니다. 부모를 바꾼 뒤에도 화면에서 보이던 자리를 지킬지, 아니면 새 부모 기준의 로컬 값을 지킬지에 따라 Unity가 다시 계산하는 값이 달라집니다. 이 선택이 실제 좌표를 어떻게 바꾸는지는 다음 절에서 살펴봅니다.
SetParent의 두 번째 매개변수
부모가 바뀌면 로컬 좌표를 해석하는 기준도 함께 바뀝니다. 이때 오브젝트의 월드 상태를 유지할지, 기존 로컬 값을 유지할지를 SetParent의 두 번째 bool 인자인 worldPositionStays가 정합니다. 두 기준은 동시에 고정할 수 없으므로, Unity는 이 값에 따라 한쪽을 유지하고 다른 쪽을 다시 계산합니다.
이름에는 Position만 적혀 있지만, 이 선택이 위치 하나에만 적용되는 것은 아닙니다. Unity는 회전과 스케일도 같은 기준으로 함께 처리합니다.
true를 넘기면 월드 상태가 기준입니다. 부모가 바뀌어도 오브젝트는 씬에서 보이던 위치와 회전, 크기를 그대로 지키며, Unity는 그 모습이 새 부모 아래에서도 유지되도록 localPosition과 localRotation, localScale을 새로 계산합니다. 인자를 생략해도 이렇게 동작하므로, SetParent(parent)는 SetParent(parent, true)와 같습니다.
false를 넘기면 로컬 값이 기준입니다. localPosition과 localRotation, localScale은 부모가 바뀐 뒤에도 그대로 남지만, 그 값이 이제 새 부모 기준으로 해석되므로 오브젝트의 월드 위치와 회전, 크기는 이전과 달라질 수 있습니다.
두 값의 차이는 좌표를 직접 계산해 보면 더 분명합니다. 아래 예시는 부모가 없던 오브젝트를 손 아래로 옮기는 상황입니다. 오브젝트의 월드 위치는 (4, 1, 0)이고, 새 부모인 손의 월드 위치는 (1, 3, 0)입니다. 여기서는 위치 변화만 보기 위해 회전과 스케일은 제외합니다.
true로 호출하면 보존되는 값은 월드 변환입니다. 오브젝트는 부모가 바뀐 뒤에도 월드 위치 (4, 1, 0)에 남아 있어야 하므로, Unity는 이 위치를 새 부모 기준의 로컬 좌표로 다시 바꿉니다.
이 예시에서는 새 부모가 월드 (1, 3, 0)에 있고 회전과 스케일이 없으므로 계산이 단순합니다. 월드 위치 (4, 1, 0)에서 부모 위치 (1, 3, 0)을 빼면 새 부모 기준의 오프셋은 (3, -2, 0)입니다. 따라서 localPosition은 (3, -2, 0)으로 바뀌지만, 오브젝트가 화면에서 보이는 자리는 그대로 유지됩니다.
반대로 false로 호출하면 보존되는 값은 로컬 변환입니다. 부모가 없던 상태에서 오브젝트의 localPosition이 (4, 1, 0)이었다면, 부모를 붙인 뒤에도 이 로컬 값은 그대로 남습니다. 다만 이제 이 값은 월드 원점 기준 좌표가 아니라 새 부모를 기준으로 한 오프셋으로 해석됩니다.
그래서 부모의 월드 위치 (1, 3, 0)에 로컬 오프셋 (4, 1, 0)이 더해지고, 오브젝트의 월드 위치는 (5, 4, 0)이 됩니다. 값 자체는 바뀌지 않았지만, 그 값을 읽는 기준이 월드 원점에서 새 부모로 바뀌었기 때문에 실제 월드 위치가 달라집니다.
정리하면 true는 월드 변환을 유지하기 위해 로컬 변환을 다시 계산하고, false는 로컬 변환을 유지한 채 월드 변환이 새 부모의 영향을 받게 둡니다. 부모를 바꾸는 순간 두 값을 동시에 유지할 수는 없으므로, 어느 좌표계를 기준으로 삼을지 선택하는 문제입니다.
이 계산은 앞에서 본 로컬 좌표와 월드 좌표의 관계와 같습니다. 자식의 월드 변환은 부모의 월드 변환에 자식의 로컬 변환을 합쳐서 얻습니다. 따라서 부모가 바뀐 뒤에도 같은 월드 상태를 유지하려면, Unity는 새 부모의 월드 변환을 거꾸로 적용해 그 부모 기준의 로컬 변환을 구해야 합니다. 예시처럼 부모에 회전과 스케일이 없으면 위치를 빼는 정도로 끝나지만, 회전이나 스케일이 포함되면 부모 월드 행렬의 역행렬을 사용해야 합니다.
실무에서는 의도에 따라 값을 고르면 됩니다. 이미 씬에 놓인 오브젝트를 화면상의 위치는 그대로 둔 채 정리용 부모 아래로 옮길 때는 true가 자연스럽습니다. 반대로 새 부모 기준으로 미리 정해 둔 배치를 유지해야 할 때, 예를 들어 무기 프리팹을 손 소켓 아래에 붙이고 소켓 기준 오프셋을 그대로 쓰고 싶을 때는 false가 맞습니다.
다만 SetParent는 단순히 참조 하나를 바꾸는 호출이 아닙니다. 호출할 때마다 계층 재구성, 좌표 재계산, 관련 시스템 통지가 함께 일어납니다. 부모-자식 관계가 실제로 바뀌는 순간에만 호출하고, 이미 같은 부모 아래에 있는 오브젝트의 위치만 바꿀 때는 localPosition을 직접 조정하는 편이 좋습니다.
씬 그래프 (Scene Graph)
씬 안의 오브젝트들은 평평하게 나열되는 대신, 각 GameObject의 Transform이 부모-자식으로 이어져 하나의 트리를 이룹니다. 이 트리 전체가 씬 그래프(Scene Graph)이며, 오브젝트가 월드 공간의 어디에 놓이는지는 이 트리를 따라 결정됩니다.
트리 구조
씬 그래프의 가장 위에는 부모가 없는 GameObject들이 놓입니다. 이런 오브젝트를 루트 오브젝트(Root Object)라고 하며, Hierarchy 창에서 들여쓰기 없이 가장 왼쪽에 표시됩니다. 그 아래로는 한 부모가 여러 자식을 두고, 그 자식이 다시 자식을 두면서 계층이 깊어집니다.
루트 오브젝트는 기준이 되는 부모가 없으므로, 부모를 기준으로 한 localPosition과 월드를 기준으로 한 position이 같은 값이 됩니다.
월드 좌표 계산
씬 그래프에서 한 오브젝트의 월드 좌표는 그 오브젝트 자신의 로컬 값만으로 정해지지 않습니다. 부모가 어디에 있고, 그 부모의 부모가 어떻게 회전했는지까지 모두 반영된 결과가 월드 좌표입니다.
Unity는 각 Transform의 이동(Translation), 회전(Rotation), 스케일(Scale)을 하나의 4×4 TRS 행렬(TRS Matrix)로 다룹니다. 이 행렬은 해당 오브젝트가 부모 기준에서 어떻게 놓이는지를 나타냅니다. 즉 localPosition, localRotation, localScale을 하나로 묶은 로컬 변환입니다.
월드 변환을 구할 때는 루트에서 대상 오브젝트까지 내려오며 이 로컬 변환들을 순서대로 누적합니다. 예를 들어 Sword의 경로가 Player → Body → RightArm → RightHand → Sword라면, Player의 변환을 먼저 적용하고 그 결과에 Body, RightArm, RightHand, Sword의 로컬 변환을 차례로 합칩니다. 변환 행렬을 이렇게 이어서 곱하는 과정을 연쇄 곱(Matrix Concatenation)이라고 합니다.
중요한 점은 각 단계가 이전 단계의 결과를 기준으로 한다는 것입니다. Sword의 localPosition은 RightHand 기준 좌표이고, RightHand의 위치는 다시 RightArm 기준 좌표입니다. 따라서 Sword의 월드 위치를 얻으려면 이 기준들을 루트까지 거슬러 올라가 하나의 월드 기준으로 합쳐야 합니다.
4×4 행렬을 쓰는 이유와 동차 좌표계 등 변환 행렬의 수학적 배경은 그래픽스 수학 (2) - 행렬과 변환에서 자세히 다룹니다.
이렇게 얻은 행렬은 Sword가 월드 공간에 어떻게 놓이는지를 담은 월드 변환입니다. transform.position은 이 월드 변환을 Sword의 로컬 원점인 (0, 0, 0)에 적용했을 때 나오는 위치로 볼 수 있습니다.
렌더링에서도 같은 구조가 사용됩니다. Renderer가 메시를 그릴 때는 Sword의 로컬 공간에 있는 각 정점에 월드 변환을 적용해 월드 공간의 정점 위치를 얻습니다. position은 로컬 원점 하나를 월드로 옮긴 결과이고, 렌더링은 메시를 이루는 정점 전체를 같은 방식으로 옮긴 결과라고 볼 수 있습니다.
따라서 오브젝트가 트리에서 깊을수록 월드 변환을 얻기 위해 누적해야 하는 부모 변환도 늘어납니다. 한 번의 계산만 보면 작은 차이지만, 많은 Transform이 자주 움직이는 장면에서는 이 누적 과정이 반복 비용으로 이어집니다. 특히 Transform 값이 바뀌면 이 월드 변환을 다시 계산하고 자식 쪽으로도 변경을 전파해야 하므로, 다음 절에서 이 비용을 따로 살펴봅니다.
Transform 변경의 비용
앞에서 본 월드 변환은 Transform 계층을 따라 계산됩니다. 따라서 Transform 값 하나가 바뀌면 그 오브젝트의 위치 값만 바꾸고 끝나는 것이 아니라, 그 값을 기준으로 만들어 둔 월드 변환도 다시 맞춰야 합니다.
이 과정에서는 자신의 TRS 행렬을 다시 만들고, 그 아래 자식들의 월드 변환을 갱신하며, 필요한 경우 물리와 렌더링 시스템에도 변경을 알려야 합니다. Transform 변경 비용은 이 재계산과 전파, 통지 과정에서 발생합니다.
변경 전파의 메커니즘
Transform 변경은 해당 오브젝트 하나에서 시작하지만, 계층과 엔진 시스템으로 이어집니다. transform.position = newPos 같은 코드는 한 줄이지만, 내부에서는 크게 세 단계의 갱신이 발생합니다.
먼저 Unity는 바뀐 position, rotation, localScale 값을 기준으로 해당 Transform의 TRS 행렬을 다시 만듭니다. 이 행렬이 바뀌면 그 오브젝트의 월드 변환도 이전 값과 달라집니다.
다음으로 변경은 자식 방향으로 내려갑니다. 자식의 localPosition 값이 그대로여도, 기준이 되는 부모의 월드 변환이 바뀌면 자식의 월드 변환도 다시 계산해야 합니다. 이 전파는 자식의 자식까지 이어지므로, 하위 오브젝트가 많거나 계층이 깊을수록 비용이 커집니다.
마지막으로 Unity는 Transform이 바뀌었다는 사실을 관련 시스템에 전달합니다. 이 통지 과정이 TransformChangeDispatch입니다. 물리 시스템은 Collider 위치를 다시 맞추고, 렌더링 시스템은 Renderer의 바운딩 박스와 컬링 데이터를 갱신하는 식으로 각 시스템이 필요한 처리를 이어 갑니다.
계층 깊이와 비용의 관계
Transform 변경 비용은 바뀐 오브젝트 하나만으로 결정되지 않습니다. 그 아래에 어떤 자식들이 얼마나 많이 연결되어 있는지, 그리고 그 자식들이 몇 단계로 이어져 있는지가 함께 영향을 줍니다.
자식이 없는 오브젝트라면 자신의 TRS 행렬을 다시 만들고 관련 시스템에 변경을 알리는 정도로 끝납니다. 반대로 자식이 있는 오브젝트가 바뀌면, 그 자식들의 월드 변환도 부모의 새 변환을 기준으로 다시 계산해야 합니다. 이 전파는 하위 계층 전체로 내려가므로, 자식 수가 많거나 계층이 깊을수록 갱신 범위가 넓어집니다.
변경한 것은 부모 Transform 하나뿐이지만, 그 아래에 있는 11개 오브젝트의 월드 좌표가 다시 계산됩니다. 각 오브젝트에 Collider나 Renderer 같은 컴포넌트가 붙어 있다면 물리·렌더링 시스템에도 추가 갱신이 전달됩니다. 이런 변경이 매 프레임 반복되면 비용이 빠르게 누적됩니다.
TransformChangeDispatch의 세부 비용
TransformChangeDispatch가 통지를 보내면, 통지를 받은 시스템은 각자 필요한 데이터를 갱신합니다.
위 도식은 Transform 변경이 영향을 줄 수 있는 대표적인 시스템을 보여 줍니다. 다만 모든 오브젝트가 이 통지를 전부 받는 것은 아닙니다.
TransformChangeDispatch는 오브젝트에 붙은 컴포넌트 구성을 보고, 실제로 갱신이 필요한 시스템에만 변경을 전달합니다. 예를 들어 Collider가 없는 오브젝트는 물리 엔진 쪽 위치 갱신이 필요 없고, Renderer가 없는 오브젝트는 렌더링용 바운딩 박스나 컬링 데이터를 다시 계산할 필요가 없습니다.
반대로 한 오브젝트에 Collider와 Renderer가 함께 붙어 있다면 물리와 렌더링 양쪽 갱신이 모두 필요합니다. 그래서 같은 transform.position 변경이라도 빈 GameObject, 충돌체만 가진 오브젝트, 화면에 그려지는 오브젝트의 실제 비용은 서로 달라질 수 있습니다.
또 하나 구분해야 할 점은, TransformChangeDispatch가 엔진 내부 시스템을 위한 통지라는 점입니다. 사용자가 작성한 스크립트의 특정 메서드를 자동으로 호출해 주는 이벤트가 아닙니다. 예를 들어 플레이어가 실제로 움직였을 때만 미니맵 아이콘을 갱신하고 싶다면, 스크립트 쪽에서 변경 여부를 직접 확인해야 합니다.
이때 사용할 수 있는 값이 Transform.hasChanged입니다. Transform에 변경이 생기면 Unity가 이 플래그를 true로 설정합니다. 스크립트는 이 값을 보고 필요한 처리를 한 뒤, 직접 false로 되돌려 다음 변경을 다시 감지할 수 있게 만들어야 합니다.
Unity가 이 플래그를 자동으로 다시 내리지 않는 이유는 간단합니다. 엔진은 Transform이 바뀌었다는 사실은 알 수 있지만, 사용자 스크립트가 그 변경을 언제 처리했는지는 알 수 없습니다. 따라서 hasChanged는 콜백이 아니라 “변경이 있었다”는 표시이고, 그 표시를 소비한 뒤 초기화하는 책임은 스크립트에 있습니다.
Transform 계층 최적화
Transform 변경 비용은 계층 구조와 변경 빈도에 따라 달라집니다. 같은 수의 오브젝트가 있어도, 자주 움직이는 오브젝트가 깊은 계층에 있거나 많은 자식을 거느리고 있으면 재계산과 통지가 더 넓게 퍼집니다.
따라서 최적화의 기준은 단순히 하이어라키를 짧게 만드는 것이 아닙니다. 부모의 이동, 회전, 스케일을 실제로 따라가야 하는 관계만 Transform 계층으로 표현하고, 정리 목적이나 논리적 소속 관계는 가능한 한 런타임 전파 비용을 만들지 않는 방식으로 다루는 것이 중요합니다.
불필요한 중간 노드 제거
씬 하이어라키를 정리할 때, 빈 GameObject를 폴더처럼 두고 관련 오브젝트를 그 아래에 모으는 방식이 있습니다. 에디터에서 종류별로 묶어 보기에는 편리하지만, 런타임에서는 이 빈 노드도 Transform 계층을 이루는 하나의 부모로 남습니다.
이 빈 노드도 부모인 이상, 폴더로 쓴다고 해서 전파 비용이 줄어들지는 않습니다. 이 노드를 이동시키거나 초기화 과정에서 Transform 값을 설정하면, 다른 부모와 마찬가지로 그 아래 자식 전체의 월드 좌표가 다시 계산됩니다. 다만 이 재계산은 노드가 실제로 움직일 때만 일어납니다. 폴더로만 두고 런타임 내내 한 번도 움직이지 않는다면 그 아래로 전파할 변경이 없으므로, 변경 비용은 거의 들지 않습니다.
따라서 움직일 일이 없는 정리용 노드는 제거해도 성능상 이득이 없습니다. 오히려 이런 노드까지 모두 제거하면 에디터에서 여러 오브젝트를 한꺼번에 선택하거나 논리적으로 묶어 두기가 어려워집니다. 정작 비용으로 이어지는 쪽은 자주 움직이는 오브젝트의 상위 계층에 놓인 노드이므로, 정리용 노드를 한꺼번에 없애기보다 이런 계층부터 먼저 단순화하는 편이 실용적입니다.
계층 깊이 최소화
계층 비용을 키우는 요인은 노드 수만이 아닙니다. 같은 수의 노드라도 계층이 몇 단계 깊이인지에 따라 재계산 비용이 달라집니다. 자식의 월드 좌표는 부모의 월드 좌표가 정해진 뒤에야 구할 수 있어서, 계층의 각 단계가 바로 위 단계의 결과에 의존하기 때문입니다.
위 예시에서 두 구조는 재계산되는 자식이 다섯으로 같지만, 그 자식들이 연결된 방식이 다릅니다. 깊은 계층은 A에서 F까지 한 줄로 이어져 앞 단계의 결과가 다음 단계의 입력이 되므로, 다섯 단계를 순서대로 거쳐야 합니다. 얕은 계층에서는 다섯 자식이 모두 A의 결과 하나만 참조하므로 의존 깊이가 한 단계로 줄고, A만 정해지면 나머지는 곧바로 계산할 수 있습니다. 따라서 같은 오브젝트들을 묶더라도 계층을 얕게 구성하면, 이 의존 사슬이 짧아져 재계산이 단순해집니다.
빈번하게 변하는 오브젝트는 얕은 계층에
Transform 계층은 오브젝트를 보기 좋게 정리하는 폴더가 아니라, 좌표 계산의 의존 관계입니다. 어떤 오브젝트가 부모 아래에 있다는 것은 그 오브젝트의 월드 변환이 부모의 월드 변환에 의존한다는 뜻입니다. 따라서 부모를 따라 움직일 필요가 없는 오브젝트를 깊은 계층에 넣으면, 단순한 분류를 위해 불필요한 의존 관계를 만드는 셈이 됩니다.
이 영향은 부모가 움직일 때만 생기지 않습니다. 총알, 투사체, 이동하는 캐릭터처럼 자기 Transform이 자주 바뀌는 오브젝트도 부모 체인이 길수록 월드 변환을 유지하기 위한 의존 관계가 길어집니다. 한두 개라면 체감하기 어렵지만, 이런 오브젝트가 많이 생기고 매 프레임 움직이면 계층 구조 자체가 반복 비용의 일부가 됩니다.
그래서 자주 움직이는 오브젝트는 가능하면 얕은 계층에 둡니다. 실제로 부모를 따라 움직여야 하는 무기, 장비, 이펙트는 해당 부모 아래에 두는 것이 맞지만, 단순히 어느 구역에 속하는지 표시하려는 목적이라면 Transform 부모로 묶지 않는 편이 좋습니다.
Bullet이 어느 Area에서 발사되었는지와 Bullet이 어떤 Transform 아래에 있어야 하는지는 별개의 문제입니다. Area 소속, 발사자, 팀, 오브젝트 풀 같은 정보는 게임 로직의 데이터입니다. 반면 Transform 부모 관계는 부모의 이동, 회전, 스케일을 자식이 따라야 할 때 필요한 좌표 관계입니다.
따라서 Bullet이 Area를 따라 함께 움직여야 하는 경우가 아니라면, Area 아래에 둘 이유는 없습니다. 소속 정보는 스크립트 필드나 ID, 컬렉션, 풀 매니저 같은 구조로 관리하고, Transform 계층에는 실제 공간 변환을 공유해야 하는 관계만 남깁니다. 이렇게 하면 Bullet은 필요한 게임 정보를 유지하면서도 매 프레임 이동할 때 불필요한 부모 체인에 묶이지 않습니다.
localPosition 사용
Transform 값을 바꿀 때는 계층 구조뿐 아니라 좌표를 어떤 기준으로 넘기는지도 비용에 영향을 줍니다. transform.position은 월드 좌표입니다. 부모가 있는 오브젝트에 월드 위치를 지정하면, Unity는 그 위치를 부모 기준의 로컬 위치로 다시 환산해야 합니다.
반대로 transform.localPosition은 처음부터 부모 기준 좌표입니다. 부모를 기준으로 한 위치를 이미 알고 있다면 월드 좌표로 돌려서 넣을 이유가 없습니다. 곧바로 localPosition을 설정하면 월드 좌표를 로컬 좌표로 바꾸는 단계를 피할 수 있습니다.
한 번의 설정에서 생기는 차이는 작을 수 있습니다. 하지만 자식 오브젝트 수백 개를 매 프레임 부모 기준 오프셋으로 움직이는 코드라면, 매번 월드 좌표를 넣고 다시 로컬 좌표로 환산하는 과정이 반복됩니다. 이런 경우에는 계산 자체를 부모 기준으로 유지하고 localPosition을 직접 갱신하는 편이 더 단순하고 유리합니다.
반대로 목표 위치가 월드 공간에서 정해지는 경우라면 position을 쓰는 것이 맞습니다. 중요한 기준은 API 이름이 아니라, 현재 계산하고 있는 값이 월드 좌표인지 부모 기준 좌표인지입니다. 값의 기준과 맞는 프로퍼티를 사용해야 불필요한 변환을 줄일 수 있습니다.
position 캐싱 등 추가적인 Transform 최적화 기법은 스크립트 최적화 (2) - Unity API와 실행 비용에서 자세히 다룹니다.
SetPositionAndRotation
위치와 회전을 같은 흐름에서 함께 바꾼다면 두 값을 따로 설정할 필요가 없습니다. position과 rotation을 각각 대입하면 같은 Transform에 변경 요청을 두 번 보내게 됩니다. 값이 결국 하나의 월드 변환을 구성한다면, 처음부터 한 번의 호출로 묶어 전달하는 편이 낫습니다.
Transform.SetPositionAndRotation()은 월드 위치와 월드 회전을 함께 설정합니다. 캐릭터를 순간 이동시키거나, 투사체를 스폰 위치와 방향에 맞춰 배치하거나, 네트워크에서 받은 Transform 값을 반영할 때처럼 위치와 회전이 한 쌍으로 갱신되는 코드에 적합합니다.
1
2
3
4
5
6
// 따로 설정: 같은 Transform에 변경 요청을 두 번 보냄
transform.position = newPos;
transform.rotation = newRot;
// 함께 설정: 위치와 회전을 한 번에 갱신
transform.SetPositionAndRotation(newPos, newRot);
부모 기준 좌표를 이미 계산해 둔 상황이라면 로컬 버전도 사용할 수 있습니다. Unity 2021.3.11f1 이상에서는 Transform.SetLocalPositionAndRotation()을 제공하므로, localPosition과 localRotation을 한 번에 설정할 수 있습니다. 값이 이미 부모 기준이라면 월드 좌표를 다시 로컬 좌표로 환산할 필요도 없습니다.
정리하면, 월드 기준 위치와 회전을 함께 바꿀 때는 SetPositionAndRotation()을 사용하고, 부모 기준 위치와 회전을 함께 바꿀 때는 SetLocalPositionAndRotation()을 사용합니다. 핵심은 좌표 기준을 맞춘 뒤, 같은 Transform에 대한 변경을 불필요하게 나누어 호출하지 않는 것입니다.
마무리
Transform은 위치와 회전, 스케일을 담는 컴포넌트인 동시에 부모-자식 계층을 이룹니다. 이 글에서 다룬 런타임 비용은 대부분 그 계층 구조에서 비롯됩니다. 핵심은 다음과 같습니다.
- Transform은 위치와 회전, 스케일을 담는 필수 컴포넌트로, 모든 GameObject에 하나씩 존재합니다.
- localPosition은 부모를 기준으로 한 좌표이고, position은 씬의 월드 원점을 기준으로 한 좌표입니다. 부모의 변환이 바뀌면 자식의 월드 좌표가 다시 계산됩니다.
- SetParent()는 실행 중에 부모-자식 관계를 바꾸는 API입니다. 자식은 새 부모의 이동과 회전, 스케일을 따라가지만, 호출할 때마다 계층 재구성과 좌표 재계산, 관련 시스템 통지가 함께 일어납니다.
- 씬 그래프는 이 부모-자식 관계가 모여 이룬 트리입니다. 한 오브젝트의 월드 좌표는 루트에서 그 오브젝트까지 이어지는 변환 행렬을 순서대로 곱해 얻습니다.
- Transform 변경 하나는 자신의 행렬 재계산에서 시작해, 하위 계층으로의 전파와 TransformChangeDispatch 통지로 이어집니다. 자식이 많거나 계층이 깊을수록 이 비용은 커집니다.
- 최적화의 기준은 계층 깊이와 변경 빈도입니다. 불필요한 중간 노드를 줄이고, 자주 움직이는 오브젝트는 얕은 계층에 두며, 위치를 자주 바꾸는 코드에서는
localPosition과SetPositionAndRotation()으로 재계산과 통지를 줄일 수 있습니다.
이 최적화들은 결국 하나의 원칙으로 모입니다. Transform 비용이 계층 구조에서 나오는 이상, 실제로 부모를 따라 움직여야 하는 관계만 계층에 남기는 것입니다. 따라서 런타임에 뒤늦게 고치기보다, 씬을 설계하는 단계에서부터 어떤 오브젝트가 자주 움직이고 어떤 오브젝트가 부모를 따라야 하는지를 함께 정해 두는 편이 효과적입니다.
다음 글에서는 Unity가 매 프레임 Awake, FixedUpdate, Update, LateUpdate, 렌더링 콜백, 코루틴 재개 시점을 어떤 순서로 호출하는지 살펴봅니다. Unity 엔진 핵심 (3) - Unity 실행 순서에서 이어집니다.
관련 글
전체 시리즈
- 하드웨어 기초 (1) - CPU 아키텍처와 파이프라인
- 하드웨어 기초 (2) - 메모리 계층 구조
- 하드웨어 기초 (3) - GPU의 탄생과 발전
- 하드웨어 기초 (4) - 모바일 SoC
- 그래픽스 수학 (1) - 벡터와 벡터 연산
- 그래픽스 수학 (2) - 행렬과 변환
- 그래픽스 수학 (3) - 좌표 공간의 전환
- 그래픽스 수학 (4) - 투영
- C# 런타임 기초 (1) - 값 타입과 참조 타입
- C# 런타임 기초 (2) - .NET 런타임과 IL2CPP
- C# 런타임 기초 (3) - 가비지 컬렉션의 기초
- C# 런타임 기초 (4) - 스레딩과 비동기
- 색과 빛 (1) - 빛의 물리적 원리
- 색과 빛 (2) - 색 표현과 색공간
- 색과 빛 (3) - 셰이딩 모델
- 래스터화 파이프라인 (1) - 삼각형에서 프래그먼트까지
- 래스터화 파이프라인 (2) - 출력 병합
- 래스터화 파이프라인 (3) - 디스플레이와 안티앨리어싱
- Unity 엔진 핵심 (1) - GameObject와 Component
- Unity 엔진 핵심 (2) - Transform 계층과 씬 그래프 (현재 글)
- Unity 엔진 핵심 (3) - Unity 실행 순서
- Unity 엔진 핵심 (4) - Unity의 스레딩 모델
- Unity 에셋 시스템 (1) - Asset Import Pipeline
- Unity 에셋 시스템 (2) - Serialization과 Instantiation
- Unity 에셋 시스템 (3) - Scene Management
- Unity 렌더링 (1) - Camera와 Rendering Layer
- Unity 렌더링 (2) - Render Target과 Frame Buffer
- Unity 렌더링 (3) - Render Pipeline 개요