Unity, физика, работа с Joint и не только

Я продолжаю изучать Unity на одном из платных курсов платформы Skillbox. Несколько модулей в нем отведено изучению использования встроенного физического «движка». Интересные возможности для реализации Ваших механик представляет работа с Joint. В Unity можно задать некоторые варианты взаимодействия предметов, связав их с помощью различных Joint между собой. Их существует 5 типов для разных случаев взаимодействия. Подробно назначение всех параметров описано в документации по Unity, например, для версии 2021 docs.unity3d.com. Сразу покажу видео фрагмент действий в «песочнице», а далее детально разберём что и как настроить и запрограммировать.

Fixed Joint

Это самый простой вариант соединения объектов. Выставленные по местам объекты должны иметь элемент Rigidbody. Одному из объектов добавляем Fixed Joint, а другой устанавливаем в качестве присоединенного тела в соответствующее поле. Смотри рис. 1:

Рис. 1 Настройка Fixed Joint

Если теперь перемещать зелёный блок, то красный будет двигаться синхронно на постоянном расстоянии. При желании в ходе игры разорвать это соединение под действием других объектов нужно установить значения break полей. Можно программно изменить положение красного блока относительно зелёного сохраняя взаимосвязь блоков. На видео видно, что красный блок движется вправо-влево тогда, когда поворачивается стрела в соответствии с логикой в коде ниже:

using System.Collections;
using System.Collections.Generic;
using UnityEngine;
using UnityEngine.UI;

public class FixedCubeMove : MonoBehaviour
{
    [SerializeField] private float Speed = 2f;

    private FixedJoint fj;
    private Vector3 startAnchor; 
    private Vector3 startPos;
    private bool IsMove = true;
    private float deltaY = 0;
    // Start is called before the first frame update
    void Start()
    {
        fj = GetComponent<FixedJoint>();
        startAnchor = fj.anchor;
        startPos = transform.position;
    }

    // Update is called once per frame
    void FixedUpdate()
    {
        float hor = Input.GetAxis("Horizontal");
        if (Input.GetKey("z"))
        {
            IsMove = true;

        }
        if (Input.GetKey("x"))
        {
            IsMove = false;

        }
        if (!IsMove)
        {
            if (startAnchor != null)
            {
                fj.autoConfigureConnectedAnchor = false;
                deltaY += hor * Time.fixedDeltaTime * Speed;
                if (deltaY < 0) deltaY = 0;
                if (deltaY > 2.5f) deltaY = 2.5f;

                Vector3 movePos = startAnchor;
                movePos = startPos;
                movePos.x += deltaY;
                movePos.z += deltaY;
                transform.position = movePos;
                fj.autoConfigureConnectedAnchor = true;                
            }
        }
    }
}

При нажатии кнопок горизонтального перемещения координаты x и z вектора позиции меняются в диапазоне [0, 2.5 f].

Spring Joint

Пружинное соединение применено для группы из 4 блоков справа. В видео показан момент нажатия на первый красный блок машиной с попыткой сдвинуть всю группу к синему блоку (стене). Для синего блока стоит галочка IsKinematic, чтобы он был неподвижен и не участвовал в расчетах физического движка. Желтый блок присоединён к синему с помощью spring joint (смотри рис. 2), а также далее красный к жёлтому и самый левый красный к правому красному. Силы инерции машины не хватает и пружина из блоков в какой-то момент начинает отталкивать машину назад. Если блоки освобождаются, то они начинают характерно дрожать, вися в воздухе, и постепенно успокаиваются.

Рис. 2 Пружина из 4 блоков

Параметры пружины определяют её поведение. Чем больше значение Spring и Damper, тем пружина жестче и быстрее «успокаивается».

Hinge Joint

Это соединение напоминает дверную петлю. Объект с таким соединением (зелёный блок) может вращаться по одной из осей (здесь ось Z) вокруг точки прикрепления (смотри рис. 3). В синем кружочке еле видной желтой стрелкой (там две стрелки совмещены в одной) обозначена точка прикрепления блока. А также вокруг точки крепления розовый круг показывает направление и пределы вращения. Если установить галочку UseLimits и выставить значения Min и Max, то вместо круга будет сектор. Значениями Axis (X,Y,Z) можно настроить направление вращения. Если нужно сдвинуть точку вращения, то можно изменить значения (X,Y,Z) Anchor. Чтобы сымитировать дверной доводчик, можно использовать блок переменных UseSpring. Если настроить блок переменных UseMotor, то можно заставить объект вращаться.

Рис. 3 Петлевое соединение 2 блоков

В видео показано, как раскачивается зелёный блок после соударения с шаром машины.

Работа с Joint для шара на цепочке

