开篇:一个让人抓狂的下午
老夏负责的第三人称 RPG 项目最近要上一个新功能:NPC 头顶显示名字和血条,地面掉落物要有名字提示,场景里的控制台需要可交互面板。策划同学在需求文档里贴了三张参考图,美术已经把 UI 素材切好,剩下的活全砸给了程序组。
老夏打开 UE5.8,先拖了一个 Widget Component 到 NPC 身上,把做好的血条 Widget 赋进去,结果运行一看,血条要么被脑袋挡住,要么大得像块门板。场景控制台的面板更离谱,站到侧面看整个界面被拉伸得面目全非。一个晚上过去,他发现自己对 Widget Component 的理解还停留在:拖进去,赋材质,祈祷。
这篇文章就从老夏踩过的坑出发,把 Widget Component 与 3D UI 在 UE5.8 里的关键概念和实践方法串一遍。
Widget Component 的 Screen Space 与 World Space
Widget Component 是 Unreal Engine 提供的在 3D 场景中渲染 UMG Widget 的组件。同一个 Widget,既能在屏幕上作为 HUD 显示,也能贴在模型表面变成场景的一部分。两种呈现方式分别叫做 Screen Space 与 World Space。
Screen Space 下,Widget 被投影到摄像机近平面,本质还是 2D HUD。World Space 下,Widget 被渲染到一张动态纹理上,再贴到场景中的面片。这个面片会随视角变化产生透视,也会受光照和遮挡影响。
区别不止这些。Screen Space 的 Widget 分辨率取决于屏幕,文字边缘清晰。World Space 的 Widget 分辨率由组件的 Draw Size 决定,拉太近会看到像素。Screen Space Widget 总是面向相机。World Space Widget 有固定朝向,可以做成 billboard,也可以固定贴在物体表面。
flowchart TD
subgraph engine ["UE5.8 渲染管线"]
slate["Slate UI"]
rhi["RHI"]
end
subgraph scene ["场景"]
wc["Widget Component"]
end
subgraph ui ["UI 资产"]
uw["User Widget"]
end
uw --> slate
slate --> rhi
rhi --> wc用老夏的项目举例,NPC 头顶的血条其实更适合 Screen Space。玩家从任何角度看,名字和血量都保持 readable,不会被身体遮挡。场景控制台的操作界面则必须放在 World Space,因为它要贴在模型表面,让玩家感觉那是一个真实存在的终端。
选择哪种空间没有固定答案。信息类 UI,比如名字、血量、任务标记,通常用 Screen Space,保证可读性。沉浸式 UI,比如场景里的屏幕、仪表盘、告示牌,通常用 World Space,追求空间一致性。
3D UI 的创建与配置
创建 3D UI 的第一步是新建一个 Widget Blueprint。在 Content Browser 里右键,选择 User Interface -> Widget Blueprint,命名为 WBP_NPCNameplate。然后新建一个 Actor Blueprint,添加 Widget Component,把 Widget Class 设为 WBP_NPCNameplate。
Widget Component 的关键参数集中在 Details 面板的 Widget 与 Rendering 分类下。
Space 选项决定渲染空间。Screen 表示屏幕空间,World 表示世界空间。Draw Size 控制渲染目标纹理的尺寸。常见的设置是 500x500,具体要看 UI 内容。如果文字太小,提高 Draw Size,同时在 Widget 里缩小 Canvas Panel 的缩放比例。如果 Draw Size 太小,近距离会出现马赛克。
Pivot 决定 Widget 围绕哪个点定位。头顶血条通常把 Pivot 设为 (0.5, 1.0),让 UI 底部对齐头顶。地面物品提示可以设为 (0.5, 0.0),让 UI 顶部对齐物品。Draw at Desired Size 会让组件自动按 Widget 的设计尺寸输出,省去手动算 Draw Size 的麻烦,但复杂布局下可能不准。
World Space 下还有几个额外参数。Facing Mode 决定面片朝向。Billboard 始终面向摄像机,适合名字提示。Fixed 保持组件本地坐标朝向,适合场景终端。Y Axis Rotation 与 Locked to Axis 可以限制 billboard 的旋转轴,避免 UI 完全躺平。
Layer Name 与 ZOrder 也需要留意。多个 Screen Space Widget 叠加时,ZOrder 决定前后层级。ZOrder 大的会盖住小的。World Space 下层级由半透明排序和组件在场景中的位置共同决定,必要时用 Translucent Sort Priority 手动干预。
flowchart TD
A["新建 Widget Blueprint"] --> B["设计 UI 布局"]
B --> C["创建 Actor 并添加 Widget Component"]
C --> D["设置 Widget Class"]
D --> E{选择 Space}
E -->|Screen| F["调整 Pivot 与 Offset"]
E -->|World| G["设置 Draw Size"]
G --> H["选择 Facing Mode"]
H --> I["Billboard 或 Fixed"]
F --> J["运行测试"]
I --> J老夏刚开始把 Draw Size 全部设成 100x100,结果所有 3D UI 都糊成一片。后来他改成 512x512,并在 Widget 里用 Size Box 约束实际内容大小,清晰度才上来。
他还发现不同分辨率下字号会漂移。1920x1080 看着刚好,4K 屏幕上名字小得像蚂蚁。解决办法是在 Widget 里用 DPI Scale 绑定,或者按屏幕短边比例自动缩放文本大小。
头顶血条与名称
头顶 UI 是最典型的 Screen Space Widget 应用。玩家控制的镜头会围绕角色旋转,如果血条做成 World Space,跑起来时面板会倾斜,阅读体验很差。Screen Space 能让名字和血条始终正对屏幕。
实现方式通常是:在角色蓝图的 Mesh 根节点下挂一个 Widget Component,Space 设为 Screen。接着在 Widget 里放一个垂直 Box,顶部放 Text Block 显示名字,底部放 Progress Bar 显示血量。为了让 UI 跟随角色移动,需要在 Tick 或委托里把 Widget Component 的 Screen Position 映射到角色头顶的世界位置。
具体做法有两种。简单做法是利用 Widget Component 自带的 Attach To Component,直接把组件挂到角色骨骼的 head socket。这样位置会自然跟随动画。缺点是屏幕空间下组件还是 2D 元素,如果角色跑到屏幕边缘,UI 可能被裁剪。另一种做法是手动计算投影位置,用 Project World to Screen 把头顶坐标转成屏幕坐标,再动态设置 Widget 在屏幕上的位置。
// 每帧更新头顶 UI 的屏幕位置
FVector HeadLocation = CharacterMesh->GetSocketLocation(FName("head"));
FVector2D ScreenPosition;
PlayerController->ProjectWorldLocationToScreen(HeadLocation, ScreenPosition);
// 加上偏移,避免挡住角色脸
ScreenPosition.Y -= NameplateOffset;
NameplateWidget->SetPositionInViewport(ScreenPosition);老夏选择了第一种方案。他把 Widget Component 的 Widget Space 设为 Screen,Pivot 设为底边中点,再向上偏移 80 个单位。运行后发现名字确实跟在头顶,但角色蹲下时 UI 会沉进模型里。于是他在 C++ 里加了射线检测,从 head socket 向上打一条短射线,如果撞到天花板,就把 UI 往旁边挪一点。
当同屏 NPC 很多时,头顶 UI 的数量会线性增长。每个 Widget Component 都带一份 Slate 开销,一百个 NPC 就可能让帧时间明显上升。老夏做了一个对象池,只给屏幕内的 NPC 创建血条,离开视锥就回收。配合 visibility 裁剪,同屏两百个敌人也能保持流畅。
血条更新可以直接绑定到角色的 Health 属性。在 Widget 的 Event Construct 里拿到 Owning Actor,转成自定义接口或 Gameplay Tag,监听血量变化。不要用 Tick 去轮询血量,事件驱动更省性能。
sequenceDiagram
participant P as PlayerController
participant C as Character
participant W as "Widget Component"
participant U as "Nameplate Widget"
C->>W: 绑定 Health 变化委托
P->>W: 每帧投影头顶位置
W->>U: 更新 Screen Position
C->>U: Health 变化时刷新 Progress Bar
U->>W: 通知重绘地面物品提示
地面物品提示和头顶名称类似,区别主要在信息量与触发方式。提示通常只显示物品名称,信息量小。它还需要在玩家靠近时显示,远离时淡出或隐藏。
老夏的做法是:给可拾取 Actor 添加 Widget Component,Space 设为 Screen,Widget Class 用 WBP_ItemLabel。在 Widget 里只放一个 Text Block,文字颜色根据物品稀有度变化。Widget Component 默认 Hidden in Game 设为 true,当玩家进入触发范围时再设为 false。
触发范围用 Sphere Collision 或 Box Collision 都可以。进入时把 Widget Component 的 SetVisibility 打开,离开时关闭。为了让过渡自然,可以在 Widget 里用动画控制透明度,从 0 渐变到 1。
距离缩放是地面提示的关键。太近时文字不能太大,太远时文字不能小得看不清。Screen Space Widget 的大小由 Widget 自身尺寸决定,不会随距离变化。如果希望远处也能看清,需要自己写逻辑。一个简单办法是:根据玩家与物品的距离,动态调整 Widget 的 Render Transform Scale。
假设基准距离 ,基准缩放 ,当前距离为 ,则缩放系数可以写成:
这样距离越远,UI 越大,抵消透视缩小。但这个公式在极近距离会爆炸,所以需要加限制:
可以取 100 cm。老夏在 Widget 的 Tick 里更新 Scale,后来发现 50 个掉落物同时存在时帧率掉得厉害。他把更新频率降到每 0.1 秒一次,或者只在距离变化超过 10 cm 时才更新,性能立刻回到正常水平。
graph LR
A["玩家靠近"] --> B["触发重叠事件"]
B --> C["显示 Widget Component"]
C --> D{距离是否改变}
D -->|"是"| E["计算新缩放"]
D -->|"否"| F["保持当前缩放"]
E --> G["应用 Render Transform"]
F --> H["玩家远离"]
G --> H
H --> I["隐藏 Widget Component"]交互面板与按钮
场景里的交互面板是 World Space Widget 的代表。老夏做了一个控制终端,玩家走到跟前按下交互键,终端屏幕亮起,显示几个按钮:开门、关灯、查看日志。这些按钮必须能被鼠标或手柄选中,点击后触发对应事件。
World Space Widget 要接收输入,需要满足几个条件。组件的 Space 设为 World。Receive Hardware Input 要勾选,这样鼠标事件才能命中 3D 面片。Window Focusable 建议打开,避免点击面板时同时触发角色射击。Trace Complex 或简单碰撞要设置正确,确保 Line Trace 能打到面板所在位置。
UE5.8 提供了 Widget Interaction Component,专门用于和 World Space Widget 交互。把它挂到玩家摄像机前面,设置 Interaction Distance 和 Trace Channel。当玩家注视面板时,组件会模拟鼠标光标,触发按钮的 Hover 和 Click 事件。
// WidgetInteractionComponent 简单调用示例
UWidgetInteractionComponent* Interaction = CreateDefaultSubobject<UWidgetInteractionComponent>(TEXT("Interaction"));
Interaction->SetupAttachment(Camera);
Interaction->InteractionDistance = 500.0f;
Interaction->bShowDebug = false;按钮事件直接走 UMG 的 OnClicked。在按钮的 OnClicked 里调用 Get Owning Player Pawn,再调用服务器 RPC,把操作同步给其他玩家。不要在 Widget 里直接修改游戏状态,尤其是多人模式下。
老夏第一次做交互面板时,按钮死活点不中。查了半天发现是 Widget Component 的 Collision 没开,Widget Interaction Component 的射线从面板后面穿了过去。另外一个坑是 Draw Size 太大,实际按钮命中区域和视觉区域对不上。把 Draw Size 和 Widget 里的 Canvas Panel 尺寸统一成 1024x1024 后,命中精度才稳定下来。
手柄操作需要额外处理。Widget Interaction Component 可以绑定到右摇杆,用模拟鼠标移动光标。也可以关闭虚拟光标,把按钮改成 Focus 模式,用方向键切换焦点。老夏的项目同时支持键鼠和手柄,他在 Widget 里放了一个 Focus 边框,玩家按下手柄确认键时触发当前高亮按钮。
World Space Widget 的性能注意事项
World Space Widget 不是免费的。每个 Widget Component 都会申请一张 Render Target,UE5.8 默认按 Draw Size 创建。场景中如果同时存在几十个 3D 面板,显存占用会迅速上升。
降低开销可以从三个方向入手。减少 Draw Size 是最直接的手段。降低更新频率能省 CPU。合并 Widget 则能减少 Draw Call。
如果面板内容是静态的,比如告示牌,把 Widget Component 的 Tick Mode 设为 Disabled,并取消 Redraw When Screen Size Changes。同一片区域的多个小面板可以合并成一张大纹理,通过 UV 偏移区分,减少 Draw Call。
还有一个细节是 Translucent Sort Priority。多个半透明 Widget 叠加时,排序错误会导致闪烁或前后关系混乱。把优先级设成不同值可以强制排序,但要避免频繁变动。
老夏在村庄场景里放了二十块路牌,每块都是独立的 World Space Widget。打包后在低端机上帧率不稳。他把路牌合并到一个 Actor 里,共用一张 Render Target,再按距离 LOD 动态隐藏远处的路牌,性能提升了一大截。
距离 LOD 是另一个有效手段。五米外的路牌可以隐藏 Widget Component,十米外的只保留静态贴花。UE5.8 的 HLOD 对 Widget Component 支持有限,所以尽量手动控制可见性。Console 命令 r.WidgetComponent.ScreenPercentage 也能降低 World Space Widget 的渲染分辨率,适合在低端机上临时救急。
遮挡与距离缩放
3D UI 必须处理被场景物体挡住的情况。World Space Widget 默认按半透明材质渲染,会被墙、门、其他角色遮挡。Screen Space Widget 不会被场景几何体遮挡,除非主动做裁剪。
如果被遮挡时仍然显示,玩家会觉得信息不真实。常见的做法是用 Line Trace 判断 Widget 与摄像机之间是否有遮挡物。有遮挡时降低透明度或隐藏,无遮挡时正常显示。
遮挡检测的频率不必每帧都做。老夏把检测放到 0.2 秒一次,用 Visibility 通道从玩家摄像机向 Widget 位置打射线。命中遮挡物且不是拥有者本人时,把 Widget 的 Opacity 降到 0.2,或者直接 SetVisibility 为 Hidden。
距离缩放前面已经提过。World Space Widget 本身会随距离变小,远处可能看不清。如果信息必须远距离可读,有两种方案。一种是放大 Draw Size,但会牺牲近距离清晰度。另一种是切换到 Screen Space,并用距离限制最大显示范围。
可以用距离阈值做切换。当玩家与目标距离小于 时显示 World Space 面板,大于 时隐藏,中间区域淡出。透明度可以按线性插值:
老夏在交互面板上用了这个公式。玩家走近时面板完全不透明,走远时渐渐隐去,超出 5 米彻底消失。效果比直接弹出消失自然得多。
更高级的做法是用 Custom Depth 或 Stencil Mask。把 Widget Component 的材质改成支持 Stencil 测试,被遮挡时只显示轮廓线,不被遮挡时正常显示。这种效果在竞技游戏里常见,能同时保证信息可读与空间真实感。实现起来需要配合 Post Process Material,成本比简单透明度切换高,但视觉表现更好。
多人游戏中的 3D UI
多人游戏让 3D UI 变得复杂。一个 Widget Component 只存在于某个客户端或服务器上,不同玩家看到的内容可能不一样。头顶血条要对所有玩家可见,交互面板只对正在操作的玩家显示,地面物品提示则取决于每个玩家是否靠近。
关键原则是:Widget Component 本身不要放在服务器上同步。UI 属于表现层,应该根据服务器同步的游戏状态在本地创建和更新。可以在 Actor 的 BeginPlay 里为本地控制器创建 Widget Component,或者把 Widget Component 的 Replicates 关闭,只在 Owner Client 生成。
头顶血条通常在每个客户端本地生成。服务器同步 Health 数值,客户端收到后更新本地血条。如果血条挂在 replicated actor 上,默认会同步到所有客户端,造成不必要的网络流量。更好的做法是把 Widget Component 的 Net Load on Client 关闭,或者在 Character 的 PossessedBy 里只为本地玩家创建。
交互面板涉及输入 ownership。当玩家 A 操作终端时,其他玩家不应该能看到 A 的光标或选中状态。Widget Interaction Component 只存在于玩家 A 的本地 Controller,面板按钮的事件也只在 A 的客户端触发。触发后通过 Run on Server RPC 修改游戏状态,再由 Game State 同步给所有客户端。
graph TD
A["客户端 A 点击面板按钮"] --> B["本地触发 OnClicked"]
B --> C["调用 Server RPC"]
C --> D["服务器验证并修改状态"]
D --> E["通过 RepNotify 同步"]
E --> F["所有客户端更新本地 UI"]
F --> G["玩家 B 看到门打开"]地面物品提示按玩家距离本地计算,不需要服务器参与。每个客户端自己检测与物品的距离,控制 Widget 的显示和缩放。这样网络负担最小,不过所有客户端都需要有物品的位置信息,所以掉落物 Actor 的位置仍需要同步。
老夏在多人测试时发现,两个玩家同时看同一个 NPC,血条会闪烁。原因是 Widget Component 默认 Tick 更新,两个客户端的 Widget 渲染不同步。他把 Health 更新改成 RepNotify,只在数值变化时刷新 Widget,闪烁问题就解决了。
Listen Server 模式下要格外小心。作为服务器的玩家看到的 Widget 和客户端可能混在一起。老夏习惯把所有 UI 创建逻辑放到 Client 事件里,用 IsLocallyControlled 做判断,确保服务器端不创建多余的 Widget。Dedicated Server 则简单得多,因为服务器没有渲染,UI 全部由客户端自己负责。
结语
Widget Component 把 UMG 从屏幕拉进了 3D 世界,也让 UI 设计多了很多需要考虑的因素。Screen Space 保证可读性,World Space 提供沉浸感,两者没有优劣,只有场景适不适合。头顶血条、地面提示、交互面板三种案例覆盖了日常开发中最常见的需求。
老夏在项目收尾时整理了一套 checklist:先确定空间类型,再算 Draw Size,接着处理距离缩放和遮挡,最后验证多人下的同步。按照这套流程,3D UI 不再靠祈祷,而是能稳定运行。
UE5.8 在 Widget Component 上做了一些细节改进,比如渲染线程与 Slate 的同步更稳定,远距离 World Space Widget 的锯齿问题也有缓解。这些小改进让 3D UI 在复杂场景里更可控。老夏把这套经验写进了项目 Wiki,后来新加入的策划与美术也能快速理解 3D UI 的限制与用法。