KoreanFoodie's Study

[언리얼] 언리얼 UI 최적화 정리 (언리얼 공식 문서를 중심으로) 본문

Game Dev/Unreal C++ : Dev Log

[언리얼] 언리얼 UI 최적화 정리 (언리얼 공식 문서를 중심으로)

GoldGiver 2024. 2. 6. 20:33

언리얼 UI 최적화

핵심 :

1. UI 최적화를 위해서는 먼저 UI 구조와 렌더링 프로세스를 이해해야 한다.

2. UI 최적화의 핵심은 결국 Tick 이다. Tick 을 필요할 때만 호출하게 만들거나, Tick 당 담기는 연산의 크기를 줄여야 한다. 전자는 게임쓰레드와, 후자는 렌더링 쓰레드와 연관이 깊다.

3. 게임 쓰레드 최적화에는 Invalidation Box, Visibility, Widget Binding 등이 있고, 렌더링 쓰레드 최적화에는 Merging Batches, Retainer Box 등이 있다.

언리얼에서 UMG 를 이용한 UI 작업을 상당히 많이 하는데, 일정에 쫓기다 보니 프로젝트 차원에서 최적화를 고려하면서 만드는 경우가 잘 없는 것 같다는 생각이 들었다. 또한 최적화를 한다고 해도, 작업자마다 알고 있는 부분이 다르고 최적화를 적용하는 포인트가 다르다보니, 파편화된 지식을 정리할 필요하다는 생각이 들었다.

이번 글에서는 언리얼 공식 문서와 언리얼 페스트 등의 자료를 참고하여 언리얼에서 UI 를 어떻게 최적화하는지에 대해 정리한다.

그리고 언리얼에서 UI 작업을 한다는 것은, UMG 로 작업을 한다고 봐도 무방하다. 따라서 UMG 를 중심으로 설명을 진행할 것이다. 또한 테스트한 버전은 4.27 을 기준으로 한다.

 

 

UI 의 구조와 렌더링 프로세스

먼저, UI 를 설명하기 전에 관련 용어를 정리하고 넘어가도록 하자.

  • User Widget : 유저 인터페이스 위젯
  • Widget Tree : 각각의 User Widget 은 트리 구조로 저장된다.
  • Panel Widget : 렌더링 되는 녀석은 아니지만, 자식 위젯들을 정렬하는데 사용된다. Canvas Panel, Horizontal Box 등이 있다.
  • Common Widget : 렌더링 되는 녀석들로, Button, Image, Text 등이 있다.

 

이제 용어를 정리했으니, 렌더링 프로세스를 알아보자.

게임 쓰레드에서, Slate Tick 은 Widget Tree 를 프레임당 2 번 훑는다.

 

Game Thread 에서의 틱

  • Prepass : 각 Widget 의 사이즈(Desired Size)를 계산하기 위해 트리를 아래에서 위로 순회한다.
  • OnPaint : 렌더링에 필요한 Draw Elements 를 계산하기 위해 트리를 위에서 아래로 순회한다. 이 과정에서, Common Widget 의 타입과 인자에 맞게 Vertex Buffer 가 생성된다. Common Widget 의 Render Transform 이 계산되어 Vertex Buffer 로 전달되고, Layer ID 나 Material 에 따라 합칠 수 있는 것들이 하나의 Batch 로 병합(Merge) 된다. 마지막으로, User Widget 은 Draw Elements 를 렌더링 쓰레드에게 전달하는데, Draw Elements 의 갯수가 곧 Draw Call 의 갯수가 된다.

 

렌더링 쓰레드에서, Slate Rendering 은 아래와 같이 2 스텝으로 나뉜다.

 

Rendering Thread 에서의 틱

  • Widget Render : UI 의 RTT(Render To Texture) 을 수행한다. 만약 Retainer Box 가 사용되었다면, Draw Elements 는 Retainer Box 의 Render Target 으로 렌더링된다.
  • Slate Render : Draw Elements 를 Back Buffer 에 넣는다. Retainer Box 가 사용되었다면, Retainer Box 에 맞는 텍스쳐가 Back Buffer 에 렌더링된다.

