공부/Unity

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

BeepMaeae 2025. 11. 21. 04:47

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

 

짧은 잡담

지스타를 가서 나의 우상 나의 영웅 나의 왕 골드메탈 님을 만났다.

유니티 관련 질문으로 ‘최근에 최적화 작업을 하고 있는데 신경 쓸 부분이 없겠느냐’라고 여쭤봤는데 Project Auditor 패키지를 사용하면 코드, 에셋, 셰이더 등등 모든 부분에서 최적화 해야 할 부분을 알려준다고 하며 사용법을 정확하게 설명해주셨다.

추가로, 현재는 Profiler로 하고 있다고 말씀드리니까 ‘그 방법은 실제로 튀어야 찾을 수 있기 때문에 힘들 거다’라고 하셨다. (하지 말라는 말은 아니긴 하지만 확실히 모든 씬 다 해봐야 되니까 잠재적인 위협이나 우연히 잡히지 않은 부분을 캐치할 수 없을 것 같기는 함)

정말 감동의 도가니였습니다 …

 

Proejct Auditor

 

Unity 프로젝트의 설정, 에셋, 코드 등을 정적 분석하여 잠재적인 문제점이나 최적화가 필요한 부분을 찾아내는 유니티 공식 패키지다.

프로젝트가 커지고 복잡해질수록 개발자가 모든 설정을 일일이 확인하거나 비효율적인 에셋 사용을 추적하기 어려워지는데, 프로젝트 오디터는 이 과정을 자동화하여 리포트로 보여준다.

 

 

주요 기능

  • 에셋 분석
    • 미사용 에셋: 프로젝트에는 포함되어 있지만, 어떤 씬이나 프리팹에서도 참조되지 않는 에셋(스크립트, 텍스처, 머티리얼 등)을 찾아낸다.
    • 에셋 설정 오류: 텍스처의 압축 설정이 잘못되었거나, 오디오 임포트 설정이 비효율적인 경우 등을 검사한다.
  • 프로젝트 설정 분석:
    • 피직스 설정: 불필요하게 정밀한 피직스 설정을 확인한다.
    • 그래픽스/퀄리티 설정: 쉐도우 품질, 픽셀 라이트 개수 등 성능에 영향을 미치는 설정들을 검토하고 권장 사항을 제시한다
    •  
  • 스크립트 분석 (중요):
    • 비효율적인 API 사용: Update() 함수 내에서 GetComponent()나 GameObject.Find()처럼 성능 부하가 큰 API를 호출하는 경우를 찾아낸다.
    • 빈 콜백 함수: 내용이 비어있는 Update()나 LateUpdate() 함수(불필요한 오버헤드 유발)를 감지한다.
  • 리포트 및 대시보드:
    • 분석 결과를 심각도(Major, Moderate, Minor, Ignored)별로 분류하여 대시보드 형태로 보여준다.
    • 각 항목을 클릭하면 어떤 에셋이나 설정이 문제인지 상세 내용을 확인할 수 있으며, ‘Fix' 버튼으로 일부 문제는 즉시 해결할 수 있다.

현재 Code에서 가장 위협적인 Major 문제를 몇 가지 살펴보았다.

 

CurvedWorldController.cs - GenerateShaderPropertyIDs

//변경 전
void GenerateShaderPropertyIDs()
{
    materialPropertyID_PivotPoint = Shader.PropertyToID(string.Format("CurvedWorld_{0}_ID{1}_PivotPoint", bendType, bendID));
    materialPropertyID_RotationCenter = Shader.PropertyToID(string.Format("CurvedWorld_{0}_ID{1}_RotationCenter", bendType, bendID));
    materialPropertyID_RotationCenter2 = Shader.PropertyToID(string.Format("CurvedWorld_{0}_ID{1}_RotationCenter2", bendType, bendID));
    materialPropertyID_RotationAxis = Shader.PropertyToID(string.Format("CurvedWorld_{0}_ID{1}_RotationAxis", bendType, bendID));
    materialPropertyID_BendSize = Shader.PropertyToID(string.Format("CurvedWorld_{0}_ID{1}_BendSize", bendType, bendID));
    materialPropertyID_BendOffset = Shader.PropertyToID(string.Format("CurvedWorld_{0}_ID{1}_BendOffset", bendType, bendID));
    materialPropertyID_BendAngle = Shader.PropertyToID(string.Format("CurvedWorld_{0}_ID{1}_BendAngle", bendType, bendID));
    materialPropertyID_BendMinimumRadius = Shader.PropertyToID(string.Format("CurvedWorld_{0}_ID{1}_BendMinimumRadius", bendType, bendID));
    materialPropertyID_BendRolloff = Shader.PropertyToID(string.Format("CurvedWorld_{0}_ID{1}_BendRolloff", bendType, bendID));
}

