Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions org.mixedrealitytoolkit.input/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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();
}
Expand All @@ -58,7 +61,10 @@ protected override void OnEnable()
/// </summary>
protected virtual void OnDisable()
{
rayInteractor.selectEntered.RemoveListener(LocateTargetHitPoint);
if (rayInteractor != null)
{
rayInteractor.selectEntered.RemoveListener(LocateTargetHitPoint);
}
Application.onBeforeRender -= UpdateReticle;

ReticleSetActive(false);
Expand Down Expand Up @@ -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)
{
Expand Down Expand Up @@ -148,7 +157,10 @@ private void ReticleSetActive(bool value)
/// </summary>
private void LocateTargetHitPoint(SelectEnterEventArgs args)
{
rayInteractor.TryLocateTargetHitPoint(args.interactableObject, out selectedHitDetails);
if (rayInteractor != null)
{
rayInteractor.TryLocateTargetHitPoint(args.interactableObject, out selectedHitDetails);
}
}

/// <summary>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,7 @@ public class MRTKRayInteractor :
/// <summary>
/// Is this ray currently hovering a UnityUI/Canvas element?
/// </summary>
public bool HasUIHover => TryGetUIModel(out TrackedDeviceModel model) && model.currentRaycast.isValid;
public bool HasUIHover => isHoverActive && TryGetUIModel(out TrackedDeviceModel model) && model.currentRaycast.isValid;

/// <summary>
/// Is this ray currently selecting a UnityUI/Canvas element?
Expand All @@ -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.
/// </summary>
private bool IsTracked => xrController.currentControllerState.inputTrackingState.HasPositionAndRotation();
private bool IsTracked => xrController != null && xrController.currentControllerState.inputTrackingState.HasPositionAndRotation();

/// <summary>
/// Cached reference to hands aggregator for efficient per-frame use.
Expand Down Expand Up @@ -228,6 +228,43 @@ protected override void OnSelectEntering(SelectEnterEventArgs args)

#endregion XRBaseInteractor

#region IUIInteractor

private bool wasUIInteractionActive = false;

/// <inheritdoc />
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<Vector3>();
}
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<Vector3>(model.raycastPoints);
model.raycastPoints.Clear();
model.raycastPoints = newPoints;
}
}

#endregion IUIInteractor

/// <summary>
/// A Unity event function that is called every frame, if this object is enabled.
/// </summary>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
{
Expand Down Expand Up @@ -95,6 +97,164 @@ public IEnumerator ReticleAndLineVisualActiveTest()
Assert.IsTrue(lineVisual.GetComponentInChildren<LineRenderer>().enabled);
Assert.IsFalse(reticleVisual.Reticle.activeSelf);
}

/// <summary>
/// Ensure that far ray reticle visual is hidden when tracking is lost while hovering a WorldSpace UI Canvas with TrackedDeviceGraphicRaycaster.
/// </summary>
[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>();
canvas.renderMode = RenderMode.WorldSpace;
canvas.worldCamera = Camera.main;

canvasGo.AddComponent<CanvasScaler>();
canvasGo.AddComponent<GraphicRaycaster>();
canvasGo.AddComponent<TrackedDeviceGraphicRaycaster>();

var rectTransform = canvasGo.GetComponent<RectTransform>();
rectTransform.sizeDelta = new Vector2(500, 500);

var imageGo = new GameObject("Image", typeof(RectTransform));
imageGo.transform.SetParent(canvasGo.transform, false);
var image = imageGo.AddComponent<Image>();
image.raycastTarget = true;
var imageRect = imageGo.GetComponent<RectTransform>();
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<MRTKRayInteractor>();
var reticleVisual = CachedLookup.RightHandController.GetComponentInChildren<MRTKRayReticleVisual>();

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");
}

/// <summary>
/// Ensure that far ray reticle visual is hidden when tracking is lost even if VisibilitySettings is AllValidSurfaces.
/// </summary>
[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<MRTKRayReticleVisual>();
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;
}
}

/// <summary>
/// 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.
/// </summary>
[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<StatefulInteractable>();

TestHand hand = new TestHand(Handedness.Right);
yield return hand.Show(Vector3.zero);
yield return RuntimeTestUtilities.WaitForUpdates();

var rayInteractor = CachedLookup.RightHandController.GetComponentInChildren<MRTKRayInteractor>();
var reticleVisual = CachedLookup.RightHandController.GetComponentInChildren<MRTKRayReticleVisual>();

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
Loading