Unity 최적화

REIN·2025년 10월 3일

Unity 관련 정보들

목록 보기
3/3

60fps를 사수하는 Unity 성능 최적화 실전 가이드

목차

  1. Unity 라이프사이클
  2. 가비지 컬렉션 이해와 최적화
  3. 코루틴 vs async/await
  4. 오브젝트 풀링
  5. 렌더링 최적화
  6. 물리 최적화
  7. 메모리 최적화
  8. Job System과 Burst
  9. 프로파일링
  10. 모바일 최적화

들어가며

"게임이 PC에서는 잘 돌아가는데, 모바일에서는 10fps밖에 안 나와요."

Unity는 쉽게 시작할 수 있지만, 최적화는 어렵습니다. 특히 60fps를 유지하려면 16.6ms 안에 모든 것을 처리해야 합니다. CPU 로직, GPU 렌더링, 물리 시뮬레이션, 가비지 컬렉션까지.

이 글은 Unity의 내부 동작 방식부터 최신 DOTS(Data-Oriented Technology Stack)까지, 실전에서 바로 써먹을 수 있는 최적화 기법을 다룹니다.


Unity 라이프사이클

실행 순서

프레임 시작
│
├─ Awake()                  ← 모든 GameObject (순서 불확정)
├─ OnEnable()
├─ Start()                  ← 첫 Update 전 1회
│
├─ FixedUpdate()            ← 고정 시간 간격 (0.02초)
│  └─ 물리 시뮬레이션
│     └─ OnCollisionXXX()
│     └─ OnTriggerXXX()
│
├─ Update()                 ← 매 프레임
│  └─ 입력 처리
│  └─ 게임 로직
│
├─ LateUpdate()             ← 모든 Update 후
│  └─ 카메라 추적
│
├─ OnPreRender()            ← 렌더링 직전 (카메라별)
├─ OnRenderObject()
├─ OnPostRender()
│
├─ OnGUI()                  ← 레거시 GUI (매우 느림!)
│
└─ OnApplicationQuit()
   └─ OnDisable()
   └─ OnDestroy()

Update vs FixedUpdate vs LateUpdate

public class LifecycleExample : MonoBehaviour
{
    // Update: 매 프레임 (가변 주기)
    void Update()
    {
        // 프레임마다 다른 deltaTime
        // 60fps: 0.0166초
        // 30fps: 0.0333초
        
        // 용도:
        // - 입력 처리
        // - 카메라 컨트롤
        // - UI 업데이트
        // - 일반 게임 로직
        
        if (Input.GetKeyDown(KeyCode.Space))
        {
            Jump();
        }
        
        transform.position += velocity * Time.deltaTime;
    }
    
    // FixedUpdate: 고정 시간 간격 (기본 0.02초 = 50Hz)
    void FixedUpdate()
    {
        // 항상 같은 Time.fixedDeltaTime (0.02초)
        
        // 용도:
        // - 물리 계산
        // - Rigidbody 조작
        
        // ❌ Update에서 물리
        // rigidbody.AddForce(Vector3.forward);  // 프레임마다 다른 힘!
        
        // ✅ FixedUpdate에서 물리
        rigidbody.AddForce(Vector3.forward);  // 항상 일정한 힘
    }
    
    // LateUpdate: 모든 Update 후
    void LateUpdate()
    {
        // 용도:
        // - 카메라 추적 (플레이어가 먼저 움직인 후)
        // - 타겟 따라가기
        // - Procedural Animation
        
        // 플레이어 위치는 Update에서 이미 변경됨
        Vector3 desiredPosition = player.position + offset;
        transform.position = Vector3.Lerp(
            transform.position,
            desiredPosition,
            Time.deltaTime * smoothSpeed
        );
    }
}

프레임 타임라인:

시간     0ms      16ms     32ms     48ms     64ms
프레임   ├────────┼────────┼────────┼────────┼────
         Frame 1  Frame 2  Frame 3  Frame 4

Frame 1 (16ms):
├─ FixedUpdate (20ms 경과했으면)
├─ FixedUpdate (40ms 경과했으면)
├─ Update
└─ LateUpdate

프레임 드롭 시 (50ms):
├─ FixedUpdate (20ms)
├─ FixedUpdate (40ms)
├─ FixedUpdate (60ms)  ← 3번 호출!
├─ Update
└─ LateUpdate

실행 순서 제어

// Script Execution Order 설정
// Edit → Project Settings → Script Execution Order

// 또는 속성으로
[DefaultExecutionOrder(-100)]  // 다른 스크립트보다 먼저
public class InputManager : MonoBehaviour
{
    void Update()
    {
        // 입력 처리 (다른 스크립트보다 먼저)
    }
}

[DefaultExecutionOrder(100)]  // 다른 스크립트보다 나중에
public class CameraController : MonoBehaviour
{
    void LateUpdate()
    {
        // 카메라 업데이트 (플레이어 움직임 후)
    }
}

최적화 팁

public class UpdateOptimization : MonoBehaviour
{
    // ❌ 빈 Update는 오버헤드
    void Update()
    {
        // 아무것도 안 함
    }
    // → 삭제하세요!
    
