世界空间 UI 的使用

本章节介绍如何在 VR 项目3D 场景交互 中使用 Fink Framework 的多画布系统。

世界空间 UI 不再局限于唯一的 MainCanvas,而是基于场景中的 CanvasRoot 节点自动注册。
UIManager 能够将面板实例精准挂载到指定的 Canvas 下,从而实现:

  • VR HUD:头显跟随 UI
  • Hand Menu:手部跟随菜单
  • World Panel:场景中固定位置的交互面板(如 NPC 对话框、商品列表)
  • 多画布管理:支持跨场景、多 ID 的独立画布管理

1. 系统构成

世界空间 UI 系统由以下三部分组成:

① CanvasRoot(组件)
挂载在场景 Canvas 上的标记脚本,用于定义“这个 Canvas 属于哪一类”以及“叫什么名字”。

② CanvasManager(管理器)
负责扫描场景、注册所有 CanvasRoot,并处理摄像机绑定和 XR 射线检测组件的自动挂载。

③ UIManager(统一入口)
提供了专用的 MultiCanvas 系列方法,根据 rootType + canvasId 决定面板加载到哪里。


2. 搭建 Canvas(CanvasRoot)

要使用世界空间 UI,你需要在场景中创建一个 Canvas,并挂载 CanvasRoot 组件。

层级结构示例:

ShopCanvas (RenderMode: World Space)  <-- 挂载 CanvasRoot
  └── ContentRoot (可选,用于作为面板父节点)

CanvasRoot 参数说明:

public class CanvasRoot : MonoBehaviour
{
    public E_UIRoot rootType;      // 画布类型:HUD / HandMenu / WorldPanel
    public string canvasId;        // 唯一标识:如 "LeftHand", "ShopBoard"
    public Transform panelParent;  // 面板挂载点(为空则默认挂在 Canvas 下)
}

2.1 rootType(画布类型)

枚举 E_UIRoot 定义了三种基础类型,用于逻辑分类:

  • HUD:通常指头显平面 UI(注意:UIManager 默认的 MainCanvas 即为 HUD 类型,ID 为空)。
  • HandMenu:用于 VR 手部菜单。
  • WorldPanel:用于场景中的 3D 面板。

2.2 canvasId(唯一 ID)

  • 同一 rootType 下,canvasId 必须唯一。
  • 跨场景防冲突:框架内部注册时,会自动拼接 SceneName_CanvasId,因此不同场景可以使用相同的 ID(如 "Shop"),互不冲突。

3. 自动注册与配置

场景启动时,CanvasManager 会自动执行 RegisterAllCanvas(),扫描所有激活的 CanvasRoot

3.1 自动绑定 Camera

如果 Canvas 模式为 World Space 且未赋值 WorldCamera,框架会自动将 UIManager.Instance.uiCamera 赋值给它,确保 UI 事件响应正常。

3.2 自动支持 VR 交互 (XR Interaction Toolkit)

如果项目开启了 VR 模式 (GlobalConfig.isVR),框架会自动检测并为 Canvas 添加 TrackedDeviceGraphicRaycaster 组件,无需手动配置。


4. 显示面板(MultiCanvas 系列方法)

在世界空间显示面板,需要使用带有 MultiCanvas 后缀的方法,并指定 rootTypecanvasId

4.1 异步显示(推荐)

使用 UniTask 等待加载完成。

// 参数:层级, 画布类型, 画布ID, 自定义路径(可选)
await UIManager.Instance.ShowPanelMultiCanvasAsync<NPCShopPanel>(
    layer: E_MainLayer.Middle,      // 层级(通常 WorldCanvas 忽略此项,除非自己实现了层级逻辑)
    uiRootType: E_UIRoot.WorldPanel,
    canvasId: "ShopBoard"           // 对应 CanvasRoot 的 ID
);

4.2 回调形式

