От винта! Рычаги и винты в Unity

от автора

Всем привет! Меня зовут Григорий Дядиченко, и я разрабатываю разные проекты на заказ. Сегодня хотелось бы поговорить про рычаги и винты, и их реализацию в Unity. Сейчас как раз на хайпе Apple Vision Pro, а подобные штуки бывают весьма полезны в проектах с виртуальной и дополненной реальностью. Если вы интересуетесь Unity разработкой и темой MR — добро пожаловать под кат! Может данная реализация пригодится в вашем проекте.

Почему тут важны MR проекты?

Давайте сначала немного определимся в терминах. MR (mixed reality) или смешанная реальность — это понятие по сути объединяющая понятия дополненной и виртуальной реальности. Данный термин несколько лет назад по сути начал популяризировать Microsoft.

Но причём тут рычаги и винты? Они в играх используются достаточно часто. Подойти и нажать запустить что-то по рычагу, покрутив винт можно во многих тайтлах вроде Half-Life или же Bioshock. Но чаще всего в игре они представляют из себя просто «логический объект». Это немного по-другому оформленные кнопки на которые можно нажать и произойдёт анимация вращения.

В MR так тоже конечно можно, но это может сбивать эффект погружения. Поэтому качественные AR и VR проекты чаще всего разрабатывать несколько дороже. Там требуется более «физичное» поведение объектов окружения. Тоже самое и с винтами. Классно когда за него можно взяться и физически покрутить.

Такие физические рычаги часто используются в промышленных VR проектах, где нужно показать какую-то работу с каким-то оборудованием или же скажем закручивать гайки. Так как гаечный ключ является тем же логическим рычагом. В игровых проектах я однажды использовал подобную технику, чтобы применить её в космическом корабле в качестве штурвала для управления.

Давайте сделаем такой рычаг и винт в Unity.

Сделаем наш рычаг

В чём заключается наша задача «Сделать рычаг»? По своей сути нам нужен объект в 3д пространстве за который мы можем взяться в определённой точке и с определённым ограничением движения изменить его положение в пространстве.

И для начала нам нужно определиться с системой координат. По сути тут их может быть три: мировая система координат, система координат рычага и система координат игрока.

Сразу отбросим систему координат игрока, как бессмысленную. В ней неудобно рассчитывать нужные нам параметры. Остались два кандидата.

И казалось бы с точки зрения Unity у нас есть доступ к мировой координате руки игрока и к мировой координате места, где мы берёмся за рычаг. Но не всё так просто. У мировой системы координат будет одна ключевая проблема — дальнейшая настройка рычагов в разных положениях в сцене.

С точки зрения разработки нам удобно сделать один рычаг и потом его клонировать по сцене. Он будет ограничен, с точки зрения объекта в пространстве двумя, основными параметрами: плоскость вращения и углы вращения. Рычаг же не должен ходить свободно в любую сторону, поэтому мы ограничиваем плоскость его движения. Для определения плоскости в трёхмерном пространстве нам достаточно двух параметров: точки на плоскости и нормали к этой плоскости. Если точкой на плоскости всегда является основание рычага, и там не имеет значения в какой оно системе координат. То вот с нормалью к плоскости в случае клонирования префаба по сцене работать в разы удобнее в локальной системе координат рычага. Потому что в противном случае нам нужно будет определять нормаль к плоскости вращения рычага в каждом созданном инстансе префаба.

Так что определим рычаг как его основание, плоскость вращения, начальное положение (или значение) и ограничения вращения. Я предпочитаю определять плоскость вращения енамом, так как тонкие настройки уже проще делать на уровне префаба. На мой взгляд есть две разумные плоскости вращения XY и ZY:

