UE5.8 UMG Widget Blueprint 基础
从一次 HUD 重构说起
小张去年刚进组时,接到的第一个任务是给一款动作 RPG 做 HUD。策划案上画得很清楚:左上角血条和体力条,右下角技能轮盘,屏幕中央上方是任务提示,死亡时弹出重新加载界面。他打开 UE5.8,在 Content Browser 里右键新建了一个 Widget Blueprint,取名 WB_PlayerHUD,拖了几个 Progress Bar 和 Text 进去,连上玩家属性引用,半天不到就在屏幕上看到了自己的血条。
组长看了一眼,说能跑就行,先上测试版。小张挺高兴,觉得 UMG 不过如此。
问题是测试才开始一周,策划就抱着笔记本来找他。任务提示在 4K 显示器上小得像蚂蚁,血条在 21:9 带鱼屏上被拉得变形,背包界面打开时帧率从 120 掉到 40,更麻烦的是所有数值更新都用 Event Tick 拉线,导致网络同步时客户端 A 看到的血量和客户端 B 不一样。小张这才意识到,UMG 的门槛不在能不能把控件摆出来,而在理解它背后的对象模型、布局系统、事件机制、生命周期和性能账。
这篇文章把那次重构里学到的内容整理出来,按模块拆开讲。希望对正在做 HUD、菜单、背包界面的同学有点用。
UserWidget 与 Widget Component
UMG 里最常见的两个概念是 UserWidget 和 Widget Component。很多新手会把它们混为一谈,实际上解决的问题完全不同。
UserWidget 是一块独立的 UI 面板。你在 Content Browser 里右键创建的 Widget Blueprint,本质上就是继承自 UUserWidget 的蓝图类。它里面可以摆按钮、图片、进度条、文本,也可以写蓝图逻辑。它本身不是场景里的物体,需要被创建并添加到 Viewport 才会显示在屏幕上。屏幕 HUD、主菜单、背包、设置界面,通常都用 UserWidget 做。
Widget Component 则是挂在 Actor 上的组件,属于 3D 空间里的 UI。它把一块 UserWidget 渲染到场景中的一张虚拟面片上,可以贴在 NPC 头顶显示名字,也可以做场景里的交互终端、驾驶舱仪表盘、广告牌。玩家绕着它走,能看到透视和遮挡关系。Widget Component 负责的是「世界空间 UI」,而不是「屏幕空间 HUD」。
下面这张图展示了两者在 UE5.8 渲染管线中的位置。
flowchart TD
subgraph game ["游戏逻辑层"]
end
subgraph ui ["UMG 中间层"]
end
subgraph render ["渲染层"]
end
subgraph world ["世界空间"]
pc["PlayerController"]
hud["HUD 逻辑"]
uw["UserWidget"]
wc["Widget Component"]
vp["Viewport"]
slate["Slate 渲染"]
rhi["RHI"]
mesh["Actor Mesh"]
comp["Widget Component 面片"]
end
pc --> uw
hud --> uw
uw --> vp
vp --> slate
slate --> rhi
wc --> uw
mesh --> comp
comp --> wc
wc --> rhi图中左侧是屏幕 HUD 的链路。PlayerController 创建 UserWidget,UserWidget 被 Add to Viewport 后进入 Slate 的 Viewport 列表,最终走 Slate 渲染到 RHI。右侧是世界空间 UI 的链路。Widget Component 作为 ActorComponent 挂载在 Actor 上,它内部也维护了一个 UserWidget,但这个 UserWidget 被渲染到一张 3D 面片上,随 Actor 一起被场景管理器绘制。
在 C++ 里创建并显示一个 UserWidget 的代码大致如下:
void AMyPlayerController::ShowHUD()
{
if (PlayerHUDClass)
{
PlayerHUD = CreateWidget<UUserWidget>(this, PlayerHUDClass);
if (PlayerHUD)
{
PlayerHUD->AddToViewport(0);
}
}
}这段代码在 PlayerController 里执行,CreateWidget 负责实例化 Widget,AddToViewport 把它塞进视口栈。ZOrder 参数为 0 表示最底层,数值越大越靠近屏幕前方。
Widget Component 则不需要手动 AddToViewport,只需要在 Actor 上添加组件并指定 Widget Class,编辑器里就能看到预览:
UWidgetComponent* Nameplate = NewObject<UWidgetComponent>(this);
Nameplate->SetWidgetClass(UUserWidget::StaticClass());
Nameplate->SetDrawSize(FVector2D(256.0f, 64.0f));
Nameplate->RegisterComponent();理解这一层区分后,做 HUD 时不会把 3D 世界 UI 的代码写到 PlayerController 里,做头顶血条时也不会傻乎乎地去算屏幕投影。
UMG 面板与常见控件
UMG 的布局系统借鉴了前端思路,核心是各种 Panel 容器。面板决定子控件怎么排、怎么拉伸、怎么对齐。常见面板有 Canvas Panel、Vertical Box、Horizontal Box、Uniform Grid Panel、Overlay、Scroll Box、Size Box 等。
Canvas Panel 是最自由的。每个子控件用 Anchors 和 Offsets 定位,适合做绝对定位的 HUD,比如把血条钉在左上角、把小地图钉在右上角。Anchors 用 Min 和 Max 两个 UV 坐标定义一个锚点矩形,子控件的位置和大小可以相对于这个矩形做拉伸。这是处理多分辨率适配的关键。
Vertical Box 和 Horizontal Box 是一维自动排列容器,分别垂直和水平堆叠子控件。做设置菜单里的一排按钮、背包里的一列物品槽,用它们很顺手。每个子控件的 Slot 可以设置 Padding、Horizontal Alignment、Vertical Alignment、Fill 权重。
Overlay 允许子控件互相叠加。做按钮时,底层放背景图,中间放文字,右上角放小红点提示,就可以用 Overlay。
常见控件中,Progress Bar 和 Slider 常用于血条、体力条、音量条;Text 和 Rich Text Block 用于显示文字,后者支持换色、加粗、超链接;Image 用于显示贴图,支持 Brush、Tint、Opacity;Button、Check Box、Editable Text 用于交互;List View、Tile View、Tree View 用于数据量大的列表,比如背包、商店、好友列表。
下面这张图展示了 HUD 中几种典型面板的组合关系。
flowchart TD
A["Canvas Panel 根节点"] --> B["Vertical Box 左上角"]
A --> C["Overlay 中央上方"]
A --> D["Canvas Panel 右下角"]
B --> B1["Progress Bar 血量"]
B --> B2["Progress Bar 体力"]
B --> B3["Text 数值"]
C --> C1["Image 任务背景"]
C --> C2["Rich Text Block 任务描述"]
D --> D1["Horizontal Box 技能槽"]
D1 --> D1a["Image 技能 A"]
D1 --> D1b["Image 技能 B"]
D1 --> D1c["Image 技能 C"]做多分辨率适配时,DPI 缩放是绕不开的。UMG 默认用用户配置的 DPI Scale 把设计分辨率下的尺寸映射到实际屏幕。如果设计时按 1920x1080 布局,在 4K 屏上 Scale 可能是 2.0,所有尺寸会等比放大。这个缩放可以用公式近似表示:
其中 是项目设置里的参考 DPI,常见取值为 96; 是当前显示设备的物理 DPI。UMG 实际使用的 Scale 还会受到 Curve 配置影响,策划和美术通常会在 Project Settings 的 DPI Scaling 里拉一条曲线,避免小屏幕缩放过大。
小张第一次重构时犯了一个错误:所有尺寸都写死像素值,血条宽度 300,高度 30。在 4K 屏上变成了一条细线。后来他改用 Size Box 包裹 Progress Bar,把宽度设成屏幕宽度的 18% 左右,再让 Progress Bar 填充满 Size Box,问题才解决。
绑定与事件
UI 不是静态图片,它需要响应游戏状态变化。UMG 里最常见的两种机制是属性绑定和事件分发。
属性绑定让 Widget 里的某个属性自动跟随某个数据源。比如 Text 的 Text 属性可以绑定到一个返回 FText 的函数,这个函数每帧读取玩家血量并格式化。绑定在蓝图中很直观:选中 Text,在 Content 旁边选 Bind,然后 Create Binding,写一个 GetHealthText 函数即可。它的优点是简单,缺点是如果函数复杂,每帧调用会有开销。
事件分发更适合状态变化驱动的更新。玩家受击时血量从 100 降到 80,这种事件一年才发生几次,没必要每帧去算。可以在玩家类里定义一个 OnHealthChanged 事件,UMG 初始化时绑定这个事件,血量变化时只更新一次 UI。网络环境下尤其要这样做,避免 Tick 导致不同客户端的 UI 刷新节奏不一致。
下面这张序列图展示了玩家受击后,HUD 血条如何被更新。
sequenceDiagram
participant Enemy as "敌人"
participant Server as "服务器"
participant PC as PlayerController
participant Char as "玩家角色"
participant HUD as "UserWidget HUD"
Enemy->>Server: 发起攻击
Server->>Server: 计算伤害
Server->>Char: ApplyDamage
Char->>Char: 修改 CurrentHealth
Char->>PC: OnHealthChanged 复制/回调
PC->>HUD: 调用 UpdateHealthBar
HUD->>HUD: 设置 Progress Bar 百分比这个流程里,真正的数值修改发生在服务器上的玩家角色里。如果是联网游戏,CurrentHealth 应该标记为 Replicated,服务器把新值同步到 owning client。客户端收到后,要么在 RepNotify 里通知 HUD,要么让 HUD 在初始化时绑定角色上的委托。不要在 HUD 的 Event Tick 里直接读角色血量,否则本地客户端 Tick 频率和服务器同步频率不一致,会出现 UI 闪跳。
蓝图里绑定事件的典型做法:
UFUNCTION(BlueprintCallable, Category = "HUD")
void BindHealthEvents();在 UserWidget 的 Native Construct 中拿到 Owning Player Pawn,转成玩家角色类型,然后绑定委托:
void UMyHUDWidget::NativeConstruct()
{
Super::NativeConstruct();
AMyPlayerCharacter* Character = Cast<AMyPlayerCharacter>(GetOwningPlayerPawn());
if (Character)
{
Character->OnHealthChanged.AddDynamic(this, &UMyHUDWidget::UpdateHealthBar);
}
}绑定时一定要记得在 NativeDestruct 里解除绑定,否则 Widget 被销毁后委托还在,下次事件触发会访问野指针。
void UMyHUDWidget::NativeDestruct()
{
AMyPlayerCharacter* Character = Cast<AMyPlayerCharacter>(GetOwningPlayerPawn());
if (Character)
{
Character->OnHealthChanged.RemoveDynamic(this, &UMyHUDWidget::UpdateHealthBar);
}
Super::NativeDestruct();
}按钮点击事件则简单很多。在 Widget Blueprint 里选中 Button,在 Details 面板找到 OnClicked,点击加号,就能生成一个事件节点。这个事件只在本地触发,适合做打开菜单、切换武器、释放技能这些本地响应。如果点击结果需要影响游戏状态,比如使用药水,应该让事件调用 PlayerController 或 Character 上的 RPC 函数,由服务器裁决。
自定义 Slate 控件简介
UMG 底层是 Slate,一套 C++ 编写的 UI 框架。Slate 不依赖反射,运行效率比 UMG 高一些,但写起来更繁琐。大部分项目用 UMG 就够了,但遇到需要高度自定义绘制或大量动态控件时,可以考虑自定义 Slate 控件并在 UMG 里包装。
自定义 Slate 控件的核心是继承 SCompoundWidget 或 SWidget,重写 OnPaint 和 ComputeDesiredSize。SCompoundWidget 适合组合现有控件,SWidget 适合完全自绘。一个最简单的 Slate 控件看起来像这样:
class SMyCustomWidget : public SCompoundWidget
{
public:
SLATE_BEGIN_ARGS(SMyCustomWidget) {}
SLATE_END_ARGS()
void Construct(const FArguments& InArgs);
virtual int32 OnPaint(
const FPaintArgs& Args,
const FGeometry& AllottedGeometry,
const FSlateRect& MyCullingRect,
FSlateWindowElementList& OutDrawElements,
int32 LayerId,
const FWidgetStyle& InWidgetStyle,
bool bParentEnabled) const override;
};如果想在 UMG 里用这个控件,需要再写一个 UWidget 子类作为胶水层。UWidget 负责暴露属性到蓝图编辑器,Slate 负责实际渲染。这种写法在需要画复杂图表、自定义血条形状、绘制大量数据点时比较有用。
小张的背包项目里有 200 多个物品格子。如果用普通的 Uniform Grid Panel 加 200 个 Button,打开背包时会卡一下。后来他改用 List View 加自定义 Entry Widget,只创建视口内可见的格子,滚动时再复用,性能好了很多。这其实是 UMG 层面的优化,没有用到 Slate,但思路是一样的:不要为看不见的东西买单。
Widget 动画
UMG 的动画系统让 UI 动起来。打开菜单时的淡入、按钮点击时的缩放、血条受击时的抖动、任务提示的滑入滑出,都可以用 Widget Animation 做。
在 Widget Blueprint 的下方面板里有一个 Animations 标签,点击 Add Animation 可以创建新动画。动画由多个 Track 组成,每个 Track 对应一个控件的某个属性。比如可以让 Image 的 Render Transform Scale 从 0.8 变到 1.2 再变回 1.0,实现按钮点击的弹性反馈。
动画播放有三种主要方式:Play Animation、Play Animation Forward、Play Animation Reverse。可以设置播放速度、循环次数、起始时间。蓝图里直接拖节点就能用,C++ 里通过 UUserWidget::PlayAnimation 调用。
一个常见的需求是血条受击时变红闪烁。实现方式是在血条 Progress Bar 上做一个动画 Track,控制 Fill Color and Opacity 的颜色,从绿色插值到红色,持续 0.2 秒,再插值回绿色。每次血量下降时触发这个动画。
动画插值可以用线性插值描述。假设颜色从 变到 ,动画进度 ,则当前颜色:
这里的 不是真实时间,而是归一化后的动画进度。UMG 内部会根据 Easing Function 对 做再映射,比如 Ease In Out 会让变化先慢后快再慢。蓝图里选 Ease 类型时,本质上就是在选这个再映射函数。
小张做技能冷却时踩过一个坑:他用 Tick 自己算冷却圈的角度,每帧更新 Image 的 Material Parameter。后来发现用 Widget Animation 控制 Material Parameter Collection 里的值更简单,也更容易和音效同步。冷却结束时让动画触发一个 Finished 事件,播放音效,比 Tick 里判断 更稳。
定时器与生命周期
Widget 的生命周期和 Actor 类似,但要更短。一个 UserWidget 从 Construct 到 Destruct,可能只持续几秒钟,比如一个弹出提示。理解生命周期有助于避免空指针和内存泄漏。
主要回调有:
- Construct:蓝图构造,适合初始化可见状态。
- Native Construct:C++ 层构造,适合绑定事件、初始化 Slate 控件。
- Native On Initialized:初始化完成,只调用一次。
- Native Tick:每帧调用,默认关闭,需要手动设置 bCanEverTick。
- Native Destruct:销毁前调用,适合解绑事件、清理资源。
下面这张图展示了 UserWidget 的完整生命周期。
graph LR
A["Create Widget"] --> B["Native On Initialized"]
B --> C[Construct]
C --> D["Native Construct"]
D --> E["Add To Viewport"]
E --> F{Tick 启用?}
F -->|"是"| G["Native Tick 每帧"]
F -->|"否"| H["事件驱动更新"]
G --> I["Remove From Parent"]
H --> I
I --> J["Native Destruct"]
J --> K["GC 回收"]图中需要注意两点。一是 Tick 默认不启用,如果确实需要每帧更新,要在构造函数里写 bCanEverTick = true,并在不需要时关闭。二是 Native Destruct 之后,Widget 对象不一定立刻被回收,因为可能还有强引用存在。如果别的地方还拿着这个 Widget 指针,GC 不会释放它。
定时器在 UI 里经常用到,比如显示 3 秒提示后自动隐藏、冷却倒计时、连击倒计时。UMG 内部没有专门的 UI Timer,通常用 GetWorld()->GetTimerManager() 设置。在 Widget 里使用定时器时,最好用 Set Timer by Function Name,并在 NativeDestruct 里 Clear Timer:
void UMyHUDWidget::StartDamageIndicator(float Duration)
{
GetWorld()->GetTimerManager().SetTimer(
DamageIndicatorTimerHandle,
this,
&UMyHUDWidget::HideDamageIndicator,
Duration,
false);
}
void UMyHUDWidget::NativeDestruct()
{
GetWorld()->GetTimerManager().ClearTimer(DamageIndicatorTimerHandle);
Super::NativeDestruct();
}这个定时器在 Duration 秒后触发 HideDamageIndicator。如果 Widget 提前被销毁,ClearTimer 保证回调不会跑到已经回收的对象上。
小张最初在背包关闭时直接 Set Visibility 隐藏,没有 Remove From Parent。结果背包界面虽然看不见,但仍在 Tick 里更新所有格子的图片,帧率一直很低。后来他改成真正的创建/销毁流程,只在打开背包时 Create Widget,关闭时 Remove From Parent 并清空引用,帧率立刻回来了。
性能基础
UI 性能容易被忽视,但它直接影响玩家体验。UMG 里常见的性能陷阱有 Tick 滥用、过多重绘、过度绑定、材质参数频繁修改、动态分辨率下的过度采样等。
Tick 是最常见的坑。很多初学者习惯在 Event Tick 里读取玩家属性并更新 UI。单个 Widget 可能没问题,但 HUD、背包、地图、任务日志同时打开时,Tick 开销会叠加。UMG 的 Tick 是在 Slate 线程里跑的,虽然不重,但积少成多。能用事件驱动就用事件驱动。
Invalidation Box 是 UE 提供的一个优化容器。它下面的子控件只有在显式标记为 Invalidated 时才会重绘。适合做静态背景、复杂的装饰性 UI。把不常变化的部分包在 Invalidation Box 里,可以显著降低 Slate 的渲染开销。
材质参数频繁修改也会带来开销。如果每个血条都用不同的 Dynamic Material Instance,并且每帧改参数,Draw Call 和材质状态切换都会增加。批量相同外观的控件可以共享同一个 Material Instance,只在数值变化时改一次参数。
控件数量本身也是成本。背包有 200 个格子,如果每个格子都是完整的 Button + Image + Text + Border,并且同时存在于视口中,Slate 需要维护 800 个以上的 Slate Widget。换成 List View 或 Tile View,只保留可见项,数量会降到 20 到 30 个。
性能优化可以量化为帧时间。假设一帧预算是 16.67 毫秒(对应 60 FPS),UI 渲染应控制在 1 到 2 毫秒以内。如果 UI 占用 毫秒,留给游戏逻辑和渲染的时间就是 毫秒。当 超过 3 毫秒时,大概率会在复杂场景下掉帧。
小张用 Stat Slate 命令看了背包打开前后的 Slate 耗时。优化前每次打开背包 Slate 耗时从 0.8 毫秒跳到 4.2 毫秒,优化后稳定在 1.1 毫秒左右。这个对比让他对 Invalidation Box 和 List View 的价值有了直观认识。
UE5.8 UMG 改进
UE5.8 在 UMG 上做了不少细节改进,虽然没有翻天覆地,但对日常开发很实在。
第一个是 Widget Reflector 的增强。反射器现在可以更清楚地显示每个 Slate 控件的层级、渲染耗时和 Invalidation 状态。排查哪个控件导致重绘变慢时,这个功能比 Stat 命令更直观。
第二个是 Retainer Box 和 Invalidation Box 的默认行为调整。UE5.8 对这两类容器的渲染流程做了优化,减少了不必要的 GPU 回读,移动设备上的功耗下降明显。对于做手游项目的团队,这个改动比较香。
第三个是 Material Designer for UMG 的改进。5.8 引入了更多针对 UI 的 Material Function,比如圆角遮罩、九宫格拉伸、SDF 文字描边。做现代扁平风 HUD 时,不需要每次都在 Photoshop 里切图,直接在材质里做圆角和描边,省了不少美术资源。
第四个是编辑器预览的多 DPI 模拟。Widget Designer 里可以直接切换 100%、150%、200% DPI 预览,不需要每次打包到高分辨率显示器上看效果。小张重构 HUD 时反复用这个功能调 Anchors 和 Scale Box,比在真机上反复调整要快很多。
第五个是异步加载 Widget Class 的改进。Create Widget Async 现在支持更细粒度的加载回调,打开大型背包时可以先显示占位图,等资源加载完再替换真实内容,避免卡顿。
这些改进都不是革命性的,但加在一起让 UMG 在 5.8 里更稳、更快、更容易调试。对于以 HUD 和菜单为核心体验的项目,值得专门升级一次。
小结
小张那个 HUD 最后重构完成时,血条和体力条用 Vertical Box 加 Progress Bar,任务提示用 Overlay 加 Rich Text Block,技能轮盘用 Horizontal Box 加 Image,死亡界面单独做了一个 UserWidget,只在需要时创建。所有数值更新都改成事件驱动,背包用 List View 加自定义 Entry,动画用 Widget Animation 做冷却和受击反馈。4K 和 21:9 都跑了一遍,帧率稳定,策划终于没再来找他了。
UMG Widget Blueprint 不是难学,而是细节多。UserWidget 和 Widget Component 分清使用场景,面板和控件选对容器,绑定和事件用事件驱动替代 Tick,动画用内置系统而不是自己算,生命周期里注意 Construct 和 Destruct 的成对操作,性能上盯住 Tick、重绘和控件数量,基本就不会出大问题。UE5.8 在这些基础上又补了一些工具,日常开发更顺手。