一、框架视图
二、关键代码
XRInteractableAffordanceStateProvider
using System;
using System.Collections;
using Unity.XR.CoreUtils;
using Unity.XR.CoreUtils.Datums;
using UnityEngine.XR.Interaction.Toolkit.Utilities.Internal;
/// <summary>
/// State Machine component that derives an interaction affordance state from an associated <see cref="IXRInteractable"/>.
/// </summary>
[AddComponentMenu("Affordance System/XR Interactable Affordance State Provider", 11)]
[HelpURL(XRHelpURLConstants.k_XRInteractableAffordanceStateProvider)]
[DisallowMultipleComponent]
public class XRInteractableAffordanceStateProvider : BaseAffordanceStateProvider
{
/// <summary>
/// Animation mode options used for select state callbacks.
/// </summary>
public enum SelectClickAnimationMode
{
/// <summary>
/// No click animation override for select events.
/// </summary>
None,
/// <summary>
/// Use click animation on select entered event.
/// </summary>
SelectEntered,
/// <summary>
/// Use click animation on select exited event.
/// </summary>
SelectExited,
}
/// <summary>
/// Animation mode options used for activate state callbacks.
/// </summary>
public enum ActivateClickAnimationMode
{
/// <summary>
/// No click animation override for activate events.
/// </summary>
None,
/// <summary>
/// Use click animation on activate event.
/// </summary>
Activated,
/// <summary>
/// Use click animation on deactivate event.
/// </summary>
Deactivated,
}
[SerializeField]
[RequireInterface(typeof(IXRInteractable))]
[Tooltip("The interactable component that drives the affordance states. If null, Unity will try and find an interactable component attached.")]
Object m_InteractableSource;
/// <summary>
/// The interactable component that drives the affordance states.
/// If <see langword="null"/>, Unity will try and find an interactable component attached.
/// </summary>
public Object interactableSource
{
get => m_InteractableSource;
set
{
m_InteractableSource = value;
if (Application.isPlaying && isActiveAndEnabled)
SetBoundInteractionReceiver(value as IXRInteractable);
}
}
[Header("Event Constraints")]
[SerializeField]
[Tooltip("When hover events are registered and this is true, the state will fallback to idle or disabled.")]
bool m_IgnoreHoverEvents;
/// <summary>
/// When hover events are registered and this is true, the state will fallback to idle or disabled.
/// </summary>
public bool ignoreHoverEvents
{
get => m_IgnoreHoverEvents;
set => m_IgnoreHoverEvents = value;
}
[SerializeField]
[Tooltip("When this is true, the state will fallback to hover if the later is not ignored. When this is false, this provider will check " +
"if the Interactable Source has priority for selection when hovered, and update its state accordingly.")]
bool m_IgnoreHoverPriorityEvents = true;
/// <summary>
/// When hover events are registered and this is true, the state will fallback to hover. When this is <see langword="false"/>, this
/// provider will check if the Interactable Source has priority for selection when hovered, and update its state accordingly
/// </summary>
/// <remarks>When updating this value to <see langword="false"/> during runtime, previously hover events are ignored.</remarks>
public bool ignoreHoverPriorityEvents
{
get => m_IgnoreHoverPriorityEvents;
set
{
if (Application.isPlaying && isActiveAndEnabled && !m_IgnoreHoverPriorityEvents && value)
{
StopHoveredPriorityRoutine();
RefreshState();
}
m_IgnoreHoverPriorityEvents = value;
}
}
[SerializeField]
[Tooltip("When select events are registered and this is true, the state will fallback to idle or disabled. " +
"Note this will not affect click animations which can be disabled separately.")]
bool m_IgnoreSelectEvents;
/// <summary>
/// When select events are registered and this is true, the state will fallback to idle or disabled.
/// </summary>
public bool ignoreSelectEvents
{
get => m_IgnoreSelectEvents;
set => m_IgnoreSelectEvents = value;
}
[SerializeField]
[Tooltip("When activate events are registered and this is true, the state will fallback to idle or disabled." +
"Note this will not affect click animations which can be disabled separately.")]
bool m_IgnoreActivateEvents;
/// <summary>
/// When activate events are registered and this is true, the state will fallback to idle or disabled.
/// </summary>
public bool ignoreActivateEvents
{
get => m_IgnoreActivateEvents;
set => m_IgnoreActivateEvents = value;
}
[Header("Click Animation Config")]
[SerializeField]
[Tooltip("Condition to trigger click animation for Selected interaction events.")]
SelectClickAnimationMode m_SelectClickAnimationMode = SelectClickAnimationMode.SelectEntered;
/// <summary>
/// Condition to trigger click animation for Selected interaction events.
/// </summary>
public SelectClickAnimationMode selectClickAnimationMode
{
get => m_SelectClickAnimationMode;
set => m_SelectClickAnimationMode = value;
}
[SerializeField]
[Tooltip("Condition to trigger click animation for activated interaction events.")]
ActivateClickAnimationMode m_ActivateClickAnimationMode = ActivateClickAnimationMode.None;
/// <summary>
/// Condition to trigger click animation for activated interaction events.
/// </summary>
public ActivateClickAnimationMode activateClickAnimationMode
{
get => m_ActivateClickAnimationMode;
set => m_ActivateClickAnimationMode = value;
}
[SerializeField]
[Range(0f, 1f)]
[Tooltip("Duration of click animations for selected and activated events.")]
float m_ClickAnimationDuration = 0.25f;
/// <summary>
/// Duration of click animations for selected and activated events.
/// </summary>
public float clickAnimationDuration
{
get => m_ClickAnimationDuration;
set => m_ClickAnimationDuration = value;
}
[SerializeField]
[Tooltip("Animation curve reference for click animation events. Select the More menu (\u22ee) to choose between a direct reference and a reusable scriptable object animation curve datum.")]
AnimationCurveDatumProperty m_ClickAnimationCurve = new AnimationCurveDatumProperty(AnimationCurve.EaseInOut(0f, 0f, 1f, 1f));
/// <summary>
/// Animation curve reference for click animation events.
/// </summary>
public AnimationCurveDatumProperty clickAnimationCurve
{
get => m_ClickAnimationCurve;
set => m_ClickAnimationCurve = value;
}
/// <summary>
/// Is attached interactable in a hovered state.
/// </summary>
protected virtual bool isHovered => m_HoverInteractable != null && m_HoverInteractable.isHovered;
/// <summary>
/// Is attached interactable in a selected state.
/// </summary>
protected virtual bool isSelected => m_SelectInteractable != null && m_SelectInteractable.isSelected;
/// <summary>
/// Is attached interactable in an activated state.
/// </summary>
protected virtual bool isActivated => m_IsActivated;
/// <summary>
/// Is attached interactable in a registered state.
/// </summary>
protected virtual bool isRegistered => m_IsRegistered;
IXRInteractable m_Interactable;
IXRHoverInteractable m_HoverInteractable;
IXRSelectInteractable m_SelectInteractable;
IXRActivateInteractable m_ActivateInteractable;
IXRInteractionStrengthInteractable m_InteractionStrengthInteractable;
Coroutine m_SelectedClickAnimation;
Coroutine m_ActivatedClickAnimation;
Coroutine m_HoveredPriorityRoutine;
bool m_IsBoundToInteractionEvents;
bool m_IsActivated;
bool m_IsRegistered;
bool m_IsHoveredPriority;
bool m_HasInteractionStrengthInteractable;
int m_HoveringPriorityInteractorCount;
/// <summary>
/// See <see cref="MonoBehaviour"/>.
/// </summary>
protected void Awake()
{
var receiver = m_InteractableSource != null && m_InteractableSource is IXRInteractable interactable
? interactable
: GetComponentInParent<IXRInteractable>();
if (!SetBoundInteractionReceiver(receiver))
{
XRLoggingUtils.LogWarning($"Could not find required interactable component on {gameObject}" +
" for which to provide affordance states.", this);
enabled = false;
}
}
/// <inheritdoc />
protected override void OnValidate()
{
base.OnValidate();
if (Application.isPlaying && isActiveAndEnabled && m_IgnoreHoverPriorityEvents)
{
StopHoveredPriorityRoutine();
RefreshState();
}
}
/// <summary>
/// Bind affordance provider state to <see cref="IXRInteractable"/> state and events.
/// </summary>
/// <param name="receiver">Receiver to bind events from.</param>
/// <returns>Whether binding was successful.</returns>
public bool SetBoundInteractionReceiver(IXRInteractable receiver)
{
ClearBindings();
var isInteractableValid = receiver is Object unityObject && unityObject != null;
if (isInteractableValid)
{
m_Interactable = receiver;
if (m_Interactable is IXRHoverInteractable hoverInteractable)
m_HoverInteractable = hoverInteractable;
if (m_Interactable is IXRSelectInteractable selectInteractable)
m_SelectInteractable = selectInteractable;
if (m_Interactable is IXRActivateInteractable activateInteractable)
m_ActivateInteractable = activateInteractable;
if (m_Interactable is IXRInteractionStrengthInteractable interactionStrengthInteractable)
m_InteractionStrengthInteractable = interactionStrengthInteractable;
}
else
{
m_Interactable = null;
m_HoverInteractable = null;
m_SelectInteractable = null;
m_ActivateInteractable = null;
m_InteractionStrengthInteractable = null;
}
BindToProviders();
return isInteractableValid;
}
/// <summary>
/// Callback triggered when the interactable is registered with the <see cref="XRInteractionManager"/>.
/// Sets the internal isRegistered flag to true and refreshes the affordance state.
/// </summary>
/// <param name="args"><see cref="InteractableRegisteredEventArgs"/> callback args.</param>
protected virtual void OnRegistered(InteractableRegisteredEventArgs args)
{
m_IsRegistered = true;
RefreshState();
}
/// <summary>
/// Callback triggered when the interactable is unregistered with the <see cref="XRInteractionManager"/>.
/// Sets the internal isRegistered flag to false and refreshes the affordance state.
/// </summary>
/// <param name="args"><see cref="InteractableUnregisteredEventArgs"/> callback args.</param>
protected virtual void OnUnregistered(InteractableUnregisteredEventArgs args)
{
m_IsRegistered = false;
RefreshState();
}
/// <summary>
/// Callback triggered by <see cref="IXRHoverInteractable"/> when the first interactor begins hovering over this interactable.
/// Refreshes the affordance state.
/// </summary>
/// <param name="args"><see cref="HoverEnterEventArgs"/> callback args.</param>
/// <seealso cref="IXRHoverInteractable.firstHoverEntered"/>
protected virtual void OnFirstHoverEntered(HoverEnterEventArgs args)
{
RefreshState();
}
/// <summary>
/// Callback triggered by <see cref="IXRHoverInteractable"/> when the last interactor exits hovering over this interactable.
/// Refreshes the affordance state.
/// </summary>
/// <param name="args"><see cref="HoverExitEventArgs"/> callback args.</param>
/// <seealso cref="IXRHoverInteractable.lastHoverExited"/>
protected virtual void OnLastHoverExited(HoverExitEventArgs args)
{
RefreshState();
}
/// <summary>
/// Callback triggered by <see cref="IXRHoverInteractable"/> when an interactor begins hovering over this interactable.
/// Refreshes the affordance state.
/// </summary>
/// <param name="args"><see cref="HoverEnterEventArgs"/> callback args.</param>
/// <seealso cref="IXRHoverInteractable.hoverEntered"/>
protected virtual void OnHoverEntered(HoverEnterEventArgs args)
{
if (m_IgnoreHoverPriorityEvents)
return;
if (args.interactorObject is IXRTargetPriorityInteractor priorityInteractor)
{
m_HoveringPriorityInteractorCount++;
if (priorityInteractor.targetPriorityMode != TargetPriorityMode.None)
m_HoveredPriorityRoutine = m_HoveredPriorityRoutine ?? StartCoroutine(HoveredPriorityRoutine());
}
}
/// <summary>
/// Callback triggered by <see cref="IXRHoverInteractable"/> when an interactor exits hovering over this interactable.
/// Refreshes the affordance state.
/// </summary>
/// <param name="args"><see cref="HoverExitEventArgs"/> callback args.</param>
/// <seealso cref="IXRHoverInteractable.hoverExited"/>
protected virtual void OnHoverExited(HoverExitEventArgs args)
{
if (m_IgnoreHoverPriorityEvents)
return;
if (args.interactorObject is IXRTargetPriorityInteractor)
{
m_HoveringPriorityInteractorCount--;
if (m_HoveringPriorityInteractorCount > 0)
return;
StopHoveredPriorityRoutine();
RefreshState();
}
}
void StopHoveredPriorityRoutine()
{
m_HoveringPriorityInteractorCount = 0;
m_IsHoveredPriority = false;
if (m_HoveredPriorityRoutine != null)
{
StopCoroutine(m_HoveredPriorityRoutine);
m_HoveredPriorityRoutine = null;
}
}
/// <summary>
/// Callback triggered by <see cref="IXRSelectInteractor"/> when the first interactor begins selecting over this interactable.
/// Refreshes the affordance state and triggers the <see cref="SelectedClickBehavior"/> animation coroutine if the select animation mode is set to SelectEntered.
/// </summary>
/// <param name="args"><see cref="SelectEnterEventArgs"/> callback args.</param>
/// <seealso cref="IXRSelectInteractable.firstSelectEntered"/>
protected virtual void OnFirstSelectEntered(SelectEnterEventArgs args)
{
if (m_IgnoreSelectEvents || m_SelectClickAnimationMode != SelectClickAnimationMode.SelectEntered || m_ClickAnimationDuration < Mathf.Epsilon)
{
RefreshState();
return;
}
SelectedClickBehavior();
}
/// <summary>
/// Callback triggered by <see cref="IXRSelectInteractor"/> when the last interactor exits selecting over this interactable.
/// Refreshes the affordance state and triggers the <see cref="SelectedClickBehavior"/> animation coroutine if the select animation mode is set to SelectExited.
/// </summary>
/// <param name="args"><see cref="SelectExitEventArgs"/> callback args.</param>
/// <seealso cref="IXRSelectInteractable.lastSelectExited"/>
protected virtual void OnLastSelectExited(SelectExitEventArgs args)
{
if (m_IgnoreSelectEvents || m_SelectClickAnimationMode != SelectClickAnimationMode.SelectExited || m_ClickAnimationDuration < Mathf.Epsilon)
{
// If Select animation is playing and we are exiting, we need to wait for the animation to finish before refreshing state
if(m_SelectedClickAnimation != null)
return;
RefreshState();
return;
}
SelectedClickBehavior();
}
/// <summary>
/// Callback triggered by <see cref="IXRActivateInteractable"/> when the interactor triggers an activated event on the interactable.
/// Refreshes the affordance state and triggers the <see cref="ActivatedClickBehavior"/> animation coroutine if the activated animation mode is set to Activated.
/// </summary>
/// <param name="args"><see cref="ActivateEventArgs"/> callback args.</param>
/// <seealso cref="IXRActivateInteractable.activated"/>
protected virtual void OnActivatedEvent(ActivateEventArgs args)
{
m_IsActivated = true;
if (m_IgnoreActivateEvents || (m_ActivateClickAnimationMode != ActivateClickAnimationMode.Activated) || m_ClickAnimationDuration < Mathf.Epsilon)
{
RefreshState();
return;
}
ActivatedClickBehavior();
}
/// <summary>
/// Callback triggered by <see cref="IXRActivateInteractable"/> when the interactor triggers an deactivated event on the interactable.
/// Refreshes the affordance state and triggers the <see cref="ActivatedClickBehavior"/> animation coroutine if the activated animation mode is set to deactivated.
/// </summary>
/// <param name="args"><see cref="DeactivateEventArgs"/> callback args.</param>
/// <seealso cref="IXRActivateInteractable.deactivated"/>
protected virtual void OnDeactivatedEvent(DeactivateEventArgs args)
{
m_IsActivated = false;
if (m_IgnoreActivateEvents || (m_ActivateClickAnimationMode != ActivateClickAnimationMode.Deactivated) || m_ClickAnimationDuration < Mathf.Epsilon)
{
// If activate animation is playing and we are exiting, we need to wait for the animation to finish before refreshing state
if (m_ActivatedClickAnimation != null)
return;
RefreshState();
return;
}
ActivatedClickBehavior();
}
/// <summary>
/// Callback triggered by <see cref="IXRInteractionStrengthInteractable"/> when the largest interaction strength of the interactable changes.
/// Refreshes the affordance state.
/// </summary>
/// <param name="value">The new largest interaction strength value of all interactors hovering or selecting the interactable.</param>
protected virtual void OnLargestInteractionStrengthChanged(float value)
{
// If currently executing animation, do not update interaction strength state.
if(m_SelectedClickAnimation != null || m_ActivatedClickAnimation != null)
return;
RefreshState();
}
/// <summary>
/// Handles starting the selected click animation coroutine. Stops any previously started coroutine.
/// </summary>
protected virtual void SelectedClickBehavior()
{
StopAllActivateAnimations();
m_SelectedClickAnimation = StartCoroutine(ClickAnimation(AffordanceStateShortcuts.selected, m_ClickAnimationDuration, () => m_SelectedClickAnimation = null));
}
/// <summary>
/// Handles starting the activated click animation coroutine. Stops any previously started coroutine.
/// </summary>
protected virtual void ActivatedClickBehavior()
{
StopAllActivateAnimations();
m_ActivatedClickAnimation = StartCoroutine(ClickAnimation(AffordanceStateShortcuts.activated, m_ClickAnimationDuration, () => m_ActivatedClickAnimation = null));
}
void StopActivatedCoroutine()
{
if (m_ActivatedClickAnimation == null)
return;
StopCoroutine(m_ActivatedClickAnimation);
m_ActivatedClickAnimation = null;
}
void StopSelectedCoroutine()
{
if (m_SelectedClickAnimation == null)
return;
StopCoroutine(m_SelectedClickAnimation);
m_SelectedClickAnimation = null;
}
void StopAllActivateAnimations()
{
StopActivatedCoroutine();
StopSelectedCoroutine();
}
/// <summary>
/// Click animation coroutine that plays over a set period of time, transitioning between the lower and upper bounds of a given affordance state.
/// </summary>
/// <param name="targetStateIndex">Target animation state with bounds which to transition between.</param>
/// <param name="duration">Duration of the animation.</param>
/// <param name="onComplete">OnComplete callback action.</param>
/// <returns>Enumerator used to play as a coroutine.</returns>
protected virtual IEnumerator ClickAnimation(byte targetStateIndex, float duration, Action onComplete = null)
{
var elapsedTime = 0f;
while (elapsedTime < duration)
{
var animValue = Mathf.Clamp01(elapsedTime / duration);
var curveAdjustedAnimValue = m_ClickAnimationCurve.Value.Evaluate(animValue);
var newAnimationState = new AffordanceStateData(targetStateIndex, curveAdjustedAnimValue);
UpdateAffordanceState(newAnimationState);
yield return null;
elapsedTime += Time.deltaTime;
}
yield return null;
RefreshState();
onComplete?.Invoke();
}
/// <summary>
/// Evaluates the state of the current interactable to generate a corresponding <see cref="AffordanceStateData"/>.
/// </summary>
/// <returns>Newly generated affordance state corresponding to the interactable state.</returns>
protected virtual AffordanceStateData GenerateNewAffordanceState()
{
if (!m_IsBoundToInteractionEvents)
{
return currentAffordanceStateData.Value;
}
if (m_IsActivated && !m_IgnoreActivateEvents)
{
return AffordanceStateShortcuts.activatedState;
}
if (isSelected && !m_IgnoreSelectEvents)
{
var transitionAmount = m_HasInteractionStrengthInteractable ? m_InteractionStrengthInteractable.largestInteractionStrength.Value : 1f;
return new AffordanceStateData(AffordanceStateShortcuts.selected, transitionAmount);
}
if (isHovered && !m_IgnoreHoverEvents)
{
var stateIndex = m_IsHoveredPriority ? AffordanceStateShortcuts.hoveredPriority : AffordanceStateShortcuts.hovered;
var transitionAmount = m_HasInteractionStrengthInteractable ? m_InteractionStrengthInteractable.largestInteractionStrength.Value : 0f;
return new AffordanceStateData(stateIndex, transitionAmount);
}
return m_IsRegistered ? AffordanceStateShortcuts.idleState : AffordanceStateShortcuts.disabledState;
}
IEnumerator HoveredPriorityRoutine()
{
do
{
if (m_HoverInteractable is XRBaseInteractable baseInteractable &&
baseInteractable.interactionManager != null &&
baseInteractable.interactionManager.IsHighestPriorityTarget(baseInteractable) != m_IsHoveredPriority)
{
m_IsHoveredPriority = !m_IsHoveredPriority;
RefreshState();
}
yield return null;
} while (m_HoveringPriorityInteractorCount > 0);
m_HoveredPriorityRoutine = null;
}
/// <inheritdoc/>
protected override void BindToProviders()
{
base.BindToProviders();
m_IsBoundToInteractionEvents = m_Interactable is Object unityObject && unityObject != null;
if (m_IsBoundToInteractionEvents)
{
m_Interactable.registered += OnRegistered;
m_Interactable.unregistered += OnUnregistered;
if (m_HoverInteractable != null)
{
m_HoverInteractable.firstHoverEntered.AddListener(OnFirstHoverEntered);
m_HoverInteractable.lastHoverExited.AddListener(OnLastHoverExited);
m_HoverInteractable.hoverEntered.AddListener(OnHoverEntered);
m_HoverInteractable.hoverExited.AddListener(OnHoverExited);
}
if (m_SelectInteractable != null)
{
m_SelectInteractable.firstSelectEntered.AddListener(OnFirstSelectEntered);
m_SelectInteractable.lastSelectExited.AddListener(OnLastSelectExited);
}
if (m_ActivateInteractable != null)
{
m_ActivateInteractable.activated.AddListener(OnActivatedEvent);
m_ActivateInteractable.deactivated.AddListener(OnDeactivatedEvent);
}
if (m_InteractionStrengthInteractable != null)
{
AddBinding(m_InteractionStrengthInteractable.largestInteractionStrength.Subscribe(OnLargestInteractionStrengthChanged));
m_HasInteractionStrengthInteractable = true;
}
else
{
m_HasInteractionStrengthInteractable = false;
}
m_IsActivated = false;
// Initialize field for whether the interactable is registered to distinguish between Idle and Disabled affordance states.
// The registration status is not yet part of the base interactable interface, so we use a reasonable assumption here.
if (m_Interactable is XRBaseInteractable baseInteractable)
{
m_IsRegistered = baseInteractable.interactionManager != null && baseInteractable.interactionManager.IsRegistered(m_Interactable);
}
else if (m_Interactable is Behaviour behavior)
{
m_IsRegistered = behavior.isActiveAndEnabled;
}
else
{
m_IsRegistered = true;
}
}
RefreshState();
}
/// <summary>
/// Re-evaluates the current affordance state and triggers events for receivers if it changed.
/// </summary>
public void RefreshState()
{
var newState = GenerateNewAffordanceState();
// If leaving the selected state, we have to terminate select animation coroutines.
if (newState.stateIndex != AffordanceStateShortcuts.selected)
StopSelectedCoroutine();
// If Leaving the activated state, we have to terminate activated animation coroutines.
if(newState.stateIndex == AffordanceStateShortcuts.activated)
StopActivatedCoroutine();
UpdateAffordanceState(GenerateNewAffordanceState());
}
/// <inheritdoc/>
protected override void ClearBindings()
{
base.ClearBindings();
if (m_IsBoundToInteractionEvents)
{
m_Interactable.registered -= OnRegistered;
m_Interactable.unregistered -= OnUnregistered;
if (m_HoverInteractable != null)
{
m_HoverInteractable.firstHoverEntered.RemoveListener(OnFirstHoverEntered);
m_HoverInteractable.lastHoverExited.RemoveListener(OnLastHoverExited);
m_HoverInteractable.hoverEntered.RemoveListener(OnHoverEntered);
m_HoverInteractable.hoverExited.RemoveListener(OnHoverExited);
}
if (m_SelectInteractable != null)
{
m_SelectInteractable.firstSelectEntered.RemoveListener(OnFirstSelectEntered);
m_SelectInteractable.lastSelectExited.RemoveListener(OnLastSelectExited);
}
if (m_ActivateInteractable != null)
{
m_ActivateInteractable.activated.RemoveListener(OnActivatedEvent);
m_ActivateInteractable.deactivated.RemoveListener(OnDeactivatedEvent);
}
// No need to unsubscribe from interaction strength here since it was added to the binding group
// and would have been cleared upon calling the base method.
}
m_IsBoundToInteractionEvents = false;
}
}
OnSelectInteractable
using UnityEngine.XR.Interaction.Toolkit;
namespace UnityEngine.XR.Content.Interaction
{
/// <summary>
/// Triggers a Unity Event once when an interactable is selected by an interactor.
/// </summary>
public class OnSelectInteractable : MonoBehaviour
{
[SerializeField]
[Tooltip("The interactable that is checked for selection.")]
XRBaseInteractable m_TargetInteractable;
[SerializeField]
[Tooltip("The function to call when the interactable is selected.")]
SelectEnterEvent m_OnSelected;
void Start()
{
if (m_TargetInteractable != null)
m_TargetInteractable.selectEntered.AddListener(OnSelected);
}
void OnSelected(SelectEnterEventArgs args)
{
m_OnSelected.Invoke(args);
if (m_TargetInteractable != null)
m_TargetInteractable.selectEntered.RemoveListener(OnSelected);
}
}
}
XRDualGrabFreeTransformer
using Unity.XR.CoreUtils;
using UnityEngine.Assertions;
namespace UnityEngine.XR.Interaction.Toolkit.Transformers
{
/// <summary>
/// Grab transformer which supports moving and rotating unconstrained with multiple Interactors.
/// Maintains the offset from the attachment points used for each Interactor and points in the
/// direction made by each grab.
/// This is the default grab transformer used for multiple selections.
/// </summary>
/// <remarks>
/// When there is a single Interactor, this has identical behavior to <see cref="XRSingleGrabFreeTransformer"/>.
/// </remarks>
/// <seealso cref="XRGrabInteractable"/>
[AddComponentMenu("XR/Transformers/XR Dual Grab Free Transformer", 11)]
[HelpURL(XRHelpURLConstants.k_XRDualGrabFreeTransformer)]
public class XRDualGrabFreeTransformer : XRBaseGrabTransformer
{
/// <summary>
/// Describes which combination of interactors influences a pose.
/// </summary>
public enum PoseContributor
{
/// <summary>
/// Use the first interactor's data.
/// </summary>
First,
/// <summary>
/// Use the second interactor's data.
/// </summary>
Second,
/// <summary>
/// Use an average of the first and second interactor's data.
/// </summary>
Average,
}
[SerializeField]
PoseContributor m_MultiSelectPosition = PoseContributor.First;
/// <summary>
/// Controls how multiple interactors combine to drive this interactable's position
/// </summary>
/// <seealso cref="PoseContributor"/>
public PoseContributor multiSelectPosition
{
get => m_MultiSelectPosition;
set => m_MultiSelectPosition = value;
}
[SerializeField]
PoseContributor m_MultiSelectRotation = PoseContributor.Average;
/// <summary>
/// Controls how multiple interactors combine to drive this interactable's rotation
/// </summary>
/// <seealso cref="PoseContributor"/>
public PoseContributor multiSelectRotation
{
get => m_MultiSelectRotation;
set => m_MultiSelectRotation = value;
}
/// <inheritdoc />
protected override RegistrationMode registrationMode => RegistrationMode.Multiple;
// For Gizmo
internal Pose lastInteractorAttachPose { get; private set; }
Vector3 m_LastUp;
/// <summary>
/// See <see cref="MonoBehaviour"/>.
/// </summary>
// ReSharper disable once Unity.RedundantEventFunction -- See comment in method
protected virtual void OnDrawGizmosSelected()
{
// Empty method, but needed to allow the user to toggle it by folding the component in the Inspector window
// and making it visible in the Gizmos dropdown in the Scene view.
}
/// <inheritdoc />
public override void OnGrabCountChanged(XRGrabInteractable grabInteractable, Pose targetPose, Vector3 localScale)
{
base.OnGrabCountChanged(grabInteractable, targetPose, localScale);
if (grabInteractable.interactorsSelecting.Count == 2)
m_LastUp = grabInteractable.transform.up;
}
/// <inheritdoc />
public override void Process(XRGrabInteractable grabInteractable, XRInteractionUpdateOrder.UpdatePhase updatePhase, ref Pose targetPose, ref Vector3 localScale)
{
switch (updatePhase)
{
case XRInteractionUpdateOrder.UpdatePhase.Dynamic:
case XRInteractionUpdateOrder.UpdatePhase.OnBeforeRender:
{
UpdateTarget(grabInteractable, ref targetPose);
break;
}
}
}
void UpdateTarget(XRGrabInteractable grabInteractable, ref Pose targetPose)
{
if (grabInteractable.interactorsSelecting.Count == 1)
XRSingleGrabFreeTransformer.UpdateTarget(grabInteractable, ref targetPose);
else
UpdateTargetMulti(grabInteractable, ref targetPose);
}
void UpdateTargetMulti(XRGrabInteractable grabInteractable, ref Pose targetPose)
{
Debug.Assert(grabInteractable.interactorsSelecting.Count > 1, this);
var primaryAttachPose = grabInteractable.interactorsSelecting[0].GetAttachTransform(grabInteractable).GetWorldPose();
var secondaryAttachPose = grabInteractable.interactorsSelecting[1].GetAttachTransform(grabInteractable).GetWorldPose();
// When multi-selecting, adjust the effective interactorAttachPose with our default 2-hand algorithm.
// Default to the primary interactor.
var interactorAttachPose = primaryAttachPose;
switch (m_MultiSelectPosition)
{
case PoseContributor.First:
interactorAttachPose.position = primaryAttachPose.position;
break;
case PoseContributor.Second:
interactorAttachPose.position = secondaryAttachPose.position;
break;
case PoseContributor.Average:
interactorAttachPose.position = (primaryAttachPose.position + secondaryAttachPose.position) * 0.5f;
break;
default:
Assert.IsTrue(false, $"Unhandled {nameof(PoseContributor)}={m_MultiSelectPosition}.");
goto case PoseContributor.First;
}
// For rotation, we match the anchor's forward to the vector made by the two interactor positions - imagine a hammer handle.
// We use the interactor's up as the base of the combined multi-select up, unless it is too similar to the forward vector
// In that case, we will gradually fall back to the right vector and calculate the final 'up' from that
var forward = (secondaryAttachPose.position - primaryAttachPose.position).normalized;
Vector3 up;
Vector3 right;
switch (m_MultiSelectRotation)
{
case PoseContributor.First:
up = primaryAttachPose.up;
right = primaryAttachPose.right;
if (forward == Vector3.zero)
forward = primaryAttachPose.forward;
break;
case PoseContributor.Second:
up = secondaryAttachPose.up;
right = secondaryAttachPose.right;
if (forward == Vector3.zero)
forward = secondaryAttachPose.forward;
break;
case PoseContributor.Average:
up = Vector3.Slerp(primaryAttachPose.up, secondaryAttachPose.up, 0.5f);
right = Vector3.Slerp(primaryAttachPose.right, secondaryAttachPose.right, 0.5f);
if (forward == Vector3.zero)
forward = primaryAttachPose.forward;
break;
default:
Assert.IsTrue(false, $"Unhandled {nameof(PoseContributor)}={m_MultiSelectRotation}.");
goto case PoseContributor.First;
}
var crossUp = Vector3.Cross(forward, right);
var angleDiff = Mathf.PingPong(Vector3.Angle(up, forward), 90f);
up = Vector3.Slerp(crossUp, up, angleDiff / 90f);
var crossRight = Vector3.Cross(up, forward);
up = Vector3.Cross(forward, crossRight);
// We also keep track of whether the up vector was pointing up or down previously, to allow for objects to be flipped through a series of rotations
// Such as a 180 degree rotation on the y, followed by a 180 degree rotation on the x
if (Vector3.Dot(up, m_LastUp) <= 0f)
{
up = -up;
}
m_LastUp = up;
interactorAttachPose.rotation = Quaternion.LookRotation(forward, up);
lastInteractorAttachPose = interactorAttachPose;
if (!grabInteractable.trackRotation)
{
// When not using the rotation of the Interactor we apply the position without an offset
targetPose.position = interactorAttachPose.position;
return;
}
// Compute the new target world pose
if (m_MultiSelectRotation == PoseContributor.First || m_MultiSelectRotation == PoseContributor.Second)
{
var controllerIndex = m_MultiSelectRotation == PoseContributor.First ? 0 : 1;
var thisAttachTransform = grabInteractable.GetAttachTransform(grabInteractable.interactorsSelecting[controllerIndex]);
var thisTransformPose = grabInteractable.transform.GetWorldPose();
// Calculate offset of the grab interactable's position relative to its attach transform.
// Transform that offset direction from world space to local space of the transform it's relative to.
// It will be applied to the interactor's attach position using the orientation of the Interactor's attach transform.
var attachOffset = thisTransformPose.position - thisAttachTransform.position;
var positionOffset = thisAttachTransform.InverseTransformDirection(attachOffset);
targetPose.position = (interactorAttachPose.rotation * positionOffset) + interactorAttachPose.position;
}
else if (m_MultiSelectRotation == PoseContributor.Average)
{
// Average rotation does not use offset and keeps objects between two attach points (controllers).
targetPose.position = interactorAttachPose.position;
}
else
{
Assert.IsTrue(false, $"Unhandled {nameof(PoseContributor)}={m_MultiSelectRotation}.");
}
targetPose.rotation = interactorAttachPose.rotation;
}
}
}
XRControllerRecorder
namespace UnityEngine.XR.Interaction.Toolkit
{
/// <summary>
/// <see cref="MonoBehaviour"/> that controls interaction recording and playback (via <see cref="XRControllerRecording"/> assets).
/// </summary>
[AddComponentMenu("XR/Debug/XR Controller Recorder", 11)]
[DisallowMultipleComponent]
[DefaultExecutionOrder(XRInteractionUpdateOrder.k_ControllerRecorder)]
[HelpURL(XRHelpURLConstants.k_XRControllerRecorder)]
public class XRControllerRecorder : MonoBehaviour
{
[Header("Input Recording/Playback")]
[SerializeField, Tooltip("Controls whether this recording will start playing when the component's Awake() method is called.")]
bool m_PlayOnStart;
/// <summary>
/// Controls whether this recording will start playing when the component's <see cref="Awake"/> method is called.
/// </summary>
public bool playOnStart
{
get => m_PlayOnStart;
set => m_PlayOnStart = value;
}
[SerializeField, Tooltip("Controller Recording asset for recording and playback of controller events.")]
XRControllerRecording m_Recording;
/// <summary>
/// Controller Recording asset for recording and playback of controller events.
/// </summary>
public XRControllerRecording recording
{
get => m_Recording;
set => m_Recording = value;
}
[SerializeField, Tooltip("XR Controller who's output will be recorded and played back")]
XRBaseController m_XRController;
/// <summary>
/// The controller that this recording uses for recording and playback.
/// </summary>
public XRBaseController xrController
{
get => m_XRController;
set => m_XRController = value;
}
/// <summary>
/// Whether the <see cref="XRControllerRecorder"/> is currently recording interaction state.
/// </summary>
public bool isRecording
{
get => m_IsRecording;
set
{
if (m_IsRecording != value)
{
recordingStartTime = Time.time;
isPlaying = false;
m_CurrentTime = 0d;
if (m_Recording)
{
if (value)
m_Recording.InitRecording();
else
m_Recording.SaveRecording();
}
m_IsRecording = value;
}
}
}
/// <summary>
/// Whether the XRControllerRecorder is currently playing back interaction state.
/// </summary>
public bool isPlaying
{
get => m_IsPlaying;
set
{
if (m_IsPlaying != value)
{
isRecording = false;
if (m_Recording)
ResetPlayback();
m_CurrentTime = 0d;
m_IsPlaying = value;
// Cache the previous state of the XRController, or put it back
if (value && m_XRController != null)
{
m_PrevEnableInputActions = m_XRController.enableInputActions;
m_PrevEnableInputTracking = m_XRController.enableInputTracking;
m_XRController.enableInputActions = false;
m_XRController.enableInputTracking = false;
}
else if (m_XRController != null)
{
m_XRController.enableInputActions = m_PrevEnableInputActions;
m_XRController.enableInputTracking = m_PrevEnableInputTracking;
}
}
}
}
double m_CurrentTime;
/// <summary>
/// (Read Only) The current recording/playback time.
/// </summary>
public double currentTime => m_CurrentTime;
/// <summary>
/// (Read Only) The total playback time (or 0 if no recording).
/// </summary>
public double duration => m_Recording != null ? m_Recording.duration : 0d;
/// <summary>
/// The <see cref="Time.time"/> when recording was last started.
/// </summary>
protected float recordingStartTime { get; set; }
bool m_IsRecording;
bool m_IsPlaying;
double m_LastPlaybackTime;
int m_LastFrameIdx;
bool m_PrevEnableInputActions;
bool m_PrevEnableInputTracking;
/// <summary>
/// See <see cref="MonoBehaviour"/>.
/// </summary>
protected void Awake()
{
if (m_XRController == null)
m_XRController = GetComponent<XRBaseController>();
m_CurrentTime = 0d;
if (m_PlayOnStart)
isPlaying = true;
}
/// <summary>
/// See <see cref="MonoBehaviour"/>.
/// </summary>
protected virtual void Update()
{
if (isRecording && m_XRController != null)
{
var state = m_XRController.currentControllerState;
state.time = Time.time - recordingStartTime;
m_Recording.AddRecordingFrame(state);
}
else if (isPlaying)
{
UpdatePlaybackTime(m_CurrentTime);
}
if (isRecording || isPlaying)
m_CurrentTime += Time.deltaTime;
if (isPlaying && m_CurrentTime > m_Recording.duration)
isPlaying = false;
}
/// <summary>
/// See <see cref="MonoBehaviour"/>.
/// </summary>
protected void OnDestroy()
{
isRecording = false;
isPlaying = false;
}
/// <summary>
/// Resets the recorder to the start of the clip.
/// </summary>
public void ResetPlayback()
{
m_LastPlaybackTime = 0d;
m_LastFrameIdx = 0;
}
void UpdatePlaybackTime(double playbackTime)
{
if (!m_Recording || m_Recording == null || m_Recording.frames.Count == 0 || m_LastFrameIdx >= m_Recording.frames.Count )
return;
// Look for next frame in order (binary search would be faster but we are only searching from last cached frame index)
var prevFrame = m_Recording.frames[m_LastFrameIdx];
var frameIdx = m_LastFrameIdx;
if (prevFrame.time < playbackTime)
{
for (; frameIdx < m_Recording.frames.Count &&
m_Recording.frames[frameIdx].time >= m_LastPlaybackTime &&
m_Recording.frames[frameIdx].time <= playbackTime;
++frameIdx) { }
}
// Past last frame or on the same frame, don't do anything
if (frameIdx >= m_Recording.frames.Count)
return;
if (m_XRController != null)
{
var recordingFrame = m_Recording.frames[frameIdx];
m_XRController.currentControllerState = recordingFrame;
}
m_LastFrameIdx = frameIdx;
m_LastPlaybackTime = playbackTime;
}
/// <summary>
/// Gets the state of the controller.
/// </summary>
/// <param name="controllerState">When this method returns, contains the <see cref="XRControllerState"/> object representing the state of the controller.</param>
/// <returns>Returns <see langword="true"/> when playing or recording. Otherwise, returns <see langword="false"/>.</returns>
public virtual bool GetControllerState(out XRControllerState controllerState)
{
if (isPlaying)
{
// Return the current frame we're playing back
if (m_Recording.frames.Count > m_LastFrameIdx)
{
controllerState = m_Recording.frames[m_LastFrameIdx];
return true;
}
}
else if (isRecording)
{
// Relay the last frame we got
if (m_Recording.frames.Count > 0)
{
controllerState = m_Recording.frames[m_Recording.frames.Count - 1];
return true;
}
}
else if (m_XRController != null)
{
// Pass through as we're not recording or playing
controllerState = m_XRController.currentControllerState;
return false;
}
controllerState = new XRControllerState();
return false;
}
}
}
ObjectReset
using System.Collections.Generic;
namespace UnityEngine.XR.Content.Interaction
{
/// <summary>
/// Provides the ability to reset specified objects if they fall below a certain position - designated by this transform's height.
/// </summary>
public class ObjectReset : MonoBehaviour
{
[SerializeField]
[Tooltip("Which objects to reset if falling out of range.")]
List<Transform> m_ObjectsToReset = new List<Transform>();
[SerializeField]
[Tooltip("How often to check if objects should be reset.")]
float m_CheckDuration = 2f;
readonly List<Pose> m_OriginalPositions = new List<Pose>();
float m_CheckTimer;
/// <summary>
/// See <see cref="MonoBehaviour"/>.
/// </summary>
protected void Start()
{
foreach (var currentTransform in m_ObjectsToReset)
{
if (currentTransform != null)
{
m_OriginalPositions.Add(new Pose(currentTransform.position, currentTransform.rotation));
}
else
{
Debug.LogWarning("Objects To Reset contained a null element. Update the reference or delete the array element of the missing object.", this);
m_OriginalPositions.Add(new Pose());
}
}
}
/// <summary>
/// See <see cref="MonoBehaviour"/>.
/// </summary>
protected void Update()
{
m_CheckTimer -= Time.deltaTime;
if (m_CheckTimer > 0)
return;
m_CheckTimer = m_CheckDuration;
var resetPlane = transform.position.y;
for (var transformIndex = 0; transformIndex < m_ObjectsToReset.Count; transformIndex++)
{
var currentTransform = m_ObjectsToReset[transformIndex];
if (currentTransform == null)
continue;
if (currentTransform.position.y < resetPlane)
{
currentTransform.SetPositionAndRotation(m_OriginalPositions[transformIndex].position, m_OriginalPositions[transformIndex].rotation);
var rigidBody = currentTransform.GetComponentInChildren<Rigidbody>();
if (rigidBody != null)
{
rigidBody.velocity = Vector3.zero;
rigidBody.angularVelocity = Vector3.zero;
}
}
}
}
}
}