본문 바로가기
언리얼_엔진_게임개발_공부/언리얼 C++

[언리얼/C++] UUserWidget 인스턴스에 RemoveFromParent 호출 시 메모리 해제?

by jaboy 2025. 2. 10.

물론 알아서 잘 해주는 언리얼 엔진이지만...

언리얼 엔진의 UMG 를 활용해 게임 UI 를 만드는 것에 대한 강의를 들었다.

예제를 다루며 특정 상황에 위젯 객체를 RemoveFromParent 함수를 호출하여 화면에서 삭제하고 객체를 가리키던 포인터 변수에 nullptr 를 대입하였다.

이 때 메모리가 해제되는 것인지에 대한 언급이 없어서, RemoveFromParent 함수에서 메모리 해제가 되는 것이 맞는지 확인하고 싶어졌다.

(생성자에서 초기화하는 것 외에, 런타임에 생성되는 객체를 가리키는 raw pointer 변수에 특정 함수에서 nullptr 를 직접 assign 하는 것에 대한 두려움이 있다 ㅋ... 지난 첫 팀프로젝트에서 메모리 릭을 잔뜩 만들어냈던 장본인으로숴...^^ 다행히 고쳤지만...)

 

CreateWidget

UUserWidget* 타입의 WidgetInstance 변수에 CreateWidget<UUserWidget> 함수를 통해 생성한 객체를 가리키도록 할당하였으므로, 우선 CreateWidget 이 어떤 방식으로 객체를 생성하고 있는지 살펴보았다.

template <typename WidgetT = UUserWidget, typename OwnerType = UObject>
WidgetT* CreateWidget(OwnerType OwningObject, TSubclassOf<UUserWidget> UserWidgetClass = WidgetT::StaticClass(), FName WidgetName = NAME_None)
{
	static_assert(TIsDerivedFrom<WidgetT, UUserWidget>::IsDerived, "CreateWidget can only be used to create UserWidget instances. If creating a UWidget, use WidgetTree::ConstructWidget.");
	
	static_assert(TIsDerivedFrom<TPointedToType<OwnerType>, UWidget>::IsDerived
		|| TIsDerivedFrom<TPointedToType<OwnerType>, UWidgetTree>::IsDerived
		|| TIsDerivedFrom<TPointedToType<OwnerType>, APlayerController>::IsDerived
		|| TIsDerivedFrom<TPointedToType<OwnerType>, UGameInstance>::IsDerived
		|| TIsDerivedFrom<TPointedToType<OwnerType>, UWorld>::IsDerived, "The given OwningObject is not of a supported type for use with CreateWidget.");

	SCOPE_CYCLE_COUNTER(STAT_CreateWidget);
	FScopeCycleCounterUObject WidgetObjectCycleCounter(UserWidgetClass, GET_STATID(STAT_CreateWidget));

	if (OwningObject)
	{
		return Cast<WidgetT>(UUserWidget::CreateWidgetInstance(*OwningObject, UserWidgetClass, WidgetName));
	}
	return nullptr;
}

1. OwningObject 에 대한 타입 체크 (assert)

- 위젯을 만들 수 있는 OwningObject 의 타입 : UWidget, UWidgetTree, 플레이어 컨트롤러, 게임 인스턴스, 월드

- UWidgetTree : The widget tree manages the collection of widgets in a blueprint widget. 

- UWidget : This is the base class for all wrapped Slate controls that are exposed to UObjects.

(출처: WidgetTree.h / Widget.h 엔진 코드)

 

2. CreateWidgetInstance 호출

UUserWidget::CreateWidgetInstance

- 강의 예제의 경우 PlayerController 에서 위젯 객체를 생성하므로 오버로드된 함수 중 해당 함수를 살펴보았다.