public enum Axis {     x,     z }
public class RotateObjectController : MonoBehaviour {     [SerializeField] private Transform _leverRoot;     [SerializeField] private Axis _localAxis;     [SerializeField] private float _startValue;     [SerializeField] private Vector2 _constraints;      private void Start()     {         _value =  Mathf.Clamp(_startValue, _constraints.x, _constraints.y);         _leverRoot.localRotation = Quaternion.Euler(GetAxis(_localAxis) * _value);     }      private void MoveLever(Vector3 worldPosition)     {         var localPosition = _leverRoot.parent.InverseTransformPoint(worldPosition);         var leverRootLocalPosition = _leverRoot.localPosition;         var localAxis = GetAxis(_localAxis);     }      private Vector3 GetAxis(Axis axis)     {         switch (axis)         {             case Axis.x:                 return new Vector3(1, 0, 0);             case Axis.z:                 return new Vector3(0, 0, 1);         }         return new Vector3();     } }

Заготовка готова. Осталось добавить немного математики. У нас есть основание рычага, есть точка в которую мы хотим подвинуть наш рычаг. Но нужно учитывать нюансы MR и тот факт, что это не рейкаст. Поэтому первоначально нам надо спроецировать точку которую мы получили для перемещения рычага в плоскость вращения рычага. Так как в Unity есть много встроенных удобных инструментов, то это делается в две строчки. Создаём плоскость и получаем проекцию точки на эту плоскость.

var plane = new Plane(localAxis, leverRootLocalPosition); var pos = plane.ClosestPointOnPlane(localPosition);

Дальше нам нужно рассчитать итоговое вращение, чтобы его применить. Для этого удобнее всего использовать функцию Quaternion.LookRotation. Данный метод возвращает кватернион определяющий поворот объекта в пространстве на основе двух векторов: forward и up. Итого у нас получилось:

public class RotateObjectController : MonoBehaviour {     [SerializeField] private Transform _leverRoot;     [SerializeField] private Axis _localAxis;     [SerializeField] private float _startValue;     [SerializeField] private Vector2 _constraints;      private void Start()     {         _value =  Mathf.Clamp(_startValue, _constraints.x, _constraints.y);         _leverRoot.localRotation = Quaternion.Euler(GetAxis(_localAxis) * _value);     }      private void MoveLever(Vector3 worldPosition)     {         var localPosition = _leverRoot.parent.InverseTransformPoint(worldPosition);         var leverRootLocalPosition = _leverRoot.localPosition;         var localAxis = GetAxis(_localAxis);         var plane = new Plane(localAxis, leverRootLocalPosition);         var pos = plane.ClosestPointOnPlane(localPosition);         _leverRoot.localRotation = Quaternion.LookRotation(                 localAxis,                 (pos - leverRootLocalPosition).normalized);     }      private Vector3 GetAxis(Axis axis)     {         switch (axis)         {             case Axis.x:                 return new Vector3(1, 0, 0);             case Axis.z:                 return new Vector3(0, 0, 1);         }         return new Vector3();     } }

Для анимаций или для управления чем-то, нам необходимо ещё получать значения вращения этого рычага. От значения вращения может зависеть насколько открыта какая-то дверь или в каком положении находится какой-то манипулятор. И тут есть важный момент.

Вращение в разы удобнее считать как дельту от прошлого вращения. В первую очередь это пригодится для реализации винта, так как винт может делать несколько оборотов вокруг своей оси. Следовательно нам нужно нормализованное значение общего вращения. Поэтому удобнее и проще работать с дельтой. Так как мы знаем нашу плоскость, то проще всего получить два вектора направления на этой плоскости и посчитать угол между ними со знаком. Таким образом мы получим нужную нам дельту через метод Vector3.SignedAngle:

public class RotateObjectController : MonoBehaviour {     [SerializeField] private Transform _leverRoot;     [SerializeField] private Axis _localAxis;     [SerializeField] private float _startValue;     [SerializeField] private Vector2 _constraints;      private Quaternion _lastRotation;     private float _value;      private void Start()     {         _value =  Mathf.Clamp(_startValue, _constraints.x, _constraints.y);         _leverRoot.localRotation = Quaternion.Euler(GetAxis(_localAxis) * _value);         _lastRotation = _leverRoot.localRotation;     }      private void MoveLever(Vector3 worldPosition)     {         var localPosition = _leverRoot.parent.InverseTransformPoint(worldPosition);         var leverRootLocalPosition = _leverRoot.localPosition;         var localAxis = GetAxis(_localAxis);         var plane = new Plane(localAxis, leverRootLocalPosition);         var pos = plane.ClosestPointOnPlane(localPosition);         _leverRoot.localRotation = Quaternion.LookRotation(                 localAxis,                 (pos - leverRootLocalPosition).normalized);         _value += CalculateValueDelta(_leverRoot.localRotation, localAxis);     }      private float CalculateValueDelta(Quaternion rotation, Vector3 axis)     {         return Vector3.SignedAngle(_lastRotation * Vector3.up, rotation * Vector3.up, axis);     }      private Vector3 GetAxis(Axis axis)     {         switch (axis)         {             case Axis.x:                 return new Vector3(1, 0, 0);             case Axis.z:                 return new Vector3(0, 0, 1);         }         return new Vector3();     } }

