using System; using System.Collections.Generic; using Unity.XR.CoreUtils.Bindings; using Unity.XR.CoreUtils.Bindings.Variables; using UnityEngine.EventSystems; using UnityEngine.Pool; using UnityEngine.UI; using UnityEngine.XR.Interaction.Toolkit.Filtering; using UnityEngine.XR.Interaction.Toolkit.Interactors; namespace UnityEngine.XR.Interaction.Toolkit.UI { /// /// Custom implementation of for XR Interaction Toolkit. /// This behavior is used to ray cast against a . The Raycaster looks /// at all Graphics on the canvas and determines if any of them have been hit by a ray /// from a tracked device. /// [AddComponentMenu("Event/Tracked Device Graphic Raycaster", 11)] [HelpURL(XRHelpURLConstants.k_TrackedDeviceGraphicRaycaster)] public class TrackedDeviceGraphicRaycaster : BaseRaycaster, IPokeStateDataProvider, IMultiPokeStateDataProvider { const int k_MaxRaycastHits = 10; readonly struct RaycastHitData { public RaycastHitData(Graphic graphic, Vector3 worldHitPosition, Vector2 screenPosition, float distance, int displayIndex) { this.graphic = graphic; this.worldHitPosition = worldHitPosition; this.screenPosition = screenPosition; this.distance = distance; this.displayIndex = displayIndex; } public Graphic graphic { get; } public Vector3 worldHitPosition { get; } public Vector2 screenPosition { get; } public float distance { get; } public int displayIndex { get; } } /// /// Compares ray cast hits by graphic depth, to sort in descending order. /// sealed class RaycastHitComparer : IComparer { public int Compare(RaycastHitData a, RaycastHitData b) { var canvasSort = b.graphic.canvas.sortingOrder.CompareTo(a.graphic.canvas.sortingOrder); return (canvasSort == 0) ? b.graphic.depth.CompareTo(a.graphic.depth) : canvasSort; } } [SerializeField] [Tooltip("Whether Graphics facing away from the ray caster are checked for ray casts. Enable this to ignore backfacing Graphics.")] bool m_IgnoreReversedGraphics; /// /// Whether Graphics facing away from the ray caster are checked for ray casts. /// Enable this to ignore backfacing Graphics. /// public bool ignoreReversedGraphics { get => m_IgnoreReversedGraphics; set => m_IgnoreReversedGraphics = value; } [SerializeField] [Tooltip("Whether or not 2D occlusion is checked when performing ray casts. Enable to make Graphics be blocked by 2D objects that exist in front of it.")] bool m_CheckFor2DOcclusion; /// /// Whether or not 2D occlusion is checked when performing ray casts. /// Enable to make Graphics be blocked by 2D objects that exist in front of it. /// /// /// This property has no effect when the project does not include the Physics 2D module. /// public bool checkFor2DOcclusion { get => m_CheckFor2DOcclusion; set => m_CheckFor2DOcclusion = value; } [SerializeField] [Tooltip("Whether or not 3D occlusion is checked when performing ray casts. Enable to make Graphics be blocked by 3D objects that exist in front of it.")] bool m_CheckFor3DOcclusion; /// /// Whether or not 3D occlusion is checked when performing ray casts. /// Enable to make Graphics be blocked by 3D objects that exist in front of it. /// public bool checkFor3DOcclusion { get => m_CheckFor3DOcclusion; set => m_CheckFor3DOcclusion = value; } [SerializeField] [Tooltip("The layers of objects that are checked to determine if they block Graphic ray casts when checking for 2D or 3D occlusion.")] LayerMask m_BlockingMask = -1; /// /// The layers of objects that are checked to determine if they block Graphic ray casts /// when checking for 2D or 3D occlusion. /// public LayerMask blockingMask { get => m_BlockingMask; set => m_BlockingMask = value; } [SerializeField] [Tooltip("Specifies whether the ray cast should hit Triggers when checking for 3D occlusion. Use Global refers to the Queries Hit Triggers setting in Physics Project Settings.")] QueryTriggerInteraction m_RaycastTriggerInteraction = QueryTriggerInteraction.Ignore; /// /// Specifies whether the ray cast should hit Triggers when checking for 3D occlusion. /// /// /// When set to , the value of Queries Hit Triggers () /// in Edit > Project Settings > Physics will be used. /// public QueryTriggerInteraction raycastTriggerInteraction { get => m_RaycastTriggerInteraction; set => m_RaycastTriggerInteraction = value; } /// /// See [BaseRaycaster.eventCamera](xref:UnityEngine.EventSystems.BaseRaycaster.eventCamera). /// public override Camera eventCamera => canvas != null && canvas.worldCamera != null ? canvas.worldCamera : Camera.main; /// /// Performs a ray cast against objects within this Raycaster's domain. /// /// Data containing where and how to ray cast. /// The resultant hits from the ray cast. public override void Raycast(PointerEventData eventData, List resultAppendList) { if (eventData is TrackedDeviceEventData trackedEventData) { PerformRaycasts(trackedEventData, resultAppendList); } } Canvas m_Canvas; Canvas canvas { get { if (m_Canvas != null) return m_Canvas; TryGetComponent(out m_Canvas); return m_Canvas; } } bool m_HasWarnedEventCameraNull; readonly RaycastHit[] m_OcclusionHits3D = new RaycastHit[k_MaxRaycastHits]; #if PHYSICS2D_MODULE_PRESENT // Create for a single hit only. In 2D physics it'll always be the closest hit. readonly RaycastHit2D[] m_OcclusionHits2D = new RaycastHit2D[1]; #endif static readonly RaycastHitComparer s_RaycastHitComparer = new RaycastHitComparer(); static readonly Vector3[] s_Corners = new Vector3[4]; // Use this list on each ray cast to avoid continually allocating. readonly List m_RaycastResultsCache = new List(); [NonSerialized] static readonly List s_SortedGraphics = new List(); // Poke-specific variables and methods XRPokeLogic m_PokeLogic; [NonSerialized] static readonly Dictionary s_InteractorRaycasters = new Dictionary(); [NonSerialized] static readonly Dictionary> s_PokeHoverRaycasters = new Dictionary>(); /// /// Checks if poke interactor is interacting with any raycaster in the scene. /// /// The to check against, typically an . /// Returns if the poke interactor is hovering or selecting any graphic in the scene. public static bool IsPokeInteractingWithUI(IUIInteractor interactor) { foreach (var pokeUIInteractorSet in s_PokeHoverRaycasters.Values) { if (pokeUIInteractorSet.Contains(interactor)) return true; } return false; } /// /// Removes interactor from poke data and calls OnHoverExited on the . /// This will not end poke interactions for other TrackedDeviceGraphicsRaycasters in the scene. /// /// Interactor to end the poke interaction. void EndPokeInteraction(IUIInteractor interactor) { if (interactor == null) return; m_PokeLogic.OnHoverExited(interactor); if (s_InteractorRaycasters.TryGetValue(interactor, out var raycaster) && raycaster != null && raycaster == this) s_InteractorRaycasters.Remove(interactor); s_PokeHoverRaycasters[this].Remove(interactor); } /// /// Attempts to get the for the provided . /// /// The to check against, typically an . /// The associated with the if it is found. /// Returns if the poke interactor is hovering or selecting any graphic in the scene /// and thus its associated is retrieved successfully, otherwise returns . /// public static bool TryGetPokeStateDataForInteractor(IUIInteractor interactor, out PokeStateData data) { foreach (var kvp in s_PokeHoverRaycasters) { var pokeUIInteractorSet = kvp.Value; if (pokeUIInteractorSet.Contains(interactor)) { var raycaster = kvp.Key; data = raycaster.pokeStateData.Value; return true; } } data = default; return false; } /// public IReadOnlyBindableVariable pokeStateData => m_PokeLogic?.pokeStateData; Dictionary> pokeStateDataDictionary { get; } = new Dictionary>(); BindingsGroup m_BindingsGroup = new BindingsGroup(); /// /// Gets the as an for the target transform. /// /// The target to get the for. /// Returns an for the for the target. public IReadOnlyBindableVariable GetPokeStateDataForTarget(Transform target) { if (!pokeStateDataDictionary.ContainsKey(target)) pokeStateDataDictionary[target] = new BindableVariable(); return pokeStateDataDictionary[target]; } /// /// This method is used to determine if the poke interactor has met the requirements for selecting. /// This can be treated like the equivalent of left mouse down for a mouse. /// /// The to check against, typically an . /// Returns if the meets requirements for poke with any . public static bool IsPokeSelectingWithUI(IUIInteractor interactor) { return interactor != null && s_InteractorRaycasters.TryGetValue(interactor, out var raycaster) && raycaster != null; } PhysicsScene m_LocalPhysicsScene; #if PHYSICS2D_MODULE_PRESENT PhysicsScene2D m_LocalPhysicsScene2D; #endif static RaycastHit FindClosestHit(RaycastHit[] hits, int count) { var index = 0; var distance = float.MaxValue; for (var i = 0; i < count; i++) { if (hits[i].distance < distance) { distance = hits[i].distance; index = i; } } return hits[index]; } /// /// See MonoBehaviour.Awake. /// protected override void Awake() { base.Awake(); m_LocalPhysicsScene = gameObject.scene.GetPhysicsScene(); #if PHYSICS2D_MODULE_PRESENT m_LocalPhysicsScene2D = gameObject.scene.GetPhysicsScene2D(); #endif s_PokeHoverRaycasters.Add(this, new HashSet()); SetupPoke(); } /// protected override void OnDisable() { base.OnDisable(); // Clean up any existing data of interactors hovering or selecting this disabled TrackedDeviceGraphicRaycaster using (HashSetPool.Get(out var interactorHashSet)) { foreach (var kvp in s_InteractorRaycasters) { if (kvp.Value == this) interactorHashSet.Add(kvp.Key); } foreach (var interactor in s_PokeHoverRaycasters[this]) { interactorHashSet.Add(interactor); } // End poke interaction on each interactor, which calls OnHoverExited foreach (var interactor in interactorHashSet) { EndPokeInteraction(interactor); } } } /// /// See MonoBehaviour.OnDestroy. /// protected override void OnDestroy() { base.OnDestroy(); s_PokeHoverRaycasters.Remove(this); pokeStateDataDictionary.Clear(); m_BindingsGroup.Clear(); } void SetupPoke() { m_BindingsGroup.Clear(); if (m_PokeLogic == null) m_PokeLogic = new XRPokeLogic(); var pokeData = new PokeThresholdData { pokeDirection = PokeAxis.Z, interactionDepthOffset = 0f, enablePokeAngleThreshold = true, pokeAngleThreshold = 89.9f, }; m_PokeLogic.Initialize(transform, pokeData, null); m_PokeLogic.SetPokeDepth(0.1f); m_BindingsGroup.AddBinding(m_PokeLogic.pokeStateData.SubscribeAndUpdate(data => { if (data.target != null) { if (!pokeStateDataDictionary.ContainsKey(data.target)) pokeStateDataDictionary[data.target] = new BindableVariable(); pokeStateDataDictionary[data.target].Value = data; } else { // If we get a null target, we should reset targetted listeners. foreach (var value in pokeStateDataDictionary.Values) { value.Value = data; } } })); } void PerformRaycasts(TrackedDeviceEventData eventData, List resultAppendList) { if (canvas == null) return; // Property can call Camera.main, so cache the reference var currentEventCamera = eventCamera; if (currentEventCamera == null) { if (!m_HasWarnedEventCameraNull) { Debug.LogWarning("Event Camera must be set on World Space Canvas to perform ray casts with tracked device." + " UI events will not function correctly until it is set.", this); m_HasWarnedEventCameraNull = true; } return; } var layerMask = eventData.layerMask; var interactor = eventData.interactor; if (interactor != null && interactor.TryGetUIModel(out var uiModel) && uiModel.interactionType == UIInteractionType.Poke) { // Check if poke is blocked for this frame. Unlike rays, updates for poke ui interaction are isolated from the poke interactor. if (PerformSpherecast(uiModel.position, uiModel.pokeDepth, layerMask, currentEventCamera, resultAppendList) && resultAppendList.Count > 0) { eventData.rayHitIndex = 1; var firstHit = resultAppendList[0]; var hitTransform = firstHit.gameObject.transform; m_PokeLogic.SetPokeDepth(uiModel.pokeDepth); // Check if not already hovering interactor if (!s_PokeHoverRaycasters[this].Contains(interactor)) { s_PokeHoverRaycasters[this].Add(interactor); m_PokeLogic.OnHoverEntered(interactor, new Pose(uiModel.position, uiModel.orientation), hitTransform); } if (m_PokeLogic.MeetsRequirementsForSelectAction(interactor, hitTransform.position, uiModel.position, 0f, hitTransform)) { s_InteractorRaycasters[interactor] = this; } else { s_InteractorRaycasters.Remove(interactor); } } else { EndPokeInteraction(interactor); } } else { var rayPoints = eventData.rayPoints; float existingHitLength = 0f; for (var i = 1; i < rayPoints.Count; i++) { var from = rayPoints[i - 1]; var to = rayPoints[i]; if (PerformRaycast(from, to, layerMask, currentEventCamera, resultAppendList, ref existingHitLength)) { eventData.rayHitIndex = i; break; } } } } bool PerformSpherecast(Vector3 origin, float radius, LayerMask layerMask, Camera currentEventCamera, List resultAppendList) { m_RaycastResultsCache.Clear(); SortedSpherecastGraphics(canvas, origin, radius, layerMask, currentEventCamera, m_RaycastResultsCache); if (m_RaycastResultsCache.Count <= 0) return false; var firstResult = m_RaycastResultsCache[0]; var ray = new Ray(origin, firstResult.worldHitPosition - origin); // Results from spherecast aim every which direction! We only want to test the nearest first direction. m_RaycastResultsCache.Clear(); m_RaycastResultsCache.Add(firstResult); return ProcessSortedHitsResults(ray, float.PositiveInfinity, false, m_RaycastResultsCache, resultAppendList); } bool PerformRaycast(Vector3 from, Vector3 to, LayerMask layerMask, Camera currentEventCamera, List resultAppendList, ref float existingHitLength) { var hitSomething = false; var rayDistance = Vector3.Distance(to, from); var ray = new Ray(from, to - from); var hitDistance = rayDistance; if (m_CheckFor3DOcclusion) { var hitCount = m_LocalPhysicsScene.Raycast(ray.origin, ray.direction, m_OcclusionHits3D, hitDistance, m_BlockingMask, m_RaycastTriggerInteraction); if (hitCount > 0) { var hit = FindClosestHit(m_OcclusionHits3D, hitCount); hitDistance = existingHitLength + hit.distance; hitSomething = true; } } if (m_CheckFor2DOcclusion) { #if PHYSICS2D_MODULE_PRESENT if (m_LocalPhysicsScene2D.GetRayIntersection(ray, hitDistance, m_OcclusionHits2D, m_BlockingMask) > 0) { // Unlike 3D physics, all 2D physics spatial queries are sorted by distance or in this case, // sorted by Z depth along the ray so there's no need to find the closest hit, it'll always be the first result. hitDistance = m_OcclusionHits2D[0].distance; hitSomething = true; } #endif } m_RaycastResultsCache.Clear(); SortedRaycastGraphics(canvas, ray, hitDistance, layerMask, currentEventCamera, m_RaycastResultsCache); return ProcessSortedHitsResults(ray, hitDistance, hitSomething, m_RaycastResultsCache, resultAppendList); } bool ProcessSortedHitsResults(Ray ray, float hitDistance, bool hitSomething, List raycastHitDatums, List resultAppendList) { // Now that we have a list of sorted hits, process any extra settings and filters. foreach (var hitData in raycastHitDatums) { var validHit = true; var go = hitData.graphic.gameObject; if (m_IgnoreReversedGraphics) { var forward = ray.direction; var goDirection = go.transform.rotation * Vector3.forward; validHit = Vector3.Dot(forward, goDirection) > 0; } validHit &= hitData.distance <= hitDistance; if (validHit) { var trans = go.transform; var transForward = trans.forward; var castResult = new RaycastResult { gameObject = go, module = this, distance = hitData.distance, index = resultAppendList.Count, depth = hitData.graphic.depth, sortingLayer = canvas.sortingLayerID, sortingOrder = canvas.sortingOrder, worldPosition = hitData.worldHitPosition, worldNormal = -transForward, screenPosition = hitData.screenPosition, displayIndex = hitData.displayIndex, }; resultAppendList.Add(castResult); hitSomething = true; } } return hitSomething; } static void SortedSpherecastGraphics(Canvas canvas, Vector3 origin, float radius, LayerMask layerMask, Camera eventCamera, List results) { var graphics = GraphicRegistry.GetGraphicsForCanvas(canvas); s_SortedGraphics.Clear(); for (int i = 0; i < graphics.Count; ++i) { var graphic = graphics[i]; if (!ShouldTestGraphic(graphic, layerMask)) continue; #if UNITY_2020_1_OR_NEWER var raycastPadding = graphic.raycastPadding; #else var raycastPadding = Vector4.zero; #endif if (SphereIntersectsRectTransform(graphic.rectTransform, raycastPadding, origin, out var worldPos, out var distance)) { if (distance <= radius) { Vector2 screenPos = eventCamera.WorldToScreenPoint(worldPos); // mask/image intersection - See Unity docs on eventAlphaThreshold for when this does anything if (graphic.Raycast(screenPos, eventCamera)) { s_SortedGraphics.Add(new RaycastHitData(graphic, worldPos, screenPos, distance, eventCamera.targetDisplay)); } } } } SortingHelpers.Sort(s_SortedGraphics, s_RaycastHitComparer); results.AddRange(s_SortedGraphics); } static void SortedRaycastGraphics(Canvas canvas, Ray ray, float maxDistance, LayerMask layerMask, Camera eventCamera, List results) { var graphics = GraphicRegistry.GetGraphicsForCanvas(canvas); s_SortedGraphics.Clear(); for (int i = 0; i < graphics.Count; ++i) { var graphic = graphics[i]; if (!ShouldTestGraphic(graphic, layerMask)) continue; #if UNITY_2020_1_OR_NEWER var raycastPadding = graphic.raycastPadding; #else var raycastPadding = Vector4.zero; #endif if (RayIntersectsRectTransform(graphic.rectTransform, raycastPadding, ray, out var worldPos, out var distance)) { if (distance <= maxDistance) { Vector2 screenPos = eventCamera.WorldToScreenPoint(worldPos); // mask/image intersection - See Unity docs on eventAlphaThreshold for when this does anything if (graphic.Raycast(screenPos, eventCamera)) { s_SortedGraphics.Add(new RaycastHitData(graphic, worldPos, screenPos, distance, eventCamera.targetDisplay)); } } } } SortingHelpers.Sort(s_SortedGraphics, s_RaycastHitComparer); results.AddRange(s_SortedGraphics); } static bool ShouldTestGraphic(Graphic graphic, LayerMask layerMask) { // -1 means it hasn't been processed by the canvas, which means it isn't actually drawn if (graphic.depth == -1 || !graphic.raycastTarget || graphic.canvasRenderer.cull) return false; if (((1 << graphic.gameObject.layer) & layerMask) == 0) return false; return true; } static bool SphereIntersectsRectTransform(RectTransform transform, Vector4 raycastPadding, Vector3 from, out Vector3 worldPosition, out float distance) { var plane = GetRectTransformPlane(transform, raycastPadding, s_Corners); var closestPoint = plane.ClosestPointOnPlane(from); var ray = new Ray(from, closestPoint - from); return RayIntersectsRectTransform(ray, plane, out worldPosition, out distance); } static bool RayIntersectsRectTransform(RectTransform transform, Vector4 raycastPadding, Ray ray, out Vector3 worldPosition, out float distance) { var plane = GetRectTransformPlane(transform, raycastPadding, s_Corners); return RayIntersectsRectTransform(ray, plane, out worldPosition, out distance); } static bool RayIntersectsRectTransform(Ray ray, Plane plane, out Vector3 worldPosition, out float distance) { if (plane.Raycast(ray, out var enter)) { var intersection = ray.GetPoint(enter); var bottomEdge = s_Corners[3] - s_Corners[0]; var leftEdge = s_Corners[1] - s_Corners[0]; var bottomDot = Vector3.Dot(intersection - s_Corners[0], bottomEdge); var leftDot = Vector3.Dot(intersection - s_Corners[0], leftEdge); // If the intersection is right of the left edge and above the bottom edge. if (leftDot >= 0f && bottomDot >= 0f) { var topEdge = s_Corners[1] - s_Corners[2]; var rightEdge = s_Corners[3] - s_Corners[2]; var topDot = Vector3.Dot(intersection - s_Corners[2], topEdge); var rightDot = Vector3.Dot(intersection - s_Corners[2], rightEdge); // If the intersection is left of the right edge, and below the top edge if (topDot >= 0f && rightDot >= 0f) { worldPosition = intersection; distance = enter; return true; } } } worldPosition = Vector3.zero; distance = 0f; return false; } static Plane GetRectTransformPlane(RectTransform transform, Vector4 raycastPadding, Vector3[] fourCornersArray) { GetRectTransformWorldCorners(transform, raycastPadding, fourCornersArray); return new Plane(fourCornersArray[0], fourCornersArray[1], fourCornersArray[2]); } // This method is similar to RecTransform.GetWorldCorners, but with support for the raycastPadding offset. static void GetRectTransformWorldCorners(RectTransform transform, Vector4 offset, Vector3[] fourCornersArray) { if (fourCornersArray == null || fourCornersArray.Length < 4) { Debug.LogError("Calling GetRectTransformWorldCorners with an array that is null or has less than 4 elements."); return; } // GraphicRaycaster.Raycast uses RectTransformUtility.RectangleContainsScreenPoint instead, // which redirects to PointInRectangle defined in RectTransformUtil.cpp. However, that method // uses the Camera to convert from the given screen point to a ray, but this class uses // the ray from the Ray Interactor that feeds the event data. // Offset calculation for raycastPadding from PointInRectangle method, which replaces RectTransform.GetLocalCorners. var rect = transform.rect; var x0 = rect.x + offset.x; var y0 = rect.y + offset.y; var x1 = rect.xMax - offset.z; var y1 = rect.yMax - offset.w; fourCornersArray[0] = new Vector3(x0, y0, 0f); fourCornersArray[1] = new Vector3(x0, y1, 0f); fourCornersArray[2] = new Vector3(x1, y1, 0f); fourCornersArray[3] = new Vector3(x1, y0, 0f); // Transform the local corners to world space, which is from RectTransform.GetWorldCorners. var localToWorldMatrix = transform.localToWorldMatrix; for (var index = 0; index < 4; ++index) fourCornersArray[index] = localToWorldMatrix.MultiplyPoint(fourCornersArray[index]); } /// /// See . /// [System.Diagnostics.Conditional("UNITY_EDITOR")] protected void OnDrawGizmosSelected() { #if UNITY_EDITOR if (!enabled) return; if (m_PokeLogic != null) m_PokeLogic?.DrawGizmos(); #endif } } }