quot;Interaction layer '{teleportLayerName}' not found. " + "Add it under Project Settings > XR Plug-in Management > XR Interaction Toolkit, " + "or set it on the TeleportationArea manually."); } } void BuildWalls() { float hx = roomSize.x * 0.5f; float hz = roomSize.y * 0.5f; float y = wallHeight * 0.5f; Block("Wall N", new Vector3(0f, y, hz), new Vector3(roomSize.x, wallHeight, 0.1f), wallMat); Block("Wall S", new Vector3(0f, y, -hz), new Vector3(roomSize.x, wallHeight, 0.1f), wallMat); Block("Wall E", new Vector3(hx, y, 0f), new Vector3(0.1f, wallHeight, roomSize.y), wallMat); Block("Wall W", new Vector3(-hx, y, 0f), new Vector3(0.1f, wallHeight, roomSize.y), wallMat); } void BuildTable() { Block("Table", new Vector3(0f, 0.4f, 1.5f), new Vector3(1.4f, 0.8f, 0.7f), wallMat); } void BuildLamp() { var go = new GameObject("Lamp"); go.transform.SetParent(transform, false); go.transform.localPosition = new Vector3(0f, wallHeight - 0.3f, 0f); Lamp = go.AddComponent<Light>(); Lamp.type = LightType.Point; Lamp.range = 8f; Lamp.intensity = 1.5f; Lamp.shadows = LightShadows.None; // real-time shadows are expensive on Quest 2 } void BuildCubes() { for (int i = 0; i < cubeCount; i++) { float x = (i - (cubeCount - 1) * 0.5f) * 0.3f; var pos = new Vector3(x, 0.8f + cubeSize * 0.5f + 0.01f, 1.5f); var cube = Block(quot;Grab Cube {i + 1}", pos, Vector3.one * cubeSize, cubeMat); // XRGrabInteractable requires a Rigidbody and a collider. var rb = cube.AddComponent<Rigidbody>(); rb.mass = 0.3f; cube.AddComponent<XRGrabInteractable>(); cubes.Add((cube.transform, cube.transform.position, cube.transform.rotation)); } } } ``` Attach `QuestRoomBuilder` to an empty GameObject named `Room`. Press Play in the simulator or Link: you should see a floor and four cubes on a table. Point the teleport ray at the floor and release to teleport. Grab a cube with the grip button. > [!note] Why these choices > - Grabbing uses `XRGrabInteractable` (XRI 3.x namespace `UnityEngine.XR.Interaction.Toolkit.Interactables`). Teleporting uses `TeleportationArea` (`...Locomotion.Teleportation`). > - Grab/teleport separation is by **interaction layers**: the Teleport ray only sees `Teleport`-layer interactables, so it never tries to grab. > - A single point light with shadows off keeps GPU cost in budget ([[5.Quest 2 Performance and Rendering]]). ## 7.3 The UI Toolkit panel assets ### RoomPanel.uxml Create `Assets/UI/RoomPanel.uxml` (UI Toolkit Document): ```xml <ui:UXML xmlns:ui="UnityEngine.UIElements" editor-extension-mode="False"> <ui:Style src="RoomPanel.uss" /> <ui:VisualElement name="root" class="panel"> <ui:Label text="Room Controls" class="title" /> <ui:Toggle name="lamp-toggle" label="Lamp" value="true" class="row" /> <ui:Button name="reset-button" text="Reset cubes" class="row" /> <ui:VisualElement class="divider" /> <ui:Label name="refresh-label" text="Refresh: -" class="info" /> <ui:Label name="fps-label" text="FPS: -" class="info" /> <ui:Label name="hands-label" text="Hands: -" class="info" /> </ui:VisualElement> </ui:UXML> ``` ### RoomPanel.uss Create `Assets/UI/RoomPanel.uss`. Fonts are large because Quest 2's panel is lower resolution than a monitor and text can shimmer under foveation. Hover is a class because VR rays are not mouse pointers (the XRI hover events still drive `:hover` where supported; the explicit class below is also used by the fallback in [[8.UI Toolkit for VR]]). ```css .panel { width: 800px; padding: 24px; background-color: rgba(18, 22, 30, 0.94); border-radius: 24px; } .title { font-size: 48px; color: rgb(255, 255, 255); -unity-font-style: bold; margin-bottom: 16px; } .row { margin-bottom: 16px; font-size: 40px; min-height: 88px; color: rgb(255, 255, 255); } Button.row { background-color: rgb(52, 118, 212); border-radius: 16px; -unity-text-align: middle-center; } Button.row:hover, Button.row.xr-hover { background-color: rgb(82, 148, 242); } Button.row:active { background-color: rgb(32, 88, 172); } .divider { height: 2px; background-color: rgba(255, 255, 255, 0.25); margin-top: 8px; margin-bottom: 16px; } .info { font-size: 34px; color: rgb(190, 200, 215); margin-bottom: 8px; } ``` ## 7.4 Make the panel a world-space UI Toolkit panel Full explanations and the fallback are in [[8.UI Toolkit for VR]]. Short version: 1. **Create > UI Toolkit > Panel Settings Asset** named `RoomPanelSettings`. Set **Render Mode = World Space**. Add a **Theme Style Sheet** (the default runtime theme) if prompted. 2. Create an empty GameObject `RoomPanel` in front of the table at about 1.4 m height, facing the player. Add a **UI Document** component. Assign `RoomPanel.uxml` as Source Asset and `RoomPanelSettings` as Panel Settings. 3. Add a **Box Collider** to `RoomPanel` that matches the panel size, or set the collider update mode on the UI Document (Unity 6.2+ adds a collider-update mode to Panel Settings; the options include keeping your own collider, matching the 2D rect, or matching the 3D bounding box). 4. Add an **XR Simple Interactable** and an **XR Poke Filter** (if you want finger poke) to the panel object. 5. Add an **XR UI Toolkit Manager** component (`XRUIToolkitManager`) once to the scene (a new GameObject `XR UI Toolkit`). On the same or another scene object add a **Panel Input Configuration** component and set **Panel Input Redirection = No input redirection** (the XRI docs: the event system can interfere with UI Toolkit input). 6. You only need an EventSystem with the **XR UI Input Module** (`XRUIInputModule`) if you also use uGUI canvases; in that case keep its **Bypass UI Toolkit Events** off. Check that the rig's ray interactors have UI interaction enabled (the Starter Assets rig does). 7. Scale the panel so 800 px is about 0.8 m wide (adjust the panel settings scale/pixels per unit until it looks right). > [!warning] Checks > This tutorial uses the **UI Document** component with a **World Space** Panel Settings asset: the path the XRI docs describe and the one Unity 6.3 LTS documents. Unity 6000.5 and later add a `PanelRenderer` component and newer docs mark UI Document obsolete; that path is not used here, and its behaviour with XRI is **(unverified)** ([[8.UI Toolkit for VR]]). ## 7.5 Script: RoomControlPanel Create `Assets/Scripts/RoomControlPanel.cs` and add it to the `RoomPanel` object (it needs the UI Document next to it). Assign `room` in the Inspector. ```csharp using UnityEngine; using UnityEngine.UIElements; /// <summary> /// Wires the UI Toolkit room panel to the room and shows device status. /// Not compiled in an editor. /// </summary> [RequireComponent(typeof(UIDocument))] public class RoomControlPanel : MonoBehaviour { [SerializeField] QuestRoomBuilder room; [SerializeField] int statusIntervalMs = 500; UIDocument document; Toggle lampToggle; Button resetButton; Label refreshLabel; Label fpsLabel; Label handsLabel; IVisualElementScheduledItem statusTimer; float smoothedDelta = 1f / 72f; void OnEnable() { document = GetComponent<UIDocument>(); // RequireComponent puts UIDocument before this script, so its rootVisualElement is ready. var root = document.rootVisualElement; if (root == null) { Debug.LogError("RoomControlPanel: UIDocument has no rootVisualElement. Check Source Asset and Panel Settings."); return; } lampToggle = root.Q<Toggle>("lamp-toggle"); resetButton = root.Q<Button>("reset-button"); refreshLabel = root.Q<Label>("refresh-label"); fpsLabel = root.Q<Label>("fps-label"); handsLabel = root.Q<Label>("hands-label"); if (lampToggle != null) lampToggle.RegisterValueChangedCallback(OnLampChanged); if (resetButton != null) resetButton.clicked += OnResetClicked; statusTimer = root.schedule.Execute(UpdateStatus).Every(statusIntervalMs); } void OnDisable() { if (lampToggle != null) lampToggle.UnregisterValueChangedCallback(OnLampChanged); if (resetButton != null) resetButton.clicked -= OnResetClicked; statusTimer?.Pause(); } void Update() { // Cheap exponential moving average of frame time. smoothedDelta = Mathf.Lerp(smoothedDelta, Time.unscaledDeltaTime, 0.05f); } void OnLampChanged(ChangeEvent<bool> evt) { if (room != null) room.SetLamp(evt.newValue); } void OnResetClicked() { if (room != null) room.ResetCubes(); } void UpdateStatus() { if (refreshLabel != null) refreshLabel.text = quot;Refresh: {QuestPerformanceProfile.CurrentRefreshRate:0} Hz"; if (fpsLabel != null) fpsLabel.text = quot;FPS: {1f / Mathf.Max(smoothedDelta, 0.0001f):0}"; if (handsLabel != null) { string hands = HandTrackingProbe.SubsystemRunning ? quot;L {(HandTrackingProbe.LeftTracked ? "tracked" : "-")} R {(HandTrackingProbe.RightTracked ? "tracked" : "-")}" : "off"; handsLabel.text = quot;Hands: {hands}"; } } } ``` > [!note] Why a scheduler and not Update > `schedule.Execute(...).Every(ms)` only changes UI text twice a second. Writing to labels every frame would dirty the panel layout each frame, which is wasteful on Quest 2 ([[7.UI Toolkit Pitfalls and Performance]]). ## 7.6 Test it 1. **Simulator/Link:** press Play. Aim the controller ray at the panel: the button highlights; pull the trigger to click. The Lamp toggle turns the point light on and off. 2. **Poke (optional):** if you added `XRPokeFilter`, push the controller tip or finger into the button. 3. **Device:** build and sideload ([[4.Quest 2 Testing and Deployment]]). Confirm FPS holds 72, then try 90 Hz only if you have headroom. 4. If the panel renders but ignores input, go through the checklist in [[8.UI Toolkit for VR]] (`XRUIToolkitManager`, Panel Input Configuration, collider, Game view focus over Link). ## 7.7 Variations - **Hand tracking only:** enable XR Hands and use XRI's hand interactors; the UI Toolkit panel works with poke (see [[8.UI Toolkit for VR]]). - **Meta Interaction SDK instead of XRI:** ISDK's UI path is documented for uGUI canvases (`PointableCanvas`). With UI Toolkit use the render-texture fallback in [[8.UI Toolkit for VR]]. - **Wrist panel:** reuse [[6.Diegetic Terminal and Wrist Display]] and attach it to a controller anchor. - **Passthrough background:** see [[6.Quest 2 Input Hands and Passthrough]] (grayscale only on Quest 2). ## Official references - [Unity XRI: UI Toolkit support](https://docs.unity3d.com/Packages/[email protected]/manual/ui-world-space-ui-toolkit-support.html) - [Unity XRI: Teleportation Area](https://docs.unity3d.com/Packages/[email protected]/manual/teleportation-area.html) - [Unity XRI: XR Grab Interactable](https://docs.unity3d.com/Packages/[email protected]/manual/xr-grab-interactable.html) Next: [[8.Quest 2 Changelog]] > [!note] Unity 6000.4 and 6000.5 are not recommended > This series targets Unity 6.3 LTS. 6000.4.x and 6000.5.x are end of life, have no patch for the October 2026 advisories, and have reported Meta SDK build breakage. See [[0.Choosing a Unity Version]].