    // ❌ 매 프레임 무거운 연산
    void Update()
    {
        GameObject enemy = GameObject.Find("Enemy");  // 매우 느림!
        Transform target = GameObject.FindWithTag("Player").transform;  // 느림!
    }
    
    // ✅ 캐싱
    private GameObject enemy;
    private Transform target;
    
    void Start()
    {
        enemy = GameObject.Find("Enemy");  // 1회만
        target = GameObject.FindWithTag("Player").transform;
    }
    
    void Update()
    {
        // 캐시된 참조 사용
    }
    
    // ✅ 필요할 때만 실행
    private float updateInterval = 0.1f;
    private float lastUpdate = 0f;
    
    void Update()
    {
        if (Time.time - lastUpdate < updateInterval)
            return;
        
        lastUpdate = Time.time;
        
        // 0.1초마다만 실행
        ExpensiveOperation();
    }
}

가비지 컬렉션 이해와 최적화

GC가 뭔가요?

Unity는 C#의 Garbage Collector를 사용합니다. 힙에 할당된 메모리 중 더 이상 참조되지 않는 것을 자동으로 해제합니다.

문제는 타이밍입니다:

GC 실행 중에는 게임이 멈춥니다!

60fps (16.6ms per frame):
├─ 게임 로직: 5ms
├─ 렌더링: 8ms
├─ GC: 0ms        ← 정상
└─ 여유: 3.6ms

GC 발생:
├─ 게임 로직: 5ms
├─ 렌더링: 8ms
├─ GC: 50ms       ← 💥 프레임 드롭!
└─ 총: 63ms → 15fps로 추락

GC 할당 원인

public class GCAllocations : MonoBehaviour
{
    // ❌ 매 프레임 할당
    void Update()
    {
        string text = "Score: " + score;  // 문자열 연결 → 새 문자열 생성
        
        Vector3[] positions = new Vector3[100];  // 배열 생성
        
        var result = GetEnemies();  // List 생성
    }
    
    List<Enemy> GetEnemies()
    {
        return new List<Enemy>();  // 매번 새 List
    }
    
    // ❌ 박싱 (Boxing)
    void BadBoxing()
    {
        int value = 42;
        object obj = value;  // int → object (힙 할당!)
        
        // 특히 조심: foreach + Dictionary/Hashtable
        foreach (var key in dictionary.Keys)  // IEnumerator 박싱!
        {
            // ...
        }
    }
    
    // ❌ 람다 캡처
    void BadLambda()
    {
        int localVar = 10;
        
        button.onClick.AddListener(() => {
            Debug.Log(localVar);  // 캡처 → 클로저 생성 (힙 할당)
        });
    }
}

GC 최적화

public class GCOptimizations : MonoBehaviour
{
    // ✅ StringBuilder (문자열 연결)
    private StringBuilder sb = new StringBuilder(100);
    
    void UpdateScore(int score)
    {
        sb.Clear();
        sb.Append("Score: ");
        sb.Append(score);
        scoreText.text = sb.ToString();  // 1번만 할당
    }
    
    // ✅ 오브젝트 풀
    private List<Enemy> enemyPool = new List<Enemy>(100);
    private List<Enemy> activeEnemies = new List<Enemy>(100);
    
    List<Enemy> GetEnemies()
    {
        activeEnemies.Clear();  // 재사용
        // enemyPool에서 가져오기
        return activeEnemies;
    }
    
    // ✅ 구조체 (Stack)
    struct EnemyData  // class 대신 struct
    {
        public Vector3 position;
        public int health;
    }
    
    // ✅ 배열 재사용
    private Vector3[] positionsCache = new Vector3[100];
    
    void UpdatePositions()
    {
        // positionsCache 재사용 (할당 없음)
        for (int i = 0; i < enemies.Count; i++)
        {
            positionsCache[i] = enemies[i].position;
        }
    }
    
    // ✅ 박싱 방지
    void NoBo xing()
    {
        // List<T>.Enumerator는 struct (박싱 없음)
        foreach (var enemy in enemies)
        {
            // ...
        }
        
        // Dictionary도 마찬가지
        foreach (var kvp in dictionary)
        {
            // ...
        }
    }
    
    // ✅ 람다 캡처 방지
    void NoCapture()
    {
        // 지역 변수 캡처 안 함
        button.onClick.AddListener(OnButtonClick);
    }
    
    void OnButtonClick()
    {
        // 멤버 함수 사용
    }
}

Incremental GC

Unity 2019+는 Incremental GC를 지원합니다.

// Incremental GC:
// - GC를 여러 프레임에 나눠서 실행
// - 한 프레임에 최대 2ms만 사용
// - 프레임 드롭 완화

// 설정
// Player Settings → Other Settings → Garbage Collector
// → Incremental

// 모니터링
void Update()
{
    // GC 메모리
    long totalMemory = GC.GetTotalMemory(false);
    
    // GC 횟수
    int gen0Collections = GC.CollectionCount(0);
    int gen1Collections = GC.CollectionCount(1);
    int gen2Collections = GC.CollectionCount(2);
    
    Debug.Log($"GC Gen0: {gen0Collections}, Gen1: {gen1Collections}, Gen2: {gen2Collections}");
}