UUserWidget* UUserWidget::CreateWidgetInstance(APlayerController& OwnerPC, TSubclassOf<UUserWidget> UserWidgetClass, FName WidgetName)
{
	if (!OwnerPC.IsLocalPlayerController())
		{
		const FText FormatPattern = LOCTEXT("NotLocalPlayer", "Only Local Player Controllers can be assigned to widgets. {PlayerController} is not a Local Player Controller.");
		FFormatNamedArguments FormatPatternArgs;
		FormatPatternArgs.Add(TEXT("PlayerController"), FText::FromName(OwnerPC.GetFName()));
		FMessageLog("PIE").Error(FText::Format(FormatPattern, FormatPatternArgs));
		}
	else if (!OwnerPC.Player)
	{
		const FText FormatPattern = LOCTEXT("NoPlayer", "CreateWidget cannot be used on Player Controller with no attached player. {PlayerController} has no Player attached.");
		FFormatNamedArguments FormatPatternArgs;
		FormatPatternArgs.Add(TEXT("PlayerController"), FText::FromName(OwnerPC.GetFName()));
		FMessageLog("PIE").Error(FText::Format(FormatPattern, FormatPatternArgs));
	}
	else if (UWorld* World = OwnerPC.GetWorld())
	{
		UGameInstance* GameInstance = World->GetGameInstance();
		UObject* Outer = GameInstance ? StaticCast<UObject*>(GameInstance) : StaticCast<UObject*>(World);
		return CreateInstanceInternal(Outer, UserWidgetClass, WidgetName, World, CastChecked<ULocalPlayer>(OwnerPC.Player));
	}
	return nullptr;
}

1. Player Controller 가 로컬인지, 플레이어를 possess 했는지 validate

2. 게임 인스턴스 포인터를 Outer 라는 UObject* 로 캐스팅하여 (없으면 월드 포인터)

3. CreateInstanceInternal 함수 호출 / Outer 인자로 전달

UUserWidget::CreateInstanceInternal

UUserWidget* UUserWidget::CreateInstanceInternal(UObject* Outer, TSubclassOf<UUserWidget> UserWidgetClass, FName InstanceName, UWorld* World, ULocalPlayer* LocalPlayer)
{
	LLM_SCOPE_BYTAG(UI_UMG);

#if !(UE_BUILD_SHIPPING || UE_BUILD_TEST)
	// Only do this on a non-shipping or test build.
	if (!CreateWidgetHelpers::ValidateUserWidgetClass(UserWidgetClass))
	{
		return nullptr;
	}
#else
	if (!UserWidgetClass)
	{
		UE_LOG(LogUMG, Error, TEXT("CreateWidget called with a null class."));
		return nullptr;
	}
#endif

#if !UE_BUILD_SHIPPING
	// Check if the world is being torn down before we create a widget for it.
	if (World)
	{
		// Look for indications that widgets are being created for a dead and dying world.
		ensureMsgf(!World->bIsTearingDown, TEXT("Widget Class %s - Attempting to be created while tearing down the world '%s'"), *UserWidgetClass->GetName(), *World->GetName());
	}
#endif

	if (!Outer)
	{
		FMessageLog("PIE").Error(FText::Format(LOCTEXT("OuterNull", "Unable to create the widget {0}, no outer provided."), FText::FromName(UserWidgetClass->GetFName())));
		return nullptr;
	}
	LLM_SCOPE_DYNAMIC_STAT_OBJECTPATH(Outer->GetPackage(), ELLMTagSet::Assets);
	LLM_SCOPE_DYNAMIC_STAT_OBJECTPATH(UserWidgetClass, ELLMTagSet::AssetClasses);
	UE_TRACE_METADATA_SCOPE_ASSET_FNAME(InstanceName, UserWidgetClass->GetFName(), Outer->GetPackage()->GetFName());

	UUserWidget* NewWidget = NewObject<UUserWidget>(Outer, UserWidgetClass, InstanceName, RF_Transactional);
	
	if (LocalPlayer)
	{
		NewWidget->SetPlayerContext(FLocalPlayerContext(LocalPlayer, World));
	}

	NewWidget->Initialize();

	return NewWidget;
}

1. 빌드 세팅에 따라 다른 UserWidgetClass 및 World 의 validation

2. Outer 의 validation

3. NewObject<UUserWidget> 으로 위젯 객체 생성 (드디어...)

4. 위젯의 player context 를 로컬 플레이어로 설정

5. 초기화 및 반환

 

UWidget::RemoveFromParent

참고: UUserWidget 은 UWidget 을 상속하는 서브클래스로, 엔진 코드의 주석에 따르면 A widget that enables UI extensibility through WidgetBlueprint. 라고 한다.

