Retainer Box
- 핵심 메커니즘 : 자식 위젯을 화면에 직접 렌더링하는 대신, 중간 단계인 렌더 타켓이라는 텍스처에 렌더링한 다음, 그 렌더 타겟을 최종적으로 화면에 표시하는 것이다. ⇒ 리테이너 박스에 의해 감싸져 있는 위젯은 모든 프레임보다 느리게 다시 그리도록 만들 때 사용 ⇒ 리테이너 박스는 한 장의 텍스처처럼 만들어서 그리는 박스라는 개념에 가까움.
- 성능 최적화 및 캐싱
- 독립적인 렌더링 주기 제어 : Retainer Box는 Phase와 PhaseCount 설정을 통해 자식 위젯의 렌더링 빈도와 시작 시점을 메인 게임 렌더링 루프와 독립적으로 제어할 수 있다.
- 예시 : 게임이 초당 60프레임으로 렌더링되더라도, 변경이 거의 없는 UI 요소를 Retainer Box로 감싸고, PhaseCount를 2로 설정하면 해당 UI요소는 매 프레임이 아닌 격 프레임(30Hz)마다 한 번만 다시 그려짐.
- 캐싱 효과 : 자식 위젯이 렌더 타켓에 한번 그려지면, 다음 렌더링 주기가 돌아올 때까지는 해당 텍스처(렌더 타켓)을 단순히 재사용한다. 이는 매우 복잡하지만 자주 변하지 않는 UI 부분의 CPU 및 Draw Call 부하를 크게 줄여 성능을 최적화하는데 도움을 준다.
- 렌더 타켓에 Material 적용 (Post Process)
- 간단한 Post Processing 효과 : 자식 위젯들이 렌더 타켓에 그려진 후에, 이 텍스처를 입력으로 받아 자동으로 커스텀 재질을 적용할 수 있음.
- 다양한 시각 효과 구현 : 이를 통해 블러, 색상 조정, 왜곡, 외곽선 등과 같은 다양한 포스트 프로세싱 효과를 해당 UI 영역에 쉽게 적용할 수 있음.
- 위젯 계층 구조적 특징
- Retainer Box는 다른 컨테이너 위젯과 마찬가지로 하나의 자식 위젯을 가짐.
- 복잡한 UI를 최적화하려면, 여러 자식 위젯들을 하나의 다른 컨테이너(예: Vertical Box/Border 등)로 묶은 다음 그 컨테이너를 Retainer Box의 자식으로 사용해야함.
UE_DEPRECATED(5.2, "Direct access to Phase is deprecated. Please use the getter. Note that this property is only set at construction and is not modifiable at runtime.")
UPROPERTY(EditAnywhere, BlueprintReadOnly, Getter, Category="Render Rules", meta=(UIMin=0, ClampMin=0))
int32 Phase;
UE_DEPRECATED(5.2, "Direct access to PhaseCount is deprecated. Please use the getter. Note that this property is only set at construction and is not modifiable at runtime.")
UPROPERTY(EditAnywhere, BlueprintReadOnly, Getter, Category="Render Rules", meta=(UIMin=1, ClampMin=1))
int32 PhaseCount;
UPROPERTY(EditAnywhere, BlueprintReadWrite, Getter = "GetEffectMaterialInterface", Setter, BlueprintSetter = "SetEffectMaterial", Category = "Effect")
TObjectPtr<UMaterialInterface> EffectMaterial
TSharedPtr<class SRetainerWidget> MyRetainerWidget;
- 실제 렌더링 부분 - SRetainerWidget
- OnPaint → 그릴지 말지를 결정하는 최종 분기 (최적화 필터)
- Grid 를 추가하고 관리하는 이유는 화면에 인터랙션의 유무를 결정하기 위함.
- MakeBox 함수 내부에서 각 위젯의 슬레이트 정보를 그림을 그려냄.
- PaintRetainedContentImpl → 언제 다시 렌더할지를 결정하는 내부 정책
int32 SRetainerWidget::OnPaint(const FPaintArgs& Args, const FGeometry& AllottedGeometry, const FSlateRect& MyCullingRect, FSlateWindowElementList& OutDrawElements, int32 LayerId, const FWidgetStyle& InWidgetStyle, bool bParentEnabled) const
{
STAT(FScopeCycleCounter PaintCycleCounter(MyStatId););
SRetainerWidget* MutableThis = const_cast<SRetainerWidget*>(this);
bool bShouldRetainRendering = bEnableRetainedRendering;
#if WITH_EDITOR
bool bShouldSkipDesignerRendering = bIsDesignTime && (!bShowEffectsInDesigner || !GEnableDesignerRetainedRendering);
bShouldRetainRendering = bEnableRetainedRendering && !bShouldSkipDesignerRendering;
#endif
if (bShouldRetainRendering && IsAnythingVisibleToRender())
{
SCOPE_CYCLE_COUNTER(STAT_SlateRetainerWidgetPaint);
const bool bHittestCleared = HittestGrid->SetHittestArea(Args.RootGrid.GetGridOrigin(), Args.RootGrid.GetGridSize(), Args.RootGrid.GetGridWindowOrigin());
if (bHittestCleared)
{
MutableThis->RequestRender();
}
HittestGrid->SetOwner(this);
HittestGrid->SetCullingRect(MyCullingRect);
FPaintArgs NewArgs = Args.WithNewHitTestGrid(HittestGrid.Get());
NewArgs.GetHittestGrid().SetUserIndex(Args.RootGrid.GetUserIndex());
FSlateInvalidationContext Context(OutDrawElements, InWidgetStyle);
Context.bParentEnabled = bParentEnabled;
Context.bAllowFastPathUpdate = true;
Context.LayoutScaleMultiplier = GetPrepassLayoutScaleMultiplier();
Context.PaintArgs = &NewArgs;
Context.IncomingLayerId = LayerId;
Context.CullingRect = MyCullingRect;
EPaintRetainedContentResult PaintResult = MutableThis->PaintRetainedContentImpl(Context, AllottedGeometry, LayerId);
#if WITH_SLATE_DEBUGGING
if (PaintResult == EPaintRetainedContentResult::NotPainted
|| PaintResult == EPaintRetainedContentResult::TextureSizeZero
|| PaintResult == EPaintRetainedContentResult::TextureSizeTooBig)
{
MutableThis->SetLastPaintType(ESlateInvalidationPaintType::None);
}
#endif
if (PaintResult == EPaintRetainedContentResult::TextureSizeTooBig)
{
return SCompoundWidget::OnPaint(Args, AllottedGeometry, MyCullingRect, OutDrawElements, LayerId, InWidgetStyle, bParentEnabled);
}
else if (PaintResult == EPaintRetainedContentResult::TextureSizeZero)
{
return GetCachedMaxLayerId();
}
else
{
UTextureRenderTarget2D* RenderTarget = RenderingResources->RenderTarget;
check(RenderTarget);
if (RenderTarget->GetSurfaceWidth() >= 1 && RenderTarget->GetSurfaceHeight() >= 1)
{
const FLinearColor ComputedColorAndOpacity(Context.WidgetStyle.GetColorAndOpacityTint() * GetColorAndOpacity() * SurfaceBrush.GetTint(Context.WidgetStyle));
const FLinearColor PremultipliedColorAndOpacity(ComputedColorAndOpacity * ComputedColorAndOpacity.A);
FWidgetRenderer* WidgetRenderer = RenderingResources->WidgetRenderer;
UMaterialInstanceDynamic* DynamicEffect = RenderingResources->DynamicEffect;
const bool bDynamicMaterialInUse = (DynamicEffect != nullptr);
if (bDynamicMaterialInUse)
{
DynamicEffect->SetTextureParameterValue(DynamicEffectTextureParameter, RenderTarget);
}
FSlateDrawElement::MakeBox(
*Context.WindowElementList,
Context.IncomingLayerId,
AllottedGeometry.ToPaintGeometry(),
&SurfaceBrush,
ESlateDrawEffect::PreMultipliedAlpha | ESlateDrawEffect::NoGamma,
FLinearColor(PremultipliedColorAndOpacity.R, PremultipliedColorAndOpacity.G, PremultipliedColorAndOpacity.B, PremultipliedColorAndOpacity.A)
);
}
const bool bInheritedHittestability = Args.GetInheritedHittestability();
const bool bOutgoingHittestability = bInheritedHittestability && GetVisibility().AreChildrenHitTestVisible();
if (bOutgoingHittestability)
{
Args.GetHittestGrid().AddGrid(HittestGrid);
}
return GetCachedMaxLayerId();
}
}
else
{
return SCompoundWidget::OnPaint(Args, AllottedGeometry, MyCullingRect, OutDrawElements, LayerId, InWidgetStyle, bParentEnabled);
}
}