Шар на цепочке — один из классических примеров применения Hinge Joint. Если нужно получить симуляцию верёвки, то вместо звеньев цепи можно использовать маленькие коричневые цилиндры. На рис. 4 видно как последовательно соединены шар, 10 звеньев а также точка подвеса на стреле. Каждому звену и шару я назначил свой Hinge Joint, в котором он соединён с вышестоящим элементом. Видно, что все эти звенья повёрнуты на 90 градусов друг относительно друга, а стрелки и розовые круги хорошо это иллюстрируют. Во избежании странного поведения нужно Joint’ы подключать снизу вверх. Рекомендуют более низкие звенья делать легче вышестоящих. Но у меня все звенья одного веса по 0.1f, а шар весит 2f и всё выглядит достаточно натурально.

Неадекватное поведение можно заметить при вращении стрелы. Я сделал его т.н. «телепортацией» вместо использования физики. Не смог настроить параметры UseMotor вращения кабины относительно платформы. Когда ещё не было подъёма стрелы, то шар отклонялся под воздействием центробежной силы.

Машина-разрушитель

Модель машины я сделал в Blender как набор из составных частей. Импортировал через формат fbx в Unity и целиком вставил в пустой объект, развернув нужным образом. Далее к отдельным частям добавил соответствующие коллайдеры (Box и Capsule). Вложенные объекты не рекомендуют соединять с помощью Joint во избежании неадекватного поведения. Не всё удалось настроить так, как задумывалось.

Рис. 5 Машина в «сборе»

Для управления используем клавиши W,A,S,D или стрелки. Клавишами Z и X переключаем режим управления движением машины и перемещением стрелы с шаром. К платформе с помощью Hinge Joint (у колёс) присоединены колёса. Используя UseMotor колёс, за счёт их вращения выполнено движение машины вперёд- назад. Видео ниже:

Нажатие клавиш через код двух скриптов преобразуется в скорость (Target Velocity) вращения колёс.

using System.Collections;
using System.Collections.Generic;
using UnityEngine;
using UnityEngine.UI;

public class CarControl : MonoBehaviour
{
    [SerializeField] private WheelMove wheelLF;
    [SerializeField] private WheelMove wheelLB;
    [SerializeField] private WheelMove wheelRF;
    [SerializeField] private WheelMove wheelRB;
    [SerializeField] private CabinMove cabinMove;
    [SerializeField] private WheelRotate wheelRotateLF;
    [SerializeField] private WheelRotate wheelRotateRF;

    private bool IsMove = true;

    // Update is called once per frame
    void Update()
    {
        float hor = Input.GetAxis("Horizontal"), ver = Input.GetAxis("Vertical");
        if (Input.GetKey("z"))
        {
            IsMove = true;
        }
        if (Input.GetKey("x"))
        {
            IsMove = false;
        }

        if (IsMove)
        {
            wheelLB.SetVelosity(ver);
            wheelLF.SetVelosity(ver);
            wheelRB.SetVelosity(ver);
            wheelRF.SetVelosity(ver);
            wheelRotateLF.SetVelosity(hor);
            wheelRotateRF.SetVelosity(hor);
            cabinMove.SetVelosity(0);
        }
        else
        {
            wheelLB.SetVelosity(0);
            wheelLF.SetVelosity(0);
            wheelRB.SetVelosity(0);
            wheelRF.SetVelosity(0);
            cabinMove.SetVelosity(hor);
        }
    }
}

Выше скрипт обработки ввода пользователя. Ниже скрипт управления вращением колёс.

using System.Collections;
using System.Collections.Generic;
using UnityEngine;
using UnityEngine.UI;

public class WheelMove : MonoBehaviour
{
    private HingeJoint hj;
    [SerializeField] private float speed = 400f;

    // Start is called before the first frame update
    void Start()
    {
        hj = GetComponent<HingeJoint>();
    }

    public void SetVelosity(float vel)
    {
        JointMotor jm = hj.motor;
        jm.targetVelocity = vel * speed;
        hj.motor = jm;
    }
}

Чтобы машина двигалась бодро нужно подобать скорость и силу вращения колёс относительно массы машины, массу колёс и параметры трения физического материала. Если поставить большую силу и слабое трение, то можно достичь эффекта скольжения или заноса. Для поворота колес используем ещё один скрипт:

using System.Collections;
using System.Collections.Generic;
using UnityEngine;

public class WheelRotate : MonoBehaviour
{
    [SerializeField] private float maxAngle = 30f;
    private Vector3 startRotation;
    private float delta = 0;

    // Start is called before the first frame update
    void Start()
    {
        startRotation = transform.localRotation.eulerAngles;
    }

