# 8. UI Toolkit for VR Part of [[UI Toolkit and Diegetic UI]]. Requires Unity 6.3 LTS (6000.3.27f2) and newer. Previous: [[7.UI Toolkit Pitfalls and Performance]]. Related: [[Meta Quest 2]], [[7.Quest 2 Worked Example Room]]. > [!important] Version requirement > Unity 6.3 LTS (6000.3.27f2) and newer. UI Toolkit world-space support in XRI needs Unity 6.2+ and XRI 3.2.0+ at minimum; XRI 3.4+ is recommended (adds `XRRayInteractor` and z-depth poke support; 3.5.0+ adds thumbstick scrolling of scroll views). > [!note] Scripts are uncompiled > The scripts here were written without an editor. Treat the first compile as part of the tutorial. So far this tutorial series put UI Toolkit on screens, on meshes via RenderTexture ([[6.Diegetic Terminal and Wrist Display]]) and inside the world. This note puts it in **VR**, where the pointer is a ray or a fingertip instead of a mouse. ## 8.1 What is supported | Question | Answer | Source | |---|---|---| | Can UI Toolkit render in world space? | Yes. `PanelSettings` has a **World Space** render mode (Unity 6.2+). | [XRI + UI Toolkit](https://docs.unity3d.com/Packages/[email protected]/manual/ui-world-space-ui-toolkit-support.html) | | Do XRI interactors drive it? | Yes. Ray (XR Ray Interactor, Near-Far Interactor) and poke (`XRPokeInteractor`) via `XRUIToolkitManager`; hands and controllers. | same | | Minimum versions | Unity 6.2, XRI 3.2.0. `XRRayInteractor` support was added in XRI 3.4.0 per the XRI changelog. | same, [changelog](https://docs.unity3d.com/Packages/[email protected]/changelog/CHANGELOG.html) | | Text input | System/software keyboard only; on Quest enable **Enable System Keyboard** in Meta Quest Support (OpenXR 1.17.0+ required by the XRI docs). | XRI docs | | Scroll views | Drag-scroll needs Unity 6000.3.8f1 / 6000.4.0b9 / 6000.5.0a7 or newer; otherwise use the scroll bar handles or thumbstick (thumbstick scrolling of UI Toolkit scroll views needs XRI 3.5.0+). | XRI docs | | Meta Interaction SDK (ISDK) | ISDK's UI integration is documented for **uGUI** (`PointableCanvas`). I found **no ISDK documentation for UI Toolkit panels**. Use the fallback in 8.6. | Meta ISDK docs **(absence of UI Toolkit docs is unverified)** | | `PanelRenderer` (6000.5+ component) with XRI | Not used here: this series targets Unity 6.3 LTS, where `UIDocument` with a World Space Panel Settings asset is the path. Newer Unity docs mark `UIDocument` obsolete in favour of `PanelRenderer`; the XRI docs I read describe `UIDocument`, so XRI with `PanelRenderer` is **unverified**. | - | Conclusion: with **XRI 3.x on OpenXR**, direct support is complete enough to build the Quest 2 panel in [[7.Quest 2 Worked Example Room]] without a fallback. With **Meta ISDK** or a **custom rig**, use the render-texture fallback. ## 8.2 Route A: XRI direct world-space panels (steps) 1. **Panel Settings.** Right-click in Project > **Create > UI Toolkit > Panel Settings Asset**. Set **Render Mode = World Space**. 2. **Collider mode** (also on Panel Settings, "Collider Update Mode"). The XRI docs list: - **Keep existing colliders (if any)**: you add and size a collider yourself. - **Match 2-D document rect**: UI Toolkit creates a flat collider. Poke still works but completes instantly (zero thickness, no depth animation). - **Match 3-D bounding box**: gives depth along the poke axis, so poke depth animation works. Prefer this for poke. 3. **UI Document.** Create **GameObject > UI Toolkit > UI Document**. Assign the UXML as Source Asset and your Panel Settings. 4. **Scene components.** - Add **XR UI Toolkit Manager** (`XRUIToolkitManager`) to one GameObject. It globally enables UI Toolkit support in XRI. - Add a **Panel Input Configuration** component and set **Panel Input Redirection = No input redirection** (enum value Never) so the event system does not interfere. - If you also use uGUI: keep an EventSystem with **XR UI Input Module** and **disable `bypassUIToolkitEvents`**. 5. **Ray**: that is all that is required for ray interaction once the collider exists. 6. **Poke** (finger or controller tip): add **XRSimpleInteractable** and **XRPokeFilter** to the panel object. Use USS `translate` on a `Button`/`Toggle`/`VisualElement` to set its resting z-depth. Nested children of z-depth elements can block poke (handled for Button and Toggle). 7. **Hover**: interactors implementing `IUIHoverInteractor` raise `UIHoverEnterEvent`/`UIHoverExitEvent` with the `VisualElement` and `UIDocument` in `UIHoverEventArgs`. > [!warning] Order and collider traps from the XRI docs > - The `UIDocument` component must exist **before** the interactable registers with the interaction manager. > - `XRBaseInteractable` searches the GameObject and children for colliders. A nested UIDocument collider can be mistaken for the interactable's own collider. Assign interactable colliders manually, or set **Is Trigger** on the UI Document collider. > - Match 2-D / 3-D colliders are created in `LateUpdate` after the first render; the XR UI Toolkit Manager registers them with the interactable automatically. > - Changing Collider Update Mode at runtime is not supported. > - In the editor over **Meta Horizon Link**, click the **Game view** once: UI Toolkit input is only redirected when the Game view has focus. ## 8.3 Script: setup diagnostics Add this to the panel GameObject. It prints what is missing, so you do not have to hunt through the Inspector. It looks up XRI types **by name** so it still compiles if a namespace differs between XRI versions. ```csharp using System.Linq; using UnityEngine; using UnityEngine.UIElements; /// <summary> /// Logs the common setup mistakes for an XRI world-space UI Toolkit panel. /// Not compiled in an editor. /// </summary> [RequireComponent(typeof(UIDocument))] public class XRUIToolkitPanelDiagnostics : MonoBehaviour { void Start() { var doc = GetComponent<UIDocument>(); int problems = 0; if (doc.panelSettings == null) { Report("UIDocument has no Panel Settings asset."); problems++; } else if (doc.panelSettings.renderMode != PanelRenderMode.WorldSpace) { Report(quot;Panel Settings render mode is {doc.panelSettings.renderMode}, expected WorldSpace."); problems++; } if (doc.visualTreeAsset == null) { Report("UIDocument has no Source Asset (UXML)."); problems++; } if (!HasComponentNamed("XRUIToolkitManager")) { Report("No XRUIToolkitManager in the scene (add 'XR UI Toolkit Manager')."); problems++; } if (!HasComponentNamed("PanelInputConfiguration")) { Report("No Panel Input Configuration in the scene (set Panel Input Redirection to 'No input redirection')."); problems++; } if (GetComponentInChildren<Collider>() == null) { Report("No collider yet. With Collider Update Mode = Match 2-D/3-D, UI Toolkit creates it after the first render. " + "Rechecking after two frames."); StartCoroutine(RecheckCollider()); } else if (!HasInteractable()) { Report("No XR interactable on the panel; ray hover works, but poke needs XRSimpleInteractable + XRPokeFilter."); } Debug.Log(problems == 0 ? "[XRUIToolkitPanelDiagnostics] no obvious setup problems found." : quot;[XRUIToolkitPanelDiagnostics] {problems} problem(s) found."); } System.Collections.IEnumerator RecheckCollider() { yield return null; yield return null; bool has = GetComponentInChildren<Collider>() != null; Debug.Log(has ? "[XRUIToolkitPanelDiagnostics] collider appeared after first render (expected)." : "[XRUIToolkitPanelDiagnostics] still no collider. Set Collider Update Mode or add one manually."); } static bool HasComponentNamed(string typeName) { return Object.FindObjectsByType<MonoBehaviour>(FindObjectsInactive.Exclude, FindObjectsSortMode.None) .Any(m => m != null && m.GetType().Name == typeName); } bool HasInteractable() { return GetComponents<MonoBehaviour>().Any(m => m != null && m.GetType().Name.Contains("Interactable")); } void Report(string message) => Debug.LogWarning(quot;[XRUIToolkitPanelDiagnostics] {message}", this); } ``` ## 8.4 Quest 2 design rules for VR UI Toolkit panels - **Large text.** Use font sizes around 34-48 px for a 800 px wide panel viewed at roughly 1 m (the worked example uses these). Quest 2 resolution is lower than a monitor, and foveated rendering ([[5.Quest 2 Performance and Rendering]]) blurs the periphery. - **Update sparsely.** Do not write labels every frame. Use `schedule.Execute(...).Every(ms)` ([[7.UI Toolkit Pitfalls and Performance]]). - **Keep it world-space.** VRC.Quest.Functional.10 recommends avoiding head-locked UI. - **Keep draw cost small.** The cost of a world-space UI Toolkit panel on Quest 2 is **not documented**; profile on the device. - **Prefer ray first, poke second.** Poke needs a collider with depth (Match 3-D bounding box) and careful z-depth. ## 8.5 Route B: Meta Interaction SDK ISDK's documented UI route uses uGUI canvases. If you must keep ISDK as the only interaction system, use Route C. If you want UI Toolkit with XRI interactors, use Route A and let ISDK own only the hands or grabs, never both on the same hand ([[2.Quest 2 SDK Stack]]). ## 8.6 Route C (fallback): RenderTexture panel with ray-to-UV input When the XRI route is not available, render the panel into a `RenderTexture` shown on a quad (the approach of [[6.Diegetic Terminal and Wrist Display]]) and forward your own ray hits into the panel. > [!warning] Unverified fallback > This route uses public UI Toolkit APIs (`panel.Pick`, `NavigationSubmitEvent`) as I understand them, but I did not compile or run it. Test on the Quest 2 early. ### Setup 1. Create a `RenderTexture` (for example 1024x640) asset. 2. Create a **Panel Settings** asset with **Render Mode = Render Texture** and assign the `RenderTexture` as the **Target Texture**. 3. Put a **UI Document** (UXML as in [[7.Quest 2 Worked Example Room]]) on a GameObject using that Panel Settings. 4. Create a **Quad** (it comes with a MeshCollider; UVs only come back from a **MeshCollider**). Give it an **Unlit** material whose base map is the `RenderTexture`. 5. Add `RenderTexturePanelPointer` below to the quad. Assign the document, a `rayOrigin` (the controller transform, or the XRI ray interactor's attach/origin transform), and a trigger `InputActionProperty` (for example the right controller's **Activate** or **Select** action). ### Script: RenderTexturePanelPointer ```csharp using UnityEngine; using UnityEngine.InputSystem; using UnityEngine.UIElements; /// <summary> /// Forwards a VR controller ray to a UI Toolkit panel rendered on a quad via RenderTexture. /// Hover is shown with the USS class "xr-hover"; the trigger sends a NavigationSubmitEvent /// to the nearest focusable element (Button, Toggle). Not compiled in an editor. /// </summary> [RequireComponent(typeof(MeshCollider))] public class RenderTexturePanelPointer : MonoBehaviour { const string HoverClass = "xr-hover"; [SerializeField] UIDocument document; [SerializeField] Transform rayOrigin; [SerializeField] InputActionProperty triggerAction; [SerializeField] float maxDistance = 6f; [Tooltip("Flip if hover/clicks appear mirrored vertically.")] [SerializeField] bool flipV = true; MeshCollider meshCollider; VisualElement hovered; bool wasPressed; void Awake() { meshCollider = GetComponent<MeshCollider>(); } void OnEnable() { if (triggerAction.action != null) triggerAction.action.Enable(); } void OnDisable() { if (triggerAction.action != null) triggerAction.action.Disable(); SetHover(null); } void Update() { var root = document != null ? document.rootVisualElement : null; if (root == null || root.panel == null || rayOrigin == null) return; VisualElement under = null; if (TryGetPanelPoint(root, out Vector2 panelPoint)) { under = root.panel.Pick(panelPoint); } SetHover(under); bool pressed = triggerAction.action != null && triggerAction.action.IsPressed(); if (pressed && !wasPressed && under != null) { Submit(under); } wasPressed = pressed; } bool TryGetPanelPoint(VisualElement root, out Vector2 panelPoint) { panelPoint = default; var ray = new Ray(rayOrigin.position, rayOrigin.forward); if (!meshCollider.Raycast(ray, out RaycastHit hit, maxDistance)) return false; Vector2 uv = hit.textureCoord; // valid only for MeshCollider hits Vector2 size = root.panel.visualTree.layout.size; float v = flipV ? 1f - uv.y : uv.y; panelPoint = new Vector2(uv.x * size.x, v * size.y); return true; } void SetHover(VisualElement next) { if (ReferenceEquals(hovered, next)) return; hovered?.RemoveFromClassList(HoverClass); hovered = next; hovered?.AddToClassList(HoverClass); } static void Submit(VisualElement picked) { // Walk up to the nearest focusable element so clicking a Button's inner label works. VisualElement target = picked; while (target != null && !target.focusable) { target = target.parent; } if (target == null) return; target.Focus(); using (var submit = NavigationSubmitEvent.GetPooled()) { submit.target = target; target.SendEvent(submit); } } } ``` > [!note] How the fallback maps coordinates > `hit.textureCoord` gives UV in 0..1 with the origin at the bottom-left; UI Toolkit panel coordinates have the origin at the top-left, so the script flips V. The panel size comes from the panel's root layout. If your panel scale differs from the texture size, adjust the multiplication. > [!note] What this fallback does not do > No pointer-drag (scroll views, sliders) and no text input. For those you need real pointer events; consider the virtual-mouse approach (an Input System `Mouse` device driven with `InputState.Change`, plus `SetScreenToPanelSpaceFunction` as in note 6), which I did not verify for VR. ## 8.7 Troubleshooting | Symptom | Likely cause | |---|---| | Ray passes through the panel | No collider yet, or collider on a layer the interactor ignores | | Hover works, click does nothing | `Panel Input Configuration` not set to No input redirection, or the Game view is not focused (Link) | | Poke completes instantly | Collider Update Mode = Match 2-D document rect (zero thickness); use Match 3-D bounding box | | Poke z-depth stuck | Nested child of a z-depth element; apply depth to the Button/Toggle itself | | Panel interactable steals the ray | Nested UIDocument collider adopted by an interactable; set Is Trigger or assign colliders manually | | Keyboard does not appear | **Enable System Keyboard** off, or `Hide Soft Keyboard` / `Hide Mobile Input` ticked on the TextField | ## Official references - [XRI: XR Interaction Toolkit + UI Toolkit](https://docs.unity3d.com/Packages/[email protected]/manual/ui-world-space-ui-toolkit-support.html) - [XRI: XR UI Input Module](https://docs.unity3d.com/Packages/[email protected]/manual/xr-ui-input-module.html) - [Unity: Panel Settings (UI Toolkit)](https://docs.unity3d.com/6000.3/Documentation/Manual/UIE-Runtime-Panel-Settings.html) Back to [[UI Toolkit and Diegetic UI]]. Quest 2 project: [[Meta Quest 2]], [[7.Quest 2 Worked Example Room]].