GC 세대:

Gen 0 (신생):
- 새로 할당된 객체
- 자주 수집 (빠름)
- 대부분 여기서 해제

Gen 1 (중간):
- Gen 0에서 살아남은 객체
- 가끔 수집

Gen 2 (노년):
- 오래 살아남은 객체
- 드물게 수집 (느림)
- 여기 도달하면 오래 남음

코루틴 vs async/await

코루틴

public class CoroutineExample : MonoBehaviour
{
    // 기본 사용
    void Start()
    {
        StartCoroutine(FadeOut());
    }
    
    IEnumerator FadeOut()
    {
        float alpha = 1f;
        
        while (alpha > 0)
        {
            alpha -= Time.deltaTime;
            canvasGroup.alpha = alpha;
            
            yield return null;  // 다음 프레임까지 대기
        }
        
        Destroy(gameObject);
    }
    
    // 시간 대기
    IEnumerator DelayedAction()
    {
        Debug.Log("Start");
        
        yield return new WaitForSeconds(2f);  // 2초 대기
        
        Debug.Log("After 2 seconds");
    }
    
    // 조건 대기
    IEnumerator WaitForCondition()
    {
        yield return new WaitUntil(() => player.health <= 0);
        
        ShowGameOverScreen();
    }
    
    // WWW/UnityWebRequest
    IEnumerator DownloadTexture(string url)
    {
        UnityWebRequest www = UnityWebRequestTexture.GetTexture(url);
        
        yield return www.SendWebRequest();
        
        if (www.result == UnityWebRequest.Result.Success)
        {
            Texture2D texture = DownloadHandlerTexture.GetContent(www);
            renderer.material.mainTexture = texture;
        }
    }
    
    // 중첩 코루틴
    IEnumerator Sequence()
    {
        yield return StartCoroutine(Step1());
        yield return StartCoroutine(Step2());
        yield return StartCoroutine(Step3());
    }
    
    // 코루틴 정지
    void StopFading()
    {
        StopCoroutine(FadeOut());
        // 또는
        StopAllCoroutines();
    }
}

코루틴의 특징:

장점:
+ Unity API 자유롭게 사용
+ 간단한 지연/애니메이션에 적합
+ GC 압박 적음 (yield return null 재사용)

단점:
- 타입 안전성 없음 (IEnumerator)
- 반환값 처리 어려움
- 예외 처리 불편
- GameObject 파괴되면 자동 중단

async/await

using System.Threading.Tasks;
using UnityEngine;

public class AsyncExample : MonoBehaviour
{
    // 기본 사용
    async void Start()
    {
        await FadeOutAsync();
    }
    
    async Task FadeOutAsync()
    {
        float alpha = 1f;
        
        while (alpha > 0)
        {
            alpha -= Time.deltaTime;
            canvasGroup.alpha = alpha;
            
            await Task.Yield();  // 다음 프레임
        }
        
        Destroy(gameObject);
    }
    
    // 시간 대기
    async Task DelayedActionAsync()
    {
        Debug.Log("Start");
        
        await Task.Delay(2000);  // 2000ms 대기
        
        Debug.Log("After 2 seconds");
    }
    
    // 반환값
    async Task<int> CalculateScoreAsync()
    {
        await Task.Delay(1000);
        
        return 100;
    }
    
    async void UseScore()
    {
        int score = await CalculateScoreAsync();
        Debug.Log($"Score: {score}");
    }
    
    // 병렬 실행
    async Task ParallelLoadingAsync()
    {
        Task loadMesh = LoadMeshAsync();
        Task loadTexture = LoadTextureAsync();
        Task loadAudio = LoadAudioAsync();
        
        // 모두 완료될 때까지 대기
        await Task.WhenAll(loadMesh, loadTexture, loadAudio);
        
        Debug.Log("All loaded!");
    }
    
    // 예외 처리
    async Task SafeLoadAsync()
    {
        try
        {
            await LoadDataAsync();
        }
        catch (Exception e)
        {
            Debug.LogError($"Load failed: {e.Message}");
        }
    }
    
    // 취소 토큰
    private CancellationTokenSource cts;
    
    async void StartLongOperation()
    {
        cts = new CancellationTokenSource();
        
        try
        {
            await LongOperationAsync(cts.Token);
        }
        catch (OperationCanceledException)
        {
            Debug.Log("Operation cancelled");
        }
    }
    
    async Task LongOperationAsync(CancellationToken ct)
    {
        for (int i = 0; i < 1000; i++)
        {
            ct.ThrowIfCancellationRequested();
            
            await Task.Delay(10, ct);
        }
    }
    
    void OnDestroy()
    {
        cts?.Cancel();  // 취소
    }
}

Unity 2023+ Awaitable:

