ARTICLE DETAIL

资讯详情

深耕网站视觉设计与运营推广的一线实战洞察。

在UE中用Slate复刻ImGui:立即模式调试面板的实现

在UE中用Slate复刻ImGui:立即模式调试面板的实现 1. 为什么要复刻一个“不需要 ImGui 的 ImGui”看到这个标题你可能会觉得有点绕ImGui 本身就是开源库UE 项目里装一个 UnrealImGui 插件就能直接开窗口为什么还要讨论“不用 ImGui 重新实现 ImGui”这个问题背后的技术疑问其实很有价值——ImGui 能火不是因为它的代码有多不可替代而是它给调试工具提供了一种完全不同于 UE 原生 UI 的交互体验。如果你只是想在 UE 里查 Log、调数值、看性能常见做法是 UMG 面板或者第三方的 ImGui 插件但如果你希望不增加插件依赖又想在运行时获得类似 ImGui 那样的动态窗口体验就需要自己把 ImGui 的“即时模式”思维搬到 UE 的 UI 体系里。本文要做的不是逆向 ImGui 源码而是通过一个最小但可运行的实验用 UE 的 HUD、Slate 和少量 C 实现一个“ImGui 风格”的调试面板。完成这个实验后你会得到一个明确判断ImGui 和 UE UI 的差别本质上不是窗口样式、不是控件库而是“立即模式”和“保留模式”这两种 UI 架构的差别。只要理解了这一点你完全可以用 UE 的原生控件复刻出 ImGui 最常见的窗口、按钮、刷新交互甚至在打包项目中不引入任何第三方插件。更重要的是这篇文章会告诉你这种复刻方案的边界。它非常适合游戏内临时调试、项目组内部工具、快速查看运行数据但如果你想要的是完整窗口停靠、多文档布局、皮肤系统这种重功能直接使用现成的 ImGui 插件或者把 Slate 官方案例吃透可能更合适。先把这个判断放在前面后面所有代码和设计都围绕它展开。2. 基础概念ImGui 的立即模式与 UE 的保留模式在动手写代码之前必须把“立即模式Immediate Mode”和“保留模式Retained Mode”这两个术语讲清楚因为很多开发者的误区正在于此。ImGui 的代码看起来像一个普通的函数调用流在每一帧里你调用ImGui::Begin(窗口名)然后添加按钮、文本、滑动条再调用ImGui::End()。从开发者角度看UI 是“每帧重新声明一遍”的。ImGui 内部会把你的调用转换成顶点和绘制命令交给 GPU 渲染它并没像 UMG 那样保存一棵长期存在的控件树。UE 的 Slate 和 UMG 则是典型的保留模式。你在Construct或蓝图里创建的SButton、UTextBlock、UVerticalBox一旦创建就会一直存在于控件树中。后续要改变界面不是重新声明界面而是修改控件属性比如调用SetText、SetVisibility、AddChild。窗口布局、事件绑定、样式引用都由控件自己“记住”。这种模式的优点是稳定、适合复杂界面缺点是动态界面改起来比较繁琐。下面用一张表把两个模式的差异拆开对比维度ImGui立即模式UE Slate / UMG保留模式UI 存在形式每帧提交绘制指令控件树长期存在于内存开发代码形态每帧声明窗口内容创建控件后续更新属性事件处理通过返回值判断点击/拖拽通过委托绑定回调界面动态性天然适合频繁变化需要手动刷新或重建资源管理库内部管理窗口状态UObject 和 SharedPtr 管理典型场景调试工具、编辑器工具游戏主界面、复杂交互 UI从这张表可以看到ImGui 的“好用”并不是因为它自带了多少漂亮控件而是因为它的开发模型非常适合调试工具你在游戏逻辑附近直接写 UI不用考虑控件创建时机、不用想着如何更新文本、不用维护一堆成员变量。而 UE 原生 UI 更适合正式界面但对“临时拼一个窗口”这种需求来说往往显得繁琐。因此“不用 ImGui 重新实现 ImGui”的真正含义是复刻它的立即模式开发模型我们把每帧需要显示的窗口、按钮、文本抽象成数据然后在底层把这些数据翻译成 UE 的 Slate 控件树。这样上层代码保留 ImGui 式的“每帧声明”手感底层仍然使用 UE 的原生渲染和控件体系。3. 设计一个轻量的“立即模式”调试框架在写具体代码前先设计一个最简架构。ImGui 每一帧的流程大体是NewFrame开始新帧然后你调用各种BeginWindow、Button、Text最后Render统一绘制。我们在 UE 中的复刻不需要这么重只需要三个步骤清空上一帧的 UI 数据缓冲。业务代码向缓冲中添加窗口和控件描述。消费缓冲生成或刷新 Slate 控件。对应的数据结构可以非常简单。下面是一个用于描述调试面板条目的 C 结构体它保存标签和值相当于 ImGui 里的Text(Label: %s, Value)// DebugPanelTypes.h #pragma once #include CoreMinimal.h struct FDebugPanelItem { FString Label; FString Value; FDebugPanelItem() : Label(TEXT()) , Value(TEXT()) { } FDebugPanelItem(const FString InLabel, const FString InValue) : Label(InLabel) , Value(InValue) { } };再定义一个上下文类作为“每帧提交 UI 声明”的入口。它内部只有一个数组负责收集当前帧要显示的条目// DebugPanelContext.h #pragma once #include CoreMinimal.h #include DebugPanelTypes.h class FDebugPanelContext { public: void BeginFrame() { Items.Reset(); } void AddItem(const FString InLabel, const FString InValue) { FDebugPanelItem Item(InLabel, InValue); Items.Add(Item); } const TArrayFDebugPanelItem GetItems() const { return Items; } private: TArrayFDebugPanelItem Items; };这段代码非常简单但它已经抓住了立即模式的核心业务代码不关心窗口是怎么画出来的只负责往上下文里“丢数据”UI 层在帧末消费这些数据决定是否重建 Slate 控件。如果你觉得这个抽象太简陋完全可以扩展成窗口数组、控件类型枚举、回调函数表等但不要被架构束缚住。先把最小流程跑通再逐步加复杂度。4. 环境准备与前置条件接下来进入可运行的实现。本文代码面向 UE5 的 C 工程核心 API 在 UE4 大多数版本同样适用具体的头文件路径和模块名略有差异请以你当前引擎版本为准。创建一个 UE C 项目后需要确保你的目标模块依赖中包含 Slate 和 SlateCore。因为我们要直接使用SVerticalBox、SButton、STextBlock这类 Slate 控件而不是通过 UMG 蓝图间接使用。修改项目的*.Build.cs文件加入以下模块// 项目名.Build.cs PublicDependencyModuleNames.AddRange(new string[] { Core, CoreUObject, Engine, InputCore, Slate, SlateCore, UMG });如果你要使用 UMG 控件还必须开启 UMG 模块并确保项目设置里启用了 Slate/UMG 相关功能。后续代码中我们会直接把 Slate 控件挂到GameViewport上因此不需要创建 UMG Widget Blueprint这也是本方案更轻量的原因之一。另外为了用键盘快捷键控制面板显示和隐藏我会使用 UE 的 Input Action 机制。你可以在项目设置中添加一个名为ToggleDebugPanel的 Action 映射给它分配一个按键例如 F8。也可以直接在 C 的SetupInputComponent中绑定一个按键命令下面会给出示例。5. 第一步用 HUD Canvas 画一个最简信息面板先从一个最简单的方案开始只画信息不做交互。UE 的AHUD本身可以在屏幕上绘制矩形和文字适合做纯展示型的调试覆盖层。这个方案不需要任何 Slate 控件代码量很少适合理解“调试 UI 本质上是往屏幕上画信息”这件事。创建一个自定义 HUD 类并重写DrawHUD// MyDebugHUD.h #pragma once #include CoreMinimal.h #include GameFramework/HUD.h #include MyDebugHUD.generated.h UCLASS() class AMyDebugHUD : public AHUD { GENERATED_BODY() public: virtual void DrawHUD() override; protected: void DrawDebugPanel(); };// MyDebugHUD.cpp #include MyDebugHUD.h #include Engine/Canvas.h #include CanvasItem.h void AMyDebugHUD::DrawHUD() { Super::DrawHUD(); DrawDebugPanel(); } void AMyDebugHUD::DrawDebugPanel() { const float X 20.0f; const float Y 20.0f; const float Width 320.0f; const float Height 160.0f; DrawRect(FLinearColor(0.05f, 0.05f, 0.05f, 0.75f), X, Y, Width, Height); DrawText(TEXT(Debug Panel), FLinearColor::White, X 12.0f, Y 10.0f); DrawText(TEXT(FPS: --), FLinearColor::Green, X 12.0f, Y 40.0f); DrawText(TEXT(Press F8 to toggle), FLinearColor::Gray, X 12.0f, Y 70.0f); }这段代码会在屏幕左上角画一个半透明的黑色矩形并输出几行文字。优点是极轻不依赖任何额外控件缺点也很明显没有状态没有交互矩形只是视觉装饰文字一刷新就覆盖。如果你只是想要“在屏幕上显示几个数字”这个方案非常合适如果你想复刻 ImGui 的按钮和动态输入就必须进入 Slate 层面。6. 第二步用 Slate 复刻 ImGui 的窗口和按钮既然 HUD Canvas 无法处理交互我们改用 Slate。Slate 是 UE 的 C UI 框架UMG 底层实际上也构建在 Slate 之上。我们直接创建自定义 Slate 控件它相当于一个“窗口根节点”里面动态添加文本和按钮。首先定义一个自定义控件类SImGuiLikePanel。它继承SCompoundWidget内部维护一个SVerticalBox作为内容容器// ImGuiLikePanel.h #pragma once #include CoreMinimal.h #include Widgets/SCompoundWidget.h class SVerticalBox; class SImGuiLikePanel : public SCompoundWidget { public: SLATE_BEGIN_ARGS(SImGuiLikePanel) {} SLATE_END_ARGS() void Construct(const FArguments InArgs); void RebuildContent(); private: FReply OnToggleFPS(); FReply OnLogActorCount(); TSharedPtrSVerticalBox RootBox; };然后实现这个类。重点是RebuildContent()每次调用时它会先清空RootBox里的所有 Slot然后重新添加一批子控件。这正是我们用“立即模式”思路改造 Slate 的关键——我们不维护固定的 UI 树结构而是把 UI 树看成“每一帧或每次刷新时重新声明一次”的产物。// ImGuiLikePanel.cpp #include ImGuiLikePanel.h #include Widgets/Layout/SVerticalBox.h #include Widgets/Text/STextBlock.h #include Widgets/Input/SButton.h #include Widgets/Layout/SBorder.h #include Engine/Engine.h #include Engine/World.h #include EngineUtils.h void SImGuiLikePanel::Construct(const FArguments InArgs) { RootBox SNew(SVerticalBox); RebuildContent(); ChildSlot [ SNew(SBorder) .BorderBackgroundColor(FLinearColor(0.0f, 0.0f, 0.0f, 0.65f)) .Padding(10.0f) [ RootBox.ToSharedRef() ] ]; } void SImGuiLikePanel::RebuildContent() { if (!RootBox.IsValid()) { return; } RootBox-ClearChildren(); RootBox-AddSlot() .AutoHeight() .Padding(0.0f, 2.0f) [ SNew(STextBlock) .Text(FText::FromString(TEXT(ImGui-like Debug Panel))) .ColorAndOpacity(FLinearColor::White) ]; RootBox-AddSlot() .AutoHeight() .Padding(0.0f, 4.0f) [ SNew(SButton) .OnClicked(this, SImGuiLikePanel::OnToggleFPS) .Content() [ SNew(STextBlock) .Text(FText::FromString(TEXT(Toggle FPS Stat))) ] ]; RootBox-AddSlot() .AutoHeight() .Padding(0.0f, 4.0f) [ SNew(SButton) .OnClicked(this, SImGuiLikePanel::OnLogActorCount) .Content() [ SNew(STextBlock) .Text(FText::FromString(TEXT(Log Actor Count))) ] ]; } FReply SImGuiLikePanel::OnToggleFPS() { if (UWorld* World GEngine ? GEngine-GetCurrentPlayWorld() : nullptr) { World-Exec(World, TEXT(stat fps)); } return FReply::Handled(); } FReply SImGuiLikePanel::OnLogActorCount() { if (UWorld* World GEngine ? GEngine-GetCurrentPlayWorld() : nullptr) { int32 Count 0; for (TActorIteratorAActor It(World); It; It) { Count; } UE_LOG(LogTemp, Log, TEXT(Total actors in world: %d), Count); } return FReply::Handled(); }这里实际上已经复刻了 ImGui 最常用的两个能力窗口容器和按钮交互。RebuildContent清空并重建子控件意味着你可以在其中加入任何动态数据比如实时 FPS、内存占用、玩家坐标。每次刷新时内容都能被重新声明不需要像传统 UMG 那样预先建好控件再逐个更新。当然这段代码还有一个现实问题ClearChildren和重新AddSlot会重建 Slate 控件频繁调用会带来额外的布局和创建开销。但在调试场景下使用固定时间间隔或数据变更驱动刷新完全没有问题。7. 第三步在 HUD 中挂载并控制面板现在需要把SImGuiLikePanel挂到游戏视口上并实现开关和定时刷新。最具控制力的办法是继续使用自定义 HUD 类在它的BeginPlay中创建面板在Tick中按需刷新。为了不让每帧重建 Slate 控件我这里采用了一个固定时间间隔每 0.2 秒刷新一次内容。这个频率对人眼来说足够流畅同时远低于每帧重建的开销。ImGui 是每帧提交但我们在保留模式下做了务实妥协数据变化或时钟到点时才重建性能和交互取一个平衡点。// DebugOverlayHUD.h #pragma once #include CoreMinimal.h #include GameFramework/HUD.h #include DebugOverlayHUD.generated.h class SImGuiLikePanel; UCLASS() class ADebugOverlayHUD : public AHUD { GENERATED_BODY() public: virtual void BeginPlay() override; virtual void Tick(float DeltaSeconds) override; void ShowPanel(bool bShow); void TogglePanel(); protected: void RefreshPanel(); private: TSharedPtrSImGuiLikePanel ImGuiLikePanel; bool bPanelVisible true; double LastRefreshTime 0.0; static constexpr double RefreshInterval 0.2; };// DebugOverlayHUD.cpp #include DebugOverlayHUD.h #include ImGuiLikePanel.h #include Engine/Engine.h #include Engine/GameViewportClient.h void ADebugOverlayHUD::BeginPlay() { Super::BeginPlay(); ImGuiLikePanel SNew(SImGuiLikePanel); if (GEngine GEngine-GameViewport) { GEngine-GameViewport-AddViewportWidgetContent(ImGuiLikePanel.ToSharedRef()); } } void ADebugOverlayHUD::Tick(float DeltaSeconds) { Super::Tick(DeltaSeconds); if (!ImGuiLikePanel.IsValid()) { return; } UWorld* World GetWorld(); if (!World) { return; } if (World-GetTimeSeconds() - LastRefreshTime RefreshInterval) { LastRefreshTime World-GetTimeSeconds(); RefreshPanel(); } } void ADebugOverlayHUD::RefreshPanel() { if (ImGuiLikePanel.IsValid()) { ImGuiLikePanel-RebuildContent(); } } void ADebugOverlayHUD::ShowPanel(bool bShow) { if (!GEngine || !GEngine-GameViewport || !ImGuiLikePanel.IsValid()) { return; } if (bShow !bPanelVisible) { GEngine-GameViewport-AddViewportWidgetContent(ImGuiLikePanel.ToSharedRef()); } else if (!bShow bPanelVisible) { GEngine-GameViewport-RemoveViewportWidgetContent(ImGuiLikePanel.ToSharedRef()); } bPanelVisible bShow; } void ADebugOverlayHUD::TogglePanel() { ShowPanel(!bPanelVisible); }这里值得注意一个细节AddViewportWidgetContent是往视口覆盖层添加一个SWidget而RemoveViewportWidgetContent负责移除。它和 UMG 的AddToViewport不同它是直接以 Slate 方式操作 Viewport 的内容列表适合叠加临时 UI。如果你的项目里已经有一个比较重的 UMG 界面请确认覆盖层叠加顺序避免新增面板被其他 UI 遮挡。8. 第四步用快捷键切换面板为了让面板可以随时开关你需要一个输入入口。推荐在 PlayerController 中绑定一个 Input Action这样当玩家按住键时控制器可以直接命令 HUD 显示或隐藏面板。先创建一个自定义 PlayerController 类// DebugPlayerController.h #pragma once #include CoreMinimal.h #include GameFramework/PlayerController.h #include DebugPlayerController.generated.h UCLASS() class ADebugPlayerController : public APlayerController { GENERATED_BODY() public: virtual void SetupInputComponent() override; protected: void OnToggleDebugPanel(); };然后在实现中绑定ToggleDebugPanel// DebugPlayerController.cpp #include DebugPlayerController.h #include Components/InputComponent.h #include DebugOverlayHUD.h void ADebugPlayerController::SetupInputComponent() { Super::SetupInputComponent(); InputComponent-BindAction( TEXT(ToggleDebugPanel), IE_Pressed, this, ADebugPlayerController::OnToggleDebugPanel ); } void ADebugPlayerController::OnToggleDebugPanel() { if (ADebugOverlayHUD* DebugHUD GetHUDADebugOverlayHUD()) { DebugHUD-TogglePanel(); } }注意这个绑定依赖你在项目设置中配置了名为ToggleDebugPanel的 Action Mapping。如果你不想走 Input Action也可以直接在SetupInputComponent里用BindKey或直接监听 F8 键。这里使用 Action 的原因是更符合 UE 官方输入系统习惯也方便后续改成任意按键。上面代码中的GetHUDT()是模板方法它会从 PlayerController 的 HUD 指针转换成目标类型。前提是你在项目设置中把默认 HUD 类设置为ADebugOverlayHUD或者已经在 PlayerController 里手动SpawnActor创建了该类。最常见的方式是项目设置 - Maps Modes - Default Classes 中设置 Default HUD Class 为ADebugOverlayHUDDefault Player Controller Class 为ADebugPlayerController。9. 运行结果与验证方法完成上述代码后编译并运行项目。如果一切正常你应该能看到如下现象游戏启动后画面左上角出现一个半透明面板标题是ImGui-like Debug Panel。面板上有两个按钮一个是Toggle FPS Stat一个是Log Actor Count。点击第一个按钮画面左上角会出现stat fps的帧率统计信息再点一次统计信息关闭。点击第二个按钮会在 Output Log 中输出当前世界的 Actor 数量。按 F8 时整个面板隐藏或显示。验证时可以从这几个方面判断面板是否在正确时机出现默认在BeginPlay时添加 Viewport 覆盖层。如果面板没有出现首先检查 HUD 是否被正确设置其次检查GEngine-GameViewport是否为有效指针。常见错误是在场景切换后 HUD 重新生成但旧引用没有清理。刷新是否有效每 0.2 秒钟RebuildContent会被调用。你可以在RebuildContent中临时添加一个UE_LOG观察是否每 0.2 秒触发一次。如果一次都不触发可能是Tick没有执行或 HUD 被暂停。按钮是否响应点击按钮后观察输出日志。如果点击没有反应优先排查 Slate 控件是否被遮挡或者 PlayerController 的鼠标输入是否被游戏逻辑抢占。如果在运行过程中出现面板闪烁尤其是按钮 hover 状态不稳定说明刷新过于频繁或者RemoveViewportWidgetContent和AddViewportWidgetContent被反复调用。更稳妥的做法是只在数据发生实际变化时刷新而不是定时重建。10. 常见问题与排查思路下面这张表总结了我在实践中最容易踩到的问题可以作为排错清单问题现象可能原因排查方式解决方案面板没有出现在屏幕上默认 HUD 未设置或 GameViewport 指针为空在 BeginPlay 添加日志输出确认 HUD 被生成在项目设置中正确指定默认 HUD 类或手动在 PlayerController 中创建面板出现但很快消失视口覆盖层被其他绘制逻辑移除检查其他 HUD 是否调用 RemoveViewportWidgetContent统一视口覆盖层管理不要多处移除同一引用按钮点击没有反馈鼠标输入被游戏模式截获或事件委托绑定无效在按钮 OnClicked 中加日志用 FInputModeUIOnly 调试打开面板时切换到 UI 输入模式并显示鼠标光标每帧刷新导致按钮状态异常ClearChildren 和 AddSlot 会重建控件检查刷新频率改为数据变化时刷新或延长刷新间隔面板跟随 UI 缩放偏移不处理 DPI 缩放时坐标在不同分辨率下表现不同输出 Viewport 与 DPI Scale使用 Viewport 的相对布局或根据 DPI 换算坐标编译报错缺少 Slate 头文件Build.cs 未添加 Slate/SlateCore 模块检查模块依赖添加Slate和SlateCore到模块依赖列表点击按钮同时触发游戏操作游戏输入没有暂停观察输入日志显示面板时使用SetInputMode阻断游戏操作11. 工程建议与最佳实践这个实验在小型项目中足够用但如果要进入真实项目建议在以下几个方向上做一些工程化处理。第一数据与 UI 分离。不要直接在 Slate 回调里读取GEngine和遍历世界而是先由业务模块把需要展示的数据写进一个FDebugPanelContext或类似结构体中再让 UI 控件去消费。这样将来替换成 ImGui 插件、UMG 面板甚至网页前端业务代码都不需要改动。第二注意 Slate 控件的生命周期。Slate 控件不是 UObject不跟随垃圾回收器管理而是由TSharedRef、TSharedPtr维护引用计数。当你把SImGuiLikePanel保存为 HUD 的TSharedPtr时请确保没有在另一个TWeakPtr中持有它的引用并试图访问已失效对象。ClearChildren后旧的子控件引用计数归零相关资源会被释放这是正常行为。第三合理安排刷新策略。ImGui 之所以每帧刷新是因为它把 UI 构建成本控制在极低水平Slate 控件树构建成本则明显更高。对于调试面板建议采用低频率刷新或者用脏标记只有当监控数据发生变化时才重建。例如可以记录一个uint32 DirtyFlags当检查到 FPS、内存、坐标等数据确实改变时才调用RebuildContent。第四输入冲突必须优先处理。当调试面板打开时通常应该把输入模式切到FInputModeUIOnly把鼠标光标显示出来并把焦点设置给面板控件关闭面板时再切回FInputModeGameOnly。否则玩家在点击面板按钮时游戏人物可能同时移动或开枪。void ADebugOverlayHUD::OnPanelOpened(APlayerController* OwnerController) { if (!OwnerController) { return; } FInputModeUIOnly Mode; if (ImGuiLikePanel.IsValid()) { Mode.SetWidgetToFocus(ImGuiLikePanel); } Mode.SetLockMouseToViewportBehavior(EMouseLockMode::DoNotLock); OwnerController-SetInputMode(Mode); OwnerController-SetShowMouseCursor(true); } void ADebugOverlayHUD::OnPanelClosed(APlayerController* OwnerController) { if (!OwnerController) { return; } OwnerController-SetInputMode(FInputModeGameOnly()); OwnerController-SetShowMouseCursor(false); }第五不要把所有 UI 都塞进这个方案。如果你的调试面板已经发展到多个窗口、停靠布局、主题定制说明它已经不再是“临时调试工具”这时候回到正规的 Slate 或 UMG 开发模式才是更合理的选择。这个方案的定位永远是轻量、临时、可丢弃。12. 总结与后续学习方向这个实验真正的收获不是写出一个可以替代 ImGui 的完整库而是理解了两件事。第一ImGui 的交互心智模型是“立即模式”它把 UI 声明和数据更新放在同一个地方调试体验非常顺手第二UE 原生 UI 是“保留模式”但我们可以通过数据缓冲加低频重建的方式把立即模式的手感带进 Slate。如果继续深入建议按下面的路径做扩展给面板增加窗口拖动功能在 Slate 控件中重写OnMouseButtonDown、OnMouseButtonUp、OnMouseMove手动记录并更新窗口坐标。把刷新机制升级为事件驱动用 UE 的委托或数据版本号只在数据变化时触发RebuildContent减少不必要的重建。尝试用 UMG 的动态控建设一个类似版本用CreateWidget和WidgetTree-ConstructWidget动态添加控件对比它和 Slate 的性能差异。把这个面板接入正式项目的调试系统例如统一管理 FPS、帧时间、网络延迟、Actor 数量甚至可以输出到 CSV。最后提醒一点在真实项目中并不意味着“不用 ImGui”就一定更好。如果你已经熟悉 UnrealImGui 插件且它的依赖管理和打包流程在你的项目里没有问题直接用插件仍然是最省力的选择。这篇文章的价值在于当你不方便使用插件或者需要控制依赖与体积时你能用 UE 原生能力快速搭出一个够用的调试面板。建议收藏备用后续做工具开发时可以直接复用这套思路。
返回列表