这次我们来看一个很有意思的 UE 开发话题:在不使用 ImGui 的情况下,从零手写一套类似 ImGui 的即时模式 UI 绘制系统。
这个项目的重点不是“ImGui 不好用”,而是你在 UE 内做工具开发、调试面板、内部测试界面时,未必能接受额外依赖,也可能需要更贴合引擎渲染管线的轻量方案。自己重写一套,反而能彻底掌控渲染流程、输入事件和绘制成本。
先给结论,这套重新实现的 UI 系统核心能力包括:即时模式 API、直接在 UE 的 Slate 或独立 Viewport 中绘制调试面板、支持鼠标键盘输入交互、逐帧重建 UI 状态、支持按钮/滑动条/文本标签等基础控件、可在游戏线程直接调用,不引入第三方 UI 库。做到这一层之后,我们其实就已经拿到了一个可以用于项目内工具链的最小 UI 框架。
本文会用“目标拆解 + 逐步实现 + 效果验证”的方式来写。我们会先分析 ImGui 的核心原理,然后讨论在 UE 里重写时需要处理哪些引擎侧问题,再给出一个可以直接跑起来的最小实现代码路径,最后补充常见的坑和性能观察方法。如果你在 UE 里做过编辑器插件、调试叠加层、或游戏内 HUD 原型,这篇文章建议直接收藏。
1. 核心能力速览
先快速给出一张能力表,方便判断这套方案到底适不适合你当前的需求。
项目类型UE 引擎内置 UI 调试框架
依赖项不依赖第三方库,仅使用 UE 自身模块
主要功能窗口面板、文本标签、按钮、滑动条、勾选框、信息折叠
渲染方式顶点缓冲 + 材质绘制 / Slate 委托绘制
输入处理鼠标坐标、左键点击、滚轮、键盘焦点
刷新模式即时模式,每帧提交 UI 指令并重建顶点数据
适合场景编辑器工具窗口、运行时调试面板、性能监控 HUD、内部测试菜单
不适合场景正式游戏 UI、复杂布局的交互界面、高频率滚动的长列表
硬件要求无特殊要求,可在 CPU 端构建顶点数据,也可用 GPU 绘制
批量任务具备批处理能力,多个控件合并为一次绘制调用
支持平台Windows、Linux、macOS、移动端,前提是引擎能跑
这里要特别说明为什么“正式游戏 UI”不算适用场景。即时模式 UI 的核心特征是每帧重新构建界面状态,这在调试工具和内部面板上很舒服,但在正式 UI 中会导致整棵界面树每帧重建,性能和开发效率都不理想。UE 的 UMG 是保留模式 UI,更适合正式界面。如果你在做的是玩家会直接面对的游戏界面,建议直接用 UMG。
2. 适用场景与使用边界
2.1 这套 UI 系统适合谁
适合做工具链、调试类、内部可视化的工作。
典型场景包括:
- 编辑器插件里的参数调节面板
- 游戏内叠加显示的性能监控窗口
- AI 行为调试时查看角色状态值
- 批量资源处理的进度面板
- 内部测试用的功能开关菜单
这类界面的共同特点是:界面结构简单、信息变化快、不需要复杂动画和自适应布局、开发迭代速度要快。ImGui 风格的即时模式刚好匹配这些需求。
2.2 不适合什么场景
需要明确排除的场景:
- 玩家主菜单、背包、商店这类正式 UI
- 需要复杂布局、适配多分辨率屏幕的界面
- 需要触发器和动画驱动的界面
- 需要中文本地化文本排版、图文混排的界面
另外需要注意,如果你做的功能涉及网络、账号、支付等正式业务交互,仍然建议使用官方 UI 系统和正式前端栈。手写 UI 框架更适合内部工具和调试场景,不应该承担正式业务入口。
2.3 合规与安全边界
在 UE 项目内重写 UI 系统时,需要遵守几个边界:
- 不引入未经授权的外部 UI 库代码,尤其是 GPL 协议的代码,避免传染到项目源码
- 不使用盗版插件或破解版引擎
- 不在公开插件中内置采集用户数据的功能
- 发布编辑器插件时,如果包含网络通信能力,需要明确功能范围并在插件说明中声明
- 如果是公司项目,代码归属和专利问题需要先确认
这些边界在项目一开始就要清楚,否则后续做产品化会有很大的隐患。
环境准备与前置条件
3.1 引擎版本选择
从成本最低的角度,推荐使用 UE 5.1 以上的版本。原因在于:
- Slate 渲染机制在 UE5 中保持了较好的稳定性
- 可以使用 RenderResource 接口更灵活地管理顶点缓冲
- UE5 的 RDG(Render Dependency Graph)让自定义渲染路径更容易调试
- 官方示例和文档查询资料更多
如果你在 UE 4.27 上工作,也可以运行,但需要注意部分接口差异,比如 Slate 的 FSlateDrawElement 在 4.27 和 5.x 之间存在参数差异。
3.2 操作系统与编译器
推荐开发环境:
- Windows 10/11 + Visual Studio 2022
- 或者 macOS + Xcode
- 或者 Linux + Clang
如果只做编辑器插件,不需要打包游戏端,也可以直接用 Rider for Unreal。Rider 对 UE 的反射和生成文件支持会好一些,但 VS 依然是更通用的选择。
3.3 所需模块和插件设置
在项目 Build.cs 中需要添加模块依赖。
以标准游戏项目为例,你的项目名假设叫 MyProject,修改MyProject.Build.cs:
using UnrealBuildTool; public class MyProject : ModuleRules { public MyProject(ReadOnlyTargetFilesystem Target) : base(Target) { PCHUsage = PCHUsageMode.UseExplicitOrSharedPCHs; PublicDependencyModuleNames.AddRange(new string[] { "Core", "CoreUObject", "Engine", "InputCore", "Slate", "SlateCore", "RenderCore", "RHI" }); PrivateDependencyModuleNames.AddRange(new string[] { "Projects", // 编辑器插件可用 "DeveloperSettings" }); } }如果你要做的是编辑器插件,在插件的.uplugin文件中要确保包含Slate、SlateCore、RenderCore和RHI:
{ "FileVersion": 3, "FriendlyName": "MiniUIPlugin", "Version": 1, "VersionName": "1.0", "Modules": [ { "Name": "MiniUI", "Type": "DeveloperTool", "LoadingPhase": "PreDefault", "PlatformAllowList": ["Win64", "Linux", "Mac"] } ], "Plugins": [ { "Name": "ModelingToolsEditorMode", "Enabled": true } ] }注意,这里是否需要 ModelingToolsEditorMode 取决于你的插件功能,不需要就移除,这只是演示插件依赖的配置方式。
3.4 磁盘和工程结构
建议建立一个独立的MiniUI模块或者插件目录:
MyProject/ Plugins/ MiniUI/ MiniUI.uplugin Source/ MiniUI/ MiniUI.Build.cs Public/ MiniUIModule.h MiniUIContext.h Private/ MiniUIModule.cpp MiniUIContext.cpp MiniUIRenderer.cpp MiniUIWidget.cpp MiniUIInput.cpp这里的目录结构会帮助我们隔离代码,后续如果想拆出独立插件,直接复制目录即可。
3.5 端口和进程相关
这一步虽然不涉及网络服务,但值得提醒:
- 如果是编辑器插件,启动 UE 编辑器后,在“输出日志”中能看到模块是否加载成功
- 如果不希望插件影响 PIE(Play In Editor),建议用
LoadingPhase: PreDefault并手动控制初始化开关 - 如果在运行多个 UE 编辑器实例,注意不要同时在同一个项目上操作,避免写入冲突
4. 安装部署与启动方式
4.1 创建插件框架
依次操作:
- 在项目根目录创建
Plugins/MiniUI目录 - 创建
MiniUI.uplugin - 创建
Source/MiniUI/MiniUI.Build.cs - 创建模块入口代码
先给插件文件:
{ "FileVersion": 3, "FriendlyName": "MiniUI", "Version": 1, "VersionName": "1.0", "Description": "A minimal immediate-mode UI system for Unreal Engine.", "Category": "UI", "CreatedBy": "YourName", "CreatedByURL": "", "DocsURL": "", "MarketplaceURL": "", "SupportURL": "", "CanContainContent": true, "IsBetaVersion": false, "IsExperimental": false, "Installed": false, "Modules": [ { "Name": "MiniUI", "Type": "DeveloperTool", "LoadingPhase": "PreDefault", "PlatformAllowList": ["Win64", "Linux", "Mac"] } ] }接着是 Build.cs:
using UnrealBuildTool; public class MiniUI : ModuleRules { public MiniUI(ReadOnlyTargetFilesystem Target) : base(Target) { PCHUsage = PCHUsageMode.UseExplicitOrSharedPCHs; PublicDependencyModuleNames.AddRange(new string[] { "Core", "CoreUObject", "Engine", "InputCore", "Slate", "SlateCore" }); PrivateDependencyModuleNames.AddRange(new string[] { "RenderCore", "RHI", "Projects" }); } }第 4.2 节将创建模块入口:
// MiniUIModule.h #pragma once #include "Modules/ModuleManager.h" class FMiniUIModule : public IModuleInterface { public: virtual void StartupModule() override; virtual void ShutdownModule() override; static inline FMiniUIModule& Get() { return FModuleManager::LoadModuleChecked<FMiniUIModule>("MiniUI"); } };对应的实现文件:
// MiniUIModule.cpp #include "MiniUIModule.h" #include "MiniUIContext.h" void FMiniUIModule::StartupModule() { FMiniUIContext::Initialize(); } void FMiniUIModule::ShutdownModule() { FMiniUIContext::Shutdown(); } IMPLEMENT_MODULE(FMiniUIModule, MiniUI)这个模块启动后会做两件事:
- 初始化 UI 上下文全局实例
- 绑定输入事件和渲染注册
如果你不需要自动初始化,而是希望由某个 Actor 或编辑器工具手动触发,那就在 StartupModule 里只注册,不做初始化。这样更可控。
4.3 快速验证模块是否加载
编译运行后,打开 UE 编辑器,在“窗口”菜单中找到“Developer Tools”下的“MiniUI Panel”,或者直接在控制台输入命令:
MiniUI.ShowPanel如果控制台有这个命令,说明模块已经加载成功。如果看不到,检查:
- 插件是否在项目设置里被禁用
- 编译是否成功
- 模块加载阶段是否输出了异常日志
5. 功能测试与效果验证
这一节我们会逐步完成一个最小的可运行 UI 系统。测试目标是验证:面板能否绘制、按钮能否响应点击、滑动条能否改变数值、帧率开销是否稳定。
5.1 实现 UI 上下文
首先建立全局上下文,负责管理 UI 状态、输入坐标和控件集合。
// MiniUIContext.h #pragma once #include "CoreMinimal.h" #include "Containers/Array.h" #include "Math/Color.h" enum class EMiniUIControlType : uint8 { None, Label, Button, Slider, CheckBox, Separator, SameLine }; struct FMiniUIControl { EMiniUIControlType Type; FString Text; float Value; float MinValue; float MaxValue; FVector2D Position; FVector2D Size; bool bIsHovered; bool bIsClicked; }; class MINIUI_API FMiniUIContext { public: static void Initialize(); static void Shutdown(); static FMiniUIContext& Get(); void BeginFrame(); void EndFrame(); void Text(const FString& InText); bool Button(const FString& InText); float Slider(const FString& InLabel, float InValue, float InMin, float InMax); bool CheckBox(const FString& InLabel, bool bInValue); void Separator(); const TArray<FMiniUIControl>& GetControls() const { return Controls; } void ClearControls() { Controls.Reset(); } void SetMousePosition(const FVector2D& InPos) { MousePos = InPos; } void SetMouseButtonDown(bool bInDown) { bMouseDown = bInDown; } private: FMiniUIContext() {} TArray<FMiniUIControl> Controls; FVector2D MousePos = FVector2D::ZeroVector; bool bMouseDown = false; int32 NextControlIndex = 0; FVector2D CursorPos = FVector2D(10.0f, 10.0f); float LineHeight = 24.0f; float ItemWidth = 240.0f; FVector2D SameLineOffset = FVector2D::ZeroVector; };这里的关键点在于CursorPos和NextControlIndex,它们用来模拟 ImGui 的“自动布局”。每次调用Text、Button等接口时,内部就会向Controls数组追加一条控件记录,同时推进光标位置,最终把这帧所有控件交给渲染器。
5.2 控件逻辑:按钮
按钮是交互控件里最基础的。我们定义:
bool FMiniUIContext::Button(const FString& InText) { FMiniUIControl Ctl; Ctl.Type = EMiniUIControlType::Button; Ctl.Text = InText; Ctl.Position = CursorPos; Ctl.Size = FVector2D(ItemWidth, LineHeight); Ctl.bIsHovered = (MousePos.X >= Ctl.Position.X && MousePos.X <= Ctl.Position.X + Ctl.Size.X && MousePos.Y >= Ctl.Position.Y && MousePos.Y <= Ctl.Position.Y + Ctl.Size.Y); bool bClicked = false; if (Ctl.bIsHovered && bMouseDown) { bClicked = true; } Controls.Add(Ctl); CursorPos.Y += LineHeight; return bClicked; }这段代码表达的就是即时模式 UI 的核心思路:不保存状态,只根据当前帧的输入和控件位置直接计算结果。点击判定不需要按钮内部维护按下状态,完全依赖鼠标坐标和按键状态。
测试预期是:
- 鼠标移到按钮区域,背景色改变
- 鼠标左键按下后返回 true
- 鼠标不在区域内时返回 false,不产生误触发
5.3 控件逻辑:滑动条
滑动条比按钮稍复杂一点,需要根据鼠标横向位置计算数值。
float FMiniUIContext::Slider(const FString& InLabel, float InValue, float InMin, float InMax) { FMiniUIControl Ctl; Ctl.Type = EMiniUIControlType::Slider; Ctl.Text = InLabel; Ctl.Value = InValue; Ctl.MinValue = InMin; Ctl.MaxValue = InMax; Ctl.Position = CursorPos; Ctl.Size = FVector2D(ItemWidth, LineHeight); if (Ctl.bIsHovered && bMouseDown) { float T = (MousePos.X - Ctl.Position.X) / Ctl.Size.X; T = FMath::Clamp(T, 0.0f, 1.0f); InValue = InMin + T * (InMax - InMin); } Ctl.Value = InValue; Controls.Add(Ctl); CursorPos.Y += LineHeight; return InValue; }测试预期是:
- 拖动滑条时数值连续变化
- 鼠标离开后数值保持当前值,不会回跳
- 数值被限制在最小值和最大值之间
需要说明的是,这里我们把“数值保留”交给了调用者。调用方需要在外部保存状态,例如:
float MyValue = 0.5f; MyValue = MiniUIContext.Get().Slider("Brightness", MyValue, 0.0f, 1.0f);5.4 渲染器:把控件数据变成画面
控件数据处理完之后,接下来要做的是渲染。这一步有两个方案:
方案一:Slate 委托绘制
在 Slate 的OnPaint中,遍历Controls数组,用FSlateDrawElement绘制矩形和文本。
void FMiniUIWidget::OnPaint( const FPaintArgs& Args, const FGeometry& AllottedGeometry, const FSlateRect& MyCullingRect, FSlateWindowElementList& OutDrawElements, int32 LayerId, const FWidgetStyle& InWidgetStyle, bool bParentEnabled) const { const TArray<FMiniUIControl>& Controls = FMiniUIContext::Get().GetControls(); FSlateDrawElement::MakeBox( OutDrawElements, LayerId, AllottedGeometry.ToPaintGeometry(), &FCoreStyle::Get().GetBrush("WhiteBrush"), ESlateDrawEffect::None, FLinearColor(0.1f, 0.1f, 0.1f, 0.9f) ); for (const FMiniUIControl& Ctl : Controls) { FVector2D DrawPos = Ctl.Position; FVector2D DrawSize = Ctl.Size; if (Ctl.Type == EMiniUIControlType::Button) { FLinearColor BgColor = Ctl.bIsHovered ? FLinearColor(0.3f, 0.3f, 0.4f, 1.0f) : FLinearColor(0.2f, 0.2f, 0.3f, 1.0f); FSlateDrawElement::MakeBox( OutDrawElements, LayerId + 1, AllottedGeometry.ToPaintGeometry(DrawSize, FSlateLayoutTransform(DrawPos)), &FCoreStyle::Get().GetBrush("WhiteBrush"), ESlateDrawEffect::None, BgColor ); FSlateDrawElement::MakeText( OutDrawElements, LayerId + 2, AllottedGeometry.ToPaintGeometry(FVector2D(10.0f, 10.0f), FSlateLayoutTransform(DrawPos + FVector2D(4.0f, 4.0f))), Ctl.Text, FCoreStyle::Get().GetFontStyle("NormalFont"), FLinearColor::White ); } else if (Ctl.Type == EMiniUIControlType::Slider) { float FillRatio = (Ctl.Value - Ctl.MinValue) / (Ctl.MaxValue - Ctl.MinValue); float FillWidth = DrawSize.X * FillRatio; FSlateDrawElement::MakeBox( OutDrawElements, LayerId + 1, AllottedGeometry.ToPaintGeometry(FVector2D(FillWidth, DrawSize.Y), FSlateLayoutTransform(DrawPos)), &FCoreStyle::Get().GetBrush("WhiteBrush"), ESlateDrawEffect::None, FLinearColor(0.2f, 0.6f, 0.9f, 1.0f) ); FSlateDrawElement::MakeText( OutDrawElements, LayerId + 2, AllottedGeometry.ToPaintGeometry(FVector2D(10.0f, 10.0f), FSlateLayoutTransform(DrawPos + FVector2D(4.0f, 4.0f))), Ctl.Text, FCoreStyle::Get().GetFontStyle("NormalFont"), FLinearColor::White ); } } }这个方案适合编辑器工具和调试叠加层,实现简单,不需要自己管顶点缓冲。缺点是在大量控件时会产生大量 Slate 绘制元素,性能会比自定义渲染路径差一些,但在几十个控件的调试场景下完全够用。
方案二:独立 RHI 渲染
如果你希望这套 UI 能在游戏内叠加层使用,并且对每帧绘制调用数有更高要求,可以创建自定义顶点缓冲,合并所有控件为一次 DrawPrimitive 调用。这一步更复杂,需要:
- 创建
FRenderResource子类管理缓冲区 - 在渲染线程中提交坐标和颜色数据
- 使用自己的材质或绑定最小着色器
- 处理视口尺寸变化
我把这部分留给进阶。如果你只做内部工具面板,方案一已经足够。
5.5 输入处理
输入处理的核心思路是:把全局鼠标坐标和按键状态写入FMiniUIContext。
// 在 Slate 控件内处理输入 FReply FMiniUIWidget::OnMouseButtonDown(const FGeometry& MyGeometry, const FPointerEvent& MouseEvent) { if (MouseEvent.GetEffectingButton() == EKeys::LeftMouseButton) { FMiniUIContext::Get().SetMousePosition(MyGeometry.AbsoluteToLocal(MouseEvent.GetScreenSpacePosition())); FMiniUIContext::Get().SetMouseButtonDown(true); return FReply::Handled(); } return FReply::Unhandled(); } FReply FMiniUIWidget::OnMouseButtonUp(const FGeometry& MyGeometry, const FPointerEvent& MouseEvent) { FMiniUIContext::Get().SetMouseButtonDown(false); return FReply::Handled(); } FReply FMiniUIWidget::OnMouseMove(const FGeometry& MyGeometry, const FPointerEvent& MouseEvent) { FMiniUIContext::Get().SetMousePosition(MyGeometry.AbsoluteToLocal(MouseEvent.GetScreenSpacePosition())); return FReply::Handled(); }这里有一个细节:输入事件驱动的顺序是关键。BeginFrame应该先清空控件,然后由业务代码调用Draw系列接口,最后在EndFrame中把控件提交给渲染器。如果顺序反过来,控件绘制的位置会延迟一帧。针对这一点,常见的做法是:
// 每帧 Tick void UMyUISubsystem::Tick(float DeltaTime) { FMiniUIContext& MiniUI = FMiniUIContext::Get(); MiniUI.BeginFrame(); // 业务代码描述 UI MiniUI.Text("Debug Panel"); MiniUI.Separator(); this->bShowTriggerRanges = MiniUI.CheckBox("Show Trigger", this->bShowTriggerRanges); this->AIAlertRadius = MiniUI.Slider("AlertRadius", this->AIAlertRadius, 100.0f, 2000.0f); if (MiniUI.Button("Reload Config")) { ReloadConfig(); } MiniUI.EndFrame(); }这段代码会让每次 UI 构建与该帧的输入状态对齐,避免逻辑闪烁。
5.6 测试流程
在编辑器中打开MiniUI Panel后,逐项测试:
- 面板是否显示在左上角,背景是否半透明
- 文本标签是否正确显示
- 鼠标移动时按钮背景是否变亮
- 点击按钮时某个日志是否输出
- 拖动滑条时数值是否变化且显示更新
- 勾选框点击后对勾状态是否切换
- 显示
stat fps或使用stat unit观察帧耗时是否出现明显异常
如果面板显示但按钮不响应,优先检查输入事件是否把鼠标坐标转换正确。常见错误是拿到了屏幕绝对坐标却忘了转换为局部坐标。如果控件位置正确但点击无反馈,检查SetMouseButtonDown是否在每帧BeginFrame后被重置了。
6. 接口 API 与批量任务
6.1 接口设计
这套 UI 系统的对外接口其实就是FMiniUIContext的公开方法。虽然这里不是网络 API,但我们在做工具框架时依然可以把接口分成几层:
| 方法 | 作用 | 返回类型 |
|---|---|---|
BeginFrame() | 开始一帧 UI 构建,重置控件和光标位置 | void |
EndFrame() | 结束一帧构建,把控件提交给渲染器 | void |
Text(FString) | 绘制文本标签 | void |
Button(FString) | 绘制按钮,返回是否点击 | bool |
Slider(FString, float, float, float) | 绘制滑动条,返回新数值 | float |
CheckBox(FString, bool) | 绘制勾选框,返回新状态 | bool |
Separator() | 绘制分割线 | void |
SameLine() | 指示下一个控件绘制在同一行 | void |
6.2 批量构建批量渲染
从批量任务角度看,这套系统非常自然:
- 你在一个循环里可以添加几十个控件
- 渲染器一次消费完整控件数组
- 不需要为每个控件单独维护状态类
例如批量处理列表:
void UMyUISubsystem::Tick(float DeltaTime) { FMiniUIContext& MiniUI = FMiniUIContext::Get(); MiniUI.BeginFrame(); MiniUI.Text("Batch Task Monitor"); for (const FAssetData& Asset : BatchAssets) { FString StatusText = FString::Printf(TEXT("[%s] %s"), *Asset.AssetName.ToString(), *GetAsyncStatusString(Asset)); MiniUI.Text(StatusText); if (MiniUI.Button("Remove")) { BatchAssets.Remove(Asset); break; // 注意:不要在迭代时直接修改数组,先标记再处理 } } MiniUI.EndFrame(); }这里说明一个小坑:在循环控件期间如果有按钮返回“需要删除当前项”,不要直接在迭代器里 Modify 数组。正确做法是记录标记,在循环结束后删除或复制数组后遍历。否则会触发迭代器失稳,导致崩溃或内容错乱。
6.3 通信与其他系统
由于这套 UI 不依赖网络,它和外部系统之间主要靠“数据读取 + 数据回调”完成:
- 读取 Actor 的状态值显示在面板中
- 点击按钮时调用回调函数
- 滑条数值修改后写回配置对象
- 面板开关状态记录在 Editor PerUser 配置文件中
如果你需要把这套面板接入游戏内远程调试接口,可以单独加一个 UDP 或 HTTP 服务,把控件的修改命令发出去。但这里要强调安全边界:涉及端口开放、外部访问、命令执行的接口,必须限制访问范围,只能绑定本机回环地址,做好鉴权,禁止未授权加载外部命令。
7. 资源占用与性能观察
7.1 Slate 路径的性能特征
在 Slate 路径下,每一帧你会产生多条FSlateDrawElement。每个文本标签、背景矩形都是一次元素提交。
性能观察方式:
- 打开控制台输入
stat slate - 查看
DrawElements数量 - 查看
RenderThread耗时 - 对比空面板和有面板时的
stat unit数据
一般几十个控件的调试面板,在普通台式机和笔记本上都感觉不到明显掉帧。但如果达到数百个控件,并且每个控件都有文本,Slate 的字符缓存和文本布局会成为开销热点。
7.2 优化手段
如果你发现自己绘制的控件数量较多,可以从以下几方面优化:
- 静态文本合并:不变的文本合并成一张纹理,不需要每帧重新生成
- 背景矩形合并:相同颜色的矩形合并成一个顶点区域
- 避免透明重叠:大量半透明矩形叠在一起会产生多次混合开销
- 减少每帧动态创建的 FString:尽量复用字符串缓冲
- 控件按需求裁剪:显示范围外的控件直接丢弃
7.3 CPU 还是 GPU
这里要分清楚:
- Slate 的
MakeBox和MakeText使用的是引擎已有的 Slate 渲染器,管线由引擎管理 - 如果走独立 RHI 路径,顶点数据在 CPU 端构建,然后一次性提交到 GPU
所以 CPU 端的开销主要在控件逻辑和文本布局,GPU 端开销通常在像素填充和混合次数。调试面板一般小尺寸、局部区域,所以 GPU 压力很低。如果你把面板拉伸到全屏并且透明控件叠很多层,帧率就会明显下降。
7.4 显存占用观察
这套 UI 系统在 Slate 路径下几乎不产生额外显存占用。字体纹理和 Slate 缓存在引擎启动时已经分配。在独立 RHI 路径下,你会为顶点缓冲申请少量显存,通常几 MB 以内。
显存具体占用需要按本机分辨率和控件数量实测。观察方式:控制台输入stat rhi查看总显存使用趋势,以及stat memory查看 CPU 侧内存。
8. 常见问题与排查方法
下面按实际开发中容易踩到的坑逐一列出排查方法。
| 问题现象 | 可能原因 | 排查方式 | 解决方案 |
|---|---|---|---|
| 插件编译失败,提示缺少 Slate 模块 | Build.cs 未添加依赖 | 检查编译日志中的错误模块名 | 在 PublicDependencyModuleNames 中加 Slate、SlateCore |
| 编译成功但面板打不开 | 插件未启用或命令未注册 | 检查插件启用状态和控制台命令 | 在项目设置中启用插件,确认 LoadingPhase 正确 |
| 面板打开后一片空白 | 渲染回调没有拿到控件数据 | 在 EndFrame 里加 Log | 确认 BeginFrame/EndFrame 调用时序 |
| 按钮无法点击 | 鼠标坐标未转换 | 打印 MousePos 与按钮矩形范围 | 使用 AbsoluteToLocal 转换坐标 |
| 按钮一直处于按下状态 | SetMouseButtonDown(false)没有触发 | 检查 MouseUp 事件是否被其他控件拦截 | 确保 Slate 控件接收 Focus |
| 滑动条数值跳跃 | 鼠标坐标转换错误 | 打印 Slot 坐标与鼠标坐标 | 校正坐标系,注意是否受 DPI 缩放影响 |
| 中文文本显示乱码 | Slate 字体未支持中文 | 检查字符缓存 | 更换支持中文的字体,或使用局部的字体策略 |
| 帧率明显降低 | 控件数量过多或文本变动频繁 | 观察stat slate | 静态文本合并、减少动态字符串 |
| 控件位置飘移 | 光标推进逻辑不完整 | 检查 SameLine 和 Separator 对 CursorPos 的影响 | 统一管理光标推进规则 |
| 编辑器崩溃但无明确日志 | 正在遍历控件时修改了数组 | 检查是否有删除操作 | 使用标记删除或复制数组 |
8.1 具体案例:DPI 缩放下鼠标错位
在 Windows 高 DPI 环境下,Slate 的坐标单位与物理像素可能存在差异。常见现象是按钮实际点击区域偏移,或在 150% 缩放下按钮位置和鼠标位置不匹配。
排查手段是启用编辑器的高 DPI 日志:
Editor.Experimental.ShouldAllowHighDPISupport true或者在鼠标事件里输出坐标来检查。
UE_LOG(LogTemp, Warning, TEXT("Mouse Screen: %s, Mouse Local: %s"), *MouseEvent.GetScreenSpacePosition().ToString(), *MyGeometry.AbsoluteToLocal(MouseEvent.GetScreenSpacePosition()).ToString());对比这两个值就知道坐标转换是否正确。
8.2 具体案例:字体缺失导致中文按钮显示空白
如果你绘制中文按钮,但按钮背景正确显示而文字空白,大概率是字体不支持中文字符集。在编辑器里可以使用引擎自带的NormalFont,但很多编辑器环境会绑定本地字体。更稳妥的做法是:
static const FSlateFontInfo ChineseFont = FCoreStyle::GetDefaultFontStyle("Bold", 12);如果依然不支持中文,需要换成你的项目中已配置的中文字体资产。
8.3 具体案例:点击穿透
如果你在游戏内叠加层的 Actor 或 PlayerController 中绘制 UI,注意点击事件是否会被 UIT 的可见性遮挡。如果你只是想在调试时用鼠标控制面板,建议在 PlayerController 的InputMode中同时保留UI Only和Game And UI特性,避免鼠标事件被游戏业务消费。
使用 Slate 独立窗口(如编辑器面板)时,底层窗口会自然消费鼠标事件。但如果你在 Viewport 上叠加了自定义 Slate 控件或 HUD,需要手动设置Visibility和HitTestable:
SetVisibility(EVisibility::SelfHitTestInvisible);8.4 具体案例:批量循环删除控件
之前提到过遍历数组时删除元素会崩溃,这里给一个安全写法:
TArray<int32> PendingRemove; for (int32 i = 0; i < BatchAssets.Num(); i++) { if (MiniUI.Button("Remove")) { PendingRemove.Add(i); } } for (int32 i = PendingRemove.Num() - 1; i >= 0; i--) { BatchAssets.RemoveAt(PendingRemove[i]); }从后往前删除可以避免下标偏移问题。
9. 最佳实践与使用建议
9.1 第一次先做最小闭环
不要一开始就追求和 ImGui 完全一致的功能集。先实现“文本 + 按钮 + 滑条 + 面板背景 + 输入点击”这五个基础能力,跑通一遍后再扩展勾选框、组合框、折叠分组和颜色编辑控件。这个顺序能保证每次新增功能都不破坏已有基础。
9.2 布局系统分开管理
即时模式 UI 最大的陷阱是光标推进逻辑和控件逻辑混在一起。建议把布局计算独立出来,提供SameLine、NextColumn、Group这类辅助接口。这样后面调整行距、缩进时不会把点击判定逻辑改坏。
9.3 状态与绘制分离
即使是即时模式,设置开关、滑条数值、下拉选项这类状态依然需要外部保存。建议创建一个FDrawContext结构,其中包含:
- 控件状态存储(
TMap<FString, float>) - 输入状态快照
- 当前光标位置
- 样式配置
把上下文对象作为参数传入控件绘制接口,而不是所有状态都挂在全局单例上。虽然全局单例在工具面板场景下简单,但一旦变复杂或需要多个面板同时存在,全局单例就会成为瓶颈。
9.4 多面板支持
如果同一个 UI 系统需要在多个窗口中使用,每个面板应该有独立的上下文实例,而不是复用全局上下文。全局上下文只存放公共输入状态和字体,面板画布数据存放在每个面板实例自己的FMiniUIContext中。
9.5 性能基线
第一次跑通后,记录空面板和基础面板的stat unit数值。后续每新增一个控件类型,都对照这个基线,避免不知不觉把性能拖垮。关于质量,工具面板可以暂时不在乎抗锯齿,但背景和前景的对比度一定要重视。半透明背景配合亮色前景文本,能明显提升可读性。
9.6 合规与代码管理
如果计划开源这套 UI 系统,需要注意:
- 自己实现核心代码,不要照搬第三方库的源码实现
- 在 README 中说明参考了 ImGui 的交互设计思想,但代码独立编写
- 使用 UE 引擎时遵守 Epic Games 的引擎许可协议
- 如果包含字体资产,注意字体的可嵌入许可
9.7 建议保留的辅助工具
建议把以下工具纳入你的调试组合:
stat fps观察帧率stat slate观察 Slate 绘制元素数量stat unit观察游戏线程与渲染线程耗时r.RHI.MaximumFrameLatency在需要排查输入延迟时使用consolevars注册一个MiniUI.DebugDraw命令来切换调试可见性
10. 总结与下一步
在不使用 ImGui 的情况下重新实现一套类似 ImGui 的 UI 系统,这件事最值得尝试的点在于:它能让你理解即时模式 UI 和保留模式 UI 在设计和实现上的根本差异,同时给你一套完全不依赖第三方库、完全握在自己手里的工具面板方案。
先要验证的是“最小闭环”:文本、按钮、滑条、面板背景和鼠标点击。这五件事跑通后,你已经有了 UI 框架的骨架。后续再扩展勾选框、组合框、折叠分组、拖拽窗口、键盘焦点这些功能时,只是往骨架里填充模块。
最容易踩的坑有三个:
- 鼠标坐标转换错误,导致点击点偏移
- 在遍历控件时修改控件数组,导致崩溃
- 字体不支持中文,导致文字空白
优先修正这三个问题,你的面板就已经具备日常可用性。
下一步可以继续扩展的方向包括:把控件数据接入编辑器配置系统、增加自定义字体和样式、多窗口多上下文支持、控件缓存以减少动态文本构建、实现拖拽窗口位置和缩放、以及独立 RHI 渲染路径来把控件合并为单次绘制调用。
建议收藏备用。如果你正在做 UE 内部的调试工具、性能监控 HUD 或编辑器插件,这套思路可以帮你减少对第三方 UI 库的依赖,也能在需要高度定制时给你更大的自由度。