https://coding-hell.tistory.com/78
https://topic.alibabacloud.com/a/ui-optimization-tips-in-unreal-engine-4_8_8_10274886.html
Slate Render Process
Game Thread
- Game Thread에서 Slate Tick은 Frame당 2번 WidgetTree를 순회한다.
- 한 번은 Paint할 Widget의 크기를 계산하기 위해 PrePass 과정에서.
- 다른 한 번은 Paint 과정에서 Draw Elements를 계산하기 위해 OnPaint 과정에서.
- 조금 더 풀어 설명하면 다음과 같다.
- Common Widget의 Type과 Parameter에 해당하는 Vertex Buffer 생성
- Widget의 Render Transform이 Vertex Buffer로 계산 되어 Layer ID 및 Material 정보에 따라 Batch Merge 수행
- 마지막 User Widget이 하나 이상의 Draw Elements 생성
- 각 Draw Element가 Draw call에 해당하는 Render Thread에 Draw Element를 전달
Render Thread
- Render Thread에서의 Slate Rendering 작업은 다음 순서를 거친다.
Widget Render
- UI의 RTT(Render to Texture) 수행
- Retainer Box를 사용하면 Draw Elements가 Retainer Box의 Retain Target으로 Rendering 된다.
Slate Render
- Draw Elements를 Back Buffer에 Render
- Retainer Box를 사용하는 경우, Retainer Box의 Texture Resource가 Back Buffer에 Rendering 된다.
Invalidation
- Slate Widget을 캐싱하고 Paint, Layout, 계층 정보 등의 변경점을 관리하는 기능.
- Widget의 위와 같은 정보가 변경되지 않으면, 매 Frame마다 Widget을 다시 그리는 대신 Cache를 출력한다.
- 위 정보들에 유효한 변경사항이 발생하면 Slate가 이를 재 계산하여 다시 그려준다.
Invalidation Box
- Child Widget의 Geometry를 Cache 하고 관리하는 UI
- 해당 Widget의 Geometry가 바뀌지 않는 한 Cache 된 Geometry로 대체되어 CPU 사용량을 크게 줄여준다.
- Invalidation Box는 감싸진 UI 뿐 아니라 그 하위 계층의 모든 UI에 대해 Geometry Cache를 진행합니다.
Global Invalidation
- SWindow의 Invalidation을 이용해 효과적으로 모든 UI를 Invalidation Box로 Wrapping하는 기능.
- 이 SWindow에 포함 된 모든 Invalidation Box는 무효화 되고, SWindow의 Invalidation Box만 동작하게 된다.
- Slate.EnableGlobalInvalidation을 true로 트리거하여 활성화
void SWidget::Invalidate(EInvalidateWidgetReason InvalidateReason)
{
SLATE_CROSS_THREAD_CHECK();
if (InvalidateReason == EInvalidateWidgetReason::None || !IsConstructed())
{
return;
}
SCOPED_NAMED_EVENT_TEXT("SWidget::Invalidate", FColor::Orange);
// Backwards compatibility fix: Its no longer valid to just invalidate volatility since we need to repaint to cache elements if a widget becomes non-volatile. So after volatility changes force repaint
if (EnumHasAnyFlags(InvalidateReason, EInvalidateWidgetReason::Volatility))
{
InvalidateReason |= EInvalidateWidgetReason::PaintAndVolatility;
}
if (EnumHasAnyFlags(InvalidateReason, EInvalidateWidgetReason::Prepass))
{
MarkPrepassAsDirty();
InvalidateReason |= EInvalidateWidgetReason::Layout;
}
if (EnumHasAnyFlags(InvalidateReason, EInvalidateWidgetReason::ChildOrder) || !PrepassLayoutScaleMultiplier.IsSet())
{
MarkPrepassAsDirty();
InvalidateReason |= EInvalidateWidgetReason::Prepass;
InvalidateReason |= EInvalidateWidgetReason::Layout;
}
const bool bVolatilityChanged = EnumHasAnyFlags(InvalidateReason, EInvalidateWidgetReason::Volatility) ? Advanced_InvalidateVolatility() : false;
if(FastPathProxyHandle.IsValid(this))
{
// Current thinking is that visibility and volatility should be updated right away, not during fast path invalidation processing next frame
if (EnumHasAnyFlags(InvalidateReason, EInvalidateWidgetReason::Visibility))
{
SCOPED_NAMED_EVENT(SWidget_UpdateFastPathVisibility, FColor::Red);
UpdateFastPathVisibility(FastPathProxyHandle.GetProxy().Visibility.MimicAsParent(), FastPathProxyHandle.GetInvalidationRoot_NoCheck()->GetHittestGrid());
}
if (bVolatilityChanged)
{
SCOPED_NAMED_EVENT(SWidget_UpdateFastPathVolatility, FColor::Red);
TSharedPtr<SWidget> ParentWidget = GetParentWidget();
UpdateFastPathVolatility(ParentWidget.IsValid() ? ParentWidget->IsVolatile() || ParentWidget->IsVolatileIndirectly() : false);
ensure(!IsVolatile() || IsVolatileIndirectly() || EnumHasAnyFlags(UpdateFlags, EWidgetUpdateFlags::NeedsVolatilePaint));
}
FastPathProxyHandle.MarkWidgetDirty_NoCheck(InvalidateReason);
}
else
{
#if WITH_SLATE_DEBUGGING
FSlateDebugging::BroadcastWidgetInvalidate(this, nullptr, InvalidateReason);
#endif
UE_TRACE_SLATE_WIDGET_INVALIDATED(this, nullptr, InvalidateReason);
}
}
Retainer Panel
- Child Widgets을 유저의 화면에 Render 하기 전에 하나의 Texture로 병합
- 여기서 Phase는 Render를 시작하는 Frame, Phase Count는 Render가 되는 Frame 주기이다.
- 예를 들어 위의 경우 0 Frame에서 시작하여 3 Frame 단위로 Retainer Panel의 Child Widgets이 Render 된다.
- 이러한 기능들은 매 Frame마다 호출되는 UI의 Draw call을 줄여주는 기능을 제공한다.
- 하지만 Retainer Panel은 다시 그려질 때 큰 overhead를 가지면서,
Widget 개개인이 점유하는 Memory가 Invalidation Box보다 크다.- 이는 Retainer Panel이 각 Widget의 Invalidation Data 뿐 아니라 고유한 Render Target도 가지기 때문이다.
- 때문에 UI의 CPU 점유율을 낮추려 한다면 우선 Invalidation Box부터 써야 한다.
- 그럼에도 Draw Call을 줄이고 싶다면, Retainer Panel을 이용함으로 CPU 사용량을 더 압축할 수 있다.
- 이는 성능 제약이 빡빡한 저성능 모바일에서 유용다.
How Invalidation works
- Widget이 화면에 그려질 때 다음 작업들이 순차적으로 발생한다.
- 계층 구조 - Slate가 root widget과 그들의 child를 모두 포함한 계층에 따라 widget tree를 생성한다.
- Layout - Slate가 Render Transform을 기반으로 Widget의 크기와 스크린 상 위치를 계산한다.
- Paint - Slate가 각 Widget의 Geometry를 계산한다.
- 각 작업들은 수행 할 때 뒤이은 작업을 반드시 수행해야 한다.
- 예를 들어 계층 구조 작업이 수행되면 반드시 Layout, Paint 작업이 수행되어야 한다.
- Invalidation System은 위 과정에서 발생하는 모든 데이터를 Memory에 Cache한다.
- Widget이 Invalidation Box를 사용하든, SWidget의 Global Invalidation을 사용하든.
- Memory에 Cache된 Widget들은 변경사항이 발생하지 않는 한 재연산을 하지 않는다.
- 만약 변경사항이 발생하게 되면 Dirty List에 추가되고,
이 List의 Widget들은 다음 Frame에서 재연산이 이루어진다.
- 만약 변경사항이 발생하게 되면 Dirty List에 추가되고,
- Invalidation에서 Cache된 Data를 갱신하게 되는 타입은 대략 다음과 같다.
/**
* The different types of invalidation that are possible for a widget.
*/
enum class EInvalidateWidgetReason : uint8
{
None = 0,
/**
* Use Layout invalidation if your widget needs to change desired size. This is an expensive invalidation so do not use if all you need to do is redraw a widget
*/
Layout = 1 << 0,
/**
* Use when the painting of widget has been altered, but nothing affecting sizing.
*/
Paint = 1 << 1,
/**
* Use if just the volatility of the widget has been adjusted.
*/
Volatility = 1 << 2,
/**
* A child was added or removed. (this implies prepass and layout)
*/
ChildOrder = 1 << 3,
/**
* A Widgets render transform changed
*/
RenderTransform = 1 << 4,
/**
* Changing visibility (this implies layout)
*/
Visibility = 1 << 5,
/**
* Attributes got bound or unbound (it's used by the SlateAttributeMetaData)
*/
AttributeRegistration = 1 << 6,
/**
* Re-cache desired size of all of this widget's children recursively (this implies layout)
*/
Prepass = 1 << 7,
/**
* Use Paint invalidation if you're changing a normal property involving painting or sizing.
* Additionally if the property that was changed affects Volatility in anyway, it's important
* that you invalidate volatility so that it can be recalculated and cached.
*/
PaintAndVolatility = Paint | Volatility,
/**
* Use Layout invalidation if you're changing a normal property involving painting or sizing.
* Additionally if the property that was changed affects Volatility in anyway, it's important
* that you invalidate volatility so that it can be recalculated and cached.
*/
LayoutAndVolatility = Layout | Volatility,
/**
* Do not use this ever unless you know what you are doing
*/
All UE_DEPRECATED(4.22, "EInvalidateWidget::All has been deprecated. You probably wanted EInvalidateWidget::Layout but if you need more than that then use bitwise or to combine them") = 0xff
};
- Invalidation은 자주 변경되지 않은 UI에서 최적화된 기능이다.
- Widget이 오랫동안 변경되지 않을수록 Slate가 Cache Data를 오래 들고 있고, 그 만큼 CPU 부하를 줄여준다.
- 이는 특히 MMORPG나 라이브 서비스 게임의 Depth가 있는 메뉴와 같이 규모가 크고 복잡한 UI 작업에서 중요하다.
Volatile Widget
- 간혹 특정 Widget은 가능한 매 Frame마다 업데이트가 되어야 하는 경우가 있다.
- 이 경우 Widget은 변경사항이 있을 때마다 매 tick이 Invalidate 된다.
- 하지만 CPU 부하는 Invalidation을 사용할 때와 동일하게 발생한다.
- 더불어 Hiearchy를 Cache하기 위해 Memory도 점유하게 된다.
- 이를 해결하기 위해 자주 사용하는 Widget에 Volatile 선언을 해주는 것이 좋다.
- Volatile 선언이 된 Widget과 그 Child Widget들은 Paint Data가 Cache되지 않는다.
- 비록 Geometry는 매 Frame마다 재계산되어 다시 그려지겠지만,
Slate는 직접적인 변경사항이 없는 이상 Layout 계산은 계속해서 Skip한다. - 이는 UI를 전반적으로 Invalidation 하고 싶지만,
자주 갱신되는 소수의 Widget으로 인해 이득을 보지 못할 때 유용하다.
개발자가 주의해야 할 점
OnTick/OnPaint 사용 지양
- OnTick이나 OnPaint에서 작업을 하게 되면 그 작업이 매 Frame마다 호출하게 됨.
- 가급적 Event Dispatch나 Delegate를 이용할 것을 권장
Attribute Bind 대신 Event-driven Update 사용
- Unreal Engine에서 제공하는 Attribute Bind 역시 매 Frame마다 할당 됨.
- 이 역시 Event와 Delegate를 이용해 변경사항이 있을 때에만 Widget에 적용 되도록 작업할 것을 권장 함.
Widget Construction
Reduce unused widgets
- Widget의 모든 child는 시각화 여부와 무관하게 항상 construct 됨.
- 이는 Render가 되지 않더라도 Loading time, Construction time, Memory를 점유한다는 의미.
- 때문에 당장 사용되지 않는 Widget들은 Hierarchy에서 제거하는 것이 옳다.
Break complex widgets
- 특히 Main System에서 사용되는 Widget의 경우 실제로 표시되는 것은 몇 없지만 Cild가 수 천개씩 있는 경우가 있다.
- 이러한 Widget을 한꺼번에 Load하게 되면 불필요한 Child로 인해 Loading이 지연되고 Memory를 점유하게 된다.
- 때문에 일정 규모 이상의 Widget은 어느정도 Child Widget을 세분화 하는 것이 좋다.
- 예를 들어, 다음과 같이 Widget을 구분하여 작업 방향성을 정할 수 있다.
- 항상 표시되는 Widget
- BaseWidget과 같이 Load하면서 화면에 바로 출력
- 가능한 빨리 표시되어야 하는 Widget
- 당장 사용하지 않을 수도 있지만 반응성이 높아야 함.
- BaseWidget과 같이 Load 하되, 화면에는 출력하지 않고 Visibility로 컨트롤.
- 조금 늦게 표시되어도 괜찮은 Widget
- 가끔 사용되거나 아얘 사용되지 않는 경우도 있음.
- BaseWidget과 별개로 필요할 때마다 Async Load.
- 항상 표시되는 Widget
- 이러한 방식은 Memory를 크게 절약할 뿐 아니라 Load 할 때의 CPU 영향도 줄일 수 있다.
Layout
CanvasPanel 사용 지양
- CanvasPanel은 좌표 평면과 Widget 별 Anchor를 이용해 다른 Widget의 위치를 지정할 수 있는 강력한 Widget이다.
- 이는 Widget을 원하는 위치에 정확하게 지정하면서,
동시에 Screen의 외각을 기준으로 Widget의 위치를 유지할 수 있다.
- 이는 Widget을 원하는 위치에 정확하게 지정하면서,
- 하지만 그와 동시에 높은 성능을 요구하는 Widget이기도 하다.
- Slate의 Draw call은 Widget의 Layer ID별로 발생한다.
- VerticalBox나 HorizontalBox등의 다른 Container Widget들은 Child Widget의 Layer ID를 통합한다.
- 하지만 CanvalsPanel은 Child Widget이 필요할 때 다른 Widget 위에 Render 될 수 있도록 ID를 증가시킨다.
- 결과적으로 CanvasPanel은 단기로 여러 개의 Draw call을 발생시켜 높은 CPU 사용량을 요구하게 된다.
- 비록 CanvasPanel보다 용처가 제한적이지만, OverlayPanel 역시 Draw call을 증가시킨다.
- GridPanel은 Slot마다 Layer를 직접 지정할 수 있지만, 보통은 Child를 Iterate하며 LayerID를 새로 부여한다.
- ScrollBox는 Scroll Bar가 Layer ID를 증가시킨다.
- Border 역시 Layer ID를 증가시킨다.
- HUD나 Menu System의 Root Widget으로 CanvasPanel을 사용하는 것은 크게 문제가 되지 않는다.
- 이 경우에는 상세한 위치 조정이나 복잡한 Z-Order 배치가 필요할 가능성이 높기 때문이다.
- 다만 Template 속성이 있는 Widget은 CanvasPanel 위에서 작업하는 것을 지양해야 한다.
- TextBox, Custom Button과 같이 다른 Widget의 구성요소로 사용되는 Custom Widget들
- 또는 CanvasPanel을 다수의 Layer에서 과도하게 사용하면, 최종 Layer를 혼돈하기 쉽다.
- 일반적으로 하나의 요소로 구성된 Widget은 Canvas Panel로 감쌀 필요가 전혀 없다.
- 또한 HUD나 Menu 같은 경우에도,
Overlay나 SizeBox를 HorizontalBox, VerticalBox, GridBox와 같이 사용하여 CanvasPanel 사용을 대체할 수 있다.
SizeBox 대신 가능한 Spacer 사용
- SizeBox는 자신의 크기를 계산하고 Render하는데 다양한 값을 사용한다.
- 만약 Widget이 특정 Width와 Height를 고정적으로 가진다면, Spacer가 훨씬 가볍다.
ScaleBox와 SizeBox를 같이 사용하지 않기
- ScaleBox와 SizeBox를 같이 사용하면 매 Frame마다 각자 서로의 Size를 오가며 Update하는 Loop에 빠지게 된다.
- 이 둘에 의존하기 보다는 Layout이 Content의 Native Size에 따라 동작하도록 만드는 것이 적절하다.
RichTextWidget 사용 지양
- RichTextWidget은 강력한 Format 지정 기능을 제공하는 만큼 표준 TextBox보다 훨씬 무겁다.
- 만약 RichTextWidget의 모든 기능이 필요한게 아니라 디자인적인 표현만이 필요하다면,
원하는 외형을 표현할 수 있는 Font를 제작하여 TextWidget에서 사용하는 것이 훨씬 가볍다.
Visibility
- 화면 출력되어야 하는 Widget이 Visible인 경우, 클릭 시 Click 반응이 동작해 오버헤드가 발생 함.
- visible 대신 HitTestInvisible, SelfHitTestInvisible을 사용할 것을 권장
- Hidden의 경우 화면에 보이지 않더라도 영역을 차지하기 위해 Layout Space를 사용 함.
- Layout Space를 사용하면 Prepass 계산이 매 Frame마다 수행 됨.
- 완전히 화면에서 숨기면서 영역을 차지할 필요가 없다면 Collapse를 사용하는 것을 권장.
Texture
Merged Texture
- Widget에서 여러 개의 Texture를 사용하는 경우, Texture의 갯수만큼 Draw call이 발생한다.
- 때문에 가능하면 Merge Texture 1개를 사용하는 것이 좋다.
- Widget의 Sprite를 사용하면 Merged Texture를 사용하거나 편집할 수 있다.
Atlas Group
https://velog.io/@devkcy/ue5-textureatlas
- Atlas를 효율적으로 사용하는 방안
- 위 Merged Texture를 Sprite로 사용하는 것도 Atlas를 거친다.
- 보통 Texture는 2의 지수로 저장이 된다.
- 300 * 300인 경우 실제 크기는 512 * 512가 된다.
- 이 때 필요한 영역 외의 Pixel 너비 만큼 메모리를 낭비하게 된다.
- Atlas Group은 이런 Texture를 하나의 Texture로 묶는 방식이다.
- 이는 같은 Widget에서 사용되는 Image들에게 주로 사용되며, Context Switching을 줄이는 효과가 있다.
- 반대로 다른 Widget의 Image를 묶어주면 Context Switch가 증가한다.
- 일반적인 Atlas Group은 2048 * 2048 크기를 가진다.
Animation Cost
Material만 있는 Animation
- GPU로 처리되기 때문에 CPU 비용이 발생하지 않는다.
- 발광 이펙트, 배경 Scroll, Material 변화로만 표현 가능한 Effect들이 포이에 포함된다.
- Animation을 Material에 포함시킬 수 있다면, 가장 우선적으로 사용하는 것을 권장한다.
Blueprint로 작업 되었지만 Sequencer가 필요 없는 Animation
- 실행 비용은 거의 같지만,
Sequencer Animation은 실행 전 Animation Object Initialize와 담당 Property Path 해석이 요구된다.- 즉, Sequencer Animation이 BP Animation보다 CPU 비용이 조금 더 높다.
- 비교적 짧고 자주 사용되는 Animation의 경우 Sequencer를 지양하고 BP Script로 작업할 것을 권장.
UMG의 Animation Editor로 만들어진 Sequencer Animation
- UMG의 Animation Editor은 Sequencer 구현체이다.
- Color 같이 Attribute를 변경하는 경우에는 Widget을 다시 그리게 하지만 Layout을 Invalidate 하지 않는다.
- 반대로 Render Transform의 변화를 가져오는 모든 Animation은 Layout을 Invalidate한다.
- 가급적 이러한 작업은 피하거나, Volatile 선언을 해줘야 한다.
Layout 변경을 유발하는 Animation
- CPU 비용이 가장 높음
기타 최적화 방안
Switch Material
- 성능이 낮은 Machine에서 Material Effect의 부하를 줄이거나 제거하는 방안
- DYNAMIC_MULTICAST Framework를 사용
Manager Class
- 모든 USerWidget과 Brush, Font등 UI Resource를 관리하는 Manager Class를 만드는 것을 권장.
Free Texture Memory
- Texture를 미리 Setting 하는 것이 아니라 코드를 통해 수동으로 Load/Set/Destroy 하는 것을 전제
- Editor에서는 Texture를 설정하지 않으면 CDO에서 이 Texture 객체를 참조하는 것을 피할 수 있다.
- CDO Reference는 ShardPtr의 Reference Count를 최소 1로 만들어 앱이 종료될 때까지 제거되지 않는다.
- Editor에서 Image Property가 설정되어 있고 Texture를 삭제하는 경우,
CookStage에서 UImage와 UTexture 사이 Reference를 제거하여 UserWidget의 CDO가
UTexture를 Reference 하지 않아야 한다.
void UWeakRefImage::Serialize(FArchive& Ar)
{
Super::Serialize(Ar);
if (Ar.IsCooking() && Ar.IsSaving())
{
// Let's suppose user always intend to set a valid resource texture here.
UObject* ImageRes Brush.GetResourceObject();
if (ImageRes != nullptr)
{
ImageWeakRef = ImageRes;
Brush.SetResourceObject(nullptr);
SetBrush(Brush);
}
}
Ar << ImageWeakRef;
}
- 이 외에 Texture를 Load하는 코드는 다음과 같다.
void UWeakRefImage::LoadTextureResource(bool bAsync/*= true*/)
{
if (ImageWeakRef.ResolveObject() || (!bAsync && ImageWeakRef.TryLoad()))
{
Brush.SetResourceObject(ImageWeakRef.ResolveObject());
SetBrush(Brush);
}
else
{
if (StreamableMgr == nullptr)
{
StreamableMgr = new FStreamableManager();
}
StreamableMgr->RequestAsyncLoad(ImageWeakRef,
FStreamableDelegate::CreateUObject(this, &UWeakRefImage:: LoadTextureDeferred));
}
}
void UWeakRefImage::LoadTextureDeferred()
{
if (ImageWeakRef.ResolveObject())
{
Brush.SetResourceObject(ImageWeakRef.ResolveObject());
SetBrush (Brush);
}
}
- Texture Unload하는 코드는 다음과 같다.
void UWeakRefImage::UnloadTextureResource()
{
TSharedPtr<FSlateRenderer> Renderer = FSlateApplicationBase::Get().GetRenderer();
if (Renderer.IsValid())
{
FSlateApplicationBase::Get().GetRenderer()->ReleaseDynamicResource(Brush);
FSlateApplicationBase::Get().GetRenderer()->ReleaseAccessedResources(true);
}
Brush.SetResourceObject(nullptr);
SetBrush (Brush);
}
3D RTT 최적화
- SceneCaptureComponent2D는 매 Frame에서 tick이 호출된다.
- 매 Frame마다 Image Update가 발생하는 것을 취소할 수는 있다.
- 이 때 Animation의 Update 빈도는 30 fps 정도로 충분하므로 BP에서 Tick 간격을 설정할 수 있다.
- 이 경우 BP에서 Capture을 수동으로 호출해야 한다.
- 이와 별개로, SceneCapturecomponent2D는 Render Target이 크면 그 자체만으로 성능이 악화된다.
'UE5 > UI' 카테고리의 다른 글
[UI] Common UI FAQ (0) | 2024.07.10 |
---|---|
[UI] CommonUI Technical Guide (0) | 2024.07.10 |
[UI] Common UI Widget (0) | 2024.07.10 |
[UI] Common UI Introduction (0) | 2024.07.10 |
[UI] UMG ViewModel (0) | 2024.07.08 |