// Copyright (c) Microsoft Corporation. // Licensed under the MIT License. using Microsoft.MixedReality.Toolkit.Input; using Microsoft.MixedReality.Toolkit.Physics; using Microsoft.MixedReality.Toolkit.Utilities; using System; using System.Runtime.CompilerServices; using Unity.Profiling; using UnityEngine; using UnityEngine.Serialization; using UnityPhysics = UnityEngine.Physics; [assembly: InternalsVisibleTo("Microsoft.MixedReality.Toolkit.Tests.PlayModeTests")] namespace Microsoft.MixedReality.Toolkit.Teleport { /// /// Implementation for teleportation pointer to support movement based on teleport raycasts and requests with the MRTK Teleport system /// [RequireComponent(typeof(DistorterGravity))] [AddComponentMenu("Scripts/MRTK/SDK/TeleportPointer")] public class TeleportPointer : CurvePointer, IMixedRealityTeleportPointer, IMixedRealityTeleportHandler { /// /// True if a teleport request is being raised, false otherwise. /// public bool TeleportRequestRaised { get; private set; } = false; /// /// The result from the last raycast. /// public TeleportSurfaceResult TeleportSurfaceResult { get; private set; } = TeleportSurfaceResult.None; /// public IMixedRealityTeleportHotspot TeleportHotspot { get; set; } [SerializeField] [Tooltip("Teleport Pointer will only respond to input events for teleportation that match this MixedRealityInputAction")] private MixedRealityInputAction teleportAction = MixedRealityInputAction.None; /// /// Teleport Pointer will only respond to input events for teleportation that match this MixedRealityInputAction /// public MixedRealityInputAction TeleportInputAction => teleportAction; [SerializeField] [Tooltip("Teleport Pointer Cursor visibility when a hotspot is in focus")] private bool hotSpotCursorVisibility = true; /// /// Teleport pointer cursor visibility if pointer is focused on hotspot /// public bool TeleportHotSpotCursorVisibility { get => hotSpotCursorVisibility; set => hotSpotCursorVisibility = value; } [SerializeField] [Range(0f, 1f)] [Tooltip("The threshold amount for joystick input (Dead Zone)")] private float inputThreshold = 0.5f; [SerializeField] [Range(0f, 360f)] [Tooltip("If Pressing 'forward' on the thumbstick gives us an angle that doesn't quite feel like the forward direction, we apply this offset to make navigation feel more natural")] private float angleOffset = 0f; [SerializeField] [Range(5f, 90f)] [Tooltip("The angle from the pointer's forward position that will activate the teleport.")] private float teleportActivationAngle = 45f; [SerializeField] [Range(5f, 90f)] [Tooltip("The angle from the joystick left and right position that will activate a rotation")] private float rotateActivationAngle = 22.5f; [SerializeField] [Range(5f, 180f)] [Tooltip("The amount to rotate the camera when rotation is activated")] private float rotationAmount = 90f; [SerializeField] [Range(5, 90f)] [Tooltip("The angle from the joystick down position that will activate a strafe that will move the camera back")] private float backStrafeActivationAngle = 45f; [SerializeField] [Tooltip("The distance to move the camera when the strafe is activated")] internal float strafeAmount = 0.25f; [SerializeField] [Tooltip("Whether or not a strafe checks that there is a floor beneath the user's origin on strafe")] internal bool checkForFloorOnStrafe = false; [SerializeField] [Tooltip("Whether or not the user's y-position can move during a strafe")] internal bool adjustHeightOnStrafe = false; [SerializeField] [Tooltip("The detection range for a floor on strafe, as well as the max amount that a user's y-position can change on strafe")] internal float maxHeightChangeOnStrafe = 0.5f; [SerializeField] [Range(0f, 1f)] [Tooltip("The up direction threshold to use when determining if a surface is 'flat' enough to teleport to.")] private float upDirectionThreshold = 0.2f; [SerializeField] protected Gradient LineColorHotSpot = new Gradient(); [SerializeField] [FormerlySerializedAs("teleportLayerMasks")] [Tooltip("The LayerMasks, in prioritized order, that are used to determine the the objects the teleport pointer is allowed to hit")] private LayerMask[] teleportRaycastLayerMasks = { UnityPhysics.DefaultRaycastLayers }; /// public override LayerMask[] PrioritizedLayerMasksOverride { get { return teleportRaycastLayerMasks; } set { teleportRaycastLayerMasks = value; } } [SerializeField] [Tooltip("Layers that are considered 'valid' for navigation. Layers which are not here are considered 'invalid' for navigation. This is separate from " + "layers which the teleport pointer is allowed to hit")] [FormerlySerializedAs("ValidLayers")] protected LayerMask ValidTeleportationLayers = UnityPhysics.DefaultRaycastLayers; protected LayerMask InvalidTeleportationLayers { get { LayerMask raycastedLayerMasks = new LayerMask(); for (int i = 0; i < PrioritizedLayerMasksOverride.Length; i++) { raycastedLayerMasks |= PrioritizedLayerMasksOverride[i]; } return ~ValidTeleportationLayers & raycastedLayerMasks; } } [SerializeField] private DistorterGravity gravityDistorter = null; /// /// The Gravity Distorter that is affecting the attached to this pointer. /// public DistorterGravity GravityDistorter => gravityDistorter; private float cachedInputThreshold = 0f; private float inputThresholdSquared = 0f; /// /// The square of the InputThreshold value. /// private float InputThresholdSquared { get { if (!Mathf.Approximately(cachedInputThreshold, inputThreshold)) { inputThresholdSquared = Mathf.Pow(inputThreshold, 2f); cachedInputThreshold = inputThreshold; } return inputThresholdSquared; } } #region Audio Management [Header("Audio management")] [SerializeField] private AudioSource pointerAudioSource = null; [SerializeField] private AudioClip teleportRequestedClip = null; [SerializeField] private AudioClip teleportCompletedClip = null; #endregion protected override void OnEnable() { base.OnEnable(); // Disable renderers so that they don't display before having been processed (which manifests as a flash at the origin). var renderers = GetComponents(); if (renderers != null) { foreach (var renderer in renderers) { renderer.enabled = false; } } if (gravityDistorter == null) { gravityDistorter = GetComponent(); } if (!lateRegisterTeleport) { CoreServices.TeleportSystem?.RegisterHandler(this); } } protected override async void Start() { base.Start(); if (lateRegisterTeleport) { if (CoreServices.TeleportSystem == null) { await new WaitUntil(() => CoreServices.TeleportSystem != null); // We've been destroyed during the await. if (this.IsNull()) { return; } // The pointer's input source was lost during the await. if (Controller == null) { Destroy(gameObject); return; } } lateRegisterTeleport = false; CoreServices.TeleportSystem?.RegisterHandler(this); } } protected override void OnDisable() { base.OnDisable(); CoreServices.TeleportSystem?.UnregisterHandler(this); } private Vector2 currentInputPosition = Vector2.zero; protected bool isTeleportRequestActive = false; private bool lateRegisterTeleport = true; private bool canTeleport = false; private bool canMove = false; protected Gradient GetLineGradient(TeleportSurfaceResult targetResult) { switch (targetResult) { case TeleportSurfaceResult.None: return LineColorNoTarget; case TeleportSurfaceResult.Valid: return LineColorValid; case TeleportSurfaceResult.Invalid: return LineColorInvalid; case TeleportSurfaceResult.HotSpot: return LineColorHotSpot; default: throw new ArgumentOutOfRangeException(nameof(targetResult), targetResult, null); } } /// /// check if a backstrafe is possible on a valid platform regarding the possible strafe height given /// /// the new position relative to backstrafe position /// actual position the strafe raycast hits /// if there is a valid layer one can backstrafe on internal bool CheckPossibleBackStep(Vector3 newPosition, out Vector3 hitStrafePosition) { var raycastProvider = CoreServices.InputSystem.RaycastProvider; Vector3 strafeOrigin = new Vector3(newPosition.x, MixedRealityPlayspace.Position.y + maxHeightChangeOnStrafe, newPosition.z); Vector3 strafeTerminus = strafeOrigin + (Vector3.down * maxHeightChangeOnStrafe * 2f); RayStep rayStep = new RayStep(strafeOrigin, strafeTerminus); LayerMask[] layerMasks = new LayerMask[] { ValidTeleportationLayers }; // check are we hiting a floor plane or step above the current MixedRealityPlayspace.Position if (!raycastProvider.IsNull() && raycastProvider.Raycast(rayStep, layerMasks, false, out var hitInfo)) { hitStrafePosition = hitInfo.point; return true; } hitStrafePosition = Vector3.zero; return false; } /// /// Performs a strafe in the opposite direction of the camera's forward direction /// internal void PerformStrafe() { canMove = false; var height = MixedRealityPlayspace.Position.y; var newPosition = -CameraCache.Main.transform.forward * strafeAmount + MixedRealityPlayspace.Position; newPosition.y = height; bool isValidStrafe = true; if (checkForFloorOnStrafe) { isValidStrafe = CheckPossibleBackStep(newPosition, out var strafeHitPosition); if (adjustHeightOnStrafe) { newPosition = strafeHitPosition; } } if (isValidStrafe) { MixedRealityPlayspace.Position = newPosition; } } #region IMixedRealityPointer Implementation /// public override bool IsInteractionEnabled => !isTeleportRequestActive && TeleportRequestRaised && MixedRealityToolkit.IsTeleportSystemEnabled; [SerializeField] [Range(0f, 360f)] [Tooltip("The Y orientation of the pointer - used for rotation and navigation")] private float pointerOrientation = 0f; /// public float PointerOrientation { get { if (TeleportHotspot != null && TeleportHotspot.OverrideOrientation && TeleportSurfaceResult == TeleportSurfaceResult.HotSpot) { return TeleportHotspot.TargetRotation; } return pointerOrientation + (raycastOrigin != null ? raycastOrigin.eulerAngles.y : transform.eulerAngles.y); } set { pointerOrientation = value < 0 ? Mathf.Clamp(value, -360f, 0f) : Mathf.Clamp(value, 0f, 360f); } } private static readonly ProfilerMarker OnPreSceneQueryPerfMarker = new ProfilerMarker("[MRTK] TeleportPointer.OnPreSceneQuery"); /// public override void OnPreSceneQuery() { using (OnPreSceneQueryPerfMarker.Auto()) { // Set up our rays // Turn off gravity so we get accurate rays GravityDistorter.enabled = false; base.OnPreSceneQuery(); // Re-enable gravity if we're looking at a hotspot GravityDistorter.enabled = (TeleportSurfaceResult == TeleportSurfaceResult.HotSpot); } } private static readonly ProfilerMarker OnPostSceneQueryPerfMarker = new ProfilerMarker("[MRTK] TeleportPointer.OnPostSceneQuery"); /// public override void OnPostSceneQuery() { using (OnPostSceneQueryPerfMarker.Auto()) { if (IsSelectPressed) { CoreServices.InputSystem.RaisePointerDragged(this, MixedRealityInputAction.None, Handedness); } if (currentInputPosition != Vector2.zero && Controller != null) { CoreServices.InputSystem.RaisePointerDragged(this, MixedRealityInputAction.None, Controller.ControllerHandedness); } // Use the results from the last update to set our NavigationResult float clearWorldLength = 0f; TeleportSurfaceResult = TeleportSurfaceResult.None; GravityDistorter.enabled = false; if (IsInteractionEnabled) { LineBase.enabled = true; // If we hit something if (Result.CurrentPointerTarget != null) { // Check if it's in our valid layers if (((1 << Result.CurrentPointerTarget.layer) & ValidTeleportationLayers.value) != 0) { // See if it's a hot spot if (TeleportHotspot != null && TeleportHotspot.IsActive) { TeleportSurfaceResult = TeleportSurfaceResult.HotSpot; // Turn on gravity, point it at hotspot GravityDistorter.WorldCenterOfGravity = TeleportHotspot.Position; GravityDistorter.enabled = true; } else { // If it's NOT a hotspot, check if the hit normal is too steep // (Hotspots override dot requirements) TeleportSurfaceResult = Vector3.Dot(Result.Details.LastRaycastHit.normal, Vector3.up) > upDirectionThreshold ? TeleportSurfaceResult.Valid : TeleportSurfaceResult.Invalid; } } else { TeleportSurfaceResult = TeleportSurfaceResult.Invalid; } clearWorldLength = Result.Details.RayDistance; // Clamp the end of the parabola to the result hit's point LineBase.LineEndClamp = LineBase.GetNormalizedLengthFromWorldLength(clearWorldLength, LineCastResolution); if (hotSpotCursorVisibility) { BaseCursor?.SetVisibility(TeleportSurfaceResult == TeleportSurfaceResult.Valid || TeleportSurfaceResult == TeleportSurfaceResult.HotSpot); } else { BaseCursor?.SetVisibility(TeleportSurfaceResult == TeleportSurfaceResult.Valid); } } else { BaseCursor?.SetVisibility(false); LineBase.LineEndClamp = 1f; } // Set the line color for (int i = 0; i < LineRenderers.Length; i++) { LineRenderers[i].LineColor = GetLineGradient(TeleportSurfaceResult); } } else { LineBase.enabled = false; } } } public override void Reset() { base.Reset(); if (gameObject == null) return; if (TeleportHotspot != null) { CoreServices.TeleportSystem?.RaiseTeleportCanceled(this, TeleportHotspot); TeleportHotspot = null; } } #endregion IMixedRealityPointer Implementation #region IMixedRealityInputHandler Implementation private static readonly ProfilerMarker OnInputChangedPerfMarker = new ProfilerMarker("[MRTK] TeleportPointer.OnInputChanged"); /// public override void OnInputChanged(InputEventData eventData) { using (OnInputChangedPerfMarker.Auto()) { // Don't process input if we've got an active teleport request in progress. if (isTeleportRequestActive || CoreServices.TeleportSystem == null) { return; } if (eventData.SourceId == InputSourceParent.SourceId && eventData.Handedness == Handedness && eventData.MixedRealityInputAction == teleportAction) { currentInputPosition = eventData.InputData; } if (currentInputPosition.sqrMagnitude > InputThresholdSquared) { // Get the angle of the pointer input float angle = Mathf.Atan2(currentInputPosition.x, currentInputPosition.y) * Mathf.Rad2Deg; // Offset the angle so it's 'forward' facing angle += angleOffset; PointerOrientation = angle; if (!TeleportRequestRaised) { float absoluteAngle = Mathf.Abs(angle); if (absoluteAngle < teleportActivationAngle) { TeleportRequestRaised = true; CoreServices.TeleportSystem?.RaiseTeleportRequest(this, TeleportHotspot); if (pointerAudioSource != null && teleportRequestedClip != null) { pointerAudioSource.PlayOneShot(teleportRequestedClip); } } else if (canMove) { // wrap the angle value. if (absoluteAngle > 180f) { absoluteAngle = Mathf.Abs(absoluteAngle - 360f); } // Calculate the offset rotation angle from the 90 degree mark. // Half the rotation activation angle amount to make sure the activation angle stays centered at 90. float offsetRotationAngle = 90f - rotateActivationAngle; // subtract it from our current angle reading offsetRotationAngle = absoluteAngle - offsetRotationAngle; // if it's less than zero, then we don't have activation if (offsetRotationAngle > 0) { // check to make sure we're still under our activation threshold. if (offsetRotationAngle < 2 * rotateActivationAngle) { canMove = false; // Rotate the camera by the rotation amount. If our angle is positive then rotate in the positive direction, otherwise in the opposite direction. MixedRealityPlayspace.RotateAround(CameraCache.Main.transform.position, Vector3.up, angle >= 0.0f ? rotationAmount : -rotationAmount); } else // We may be trying to strafe backwards. { // Calculate the offset rotation angle from the 180 degree mark. // Half the strafe activation angle to make sure the activation angle stays centered at 180f float offsetStrafeAngle = 180f - backStrafeActivationAngle; // subtract it from our current angle reading offsetStrafeAngle = absoluteAngle - offsetStrafeAngle; // Check to make sure we're still under our activation threshold. if (offsetStrafeAngle > 0 && offsetStrafeAngle <= backStrafeActivationAngle) { PerformStrafe(); } } } } } } else { canTeleport = TeleportSurfaceResult == TeleportSurfaceResult.Valid || TeleportSurfaceResult == TeleportSurfaceResult.HotSpot; if (!canTeleport && !TeleportRequestRaised) { // Reset the move flag when the user stops moving the joystick // but hasn't yet started teleport request. canMove = true; } if (canTeleport) { TeleportRequestRaised = false; if (TeleportSurfaceResult == TeleportSurfaceResult.Valid || TeleportSurfaceResult == TeleportSurfaceResult.HotSpot) { CoreServices.TeleportSystem?.RaiseTeleportStarted(this, TeleportHotspot); if (pointerAudioSource != null && teleportCompletedClip != null) { pointerAudioSource.PlayOneShot(teleportCompletedClip); } } } if (TeleportRequestRaised) { TeleportRequestRaised = false; CoreServices.TeleportSystem?.RaiseTeleportCanceled(this, TeleportHotspot); } } } } #endregion IMixedRealityInputHandler Implementation #region IMixedRealityTeleportHandler Implementation private static readonly ProfilerMarker OnTeleportRequestPerfMarker = new ProfilerMarker("[MRTK] TeleportPointer.OnPreSceneQuery"); /// public virtual void OnTeleportRequest(TeleportEventData eventData) { using (OnTeleportRequestPerfMarker.Auto()) { // Only turn off the pointer if we're not the one sending the request if (eventData.Pointer.PointerId == PointerId) { isTeleportRequestActive = false; BaseCursor?.SetVisibility(true); } else { isTeleportRequestActive = true; BaseCursor?.SetVisibility(false); } } } private static readonly ProfilerMarker OnTeleportStartedPerfMarker = new ProfilerMarker("[MRTK] TeleportPointer.OnTeleportStarted"); /// public virtual void OnTeleportStarted(TeleportEventData eventData) { using (OnTeleportStartedPerfMarker.Auto()) { // Turn off all pointers while we teleport. isTeleportRequestActive = true; BaseCursor?.SetVisibility(false); } } private static readonly ProfilerMarker OnTeleportCompletedPerfMarker = new ProfilerMarker("[MRTK] TeleportPointer.OnTeleportCompleted"); /// public virtual void OnTeleportCompleted(TeleportEventData eventData) { using (OnTeleportCompletedPerfMarker.Auto()) { isTeleportRequestActive = false; BaseCursor?.SetVisibility(false); } } private static readonly ProfilerMarker OnTeleportCanceledPerfMarker = new ProfilerMarker("[MRTK] TeleportPointer.OnTeleportCanceled"); /// public virtual void OnTeleportCanceled(TeleportEventData eventData) { using (OnTeleportCanceledPerfMarker.Auto()) { TeleportRequestRaised = false; isTeleportRequestActive = false; BaseCursor?.SetVisibility(false); } } #endregion IMixedRealityTeleportHandler Implementation } }