// Unity의 공식 async 지원
async Awaitable FadeOutUnity()
{
    float alpha = 1f;
    
    while (alpha > 0)
    {
        alpha -= Time.deltaTime;
        canvasGroup.alpha = alpha;
        
        await Awaitable.NextFrameAsync();  // Unity API 안전!
    }
}

// 취소 자동 처리
async Awaitable LoadSceneAsync()
{
    // GameObject 파괴되면 자동 취소
    await Awaitable.WaitForSecondsAsync(2f);
    
    SceneManager.LoadScene("NextScene");
}

선택 가이드

코루틴 사용:
✓ 간단한 지연/대기
✓ 애니메이션/Fade
✓ Unity API 많이 사용
✓ GameObject 수명과 함께

async/await 사용:
✓ 복잡한 비동기 로직
✓ 반환값 필요
✓ 예외 처리 필요
✓ 병렬 실행
✓ I/O 작업 (네트워크, 파일)

오브젝트 풀링

기본 오브젝트 풀

public class ObjectPool : MonoBehaviour
{
    [SerializeField] private GameObject prefab;
    [SerializeField] private int initialSize = 10;
    
    private Queue<GameObject> pool = new Queue<GameObject>();
    
    void Start()
    {
        // 미리 생성
        for (int i = 0; i < initialSize; i++)
        {
            GameObject obj = Instantiate(prefab);
            obj.SetActive(false);
            pool.Enqueue(obj);
        }
    }
    
    public GameObject Get()
    {
        if (pool.Count > 0)
        {
            GameObject obj = pool.Dequeue();
            obj.SetActive(true);
            return obj;
        }
        else
        {
            // 풀이 비었으면 새로 생성
            return Instantiate(prefab);
        }
    }
    
    public void Return(GameObject obj)
    {
        obj.SetActive(false);
        pool.Enqueue(obj);
    }
}

// 사용
public class BulletSpawner : MonoBehaviour
{
    private ObjectPool bulletPool;
    
    void Fire()
    {
        GameObject bullet = bulletPool.Get();
        bullet.transform.position = firePoint.position;
        bullet.transform.rotation = firePoint.rotation;
        
        // 3초 후 반환
        StartCoroutine(ReturnAfterDelay(bullet, 3f));
    }
    
    IEnumerator ReturnAfterDelay(GameObject obj, float delay)
    {
        yield return new WaitForSeconds(delay);
        bulletPool.Return(obj);
    }
}

제네릭 오브젝트 풀

public class GenericObjectPool<T> where T : Component
{
    private T prefab;
    private Queue<T> pool = new Queue<T>();
    private Transform parent;
    
    public GenericObjectPool(T prefab, int initialSize, Transform parent = null)
    {
        this.prefab = prefab;
        this.parent = parent;
        
        for (int i = 0; i < initialSize; i++)
        {
            T obj = Object.Instantiate(prefab, parent);
            obj.gameObject.SetActive(false);
            pool.Enqueue(obj);
        }
    }
    
    public T Get()
    {
        T obj;
        
        if (pool.Count > 0)
        {
            obj = pool.Dequeue();
        }
        else
        {
            obj = Object.Instantiate(prefab, parent);
        }
        
        obj.gameObject.SetActive(true);
        return obj;
    }
    
    public void Return(T obj)
    {
        obj.gameObject.SetActive(false);
        pool.Enqueue(obj);
    }
    
    public void Clear()
    {
        foreach (var obj in pool)
        {
            Object.Destroy(obj.gameObject);
        }
        pool.Clear();
    }
}

// 사용
public class PoolManager : MonoBehaviour
{
    [SerializeField] private Bullet bulletPrefab;
    [SerializeField] private Enemy enemyPrefab;
    
    private GenericObjectPool<Bullet> bulletPool;
    private GenericObjectPool<Enemy> enemyPool;
    
    void Start()
    {
        bulletPool = new GenericObjectPool<Bullet>(bulletPrefab, 50);
        enemyPool = new GenericObjectPool<Enemy>(enemyPrefab, 20);
    }
    
    public Bullet GetBullet()
    {
        Bullet bullet = bulletPool.Get();
        bullet.OnSpawn();  // 초기화
        return bullet;
    }
    
    public void ReturnBullet(Bullet bullet)
    {
        bullet.OnDespawn();  // 정리
        bulletPool.Return(bullet);
    }
}

Unity 공식 ObjectPool (2021+)

using UnityEngine.Pool;

public class ModernPoolExample : MonoBehaviour
{
    [SerializeField] private GameObject prefab;
    
    private ObjectPool<GameObject> pool;
    
    void Start()
    {
        pool = new ObjectPool<GameObject>(
            createFunc: () => Instantiate(prefab),
            actionOnGet: (obj) => obj.SetActive(true),
            actionOnRelease: (obj) => obj.SetActive(false),
            actionOnDestroy: (obj) => Destroy(obj),
            collectionCheck: true,  // 이미 풀에 있는지 체크
            defaultCapacity: 10,
            maxSize: 100
        );
    }
    
    void Fire()
    {
        GameObject bullet = pool.Get();
        bullet.transform.position = firePoint.position;
        
        // 자동 반환
        StartCoroutine(AutoReturn(bullet, 3f));
    }
    
