■ 쿼드트리(QuadTree) 공간분할
쿼드트리(QuadTree)는 공간을 효율적으로 관리하기 위한 트리 구조로, 하나의 노드가 4개의 자식 노드를 가지는 자료구조이다.
→ 특정 조건(예 : 최소 크기, 포함된 객체 수)에 도달할 때까지 재귀적으로 공간을 4 분할한다.
플레이어(빨간색 박스)가 속한 노드의 객체만 활성화하여 불필요한 연산이 처리되지 않도록 구현하였다.
🛠️ 구현 방식
1. 초기 공간 생성
분할될 공간(루트 노드)을 하나의 사각형 영역으로 정의한다.
2. 분할 조건 검사
본 구현에서는 노드 내부의 객체 수를 기준으로 분할 여부를 결정한다.
- 노드 안의 객체 수가 설정한 최대 허용치보다 많으면 해당 영역을 4 등분하여 자식 노드를 생성한다.
- 더 이상 분할할 필요가 없는 경우 리프 노드로 남긴다.
3. 객체 분배
객체는 자신이 속한 사각형 영역의 노드에 등록된다.
4. 최적화 적용
카메라나 플레이어가 위치한 노드만 검사하거나, 해당 영역 내 객체들만 활성화하여 불필요한 연산량울 줄인다.
■ 상세 구현 내용
1. 분할 공간(RootNode) 지정
쿼드트리의 루트 노드를 지정하기 위해 커스텀 에디터 윈도우를 열면, 사각형 영역의 좌하단과 우상단 위치를 나타내는 두 개의 오브젝트가 자동으로 생성된다.
- 이 두 지점을 기준으로 초기 분할 영역(RootNode)을 설정한다.
- 노드 안에 포함될 최대 객체 수를 설정하고 Bake 버튼을 누르면 공간 분할을 실행하도록 구현하였다.
using System;
using System.Collections.Generic;
using UnityEditor;
using UnityEngine;
public class QuadTreeEditor : EditorWindow
{
private GameObject _leftBottom;
private GameObject _rightTop;
private void OnEnable()
{
var parent = GameObject.Find("QuadTree").transform;
_quadTreeManager = FindFirstObjectByType<QuadTreeManager>();
if (_leftBottom == null)
{
_leftBottom = new GameObject("LeftBottom");
_leftBottom.transform.SetParent(parent);
}
if (_rightTop == null)
{
_rightTop = new GameObject("RightTop");
_rightTop.transform.SetParent(parent);
}
if (_quadTreeManager.RootNode != null)
{
_leftBottom.transform.position =
new Vector3(_quadTreeManager.RootNode.LeftBottom.x, 0,
_quadTreeManager.RootNode.LeftBottom.y);
_rightTop.transform.position =
new Vector3(_quadTreeManager.RootNode.RightTop.x, 0,
_quadTreeManager.RootNode.RightTop.y);
}
SceneView.duringSceneGui += OnSceneGUI;
}
private void OnDisable()
{
if (_leftBottom != null)
{
DestroyImmediate(_leftBottom);
}
if (_rightTop != null)
{
DestroyImmediate(_rightTop);
}
SceneView.duringSceneGui -= OnSceneGUI;
}
private void OnSceneGUI(SceneView view)
{
if(_leftBottom == null || _rightTop == null) return;
var lbT = _leftBottom.transform.position;
var rtT = _rightTop.transform.position;
DrawLine(new Vector2(lbT.x, lbT.z), new Vector2(rtT.x, rtT.z), Color.red);
}
private void DrawLine(Vector2 p1, Vector2 p2, Color color)
{
var leftBottom = new Vector3(p1.x, 10f, p1.y);
var leftTop = new Vector3(p1.x, 10f, p2.y);
var rightBottom = new Vector3(p2.x, 10f, p1.y);
var rightTop = new Vector3(p2.x, 10f, p2.y);
Handles.color = color;
Handles.DrawLine(leftBottom, rightBottom);
Handles.DrawLine(rightBottom, rightTop);
Handles.DrawLine(rightTop, leftTop);
Handles.DrawLine(leftTop, leftBottom);
}
}
1. OnEnable()
커스텀 에디터 윈도우가 활성화되면 호출된다.
- 분할 영역의 좌하단(LeftBottom)과 우상단(RightTop)을 나타내는 오브젝트를 생성하고, 이미 생성된 RootNode값이 있으면 그 위치로 배치한다.
분할 영역을 시각적으로 표시하기 위해 SceneView.duringSceneGui 대리자에 분할 영역을 그리는 함수를 등록한다.
2. OnDisable()
에디터 윈도우가 비활성화되면 호출되며, 생성했던 좌표 오브젝트를 제거하고 대리자에서 함수 등록도 해제한다.
3. OnSceneGUI()
씬 뷰가 갱신될 때마다 호출된다.
- 좌하단과 우상단 오브젝트 위치를 기반으로, DrawLine()을 통해 분할 영역의 경계를 씬에 그린다.
2. 트리 구성 노드
using System;
using System.Collections.Generic;
using UnityEngine;
[Serializable]
public class QNode : MonoBehaviour
{
[SerializeField] private Vector2 leftBottom;
[SerializeField] private Vector2 rightTop;
public Vector2 LeftBottom => leftBottom;
public Vector2 RightTop => rightTop;
public List<int> IncludeObjectIndex = new List<int>();
public QNode[] ChildNodes = null;
private List<IQuadObject> _includeObject;
public void SetPosition(Vector2 lb, Vector2 rt)
{
leftBottom = lb;
rightTop = rt;
}
public void SetStateIncludeObject(bool isEnable)
{
foreach (var obj in _includeObject)
{
if (isEnable == true) obj.EnableObject();
else obj.DisableObject();
}
}
public void CacheObject(List<IQuadObject> objList)
{
_includeObject = new List<IQuadObject>();
IncludeObjectIndex.ForEach(i => _includeObject.Add(objList[i]));
}
}
트리를 구성하는 QNode는 각 노드의 좌하단/우상단 좌표, 자식 노드 배열, 그리고 노드에 포함된 객체 정보를 저장하도록 구현하였다.
- IncludeObjectIndex
- 공간 분할 과정에서 해당 노드에 포함된 객체들의 “인덱스 번호”를 저장한다.
- _includeObject
- 런타임 시, 저장된 인덱스를 바탕으로 실제 참조를 IQuadObject 캐싱한다.
- 노드의 활성화/비활성화 시, IQuadObject에 선언된 EnableObject(), DisableObject()를 호출한다. (IQuadObejct는 쿼드트리 관리 대상 객체가 구현해야 하는 인터페이스)
분할 과정에서 모든 노드의 실제 객체 참조를 직접 저장하지 않고, 인덱스 번호만 기록한다.
그 후 런타임에서 단말 노드를 대상으로만 실제 객체를 캐싱하여 불필요한 메모리 낭비를 최소화하였다.
3. 공간 분할
using System;
using System.Collections.Generic;
using System.Linq;
using UnityEngine;
public class QuadTreeManager : MonoBehaviour
{
[Header("Component")]
[SerializeField] private ObjectRandomSpawner objectSpawner;
[SerializeField] private QNode rootNode;
[SerializeField] private Transform player;
public QNode RootNode => rootNode;
private QNode _prevNode;
private List<IQuadObject> _objList = null;
public void GenerateQuadTree(int includeCount)
{
if(includeCount <= 0) return;
_objList = objectSpawner.GetSpawnObjectsOrNull();
if (_objList == null) return;
SetRootNode();
var stack = new Stack<QNode>();
stack.Push(rootNode);
while (stack.Count > 0)
{
var node = stack.Pop();
for (var i = 0; i < _objList.Count; ++i)
{
if (CheckObjectInNode(((MonoBehaviour)_objList[i]).
transform.position, node) == true)
{
node.IncludeObjectIndex.Add(i);
}
}
if (node.IncludeObjectIndex.Count < includeCount) continue;
SplitNode(node);
for (var i = 0; i < 4; ++i)
{
stack.Push(node.ChildNodes[i]);
}
}
}
private void SetRootNode()
{
if (rootNode != null)
{
DestroyImmediate(rootNode.gameObject);
}
rootNode = new GameObject("RootNode").AddComponent<QNode>();
rootNode.transform.SetParent(transform);
rootNode.SetPosition(ConvertVector3ToVector2(transform.Find("LeftBottom").position),
ConvertVector3ToVector2(transform.Find("RightTop").position));
}
private bool CheckObjectInNode(Vector3 pos, QNode node)
{
if (pos.x < node.LeftBottom.x || pos.x > node.RightTop.x)
{
return false;
}
if (pos.z < node.LeftBottom.y || pos.z > node.RightTop.y)
{
return false;
}
return true;
}
private Vector2 ConvertVector3ToVector2(Vector3 pos)
{
return new Vector2(pos.x, pos.z);
}
}
- 루트 노드 생성
- 좌하단, 우상단 좌표를 기준으로 RootNode를 생성한다.
- 객체 검사
- 노드가 객체 안에 속해있는지 확인하고, 해당 노드에 인덱스를 기록한다.
- 분할 조건 검사
노드 내 객체 수가 지정한 최대치보다 많으면 4 분할을 수행한다. 부모 노드의 좌하단, 우상단 좌표와 중점 좌표만 있으면 4개의 자식 노드 영역을 위처럼 구할 수 있다.
4. 재귀 분할 진행
- 새로 생성된 자식 노드를 스택에 넣고, 위 과정을 진행한다.
4. 객체 정보 저장
public class QuadTreeManager : MonoBehaviour
{
private void Start()
{
_objList = objectSpawner.GetSpawnObjectsOrNull();
if (_objList == null) return;
CacheAllLeftNode(RootNode);
}
private void CacheAllLeftNode(QNode node)
{
if (node.ChildNodes == null || node.ChildNodes.Length == 0)
{
node.CacheObject(_objList);
return;
}
foreach (var child in node.ChildNodes)
{
CacheAllLeftNode(child);
}
}
}
Start() 함수가 호출되면, 모든 리프 노드에 객체 정보를 캐싱한다.
- CacheAllLeftNode()→ 리프 노드가 아니라면 : 자식 노드를 대상으로 재귀 호출.
- → 리프 노드라면 : 인덱스 기반으로 실제 객체 참조를 저장.
5. 플레이어 위치 추적
public class QuadTreeManager : MonoBehaviour
{
private void Update()
{
var currentNode = GetCurrentNode(RootNode);
if (_prevNode == currentNode || currentNode == null) return;
if (_prevNode != null) _prevNode.SetStateIncludeObject(false);
_prevNode = currentNode;
currentNode.SetStateIncludeObject(true);
}
private QNode GetCurrentNode(QNode node)
{
if (node.ChildNodes == null || node.ChildNodes.Length == 0)
{
return node;
}
foreach (var child in node.ChildNodes)
{
if (CheckObjectInNode(player.position, child) == true)
{
return GetCurrentNode(child);
}
}
return null;
}
}
Update()에서 플레이어가 속한 노드를 매 프레임마다 탐색한다.
- 플레이어가 동일한 노드에 존재(prevNode == curNode) : 객체 상태를 유지.
- 다른 노드로 이동(prevNode != curNode) : 이전 노드 객체 비활성화, 새 노드의 객체 활성화.
🤔 어차피 단말 노드만 탐색한다면, 리프 노드만 따로 저장해 두면 되지 않을까?
실제로 객체 정보는 리프 노드에만 저장되고, 플레이어 위치 검사 또한 리프 노드를 기준으로 진행되기 때문에 리프 노드만 따로 저장하여 사용하는 것이 더 좋게 보일 수 있다.
1. 리프 노드 배열 방식
모든 리프 노드를 별도의 리스트에 저장한다면, 매 프레임마다 전부 검사해야 한다. 따라서 최악의 경우 모든 리프 노드를 확인해야 하므로 시간 복잡도는 $O(n)$이 된다.
2. 트리 구조 사용
트리 구조를 사용하게 되면, 루트에서 시작해 플레이어가 속한 영역만 내려가며 탐색하면 된다.
- 예를 들어, 플레이어가 우측 하단 영역에 있다면 우측 하단 영역의 “자식 노드들”만 타고 내려가면 된다.
- 즉, 모든 노드를 탐색하는 것이 아닌 플레이어가 속한 노드의 자식 노드들만 탐색한다.
따라서 탐색은 트리의 깊이만큼 진행되고, 이 경우의 시간 복잡도는 $O(\log n)$이 된다.
■ 최적화 테스트
public class EnemyObject : MonoBehaviour, IQuadObject
{
private void Update()
{
_updateAction?.Invoke();
}
public void EnableObject()
{
_updateAction += PerformanceTestFunction;
_renderer.material = enableMat;
}
public void DisableObject()
{
_updateAction -= PerformanceTestFunction;
_renderer.material = disableMat;
}
private void PerformanceTestFunction()
{
for (var i = 0; i < 100; ++i)
{
var obj = new GameObject("Empty");
Destroy(obj);
}
}
}
위와 같이 매 프레임에 100개의 객체를 생성/파괴하는 작업을 수행하는 객체 100개를 배치하여, 공간 분할 적용 전/후 성능을 비교하였다.
항목 | 쿼드트리 적용 전 | 쿼드트리 적용 후 |
프레임 시간 | 75.34ms (≈ 13 FPS) | 31.76ms (≈ 31 FPS) |
주요 비용 | Destroy() - 36.4% / 호출 수 : 19,600 AddComponent - 25.6% |
Destroy() - 8.6% / 호출 수 : 700 AddComponent -9.2% |
GC Used Memory | 0.7GB | 0.7GB |
GC Allocated In Frame | 0.7MB / frame | 27.3KB / frame |
공간분할 적용으로 활성화 객체가 전체 → 일부 객체로 줄어들어 개별 객체의 생성/파괴 및 활성화 반복이 줄었고, Destroy()와 AddComponent 같은 함수의 비중이 크게 완화되었다.
또한, GC의 프레임당 할당량이 0.7MB → 27.3KB로 약 25배 감소하면서, 불필요한 임시 객체 생성이 대폭 줄어 GC 발생 빈도가 낮아지고 프레임 안전성이 향상되었다.
■ 전체 코드
1. QuadTreeEditor.cs
using UnityEditor;
using UnityEngine;
public class QuadTreeEditor : EditorWindow
{
private int _objectCount;
private QuadTreeManager _quadTreeManager = null;
private GameObject _leftBottom;
private GameObject _rightTop;
[MenuItem("Tools/QuadTree", validate = false, priority = -1)]
private static void Init()
{
var window = GetWindow<QuadTreeEditor>();
window.position = new Rect(800, 500, 500, 800);
window.Show();
}
public void OnGUI()
{
var label = new GUIStyle(GUI.skin.label)
{
alignment = TextAnchor.MiddleCenter,
fontSize = 20,
fontStyle = FontStyle.Bold,
normal = { textColor = Color.white }
};
GUILayout.Label("Quad Tree Setting", label);
GUILayout.Space(10);
_objectCount = EditorGUILayout.IntField("객체 수 입력", _objectCount);
GUILayout.Space(5);
if (GUILayout.Button("Bake"))
{
_quadTreeManager.GenerateQuadTree(_objectCount);
}
}
private void OnEnable()
{
var parent = GameObject.Find("QuadTree").transform;
_quadTreeManager = FindFirstObjectByType<QuadTreeManager>();
if (_leftBottom == null)
{
_leftBottom = new GameObject("LeftBottom");
_leftBottom.transform.SetParent(parent);
}
if (_rightTop == null)
{
_rightTop = new GameObject("RightTop");
_rightTop.transform.SetParent(parent);
}
if (_quadTreeManager.RootNode != null)
{
_leftBottom.transform.position =
new Vector3(_quadTreeManager.RootNode.LeftBottom.x, 0,
_quadTreeManager.RootNode.LeftBottom.y);
_rightTop.transform.position =
new Vector3(_quadTreeManager.RootNode.RightTop.x, 0,
_quadTreeManager.RootNode.RightTop.y);
}
SceneView.duringSceneGui += OnSceneGUI;
}
private void OnDisable()
{
if (_leftBottom != null)
{
DestroyImmediate(_leftBottom);
}
if (_rightTop != null)
{
DestroyImmediate(_rightTop);
}
SceneView.duringSceneGui -= OnSceneGUI;
}
private void OnSceneGUI(SceneView view)
{
if(_leftBottom == null || _rightTop == null) return;
var lbT = _leftBottom.transform.position;
var rtT = _rightTop.transform.position;
DrawLine(new Vector2(lbT.x, lbT.z), new Vector2(rtT.x, rtT.z), Color.red);
if (_quadTreeManager == null || _quadTreeManager.RootNode == null) return;
RecursiveDrawLine(_quadTreeManager.RootNode);
}
private void DrawLine(Vector2 p1, Vector2 p2, Color color)
{
var leftBottom = new Vector3(p1.x, 10f, p1.y);
var leftTop = new Vector3(p1.x, 10f, p2.y);
var rightBottom = new Vector3(p2.x, 10f, p1.y);
var rightTop = new Vector3(p2.x, 10f, p2.y);
Handles.color = color;
Handles.DrawLine(leftBottom, rightBottom);
Handles.DrawLine(rightBottom, rightTop);
Handles.DrawLine(rightTop, leftTop);
Handles.DrawLine(leftTop, leftBottom);
}
private void RecursiveDrawLine(QNode node)
{
DrawLine(node.LeftBottom, node.RightTop, Color.white);
if (node.ChildNodes == null || node.ChildNodes.Length <= 0) return;
for (var i = 0; i < 4; ++i)
{
RecursiveDrawLine(node.ChildNodes[i]);
}
}
}
2. QNode.cs
using System;
using System.Collections.Generic;
using UnityEngine;
[Serializable]
public class QNode : MonoBehaviour
{
[SerializeField] private Vector2 leftBottom;
[SerializeField] private Vector2 rightTop;
public Vector2 LeftBottom => leftBottom;
public Vector2 RightTop => rightTop;
public List<int> IncludeObjectIndex = new List<int>();
public QNode[] ChildNodes = null;
private List<IQuadObject> _includeObject;
public void SetPosition(Vector2 lb, Vector2 rt)
{
leftBottom = lb;
rightTop = rt;
}
public void SetStateIncludeObject(bool isEnable)
{
foreach (var obj in _includeObject)
{
if (isEnable == true) obj.EnableObject();
else obj.DisableObject();
}
}
public void CacheObject(List<IQuadObject> objList)
{
_includeObject = new List<IQuadObject>();
IncludeObjectIndex.ForEach(i => _includeObject.Add(objList[i]));
}
}
3. QuadTreeManager.cs
using System;
using System.Collections.Generic;
using System.Linq;
using UnityEngine;
public class QuadTreeManager : MonoBehaviour
{
[Header("Component")]
[SerializeField] private ObjectRandomSpawner objectSpawner;
[SerializeField] private QNode rootNode;
[SerializeField] private Transform player;
public QNode RootNode => rootNode;
private QNode _prevNode;
private List<IQuadObject> _objList = null;
private void Start()
{
_objList = objectSpawner.GetSpawnObjectsOrNull();
if (_objList == null) return;
CacheAllLeftNode(RootNode);
}
private void Update()
{
var currentNode = GetCurrentNode(RootNode);
if (_prevNode == currentNode || currentNode == null) return;
if (_prevNode != null) _prevNode.SetStateIncludeObject(false);
_prevNode = currentNode;
currentNode.SetStateIncludeObject(true);
}
public void GenerateQuadTree(int includeCount)
{
if(includeCount <= 0) return;
_objList = objectSpawner.GetSpawnObjectsOrNull();
if (_objList == null) return;
SetRootNode();
var stack = new Stack<QNode>();
stack.Push(rootNode);
while (stack.Count > 0)
{
var node = stack.Pop();
for (var i = 0; i < _objList.Count; ++i)
{
if (CheckObjectInNode(((MonoBehaviour)_objList[i]).transform.position, node) == true)
{
node.IncludeObjectIndex.Add(i);
}
}
if (node.IncludeObjectIndex.Count < includeCount) continue;
SplitNode(node);
for (var i = 0; i < 4; ++i)
{
stack.Push(node.ChildNodes[i]);
}
}
}
private void SplitNode(QNode node)
{
node.ChildNodes = new QNode[4];
var lb = node.LeftBottom;
var rt = node.RightTop;
var mid = CalculateMidPoint(node);
node.ChildNodes[0]= new GameObject("Node1").AddComponent<QNode>();
node.ChildNodes[0].SetPosition(mid, rt);
node.ChildNodes[0].transform.SetParent(node.transform);
node.ChildNodes[1] = new GameObject("Node2").AddComponent<QNode>();
node.ChildNodes[1].SetPosition(new Vector2(lb.x, mid.y),
new Vector2(mid.x, rt.y));
node.ChildNodes[1].transform.SetParent(node.transform);
node.ChildNodes[2] = new GameObject("Node3").AddComponent<QNode>();
node.ChildNodes[2].SetPosition(lb, mid);
node.ChildNodes[2].transform.SetParent(node.transform);
node.ChildNodes[3] = new GameObject("Node4").AddComponent<QNode>();
node.ChildNodes[3].SetPosition(new Vector2(mid.x, lb.y),
new Vector2(rt.x, mid.y));
node.ChildNodes[3].transform.SetParent(node.transform);
}
private QNode GetCurrentNode(QNode node)
{
if (node.ChildNodes == null || node.ChildNodes.Length == 0)
{
return node;
}
foreach (var child in node.ChildNodes)
{
if (CheckObjectInNode(player.position, child) == true)
{
return GetCurrentNode(child);
}
}
return null;
}
private void SetRootNode()
{
if (rootNode != null)
{
DestroyImmediate(rootNode.gameObject);
}
rootNode = new GameObject("RootNode").AddComponent<QNode>();
rootNode.transform.SetParent(transform);
rootNode.SetPosition(ConvertVector3ToVector2(transform.Find("LeftBottom").position),
ConvertVector3ToVector2(transform.Find("RightTop").position));
}
private bool CheckObjectInNode(Vector3 pos, QNode node)
{
if (pos.x < node.LeftBottom.x || pos.x > node.RightTop.x)
{
return false;
}
if (pos.z < node.LeftBottom.y || pos.z > node.RightTop.y)
{
return false;
}
return true;
}
private Vector2 CalculateMidPoint(QNode node)
{
return new Vector2((node.LeftBottom.x + node.RightTop.x) / 2,
(node.LeftBottom.y + node.RightTop.y) / 2);
}
private Vector2 ConvertVector3ToVector2(Vector3 pos)
{
return new Vector2(pos.x, pos.z);
}
private void CacheAllLeftNode(QNode node)
{
if (node.ChildNodes == null || node.ChildNodes.Length == 0)
{
node.CacheObject(_objList);
return;
}
foreach (var child in node.ChildNodes)
{
CacheAllLeftNode(child);
}
}
}
4. IQuadObject.cs
public interface IQuadObject
{
public void EnableObject();
public void DisableObject();
}
'Unity,C# > 알고리즘' 카테고리의 다른 글
[정렬 알고리즘, C#] 병합 정렬(Merge Sort) (1) | 2025.06.20 |
---|---|
[정렬 알고리즘, C#] 퀵 정렬(Quick Sort) - Hoare, Lomuto 분할 방식 (1) | 2025.06.18 |
[정렬 알고리즘, C#] 삽입 정렬(Insertion Sort) (0) | 2025.06.17 |
[정렬 알고리즘, C#] 선택 정렬(Selection Sort) (0) | 2025.06.17 |
[정렬 알고리즘, C#] 버블 정렬(Bubble Sort) (1) | 2025.06.17 |