SRetainerWidget::EPaintRetainedContentResult SRetainerWidget::PaintRetainedContentImpl(const FSlateInvalidationContext& Context, const FGeometry& AllottedGeometry, int32 LayerId)
{
if (RenderOnPhase)
{
if (LastTickedFrame != GFrameCounter && (GFrameCounter % PhaseCount) == Phase)
{
bRenderRequested = true;
InvalidateRootLayout();
}
}
const FPaintGeometry PaintGeometry = AllottedGeometry.ToPaintGeometry();
FSlateRenderTransform AccumulatedRenderTransform = PaintGeometry.GetAccumulatedRenderTransform();
const FVector2f RenderSize = FVector2f(PaintGeometry.GetLocalSize()) * AccumulatedRenderTransform.GetMatrix().GetScale().GetVector();
const FIntPoint RoundedRenderSize = RenderSize.IntPoint();
if (RenderOnInvalidation)
{
bRenderRequested = true;
const FIntPoint ClipRectSize = Context.CullingRect.GetSize().IntPoint();
const TOptional<FSlateClippingState> ClippingState = Context.WindowElementList->GetClippingState();
const FColor ColorAndOpacityTint = Context.WidgetStyle.GetColorAndOpacityTint().ToFColor(false);
if (RoundedRenderSize != PreviousRenderSize
|| AllottedGeometry != PreviousAllottedGeometry
|| AllottedGeometry.GetAccumulatedRenderTransform() != PreviousAllottedGeometry.GetAccumulatedRenderTransform()
|| ClipRectSize != PreviousClipRectSize
|| ClippingState != PreviousClippingState)
{
PreviousRenderSize = RoundedRenderSize;
PreviousAllottedGeometry = AllottedGeometry;
PreviousClipRectSize = ClipRectSize;
PreviousClippingState = ClippingState;
InvalidateRootLayout();
}
if (LayerId != LastIncomingLayerId
|| ColorAndOpacityTint != PreviousColorAndOpacity)
{
LastIncomingLayerId = LayerId;
PreviousColorAndOpacity = ColorAndOpacityTint;
GetRootWidget()->Invalidate(EInvalidateWidgetReason::Paint);
}
}
else if (RoundedRenderSize != PreviousRenderSize)
{
bRenderRequested = true;
InvalidateRootLayout();
PreviousRenderSize = RoundedRenderSize;
}
if (Shared_MaxRetainerWorkPerFrame > 0)
{
if (Shared_RetainerWorkThisFrame.TryGetValue(0) > Shared_MaxRetainerWorkPerFrame)
{
Shared_WaitingToRender.AddUnique(this);
return EPaintRetainedContentResult::Queued;
}
}
if (bRenderRequested)
{
UWorld* TickWorld = OuterWorld.Get();
if (TickWorld && TickWorld->Scene && IsInGameThread())
{
FSlateApplication::Get().GetRenderer()->RegisterCurrentScene(TickWorld->Scene);
}
else if (IsInGameThread())
{
FSlateApplication::Get().GetRenderer()->RegisterCurrentScene(nullptr);
}
Shared_RetainerWorkThisFrame = Shared_RetainerWorkThisFrame.TryGetValue(0) + 1;
LastTickedFrame = GFrameCounter;
const double TimeSinceLastDraw = FApp::GetCurrentTime() - LastDrawTime;
const uint32 RenderTargetWidth = FMath::RoundToInt(FMath::Abs(RenderSize.X));
const uint32 RenderTargetHeight = FMath::RoundToInt(FMath::Abs(RenderSize.Y));
const bool bTextureIsTooLarge = FMath::Max(RenderTargetWidth, RenderTargetHeight) > GetMax2DTextureDimension();
const bool bTextureSizeZero = (RenderTargetWidth == 0 || RenderTargetHeight == 0);
if (bTextureIsTooLarge || bTextureSizeZero)
{
if (!bInvalidSizeLogged)
{
bInvalidSizeLogged = true;
const bool bEnableWarnOnInvalidSize =
#if WITH_EDITOR
bWarnOnInvalidSize;
#else
true;
#endif
if (bTextureIsTooLarge)
{
if (bEnableWarnOnInvalidSize)
{
UE_LOG(LogUMG, Warning, TEXT("The requested size for SRetainerWidget is too large. W:%i H:%i"), RenderTargetWidth, RenderTargetHeight);
}
}
else
{
if (bEnableWarnOnInvalidSize)
{
UE_LOG(LogUMG, Warning, TEXT("The requested size for SRetainerWidget is 0. W:%i H:%i"), RenderTargetWidth, RenderTargetHeight);
}
}
}
return bTextureIsTooLarge ? EPaintRetainedContentResult::TextureSizeTooBig : EPaintRetainedContentResult::TextureSizeZero;
}
bInvalidSizeLogged = false;
if (RenderTargetWidth >= 1 && RenderTargetHeight >= 1)
{
const FVector2D ViewOffset = PaintGeometry.GetAccumulatedRenderTransform().GetTranslation();
UTextureRenderTarget2D* RenderTarget = RenderingResources->RenderTarget;
FWidgetRenderer* WidgetRenderer = RenderingResources->WidgetRenderer;
if ( MyWidget->GetVisibility().IsVisible() )
{
if ( (int32)RenderTarget->GetSurfaceWidth() != (int32)RenderTargetWidth ||
(int32)RenderTarget->GetSurfaceHeight() != (int32)RenderTargetHeight )
{
if(RenderTarget->GameThread_GetRenderTargetResource() && RenderTarget->OverrideFormat == PF_B8G8R8A8)
{
RenderTarget->ResizeTarget(RenderTargetWidth, RenderTargetHeight);
}
else
{
const bool bForceLinearGamma = false;
RenderTarget->InitCustomFormat(RenderTargetWidth, RenderTargetHeight, PF_B8G8R8A8, bForceLinearGamma);
RenderTarget->UpdateResourceImmediate();
}
}
const float Scale = AllottedGeometry.Scale;
const FVector2D DrawSize = FVector2D(RenderTargetWidth, RenderTargetHeight);
SurfaceBrush.ImageSize = DrawSize;
WidgetRenderer->ViewOffset = -ViewOffset;
WidgetRenderer->SetIsPrepassNeeded(false);
FVector2f WindowSize(RenderSize);
SWindow* PaintWindow = Context.WindowElementList->GetPaintWindow();
if (PaintWindow)
{
const FVector2f ViewportSize = PaintWindow->GetViewportSize();
WindowSize.X = FMath::Max(WindowSize.X, ViewportSize.X);
WindowSize.Y = FMath::Max(WindowSize.Y, ViewportSize.Y);
}
VirtualWindow->Resize(WindowSize);
bool bRepaintedWidgets = WidgetRenderer->DrawInvalidationRoot(VirtualWindow, RenderTarget, *this, Context, GDeferRetainedRenderingRenderThread != 0);
bRenderRequested = false;
Shared_WaitingToRender.Remove(this);
LastDrawTime = FApp::GetCurrentTime();
return bRepaintedWidgets ? EPaintRetainedContentResult::Painted : EPaintRetainedContentResult::NotPainted;
}
}
}
return EPaintRetainedContentResult::NotPainted;
}
- Retainer Box에서 사용하는 Enum class(EInvalidateWidgetReason)
enum class EInvalidateWidgetReason : uint8
{
None = 0,
Layout = 1 << 0,
Paint = 1 << 1,
Volatility = 1 << 2,
ChildOrder = 1 << 3,
RenderTransform = 1 << 4,
Visibility = 1 << 5,
AttributeRegistration = 1 << 6,
Prepass = 1 << 7,
PaintAndVolatility = Paint | Volatility,
LayoutAndVolatility = Layout | Volatility,
};
결론
- Retainer Box는 사전에 Phase/PhaseCount 를 지정할 수 있는데, 현재 전역 프레임을 모드 연산으로 나누어 해당 페이즈와 같다면, 리테이너 박스에 놓여진 위젯을 렌더타켓에 그림을 그려놓는 방식으로 최적화한다.
- 렌더 타켓을 사용하면, 각 자식 객체의 위젯에 대해 렌더링하는 것이 아닌 자식 객체의 복잡한 요소를 단순히 2D 텍스처에 미리 그림을 그려 GPU에게 요청하는 것(수많은 드로우콜 감소)임으로 저렴하게 해결할 수 있음
- 즉, 모든 위젯은 매 프레임마다 그리지만, Retainer Box는 크기가 변화하거나, 페이즈가 같다면 렌더 타켓에 그리도록 하는 방법이다.
- 매 프레임마다 안그린다는 장점과 렌더 타켓을 이용해서 백버퍼에 요청한다는 점이 최적화 용도에 적합하다고 할 수 있다.
- 그런데, 만약 렌더 타켓을 재조정하지 못할정도로 클 경우에는 일반 위젯처럼 그림을 그리도록 한다.
- 그리고 렌더 타켓에 그림이 그려지는 부분은 PaintRetainedContentImpl 함수에서 일어나고
- 실제 그 그림을 GPU에게 그릴 수 있도록 명령을 전달하는 부분은 OnPaint함수의 MakeBox 함수이다.