    IEnumerator AutoReturn(GameObject obj, float delay)
    {
        yield return new WaitForSeconds(delay);
        pool.Release(obj);
    }
    
    void OnDestroy()
    {
        pool.Dispose();  // 정리
    }
}

렌더링 최적화

Draw Call 줄이기

// ❌ 매 프레임 머티리얼 생성
void Update()
{
    Material mat = new Material(shader);  // 새 머티리얼
    renderer.material = mat;  // Draw Call 증가!
}

// ✅ 머티리얼 재사용
private Material sharedMat;

void Start()
{
    sharedMat = new Material(shader);
}

void Update()
{
    renderer.sharedMaterial = sharedMat;  // 같은 머티리얼 공유
}

// ✅ Static Batching
// 절대 움직이지 않는 오브젝트
void Start()
{
    gameObject.isStatic = true;  // Static으로 마크
    // 또는 Inspector에서 Static 체크
}

// ✅ GPU Instancing
// 같은 메시를 여러 개 그리기
// Shader에서 지원 필요:
// #pragma multi_compile_instancing

[SerializeField] private Mesh mesh;
[SerializeField] private Material material;

void Update()
{
    Matrix4x4[] matrices = new Matrix4x4[1000];
    
    for (int i = 0; i < 1000; i++)
    {
        Vector3 pos = new Vector3(i * 2, 0, 0);
        matrices[i] = Matrix4x4.TRS(pos, Quaternion.identity, Vector3.one);
    }
    
    Graphics.DrawMeshInstanced(mesh, 0, material, matrices);
    // 1000개를 1 Draw Call로!
}

Overdraw 줄이기

// Overdraw: 같은 픽셀을 여러 번 그리기

// ❌ 투명한 UI가 전체 화면 덮음
[SerializeField] private Image backgroundPanel;

void BadUI()
{
    // 알파 0.01이어도 전체 픽셀 그림!
    backgroundPanel.color = new Color(1, 1, 1, 0.01f);
}

// ✅ Raycast Target 끄기
void GoodUI()
{
    // 클릭 필요 없는 UI는 Raycast Target off
    backgroundPanel.raycastTarget = false;
    
    // 또는 아예 비활성화
    if (backgroundPanel.color.a < 0.01f)
    {
        backgroundPanel.enabled = false;
    }
}

// ✅ Occlusion Culling
// Window → Rendering → Occlusion Culling
// 1. Bake Occlusion Data
// 2. 카메라에 보이지 않는 것 자동 제거

LOD (Level of Detail)

public class LODManager : MonoBehaviour
{
    [SerializeField] private Mesh highDetailMesh;    // 10000 폴리곤
    [SerializeField] private Mesh mediumDetailMesh;  // 3000 폴리곤
    [SerializeField] private Mesh lowDetailMesh;     // 500 폴리곤
    
    private MeshFilter meshFilter;
    private Transform cameraTransform;
    
    void Start()
    {
        meshFilter = GetComponent<MeshFilter>();
        cameraTransform = Camera.main.transform;
    }
    
    void Update()
    {
        float distance = Vector3.Distance(transform.position, cameraTransform.position);
        
        if (distance < 10f)
        {
            meshFilter.mesh = highDetailMesh;
        }
        else if (distance < 50f)
        {
            meshFilter.mesh = mediumDetailMesh;
        }
        else
        {
            meshFilter.mesh = lowDetailMesh;
        }
    }
}

// ✅ Unity LOD Group (자동)
// 1. GameObject에 LOD Group 컴포넌트 추가
// 2. LOD 레벨별 메시 할당
// 3. 거리에 따라 자동 전환

Shader 최적화

// ❌ 복잡한 Fragment Shader
Shader "Custom/Expensive"
{
    SubShader
    {
        Pass
        {
            CGPROGRAM
            #pragma vertex vert
            #pragma fragment frag
            
            float4 frag(v2f i) : SV_Target
            {
                // 매 픽셀마다 복잡한 계산
                float3 normal = normalize(tex2D(_NormalMap, i.uv).rgb);
                float3 lightDir = normalize(_WorldSpaceLightPos0.xyz);
                float ndotl = dot(normal, lightDir);
                
                // 반사 계산
                float3 viewDir = normalize(_WorldSpaceCameraPos - i.worldPos);
                float3 reflectDir = reflect(-lightDir, normal);
                float spec = pow(max(dot(viewDir, reflectDir), 0.0), 32.0);
                
                // 프레넬
                float fresnel = pow(1.0 - dot(viewDir, normal), 5.0);
                
                return float4(ndotl + spec + fresnel, 1);
            }
            ENDCG
        }
    }
}