UIManager.Instance.ShowPanelMultiCanvasCallback<NPCShopPanel>(
    callback: (panel) => {
        panel.RefreshItems();
    },
    uiRootType: E_UIRoot.WorldPanel,
    canvasId: "ShopBoard"
);

4.3 句柄形式(用于进度条或取消)

var op = UIManager.Instance.LoadPanelMultiCanvasHandle<NPCShopPanel>(
    uiRootType: E_UIRoot.WorldPanel,
    canvasId: "ShopBoard"
);
// op.Panel, op.Progress, op.IsDone ...

5. 隐藏与销毁

5.1 隐藏单个面板

需要传入相同的 uiRootTypecanvasId 才能正确定位到面板缓存。

// 仅隐藏
UIManager.Instance.HidePanel<NPCShopPanel>(
    isDestroy: false, 
    uiRootType: E_UIRoot.WorldPanel, 
    canvasId: "ShopBoard"
);

// 隐藏并销毁
UIManager.Instance.HidePanel<NPCShopPanel>(
    isDestroy: true, 
    uiRootType: E_UIRoot.WorldPanel, 
    canvasId: "ShopBoard"
);

5.2 隐藏特定 Canvas 下的所有面板

适用于关闭整个功能模块(例如玩家走远了,关闭商店 Canvas 下的所有 UI)。

UIManager.Instance.HidePanelsInCanvas(
    uiRootType: E_UIRoot.WorldPanel, 
    canvasId: "ShopBoard",
    isDestroy: false // 可选是否销毁
);

6. 获取面板实例

如果你想获取一个已经打开的世界空间面板:

UIManager.Instance.GetPanel<NPCShopPanel>(
    callBack: (panel) => {
        // 获取成功后的逻辑
        panel.UpdateData();
    },
    uiRootType: E_UIRoot.WorldPanel,
    canvasId: "ShopBoard"
);

7. 完整示例

场景设置

  1. 在场景中创建一个 Canvas,RenderMode 设为 World Space
  2. 挂载 CanvasRoot 组件。
  3. 设置 Root Type = WorldPanel
  4. 设置 Canvas Id = MyInfoBoard

代码调用

using FinkFramework.Runtime.UI;
using FinkFramework.Runtime.UI.Canva;
using Cysharp.Threading.Tasks;

public class WorldUIController : MonoBehaviour
{
    // 触发显示
    public async void OpenInfoBoard()
    {
        var panel = await UIManager.Instance.ShowPanelMultiCanvasAsync<InfoPanel>(
            uiRootType: E_UIRoot.WorldPanel,
            canvasId: "MyInfoBoard"
        );
        
        panel.SetContent("欢迎来到 VR 世界!");
    }

    // 触发关闭
    public void CloseInfoBoard()
    {
        UIManager.Instance.HidePanel<InfoPanel>(
            isDestroy: true, // 销毁释放内存
            uiRootType: E_UIRoot.WorldPanel,
            canvasId: "MyInfoBoard"
        );
    }
}

8. 常见问题 (FAQ)

Q: 为什么 ShowPanel 找不到我的 Canvas?
A: 请检查以下几点:

  1. 场景中对应的 Canvas 是否挂载了 CanvasRoot
  2. Canvas 物体是否是激活(Active)状态?(未激活不会被注册)
  3. 代码中传入的 canvasId 和 Inspector 面板上的字符串是否完全一致(区分大小写)?
  4. 如果是动态生成的 Canvas,生成后需要手动调用 CanvasManager.Instance.RegisterAllCanvas() 刷新注册。

Q: World Space UI 无法点击?
A:

  1. 检查 Canvas 是否有 GraphicRaycaster(或 XR 版 Raycaster)。
  2. 检查 EventSystem 是否存在。
  3. 如果是 VR 项目,确保手柄射线能射到 Canvas 层级(Layer)。
  4. 检查 Canvas 的 Event Camera 是否正确自动绑定了 UICamera