//변경 후
void GenerateShaderPropertyIDs()
{
    // value type → object 로 가는 string.Format 대신, string + string으로 boxing 회피
    string bendTypeName = bendType.ToString();
    string bendIdStr    = bendID.ToString();

    string prefix = "CurvedWorld_" + bendTypeName + "_ID" + bendIdStr + "_";

    materialPropertyID_PivotPoint        = Shader.PropertyToID(prefix + "PivotPoint");
    materialPropertyID_RotationCenter    = Shader.PropertyToID(prefix + "RotationCenter");
    materialPropertyID_RotationCenter2   = Shader.PropertyToID(prefix + "RotationCenter2");
    materialPropertyID_RotationAxis      = Shader.PropertyToID(prefix + "RotationAxis");
    materialPropertyID_BendSize          = Shader.PropertyToID(prefix + "BendSize");
    materialPropertyID_BendOffset        = Shader.PropertyToID(prefix + "BendOffset");
    materialPropertyID_BendAngle         = Shader.PropertyToID(prefix + "BendAngle");
    materialPropertyID_BendMinimumRadius = Shader.PropertyToID(prefix + "BendMinimumRadius");
    materialPropertyID_BendRolloff       = Shader.PropertyToID(prefix + "BendRolloff");
}

TileSpawner.cs - GenerateShaderPropertyIDs

//변경 전
private void PlaceTile(GameObject tile, Vector3 position)
{
    if (!tile) return;
    tile.transform.SetPositionAndRotation(position, Quaternion.identity);
    tile.name = $"{tile.name}__Active{_active.Count:D2}";
}

//변경 후
private void PlaceTile(GameObject tile, Vector3 position)
{
    if (!tile) return;
    tile.transform.SetPositionAndRotation(position, Quaternion.identity);
    tile.name = tile.name + "__Active" + _active.Count.ToString("D2");
}

 

string.format 함수를 쓰면 다음 boxing 문제가 발생한다.

  • bendType (enum) → object로 변하면서 boxing
  • bendID (int) → object로 변하면서 boxing

따라서 각각 값 타입의 인스턴스 메서드 호출을 해두면 boxing이 일어나지 않는다.

System.Linq

PlateRotator.cs - CanEneter, GetExits

using System;
- using System.Linq;
using UnityEngine;
using UnityEngine.Serialization;

.
.
.

    /// <summary>
    /// entryDir(월드 방향 0=Up,1=Right,2=Down,3=Left)으로 들어온 전류가
    /// 이 판의 어떤 가닥(DirPair)에 진입할 수 있는지 검사
    /// </summary>
    public bool CanEnter(int entryDir)
    {
        int raw = (entryDir - currentState + 4) % 4;
        
+       for (int i = 0; i < connections.Length; i++)
+		    {
+		        var p = connections[i];
+		        int a = (int)p.a;
+		        int b = (int)p.b;
+		
+		        if (a == raw || b == raw)
+		            return true;
+		    }
+
+		    return false;
-       return connections.Any(p => (int)p.a == raw || (int)p.b == raw);
    }

    /// <summary>
    /// entryDir(월드 방향)으로 들어온 전류가 나가야 할 exitDir(월드 방향) 배열을 반환
    /// - 아크/직선은 하나, T자/十자 등 분기형은 2개 이상
    /// </summary>
    public int[] GetExits(int entryDir)
    {
		    int rawEntry = (entryDir - currentState + 4) % 4;
		    
-        var rawExits = connections
-		      .Where(p => (int)p.a == rawEntry || (int)p.b == rawEntry)
-		      .SelectMany(p => new[] {
-		          ((int)p.a == rawEntry ? (int)p.b : (int)p.a),
-		      })
-		      .Distinct();
		
+		    int[] tempRawExits = new int[4];
+		    int rawExitCount = 0;
		
+		    // 1) rawEntry와 연결된 모든 가닥 검사
+		    for (int i = 0; i < connections.Length; i++)
+		    {
+		        var p = connections[i];
+		        int a = (int)p.a;
+		        int b = (int)p.b;
		
+		        if (a == rawEntry || b == rawEntry)
+		        {
+		            // 이 가닥에서 opposite end
+		            int rawExit = (a == rawEntry) ? b : a;
		
+		            // 2) Distinct 기능: 이미 추가된 값인지 검사
+		            bool exists = false;
+		            for (int j = 0; j < rawExitCount; j++)
+		            {
+		                if (tempRawExits[j] == rawExit)
+		                {
+		                    exists = true;
+		                    break;
+		                }
+		            }
		
+		            if (!exists)
+		            {
+		                tempRawExits[rawExitCount] = rawExit;
+		                rawExitCount++;
+		            }
+		        }
+		    }
		
+		    // 3) rawExits(raw) → worldDir로 보정해서 리턴 배열 구성
+		    int[] exits = new int[rawExitCount];
+		    for (int i = 0; i < rawExitCount; i++)
+		    {
+		        exits[i] = (tempRawExits[i] + currentState) % 4;
+		    }

-		    return rawExits
-		      .Select(raw => (raw + currentState) % 4)
-		      .ToArray();
+		    return exits;
}

