Pull to refresh

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

Level of difficultyEasy
Reading time9 min
Views3.7K

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

Tags:
Hubs:
Total votes 7: ↑6 and ↓1+7
Comments0

Articles