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

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

d0f65dd7dda63acb2237e88c448e4390.png

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

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

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

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

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

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

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

5b7f9f4970e6611823792a77f8484973.png

В чём заключается наша задача «Сделать рычаг»? По своей сути нам нужен объект в 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();
      }

      public void OnDrag(PointerEventData eventData)
      {
          if (eventData.pointerCurrentRaycast.gameObject == null) return;
          _worldDraggable.OnDrag(eventData.pointerCurrentRaycast.worldPosition);
      }
  }

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

В заключении

b4eb3ecba75abae2a5ad86da1aa2ef0f.png

Вот мы и сделали наш рычаг или винт. С демо сценой и полным кодом проекта вы можете ознакомиться в репозитории. И возможно в каком-то проекте данная реализация под рукой будет вам полезна.

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

© Habrahabr.ru