ABOUT ME

-

Today
-
Yesterday
-
Total
-
  • UGUI의 성능 최적화
    게임 클라이언트 개발/Unity 2023. 11. 8. 00:38

    여러 장르의 게임이 존재하지만, 공통적으로 제일 중요한 부분은 사용자 인터페이스를 맡고 있는 UI일 것이다.

     

    UI는 사용자와 어떻게 상호작용을 할 것인지도 중요하지만 Unity Profiler에선 UI만 따로 볼 수 있을 정도로 UI의 성능 또한 매우 중요하다.

    Unity Profiler 중 UI 부분

     

    Unity의 UI를 담당하는 Component 중 가장 기본적인 Component는 Canvas일 것이다.

     

    Canvas의 Render Mode는 Screen Space - Overlay, Screen Space - Camera, World Space 총 세 가지가 존재하며 이번 UGUI의 성능 최적화는 Screen Space - Overlay 최적화를 목표로 한다.

     

    출처 : Unity Korea 공식 유튜브 https://www.youtube.com/watch?v=1e2mSCS7o1A

     

    UGUI의 병목

    UGUI를 최적화하기 위해선 UGUI에서 병목 현상이 어디에서 발생하는지를 알아볼 필요가 있다.

    • 유니티의 구조 : 2중 Layer (내부 레이어 C++, 유저 레이어 C#)
    • GPU 바운드 : Fillrate (해상도, 오버드로우, 대역폭 이슈 등)
    • CPU 바운드 : Draw Call/Batch, Canvas Batch 구축 연산 시간, Vertex 생성 시간(UI 역시 이미지가 아닌 Mesh로 이루어져 있음)

    같은 UI의 Shaded와 Wire Frame

     

    매 프레임마다 UI가 변할 경우 Mesh가 변하게 되고 이를 CPU에서 계산하고 GPU에서 렌더링해야 한다. 즉, UGUI의 병목 현상은 GPU 뿐 아니라 CPU에서도 발생할 수 있다.

     

    Graphic(.cs)

    UGUI의 구조를 알아보면 Image, Text, TMP_Text 등 Canvas 시스템에서 렌더링 가능한 모든 Unity UI C# 클래스의 기본 클래스는 Graphic 클래스이다.

     

    Graphic 클래스 내 함수 중 일부를 확인해 보자.

    // UI가 변한 경우(크기가 변하든, 어떤 인터렉션이 발생하든 등등) 호출
    public virtual void Rebuild(CanvasUpdate update);
    
    // Rebuild 호출 시 정점 데이터와 머테리얼 데이터를 업데이트
    protected virtual void UpdateGeometry();
    protected virtual void UpdateMaterial();
    
    // 업데이트 함수 내에서 호출되는 함수
    // 버텍스 버퍼와 인덱스 버퍼를 업데이트 함
    protected virtual void OnPopulateMesh(VertexHelper vh);

     

    업데이트된 버텍스 버퍼와 인덱스 버퍼 정보를 활용하여 VertexHelper의 FillMesh 함수를 통해 Rendering Pipeline에 데이터를 넣어준다.

     

    Canvas(.cpp)

    Canvas 클래스는 Graphic 클래스의 정보를 토대로 메시를 구성하고 Draw Call을 호출하는, 즉 실제 렌더링을 진행하는 클래스이다.

     

    모든 Draw Call은 Canvas 오브젝트 단위로 이루어지며, Canvas가 메시를 구성하고 렌더링 명령을 생성한다. (Re-batch, Batch build)

     

    그렇기 때문에 Canvas가 Re-batching이 필요한 Geometry를 포함할 경우 해당 Canvas는 Dirty flag 처리를 하고, Canvas의 Vertex Buffer를 갱신하고, Canvas.cpp 내 DrawRawMesh() 함수가 호출된다.

     

    좀 더 쉽게 표현하자면, Canvas 하위의 오브젝트가 수정될 경우 Canvas 전체를 갱신해야 한다는 소리다.

     

    Nested Canvas

    그렇다면 어떻게 해결을 해야 할까? 우선 C++ Layer의 코드를 확인해 보자.

    // Canvas를 렌더링 시 호출되는 C++ 레이어 코드
    void Canvas::RenderOverlays();
    
    (*nestedCanvasItt)->RnderOverlays(); // 자식 오브젝트가 캔버스일 경우 재귀적으로 함수 호출
    DrawRawMesh(*iter, vertexBuffer, indexBuffer); // 아닐 경우 실제 렌더링하는 함수 호출

     

    코드를 확인해 보면 Canvas 간에는 부모의 크기가 변경되는 경우를 제외하고 재구성 영향을 미치지 않는다는 것을 알 수 있다.

     

    따라서 Canvas를 정적인 객체와 동적인 객체 별로 나누는 것을 권장하며, Canvas를 나눌 때 Root Canvas를 나눌 필요는 없고 Nested Canvas를 사용하면 된다.

     

    Canvas를 얼마나 많이 나눌 것인가에 대해선 비교해야 할 관점은 Update 비용과 Draw Call 비용을 비교해야 한다.

     

    Dirty Flag

    Unity의 갱신 로직을 살펴보자.

     

    계층 구조일 경우 Dirty Flag를 통해 한 번에 계산하는 것이 유리하고, UGUI 또한 계층 구조로 이루어져 있기 때문에 Dirty Flag를 활용한 갱신 로직을 사용한다.

    public virtual void SetAllDirty()
    {
        SetLayoutDirty();
        SetVerticesDirty();
        SetMaterialDirty();
    }

     

    Rebuild

    UGUI의 최적화를 위해 중요하게 생각해야 하는 부분은 결국 Update되는 UI Component의 수를 최대한 줄여야 함이란 것으로 생각할 수 있다.

     

    이런 UI Component가 갱신되는 로직을 Rebuild라 칭하며, C# Graphic Component의 레이아웃과 메시가 Dirty Flag를 기반으로 다시 계산되는 프로세스라고 말할 수 있다.

     

    UI Element의 Layout이 변경될 경우 Layout에 Dirty Flag를 표시하고 계층 구조의 깊이 별로 정렬하는 비용이 발생한다.

     

    UI Element의 Graphic이 변경될 경우 Graphic에 Dirty Flag를 표시하고 Vertex 데이터가 Dirty일 경우(RectTransform 크기 변경 등), 메시가 다시 빌드되며 Material 데이터가 Dirty일 경우(Texture 변경 등), 연결된 Canvas Renderer의 Material를 업데이트한다.

     

    이러한 Rebuild는 alpha값이 0일지라도(육안으로 보이지 않더라도) 모든 enabled 요소들의 메시를 재생성한다.

     

    RectTransform(.cpp)

    UI 오브젝트들은 RectTransform Component를 사용하여 위치나 회전 등을 설정한다.

     

    RectTransform 역시 Transform Class를 상속받는 클래스이므로 오브젝트의 변경이 계층적으로 영향을 준다.

     

    UI의 계층 구조를 변경할 경우 Re-Parenting 비용이 발생한다.

    // Re-Parenting 시 발생되는 이벤트
    OnBeforeTransformParentChanged
    OnTransformParentChanged
    OnTransformChildrenChanged

     

    계층 구조에서 접근 계산(멀티 스레딩)을 효율적으로 실행하기 위해 메모리에 연속적으로 할당하는데 Re-Parenting이 발생할 경우 메모리를 재정렬해야 하는 오버헤드가 발생하게 된다. 이는 Transform에서도 똑같은 이슈가 존재한다.

     

    따라서 Hierarchy를 런타임에 변경하지 않는 것을 지향해야 한다.

     

    Batching Building (Canvas)

    Canvas도 결국 메시이고 메시를 렌더링 하기 위해선 Batcing이 필요하다. CPU에서 매 프레임 Batcing 데이터를 생성하지 않고 Canvas가 Dirty로 표시될 때까지 캐시 되고 재사용되며, 자식 Canvas는 포함되지 않는다.

     

    Dirty Flag가 표시될 경우 Batching 데이터를 갱신하기 위해 여러 기준을 두고 연산을 수행한다.

    • 동일한 Canvas
    • 동일한 Material 및 Sprite Asset -> Atlas를 사용할 것
    • 동일한 Z 깊이의 RectTransform
    • 동일한 마스크 적용

    여기서 눈여겨봐야 할 부분은 깊이별로도 정렬을 한다라는 점인데 UI는 alpha값이 1이더라도 Transparent Object로 취급한다.

     

    이러한 Transparent 오브젝트들은 Blending이 적용하기 위해 Rendering Order를 설정하는데(Back to Front), 이 때문에 UI 오브젝트의 Z 값이 변경될 경우 Z 값에 따라 Batch를 따로 생성하여 Batch의 수가 급격하게 증가할 수 있다.

     

    따라서 UI 오브젝트의 정렬을 위해서 Z 값을 수정하지 말고 Canvas Component의 Sorting Order를 활용해야 한다.

     

    또한 Batcing 데이터는 보통 멀티 스레드로 연산하기에 기기간 성능이 매우 다르다. 따라서 Target Device에서 프로파일링을 진행해야 한다.

     

    Pixel Perfect

    2D 이미지를 좀 더 선명하게 보여주고 싶어 Pixel Perfect 기능을 사용한다. 이는 Canvas에도 존재한다.

     

    하지만 Canvas의 Pixel Perfect는 2D Sprite와 다른 방식으로 연산되며 Canvas의 RectTransform이 변경될 경우 모든 정점을 재계산한다. 따라서 움직이는 Element가 존재한다면 치명적인 성능 하락을 경험할 수 있다.

     

    Pixel Perfect를 꼭 써야 한다면 Element가 움직일 때는 옵션을 끄고, 멈췄을 때 옵션을 키는 방식을 채용하거나, Nested Canvas를 사용하여 정적인 UI에만 옵션을 적용해야 한다.

     

    Layout Components

    UI를 제작할 때 UI를 구조화하고 UI의 요소를 순서대로 설정하기 위해 Layout Components (Vertical, Horizontal, Grid 등)을 자주 사용하게 된다.

     

    하지만 이런 Layout Components를 사용할 경우 RectTransform을 변경하고, Layout을 Dirty로 설정하고 이는 Rebuild 프로세스에 추가되므로 Rebuild 연산이 극단적으로 많이 수행하게 된다.

     

    Layout Components들은 RectTransform의 크기와 위치를 제어하기 때문에 Graphic 클래스와는 독립적으로 사용되므로, 스프라이트가 변경되는 상황과 같이 Material이 변경될 경우 Dirty Flag를 설정하지 않는다.

     

    이런 문제를 해결하기 위한 몇 가지 가이드라인이 있다.

    • 재구성이 필요할 때만 Layout Component를 활성화한다.
    • 재구축되는 하위 Element 수를 최대한 적게 구성한다.
    • Layout Manager 스크립트를 작성하여 Layout이 업데이트되는 타이밍을 제어한다.
    • Object Pool을 사용하여 Rebuild 요소를 줄인다.

     

    Canvas Rebuilding - Animator

    Unity의 Animator는 Key Frame으로 작동하는데 이는 시각적으로 변화가 없는 Animation이더라도 Dirty Flag 이벤트가 발생하게 된다.

    protected override void OnDidApplyAnimationProperties()
    {
        SetAllDirty();
    }

     

    따라서 항상 변경되는 동적인 UI에만 Animator를 사용하고 이벤트성으로 반응하는 버튼에는 Tweening 시스템을 사용해야 한다.

     

    Raycaster

    UI와 상호작용을 하기 위해 Raycast를 사용하며 Canvas에 부착되어 있는 Graphic Raycaster는 사용자의 입력을 UI 이벤트로 변환해 주는 Component이다. 

     

    하지만 이는 실제로 Ray를 발사하여 체크하는 Raycaster가 아닌 화면의 모든 입력 지점을 확인하여 UI의 RectTransform 내에 있는지를 확인하는 로직이므로 Interaction이 없는 UI까지 확인하여 잠재적인 오버헤드가 발생할 수 있다.

     

    따라서 Interaction을 사용하지 않는 Canvas는 Graphic Raycaster Component를 제거하고 Interaction이 없는 Element들은 Raycast Target을 꺼줘야 한다.

     

    Full Screen UI

    게임을 진행할 때 전체 화면을 덮는 UI를 켰을때 3D 씬이 시각적으로 보이지 않더라도 렌더링이 된다.

     

    따라서 UI에 의해 씬이 렌더링 될 필요가 없다면 카메라를 돌려 렌더링 되는 오브젝트가 없도록 수정하거나 카메라 자체를 Disable하여 불필요한 렌더링 연산을 피할 수 있다.

     

    또한 전체 화면 UI는 적정 Frame을 낼 필요가 없기 때문에 Application.targetFrameRate을 낮춰서 기기의 사용량을 줄일 수 있다. (Ex. 모바일 기기에서 화면 잠금 모드)

     

    Text -> Icon

    Text 역시 Material이기 때문에 Text가 많을수록 다른 Material를 사용하게 되어 Batching이 깨질 수 있다.

     

    따라서 Text 대신 Icon 사용을 권장하며 다국어 처리에도 효과적이다.

     

    Text Mesh Pro

    Text Mesh Pro는 사용하는 글자들을 미리 텍스쳐로 제작하여 Atlas로 묶어 사용한다.

     

    영미권 언어는 큰 문제가 없으나 한글의 경우 초성, 중성, 종성의 조합 경우의 수가 너무 많아 Atlas의 크기가 커질 수 있다.

     

    모바일 디바이스에선 Atlas Resolution이 4096 * 4096 이상 사용할 경우 대역폭 이슈가 발생한다.

     

    이를 해결하기 위해서 여러 Atlas를 사용하게 되면 Batching의 수가 증가한다.

     

    따라서 Draw Call 이슈와 대역폭 이슈를 적절히 타협하여 Atlas를 제작해야 한다.

     

     

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

    Animation Missing! 해결법  (0) 2024.04.12
    플레이어 시야 FOV 구현  (0) 2023.12.13
    길찾기 알고리즘과 최적화  (0) 2023.11.27
    Unity 스크립트 최적화  (0) 2023.11.07
Designed by Tistory.