WheelColider – это компонент Unity3D, базирующийся на функционале физического движка PhysX, созданный специально, чтобы симулировать поведение автомобилей.
Множество игр базируются на физике автомобилей, например серия игр GTA или NFS. Так же, во многих играх транспорт является важным добавлением игровой механики – серия игр Battlefield, например. И, естественно, многие разработчики хотят добавить физику автомобилей в свой проект. Как раз для этого созданы WheelCollider’ы. Казалось бы, что может пойти нет так?
С самых первых версий Unity3D разработчики ведут неравный бой с WheelCollider’ами. Изначально проблема состояла из двух факторов:
– С одной стороны, WheelCollider’ы устроены очень примитивно, базовый функционал в Unity3D доступен ограничено.
– С другой стороны, на деле, кроме колеса и подвески нужно еще много чего написать. Объем работ, нередко, невероятно огромен.
Базовые проблемы – машина обладает низкой стабильностью поведения. В первую очередь – постоянно переворачивается. В этой статье я собрал коллекцию базовых техник, необходимых, чтобы решить проблему переворотов.
Эта статья состоит из нескольких частей:
– Перевод оригинальной статьи с сайта Unity3d.com о создании автомобиля на WheelCollider’ах.
– Трабл-шутинг, устраняем проблемы, «чтобы поехало хоть как-то»
– Несколько способов\методов\лучших практик, позволяющих сделать поведение машины на WheelCollider’ах более-менее приемлемым.
– Сборка машинки на основе очень кривой модели.
Перевод: Создание простого скелета
- Сначала, добавьте GameObject, который будет служить базой автомобиля. Чтобы сделать это, нажмите GameObject > Create Empty. Смените имя GameObject’а на car_root.
- Добавьте компонент Rigidbody на car_root. Стандартное значение массы в 1 кг слишком легкое для стандартных настроек подвески; установите массу на 1500 кг.
- Далее, создаем Collider автомобиля. GameObject > 3D Object > Cube. Расположите куб под car_root в иерархии. Сбросьте параметры компонента Transform на 0 чтобы идеально выровнять куб в локальном пространстве. Автомобиль сориентирован параллельно оси Z (Z+ это направление вперед), поэтому меняем параметр Z Scale на значение 3.
- Добавьте колеса к базе автомобиля. Выберите car_root и кликните по нему правой клавишей, далее Create Empty Child. Поменяйте название на wheels. Сбросьте параметры Transform на этом объекте. Этот GameObject не обязателен, но позже он поможет для настройки и отладки.
- Чтобы создать первое колесо, выберите wheels GameObject, правая клавиша > Create Empty, назовите новый объект frontLeft. Сбросьте Transform, а потом установите координаты: X на –1, Y на 0, и Z to Чтобы добавить колайдер, в меню инспектора нажмите Add component > Physics > Wheel Collider.
- Продублируйте GameObject frontLeft (правая клавиша > Duplicate). Измените координаты X c -1 на 1. Смените имя объекта на frontRight.
- Выберите frontLeftand frontRight GameObject’ы. Продублируйте их. Измените координаты Z на обоих GameObject’ах на –1. Измените имена на rearLeft и rearRight.
- Наконец, выберите car_root приподнимите его над поверхностью земли.
У вас должно получится что-то типа этого:
Чтобы машиной можно было управлять, вам нужно написать контроллер для неё:
using UnityEngine;
using System.Collections;
using System.Collections.Generic;
public class SimpleCarController : MonoBehaviour {
public List<AxleInfo> axleInfos; // информация о каждой оси
public float maxMotorTorque; // максимальный крутящий момент
public float maxSteeringAngle; // максимальный угол поворота колес
public void FixedUpdate()
{
float motor = maxMotorTorque * Input.GetAxis("Vertical");
float steering = maxSteeringAngle * Input.GetAxis("Horizontal");
foreach (AxleInfo axleInfo in axleInfos) {
if (axleInfo.steering) {
axleInfo.leftWheel.steerAngle = steering;
axleInfo.rightWheel.steerAngle = steering;
}
if (axleInfo.motor) {
axleInfo.leftWheel.motorTorque = motor;
axleInfo.rightWheel.motorTorque = motor;
}
}
}
}
[System.Serializable]
public class AxleInfo {
public WheelCollider leftWheel;
public WheelCollider rightWheel;
public bool motor; // присоединено ли колесо к мотору?
public bool steering; // поворачивает ли это колесо?
}
Создайте новый скрипт C# и назовите его SimpleCarController. Скопируйте в новый скрипт текст скрипта выше и сохраните его. Далее добавьте его на базу автомобиля (Add Component > New Script на car_root). Вы можете попробовать разные настройки и протестировать их. Такие настройки SimpleCarController очень эффективны:

У вас может быть до 20 колес на одной машине. Далее, добавим колеса. Как видите, Wheel Collider не добавляет визуальную часть колеса, поэтому нужно еще чуть-чуть кода.
Вам нужна геометрия колеса. Вы можете сделать простое колесо с помощью цилиндров. Есть несколько способов добавить визуальную часть колеса: добавить визуальную часть в параметры скрипта или написать скрипт, который будет автоматически находить и присваивать визуальную часть. Мы воспользуемся вторым вариантом. Добавьте визуальную часть колес под GameObject’ы Wheel Collider’ов.
Далее, изменяем скрипт SimpleCarController:
using UnityEngine;
using System.Collections;
using System.Collections.Generic;
[System.Serializable]
public class AxleInfo {
public WheelCollider leftWheel;
public WheelCollider rightWheel;
public bool motor;
public bool steering;
}
public class SimpleCarController : MonoBehaviour {
public List<AxleInfo> axleInfos;
public float maxMotorTorque;
public float maxSteeringAngle;
// находит визуальную часть колес
// устанавливает новые координаты
public void ApplyLocalPositionToVisuals(WheelCollider collider)
{
if (collider.transform.childCount == 0) {
return;
}
Transform visualWheel = collider.transform.GetChild(0);
Vector3 position;
Quaternion rotation;
collider.GetWorldPose(out position, out rotation);
visualWheel.transform.position = position;
visualWheel.transform.rotation = rotation;
}
public void FixedUpdate()
{
float motor = maxMotorTorque * Input.GetAxis(“Vertical”);
float steering = maxSteeringAngle * Input.GetAxis(“Horizontal”);
foreach (AxleInfo axleInfo in axleInfos) {
if (axleInfo.steering) {
axleInfo.leftWheel.steerAngle = steering;
axleInfo.rightWheel.steerAngle = steering;
}
if (axleInfo.motor) {
axleInfo.leftWheel.motorTorque = motor;
axleInfo.rightWheel.motorTorque = motor;
}
ApplyLocalPositionToVisuals(axleInfo.leftWheel);
ApplyLocalPositionToVisuals(axleInfo.rightWheel);
}
}
}
Трабл-шутинг
- Нужно проверить настройки инпута. Есть небольшой, но реальный шанс, что инпут обнулился при создании проекта. Edit > Project Settings… > Input. Конкретно нас интересуют оси Horizontal и Vertical, должно быть, как на картинки снизу.
- Колеса слишком высоко, по итогу колеса не касаются земли > сдвинуть колеса по Y координатам на 0.5 юнита ниже (т.е. Y = -0.5)
- Не выставлены значения в SimpleCarController > значение maxMotorTorque = 400
Лучшие практики, устранение проблем
1. Колеса смотрят непонятно куда
В двух словах нужно использовать пустой GameObject как базу для колеса, а затем саму модель нужно повернуть на 90 градусов по Z. Не забудьте присвоить базу колеса в SimpleCarController. В реальной ситуации колесо всегда состоит из нескольких моделей, так что изменения иерархии не избежать. Далее эта часть будет усложнятся – будем добавлять тормозные диски и суппорта.
using UnityEngine;
using System.Collections;
using System.Collections.Generic;
[System.Serializable]
public class AxleInfo
{
public WheelCollider leftWheel;
public GameObject leftWheelVisuals;
public WheelCollider rightWheel;
public GameObject rightWheelVisuals;
public bool motor;
public bool steering;
public void ApplyLocalPositionToVisuals()
{
//left wheel
if (leftWheelVisuals == null)
{
return;
}
Vector3 position;
Quaternion rotation;
leftWheel.GetWorldPose(out position, out rotation);
leftWheelVisuals.transform.position = position;
leftWheelVisuals.transform.rotation = rotation;
//right wheel
if (rightWheelVisuals == null)
{
return;
}
rightWheel.GetWorldPose(out position, out rotation);
rightWheelVisuals.transform.position = position;
rightWheelVisuals.transform.rotation = rotation;
}
}
public class SimpleCarController : MonoBehaviour
{
public List<AxleInfo> axleInfos;
public float maxMotorTorque;
public float maxSteeringAngle;
public void FixedUpdate()
{
float motor = maxMotorTorque * Input.GetAxis("Vertical");
float steering = maxSteeringAngle * Input.GetAxis("Horizontal");
foreach (AxleInfo axleInfo in axleInfos)
{
if (axleInfo.steering)
{
axleInfo.leftWheel.steerAngle = steering;
axleInfo.rightWheel.steerAngle = steering;
}
if (axleInfo.motor)
{
axleInfo.leftWheel.motorTorque = motor;
axleInfo.rightWheel.motorTorque = motor;
}
axleInfo.ApplyLocalPositionToVisuals();
}
}
}
2. Машину трясет при наборе скорости, Машина переворачивается – как увеличить стабильность?
- Поднять точку приложения силы колеса до середины колеса – в нашем случае ForceAppPointDistance = 0.5 (в настройках WheelCollider’а)
- Правильное, корректное поведение пружины – под нагрузкой пружина пытается разжаться до длинны в состоянии «без нагрузки». Тем не менее, стандартные настройки почему-то выставлены на середину хода подвески. TargetPosition = 0
- Стабилизаторы. Что-то типа стабилизаторов поперечной устойчивости. Суть стабилизаторов поперечной устойчивости – когда машина кренится, колесо, с противоположной стороны от направления крена, приподнимается выше. Таким образом машина кренится меньше. В нашем случае, мы добавляем стабилизирующие внешние силы. Мощность стабилизаторов следует выставлять в диапазоне от 0 до жесткости пружин (WheelCollider > Suspencion Spring > Spring).
- Меньше мощность стабилизаторов – больше крен в поворотах. И еще момент: ситуация в которой автомобиль с ходом подвески в полметра и диаметром колес в один метр переворачивается на ровном месте – это нормальное, адекватное поведение автомобиля. Существует т.н. moose test, как раз, чтобы проверить подобное поведение.
Как считать:
Рассчитываем сжатие подвески в процентах слева и справа
-> сравниваем сжатия (левое сжатие минус правое)
-> знак результата сравнения дает направление приложения силы стабилизации (минус или плюс)
-> домнажаем то, что получилось на мощность стабилизатора
-> присваиваем в центр колеса.
Так же, есть смысл корректировать точку присваивания силы в соответствии с ForceAppPointDistance.
using UnityEngine;
using System.Collections;
using System.Collections.Generic;
[System.Serializable]
public class AxleInfo
{
public WheelCollider leftWheel;
public GameObject leftWheelVisuals;
private bool leftGrounded = false;
private float travelL = 0f; //дистанция от полного «разжатия» до нынешнего положения, проценты
public WheelCollider rightWheel;
public GameObject rightWheelVisuals;
private bool rightGrounded = false;
private float travelR = 0f; //дистанция от полного «разжатия» до нынешнего положения, проценты
public bool motor;
public bool steering;
public float Antiroll = 10000; //жесткость стабилизатора
private float AntrollForce = 0;
public void ApplyLocalPositionToVisuals()
{
//left wheel
if (leftWheelVisuals == null)
{
return;
}
Vector3 position;
Quaternion rotation;
leftWheel.GetWorldPose(out position, out rotation);
leftWheelVisuals.transform.position = position;
leftWheelVisuals.transform.rotation = rotation;
//right wheel
if (rightWheelVisuals == null)
{
return;
}
rightWheel.GetWorldPose(out position, out rotation);
rightWheelVisuals.transform.position = position;
rightWheelVisuals.transform.rotation = rotation;
}
public void CalculateAndApplyAntiRollForce(Rigidbody theBody) //рассчитываем стабилизаторы
{
WheelHit hit;
// с начала процент сжатия подвески
leftGrounded = leftWheel.GetGroundHit(out hit);
if (leftGrounded)
travelL = (-leftWheel.transform.InverseTransformPoint(hit.point).y - leftWheel.radius) / leftWheel.suspensionDistance;
else
travelL = 1f;
rightGrounded = rightWheel.GetGroundHit(out hit);
if (rightGrounded)
travelR = (-rightWheel.transform.InverseTransformPoint(hit.point).y - rightWheel.radius) / rightWheel.suspensionDistance;
else
travelR = 1f;
// Сила, которая будет отдана стабилизатором
AntrollForce = (travelL - travelR) * Antiroll; // (travelL-travelR) даст нам знак для следующего действия
//присваиваем силы
if (leftGrounded)
theBody.AddForceAtPosition(leftWheel.transform.up * -AntrollForce, leftWheel.transform.position);
if (rightGrounded)
theBody.AddForceAtPosition(rightWheel.transform.up * AntrollForce, rightWheel.transform.position
}
}
body body;
private void Start()
{
body = GetComponent<Rigidbody>();
}
public void FixedUpdate()
{
float motor = maxMotorTorque * Input.GetAxis("Vertical");
float steering = maxSteeringAngle * Input.GetAxis("Horizontal");
foreach (AxleInfo axleInfo in axleInfos)
{
if (axleInfo.steering)
{
axleInfo.leftWheel.steerAngle = steering;
axleInfo.rightWheel.steerAngle = steering;
}
if (axleInfo.motor)
{
axleInfo.leftWheel.motorTorque = motor;
axleInfo.rightWheel.motorTorque = motor;
}
axleInfo.ApplyLocalPositionToVisuals();
axleInfo.CalculateAndApplyAntiRollForce(body);
}
}
}
3. Когда автомобиль поворачивает, все колеса идут по разным траекториям.
Аккерман. (на русском нет статьи на вики, тем не менее). Для того, чтобы автомобиль не заносило (а в нашем случае – еще и не переворачивало), нужно, чтобы колеса рулевого управления поворачивали в соответствии с геометрией рулевого управления Аккермана. К сожалению, WheelCollider’ы устроены так, что при определенных углах проскальзывания колеса сила стремится к бесконечности – в этот момент машина пытается перевернутся. Чтобы снизить шанс переворота, нужно написать симуляцию Аккермана. ackermanSteering = 0 означает, что колеса поворачивают параллельно, 1 означает, что Аккерман присваивается в полной мере, в соответствии с положением колес\осей относительно друг друга.
using UnityEngine; using System.Collections; using System.Collections.Generic; [System.Serializable] public class AxleInfo { public WheelCollider leftWheel; public GameObject leftWheelVisuals; private bool leftGrounded = false; private float travelL = 0f; private float leftAckermanCorrectionAngle = 0; public WheelCollider rightWheel; public GameObject rightWheelVisuals; private bool rightGrounded = false; private float travelR = 0f; private float rightAckermanCorrectionAngle = 0;
public bool motor; public bool steering; public float Antiroll = 10000; private float AntrollForce = 0; public float ackermanSteering = 1f; public void ApplyLocalPositionToVisuals() { //left wheel if (leftWheelVisuals == null) { return; } Vector3 position; Quaternion rotation; leftWheel.GetWorldPose(out position, out rotation); leftWheelVisuals.transform.position = position; leftWheelVisuals.transform.rotation = rotation; //right wheel if (rightWheelVisuals == null) { return; } rightWheel.GetWorldPose(out position, out rotation); rightWheelVisuals.transform.position = position; rightWheelVisuals.transform.rotation = rotation; } public void CalculateAndApplyAntiRollForce(Rigidbody theBody) { WheelHit hit; leftGrounded = leftWheel.GetGroundHit(out hit); if (leftGrounded) travelL = (-leftWheel.transform.InverseTransformPoint(hit.point).y - leftWheel.radius) / leftWheel.suspensionDistance; else travelL = 1f; rightGrounded = rightWheel.GetGroundHit(out hit); if (rightGrounded) travelR = (-rightWheel.transform.InverseTransformPoint(hit.point).y - rightWheel.radius) / rightWheel.suspensionDistance; else travelR = 1f; AntrollForce = (travelL - travelR) * Antiroll; if (leftGrounded) theBody.AddForceAtPosition(leftWheel.transform.up * -AntrollForce, leftWheel.transform.position); if (rightGrounded) theBody.AddForceAtPosition(rightWheel.transform.up * AntrollForce, rightWheel.transform.position); } public void CalculateAndApplySteering (float input, float maxSteerAngle, List<AxleInfo> allAxles) { //first find farest axle, we got to apply default values AxleInfo farestAxle = allAxles[0]; //calculate start point for checking float farestAxleDistantion = ((allAxles[0].leftWheel.transform.localPosition - allAxles[0].rightWheel.transform.localPosition) / 2f).z; for (int a = 0; a < allAxles.Count; a++) { float theDistance = ((allAxles[a].leftWheel.transform.localPosition - allAxles[a].rightWheel.transform.localPosition)/ 2f).z; // if we found axle that farer - save it if (theDistance < farestAxleDistantion) { farestAxleDistantion = theDistance; farestAxle = allAxles[a]; } } float wheelBaseWidth = (Mathf.Abs( leftWheel.transform.localPosition.x) + Mathf.Abs( rightWheel.transform.localPosition.x))/2; float wheelBaseLength = Mathf.Abs( ((farestAxle.leftWheel.transform.localPosition + farestAxle.rightWheel.transform.localPosition)/ 2f).z) + Mathf.Abs(((leftWheel.transform.localPosition + rightWheel.transform.localPosition) / 2f).z); float angle = maxSteerAngle * input; //ackerman implementation float turnRadius = Mathf.Abs(wheelBaseLength * Mathf.Tan(Mathf.Deg2Rad * (90 - Mathf.Abs(angle)))); if (input != 0) { //right wheel if (angle > 0) {//turn right rightAckermanCorrectionAngle = Mathf.Rad2Deg * Mathf.Atan(wheelBaseLength / (turnRadius - wheelBaseWidth / 2f)); rightAckermanCorrectionAngle = (rightAckermanCorrectionAngle - Mathf.Abs(angle)) * ackermanSteering + (Mathf.Abs(angle)); rightAckermanCorrectionAngle = Mathf.Sign(angle) * rightAckermanCorrectionAngle; } else {//turn left rightAckermanCorrectionAngle = Mathf.Rad2Deg * Mathf.Atan(wheelBaseLength / (turnRadius + wheelBaseWidth / 2f)); rightAckermanCorrectionAngle = (rightAckermanCorrectionAngle - Mathf.Abs(angle)) * ackermanSteering + (Mathf.Abs(angle)); rightAckermanCorrectionAngle = Mathf.Sign(angle) * rightAckermanCorrectionAngle; } //left wheel if (angle > 0) {//turn right leftAckermanCorrectionAngle = Mathf.Rad2Deg * Mathf.Atan(wheelBaseLength / (turnRadius + wheelBaseWidth / 2f)); leftAckermanCorrectionAngle = (leftAckermanCorrectionAngle - Mathf.Abs(angle)) * ackermanSteering + (Mathf.Abs(angle)); leftAckermanCorrectionAngle = Mathf.Sign(angle) * leftAckermanCorrectionAngle; } else {//turn left leftAckermanCorrectionAngle = Mathf.Rad2Deg * Mathf.Atan(wheelBaseLength / (turnRadius - wheelBaseWidth / 2f)); leftAckermanCorrectionAngle = (leftAckermanCorrectionAngle - Mathf.Abs(angle)) * ackermanSteering + (Mathf.Abs(angle)); leftAckermanCorrectionAngle = Mathf.Sign(angle) * leftAckermanCorrectionAngle; } } else { rightAckermanCorrectionAngle = 0f; leftAckermanCorrectionAngle = 0f; } leftWheel.steerAngle = leftAckermanCorrectionAngle; rightWheel.steerAngle = rightAckermanCorrectionAngle; Debug.Log(leftAckermanCorrectionAngle + " " + rightAckermanCorrectionAngle); } } [RequireComponent(typeof(Rigidbody))] public class SimpleCarController : MonoBehaviour { public List<AxleInfo> axleInfos; public float maxMotorTorque; public float maxSteeringAngle; private Rigidbody body; private void Start() { body = GetComponent<Rigidbody>(); } public void FixedUpdate() { float motor = maxMotorTorque * Input.GetAxis("Vertical"); foreach (AxleInfo axleInfo in axleInfos) { if (axleInfo.steering) { axleInfo.CalculateAndApplySteering(Input.GetAxis("Horizontal"), maxSteeringAngle, axleInfos); } if (axleInfo.motor) { axleInfo.leftWheel.motorTorque = motor; axleInfo.rightWheel.motorTorque = motor; } axleInfo.ApplyLocalPositionToVisuals(); axleInfo.CalculateAndApplyAntiRollForce(body); } } }
4. Настройка частоты обновления колайдеров колеса
Значения могу быть разными, зависят от конкретной настройки fixedTimeStep. В общем случае уменьшение fixedTimeStep (т.е. увеличение количества раз обработки физики в секунду) улучшает ситуацию, НО, низкие значения fixedTimeStep драматично и жестко грузят процессор. Нужно найти «золотую середину» между адекватным поведением автомобиля и нагрузкой на процессор. В данном случае fixedTimeStep = 0.02. Значения меньше 0.033 считаются очень маленькими.
using UnityEngine;
using System.Collections;
using System.Collections.Generic;
[System.Serializable]
public class AxleInfo
{
public WheelCollider leftWheel;
public GameObject leftWheelVisuals;
private bool leftGrounded = false;
private float travelL = 0f;
private float leftAckermanCorrectionAngle = 0;
public WheelCollider rightWheel;
public GameObject rightWheelVisuals;
private bool rightGrounded = false;
private float travelR = 0f;
private float rightAckermanCorrectionAngle = 0;
public bool motor;
public bool steering;
public float Antiroll = 10000;
private float AntrollForce = 0;
public float ackermanSteering = 1f;
public void ApplyLocalPositionToVisuals()
{
//left wheel
if (leftWheelVisuals == null)
{
return;
}
Vector3 position;
Quaternion rotation;
leftWheel.GetWorldPose(out position, out rotation);
leftWheelVisuals.transform.position = position;
leftWheelVisuals.transform.rotation = rotation;
//right wheel
if (rightWheelVisuals == null)
{
return;
}
rightWheel.GetWorldPose(out position, out rotation);
rightWheelVisuals.transform.position = position;
rightWheelVisuals.transform.rotation = rotation;
}
public void CalculateAndApplyAntiRollForce(Rigidbody theBody)
{
WheelHit hit;
leftGrounded = leftWheel.GetGroundHit(out hit);
if (leftGrounded)
travelL = (-leftWheel.transform.InverseTransformPoint(hit.point).y – leftWheel.radius) / leftWheel.suspensionDistance;
else
travelL = 1f;
rightGrounded = rightWheel.GetGroundHit(out hit);
if (rightGrounded)
travelR = (-rightWheel.transform.InverseTransformPoint(hit.point).y – rightWheel.radius) / rightWheel.suspensionDistance;
else
travelR = 1f;
AntrollForce = (travelL – travelR) * Antiroll;
if (leftGrounded)
theBody.AddForceAtPosition(leftWheel.transform.up * -AntrollForce, leftWheel.transform.position);
if (rightGrounded)
theBody.AddForceAtPosition(rightWheel.transform.up * AntrollForce, rightWheel.transform.position);
}
public void CalculateAndApplySteering (float input, float maxSteerAngle, List<AxleInfo> allAxles)
{
//first find farest axle, we got to apply default values
AxleInfo farestAxle = allAxles[0];
//calculate start point for checking
float farestAxleDistantion = ((allAxles[0].leftWheel.transform.localPosition – allAxles[0].rightWheel.transform.localPosition) / 2f).z;
for (int a = 0; a < allAxles.Count; a++)
{
float theDistance = ((allAxles[a].leftWheel.transform.localPosition – allAxles[a].rightWheel.transform.localPosition)/ 2f).z;
// if we found axle that farer – save it
if (theDistance < farestAxleDistantion)
{
farestAxleDistantion = theDistance;
farestAxle = allAxles[a];
}
}
float wheelBaseWidth = (Mathf.Abs( leftWheel.transform.localPosition.x) + Mathf.Abs( rightWheel.transform.localPosition.x))/2;
float wheelBaseLength = Mathf.Abs( ((farestAxle.leftWheel.transform.localPosition + farestAxle.rightWheel.transform.localPosition)/ 2f).z) +
Mathf.Abs(((leftWheel.transform.localPosition + rightWheel.transform.localPosition) / 2f).z);
float angle = maxSteerAngle * input;
//ackerman implementation
float turnRadius = Mathf.Abs(wheelBaseLength * Mathf.Tan(Mathf.Deg2Rad * (90 – Mathf.Abs(angle))));
//38.363
if (input != 0)
{
//right wheel
if (angle > 0)
{//turn right
rightAckermanCorrectionAngle = Mathf.Rad2Deg * Mathf.Atan(wheelBaseLength / (turnRadius – wheelBaseWidth / 2f));
rightAckermanCorrectionAngle = (rightAckermanCorrectionAngle – Mathf.Abs(angle)) * ackermanSteering + (Mathf.Abs(angle));
rightAckermanCorrectionAngle = Mathf.Sign(angle) * rightAckermanCorrectionAngle;
}
else
{//turn left
rightAckermanCorrectionAngle = Mathf.Rad2Deg * Mathf.Atan(wheelBaseLength / (turnRadius + wheelBaseWidth / 2f));
rightAckermanCorrectionAngle = (rightAckermanCorrectionAngle – Mathf.Abs(angle)) * ackermanSteering + (Mathf.Abs(angle));
rightAckermanCorrectionAngle = Mathf.Sign(angle) * rightAckermanCorrectionAngle;
}
//left wheel
if (angle > 0)
{//turn right
leftAckermanCorrectionAngle = Mathf.Rad2Deg * Mathf.Atan(wheelBaseLength / (turnRadius + wheelBaseWidth / 2f));
leftAckermanCorrectionAngle = (leftAckermanCorrectionAngle – Mathf.Abs(angle)) * ackermanSteering + (Mathf.Abs(angle));
leftAckermanCorrectionAngle = Mathf.Sign(angle) * leftAckermanCorrectionAngle;
}
else
{//turn left
leftAckermanCorrectionAngle = Mathf.Rad2Deg * Mathf.Atan(wheelBaseLength / (turnRadius – wheelBaseWidth / 2f));
leftAckermanCorrectionAngle = (leftAckermanCorrectionAngle – Mathf.Abs(angle)) * ackermanSteering + (Mathf.Abs(angle));
leftAckermanCorrectionAngle = Mathf.Sign(angle) * leftAckermanCorrectionAngle;
}
}
else
{
rightAckermanCorrectionAngle = 0f;
leftAckermanCorrectionAngle = 0f;
}
leftWheel.steerAngle = leftAckermanCorrectionAngle;
rightWheel.steerAngle = rightAckermanCorrectionAngle;
}
}
[RequireComponent(typeof(Rigidbody))]
public class SimpleCarController : MonoBehaviour
{
public List<AxleInfo> axleInfos;
public float maxMotorTorque;
public float maxSteeringAngle;
private Rigidbody body;
private void Start()
{
body = GetComponent<Rigidbody>();
for (int a = 0; a < axleInfos.Count;a++)
{
axleInfos[a].leftWheel.ConfigureVehicleSubsteps(5, 12, 15);
axleInfos[a].rightWheel.ConfigureVehicleSubsteps(5, 12, 15);
}
}
public void FixedUpdate()
{
float motor = maxMotorTorque * Input.GetAxis(“Vertical”);
foreach (AxleInfo axleInfo in axleInfos)
{
if (axleInfo.steering)
{
axleInfo.CalculateAndApplySteering(Input.GetAxis(“Horizontal”), maxSteeringAngle, axleInfos);
}
if (axleInfo.motor)
{
axleInfo.leftWheel.motorTorque = motor;
axleInfo.rightWheel.motorTorque = motor;
}
axleInfo.ApplyLocalPositionToVisuals();
axleInfo.CalculateAndApplyAntiRollForce(body);
}
}
}
Как правильно добавить графику и модели
1. Для начала нужна модель автомобиля. В общем случае, есть 3 варианта где её достать: заказать у специалиста по игровым 3D моделям, купить/скачать с Asset store, скачать с стороннего сайта.
Нужно иметь ввиду, что модели, «выдраные» из игр нельзя использовать в комерческих играх. Так же, стоит обратить внимание, что модели для рендеренга видео и модели из google sketchup не подходят принципиально из-за их внутренней структуры. В общем случае, при всех равных условия, подобные модели требуют в 2 раза больше ресурсов компьютера.
Для тестов мы воспользуемся сайтом gamemodels.ru. Это сайт, на котором модеры (люди, занимающиеся модифициями игр) выкладывают модели, «выдраные» из всевозможных игр. Ссылка на используемую модель
2. Создайте папку Models в ассетах игры. Распакуйте архив, и добавьте содержимое в папку Models
3. Зайдите в папку и перетащите файл “r34” на сцену. Должно получится так:
Данная модель «выдрана» через рендерер – т.е. спец программа просто копирует геометрию, которая сохранена в видеопамяти. Посему, в модели нет иерархии, нет названий.
4. В иерархии жмем правой клавишей на объект r34 и жмем «Unpack prefab». Это нужно, чтобы можно было корректировать иерархию, названия, добавлять скрипты.
5. Под объектом r34 нужно создать пустой объект, назвать его Wheels, убедится, что этот объект находится на координатах 0.
6. В Scene view кликаем на колеса и диски – перетаскиваем их под объект Wheels
7. Внимание! Кликаем на колесо, смотрим координаты. Колесо, вроде, находится на дальней стороне автомобиля. И, вроде, гизмо контроля положения в его центре. А вот координаты почему-то на нуле. Это нормально – реальный центр колеса находится в центре автомобиля. Чтобы решить эту проблему создаем 3 пустых объекта на каждое колесо – один для WheelCollider’а, второй для визуальной части колеса, третий для калиперов. Далее, чтобы быстрее\проще их разместить можно воспользоватся изометрическим видом. Для этого кликните на одну из осей в правом верхнем углу Scene View. Чтобы вернутся в вид перспективы – кликните на сам центральный белый кубик. Координаты центров колес: FR(0.78, 0.32399, 1.399); FL(-0.78, 0.32399, 1.399); RL(-0.78, 0.32399, -1.26), RR(0.78, 0.32399, -1.26).
8. Переименовываем колеса в соответствии с иерархией, использованной в начале статьи: корневой объект называем в соответствии с положением (например FR_Wheel это front-right wheel, переднее правое колесо). Копируем, добавляем Visual (например, FR_WheelVisual). Добавляем под базовый объект покрышки, диски и тормозные диски. Называем покрышки Tire, тормозные диски называем BrakeDisc. Для тормозных скоб (калиперов) создаем копии базового объекта, называем в соответствии с положение – FR_Caliper и оставляем их на том же уровне иерархии, что базовые объекты колеса, добавляем под них соответствующий калипер. Должно получится вот так:
9. Создаем пустой объект под r34, назваем его Body. Перетаскиваем под него всю крупную геометрию автомобиля, в т.ч. стекла фар. На этом скриншоте я отключил отображение геометрии, которую нужно отправить под объект Body, т.е. видно только то, что трогать не надо.
10. Выделяем все объекты под Body. Добавляем компонент MeshCollider (на панеле инспектора AddComponent -> MeshCollider) и жмем галочку Convex. Должно получится так:
Суть: если нужно, чтобы сталкивались два колайдера произвольной геометрии, то один из них должен быть «Convex». Такие колайдеры генерируются используя точки, максимально удаленные от центра модели.
В идеале геометрия, используемая колайдерами\физикой должна быть создано отдельно, с помощью специального плагина. Но, в этом уроке мы используем то, что у нас есть.
11. На коренной объект (r34) добавляем скрипт, контролирующий автомобиль.
12. На пустые объекты XX_Wheel добавляем WheelCollider’ы
13. Если запустить игру, как только WheelColider’ы касаются земли, машина улетает за горизонт. Причина в том, что WheelColider’ы взаимодействуют с колайдерами кузова автомобиля И в Rigidbody не выставлен параметр массы. Для того, чтобы решить эту проблему, нужно настроить слои объектов и настроить Collision Matrix (матрицу столкновений), чтобы объекты разных слоев не сталкивались. Кликаем по корневому объекту автомобиля (r34). На инспекторе, сверху слева Layer – Add Layer… Называем новый слой Car. Опять выбираем корневой объект автомобиля (r34) и там же выбираем слой Car. В диалоговом окне спросят хотим ли мы присовить все объекты иерархии к этому слою. Выбираем «yes, change children».
Повторяем операцию с объектом Wheels, новый слой будет называться Wheel.
Далее Edit -> Project Settings… -> Physics
В матрице столкновений отключаем взаимодействие слоев Car и Wheel, должно выглядеть так:
И выставляем в Rigidbody Mass = 1500.
14. Выставляем параметры WheelCollider’ов: Radius = 0.315; ForceAppPointDistance = 0.315; TargetPosition = 0;
15. Корректируем управляющий скрипт, для работы с калиперами
using UnityEngine;
using System.Collections;
using System.Collections.Generic;
[System.Serializable]
public class AxleInfo
{
public WheelCollider leftWheel;
public GameObject leftWheelVisuals;
public GameObject leftWheelVisualsCalliper;
private bool leftGrounded = false;
private float travelL = 0f;
private float leftAckermanCorrectionAngle = 0;
public WheelCollider rightWheel;
public GameObject rightWheelVisuals;
public GameObject rightWheelVisualsCalliper;
private bool rightGrounded = false;
private float travelR = 0f;
private float rightAckermanCorrectionAngle = 0;
public bool motor;
public bool steering;
public float Antiroll = 10000;
private float AntrollForce = 0;
public float ackermanSteering = 1f;
public void ApplyLocalPositionToVisuals()
{
//left wheel
if (leftWheelVisuals == null)
{
return;
}
Vector3 position;
Quaternion rotation;
leftWheel.GetWorldPose(out position, out rotation);
leftWheelVisuals.transform.position = position;
leftWheelVisuals.transform.rotation = rotation;
leftWheelVisualsCalliper.transform.position = position;
float angles = rotation.eulerAngles.y;
leftWheelVisualsCalliper.transform.rotation = Quaternion.Euler(0f, angles, 0f);
//right wheel
if (rightWheelVisuals == null)
{
return;
}
rightWheel.GetWorldPose(out position, out rotation);
rightWheelVisuals.transform.position = position;
rightWheelVisuals.transform.rotation = rotation;
rightWheelVisualsCalliper.transform.position = position;
angles = rotation.eulerAngles.y;
rightWheelVisualsCalliper.transform.rotation = Quaternion.Euler(0f, angles, 0f);
}
public void CalculateAndApplyAntiRollForce(Rigidbody theBody)
{
WheelHit hit;
leftGrounded = leftWheel.GetGroundHit(out hit);
if (leftGrounded)
travelL = (-leftWheel.transform.InverseTransformPoint(hit.point).y - leftWheel.radius) / leftWheel.suspensionDistance;
else
travelL = 1f;
rightGrounded = rightWheel.GetGroundHit(out hit);
if (rightGrounded)
travelR = (-rightWheel.transform.InverseTransformPoint(hit.point).y - rightWheel.radius) / rightWheel.suspensionDistance;
else
travelR = 1f;
AntrollForce = (travelL - travelR) * Antiroll;
if (leftGrounded)
theBody.AddForceAtPosition(leftWheel.transform.up * -AntrollForce, leftWheel.transform.position);
if (rightGrounded)
theBody.AddForceAtPosition(rightWheel.transform.up * AntrollForce, rightWheel.transform.position);
}
public void CalculateAndApplySteering (float input, float maxSteerAngle, List<AxleInfo> allAxles)
{
//first find farest axle, we got to apply default values
AxleInfo farestAxle = allAxles[0];
//calculate start point for checking
float farestAxleDistantion = ((allAxles[0].leftWheel.transform.localPosition - allAxles[0].rightWheel.transform.localPosition) / 2f).z;
for (int a = 0; a < allAxles.Count; a++)
{
float theDistance = ((allAxles[a].leftWheel.transform.localPosition - allAxles[a].rightWheel.transform.localPosition)/ 2f).z;
// if we found axle that farer - save it
if (theDistance < farestAxleDistantion)
{
farestAxleDistantion = theDistance;
farestAxle = allAxles[a];
}
}
float wheelBaseWidth = (Mathf.Abs( leftWheel.transform.localPosition.x) + Mathf.Abs( rightWheel.transform.localPosition.x))/2;
float wheelBaseLength = Mathf.Abs( ((farestAxle.leftWheel.transform.localPosition + farestAxle.rightWheel.transform.localPosition)/ 2f).z) +
Mathf.Abs(((leftWheel.transform.localPosition + rightWheel.transform.localPosition) / 2f).z);
float angle = maxSteerAngle * input;
//ackerman implementation
float turnRadius = Mathf.Abs(wheelBaseLength * Mathf.Tan(Mathf.Deg2Rad * (90 - Mathf.Abs(angle))));
//38.363
if (input != 0)
{
//right wheel
if (angle > 0)
{//turn right
rightAckermanCorrectionAngle = Mathf.Rad2Deg * Mathf.Atan(wheelBaseLength / (turnRadius - wheelBaseWidth / 2f));
rightAckermanCorrectionAngle = (rightAckermanCorrectionAngle - Mathf.Abs(angle)) * ackermanSteering + (Mathf.Abs(angle));
rightAckermanCorrectionAngle = Mathf.Sign(angle) * rightAckermanCorrectionAngle;
}
else
{//turn left
rightAckermanCorrectionAngle = Mathf.Rad2Deg * Mathf.Atan(wheelBaseLength / (turnRadius + wheelBaseWidth / 2f));
rightAckermanCorrectionAngle = (rightAckermanCorrectionAngle - Mathf.Abs(angle)) * ackermanSteering + (Mathf.Abs(angle));
rightAckermanCorrectionAngle = Mathf.Sign(angle) * rightAckermanCorrectionAngle;
}
//left wheel
if (angle > 0)
{//turn right
leftAckermanCorrectionAngle = Mathf.Rad2Deg * Mathf.Atan(wheelBaseLength / (turnRadius + wheelBaseWidth / 2f));
leftAckermanCorrectionAngle = (leftAckermanCorrectionAngle - Mathf.Abs(angle)) * ackermanSteering + (Mathf.Abs(angle));
leftAckermanCorrectionAngle = Mathf.Sign(angle) * leftAckermanCorrectionAngle;
}
else
{//turn left
leftAckermanCorrectionAngle = Mathf.Rad2Deg * Mathf.Atan(wheelBaseLength / (turnRadius - wheelBaseWidth / 2f));
leftAckermanCorrectionAngle = (leftAckermanCorrectionAngle - Mathf.Abs(angle)) * ackermanSteering + (Mathf.Abs(angle));
leftAckermanCorrectionAngle = Mathf.Sign(angle) * leftAckermanCorrectionAngle;
}
}
else
{
rightAckermanCorrectionAngle = 0f;
leftAckermanCorrectionAngle = 0f;
}
leftWheel.steerAngle = leftAckermanCorrectionAngle;
rightWheel.steerAngle = rightAckermanCorrectionAngle;
}
}
[RequireComponent(typeof(Rigidbody))]
public class SimpleCarController : MonoBehaviour
{
public List<AxleInfo> axleInfos;
public float maxMotorTorque;
public float maxSteeringAngle;
private Rigidbody body;
private void Start()
{
body = GetComponent<Rigidbody>();
for (int a = 0; a < axleInfos.Count;a++)
{
axleInfos[a].leftWheel.ConfigureVehicleSubsteps(5, 12, 15);
axleInfos[a].rightWheel.ConfigureVehicleSubsteps(5, 12, 15);
}
}
public void FixedUpdate()
{
float motor = maxMotorTorque * Input.GetAxis("Vertical");
foreach (AxleInfo axleInfo in axleInfos)
{
if (axleInfo.steering)
{
axleInfo.CalculateAndApplySteering(Input.GetAxis("Horizontal"), maxSteeringAngle, axleInfos);
}
if (axleInfo.motor)
{
axleInfo.leftWheel.motorTorque = motor;
axleInfo.rightWheel.motorTorque = motor;
}
axleInfo.ApplyLocalPositionToVisuals();
axleInfo.CalculateAndApplyAntiRollForce(body);
}
}
}
16. Колеса ходят «восьмерками»
Причина в том, что изначальная модель вырезана с небольшим отрицательным развалом колес. Поправляем модельки калиперов и самого колеса, на левой стороне ставим RotationZ = 4.5; на правой RotationZ = -4.5;
Восьмерка хоть и пропала, но колесо дергает вверх-вниз. Чтобы как-то сгладить ситуацию нужно подвинуть визуальные части колеса немного ниже, до Y = -0.269.
Так же, хочу обратить ваше внимание, что подобного рода проблемы возникают всегда, вне зависимости от качества базовых моделей\ассетов. Если вы хотите, чтобы Ваша система физики автомобиля была жизнеспособна, нужно учитывать подобного рода проблемы.
17. Настройка посадки
Ставим на всех WheelCollider’ах позицию Y на 0.47, а SuspencionDistance на 0.2.
Да, много работы 🙂
Но, в общем, если пользоваться «нормальными» моделями автомобилей, процесс гораздо быстрее.
Так и до бесконечности не далеко 🙂