XRKnob.cs 15 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441
  1. using System;
  2. using UnityEngine;
  3. using UnityEngine.Events;
  4. using UnityEngine.XR.Interaction.Toolkit;
  5. using UnityEngine.XR.Interaction.Toolkit.Interactables;
  6. using UnityEngine.XR.Interaction.Toolkit.Interactors;
  7. namespace Unity.VRTemplate
  8. {
  9. /// <summary>
  10. /// An interactable knob that follows the rotation of the interactor
  11. /// </summary>
  12. public class XRKnob : XRBaseInteractable
  13. {
  14. const float k_ModeSwitchDeadZone = 0.1f; // Prevents rapid switching between the different rotation tracking modes
  15. /// <summary>
  16. /// Helper class used to track rotations that can go beyond 180 degrees while minimizing accumulation error
  17. /// </summary>
  18. struct TrackedRotation
  19. {
  20. /// <summary>
  21. /// The anchor rotation we calculate an offset from
  22. /// </summary>
  23. float m_BaseAngle;
  24. /// <summary>
  25. /// The target rotate we calculate the offset to
  26. /// </summary>
  27. float m_CurrentOffset;
  28. /// <summary>
  29. /// Any previous offsets we've added in
  30. /// </summary>
  31. float m_AccumulatedAngle;
  32. /// <summary>
  33. /// The total rotation that occurred from when this rotation started being tracked
  34. /// </summary>
  35. public float totalOffset => m_AccumulatedAngle + m_CurrentOffset;
  36. /// <summary>
  37. /// Resets the tracked rotation so that total offset returns 0
  38. /// </summary>
  39. public void Reset()
  40. {
  41. m_BaseAngle = 0.0f;
  42. m_CurrentOffset = 0.0f;
  43. m_AccumulatedAngle = 0.0f;
  44. }
  45. /// <summary>
  46. /// Sets a new anchor rotation while maintaining any previously accumulated offset
  47. /// </summary>
  48. /// <param name="direction">The XZ vector used to calculate a rotation angle</param>
  49. public void SetBaseFromVector(Vector3 direction)
  50. {
  51. // Update any accumulated angle
  52. m_AccumulatedAngle += m_CurrentOffset;
  53. // Now set a new base angle
  54. m_BaseAngle = Mathf.Atan2(direction.z, direction.x) * Mathf.Rad2Deg;
  55. m_CurrentOffset = 0.0f;
  56. }
  57. /// <summary>
  58. /// Updates current offset and base angle based on target direction.
  59. /// </summary>
  60. /// <param name="direction">The XZ vector used to calculate a rotation angle</param>
  61. public void SetTargetFromVector(Vector3 direction)
  62. {
  63. // Set the target angle
  64. var targetAngle = Mathf.Atan2(direction.z, direction.x) * Mathf.Rad2Deg;
  65. // Return the offset
  66. m_CurrentOffset = ShortestAngleDistance(m_BaseAngle, targetAngle, 360.0f);
  67. // If the offset is greater than 90 degrees, we update the base so we can rotate beyond 180 degrees
  68. if (Mathf.Abs(m_CurrentOffset) > 90.0f)
  69. {
  70. m_BaseAngle = targetAngle;
  71. m_AccumulatedAngle += m_CurrentOffset;
  72. m_CurrentOffset = 0.0f;
  73. }
  74. }
  75. }
  76. [Serializable]
  77. [Tooltip("Event called when the value of the knob is changed")]
  78. public class ValueChangeEvent : UnityEvent<float> { }
  79. [SerializeField]
  80. [Tooltip("The object that is visually grabbed and manipulated")]
  81. Transform m_Handle = null;
  82. [SerializeField]
  83. [Tooltip("The value of the knob")]
  84. [Range(0.0f, 1.0f)]
  85. float m_Value = 0.5f;
  86. [SerializeField]
  87. [Tooltip("Whether this knob's rotation should be clamped by the angle limits")]
  88. bool m_ClampedMotion = true;
  89. [SerializeField]
  90. [Tooltip("Rotation of the knob at value '1'")]
  91. float m_MaxAngle = 90.0f;
  92. [SerializeField]
  93. [Tooltip("Rotation of the knob at value '0'")]
  94. float m_MinAngle = -90.0f;
  95. [SerializeField]
  96. [Tooltip("Angle increments to support, if greater than '0'")]
  97. float m_AngleIncrement = 0.0f;
  98. [SerializeField]
  99. [Tooltip("The position of the interactor controls rotation when outside this radius")]
  100. float m_PositionTrackedRadius = 0.1f;
  101. [SerializeField]
  102. [Tooltip("How much controller rotation")]
  103. float m_TwistSensitivity = 1.5f;
  104. [SerializeField]
  105. [Tooltip("Events to trigger when the knob is rotated")]
  106. ValueChangeEvent m_OnValueChange = new ValueChangeEvent();
  107. IXRSelectInteractor m_Interactor;
  108. bool m_PositionDriven = false;
  109. bool m_UpVectorDriven = false;
  110. TrackedRotation m_PositionAngles = new TrackedRotation();
  111. TrackedRotation m_UpVectorAngles = new TrackedRotation();
  112. TrackedRotation m_ForwardVectorAngles = new TrackedRotation();
  113. float m_BaseKnobRotation = 0.0f;
  114. /// <summary>
  115. /// The object that is visually grabbed and manipulated
  116. /// </summary>
  117. public Transform handle
  118. {
  119. get => m_Handle;
  120. set => m_Handle = value;
  121. }
  122. /// <summary>
  123. /// The value of the knob
  124. /// </summary>
  125. public float value
  126. {
  127. get => m_Value;
  128. set
  129. {
  130. SetValue(value);
  131. SetKnobRotation(ValueToRotation());
  132. }
  133. }
  134. /// <summary>
  135. /// Whether this knob's rotation should be clamped by the angle limits
  136. /// </summary>
  137. public bool clampedMotion
  138. {
  139. get => m_ClampedMotion;
  140. set => m_ClampedMotion = value;
  141. }
  142. /// <summary>
  143. /// Rotation of the knob at value '1'
  144. /// </summary>
  145. public float maxAngle
  146. {
  147. get => m_MaxAngle;
  148. set => m_MaxAngle = value;
  149. }
  150. /// <summary>
  151. /// Rotation of the knob at value '0'
  152. /// </summary>
  153. public float minAngle
  154. {
  155. get => m_MinAngle;
  156. set => m_MinAngle = value;
  157. }
  158. /// <summary>
  159. /// The position of the interactor controls rotation when outside this radius
  160. /// </summary>
  161. public float positionTrackedRadius
  162. {
  163. get => m_PositionTrackedRadius;
  164. set => m_PositionTrackedRadius = value;
  165. }
  166. /// <summary>
  167. /// Events to trigger when the knob is rotated
  168. /// </summary>
  169. public ValueChangeEvent onValueChange => m_OnValueChange;
  170. void Start()
  171. {
  172. SetValue(m_Value);
  173. SetKnobRotation(ValueToRotation());
  174. }
  175. protected override void OnEnable()
  176. {
  177. base.OnEnable();
  178. selectEntered.AddListener(StartGrab);
  179. selectExited.AddListener(EndGrab);
  180. }
  181. protected override void OnDisable()
  182. {
  183. selectEntered.RemoveListener(StartGrab);
  184. selectExited.RemoveListener(EndGrab);
  185. base.OnDisable();
  186. }
  187. void StartGrab(SelectEnterEventArgs args)
  188. {
  189. m_Interactor = args.interactorObject;
  190. m_PositionAngles.Reset();
  191. m_UpVectorAngles.Reset();
  192. m_ForwardVectorAngles.Reset();
  193. UpdateBaseKnobRotation();
  194. UpdateRotation(true);
  195. }
  196. void EndGrab(SelectExitEventArgs args)
  197. {
  198. m_Interactor = null;
  199. }
  200. /// <inheritdoc />
  201. public override void ProcessInteractable(XRInteractionUpdateOrder.UpdatePhase updatePhase)
  202. {
  203. base.ProcessInteractable(updatePhase);
  204. if (updatePhase == XRInteractionUpdateOrder.UpdatePhase.Dynamic)
  205. {
  206. if (isSelected)
  207. {
  208. UpdateRotation();
  209. }
  210. }
  211. }
  212. /// <inheritdoc />
  213. public override Transform GetAttachTransform(IXRInteractor interactor)
  214. {
  215. return m_Handle;
  216. }
  217. void UpdateRotation(bool freshCheck = false)
  218. {
  219. // Are we in position offset or direction rotation mode?
  220. var interactorTransform = m_Interactor.GetAttachTransform(this);
  221. // We cache the three potential sources of rotation - the position offset, the forward vector of the controller, and up vector of the controller
  222. // We store any data used for determining which rotation to use, then flatten the vectors to the local xz plane
  223. var localOffset = transform.InverseTransformVector(interactorTransform.position - m_Handle.position);
  224. localOffset.y = 0.0f;
  225. var radiusOffset = transform.TransformVector(localOffset).magnitude;
  226. localOffset.Normalize();
  227. var localForward = transform.InverseTransformDirection(interactorTransform.forward);
  228. var localY = Math.Abs(localForward.y);
  229. localForward.y = 0.0f;
  230. localForward.Normalize();
  231. var localUp = transform.InverseTransformDirection(interactorTransform.up);
  232. localUp.y = 0.0f;
  233. localUp.Normalize();
  234. if (m_PositionDriven && !freshCheck)
  235. radiusOffset *= (1.0f + k_ModeSwitchDeadZone);
  236. // Determine when a certain source of rotation won't contribute - in that case we bake in the offset it has applied
  237. // and set a new anchor when they can contribute again
  238. if (radiusOffset >= m_PositionTrackedRadius)
  239. {
  240. if (!m_PositionDriven || freshCheck)
  241. {
  242. m_PositionAngles.SetBaseFromVector(localOffset);
  243. m_PositionDriven = true;
  244. }
  245. }
  246. else
  247. m_PositionDriven = false;
  248. // 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
  249. if (!freshCheck)
  250. {
  251. if (!m_UpVectorDriven)
  252. localY *= (1.0f - (k_ModeSwitchDeadZone * 0.5f));
  253. else
  254. localY *= (1.0f + (k_ModeSwitchDeadZone * 0.5f));
  255. }
  256. if (localY > 0.707f)
  257. {
  258. if (!m_UpVectorDriven || freshCheck)
  259. {
  260. m_UpVectorAngles.SetBaseFromVector(localUp);
  261. m_UpVectorDriven = true;
  262. }
  263. }
  264. else
  265. {
  266. if (m_UpVectorDriven || freshCheck)
  267. {
  268. m_ForwardVectorAngles.SetBaseFromVector(localForward);
  269. m_UpVectorDriven = false;
  270. }
  271. }
  272. // Get angle from position
  273. if (m_PositionDriven)
  274. m_PositionAngles.SetTargetFromVector(localOffset);
  275. if (m_UpVectorDriven)
  276. m_UpVectorAngles.SetTargetFromVector(localUp);
  277. else
  278. m_ForwardVectorAngles.SetTargetFromVector(localForward);
  279. // Apply offset to base knob rotation to get new knob rotation
  280. var knobRotation = m_BaseKnobRotation - ((m_UpVectorAngles.totalOffset + m_ForwardVectorAngles.totalOffset) * m_TwistSensitivity) - m_PositionAngles.totalOffset;
  281. // Clamp to range
  282. if (m_ClampedMotion)
  283. knobRotation = Mathf.Clamp(knobRotation, m_MinAngle, m_MaxAngle);
  284. SetKnobRotation(knobRotation);
  285. // Reverse to get value
  286. var knobValue = (knobRotation - m_MinAngle) / (m_MaxAngle - m_MinAngle);
  287. SetValue(knobValue);
  288. }
  289. void SetKnobRotation(float angle)
  290. {
  291. if (m_AngleIncrement > 0)
  292. {
  293. var normalizeAngle = angle - m_MinAngle;
  294. angle = (Mathf.Round(normalizeAngle / m_AngleIncrement) * m_AngleIncrement) + m_MinAngle;
  295. }
  296. if (m_Handle != null)
  297. m_Handle.localEulerAngles = new Vector3(0.0f, angle, 0.0f);
  298. }
  299. void SetValue(float newValue)
  300. {
  301. if (m_ClampedMotion)
  302. newValue = Mathf.Clamp01(newValue);
  303. if (m_AngleIncrement > 0)
  304. {
  305. var angleRange = m_MaxAngle - m_MinAngle;
  306. var angle = Mathf.Lerp(0.0f, angleRange, newValue);
  307. angle = Mathf.Round(angle / m_AngleIncrement) * m_AngleIncrement;
  308. newValue = Mathf.InverseLerp(0.0f, angleRange, angle);
  309. }
  310. m_Value = newValue;
  311. m_OnValueChange.Invoke(m_Value);
  312. }
  313. float ValueToRotation()
  314. {
  315. return m_ClampedMotion ? Mathf.Lerp(m_MinAngle, m_MaxAngle, m_Value) : Mathf.LerpUnclamped(m_MinAngle, m_MaxAngle, m_Value);
  316. }
  317. void UpdateBaseKnobRotation()
  318. {
  319. m_BaseKnobRotation = Mathf.LerpUnclamped(m_MinAngle, m_MaxAngle, m_Value);
  320. }
  321. static float ShortestAngleDistance(float start, float end, float max)
  322. {
  323. var angleDelta = end - start;
  324. var angleSign = Mathf.Sign(angleDelta);
  325. angleDelta = Math.Abs(angleDelta) % max;
  326. if (angleDelta > (max * 0.5f))
  327. angleDelta = -(max - angleDelta);
  328. return angleDelta * angleSign;
  329. }
  330. void OnDrawGizmosSelected()
  331. {
  332. const int k_CircleSegments = 16;
  333. const float k_SegmentRatio = 1.0f / k_CircleSegments;
  334. // Nothing to do if position radius is too small
  335. if (m_PositionTrackedRadius <= Mathf.Epsilon)
  336. return;
  337. var knobTransform = transform;
  338. // Draw a circle from the handle point at size of position tracked radius
  339. var circleCenter = knobTransform.position;
  340. if (m_Handle != null)
  341. circleCenter = m_Handle.position;
  342. var circleX = knobTransform.right;
  343. var circleY = knobTransform.forward;
  344. Gizmos.color = Color.green;
  345. var segmentCounter = 0;
  346. while (segmentCounter < k_CircleSegments)
  347. {
  348. var startAngle = segmentCounter * k_SegmentRatio * 2.0f * Mathf.PI;
  349. segmentCounter++;
  350. var endAngle = segmentCounter * k_SegmentRatio * 2.0f * Mathf.PI;
  351. Gizmos.DrawLine(circleCenter + (Mathf.Cos(startAngle) * circleX + Mathf.Sin(startAngle) * circleY) * m_PositionTrackedRadius,
  352. circleCenter + (Mathf.Cos(endAngle) * circleX + Mathf.Sin(endAngle) * circleY) * m_PositionTrackedRadius);
  353. }
  354. }
  355. void OnValidate()
  356. {
  357. if (m_ClampedMotion)
  358. m_Value = Mathf.Clamp01(m_Value);
  359. if (m_MinAngle > m_MaxAngle)
  360. m_MinAngle = m_MaxAngle;
  361. SetKnobRotation(ValueToRotation());
  362. }
  363. }
  364. }