Сейчас мы можем вращать наш рычаг или винт, так как в своей сути это одно и тоже. Просто фигуры вращения. Но вращать его мы можем бесконечно. А для того, чтобы связать это всё с анимациями нам нужно применить наши ограничения. Так как тогда мы сможем получить нормализованное значение поворота нашего объекта вращения и работать с ним. Так что добавим логику ограничений.

Чтобы не было проблем выходы за границы наших ограничений, в которых у новичков часто происходит застревание объектов, нам нужно написать clamp для нашего значения поворота _value и уметь из него восстанавливать значение поворота рычага. Так как все данные для этого есть, то пишется это довольно просто:

private Quaternion ClampAngle(Quaternion rot, Vector3 axis) {     if (_value <= _constraints.x)     {         _value = _constraints.x;         return Quaternion.Euler(axis * _constraints.x);     }     if (_value >= _constraints.y)     {         _value = _constraints.y;         return Quaternion.Euler(axis * _constraints.y);     }      return rot; }

И итоговый удобный скрипт выглядит как:

public class RotateObjectController : MonoBehaviour {     [SerializeField] private Transform _leverRoot;     [SerializeField] private Axis _localAxis;     [SerializeField] private float _startValue;     [SerializeField] private Vector2 _constraints;      private Quaternion _lastRotation;     private float _value;      public float Value => _value;     public float NormalizedValue => (_value - _constraints.x) / (_constraints.y - _constraints.x);          private void Start()     {         _value =  Mathf.Clamp(_startValue, _constraints.x, _constraints.y);         _leverRoot.localRotation = Quaternion.Euler(GetAxis(_localAxis) * _value);         _lastRotation = _leverRoot.localRotation;     }      private void MoveLever(Vector3 worldPosition)     {         var localPosition = _leverRoot.parent.InverseTransformPoint(worldPosition);         var leverRootLocalPosition = _leverRoot.localPosition;         var localAxis = GetAxis(_localAxis);         var plane = new Plane(localAxis, leverRootLocalPosition);         var pos = plane.ClosestPointOnPlane(localPosition);         var rotation = Quaternion.LookRotation(                 localAxis,                 (pos - leverRootLocalPosition).normalized);         _value += CalculateValueDelta(rotation, localAxis);         _leverRoot.localRotation = ClampAngle(rotation, localAxis);         _lastRotation = _leverRoot.localRotation;     }      private Quaternion ClampAngle(Quaternion rot, Vector3 axis)     {         if (_value <= _constraints.x)         {             _value = _constraints.x;             return Quaternion.Euler(axis * _constraints.x);         }         if (_value >= _constraints.y)         {             _value = _constraints.y;             return Quaternion.Euler(axis * _constraints.y);         }              return rot;     }      private float CalculateValueDelta(Quaternion rotation, Vector3 axis)     {         return Vector3.SignedAngle(_lastRotation * Vector3.up, rotation * Vector3.up, axis);     }        private Vector3 GetAxis(Axis axis)     {         switch (axis)         {             case Axis.x:                 return new Vector3(1, 0, 0);             case Axis.z:                 return new Vector3(0, 0, 1);         }         return new Vector3();     } }

Но в VR тестировать всё не особо удобно, поэтому напишем ещё управление к данному рычагу с помощью мыши. Для этого введём интерфейс IWorldDraggable:

public interface IWorldDraggable {     void OnDrag(Vector3 worldPosition); }

И имплементируем его в наш скрипт:

using UnityEngine;  public class RotateObjectController : MonoBehaviour, IWorldDraggable {     [SerializeField] private Transform _leverRoot;     [SerializeField] private Axis _localAxis;     [SerializeField] private float _startValue;     [SerializeField] private Vector2 _constraints;      private Quaternion _lastRotation;     private float _value;      public float Value => _value;     public float NormalizedValue => (_value - _constraints.x) / (_constraints.y - _constraints.x);          private void Start()     {         _value =  Mathf.Clamp(_startValue, _constraints.x, _constraints.y);         _leverRoot.localRotation = Quaternion.Euler(GetAxis(_localAxis) * _value);         _lastRotation = _leverRoot.localRotation;     }      private void MoveLever(Vector3 worldPosition)     {         var localPosition = _leverRoot.parent.InverseTransformPoint(worldPosition);         var leverRootLocalPosition = _leverRoot.localPosition;         var localAxis = GetAxis(_localAxis);         var plane = new Plane(localAxis, leverRootLocalPosition);         var pos = plane.ClosestPointOnPlane(localPosition);         var rotation = Quaternion.LookRotation(                 localAxis,                 (pos - leverRootLocalPosition).normalized);         _value += CalculateValueDelta(rotation, localAxis);         _leverRoot.localRotation = ClampAngle(rotation, localAxis);         _lastRotation = _leverRoot.localRotation;     }      public void OnDrag(Vector3 worldPosition){       MoveLever(worldPosition);     }     private Quaternion ClampAngle(Quaternion rot, Vector3 axis)     {         if (_value <= _constraints.x)         {             _value = _constraints.x;             return Quaternion.Euler(axis * _constraints.x);         }         if (_value >= _constraints.y)         {             _value = _constraints.y;             return Quaternion.Euler(axis * _constraints.y);         }              return rot;     }      private float CalculateValueDelta(Quaternion rotation, Vector3 axis)     {         return Vector3.SignedAngle(_lastRotation * Vector3.up, rotation * Vector3.up, axis);     }        private Vector3 GetAxis(Axis axis)     {         switch (axis)         {             case Axis.x:                 return new Vector3(1, 0, 0);             case Axis.z:                 return new Vector3(0, 0, 1);         }         return new Vector3();     }

Удобнее всего на мой взгляд написать реализацию управления через EventSystem не забыв предварительно добавить его в сцену и PhysicsRaycaster на нашу камеру:

 [RequireComponent(typeof(IWorldDraggable))]   public class PointerWorldDragHandler : MonoBehaviour, IDragHandler   {       private IWorldDraggable _worldDraggable;       private void Awake()       {           _worldDraggable = GetComponent<IWorldDraggable>();       }        public void OnDrag(PointerEventData eventData)       {           if (eventData.pointerCurrentRaycast.gameObject == null) return;           _worldDraggable.OnDrag(eventData.pointerCurrentRaycast.worldPosition);       }   }

А управление в VR реализуется уже аналогичным образом в зависимости от используемого вами SDK.

В заключении

Вот мы и сделали наш рычаг или винт. С демо сценой и полным кодом проекта вы можете ознакомиться в репозитории. И возможно в каком-то проекте данная реализация под рукой будет вам полезна. Кстати, а почему не использовать джоинты и Unity физику. Зачем так сложно, ведь там готовы ограничения вращения и т.п. Короткий ответ опять-таки кроется в VR. С этим неудобно работать, потому что в VR при вращении у игрока нет тактильного отклика, и он не сможет держать руку на рычаге и реально вращать рычаг. Для удобства управления при зажатии курка мы «применяем руку игрока» к объекту, а потом уже графика руки крепится на рычаге, а положение реальной руки проецируется на плоскость вращения рычага. Пока игрок не отпустит курок джойстика.

Если вам интересны новости Unity разработки и в целом тема Unity — подписывайтесь на мой блог в телеграм. Я публикую там интересные новости и обзоры на них, свои мысли про бизнес, про фриланс и про разработку. В общем там много интересного. Спасибо за внимание!


ссылка на оригинал статьи https://habr.com/ru/articles/792918/


Комментарии

Добавить комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *