From b73041e808a0a600eb922a5fe347c5326dda4618 Mon Sep 17 00:00:00 2001 From: Kurtis Date: Thu, 24 Sep 2026 22:34:57 -0700 Subject: [PATCH] Ensure the UI logic for reticle respects the same rules as 3D --- org.mixedrealitytoolkit.input/CHANGELOG.md | 1 + .../InteractorVisuals/MRTKRayReticleVisual.cs | 22 ++- .../Interactors/Ray/MRTKRayInteractor.cs | 41 ++++- .../Runtime/MRTKRayInteractorVisualsTests.cs | 160 ++++++++++++++++++ 4 files changed, 217 insertions(+), 7 deletions(-) diff --git a/org.mixedrealitytoolkit.input/CHANGELOG.md b/org.mixedrealitytoolkit.input/CHANGELOG.md index 23b92079a..2ebc4951a 100644 --- a/org.mixedrealitytoolkit.input/CHANGELOG.md +++ b/org.mixedrealitytoolkit.input/CHANGELOG.md @@ -14,6 +14,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). * Fixed "leaked managed shell" issue in `InteractionModeManager`. [PR #1096](https://github.com/MixedRealityToolkit/MixedRealityToolkit-Unity/pull/1096) * Fixed `EyeCalibrationChecker` build issue on UWP when the Mixed Reality OpenXR Plugin wasn't installed. [PR #1106](https://github.com/MixedRealityToolkit/MixedRealityToolkit-Unity/pull/1106) * Fixed object identity API usages for Unity 6.4 and newer in `InteractionModeManagerEditor`. [PR #1136](https://github.com/MixedRealityToolkit/MixedRealityToolkit-Unity/pull/1136) +* Fixed pointer reticles remaining stuck on WorldSpace UI elements or scene surfaces when hand tracking is lost. [PR #1139](https://github.com/MixedRealityToolkit/MixedRealityToolkit-Unity/pull/1139) ## [3.3.0] - 2025-11-12 diff --git a/org.mixedrealitytoolkit.input/Interactors/InteractorVisuals/MRTKRayReticleVisual.cs b/org.mixedrealitytoolkit.input/Interactors/InteractorVisuals/MRTKRayReticleVisual.cs index 83f09a153..67fe32772 100644 --- a/org.mixedrealitytoolkit.input/Interactors/InteractorVisuals/MRTKRayReticleVisual.cs +++ b/org.mixedrealitytoolkit.input/Interactors/InteractorVisuals/MRTKRayReticleVisual.cs @@ -48,7 +48,10 @@ protected override void OnEnable() { base.OnEnable(); - rayInteractor.selectEntered.AddListener(LocateTargetHitPoint); + if (rayInteractor != null) + { + rayInteractor.selectEntered.AddListener(LocateTargetHitPoint); + } Application.onBeforeRender += UpdateReticle; UpdateReticle(); } @@ -58,7 +61,10 @@ protected override void OnEnable() /// protected virtual void OnDisable() { - rayInteractor.selectEntered.RemoveListener(LocateTargetHitPoint); + if (rayInteractor != null) + { + rayInteractor.selectEntered.RemoveListener(LocateTargetHitPoint); + } Application.onBeforeRender -= UpdateReticle; ReticleSetActive(false); @@ -86,8 +92,11 @@ private void UpdateReticle() { if (Reticle != null) { - bool showReticle = VisibilitySettings == ReticleVisibilitySettings.AllValidSurfaces || rayInteractor.hasHover || rayInteractor.hasSelection || - rayInteractor.enableUIInteraction && rayInteractor.TryGetCurrentUIRaycastResult(out _); + bool showReticle = rayInteractor != null && (rayInteractor.isHoverActive || rayInteractor.hasSelection) && ( + VisibilitySettings == ReticleVisibilitySettings.AllValidSurfaces || + rayInteractor.hasHover || + rayInteractor.hasSelection || + (rayInteractor.enableUIInteraction && rayInteractor.TryGetCurrentUIRaycastResult(out _))); if (showReticle) { @@ -148,7 +157,10 @@ private void ReticleSetActive(bool value) /// private void LocateTargetHitPoint(SelectEnterEventArgs args) { - rayInteractor.TryLocateTargetHitPoint(args.interactableObject, out selectedHitDetails); + if (rayInteractor != null) + { + rayInteractor.TryLocateTargetHitPoint(args.interactableObject, out selectedHitDetails); + } } /// diff --git a/org.mixedrealitytoolkit.input/Interactors/Ray/MRTKRayInteractor.cs b/org.mixedrealitytoolkit.input/Interactors/Ray/MRTKRayInteractor.cs index 92ce1b099..eae0f565f 100644 --- a/org.mixedrealitytoolkit.input/Interactors/Ray/MRTKRayInteractor.cs +++ b/org.mixedrealitytoolkit.input/Interactors/Ray/MRTKRayInteractor.cs @@ -33,7 +33,7 @@ public class MRTKRayInteractor : /// /// Is this ray currently hovering a UnityUI/Canvas element? /// - public bool HasUIHover => TryGetUIModel(out TrackedDeviceModel model) && model.currentRaycast.isValid; + public bool HasUIHover => isHoverActive && TryGetUIModel(out TrackedDeviceModel model) && model.currentRaycast.isValid; /// /// Is this ray currently selecting a UnityUI/Canvas element? @@ -44,7 +44,7 @@ public class MRTKRayInteractor : /// Used to check if the parent controller is tracked or not /// Hopefully this becomes part of the base Unity XRI API. /// - private bool IsTracked => xrController.currentControllerState.inputTrackingState.HasPositionAndRotation(); + private bool IsTracked => xrController != null && xrController.currentControllerState.inputTrackingState.HasPositionAndRotation(); /// /// Cached reference to hands aggregator for efficient per-frame use. @@ -228,6 +228,43 @@ protected override void OnSelectEntering(SelectEnterEventArgs args) #endregion XRBaseInteractor + #region IUIInteractor + + private bool wasUIInteractionActive = false; + + /// + public override void UpdateUIModel(ref TrackedDeviceModel model) + { + bool isInteractionActive = isHoverActive || hasSelection; + if (!isInteractionActive) + { + model.select = false; + if (model.raycastPoints.Count > 0) + { + // Assigning a new empty list causes TrackedDeviceModel.raycastPoints setter to see + // m_RaycastPoints.Count != value.Count, setting changedThisFrame = true so UIInputModule + // processes the transition, dispatches pointerExit to any hovered UI elements, and resets currentRaycast. + model.raycastPoints = new List(); + } + wasUIInteractionActive = false; + return; + } + + base.UpdateUIModel(ref model); + + if (!wasUIInteractionActive) + { + wasUIInteractionActive = true; + // Transitioning from inactive to active: ensure changedThisFrame is true so UIInputModule + // processes the new raycast points even if position/rotation remained identical across the transition. + var newPoints = new List(model.raycastPoints); + model.raycastPoints.Clear(); + model.raycastPoints = newPoints; + } + } + + #endregion IUIInteractor + /// /// A Unity event function that is called every frame, if this object is enabled. /// diff --git a/org.mixedrealitytoolkit.input/Tests/Runtime/MRTKRayInteractorVisualsTests.cs b/org.mixedrealitytoolkit.input/Tests/Runtime/MRTKRayInteractorVisualsTests.cs index 90838f766..22adde1c7 100644 --- a/org.mixedrealitytoolkit.input/Tests/Runtime/MRTKRayInteractorVisualsTests.cs +++ b/org.mixedrealitytoolkit.input/Tests/Runtime/MRTKRayInteractorVisualsTests.cs @@ -9,6 +9,8 @@ using System.Collections; using UnityEngine; using UnityEngine.TestTools; +using UnityEngine.UI; +using UnityEngine.XR.Interaction.Toolkit.UI; namespace MixedReality.Toolkit.Input.Tests { @@ -95,6 +97,164 @@ public IEnumerator ReticleAndLineVisualActiveTest() Assert.IsTrue(lineVisual.GetComponentInChildren().enabled); Assert.IsFalse(reticleVisual.Reticle.activeSelf); } + + /// + /// Ensure that far ray reticle visual is hidden when tracking is lost while hovering a WorldSpace UI Canvas with TrackedDeviceGraphicRaycaster. + /// + [UnityTest] + public IEnumerator ReticleHiddenWhenTrackingLostOnUITest() + { + // Disable gaze interactions for this unit test + InputTestUtilities.DisableGazeInteractor(); + + var canvasGo = new GameObject("TestCanvas"); + canvasGo.transform.position = InputTestUtilities.InFrontOfUser(1f); + canvasGo.transform.rotation = Quaternion.identity; + + var canvas = canvasGo.AddComponent(); + canvas.renderMode = RenderMode.WorldSpace; + canvas.worldCamera = Camera.main; + + canvasGo.AddComponent(); + canvasGo.AddComponent(); + canvasGo.AddComponent(); + + var rectTransform = canvasGo.GetComponent(); + rectTransform.sizeDelta = new Vector2(500, 500); + + var imageGo = new GameObject("Image", typeof(RectTransform)); + imageGo.transform.SetParent(canvasGo.transform, false); + var image = imageGo.AddComponent(); + image.raycastTarget = true; + var imageRect = imageGo.GetComponent(); + imageRect.sizeDelta = new Vector2(400, 400); + + TestHand hand = new TestHand(Handedness.Right); + yield return hand.Show(Vector3.zero); + yield return RuntimeTestUtilities.WaitForUpdates(); + + var rayInteractor = CachedLookup.RightHandController.GetComponentInChildren(); + var reticleVisual = CachedLookup.RightHandController.GetComponentInChildren(); + + Vector3 hoverPosition = InputTestUtilities.InFrontOfUser(0.6f); + yield return hand.MoveTo(hoverPosition); + yield return RuntimeTestUtilities.WaitForUpdates(); + yield return hand.RotateTo(Quaternion.identity); + yield return RuntimeTestUtilities.WaitForUpdates(); + + // Check that the UI is hovered and the reticle is active + Assert.IsTrue(rayInteractor.TryGetCurrentUIRaycastResult(out _), "Ray should hit UI canvas"); + Assert.IsTrue(reticleVisual.Reticle.activeSelf, "Reticle should be active while hovering UI"); + + // Hide the hand (simulate tracking loss) + yield return hand.Hide(); + yield return RuntimeTestUtilities.WaitForUpdates(); + + // Reticle and UI hover should be inactive after tracking is lost + Assert.IsFalse(reticleVisual.Reticle.activeSelf, "Reticle should NOT stay active when hand tracking is lost"); + Assert.IsFalse(rayInteractor.HasUIHover, "HasUIHover should be false when hand tracking is lost"); + Assert.IsFalse(rayInteractor.TryGetCurrentUIRaycastResult(out _), "UIRaycastResult should be invalid when hand tracking is lost"); + + // Show hand again to ensure tracking restoration brings back the reticle + yield return hand.Show(); + yield return RuntimeTestUtilities.WaitForUpdates(); + + Assert.IsTrue(reticleVisual.Reticle.activeSelf, "Reticle should reactivate when hand tracking is restored"); + } + + /// + /// Ensure that far ray reticle visual is hidden when tracking is lost even if VisibilitySettings is AllValidSurfaces. + /// + [UnityTest] + public IEnumerator ReticleHiddenWhenTrackingLostAllValidSurfacesTest() + { + // Disable gaze interactions for this unit test + InputTestUtilities.DisableGazeInteractor(); + + var testObject = GameObject.CreatePrimitive(PrimitiveType.Cube); + testObject.transform.localScale = Vector3.one; + + TestHand hand = new TestHand(Handedness.Right); + yield return hand.Show(Vector3.zero); + yield return RuntimeTestUtilities.WaitForUpdates(); + + Vector3 hoverPosition = InputTestUtilities.InFrontOfUser(0.6f); + testObject.transform.position = hoverPosition + Vector3.forward * 0.5f; + + var reticleVisual = CachedLookup.RightHandController.GetComponentInChildren(); + var originalVisibility = reticleVisual.VisibilitySettings; + reticleVisual.VisibilitySettings = MRTKRayReticleVisual.ReticleVisibilitySettings.AllValidSurfaces; + + try + { + yield return hand.MoveTo(hoverPosition); + yield return RuntimeTestUtilities.WaitForUpdates(); + yield return hand.AimAt(testObject.transform.position); + yield return RuntimeTestUtilities.WaitForUpdates(); + + Assert.IsTrue(reticleVisual.Reticle.activeSelf, "Reticle should be active on valid surface"); + + yield return hand.Hide(); + yield return RuntimeTestUtilities.WaitForUpdates(); + + Assert.IsFalse(reticleVisual.Reticle.activeSelf, "Reticle should be hidden when hand tracking is lost even with AllValidSurfaces"); + } + finally + { + reticleVisual.VisibilitySettings = originalVisibility; + } + } + + /// + /// Ensure that far ray reticle visual is hidden when tracking is lost while hovering a 3D interactable, + /// and that hasHover and isHoverActive both become false. + /// + [UnityTest] + public IEnumerator ReticleHiddenWhenTrackingLostOnInteractableTest() + { + // Disable gaze interactions for this unit test + InputTestUtilities.DisableGazeInteractor(); + + var testObject = GameObject.CreatePrimitive(PrimitiveType.Cube); + testObject.transform.localScale = Vector3.one * 0.2f; + testObject.transform.position = InputTestUtilities.InFrontOfUser(1f); + testObject.AddComponent(); + + TestHand hand = new TestHand(Handedness.Right); + yield return hand.Show(Vector3.zero); + yield return RuntimeTestUtilities.WaitForUpdates(); + + var rayInteractor = CachedLookup.RightHandController.GetComponentInChildren(); + var reticleVisual = CachedLookup.RightHandController.GetComponentInChildren(); + + Vector3 hoverPosition = InputTestUtilities.InFrontOfUser(0.6f); + yield return hand.MoveTo(hoverPosition); + yield return RuntimeTestUtilities.WaitForUpdates(); + yield return hand.AimAt(testObject.transform.position); + yield return RuntimeTestUtilities.WaitForUpdates(); + + // Check that the 3D interactable is hovered, isHoverActive is true, and the reticle is active + Assert.IsTrue(rayInteractor.isHoverActive, "isHoverActive should be true while pointing forward and tracked"); + Assert.IsTrue(rayInteractor.hasHover, "hasHover should be true while aiming at the 3D interactable"); + Assert.IsTrue(reticleVisual.Reticle.activeSelf, "Reticle should be active while hovering the 3D interactable"); + + // Hide the hand (simulate tracking loss) + yield return hand.Hide(); + yield return RuntimeTestUtilities.WaitForUpdates(); + + // isHoverActive, hasHover, and reticle should all be false after tracking is lost + Assert.IsFalse(rayInteractor.isHoverActive, "isHoverActive should be false when tracking is lost"); + Assert.IsFalse(rayInteractor.hasHover, "hasHover should be false when tracking is lost"); + Assert.IsFalse(reticleVisual.Reticle.activeSelf, "Reticle should NOT stay active when hand tracking is lost"); + + // Show hand again to ensure tracking restoration brings back hover and reticle + yield return hand.Show(); + yield return RuntimeTestUtilities.WaitForUpdates(); + + Assert.IsTrue(rayInteractor.isHoverActive, "isHoverActive should be true when tracking is restored"); + Assert.IsTrue(rayInteractor.hasHover, "hasHover should be true when tracking is restored"); + Assert.IsTrue(reticleVisual.Reticle.activeSelf, "Reticle should reactivate when hand tracking is restored"); + } } } #pragma warning restore CS1591 \ No newline at end of file