PuzzleManager.cs - Update

/*
 * PuzzleManager.cs
 * 퍼즐 전체 로직을 관리하며, 연결 판정 및 이벤트 트리거 처리
 */
using UnityEngine;
- using System.Linq;

.
.
.

private void Update()
{
    
.
.
.

// 5) 퍼즐 완료 및 역연결 조건 계산
bool onLit        = !useMinus && onConn[onEndPos.x, onEndPos.y, (int)onEndDir];
 // Plus→Plus, Minus→Minus (전체 연결)
- bool plusLitAll   = usePlus  && plusEndPositions .Select((p,i)=> pConn[p.x,p.y,(int)plusEndDirections[i]]).All(b=>b);
+ bool plusLitAll = false;
+ if (usePlus)
+ {
+     plusLitAll = true;
+     int len = Mathf.Min(plusEndPositions.Length, plusEndDirections.Length);
+     for (int i = 0; i < len; i++)
+     {
+         Vector2Int p = plusEndPositions[i];
+         int dirIndex = (int)plusEndDirections[i];

+         if (!pConn[p.x, p.y, dirIndex])
+         {
+             plusLitAll = false;
+             break;
+         }
+     }
+ }
- bool minusLitAll  = useMinus && minusEndPositions.Select((p,i)=> mConn[p.x,p.y,(int)minusEndDirections[i]]).All(b=>b);
+ bool minusLitAll = false;
+ if (useMinus)
+ {
+     minusLitAll = true;
+     int len = Mathf.Min(minusEndPositions.Length, minusEndDirections.Length);
+     for (int i = 0; i < len; i++)
+     {
+         Vector2Int p = minusEndPositions[i];
+         int dirIndex = (int)minusEndDirections[i];

+         if (!mConn[p.x, p.y, dirIndex])
+         {
+             minusLitAll = false;
+             break;
+         }
+     }
+ }
 // Plus→Minus, Minus→Plus (역연결: 부분 연결만 있으면 OK)
- bool plusToMinusAny = usePlus  && minusEndPositions.Select((p,i)=> pConn[p.x,p.y,(int)minusEndDirections[i]]).Any(b=>b);
+ _bool plusToMinusAny = false;
+ if (usePlus)
+ {
+     int len = Mathf.Min(minusEndPositions.Length, minusEndDirections.Length);
+     for (int i = 0; i < len; i++)
+     {
+         Vector2Int p = minusEndPositions[i];
+         int dirIndex = (int)minusEndDirections[i];

+         if (pConn[p.x, p.y, dirIndex])
+         {
+             plusToMinusAny = true;
+             break;
+         }
+     }
+ }
- bool minusToPlusAny = useMinus && plusEndPositions .Select((p,i)=> mConn[p.x,p.y,(int)plusEndDirections[i]]).Any(b=>b);
+ bool minusToPlusAny = false;
+ if (useMinus)
+ {
+     int len = Mathf.Min(plusEndPositions.Length, plusEndDirections.Length);
+     for (int i = 0; i < len; i++)
+     {
+         Vector2Int p = plusEndPositions[i];
+         int dirIndex = (int)plusEndDirections[i];

+         if (mConn[p.x, p.y, dirIndex])
+         {
+             minusToPlusAny = true;
+             break;
+         }
+     }
+ }

.
.
.
}

 