// ✅ Vertex Shader로 이동
Shader "Custom/Optimized"
{
    SubShader
    {
        Pass
        {
            CGPROGRAM
            #pragma vertex vert
            #pragma fragment frag
            
            struct v2f
            {
                float4 pos : SV_POSITION;
                float3 lighting : TEXCOORD0;  // 미리 계산
            };
            
            v2f vert(appdata v)
            {
                v2f o;
                o.pos = UnityObjectToClipPos(v.vertex);
                
                // Vertex Shader에서 계산 (버텍스 수 << 픽셀 수)
                float3 normal = UnityObjectToWorldNormal(v.normal);
                float3 lightDir = _WorldSpaceLightPos0.xyz;
                o.lighting = saturate(dot(normal, lightDir));
                
                return o;
            }
            
            float4 frag(v2f i) : SV_Target
            {
                // Fragment Shader는 단순하게
                return float4(i.lighting, 1);
            }
            ENDCG
        }
    }
}

물리 최적화

FixedUpdate 주파수 조절

// Edit → Project Settings → Time → Fixed Timestep

// 기본: 0.02 (50Hz)
// 낮추기: 0.03 (33Hz) → 성능 향상, 정확도 감소
// 높이기: 0.01 (100Hz) → 성능 감소, 정확도 향상

// 동적 조절
void AdjustPhysicsRate()
{
    if (Application.isMobilePlatform)
    {
        Time.fixedDeltaTime = 0.03f;  // 모바일: 33Hz
    }
    else
    {
        Time.fixedDeltaTime = 0.02f;  // PC: 50Hz
    }
}

충돌 레이어 최적화

// Edit → Project Settings → Physics → Layer Collision Matrix

// ✅ 불필요한 충돌 비활성화
// 예: Bullet과 Bullet 충돌 불필요
// 예: Enemy와 Enemy 충돌 불필요

// 코드로 체크
void CheckCollisionLayers()
{
    int playerLayer = LayerMask.NameToLayer("Player");
    int bulletLayer = LayerMask.NameToLayer("Bullet");
    
    // Player와 Bullet 충돌 체크
    bool canCollide = !Physics.GetIgnoreLayerCollision(playerLayer, bulletLayer);
    
    if (!canCollide)
    {
        // 충돌 활성화
        Physics.IgnoreLayerCollision(playerLayer, bulletLayer, false);
    }
}

Rigidbody 최적화

public class RigidbodyOptimization : MonoBehaviour
{
    private Rigidbody rb;
    
    void Start()
    {
        rb = GetComponent<Rigidbody>();
        
        // ✅ 움직이지 않으면 Kinematic
        if (isStaticObstacle)
        {
            rb.isKinematic = true;
        }
        
        // ✅ 회전 안 하면 Freeze
        rb.constraints = RigidbodyConstraints.FreezeRotation;
        
        // ✅ Continuous Collision Detection (필요시만)
        if (fastMoving)
        {
            rb.collisionDetectionMode = CollisionDetectionMode.Continuous;
        }
        else
        {
            rb.collisionDetectionMode = CollisionDetectionMode.Discrete;
        }
        
        // ✅ Sleep
        rb.sleepThreshold = 0.1f;  // 빨리 잠들기
    }
    
    // ❌ Transform 직접 수정
    void BadMove()
    {
        transform.position += Vector3.forward * Time.deltaTime;
        // Rigidbody가 있으면 물리 엔진과 충돌!
    }
    
    // ✅ Rigidbody 사용
    void FixedUpdate()
    {
        rb.MovePosition(rb.position + Vector3.forward * Time.fixedDeltaTime);
        // 또는
        rb.velocity = Vector3.forward * speed;
    }
}

Raycast 최적화

public class RaycastOptimization : MonoBehaviour
{
    // ❌ 매 프레임 Raycast
    void Update()
    {
        if (Physics.Raycast(transform.position, Vector3.down))
        {
            // ...
        }
    }
    
    // ✅ 간격을 두고 Raycast
    private float lastRaycastTime;
    
    void Update()
    {
        if (Time.time - lastRaycastTime > 0.1f)  // 0.1초마다
        {
            lastRaycastTime = Time.time;
            
            if (Physics.Raycast(transform.position, Vector3.down))
            {
                // ...
            }
        }
    }
    
    // ✅ LayerMask 사용
    void RaycastWithLayer()
    {
        int layerMask = LayerMask.GetMask("Ground", "Obstacle");
        
        // Ground와 Obstacle만 체크
        if (Physics.Raycast(transform.position, Vector3.down, out RaycastHit hit, 10f, layerMask))
        {
            // ...
        }
    }
    
    // ✅ RaycastNonAlloc (GC 없음)
    private RaycastHit[] results = new RaycastHit[10];
    
    void RaycastNoGC()
    {
        int hitCount = Physics.RaycastNonAlloc(
            transform.position,
            Vector3.down,
            results,
            10f
        );
        
        for (int i = 0; i < hitCount; i++)
        {
            Debug.Log(results[i].collider.name);
        }
    }
}

메모리 최적화

텍스처 압축

// Texture Import Settings:

// PC:
// - Format: DXT5 (투명도) / DXT1 (불투명)
// - Max Size: 2048 이하

// Mobile:
// - Android: ETC2 / ASTC
// - iOS: ASTC

