// Copyright (c) Microsoft Corporation. // Licensed under the MIT License. using Microsoft.MixedReality.Toolkit.Physics; using Microsoft.MixedReality.Toolkit.Utilities; using Unity.Profiling; using UnityEngine; namespace Microsoft.MixedReality.Toolkit.Input { /// /// A near interaction pointer that generates touch events based on touchables in close proximity. /// /// /// _Reachable Objects_ are objects with a both a [BaseNearInteractionTouchable](xref:Microsoft.MixedReality.Toolkit.Input.BaseNearInteractionTouchable) and a collider within [TouchableDistance](xref:Microsoft.MixedReality.Toolkit.Input.PokePointer.TouchableDistance) from the poke pointer (based on [OverlapSphere](https://docs.unity3d.com/ScriptReference/Physics.OverlapSphere.html)). /// /// If a poke pointer has no [CurrentTouchableObjectDown](xref:Microsoft.MixedReality.Toolkit.Input.PokePointer.CurrentTouchableObjectDown), then it will try to select one from the Reachable Objects based on: /// 1. Layer mask priority: Lower-priority layer masks will only be considered if higher-priority layers don't contain any Reachable Objects. /// 1. Touchable Distance: the closest object in the highest priority layers is selected based on [DistanceToTouchable](xref:Microsoft.MixedReality.Toolkit.Input.BaseNearInteractionTouchable.DistanceToTouchable*). /// 1. Ray Distance: The object becomes the [CurrentTouchableObjectDown](xref:Microsoft.MixedReality.Toolkit.Input.PokePointer.CurrentTouchableObjectDown) once the ray cast distance becomes negative (behind the surface). At this point the [OnTouchStarted](xref:Microsoft.MixedReality.Toolkit.Input.IMixedRealityTouchHandler.OnTouchStarted*) or [OnPointerDown](xref:Microsoft.MixedReality.Toolkit.Input.IMixedRealityPointerHandler.OnPointerDown*) event is raised. /// /// If a poke pointer _does_ have a [CurrentTouchableObjectDown](xref:Microsoft.MixedReality.Toolkit.Input.PokePointer.CurrentTouchableObjectDown) it will not consider any other object, until the [DistanceToTouchable](xref:Microsoft.MixedReality.Toolkit.Input.BaseNearInteractionTouchable.DistanceToTouchable*) exceeds the [DebounceThreshold](xref:Microsoft.MixedReality.Toolkit.Input.BaseNearInteractionTouchable.DebounceThreshold) (in front of the surface). At this point the active object is cleared and the [OnTouchCompleted](xref:Microsoft.MixedReality.Toolkit.Input.IMixedRealityTouchHandler.OnTouchCompleted*) or [OnPointerUp](xref:Microsoft.MixedReality.Toolkit.Input.IMixedRealityPointerHandler.OnPointerUp*) event is raised. /// [AddComponentMenu("Scripts/MRTK/SDK/PokePointer")] public class PokePointer : BaseControllerPointer, IMixedRealityNearPointer { [SerializeField] [Tooltip("Maximum distance a which a touchable surface can be interacted with.")] protected float touchableDistance = 0.2f; /// /// Maximum distance a which a touchable surface can be interacted with. /// public float TouchableDistance => touchableDistance; [SerializeField] [Tooltip("The offset that the poke pointer has from the source pose when the index finger pose is not available.")] protected float sourcePoseOffset = 0.075f; /// /// The offset that the poke pointer has from the source pose when the index finger pose is not available. /// This value puts the pointer slightly in front of the source pose's origin, oriented according to the source pose's rotation /// public float SourcePoseOffset => sourcePoseOffset; [SerializeField] [Tooltip("Maximum number of colliders that can be detected in a scene query.")] [Min(1)] private int sceneQueryBufferSize = 64; /// /// Maximum number of colliders that can be detected in a scene query. /// public int SceneQueryBufferSize => sceneQueryBufferSize; [SerializeField] [Tooltip("Whether to ignore colliders that may be near the pointer, but not actually in the visual FOV. " + "This can prevent accidental touches, and will allow hand rays to turn on when you may be near a " + "touchable but cannot see it. Visual FOV is defined by cone centered about display center, " + "radius equal to half display height.")] private bool ignoreCollidersNotInFOV = true; /// /// Whether to ignore colliders that may be near the pointer, but not actually in the visual FOV. /// This can prevent accidental touches, and will allow hand rays to turn on when you may be near /// a touchable but cannot see it. Visual FOV is defined by cone centered about display center, /// radius equal to half display height. /// public bool IgnoreCollidersNotInFOV { get => ignoreCollidersNotInFOV; set => ignoreCollidersNotInFOV = value; } [SerializeField] [Tooltip("The LayerMasks, in prioritized order, that are used to determine the touchable objects.")] private LayerMask[] pokeLayerMasks = { UnityEngine.Physics.DefaultRaycastLayers }; /// /// The LayerMasks, in prioritized order, that are used to determine the touchable objects. /// /// /// Only [BaseNearInteractionTouchables](xref:Microsoft.MixedReality.Toolkit.Input.BaseNearInteractionTouchable) in one of the LayerMasks will raise touch events. /// [System.Obsolete("Use PrioritizedLayerMasksOverride instead")] public LayerMask[] PokeLayerMasks => pokeLayerMasks; /// public override LayerMask[] PrioritizedLayerMasksOverride { get { return pokeLayerMasks; } set { pokeLayerMasks = value; } } [SerializeField] [Tooltip("Specify whether queries for touchable surfaces hit triggers.")] protected QueryTriggerInteraction triggerInteraction = QueryTriggerInteraction.UseGlobal; /// /// Specify whether queries for touchable surfaces hit triggers. /// public QueryTriggerInteraction TriggerInteraction => triggerInteraction; private Collider[] queryBuffer; private float closestDistance = 0.0f; private Vector3 closestNormal = Vector3.forward; // previous frame pointer position public Vector3 PreviousPosition { get; private set; } = Vector3.zero; private BaseNearInteractionTouchable closestProximityTouchable = null; /// /// The closest touchable component that has been detected. /// /// /// The closest touchable component limits the set of objects which are currently touchable. /// These are all the game objects in the subtree of the closest touchable component's owner object. /// public BaseNearInteractionTouchable ClosestProximityTouchable => closestProximityTouchable; private GameObject currentTouchableObjectDown = null; /// /// The current object that is being touched. /// /// We need to make sure to consistently fire /// poke-down / poke-up events for this object. This is also the case when the object within /// the same current closest touchable component's changes (e.g. Unity UI control elements). public GameObject CurrentTouchableObjectDown => currentTouchableObjectDown; private void Awake() { queryBuffer = new Collider[sceneQueryBufferSize]; } protected void OnValidate() { touchableDistance = Mathf.Max(touchableDistance, 0); sceneQueryBufferSize = Mathf.Max(sceneQueryBufferSize, 1); } /// public virtual bool IsNearObject => closestProximityTouchable != null; /// public override bool IsInteractionEnabled => base.IsInteractionEnabled && IsNearObject; private static readonly ProfilerMarker OnPreSceneQueryPerfMarker = new ProfilerMarker("[MRTK] PokePointer.OnPreSceneQuery"); public override void OnPreSceneQuery() { using (OnPreSceneQueryPerfMarker.Auto()) { if (Rays == null) { Rays = new RayStep[1]; } // Find closest touchable BaseNearInteractionTouchable newClosestTouchable = null; foreach (var layerMask in PrioritizedLayerMasksOverride) { if (FindClosestTouchableForLayerMask(layerMask, out newClosestTouchable, out closestDistance, out closestNormal)) { break; } } if (newClosestTouchable != null) { // Build ray (poke from in front to the back of the pointer position) NearInteractionTouchableVolume touchableVolume = newClosestTouchable as NearInteractionTouchableVolume; if (touchableVolume != null && (closestDistance < 0.0f)) { // When we are inside of a volume, ensure that we actually hit it by placing the origin closely outside the volume. Vector3 start = Position + (-closestDistance * 1.01f) * closestNormal; Vector3 end = Position - touchableVolume.TouchableCollider.bounds.size.magnitude * closestNormal; Rays[0].UpdateRayStep(ref start, ref end); } else { Vector3 start = Position + touchableDistance * closestNormal; Vector3 end = Position - touchableDistance * closestNormal; Rays[0].UpdateRayStep(ref start, ref end); } } else { closestNormal = Rotation * Vector3.forward; } // Check if the currently touched object is still part of the new touchable. if (currentTouchableObjectDown != null) { if (!IsObjectPartOfTouchable(currentTouchableObjectDown, newClosestTouchable)) { TryRaisePokeUp(); } } // Set new touchable only now: If we have to raise a poke-up event for the previous touchable object, // we need to do so using the previous touchable in TryRaisePokeUp(). closestProximityTouchable = newClosestTouchable; } } private static readonly ProfilerMarker FindClosestTouchableForLayerMaskPerfMarker = new ProfilerMarker("[MRTK] PokePointer.FindClosestTouchableForLayerMask"); private bool FindClosestTouchableForLayerMask(LayerMask layerMask, out BaseNearInteractionTouchable closest, out float closestDistance, out Vector3 closestNormal) { using (FindClosestTouchableForLayerMaskPerfMarker.Auto()) { closest = null; closestDistance = float.PositiveInfinity; closestNormal = Vector3.forward; int numColliders = UnityEngine.Physics.OverlapSphereNonAlloc(Position, touchableDistance, queryBuffer, layerMask, triggerInteraction); if (numColliders == queryBuffer.Length) { Debug.LogWarning($"Maximum number of {numColliders} colliders found in PokePointer overlap query. Consider increasing the query buffer size in the input system settings."); } Camera mainCam = CameraCache.Main; for (int i = 0; i < numColliders; ++i) { Collider collider = queryBuffer[i]; #if UNITY_2019_4_OR_NEWER if (collider.TryGetComponent(out BaseNearInteractionTouchable touchable) && touchable != null) #else BaseNearInteractionTouchable touchable = collider.GetComponent(); if (touchable != null) #endif { if (IgnoreCollidersNotInFOV && !mainCam.IsInFOVCached(collider)) { continue; } float distance = touchable.DistanceToTouchable(Position, out Vector3 normal); // Favor touched volumes, but when there are multiple touched volumes, favor the one with the closest surface. bool bothInside = (distance <= 0f) && (closestDistance <= 0f); bool betterFit = bothInside ? Mathf.Abs(distance) < Mathf.Abs(closestDistance) : distance < closestDistance; if (betterFit) { closest = touchable; closestDistance = distance; closestNormal = normal; } } } // Unity UI does not provide an equivalent broad-phase test to Physics.OverlapSphere, // so we have to use a static instances list to test all NearInteractionTouchableUnityUI for (int i = 0; i < NearInteractionTouchableUnityUI.Instances.Count; i++) { NearInteractionTouchableUnityUI touchable = NearInteractionTouchableUnityUI.Instances[i]; if (touchable.gameObject.IsInLayerMask(layerMask)) { float distance = touchable.DistanceToTouchable(Position, out Vector3 normal); if (distance <= touchableDistance && distance < closestDistance) { closest = touchable; closestDistance = distance; closestNormal = normal; } } } return closest != null; } } private static readonly ProfilerMarker OnPostSceneQueryPerfMarker = new ProfilerMarker("[MRTK] PokePointer.OnPostSceneQuery"); /// public override void OnPostSceneQuery() { using (OnPostSceneQueryPerfMarker.Auto()) { base.OnPostSceneQuery(); if (!IsActive) { return; } if (Result?.CurrentPointerTarget != null && closestProximityTouchable != null) { float distToTouchable; if (closestProximityTouchable is NearInteractionTouchableVolume) { // Volumes can be arbitrary size, so don't rely on the length of the raycast ray // instead just have the volume itself give us the distance. distToTouchable = closestProximityTouchable.DistanceToTouchable(Position, out _); } else { // Start position of the ray is offset by TouchableDistance, subtract to get distance between surface and pointer position. distToTouchable = Vector3.Distance(Result.StartPoint, Result.Details.Point) - touchableDistance; } bool newIsDown = (distToTouchable < 0.0f); bool newIsUp = (distToTouchable > closestProximityTouchable.DebounceThreshold); if (newIsDown) { TryRaisePokeDown(); } else if (currentTouchableObjectDown != null) { if (newIsUp) { TryRaisePokeUp(); } else { TryRaisePokeDown(); } } } PreviousPosition = Position; } } private static readonly ProfilerMarker OnPreCurrentPointerTargetChangePerfMarker = new ProfilerMarker("[MRTK] PokePointer.OnPreCurrentPointerTargetChange"); /// public override void OnPreCurrentPointerTargetChange() { using (OnPreCurrentPointerTargetChangePerfMarker.Auto()) { // We need to raise the event now, since the pointer's focused object or touchable will change // after we leave this function. This will make sure the same object that received the Down event // will also receive the Up event. TryRaisePokeUp(); } } private static readonly ProfilerMarker TryRaisePokeDownPerfMarker = new ProfilerMarker("[MRTK] PokePointer.TryRaisePokeDown"); private void TryRaisePokeDown() { using (TryRaisePokeDownPerfMarker.Auto()) { GameObject targetObject = Result.CurrentPointerTarget; if (currentTouchableObjectDown == null) { // In order to get reliable up/down event behavior, only allow the closest touchable to be touched. if (IsObjectPartOfTouchable(targetObject, closestProximityTouchable)) { currentTouchableObjectDown = targetObject; if (closestProximityTouchable.EventsToReceive == TouchableEventType.Pointer) { CoreServices.InputSystem?.RaisePointerDown(this, pointerAction, Handedness); } else if (closestProximityTouchable.EventsToReceive == TouchableEventType.Touch) { CoreServices.InputSystem?.RaiseOnTouchStarted(InputSourceParent, Controller, Handedness, Position); } } } else { RaiseTouchUpdated(targetObject, Position); } } } private static readonly ProfilerMarker TryRaisePokeUpPerfMarker = new ProfilerMarker("[MRTK] PokePointer.TryRaisePokeUp"); private void TryRaisePokeUp() { using (TryRaisePokeUpPerfMarker.Auto()) { if (currentTouchableObjectDown != null) { Debug.Assert(Result.CurrentPointerTarget == currentTouchableObjectDown, "PokeUp will not be raised for correct object."); if (closestProximityTouchable.EventsToReceive == TouchableEventType.Pointer) { CoreServices.InputSystem.RaisePointerClicked(this, pointerAction, 0, Handedness); CoreServices.InputSystem?.RaisePointerUp(this, pointerAction, Handedness); } else if (closestProximityTouchable.EventsToReceive == TouchableEventType.Touch) { CoreServices.InputSystem?.RaiseOnTouchCompleted(InputSourceParent, Controller, Handedness, Position); } currentTouchableObjectDown = null; } } } private static readonly ProfilerMarker RaiseTouchUpdatedPerfMarker = new ProfilerMarker("[MRTK] PokePointer.RaiseTouchUpdated"); private void RaiseTouchUpdated(GameObject targetObject, Vector3 touchPosition) { using (RaiseTouchUpdatedPerfMarker.Auto()) { if (currentTouchableObjectDown != null) { Debug.Assert(Result?.CurrentPointerTarget == currentTouchableObjectDown); if (closestProximityTouchable.EventsToReceive == TouchableEventType.Touch) { CoreServices.InputSystem?.RaiseOnTouchUpdated(InputSourceParent, Controller, Handedness, touchPosition); } else if (closestProximityTouchable.EventsToReceive == TouchableEventType.Pointer) { CoreServices.InputSystem?.RaisePointerDragged(this, pointerAction, Handedness, InputSourceParent); } } } } private static readonly ProfilerMarker IsObjectPartOfTouchablePerfMarker = new ProfilerMarker("[MRTK] PokePointer.IsObjectPartOfTouchable"); private static bool IsObjectPartOfTouchable(GameObject targetObject, BaseNearInteractionTouchable touchable) { using (IsObjectPartOfTouchablePerfMarker.Auto()) { return targetObject != null && touchable != null && (targetObject == touchable.gameObject || // Descendant game objects are touchable as well. In particular, this is needed to be able to send // touch events to Unity UI control elements. (targetObject.transform != null && touchable.gameObject.transform != null && targetObject.transform.IsChildOf(touchable.gameObject.transform))); } } /// bool IMixedRealityNearPointer.TryGetNearGraspPoint(out Vector3 position) { position = Vector3.zero; return false; } /// bool IMixedRealityNearPointer.TryGetDistanceToNearestSurface(out float distance) { if (closestProximityTouchable != null) { distance = closestDistance; return true; } else { distance = 0.0f; return false; } } /// bool IMixedRealityNearPointer.TryGetNormalToNearestSurface(out Vector3 normal) { normal = closestNormal; return closestProximityTouchable != null; } private static readonly ProfilerMarker OnSourceLostPerfMarker = new ProfilerMarker("[MRTK] PokePointer.OnSourceLost"); /// public override void OnSourceLost(SourceStateEventData eventData) { using (OnSourceLostPerfMarker.Auto()) { TryRaisePokeUp(); base.OnSourceLost(eventData); } } private static readonly ProfilerMarker OnSourceDetectedPerfMarker = new ProfilerMarker("[MRTK] PokePointer.OnSourceDetected"); /// public override void OnSourceDetected(SourceStateEventData eventData) { using (OnSourceDetectedPerfMarker.Auto()) { base.OnSourceDetected(eventData); PreviousPosition = Position; } } /// public override void OnSourcePoseChanged(SourcePoseEventData eventData) { base.OnSourcePoseChanged(eventData); if (SourcePoseDataUsable(eventData)) { transform.position += sourcePoseOffset * transform.forward; } } /// public override void OnInputDown(InputEventData eventData) { // Poke pointer should not respond when a button is pressed or hand is pinched // It should only dispatch events based on collision with touchables. } /// public override void OnInputUp(InputEventData eventData) { // Poke pointer should not respond when a button is released or hand is un-pinched // It should only dispatch events based on collision with touchables. } protected override void OnEnable() { base.OnEnable(); IsTargetPositionLockedOnFocusLock = false; } private void OnDrawGizmos() { if (!IsNearObject) { return; } else { Gizmos.color = Color.green; } if (closestProximityTouchable != null) { Gizmos.DrawLine(Position, closestProximityTouchable.transform.position); } } } }