Linq가 Update에 사용되면 안 좋은 경우가 많다.

 

능 및 메모리 문제

  • 보이지 않는 메모리 할당: LINQ의 많은 메서드는 내부적으로 새로운 컬렉션이나 Iterator 객체를 생성함.
  • GC 압박: 이렇게 생성된 임시 객체들은 사용 후 가비지가 되며, 가비지 컬렉터가 이를 수거해야 한다.

LINQ 사용을 피해야 할 때

  1. 매우 빈번하게 호출되는 루프: 유니티의 Update(), FixedUpdate() 등 매 프레임 실행되는 성능 핵심 경로
  2. 단순히 인덱스가 필요한 경우

GetComponentsInChildren<UnityEngine.Render>

DisableCurvedWorld.cs

+ using System.Collections.Generic;
using UnityEngine;

namespace AmazingAssets.CurvedWorld.Examples
{
    public class DisableCurvedWorld : MonoBehaviour
    {
        .
        .
        .
        // GetComponentsInChildren 결과를 재사용할 버퍼
+        private static readonly List<Renderer> s_RendererBuffer = new List<Renderer>();

        void ToWorldSpace()
        {
            //Disable Curved World vertex transformation by enabling "CURVEDWORLD_DISABLED_ON" keyword.
            //Note, if shader is constructed using shader graph tools, "CURVEDWORLD_DISABLED_ON" keyword has to be implemented there manually, or material shader can be replaced with 'non Curved World' shader.

-            Renderer[] renderers = GetComponentsInChildren<Renderer>();
-            for (int r = 0; r < renderers.Length; r++)
-            {
-                if (renderers[r] == null || renderers[r].sharedMaterials == null)
-                    continue;

-                for (int m = 0; m < renderers[r].sharedMaterials.Length; m++)
-                {
-                    if (renderers[r].materials[m] != null)
-                        renderers[r].materials[m].EnableKeyword("CURVEDWORLD_DISABLED_ON");
-                }
-            }
            
+            s_RendererBuffer.Clear();
+            gameObject.GetComponentsInChildren(true, s_RendererBuffer);

+            for (int r = 0; r < s_RendererBuffer.Count; r++)
+            {
+                var renderer = s_RendererBuffer[r];
+                if (renderer == null)
+                    continue;

+                // renderer.materials를 한 번만 가져와서 사용
+                var mats = renderer.materials;
+                if (mats == null)
+                    continue;

+                for (int m = 0; m < mats.Length; m++)
+                {
+                    var mat = mats[m];
+                    if (mat != null)
+                        mat.EnableKeyword("CURVEDWORLD_DISABLED_ON");
+                }
+            }


            //Get Curved World equivalent position, but in World Space
            Vector3 realPosition = curvedWorldController.TransformPosition(transform.position);

            //And rotation too.
            Quaternion realRotation = curvedWorldController.TransformRotation(transform.position, transform.forward, transform.right);


            //Update object transformation
            transform.position = realPosition;
            transform.rotation = realRotation;


            //Dissable collider to avoid confusion with 'Curved World' physics
            Collider collider = GetComponent<Collider>();
            if (collider != null)
                collider.enabled = false;


            //This script can be disabled
            this.enabled = false;
        }
    }
}

저번에 말했던 new를 쓰는 문제와 비슷한 문제다.

GetComponentsInChildren<Renderer>는 호출될 때마다 항상 새로운 Renderer[] 배열을 생성하여 반환한다. 이는 가비지의 원인이 된다.

renderer.materials 속성에 접근할 때마다 유니티는 해당 렌더러가 가진 머터리얼의 복사본을 담은 새로운 배열을 만든다. 그런데 기존 코드는 for 루프 안에서 해당 속성을 두 번이나 접근했다.

이를 해결하기 위해 static List를 통해 재사용 가능한 리스트 하나만 만들어주고, 함수가 시작 될 때마다 Clear를 통해 이 리스트를 지운다. 이 방식은 새로운 배열은 만들지 않고 미리 만들어둔 s_RendererBuffer 리스트에 결과를 채워 넣는다.

추가로 render.matrerials 속성의 결과를 캐싱했다. 렌더러 1개당 발생하던 여러 번의 배열 할당이 단 1번으로 줄었다.