공부/Unity

[최적화 스터디] 3. Project Auditor 2

BeepMaeae 2025. 11. 27. 15:14

* 본 게시물은 3D 유니티 프로젝트를 다룬다.

 

지난 게시물에서 다뤘던 Project Audtior를 활요하여 최적화를 몇 가지 더 진행하였다.

 

Object.FindObjectsOfType()

PipeInteractionRig.cs/ConveyorBeltDiagonalRig.cs - FindPlayerCC

//수정 전
static CharacterController FindPlayerCC(string tagName)
{
    GameObject go = null;

    if (!string.IsNullOrEmpty(tagName))
        go = GameObject.FindGameObjectWithTag(tagName);

    CharacterController cc = null;
    if (go)
    {
        cc = go.GetComponent<CharacterController>()
          ?? go.GetComponentInChildren<CharacterController>(true)
          ?? go.GetComponentInParent<CharacterController>();
        if (cc) return cc;
    }
        
		var all = Object.FindObjectsOfType<CharacterController>(true);
		foreach (var c in all)
		{
		    if (!string.IsNullOrEmpty(tagName) &&
		        (c.CompareTag(tagName) || c.transform.root.CompareTag(tagName)))
		        return c;
		    if (c.name.ToLower().Contains("player")) return c;
}

//수정 후
static CharacterController FindPlayerCC(string tagName)
{
    GameObject go = null;

    // 1) 태그로 플레이어 오브젝트 찾기
    if (!string.IsNullOrEmpty(tagName))
        go = GameObject.FindGameObjectWithTag(tagName);

    if (!go)
        return null;

    // 2) 그 오브젝트(혹은 자식/부모)에서 CC 찾기
    CharacterController cc = go.GetComponent<CharacterController>();

    return cc;
}

 

기존 로직은 Player 태그가 없다면, ‘씬에 있는 모든 오브젝트 중 CharacterController가 붙어 있는 걸 먼저 찾는 비효율적인 코드였다.

하지만 FindObjectsOfType는 다음과 같은 문제가 존재한다.

  1. 씬에 존재하는 모든 활성/비활성 객체를 순회하며 CharacterController 컴포넌트를 찾는 CPU 부하가 매우 심한 작업이다.
  2. FindObjectsOfType는 결과를 반환할 때 내부적으로 새로운 배열을 생성하여 힙 메모리에 할당한다. 루프 안의 c.name.ToLower()도소문자로 변환된 새로운 문자열을 힙에 생성한다. 이 역시 GC 문제가 발생할 것이다.

우리는 이미 Player에 태그가 있고, 그 본체에 Player Controller가 할당된 사실을 알고 있다. 따라서 이러한 과정을 넣을 필요가 없다.

내가 알고 있는 정보를 최대한 활용하여 프로그래밍을 최적화할 필요가 있다는 사실을 다시금 깨달았다.

 

Physics.RaycastAll

Boss.cs

namespace Maggi.Character.Boss
{
    // ... 기존 변수들 ...
    private Vector3 _handCatch_InitPosition;
    private Vector3 _handUnCatch_InitPosition;

+   // [최적화] RaycastNonAlloc 결과를 재사용할 버퍼 (메모리 할당 방지)
+   private readonly RaycastHit[] _raycastHits = new RaycastHit[8];

    // ... (탐지 로직 내부) ...
    
        Vector3 directionToTarget = (_target.position - origin).normalized;
        float distanceToTarget = Vector3.Distance(origin, _target.position);
        
-       Ray ray = new Ray(origin, directionToTarget);
-       RaycastHit[] hits = Physics.RaycastAll(ray, distanceToTarget);
+       // [최적화] NonAlloc 사용 (새 배열 생성 안 함)
+       int hitCount = Physics.RaycastNonAlloc(
+           origin,
+           directionToTarget,
+           _raycastHits,
+           distanceToTarget
+       );

-       // 충돌 결과를 가까운 순서대로 정렬 (O(N log N) + GC 발생 가능성)
-       System.Array.Sort(hits, (hit1, hit2) => hit1.distance.CompareTo(hit2.distance));
+       if (hitCount <= 0)
+       {
+           Debug.DrawRay(origin, directionToTarget * distanceToTarget, Color.red, 3.0f);
+           return;
+       }

-       Color rayColor = hits[0].transform.CompareTag(PLAYER_TAG) ? Color.green : Color.red;
-       Debug.DrawRay(origin, directionToTarget * distanceToTarget, rayColor, 3.0f);
+       // [최적화] 가장 가까운 히트 직접 찾기 (O(N), 정렬 비용 제거)
+       int closestIndex = -1;
+       float closestDistance = float.MaxValue;
+
+       for (int i = 0; i < hitCount; i++)
+       {
+           var hit = _raycastHits[i];
+           if (hit.collider == null) continue;
+
+           if (hit.distance < closestDistance)
+           {
+               closestDistance = hit.distance;
+               closestIndex = i;
+           }
+       }
+
+       // 유효한 히트가 없으면 종료
+       if (closestIndex == -1)
+       {
+           Debug.DrawRay(origin, directionToTarget * distanceToTarget, Color.red, 3.0f);
+           return;
+       }
+
+       var closestHit = _raycastHits[closestIndex];
+       bool hitPlayerFirst = closestHit.transform.CompareTag(PLAYER_TAG);
+       Color rayColor = hitPlayerFirst ? Color.green : Color.red;
+       Debug.DrawRay(origin, directionToTarget * distanceToTarget, rayColor, 3.0f);

-       if (hits[0].transform.CompareTag(PLAYER_TAG))
+       if (hitPlayerFirst)
        {
            if (_currentPlayable.Director != null)
            {
                RemoveBindingsFromTimeline(_currentPlayable.Director, gameObject);
            }
            SetMode(Mode.Detect, "PlayerDetected");
            return;
        }

 

  1. RaycastAll은 호출 될 때마다 매번 새로운 RaycastHit[] 배열을 힙 메모리에 생성한다. GC 문제가 발생한다. 따라서 미리 생성한 배열에 결과를 한 번만 할당하는 것으로 수정하기 위해, 버퍼를 생성하고, RaycastAll 대신 RaycastNonAlloc을 써준다.
  2. Array.sort은 전체 히트 결과를 거리 순으로 정렬하여 O(NlogN)의 비용이 든다. 하지만 가장 가까운 것 하나만 알고 싶은데 전체 줄을 세우는 것은 낭비다. 따라서 for문을 사용하여 closestDistance보다 작은 값이 나오면 갱신하는 방식으로 사용하면, O(N)의 비용이 든다. 미미하지만 CPU 부하를 줄여준다.
  3. hits[0]에 바로 접근하는 것보다는 hitCount ≤ 0 체크를 통해 충돌체가 없는 경우를 명확하게 방어하는 것이 훨씬 안전한 코드다.

* RaycastNonAlloc 인자 참고:

  • origin (Vector3): Ray가 발사되는 시작 위치
  • directionToTarget (Vector3): Ray가 뻗어 나갈 방향
  • _raycastHits (RaycastHit[]): 충돌한 결과물들의 정보를 담을 결과 버퍼 배열
  • distanceToTarget (float): Ray를 검사할 최대 거리

 

이렇게 Maggi 내의 Major한 문제를 모두 해결하였다.

문제는 이렇게 모두 최적화를 해주었는데도 계속 문제가 남아있다는 메세지가 남아 있는 경우가 있었는데, Project Auditor는 최적화에 문제가 될 여지가 있는 인자를 사용하기만 해도 해당 문제가 해결되지 않았다고 판단하기 때문이다.

당연히 Project Auditor는 신이 아니기 때문에, 개발자가 능력껏 최적화를 한 뒤 알아서 걸러서 볼 필요가 있다.