아쉽게도 위의 설명만 들으면 잘 이해가 가지 않는다. 그 이유는 생소한 용어가 등장했기 때문이다.

필자도 맨 처음 RTT? Retainer Box? 그게 뭐지? 라는 생각이 들었다. 이에 대해서 간단하게 설명하자면...

1. RTT(Render To Texture) 란?
UI 를 텍스쳐로 렌더링하여, 3D 씬에서 material 로 사용할 수 있도록 하는 최적화 기법이다. 복잡한 UI 를 미리 렌더링하여 간단한 텍스쳐로 나타냄으로써, GPU 의 로드를 줄이고 성능을 높일 수 있다.

2. Retainer Box 란?
사실 모든 UI는 매 프레임마다 렌더링(업데이트)될 필요가 없다. 그렇다면, 렌더링된 UI 를 캐싱해서 특정 간격(e.g. 5 프레임마다)으로 업데이트를 해주고 싶다는 생각이 들 수도 있는데... 이때, Retainer Box 를 사용하면 된다. 더 자세한 내용은 아래에 최적화 파트에서 더 자세히 다루도록 한다.

 

 

성능 체크

먼저, Stat Slate 커맨드를 치면, 아래와 같이 주요 지표에 대한 통계를 확인할 수 있다.

몇 가지 주의 깊게 살펴볼 인자를 보자. 아래 Counters 영역에서 보면 된다.

SWidget::Paint (Count) : 게임 쓰레드에서 OnPaint 가 불리는 위젯의 갯수

Num Batches : Draw Element 의 갯수(Draw Call 의 갯수를 의미)

그런데 사실 Stat Slate 명령어를 치면, 통계를 보여주기 위해 생성된 UI 까지 통계에 넣어버리므로, 부정확할 수 있다.

따라서,아래 명령어를 치면 조금 더 정확한 Overhead 측정이 가능하다.

// 120 프레임 동안 각 스레드의 통계 항목의 평균값을 출력
// 필터 조건: 스레드가 0.5ms 이상 걸림
stat dumpave -num=120 -ms=0.5

위 명령어를 치고, 출력 로그에 slate 로 필터를 걸어주면 아래처럼 로그가 찍힐 것이다.

  4.990ms (   2)  -  Total Slate Tick Time - STAT_SlateTickTime - STATGROUP_Slate - STATCAT_Advanced
    2.759ms (   1)  -  SlatePrepass - STAT_SlatePrepass - STATGROUP_Slate - STATCAT_Advanced
      1.610ms (  52)  -  SlatePrepass - STAT_SlatePrepass - STATGROUP_Slate - STATCAT_Advanced
    1.522ms (   1)  -  Draw Window And Children Time - STAT_SlateDrawWindowTime - STATGROUP_Slate - STATCAT_Advanced
      0.640ms (   1)  -  Game UI Paint - STAT_ViewportPaintTime - STATGROUP_Slate - STATCAT_Advanced
0.656ms (   1)  -  SlateDrawWindowsCommand - STATGROUP_RenderThreadCommands - STATCAT_Advanced

위의 로그에서 주의 깊게 살펴볼 STAT_SlateTickTime 이 Slate Tick 을 나타내는 것을 알 수 있다.

 

만약 성능 검증을 조금 더 정확하게 하고 싶다면, 이렇게 해 보자. SlateGlobals.h 에 보면 아래처럼 자세한 설명이 적혀 있다.

// HOW TO GET AN IN-DEPTH PERFORMANCE ANALYSIS OF SLATE
//
// Step 1)
//    Set WITH_VERY_VERBOSE_SLATE_STATS to 1.
//
// Step 2)
//    When running the game (outside of the editor), run these commandline options
//    in order and you'll get a large dump of where all the time is going in Slate.
//    
//    stat group enable slateverbose
//    stat group enable slateveryverbose
//    stat dumpave -root=stat_slate -num=120 -ms=0

위에서 설명한 대로 Verbose 옵션을 켜 주면, 조금 더 디테일한 로그를 얻을 수 있다(각 위젯 항목별로 병목을 측정할 수도 있을 것이다).

너무 길어지므로 아래 부분은 잘랐지만, 각 UI 위젯별로 걸리는 시간도 나온다

 

 

게임 쓰레드 최적화

