ABOUT ME

-

Today
-
Yesterday
-
Total
-
  • Unity 스크립트 최적화
    게임 클라이언트 개발/Unity 2023. 11. 7. 01:07

    유니티에서 사용하는 C#은 프로그래머가 직접 메모리를 해제하는 C++과 달리 메모리 해제를 Garbage Collector(통칭 GC)에 의해 수행된다.

     

    GC의 메모리 해제 로직(Mark and Sweep)은 프로파일러를 확인해보면 일정 주기마다 이루어지는데 이때 해제해야 하는 메모리가 많다성능에 영향을 주는데 이를 Spark 현상이라고 한다.

    Unity Profiler 중 Garbage Collector

     

    이런 스파크 현상이 많다면 게임이 뚝뚝 끊기는 현상이 발생하여 게임 플레이에 지장을 준다.

     

    이번 스크립트 최적화에선 GC 최적화를 주로 목표로 하며 이외의 최적화 방법도 소개한다.

     

    GetComponent, Find 사용 줄이기

    GetComponent, Find, FindObjectOfType 등의 함수는 Hierarchy 내에 오브젝트를 탐색하기에 비용이 비싸다. 

    Update와 같이 자주 호출되는 함수에서 사용할 경우 게임이 느려지는 모습을 관찰할 수 있기에 객체 참조가 필요하다면 해당 객체를 필드에 캐싱 후 사용해야 한다.

    // Bad case
    // private void Update()
    // {
    //    GetComponent<Rigidbody>().AddForce(Vector3.right);
    // }
    
    // Good case
    private Rigidbody rb;
    
    private void Awake()
    {
        rb = GetComponent<Rigidbody>();
    }
    
    private void Update()
    {
        rb.AddForce(Vector3.right);
    }

     

    GetComponent 대신 TryGetComponent 사용하기

    GetComponent는 할당 성공 여부에 상관없이 GC Allocation이 발생한다.

    TryGetComponent는 GC Allocation으로부터 자유로우며 할당 성공 여부를 반환받을 수 있다.

     

    Object.name, GameObject.tag 사용을 지양하기

    해당 프로퍼티를 참조할 때마다 문자열을 힙에서 할당한다. 이는 가비지를 생성한다.

    tag 프로프터의 경우 CompareTag를 사용하여 가비지 생성을 방지할 수 있다.

     

    비어있는 유니티 이벤트 함수 방치하지 않기

    유니티 이벤트 함수(Ex. Awake, Start, ...)는 스크립트 내 작성되있는 것만으로도 호출되므로 비어있는 경우 지워야 한다.

    protected virtual 등으로 지정하는 경우에도 비워놓을 가능성이 있다면 지양하는 것이 좋다.

     

    StartCoroutine 자주 호출하지 않기

    StartCoroutine는 코루틴 타입의 객체를 리턴하므로 가비지를 생성한다.

    매 프레임 실행되는 코루틴일 경우 Update를 사용해야 한다.

     

    코루틴의 yield 캐싱하기

    코루틴에서 반환되는 WaitForSeconds 등의 객체를 매번 new 키워드로 생성한다면 이 역시 GC의 대상이 된다.

    자주 사용하는 코루틴 객체의 경우 미리 캐싱해서 사용해야 한다.

    // Bad case
    // private IEnumerator SomeCoroutine()
    // {
    //     while(true)
    //     {
    //         // ...
    //         yield return new WaitForSeconds(0.01f);
    //     }
    // }
    
    // Good case
    private IEnumerator SomeCoroutine()
    {
        var wfs = new WaitForSeconds(0.01f);
        while(true)
        {
            // ...
            yield return wfs;
        }
    }

     

    함수 호출 줄이기

    함수는 호출하는 것 자체만으로도 성능에 영향을 준다.

    함수의 결과값이 범위 내에서 항상 같은 경우 지역 변수나 필드에 함수 호출로 값을 얻어 재사용해야 한다.

    void Update()
    {
        bool isTransformFullyActive = IsFullyActive(transform);
        
        // 블록 내에서 isTransformFullyActive 재사용
    }
    
    bool IsFullyActive(Transform tr)
     => transform.gameObject.activeSelf &&
        transform.gameObject.activeInHierarchy;

     

    참조 캐싱하기

    프로퍼티는 필드가 아닌 필드처럼 호출할 수 있는 함수이다.

    내부적으로는 Setter, Getter로 구성되있으므로 호출 시 함수 호출만큼의 오버헤드가 발생한다.

    자주 호출하는 프로퍼티, 참조들은 최대한 해당 타입 그대로 필드에 캐싱해서 사용해야 한다.

    private Transform charTransform;
    
    private void Awake()
    {
        charTransform = transform;
    }
    
    private void Update()
    {
        // _ += transform.position.x;
        _ += charTransform.position.x;
        _.SetParent(charTransform.parent);
    }

     

    빌드 이후 Debug.Log() 사용하지 않기

    Debug의 함수들은 에디터에서 디버깅을 위해 사용한다.

    하지만 빌드 이후에도 호출되어 성능에 영향을 준다.

    Debug 클래스를 에디터 전용으로 래핑하여 사용하면 이런 경우를 방지할 수 있다.

    public static class Debug
    {
        [Conditional("UNITY_EDITOR")]
        public static void Log(object message)
            => UnityEngine.Debug.Log(message);
    }

     

    Transform 변경은 한 번에

    position, rotation, scale을 한 함수 내에서 여러 번 변경할 경우, 그 때마다 Transform이 변경된다.

    벡터로 미리 변환을 담아두고, 최종 계산 이후 Transform을 단 한 번만 변경하는 것이 좋다.

    position과 rotation을 모두 변경하는 경우 SetPositionAndRotation을 사용하면 된다.

     

    불필요하게 부모 자식 구조 늘리지 않기

    Hierarchy가 너무 복잡할 경우 빈 오브젝트를 사용하여 정리하는 경우가 많다.

    자식 오브젝트의 Transform은 부모 오브젝트의 Transform에 종속적이기 때문에 부모 오브젝트의 Transform에 '변경'이 발생한 경우 모든 자식 오브젝트의 Transform도 변경되어 성능에 악영향을 끼칠 수 있다.

    따라서 부모-자식 관계는 필요한 만큼만 최소한으로 구성해야 한다.

     

    ScriptableObject 활용하기

    게임 내 항상 공통으로 참조하는 변수를 사용할 경우, 각 객체의 필드로 사용하면 동일한 데이터가 객체 수 만큼의 메모리를 차지한다.

    이를 ScriptableObject로 공유하면 데이터는 객체 수와 상관없이 하나만 존재하여 메모리를 절약할 수 있다.

    (경량 패턴, Flyweight Pattern)

    public class GameUnit : MonoBehaviour
    {
        /* 개별 데이터 필드 */
        private float hp;
        private float mp;
    
        // 공유 데이터 필드(Scriptable Object)
        private GameUnitData data;
    
        private void Awake()
        {
            /* 게임 시작 시 초기 HP, MP 값을 최대치로 설정 */
            hp = data.maxHp;
            mp = data.maxMp;
        }
    }
    
    public class GameUnitData : ScriptableObject
    {
        public float maxHp = 100;
        public float maxMp = 50;
    }

     

    필요하지 않는 경우, 리턴하지 않기

    함수를 호출하고 그 리턴값을 사용하지 않는 경우, 리턴값을 void로 지정해야 한다.

    리턴하는 것만으로도 항상 성능 소모가 발생되며, 참조형 리턴값인 경우 GC의 대상이 되기 때문이다.

    //private int SomeMethod() // 정수 타입을 리턴하는 메소드
    //{
    //    ...
    //    return 0;
    //}
    
    private void SomeMethod() // 리턴값이 없는 메소드로 수정
    {
    //    ...
    }
    
    private void Caller()
    {
        SomeMethod(); // 메소드의 리턴값을 사용하지 않음
    }

     

    new로 생성하는 부분 최대한 줄이기

    클래스 타입으로 생성한 객체는 힙에 할당된다.

    더 이상 참조되지 않을 때 GC에 의해 자동 수거된다.

    가능한 한 번만 사용하고 재사용하거나 최대한 new 사용을 지양해야 한다.

     

    오브젝트 풀링 사용하기

    프로파일러를 확인해보면 게임 오브젝트의 Instantiate와 Destory는 비싼 비용이 필요하단 것을 알 수 있다.

    오브젝트의 생성 및 파괴가 빈번하게 발생할 경우(Ex. 총알 등), 오브젝트 풀링을 통해 활성화/비활성화하여 재사용하는 것이 좋다.

     

    구조체 사용하기

    클래스는 참조를 위해 8 ~ 24 바이트의 추가적인 메모리를 필요로 한다.

    구조체와 달리 클래스는 참조 타입의 객체로 스택이 아닌 힙에 저장된다.

    따라서 더 이상 참조되지 않는 경우 GC의 수거 대상이 된다.

    클래스 또는 구조체를 선택하는 가이드라인은 다음과 같다.

    • 16 bytes 이하의 데이터 클래스는 구조체로 선언
    • 생성 / 해제가 자주 일어날 경우 구조체로 선언
    • 생성 / 해제보다 전달(매개변수, 리턴)이 자주 일어날 경우 클래스로 선언하거나 매개변수 한정자 in을 사용

     

    컬렉션 재사용하기

    List, Dictionary와 같은 자료구조를 생성할 때 new 키워드를 사용하기 때문에 이 역시 GC의 대상이 된다.

    따라서 반복해서 사용하는 자료구조는 캐싱하여 재사용하는 것이 좋다.

    List<Transform> transformList = new List<Transform>();
    
    private void SomeMethod() // 여러 번 호출되는 메소드
    {
        //List<Transform> transformList = new List<Transform>();
        transformList.Clear();
        transformList.Add(...);
        // ... 
    }

     

    List 사용 시 주의할 점

    C#의 가변 배열인 List<T>는 내부적으로 배열로 구현되어 있다.

    Add()를 통해 원소를 추가할 때 배열이 가득찰 때마다 두 배 크기의 새로운 배열을 생성한다. -> C++의 Vector 방식과 유사

    리스트를 생성할 때 크기를 알고 있다면, new List<T>(100)과 같이 갯수를 미리 지정하는 것이 좋다.

     

    StringBuilder 사용하기

    String Concatenation(연결) 또는 String Formatting이 자주 발생하는 경우 StringBuilder를 사용해야 한다.

    StringBuilder 객체를 한 번만 생성하여 Clear()로 내부를 초기화하면서 재사용해야 한다.

     

    LINQ 사용 시 주의할 점

    LINQ의 대부분 연산자는 중간 버퍼(일종의 배열)을 생성한다. 이는 모두 GC의 대상이다.

    LINQ를 사용할 경우에 대한 가이드라인은 다음과 같다.

    • 빠르게 구현해야만 하는 상황(프로토 타입 개발 등) -> LINQ 사용
    • 성능에 민감하지 않은 코드(에디터 전용 코드 또는 작은 규모의 코드) -> LINQ 사용
    • LINQ의 편의성 또는 가독성이 반드시 필요 -> LINQ 사용
    • 매 프레임 호출되거나, 성능에 민감한 부분 -> LINQ 사용하지 않음

     

    박싱, 언박싱 피하기

    • 박싱(Boxing) : 값 타입이 참조 타입으로 암시적, 또는 명시적 캐스팅
    • 언박싱(Unboxing) : 참조 타입이 값 타입으로 명시적 캐스팅

    박싱과 언박싱의 성능은 단순 할당보다 매우 좋지 않다.

    참조 타입의 경우 힙에 할당되어 GC의 대상이 된다.

    함수 매개변수로 object 타입을 사용하거나 박싱을 유도하는 방식은 최대한 지양해야 한다.

    Generic 타입을 사용하여 박싱, 언박싱을 피할 수 있다.

     

    Enum HasFlag 박싱 이슈

    Flag를 사용하는 Enum의 경우 HasFlag()를 통해 간편히 포함관계를 파악할 수 있다.

    이 때 Enum 타입이 System.Enum 타입으로 변환되면서 박싱이 발생한다.

    public static bool HasFlag2<T>(this T self, T flag) where T : Enum
    {
        return (self & flag) == flag;
    }

    foreach 루프 및 Dictionary 키로 Enum을 사용할 경우의 박싱 이슈는 해결됬다.

     

    비싼 수학 계산 피하기

    나눗셈 대신 곱셈을 사용해야 한다. -> 1.0f / 2.0f 대신 1.0f * 0.5f를 사용

    나눗셈 대신 곱셈을 사용하는 가이드라인은 다음과 같다. result = a / b;

    • 만약 b값이 자주 변할 경우, 나눗셈 사용
    • b값이 상수일 경우, c = 1 / b 상수 또는 변수를 선언하여 a * c를 항상 사용
    • b값이 일정한 주기로 바뀌는 경우, 바뀌는 순간마다 c = 1 / b로 캐싱하고 a * c 사용

    if-else vs 삼항 연산자 -> 가독성을 생각하여 사용하면 된다.

    System.Math.Abs vs UnityEngine.Mathf.Abs vs 삼항 연산자 -> 삼항 연산자가 압도적으로 빠르다.

    System.Math는 대부분 UnityEngine.Mathf보다 빠르다.

     

    벡터 연산 시 주의 사항

    벡터와 스칼라의 연산은 스칼라부터 모두 계산한 후 벡터와 계산하는 것이 성능 상 매우 유리하다.

    // 벡터가 앞에 있을 경우
    Vector3 t_vector = vec * a * b;
    // 실제로 일어나는 연산
    Vector3 temp = new Vector3(vec.x * a, vec.y * a, vec.z * a);
    Vector3 t_vector = new Vector3(temp.x * b, temp.y * b, temp.z * b)
    
    // 순서를 바꿀 경우
    Vector3 t_vector = a * b * vec;
    // 실제로 일어나는 연산
    float temp = a * b;
    Vector3 t_vector = new Vector3(vec.x * temp, vec.y * temp, vec.z * temp);

     

    '게임 클라이언트 개발 > Unity' 카테고리의 다른 글

    Animation Missing! 해결법  (0) 2024.04.12
    플레이어 시야 FOV 구현  (0) 2023.12.13
    길찾기 알고리즘과 최적화  (0) 2023.11.27
    UGUI의 성능 최적화  (0) 2023.11.08
Designed by Tistory.