Looking-Glass/Assets/VRTemplateAssets/Scripts/XRKnob.cs
2024-02-02 12:54:47 +01:00

440 lines
15 KiB
C#

using System;
using UnityEngine;
using UnityEngine.Events;
using UnityEngine.XR.Interaction.Toolkit;
namespace Unity.VRTemplate
{
/// <summary>
/// An interactable knob that follows the rotation of the interactor
/// </summary>
public class XRKnob : XRBaseInteractable
{
const float k_ModeSwitchDeadZone = 0.1f; // Prevents rapid switching between the different rotation tracking modes
/// <summary>
/// Helper class used to track rotations that can go beyond 180 degrees while minimizing accumulation error
/// </summary>
struct TrackedRotation
{
/// <summary>
/// The anchor rotation we calculate an offset from
/// </summary>
float m_BaseAngle;
/// <summary>
/// The target rotate we calculate the offset to
/// </summary>
float m_CurrentOffset;
/// <summary>
/// Any previous offsets we've added in
/// </summary>
float m_AccumulatedAngle;
/// <summary>
/// The total rotation that occurred from when this rotation started being tracked
/// </summary>
public float totalOffset => m_AccumulatedAngle + m_CurrentOffset;
/// <summary>
/// Resets the tracked rotation so that total offset returns 0
/// </summary>
public void Reset()
{
m_BaseAngle = 0.0f;
m_CurrentOffset = 0.0f;
m_AccumulatedAngle = 0.0f;
}
/// <summary>
/// Sets a new anchor rotation while maintaining any previously accumulated offset
/// </summary>
/// <param name="direction">The XZ vector used to calculate a rotation angle</param>
public void SetBaseFromVector(Vector3 direction)
{
// Update any accumulated angle
m_AccumulatedAngle += m_CurrentOffset;
// Now set a new base angle
m_BaseAngle = Mathf.Atan2(direction.z, direction.x) * Mathf.Rad2Deg;
m_CurrentOffset = 0.0f;
}
/// <summary>
/// Updates current offset and base angle based on target direction.
/// </summary>
/// <param name="direction">The XZ vector used to calculate a rotation angle</param>
public void SetTargetFromVector(Vector3 direction)
{
// Set the target angle
var targetAngle = Mathf.Atan2(direction.z, direction.x) * Mathf.Rad2Deg;
// Return the offset
m_CurrentOffset = ShortestAngleDistance(m_BaseAngle, targetAngle, 360.0f);
// If the offset is greater than 90 degrees, we update the base so we can rotate beyond 180 degrees
if (Mathf.Abs(m_CurrentOffset) > 90.0f)
{
m_BaseAngle = targetAngle;
m_AccumulatedAngle += m_CurrentOffset;
m_CurrentOffset = 0.0f;
}
}
}
[Serializable]
[Tooltip("Event called when the value of the knob is changed")]
public class ValueChangeEvent : UnityEvent<float> { }
[SerializeField]
[Tooltip("The object that is visually grabbed and manipulated")]
Transform m_Handle = null;
[SerializeField]
[Tooltip("The value of the knob")]
[Range(0.0f, 1.0f)]
float m_Value = 0.5f;
[SerializeField]
[Tooltip("Whether this knob's rotation should be clamped by the angle limits")]
bool m_ClampedMotion = true;
[SerializeField]
[Tooltip("Rotation of the knob at value '1'")]
float m_MaxAngle = 90.0f;
[SerializeField]
[Tooltip("Rotation of the knob at value '0'")]
float m_MinAngle = -90.0f;
[SerializeField]
[Tooltip("Angle increments to support, if greater than '0'")]
float m_AngleIncrement = 0.0f;
[SerializeField]
[Tooltip("The position of the interactor controls rotation when outside this radius")]
float m_PositionTrackedRadius = 0.1f;
[SerializeField]
[Tooltip("How much controller rotation")]
float m_TwistSensitivity = 1.5f;
[SerializeField]
[Tooltip("Events to trigger when the knob is rotated")]
ValueChangeEvent m_OnValueChange = new ValueChangeEvent();
IXRSelectInteractor m_Interactor;
bool m_PositionDriven = false;
bool m_UpVectorDriven = false;
TrackedRotation m_PositionAngles = new TrackedRotation();
TrackedRotation m_UpVectorAngles = new TrackedRotation();
TrackedRotation m_ForwardVectorAngles = new TrackedRotation();
float m_BaseKnobRotation = 0.0f;
/// <summary>
/// The object that is visually grabbed and manipulated
/// </summary>
public Transform handle
{
get => m_Handle;
set => m_Handle = value;
}
/// <summary>
/// The value of the knob
/// </summary>
public float value
{
get => m_Value;
set
{
SetValue(value);
SetKnobRotation(ValueToRotation());
}
}
/// <summary>
/// Whether this knob's rotation should be clamped by the angle limits
/// </summary>
public bool clampedMotion
{
get => m_ClampedMotion;
set => m_ClampedMotion = value;
}
/// <summary>
/// Rotation of the knob at value '1'
/// </summary>
public float maxAngle
{
get => m_MaxAngle;
set => m_MaxAngle = value;
}
/// <summary>
/// Rotation of the knob at value '0'
/// </summary>
public float minAngle
{
get => m_MinAngle;
set => m_MinAngle = value;
}
/// <summary>
/// The position of the interactor controls rotation when outside this radius
/// </summary>
public float positionTrackedRadius
{
get => m_PositionTrackedRadius;
set => m_PositionTrackedRadius = value;
}
/// <summary>
/// Events to trigger when the knob is rotated
/// </summary>
public ValueChangeEvent onValueChange => m_OnValueChange;
void Start()
{
SetValue(m_Value);
SetKnobRotation(ValueToRotation());
}
protected override void OnEnable()
{
base.OnEnable();
selectEntered.AddListener(StartGrab);
selectExited.AddListener(EndGrab);
}
protected override void OnDisable()
{
selectEntered.RemoveListener(StartGrab);
selectExited.RemoveListener(EndGrab);
base.OnDisable();
}
void StartGrab(SelectEnterEventArgs args)
{
m_Interactor = args.interactorObject;
m_PositionAngles.Reset();
m_UpVectorAngles.Reset();
m_ForwardVectorAngles.Reset();
UpdateBaseKnobRotation();
UpdateRotation(true);
}
void EndGrab(SelectExitEventArgs args)
{
m_Interactor = null;
}
/// <inheritdoc />
public override void ProcessInteractable(XRInteractionUpdateOrder.UpdatePhase updatePhase)
{
base.ProcessInteractable(updatePhase);
if (updatePhase == XRInteractionUpdateOrder.UpdatePhase.Dynamic)
{
if (isSelected)
{
UpdateRotation();
}
}
}
/// <inheritdoc />
public override Transform GetAttachTransform(IXRInteractor interactor)
{
return m_Handle;
}
void UpdateRotation(bool freshCheck = false)
{
// Are we in position offset or direction rotation mode?
var interactorTransform = m_Interactor.GetAttachTransform(this);
// We cache the three potential sources of rotation - the position offset, the forward vector of the controller, and up vector of the controller
// We store any data used for determining which rotation to use, then flatten the vectors to the local xz plane
var localOffset = transform.InverseTransformVector(interactorTransform.position - m_Handle.position);
localOffset.y = 0.0f;
var radiusOffset = transform.TransformVector(localOffset).magnitude;
localOffset.Normalize();
var localForward = transform.InverseTransformDirection(interactorTransform.forward);
var localY = Math.Abs(localForward.y);
localForward.y = 0.0f;
localForward.Normalize();
var localUp = transform.InverseTransformDirection(interactorTransform.up);
localUp.y = 0.0f;
localUp.Normalize();
if (m_PositionDriven && !freshCheck)
radiusOffset *= (1.0f + k_ModeSwitchDeadZone);
// Determine when a certain source of rotation won't contribute - in that case we bake in the offset it has applied
// and set a new anchor when they can contribute again
if (radiusOffset >= m_PositionTrackedRadius)
{
if (!m_PositionDriven || freshCheck)
{
m_PositionAngles.SetBaseFromVector(localOffset);
m_PositionDriven = true;
}
}
else
m_PositionDriven = false;
// If it's not a fresh check, then we weight the local Y up or down to keep it from flickering back and forth at boundaries
if (!freshCheck)
{
if (!m_UpVectorDriven)
localY *= (1.0f - (k_ModeSwitchDeadZone * 0.5f));
else
localY *= (1.0f + (k_ModeSwitchDeadZone * 0.5f));
}
if (localY > 0.707f)
{
if (!m_UpVectorDriven || freshCheck)
{
m_UpVectorAngles.SetBaseFromVector(localUp);
m_UpVectorDriven = true;
}
}
else
{
if (m_UpVectorDriven || freshCheck)
{
m_ForwardVectorAngles.SetBaseFromVector(localForward);
m_UpVectorDriven = false;
}
}
// Get angle from position
if (m_PositionDriven)
m_PositionAngles.SetTargetFromVector(localOffset);
if (m_UpVectorDriven)
m_UpVectorAngles.SetTargetFromVector(localUp);
else
m_ForwardVectorAngles.SetTargetFromVector(localForward);
// Apply offset to base knob rotation to get new knob rotation
var knobRotation = m_BaseKnobRotation - ((m_UpVectorAngles.totalOffset + m_ForwardVectorAngles.totalOffset) * m_TwistSensitivity) - m_PositionAngles.totalOffset;
// Clamp to range
if (m_ClampedMotion)
knobRotation = Mathf.Clamp(knobRotation, m_MinAngle, m_MaxAngle);
SetKnobRotation(knobRotation);
// Reverse to get value
var knobValue = (knobRotation - m_MinAngle) / (m_MaxAngle - m_MinAngle);
SetValue(knobValue);
}
void SetKnobRotation(float angle)
{
if (m_AngleIncrement > 0)
{
var normalizeAngle = angle - m_MinAngle;
angle = (Mathf.Round(normalizeAngle / m_AngleIncrement) * m_AngleIncrement) + m_MinAngle;
}
if (m_Handle != null)
m_Handle.localEulerAngles = new Vector3(0.0f, angle, 0.0f);
}
void SetValue(float newValue)
{
if (m_ClampedMotion)
newValue = Mathf.Clamp01(newValue);
if (m_AngleIncrement > 0)
{
var angleRange = m_MaxAngle - m_MinAngle;
var angle = Mathf.Lerp(0.0f, angleRange, newValue);
angle = Mathf.Round(angle / m_AngleIncrement) * m_AngleIncrement;
newValue = Mathf.InverseLerp(0.0f, angleRange, angle);
}
m_Value = newValue;
m_OnValueChange.Invoke(m_Value);
}
float ValueToRotation()
{
return m_ClampedMotion ? Mathf.Lerp(m_MinAngle, m_MaxAngle, m_Value) : Mathf.LerpUnclamped(m_MinAngle, m_MaxAngle, m_Value);
}
void UpdateBaseKnobRotation()
{
m_BaseKnobRotation = Mathf.LerpUnclamped(m_MinAngle, m_MaxAngle, m_Value);
}
static float ShortestAngleDistance(float start, float end, float max)
{
var angleDelta = end - start;
var angleSign = Mathf.Sign(angleDelta);
angleDelta = Math.Abs(angleDelta) % max;
if (angleDelta > (max * 0.5f))
angleDelta = -(max - angleDelta);
return angleDelta * angleSign;
}
void OnDrawGizmosSelected()
{
const int k_CircleSegments = 16;
const float k_SegmentRatio = 1.0f / k_CircleSegments;
// Nothing to do if position radius is too small
if (m_PositionTrackedRadius <= Mathf.Epsilon)
return;
var knobTransform = transform;
// Draw a circle from the handle point at size of position tracked radius
var circleCenter = knobTransform.position;
if (m_Handle != null)
circleCenter = m_Handle.position;
var circleX = knobTransform.right;
var circleY = knobTransform.forward;
Gizmos.color = Color.green;
var segmentCounter = 0;
while (segmentCounter < k_CircleSegments)
{
var startAngle = segmentCounter * k_SegmentRatio * 2.0f * Mathf.PI;
segmentCounter++;
var endAngle = segmentCounter * k_SegmentRatio * 2.0f * Mathf.PI;
Gizmos.DrawLine(circleCenter + (Mathf.Cos(startAngle) * circleX + Mathf.Sin(startAngle) * circleY) * m_PositionTrackedRadius,
circleCenter + (Mathf.Cos(endAngle) * circleX + Mathf.Sin(endAngle) * circleY) * m_PositionTrackedRadius);
}
}
void OnValidate()
{
if (m_ClampedMotion)
m_Value = Mathf.Clamp01(m_Value);
if (m_MinAngle > m_MaxAngle)
m_MinAngle = m_MaxAngle;
SetKnobRotation(ValueToRotation());
}
}
}