Invalidation Box

Invalidation Box 를 이용해 위젯을 감싸면, 매 프레임마다 Slate Tick 연산을 하는 것을 방지할 수 있다.

Invalidation Box 아래에 있는 위젯들은 PrePass 와 OnPaint 연산 결과가 캐시되는데, 자식 위젯이 바뀔때, Invalidation Box는 그제서야 PrePass 및 OnPaint 연산을 다시 수행해 캐시를 새로 만든다.

Invalidation Box 의 옵션을 보면, Can Cache 옵션이 자동으로 선택되어 있는 것을 확인할 수 있다.

 

공식 문서에서는 인벨리데이션의 작동 순서를 아래와 같이 기술하고 있다.

Invalidation Type CPU 비용 설명
Voltile(휘발성)  또는 
비저빌리티(Visibility)
아주 낮음 휘발성 여부(Is Volatile) 또는 비저빌리티(Visibility) 플래그가 위젯과 그 자손에서 변경됩니다.
페인팅 (Paint) 낮음 휘발성 여부(Is Volatile) 또는 비저빌리티(Visibility) 플래그가 위젯과 그 자손에서 변경됩니다.
레이아웃 (Layout) 보통 페인팅 인밸리데이션의 효과에 더해 화면상에서 위젯의 크기와 위치가 변경됩니다. 위젯의 렌더 트랜스폼을 변경하면 발생합니다.
자손 (Child) 높음 레이아웃 인밸리데이션의 효과에 더해 슬레이트가 자손 위젯 목록을 리빌드합니다. 위젯과 자손의 완전한 재계산을 포함하며, 위젯에서 자손을 추가 또는 제거하면 발생합니다.

 

또한 Invalidation Box 를 사용할 때, 휘발성 여부를 고려해야 하는 경우도 있다.

즉, 휘발성을 체크하면 Invalidation Box 가 상위에 있더라도 해당 위젯을 캐시하지 않을 텐데... 어떤 경우에 이 옵션을 체크해야 할까?

만약, 특정 위젯이 매 프레임마다 갱신되어야 한다고 가정하자(예를 들어, 스태미나 수치라던가). 그런 경우에는, 어차피 해당 위젯은 매 프레임마다 렌더링되어야 하므로, 캐시를 하는 것이 불필요한 비용을 추가할 뿐일 것이다.

 

Visibility 설정

UI 작업을 조금 해 봤다면 이미 잘 알고 있겠지만, Widget 의 Visibility 설정에는 아래와 같이 5 가지가 존재한다.

  터치 가능 여부 화면 보임 여부 레이아웃 공간 차지 여부
Visible O O O
Collapsed X X X
Hidden X X O
HitTestInvisible X (자손까지 막음) O O
SelfHitTestInvisible X (자신만 막음) O O

이 때, 사용하지 않는 것들은 Hidden 보다는 Collapsed 로 두는 것이 좋다. 그 이유는, Collpased 된 녀석은 Layout 영역을 차지하지 않아 Prepass 연산이 수행되지 않기 때문이다!

또한 RemoveFromViewport / AddToViewPOrt / CreateWidget 같은 함수들은 비용이 매우 비싸다. 따라서 미리 만들어 놓고, SelfHitTestInvisible / Collapsed 설정 등을 통해 Visibility 를 조절하는 것이 더 권장되기도 한다.

참고로, Hidden 이거나 Collapsed 일 경우에는 위젯에서 NativeTick 이 불리지 않는다.

 

Widget Binding

바인드 어트리뷰트를 하게 되면, 틱마다 특정 변수의 값을 UI 에 할당하려고 시도한다.

이는 언리얼 공식문서에서 '절대 사용하지 말라'고 되어 있는 만큼, 사용하지 말자.

대신, 프로젝트가 UI 의 함수 및 이벤트를 호출하도록 만들 수 있다. 즉, HP 값을 Health 변수에 바인딩하지 말고, OnHealthChanged 이벤트를 만들어 UI 가 변경되도록 하라는 뜻이다.

 

 

렌더링 쓰레드 최적화

Merging Batches

