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
}
}
}