참고2: AddToViewport 인데 RemoveFromViewport 가 아닌 이유?

엔진 코드를 살펴보니 RemoveFromViewport 함수가 존재하는데, deprecated 된 함수로 RemoveFromParent 를 사용하라고 지시하고 있다.

void UWidget::RemoveFromParent()
{
	if (!HasAnyFlags(RF_BeginDestroyed))
	{
		if (bIsManagedByGameViewportSubsystem)
		{
			if (UGameViewportSubsystem* Subsystem = UGameViewportSubsystem::Get(GetWorld()))
			{
				Subsystem->RemoveWidget(this);
			}
		}
		else if (UPanelWidget* CurrentParent = GetParent())
		{
			CurrentParent->RemoveChild(this);
		}
		else
		{
#if !(UE_BUILD_SHIPPING || UE_BUILD_TEST)
			if (GetCachedWidget().IsValid() && GetCachedWidget()->GetParentWidget().IsValid() && !IsDesignTime())
			{
				FText WarningMessage = FText::Format(LOCTEXT("RemoveFromParentWithNoParent", "UWidget::RemoveFromParent() called on '{0}' which has no UMG parent (if it was added directly to a native Slate widget via TakeWidget() then it must be removed explicitly rather than via RemoveFromParent())"), FText::AsCultureInvariant(GetPathName()));
				// @todo: nickd - we need to switch this back to a warning in engine, but info for games
				FMessageLog("PIE").Info(WarningMessage);
			}
#endif
		}
	}
}

 

1. BeginDestroyed flag 를 확인

- BeginDestroy 함수가 asynchronously 객체를 파괴하기 때문에 로직 수행하는 시점에 확인이 필요하다.

- 아래와 같이 BeginDestroy 또한 뷰포트에서 위젯의 삭제를 수행한다.

void UWidget::BeginDestroy()
{
	if (bIsManagedByGameViewportSubsystem)
	{
		if (UGameViewportSubsystem* Subsystem = UGameViewportSubsystem::Get(GetWorld()))
		{
			Subsystem->RemoveWidget(this);
		}
	}
	Super::BeginDestroy();
}

 

2-1. 뷰포트 subsystem->RemoveWidget 

void UGameViewportSubsystem::RemoveWidget(UWidget* Widget)
{
	if (Widget && Widget->bIsManagedByGameViewportSubsystem)
	{
		FSlotInfo SlotInfo;
		TObjectKey<UWidget> WidgetKey = Widget;
		ViewportWidgets.RemoveAndCopyValue(WidgetKey, SlotInfo);
		RemoveWidgetInternal(Widget, SlotInfo.FullScreenWidget, SlotInfo.LocalPlayer);

		OnWidgetRemoved.Broadcast(Widget);
	}
}

void UGameViewportSubsystem::RemoveWidgetInternal(UWidget* Widget, const TWeakPtr<SConstraintCanvas>& FullScreenWidget, const TWeakObjectPtr<ULocalPlayer>& LocalPlayer)
{
	Widget->bIsManagedByGameViewportSubsystem = false;
	if (TSharedPtr<SWidget> WidgetHost = FullScreenWidget.Pin())
	{
		// If this is a game world remove the widget from the current world's viewport.
		UWorld* World = Widget->GetWorld();
		if (World && World->IsGameWorld())
		{
			if (UGameViewportClient* ViewportClient = World->GetGameViewport())
			{
				TSharedRef<SWidget> WidgetHostRef = WidgetHost.ToSharedRef();
				ViewportClient->RemoveViewportWidgetContent(WidgetHostRef);

				// We may no longer have access to our owning player if the player controller was destroyed
				// Passing nullptr to RemoveViewportWidgetForPlayer will search all player layers for this widget
				ViewportClient->RemoveViewportWidgetForPlayer(LocalPlayer.Get(), WidgetHostRef);
			}
		}
	}
}

 

코드를 보아하니 Widget이 Key 와 SlotInfo , 즉 map으로 관리되는 모양이다.

1. FViewportWidgetList = TMap<TObjectKey<UWidget>, FSlotInfo> 타입의 ViewportWidgets 에서 위젯을 지우고

2. value 를 SlotInfo 에 copy 한 후 RemoveWidgetInternal 함수에 전달한다

3. RemoveWidgetInternal 은 RemoveViewportWidgetContent / RemoveViewportWidgetForPlayer 이 두 함수로 위젯 삭제 요청

RemoveViewportWidgetContent

- 인자로 넘겨 받은 WidgetHostRef 를 SOverlay::RemoveSlot 함수에 전달하여 호출한다.

SOverlay / RemoveSlot

- SOverlay 는 위젯 관리 계층 구조 - 위젯들이 서로의 위/아래로 레이어될 수 있게 Slot 으로 관리하고 있다.

- TPanelChildren<FOverlaySlot> Children 이라는 변수에 SOverlay 의 위젯들이 담긴 slot 들이 저장된다.

- 위에서 호출된 RemoveSlot 함수는 Children 에 Remove(SlotWidget) 를 호출하여 삭제한다.

TPanelChildren / Remove

/** Removes the corresponding widget from the set of children if it exists.  Returns the index it found the child at, INDEX_NONE otherwise. */
int32 Remove(const TSharedRef<SWidget>& SlotWidget)
{
	for (int32 SlotIdx = 0; SlotIdx < Num(); ++SlotIdx)
	{
		if (SlotWidget == Children[SlotIdx]->GetWidget())
		{
			Children.RemoveAt(SlotIdx);
			return SlotIdx;
		}
	}

	return INDEX_NONE;
}

- TArray<TUniquePtr<SlotType>> Children 에 RemoveAt 으로 해당하는 위젯을 삭제한다!

드디어!!!!!! 각 Slot 이 TUniquePtr 로 관리되고 있기 때문에, RemoveAt 을 통해 레퍼런스 카운터가 0이 되어 메모리가 해제되겠구나 안심할 수 있었다.

 

RemoveWidgetInternal / RemoveViewportWidgetForPlayer

- GameLayerManager 에 RemoveWidgetForPlayer 호출

SGameLayerManager::RemoveWidgetForPlayer

- PlayerLayer->Widget->RemoveSlot(ViewportContent) 로 위젯 삭제 함수를 호출한다.

이 RemoveSlot 은 위에서 살펴본 RemoveSlot 과 동일하다. (즉 PlayerLayer 의 Widget 은 SOverlay 타입의 위젯)

 

2-2. 뷰포트 섭시스템에서 관리되지 않는 경우, UPanelWidget 타입의 parent 객체에서 RemoveChild 호출

UPanelWidget::RemoveChild / RemoveChildAt(Index)

- 인자로 넘겨받은 위젯을 기반으로 인덱스를 GetChildIndex --> RemoveChildAt(해당 인덱스) 호출

bool UPanelWidget::RemoveChildAt(int32 Index)
{
	if ( Index < 0 || Index >= Slots.Num() )
	{
		return false;
	}

	UPanelSlot* PanelSlot = Slots[Index];
	Slots.RemoveAt(Index);

	if (PanelSlot)
	{
		if (PanelSlot->Content)
		{
			PanelSlot->Content->Slot = nullptr;
		}

		OnSlotRemoved(PanelSlot);

		const bool bReleaseChildren = true;
		PanelSlot->ReleaseSlateResources(bReleaseChildren);
		PanelSlot->Parent = nullptr;
		PanelSlot->Content = nullptr;
	}
	else
	{
		return false;
	}

	InvalidateLayoutAndVolatility();

	return true;
}

- TArray<TObjectPtr<UPanelSlot>> Slots 에서 Slots[Index] 로 삭제할 슬롯의 포인터를 변수에 저장하고 

- Slots.RemoveAt(Index); 로 삭제한다.

- 이후 PanelSlot 포인터가 nullptr 가 아닌지 validate 하고

- ReleaseSlateResources 리소스 해제하는 듯 하다.

 

예상했던 것보다 훠어어얼씬 여러 단계를 거쳐 위젯이 관리되고 있구나... 깨닫게 되었다

그리고... 앞으로 Remove 어쩌구 하는 엔진의 코드는 그냥 메모리 해제가 포함되어 있다고 굳게 믿으면 될 것 같다...!