// ✅ 런타임에서 체크
void CheckTextureMemory()
{
    Texture2D texture = GetComponent<Renderer>().material.mainTexture as Texture2D;
    
    int width = texture.width;
    int height = texture.height;
    TextureFormat format = texture.format;
    
    // 메모리 계산
    int bytesPerPixel = GetBytesPerPixel(format);
    int totalBytes = width * height * bytesPerPixel;
    
    Debug.Log($"Texture memory: {totalBytes / 1024} KB");
}

int GetBytesPerPixel(TextureFormat format)
{
    switch (format)
    {
        case TextureFormat.RGBA32:
            return 4;
        case TextureFormat.RGB24:
            return 3;
        case TextureFormat.DXT5:
            return 1;  // 압축
        default:
            return 4;
    }
}

에셋 번들

// 에셋 번들로 메모리 관리

public class AssetBundleLoader : MonoBehaviour
{
    async void Start()
    {
        // 에셋 번들 로드
        AssetBundle bundle = await LoadAssetBundleAsync("enemies");
        
        // 프리팹 로드
        GameObject enemyPrefab = bundle.LoadAsset<GameObject>("Enemy");
        
        // 인스턴스 생성
        Instantiate(enemyPrefab);
        
        // 더 이상 필요 없으면 언로드
        bundle.Unload(false);  // false: 생성된 인스턴스는 유지
    }
    
    async Task<AssetBundle> LoadAssetBundleAsync(string name)
    {
        string path = Path.Combine(Application.streamingAssetsPath, name);
        
        AssetBundleCreateRequest request = AssetBundle.LoadFromFileAsync(path);
        
        while (!request.isDone)
        {
            await Task.Yield();
        }
        
        return request.assetBundle;
    }
}

Addressables (권장)

using UnityEngine.AddressableAssets;
using UnityEngine.ResourceManagement.AsyncOperations;

public class AddressablesExample : MonoBehaviour
{
    async void Start()
    {
        // 주소로 로드
        AsyncOperationHandle<GameObject> handle = 
            Addressables.LoadAssetAsync<GameObject>("Assets/Prefabs/Enemy.prefab");
        
        await handle.Task;
        
        if (handle.Status == AsyncOperationStatus.Succeeded)
        {
            GameObject enemyPrefab = handle.Result;
            Instantiate(enemyPrefab);
        }
        
        // 메모리 해제
        Addressables.Release(handle);
    }
    
    // 또는 간단하게
    async void LoadSimple()
    {
        GameObject prefab = await Addressables.LoadAssetAsync<GameObject>("Enemy").Task;
        Instantiate(prefab);
    }
}

Job System과 Burst

C# Job System

using Unity.Jobs;
using Unity.Collections;

public class JobSystemExample : MonoBehaviour
{
    struct AddJob : IJob
    {
        public NativeArray<float> a;
        public NativeArray<float> b;
        public NativeArray<float> result;
        
        public void Execute()
        {
            for (int i = 0; i < a.Length; i++)
            {
                result[i] = a[i] + b[i];
            }
        }
    }
    
    void Start()
    {
        // Native 배열 (GC 없음)
        NativeArray<float> a = new NativeArray<float>(1000, Allocator.TempJob);
        NativeArray<float> b = new NativeArray<float>(1000, Allocator.TempJob);
        NativeArray<float> result = new NativeArray<float>(1000, Allocator.TempJob);
        
        // 초기화
        for (int i = 0; i < 1000; i++)
        {
            a[i] = i;
            b[i] = i * 2;
        }
        
        // Job 생성
        AddJob job = new AddJob
        {
            a = a,
            b = b,
            result = result
        };
        
        // Job 스케줄
        JobHandle handle = job.Schedule();
        
        // 완료 대기
        handle.Complete();
        
        // 결과 확인
        for (int i = 0; i < 10; i++)
        {
            Debug.Log($"result[{i}] = {result[i]}");
        }
        
        // 해제
        a.Dispose();
        b.Dispose();
        result.Dispose();
    }
}

IJobParallelFor (병렬)

struct ParallelAddJob : IJobParallelFor
{
    [ReadOnly] public NativeArray<float> a;
    [ReadOnly] public NativeArray<float> b;
    [WriteOnly] public NativeArray<float> result;
    
    public void Execute(int index)
    {
        result[index] = a[index] + b[index];
    }
}

void RunParallel()
{
    // ... (배열 준비)
    
    ParallelAddJob job = new ParallelAddJob
    {
        a = a,
        b = b,
        result = result
    };
    
    // 병렬 실행 (워커 스레드들이 나눠서 처리)
    JobHandle handle = job.Schedule(
        arrayLength: 1000,
        innerloopBatchCount: 64  // 배치 크기
    );
    
    handle.Complete();
}

// 성능:
// 싱글스레드: 10ms
// 4코어 병렬: 3ms (3.3배 빠름)

Burst Compiler

using Unity.Burst;

// Burst: C# → 최적화된 네이티브 코드
[BurstCompile]
struct BurstAddJob : IJobParallelFor
{
    [ReadOnly] public NativeArray<float> a;
    [ReadOnly] public NativeArray<float> b;
    [WriteOnly] public NativeArray<float> result;
    
