UE5 VR 弹窗挂载(世界空间面板)
何时用
任何 UMG 弹窗/对话框需要在 VR 头显中可见可点时(VR 项目新增弹窗、修复弹窗不显示/点击失效)。
铁律
AddToViewport()= 屏幕空间视口,头显不渲染,仅监视器可见。VR 项目里等于不可见不可点。- 世界空间 WidgetComponent(如社交面板 WBP)的弹窗必须
AddChild到该面板的根面板(UPanelWidget)。 - Slate 鼠标/指针路由在 VR 项目不可靠(按钮基类
SimulateClick注释原话)——点击统一走直点模式,见下。
标准模式(ShowInWorldSpace)
void UConfirmRemoveFriendDialog::ShowInWorldSpace(UWidget* Context)
{
// 防御:null Context 会把弹窗自挂到自身根面板
if (!Context) { AddToViewport(); return; }
// 遍历 outer 链取最外层 UserWidget(子 widget 的 Outer=父 WidgetTree,
// WidgetTree 的 Outer=父 UserWidget;非 UUserWidget outer 安全跳过)
UUserWidget* Host = nullptr;
for (UObject* Outer = Context; Outer; Outer = Outer->GetOuter())
{
if (UUserWidget* UW = Cast<UUserWidget>(Outer)) { Host = UW; }
}
UPanelWidget* Panel = Host ? Cast<UPanelWidget>(Host->GetRootWidget()) : nullptr;
// AddChild 返回 nullptr = UContentWidget 单子根静默失败,必须检查并回退
if (!Panel || !Panel->AddChild(this)) { AddToViewport(); }
// CanvasPanel 根必须 SetZOrder(100):否则面板页签/列表压在弹窗上,
// 射线被下层吃掉,按钮点不到(名片弹窗已有此先例)
if (UPanelSlot* Slot = GetSlot(); Slot && Slot->IsA<UCanvasPanelSlot>())
{
CastChecked<UCanvasPanelSlot>(Slot)->SetZOrder(100);
}
}
配套陷阱清单(缺一不可)
- 关闭必 RemoveFromParent:面板强引用子控件,只 HideDialog(Collapsed) 会永久堆积隐藏实例。所有关闭路径(确认/取消/OnDialogClosed/HideActiveDialog)都要 RemoveFromParent。
- 持有弹窗的成员必 UPROPERTY():否则 GC 销毁弹窗后悬垂非空指针 → 后续入口被闩锁检查永久卡死。
- NativeConstruct 动态创建需 WidgetTree 守卫:
if (!NicknameBtn && ProfilePanel && WidgetTree)——无头测试 NewObject 创建的 widget 无 WidgetTree(Initialize 才创建),CreateWidget 会 ensure 崩测试。 - 多入口弹窗的状态恢复 flag 每入口更新:只在某入口设置 flag 会导致另一入口关闭时误判(例:bWasSocialUIVisibleBeforePopup 在 MD 弹窗入口漏更新 → 关闭时误关用户已开面板)。统一写成
bWasX = bCurrentState;再按需切换。 - CanvasPanel 根 AddChild 后弹窗落左上角(无锚点 slot),真机布局需实测;VerticalBox 根则追加底部可能越界。
- CanvasPanelSlot 必须 SetZOrder(100):面板页签/列表子控件会压在未设 ZOrder 的弹窗上,射线被下层吃掉,按钮点不到(点击失效排查时先查 ZOrder 再查挂载)。
- 头部环境回退:测试/无头环境 outer 无 UserWidget 时回退 AddToViewport(GameViewportSubsystem 缺失时为 no-op,不崩)。
- 统一挂载点:同一弹窗基类加一个 ShowInWorldSpace 方法,所有调用点复用,避免各写各的挂载逻辑。
按钮点击(SimulateClick 直点,绕开 Slate 路由)
Slate 鼠标/指针路由在 VR 项目不可靠。正确模式(VR 扳机与 PC 鼠标调试共用):
- hover 到按钮:按钮基类的
bPointerHovered(NativeOnMouseEnter 置位 + 150ms 冷却桥接 Slate 悬停振荡) - 扫描命中 WidgetComponent 的
GetUserWidgetObject()->WidgetTree->GetAllWidgets(),找bPointerHovered && GetIsEnabled()的按钮基类 - 直接
Btn->SimulateClick()(内部调 HandleButtonClicked())——不要走 PressPointerKey/RoutePointerDownEvent
WidgetInteraction 调试射线(PC 鼠标)
SetCustomHitResult只在InteractionSource == EWidgetInteractionSource::Custom时被消费——VR 路径是 World(手柄射线),点击瞬间切 Custom,用完恢复 World- 时序:SetCustomHitResult → 等 ~0.05s(Tick 的 SimulatePointerMovement 同步 Slate hover)→ SimulateClick
- 禁止同帧 Press+Release:Slate Down 未注册捕获时 Up 被丢弃(无 click),且鼠标捕获不释放(光标锁死)
验证方式
- 无头:构建 + Automation RunTests 全绿(断言 ActiveDialog/可见性,不覆盖真实挂载)
- 真机:VR 头显实测弹窗可见位置与点击(无头不可达,必须标注 INFERENCE)
- 全库 grep AddToViewport 排查遗漏调用点
- 挂载验证:
GetAllWidgets()扫描列表里找不到弹窗按钮 = 弹窗没进目标 widget 树(挂错宿主);IsOverInteractable=1但无 SimulateClick = hover 到了可交互物但扫描条件不满足(bPointerHovered 未置位或 GetIsEnabled=false) - 点击诊断:扫描循环打印每个按钮的
btn='%s' hovered=%d enabled=%d
微信扫一扫