    public void SetVelosity(float vel)
    {
        delta = vel * maxAngle;
        Vector3 moveRot = transform.localRotation.eulerAngles;
        moveRot.y = (delta + startRotation.y + 360) % 360;
        moveRot.x = startRotation.x;
        transform.localRotation = Quaternion.Euler(moveRot);
    }
}

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

Вращение кабины и подъём стрелы

На начальном этапе вращение кабины было выполнено аналогично колесам. Но встала проблема с подъёмом опусканием стрелы. Кабина с платформой соединены Hinge Joint. Стрела и гидроцилиндр подъемного «механизма» соединены с кабиной Hinge Joint. Также и рычаг цилиндра соединен со стрелой Hinge Joint. А вот рычаг и цилиндр соединены Fixed Joint и входят коллайдерами друг в друга. При отсутствии любого из элементов конструкция рассыпается или разлетается. Кабина «в сборе» либо не вращалась за счёт UseMotor, либо начинала вращаться платформа при неподвижной кабине. В результате экспериментов пришлось кабину вращать кодом:

using System.Collections;
using System.Collections.Generic;
using UnityEngine;

public class CabinMove : MonoBehaviour
{
    private HingeJoint hj;
    private float force = 500f;
    [SerializeField] private float speed = 50f;
    private Vector3 startRotation;
    private float delta = 0;
    private Rigidbody rb;

    // Start is called before the first frame update
    void Start()
    {
        rb = GetComponent<Rigidbody>();
        hj = GetComponent<HingeJoint>();
        startRotation = transform.localRotation.eulerAngles;
    }

    // Update is called once per frame
    void Update()
    {

    }

    public void SetVelosity(float vel)
    {
        //JointMotor jm = hj.motor;
        //jm.targetVelocity = vel * speed;
        //hj.motor = jm;
        delta += vel * speed;
        //if (rb)
        //{
        //    rb.AddTorque(Vector3.right * delta, ForceMode.Impulse);
        //}
        Vector3 moveRot = startRotation;
        moveRot.y += delta;
        transform.localRotation = Quaternion.Euler(moveRot);
    }
}

Спуск-подъём стрелы реализован по аналогии с перемещением блока в Fixed Joint тоже кодом:

using System.Collections;
using System.Collections.Generic;
using UnityEngine;

public class FixedLeverMove : MonoBehaviour
{
    [SerializeField] private float Speed = 0.01f;
    private FixedJoint fj;
    private Vector3 startAnchor;
    private Vector3 startPos;
    private bool IsMove = true;
    private float deltaY = 0;
    // Start is called before the first frame update
    void Start()
    {
        fj = GetComponent<FixedJoint>();
        startPos = transform.localPosition;
    }

    // Update is called once per frame
    void Update()
    {
        float hor = Input.GetAxis("Horizontal"), ver = Input.GetAxis("Vertical");
        if (Input.GetKey("z"))
        {
            IsMove = true;
            print($"z   IsMove = {IsMove}");
        }
        if (Input.GetKey("x"))
        {
            IsMove = false;
            print($"x   IsMove = {IsMove}");
        }
        if (!IsMove)
        {
            if (startAnchor != null)
            {
                fj.autoConfigureConnectedAnchor = false;
                deltaY += ver * Time.fixedDeltaTime * Speed;
                if (deltaY < -0.018f) deltaY = -0.018f;
                if (deltaY > 0.015f) deltaY = 0.015f;

                Vector3 movePos = startPos;
                movePos.y += deltaY;
                transform.localPosition = movePos;
                fj.autoConfigureConnectedAnchor = true;
            }
        }
    }
}

Здесь пришлось долго подбирать пределы для deltaY. Это вызвано особенностями переноса размеров из Blender и изменением размеров при вложении одних объектов в другие.

Коллайдеры и физические материалы

На рис. 5 видно, что на колёсах применён Capsule коллайдер. Он единственный более-менее подходит для такого случая. Большие выходы за границы Mesh гарантируют странное поведение при взаимодействии с коллайдерами других объектов, так как визуального пересечения нет. Чтобы физические тела объектов взаимодействовали между собой при соприкосновении (столкновении), нужно установить их коллайдерам физические материалы. Смотри рис. 6 ниже:

Рис. 6 Установка физического материала

Для установки ранее созданный материал перетаскиваем в соответствующее поле коллайдера. В параметрах материала всё интуитивно понятно. Чем больше значение Friction, тем сильнее соответствующее трение. Степень отскока определяет Bounciness от 0 до 1. При 0 отскок максимален, а при 1 его нет совсем. Варианты комбинирования параметров для двух материалов разных объектов выбираются в соответствующих ComboBox. Для материала передних колёс я трение поставил побольше, чтобы они при поворотах тянули в сторону.

