2.5D 좌표계, DNFTransform
#️⃣ 들어가며
Unity로 던전 앤 파이터 스타일의 게임을 구현하면서 2.5D 좌표계를 구현해야 했으며, 이를 위해 자체 좌표계인 DNFTransform과 전용 에디터 툴을 개발했다. 이번 글에서는 해당 시스템의 초기 구조, 발생한 문제점, 개선 과정과 그 결과에 대해 정리했다.
1️⃣ 2.5D 좌표계란?
2.5D란 화면에선 2D처럼 보이지만, 실제 계산은 3D 좌표계를 활용하는 방식이다. 주로 횡스크롤 액션 게임에서 사용되며, 오브젝트 간의 상대적인 앞뒤 위치, 충돌 판정 등을 Z 축 기반으로 계산할 수 있다. 유니티에선 2D와 3D 좌표계만 제공하므로, 2.5D 좌표계를 직접 구현할 필요가 있다.
2️⃣ 기존 구현 방식 : 계층 (Hierarchy) 기반
2.5D 좌표계를 구현하기 위해 처음에는 아래와 같은 GameObject 계층 구조를 설계했다.
- XZ 평면 이동 처리를 위한 Root 오브젝트
- Y 축 이동 처리를 위한 Y Pos 오브젝트
- Scale 값을 처리하기 위한 Scale 오브젝트
2.5D 좌표계의 Y 축과 Z 축은 모두 2D 플랫폼의 Y 축으로 표현되기 때문에 두 축을 별개의 값으로 컨트롤하기 위해 Y Pos 오브젝트를 추가했다. 또한 오브젝트의 Scale 값을 설정하고 Pivot을 별도로 설정하기 위해 Scale 오브젝트를 추가했다. 그 결과 DNFTransform 컴포넌트를 통해 화면 상으론 2D처럼 보이지만 3D로 좌표 계산을 할 수 있는 2.5D 좌표계를 구현할 수 있었다.
// Transform component used to determine the position in the XZ planes.
private Transform posTransform = null;
// Transform component used to determine the position in the Y axis.
private Transform yPosTransform = null;
// Transform component used to determine the scale value of the object.
private Transform scaleTransform = null;
// X component of the DNFTransform.
// It changes in proportion to the x-value of the position in the posTransform.
public float X
{
set
{
Vector3 pos = posTransform.position;
pos.x = value;
posTransform.position = pos;
}
get => posTransform.position.x;
}
// Z component of the DNFTransform.
// It changes in proportion to the screen ratio for the y-value of the position in the posTransform.
public float Z
{
set
{
Vector3 pos = posTransform.position;
pos.y = value * GlobalDefine.ConvRate;
posTransform.position = pos;
}
get => posTransform.position.y * GlobalDefine.InvConvRate;
}
// Y component of the DNFTransform.
// It changes in proportion to the y-value of the local position in the yPosTransform.
public float Y
{
set
{
Vector3 pos = yPosTransform.localPosition;
pos.y = value;
yPosTransform.localPosition = pos;
}
get => yPosTransform.localPosition.y;
}
// Scale value of the object.
// Modify the x and y values of the localScale in the scaleTransform.
public float LocalScale
{
set
{
scaleTransform.localScale = new Vector3(value, value, 1f);
}
get => scaleTransform.localScale.x;
}
3️⃣ 복잡한 Hierarchy를 갖게 된 이유
2.5D 좌표계는 실제 계산 시 3D 좌표계를 사용하지만, 플레이어에겐 2D처럼 보여야 한다.예를 들어, $(x, \ y, \ z)$ 좌표에서 시각적으로 보이는 위치는 $(x, \ y \ + \ z \ \times \ 화면 \ 비율)$로 변환하여 Unity의 Transform.position에 반영한다.
public static Vector3 ConvertDNFPosToWorldPos(Vector3 dnfPosition)
{
return new Vector3(dnfPosition.x, dnfPosition.y + dnfPosition.z * GlobalDefine.CONV_RATE, 0f);
}
이 계산은 코드로 간단히 처리할 수 있지만, 반대로 Transform.position을 통해 DNFTransform.position을 정확히 역산할 수는 없다. Transform.position 만으로는 DNFTransform.position의 Y 값을 추측할 수 없기 때문이다. (오브젝트가 공중에 떠 있는건지, 아님 그냥 저 멀리 있는 건지 알 방도가 없다)
특히, 문제는 에디터에서 씬 뷰를 통해 오브젝트를 직접 배치할 때 발생했다. Unity의 GUI는 Transform 컴포넌트만을 대상으로 하기 때문에, DNFTransform.position의 Y 축과 Z 축이 결합된 상태에서는 원래의 Y 값을 분리할 수 없었고, 결과적으로 공중에 떠 있는 오브젝트는 GUI로 배치할 수 없었다.
이를 해결하기 위해, Root 오브젝트의 자식 오브젝트로 Y Pos 오브젝트를 추가하여 Y 축의 값을 GUI로 손쉽게 설정할 수 있었다. 즉, 복잡한 계층 구조는 단지 논리적 설계를 위한 것이 아닌, Unity 에디터와의 호환성과 직관적인 조작을 만족시키기 위한 선택이었다.
4️⃣ 엮이고 엮여서 계속 복잡해지는 구조
하지만 시간이 지나면서 복잡한 Hierarchy 구조에 따라 여러 Side Effect가 발생했다.
❗ 작업 실수 유발
계층이 깊어질수록 애니메이션 클립을 제작할 때마다 수많은 프로퍼티가 추가되었고, 클립 하나를 만드는 데도 너무 복잡하여 실수하는 일이 잦았다. Sprite Renderer 컴포넌트가 있는 오브젝트까지의 Hierarchy 경로가 굉장히 길어 자세히 보지 않으면 프로퍼티를 잘못 할당하여 애니메이션이 고장났다.
❗ 예외 처리 증가
Y Pos 오브젝트나 Scale 오브젝트가 없는 경우를 위한 예외처리가 필수적이었다. 예를 들어, 남귀검사 소울브링어의 '냉기의 사야' 스킬과 같은 장판류의 스킬의 경우, Y 축과 상관없이 해당 영역 안에 존재하는 모든 몬스터에게 데미지를 입힌다. 또한 무조건 지상에 (Y 축 값이 무조건 0) 설치되기 때문에 Y Pos 오브젝트가 필요없다. 이렇게 자식 오브젝트가 없는 경우, DNFTransform 컴포넌트를 초기화할 때 Null 체크가 필수적으로 수행되어야 했다.
// Return whether yPosTransform exists or not.
public bool HasYObj => yPosTransform != null;
// The world position of DNFTransform.
// If yPosTransform do not exists, the y-value of DNFTransform will be fixed at 0.
public Vector3 Position
{
set
{
X = value.x;
Z = value.z;
Y = HasYObj ? value.y : 0f;
}
get => new Vector3(X, HasYObj ? Y : 0f, Z);
}
❗ 불필요한 오브젝트 유지
DNFTransform 컴포넌트가 실제로 필요하지 않은 오브젝트에도 호환성 문제로 인해 복잡한 Hierarchy 구조를 유지해야 한다. 캐릭터나 스킬 투사체와 달리 단순한 이펙트 오브젝트의 경우, 특정 위치에 생성되기만 하면 되므로, DNFTransform.position을 Transform.position으로 변환하여 배치하면 충분하다고 판단했다. 따라서 이펙트에는 굳이 DNFTrasnfrom 컴포넌트가 필요 없다고 생각했다.
하지만 실제 개발 과정에서 스킬 이펙트 역시 Sprite의 깊이 정렬 (Depth Sorting)이 필요했고, 이 작업은 DNFTransform 기반의 위치 정보를 바탕으로 이뤄져야 했다. 결국 이펙트 오브젝트도 DNFTransform과 동일한 정렬 방식을 따라야 했고, 그에 따라 모든 이펙트마다 불필요한 계층 구조를 강제로 유지해야 하는 상황이 발생했다.
// Calculate the sorting order of a sprite using the DNFTransform component
public int GetSortingOrder(DNFTransform obj)
{
float objDist = Mathf.Abs(maxZ - obj.Position.z);
return (int)(Mathf.Lerp(System.Int16.MinValue, System.Int16.MaxValue, objDist / totalZ));
}
5️⃣ 그냥 에디터를 만들어버리자!
모든 문제점의 발생 원인은 결국 Unity의 기본 씬 에디터에서 오브젝트의 위치를 직관적으로 조작하기 위해 복잡한 Hierarchy를 유지해야만 했던 점에서 비롯됬다. 그렇다면, 애초에 복잡한 계층 구조 없이도 GUI를 통해 오브젝트를 손쉽게 배치할 수 있다면, DNFTransform 컴포넌트의 구조 자체를 훨씬 간단하게 만들 수 있다. 이를 위해 UnityEditor.Editor 클래스를 활용해 DNFTransform 전용 커스텀 에디터를 구현했다.
✅ 구조 간소화
먼저, 기존 DNFTransform 클래스에서 계층을 구성하던 각 오브젝트의 Transform 참조들을 제거하고, 2.5D 좌표계 상의 위치를 표현하기 위한 하나의 Vector3 타입의 변수와 시각적 크기를 조절할 수 있는 float 타입의 스케일 변수로 단순화했다. 또한 이 변수들을 수정할 수 있도록 DNFTransform 클래스 내부에 관련 프로퍼티들을 새롭게 정의했다.
// The cached Transform component of the target object.
private Transform cachedTransform = null;
[Header("Transform variables")]
[SerializeField] private Vector3 position = Vector3.zero;
[SerializeField] private float localScale = 1f;
// X component of the position in the DNF coordinate.
// It changes in proportion to the x-value of the position of the cachedTransform.
public float X
{
set
{
position.x = value;
cachedTransform.position = ConvertPosToWorldCoord(position);
}
get => position.x;
}
// .. Use the same logic for the Y and Z axes as for the X axis
// Scale value of the object.
// Modify the x and y values of the localScale of the cachedTransform.
// The scale value is always greater than 0.
public float LocalScale
{
set
{
if (value <= 0f)
{
throw new System.Exception($"Local scale value cannot be less than 0.\nInput scale value : {value}");
}
localScale = value;
cachedTransform.localScale = new Vector3((IsLeft ? -1f : 1f) * localScale, localScale, 1f);
}
get => localScale;
}
✅ Inspector 개선
다음으로 Inspector 창을 커스터마이징하여 다음 기능을 추가했다.
- 각 변수값을 수동으로 입력할 수 있는 필드 UI 제공
- Scene GUI 표시 여부를 제어하는 토글 버튼 추가 $\rightarrow$ 여러 에디터 인스턴스가 있을 경우 충돌 방지
- 작업 평면 선택 기능 추가 $\rightarrow$ XY 평면 또는 XZ 평면 중 어떤 평면을 기준으로 조작할지를 선택할 수 있도록 함
✅ Scene GUI 추가
씬 뷰 상에서 직관적인 조작을 가능하게 하기 위해 다음과 같은 헨들러를 구현했다.
- Arrow Slider : 각 축 방향의 값을 조절 가능
- Slider2D : 평면 단위로 값을 조절 가능
이러한 Scene GUI 덕분에 Inspector 창 없이도 직접 마우스로 오브젝트의 위치나 크기를 조절할 수 있게 되었고, 더 이상 Y Pos 나 Scale 같은 서브 오브젝트가 필요없어졌다.
✅ 결과
에디터 툴의 도입으로 인해 DNFTransform 컴포넌트를 사용하는 모든 오브젝트는 단일 Root 오브젝트만으로 충분하게 되었고, 이에 따라 씬 내 오브젝트 수를 크게 줄일 수 있었다. 또한 복잡했던 계층 구조는 사라졌고, 유지보수성 또한 크게 향상되었다.
💬 회고
처음 계층 구조를 설계할 때는 2.5D 좌표계를 시각적으로 설계 가능한 형태로 구현해야 한다는 목적이 강했다. 그러나 실제로 개발이 진행되며, Unity의 기본 Transform 컴포넌트와 충돌하는 비효율적인 구조가 되어버렸고, 이를 해결하기 위해 다시 구조를 뜯어보는 과정에서 툴을 직접 만들어야 한다는 결론에 도달했다.
이번 경험을 통해 도구의 한계를 구조로 우회하는 것보다는, 근본적인 문제를 해결할 수 있는 도구를 만드는 것이 더 효과적일 수 있다는 교훈을 얻었다. 앞으로도 유사한 상황에선 '툴의 확장'이라는 선택지를 더 빠르게 고려할 수 있을 듯 하다.