    public void Execute(int index)
    {
        result[index] = a[index] + b[index];
    }
}

// 성능:
// C# (Mono): 10ms
// C# (IL2CPP): 5ms
// Burst: 0.5ms (20배 빠름!)

실전 예시: 적 AI

[BurstCompile]
struct EnemyAIJob : IJobParallelFor
{
    [ReadOnly] public NativeArray<float3> enemyPositions;
    [ReadOnly] public float3 playerPosition;
    [WriteOnly] public NativeArray<float3> enemyDirections;
    
    public float speed;
    public float deltaTime;
    
    public void Execute(int index)
    {
        float3 enemyPos = enemyPositions[index];
        
        // 플레이어 방향 계산
        float3 direction = math.normalize(playerPosition - enemyPos);
        
        // 이동 벡터
        enemyDirections[index] = direction * speed * deltaTime;
    }
}

public class EnemyManager : MonoBehaviour
{
    private NativeArray<float3> positions;
    private NativeArray<float3> directions;
    
    void Update()
    {
        // Job 생성
        EnemyAIJob job = new EnemyAIJob
        {
            enemyPositions = positions,
            playerPosition = player.transform.position,
            enemyDirections = directions,
            speed = 5f,
            deltaTime = Time.deltaTime
        };
        
        // 병렬 실행
        JobHandle handle = job.Schedule(positions.Length, 64);
        handle.Complete();
        
        // 결과 적용
        for (int i = 0; i < enemies.Count; i++)
        {
            enemies[i].transform.position += (Vector3)directions[i];
        }
    }
}

프로파일링

Unity Profiler

// Window → Analysis → Profiler

// CPU Usage:
// - Update() 시간
// - Rendering 시간
// - Physics 시간
// - GC.Alloc 크기

// Memory:
// - Texture 메모리
// - Mesh 메모리
// - Audio 메모리
// - GameObject 수

// Rendering:
// - Draw Calls
// - Batches
// - Triangles
// - Vertices

커스텀 프로파일링

using Unity.Profiling;

public class CustomProfiler : MonoBehaviour
{
    // 커스텀 마커
    static readonly ProfilerMarker s_MyFunctionMarker = new ProfilerMarker("MyFunction");
    
    void MyFunction()
    {
        s_MyFunctionMarker.Begin();
        
        // 측정할 코드
        ExpensiveOperation();
        
        s_MyFunctionMarker.End();
    }
    
    // 또는 using
    void MyFunction2()
    {
        using (s_MyFunctionMarker.Auto())
        {
            ExpensiveOperation();
        }
    }
    
    // Deep Profiling (느림, 개발용)
    [System.Diagnostics.Conditional("ENABLE_PROFILER")]
    void DeepProfile()
    {
        Profiler.BeginSample("My Operation");
        ExpensiveOperation();
        Profiler.EndSample();
    }
}

Frame Debugger

// Window → Analysis → Frame Debugger

// 렌더링 순서 분석:
// 1. Draw Call 단계별 확인
// 2. Overdraw 확인
// 3. Batching 확인
// 4. Shader 변경 횟수

모바일 최적화

해상도 조절

public class ResolutionManager : MonoBehaviour
{
    void Start()
    {
        // 해상도 낮추기 (성능 향상)
        Screen.SetResolution(1280, 720, true);
        
        // 또는 비율로
        float scale = 0.75f;  // 75%
        int width = (int)(Screen.width * scale);
        int height = (int)(Screen.height * scale);
        Screen.SetResolution(width, height, true);
    }
}

프레임레이트 제한

void Start()
{
    // 모바일: 30fps로 제한 (배터리 절약)
    Application.targetFrameRate = 30;
    
    // 고사양 기기: 60fps
    if (SystemInfo.processorCount >= 8)
    {
        Application.targetFrameRate = 60;
    }
}

Quality Settings

void Start()
{
    // Edit → Project Settings → Quality

    // 기기별 설정
    if (Application.isMobilePlatform)
    {
        QualitySettings.SetQualityLevel(0);  // Low
        QualitySettings.shadows = ShadowQuality.Disable;
        QualitySettings.antiAliasing = 0;
    }
    else
    {
        QualitySettings.SetQualityLevel(3);  // High
    }
}

참고 자료

필수 문서

도구

  • Unity Profiler: CPU, GPU, 메모리 분석
  • Frame Debugger: 렌더링 분석
  • Memory Profiler: 메모리 누수 탐지
  • Xcode Instruments (iOS): 모바일 프로파일링
  • Android Profiler (Android): 모바일 프로파일링

추가 학습


결론

Unity 최적화의 핵심 원칙:

  1. 측정 먼저: 추측하지 말고 프로파일러로 측정
  2. GC 최소화: 할당을 줄이고 재사용
  3. 배칭 최대화: Draw Call 줄이기
  4. 물리 최적화: 불필요한 충돌 제거
  5. Job System 활용: CPU 병렬화
  6. 모바일 고려: 해상도, 프레임레이트, Quality
profile
RL Researcher, Video Game Developer

0개의 댓글