Коллизии и маска уровней

Коллизии или столкновения объектов — важный приём реализации игровых механик. Тип обработки коллизии задаётся в соответствующем ComboBox в Rigidbody. Для относительно медленных столкновений достаточно варианта Discrete. В случае взаимодействия быстрых объектов особенно с тонкими коллайдерами нужно применять один из Continuous, но каждый из них тратит на вычисления всё больше ресурсов процессора. Для столкновения, например, пули или снаряда с движущимся объектом (игроком или NPC) это оправдано. Но постоянное применение для многих случаев может тормозить игру. Если нужно чтобы взаимодействовали между собой только определённые типы объектов, то им можно задать отдельные слои (уровни). В настройках (Edit -> Project Settings -> Physics) можно разрешить или убрать взаимодействие между разными слоями (уровнями). Смотри рис. 7 ниже:

Рис. 7 Редактирование матрицы слоёв (уровней)

Я добавил 2 своих слоя: Target и Player. По умолчанию все объекты имеют слой Default. Чёрному шару машины я назначил слой Player. Некоторым блокам и шарам другого цвета, столкновения с которыми мне хочется отслеживать, я назначил слой Target. Обработку столкновений я делаю в коде двух скриптов :

using System.Collections;
using System.Collections.Generic;
using UnityEngine;
using UnityEngine.UI;

public class SphereCar : MonoBehaviour
{
    [SerializeField] private Text txtTargets;
    [SerializeField] private LayerMask layerMask;

    private HashSet<string> goNames;

    // Start is called before the first frame update
    void Start()
    {
        goNames = new HashSet<string>();
        PrintTargetCount();
    }

    private void OnCollisionEnter(Collision collision)
    {
        if (((1 << collision.gameObject.layer) & layerMask.value) != 0)
        {
            goNames.Add(collision.gameObject.name);
            PrintTargetCount();
        }
    }

    private void PrintTargetCount()
    {
        txtTargets.text = $"Пройдено целей : {goNames.Count}";
    }
}

В скрипте для шара машины я проверяю, что шар столкнулся с объектом, имеющим слой (уровень) Target. Каждый такой объект имеет уникальное имя (по моей задумке) и, например, для прохождения уровня нужно поразить все цели хотя бы один раз (попасть в них шаром машины). Имена пораженных целей заношу в HashSet. Прогресс отображаю в текстовом поле как число имен в HashSet.

Визуализация попадания шара в цель

Визуально непройденные цели в сцене обозначены желтым пульсирующим знаком вопроса. Если происходит столкновение с шаром, то над целевым объектом улетает вверх красное слово Yes а знак вопроса заменяется на зелёный восклицательный знак. Всё это реализуется следующим кодом :

using System.Collections;
using System.Collections.Generic;
using UnityEngine;

public class Target : MonoBehaviour
{
    [SerializeField] private GameObject prefabExc;
    [SerializeField] private GameObject prefabYes;
    [SerializeField] private GameObject qwest;
    [SerializeField] private LayerMask layerMask;

    private void OnCollisionEnter(Collision collision)
    {
        if (qwest)
        {
            if (((1 << collision.gameObject.layer) & layerMask.value) != 0)
            {
                Vector3 pos = qwest.transform.position;
                pos.y += 2f;
                Instantiate(prefabYes, pos, qwest.transform.rotation);
                Instantiate(prefabExc, qwest.transform.position, qwest.transform.rotation);
                Destroy(qwest);
            }
        }
    }
}

Пример настройки одной из целей на рис. 9 ниже :

Рис. 9 Настройка некоторых параметров и скрипта для одной из целей

layerMask.value это 32-битное число, в котором каждому уровню соответствует один бит. Если он установлен в 1, то уровень «разрешён» в данной маске. В инспекторе при установке значения переменной Layer Mask в ComboBox можно указать несколько уровней при необходимости. Уровень для объекта это число int, имеющее значение номера бита. Поэтому для проверки присутствия уровня столкнувшегося объекта в маске я использую следующую конструкцию:

if (((1 << collision.gameObject.layer) & layerMask.value) != 0). Если уровни совпадают, то уже генерятся два префаба и уничтожается знак вопроса. В результате таким образом визуализируется для игрока попадание шара в очередную цель.

Итоги

Использование физического «движка» для реализации механик игры добавляет ощущение реальности происходящего. Но процесс настройки не всегда прост. В результате это вносит в процесс разработки некоторый элемент творчества и скучать точно не придётся.

P.S. Мои более ранние эксперименты описаны в статье Unity, физика, опыты с Joint.

Обновлено: 07.04.2024 — 10:33

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

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