Всем привет! Меня зовут Григорий Дядиченко, и я разрабатываю разные проекты на заказ. Сегодня хотелось бы поговорить про рычаги и винты, и их реализацию в 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 - подписывайтесь на мой блог в телеграм. Я публикую там интересные новости и обзоры на них, свои мысли про бизнес, про фриланс и про разработку. В общем там много интересного. Спасибо за внимание!