DebugUI的一些优化方案

前言

游戏中经常会有需要一些调试菜单或者GM菜单,如下

image-20250827143723779

功能上没有太大的问题,但是大面积的UI会非常影响游戏内容的观察,甚至并不能与游戏画面的同时运行

那么整理并记录一下更便捷的UI方案

纯粹的编辑器UI

image-20250827144138851

方案是打开一个EU(EditorUtility),然后监听或者执行游戏内的逻辑和信息

当然也可以不是EU, 一般的UMG也可以,但是既然是仅编辑器运行的,用EU也可以方便蓝图调用很多编辑器API,偶尔还是需要的

这里主要有2个注意点, 一个是如何方便的打开EU, 还有是EU内如何正确的获取游戏世界的信息

打开EU

最粗暴的方法是找到EU资源然后右键打开,但是太不方便

如果调试功能比较独立,建议封装成插件使用,然后插件本身去创建一个toolbar或者window button,通过这些编辑器按钮打开EU是比较方便的

注册按钮创建插件是自带的,这里略过

核心就是用EU蓝图资源来填充窗口,简单的代码如下

image-20250827144503070

获取正确的世界

默认情况下, EU内的蓝图逻辑获取的世界都是Editor的世界,比如你可以访问放在场景中的actor对象,对齐进行修改操作,但这不是我们想要的,我们需要的是游戏运行以后的世界对象的信息

所以必须得用cpp封装一些方便蓝图使用的方法

image-20250827144737077

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49

void UAaDebuggerWidgetBase::NativeConstruct()
{
Super::NativeConstruct();

FEditorDelegates::PostPIEStarted.AddUObject(this, &UAaDebuggerWidgetBase::HandlePIEStarted);
FEditorDelegates::EndPIE.AddUObject(this, &UAaDebuggerWidgetBase::HandlePIEEnded);
}

void UAaDebuggerWidgetBase::NativeDestruct()
{
FEditorDelegates::BeginPIE.RemoveAll(this);
FEditorDelegates::EndPIE.RemoveAll(this);

Super::NativeDestruct();
}

UWorld* UAaDebuggerWidgetBase::GetPIEWorld() const
{
if (GEditor)
{
if (GEditor->GetPIEWorldContext())
{
return GEditor->GetPIEWorldContext()->World();
}
}
return nullptr;
}

UWorld* UAaDebuggerWidgetBase::GetEditorWorld() const
{
return GEditor ? GEditor->GetEditorWorldContext().World() : nullptr;
}

bool UAaDebuggerWidgetBase::IsInPIE() const
{
return GetPIEWorld() != nullptr;
}

void UAaDebuggerWidgetBase::HandlePIEStarted(bool bIsSimulating)
{
OnPIEStarted(bIsSimulating);
}

void UAaDebuggerWidgetBase::HandlePIEEnded(bool bIsSimulating)
{
OnPIEEnded(bIsSimulating);
}

这样在蓝图中就能正确的获取actor信息,也可以监听到PIE运行的时机了

同时支持运行时的UI

如果UI在打包环境下还是需要支持的,那么EU就不够了,但是又不想UI充满整个屏幕,那么就要用到另外的方法

核心的类就是SWindow

我们在某个具体的类里面加一个可以方法

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40

UUserWidget* URGUIManager::OpenUMGInNewWindow(TSubclassOf<UUserWidget> WidgetClass,FText Title, int32 Width, int32 Height,
APlayerController* OwningPC)
{
if (!GetWorld() || !WidgetClass) return nullptr;

if (!OwningPC)
{
if (ULocalPlayer* LP = GetWorld()->GetFirstLocalPlayerFromController())
OwningPC = LP->GetPlayerController(GetWorld());
}

UUserWidget* Widget = CreateWidget<UUserWidget>(OwningPC, WidgetClass);
if (!Widget) return nullptr;

TSharedRef<SWindow> NewWindow = SNew(SWindow)
.Title(Title)
.ClientSize(FVector2D(Width, Height))
.SupportsMaximize(true)
.SupportsMinimize(true)
.HasCloseButton(true)
.IsTopmostWindow(false);

// 把 UMG 转成 Slate
TSharedRef<SWidget> SlateWidget = Widget->TakeWidget();
NewWindow->SetContent(SlateWidget);
// 监听关闭事件
NewWindow->SetOnWindowClosed(FOnWindowClosed::CreateUObject(this, &ThisClass::OnWindowClosed));

FSlateApplication::Get().AddWindow(NewWindow);

// 保存引用,避免 GC / 提前销毁
FRuntimeWindowEntry Entry;
Entry.Window = NewWindow;
Entry.Widget = Widget;
OpenedWindows.Add(MoveTemp(Entry));
SpawnedWidgets.Add(Widget);

return Widget;
}

因为要保存起来,所以建议不要用函数库的方法,可以是某个subsystem

image-20250827145216675

这样GM菜单就是独立存在的(可以放到副屏幕上)

需要注意的是要正确的管理好GC,需要在合适的时机移除掉UI

比如

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
//合适的初始化的位置
FGameDelegates::Get().GetEndPlayMapDelegate().AddUObject(this, &ThisClass::CloseAllRuntimeWindows);



void URGUIManager::DetachSlateFromWindow(const TSharedRef<SWindow>& InWindow, UUserWidget* Widget)
{
// 先把 SObjectWidget 从窗口树里摘掉
InWindow->SetContent(SNullWidget::NullWidget);

// 再让 UMG 释放它的 Slate 资源,避免 SObjectWidget 在 GC 中途才析构
if (Widget)
{
Widget->ReleaseSlateResources(true);
// RemoveFromParent() 可加可不加:它对非 Viewport 树的情况不总是起作用
}

}
void URGUIManager::CloseAllRuntimeWindows()
{
if (!FSlateApplication::IsInitialized()) return;

// 复制一份,避免回调改动原容器造成迭代器失效
TArray<FRuntimeWindowEntry> ToClose = OpenedWindows;

// 先解绑回调 & 主动拆离(防 GC 时机碰撞)
for (FRuntimeWindowEntry& E : ToClose)
{
if (E.Window.IsValid())
{
E.Window->SetOnWindowClosed(FOnWindowClosed()); // 解绑回调,避免重复清理
DetachSlateFromWindow(E.Window.ToSharedRef(), E.Widget.Get());
}
}

// 再发起关闭请求
for (FRuntimeWindowEntry& E : ToClose)
{
if (E.Window.IsValid())
{
FSlateApplication::Get().RequestDestroyWindow(E.Window.ToSharedRef());
}
}

// 最后统一清空我们自己的引用
OpenedWindows.Reset();
SpawnedWidgets.Reset();
}