GPU 성능이 올라가면서, Draw Call 의 수가 성능에 미치는 영향은 점점 줄어들고 있다. 다만 Draw Call 을 줄이면 GPU 가 호출하는 API 콜 수가 줄어서, 모바일 기기에서의 발열 문제를 완화할 수 있다.

Canvas Panel 등의 패널을 사용할 때, 언리얼은 ZOrder 에 따라 같은 ZOrder 을 가지고 있을 경우 Batch 를 병합한다.

Sprite 를 이용해 텍스쳐를 병합하여 사용하는 것도 권장되는 방식이다.

 

Retainer Box

Retainer Box 는, 간단히 말해 매 프레임마다 렌더링될 필요가 없을때, 자손 위젯에 대해 렌더링 되는 특정 간격을 부여할 수 있는 위젯이다.

렌더 규칙 옵션을 보면.. 결국 Phase Count 가 우리가 원하는 프레임 간격이라는 것을 알 수 있다.

즉, FPS 가 60Hz 라면, Phase Count 가 60일때, 해당 위젯은 1초마다 렌더링될 것이다.

 

그런데, 만약 버튼을 눌렀을 때는 뭔가 갱신이 바로 되었으면 좋겠다면..?

그럴 때는 Render on Invalidation 옵션을 켜 주면, 뭔가 인터랙션이 왔을 때 UI 가 바로 갱신될 것이다!

 

 

기타 최적화 기법

C++ 를 적극 활용하기

위젯을 C++ 클래스로 만들고, 블루프린트 위젯이 해당 C++ 클래스를 상속받도록 만들자. 또한 BindWidget 옵션을 이용하여 데이터를 연동할 수 있다.

 

Tick 을 꼭 사용해야 하는지 고민하자

Tick 을 꼭 사용해야 하는 경우가 아니라면, Tick 함수를 삭제하거나 Tick 옵션을 꺼 주자. 아래와 같이 간단하게 세팅할 수 있다.

UCLASS(meta = (DisableNativeTick))

UUserWidget 에서는 TickFrequency 를 설정할 수도 있더라(실제로 이 값을 조절할 일이 있으려나 싶긴 하다).

/** Determines what strategy we use to determine when and if the widget ticks. */
UENUM()
enum class EWidgetTickFrequency : uint8
{
	/** This widget never ticks */
	Never = 0,

	/** 
	 * This widget will tick if a blueprint tick function is implemented, any latent actions are found or animations need to play
	 * If the widget inherits from something other than UserWidget it will also tick so that native C++ or inherited ticks function
	 * To disable native ticking use add the class metadata flag "DisableNativeTick".  I.E: meta=(DisableNativeTick) 
	 */
	Auto,
};

/**
 * This widget is allowed to tick. If this is unchecked tick will never be called, animations will not play correctly, and latent actions will not execute.
 * Uncheck this for performance reasons only
 */
UPROPERTY(EditDefaultsOnly, BlueprintReadOnly, Category = "Performance", meta=(AllowPrivateAccess="true"))
EWidgetTickFrequency TickFrequency;

틱이 정말 필요없다면, 해당 변수의 모드를 바꾸는 것도 고려할 수 있다.

 

참고로, Actor 의 경우는 생성자에서 아래와 같이 처리한다.

// Set this character to call Tick() every frame.  You can turn this off to improve performance if you don't need it.	
PrimaryActorTick.bCanEverTick = true;

 

풀링 활용

런타임에 위젯을 동적으로 생성하기보다, 미리 풀링해서 사용해 보자.

 

매니저 클래스의 활용

Brush 나 Font 등 여러 곳에서 사용하는 리소스의 경우, 이를 종합적으로 관리하는 매니저 클래스를 만들어 보자.

 

SMeshWidget 활용 (심화)

SMeshWidget 클래스를 통해 커스텀 위젯을 만들어 사용해보자. Particle Emiiter 등에 적용하면, 버퍼 하나로 수많은 메시 인스턴스를 렌더링 할 수 있다.

 


참고 자료 :

1. UI optimization tips in Unreal Engine 4
2. 최적화 가이드라인
3. UI 인밸리데이션
4. 슬레이트 슬립 및 액티브 타이머
5. AAA게임_UI_최적화_및_빌드하기
6. What are Draw calls
 
Comments