Когда-то давно, еще во времена Unity 3, мне стало интересно как работает физика персонажа. И я заглянул в класс CharacterMotor. Класс был написан на JavaScript, был огромный, страшный и непонятный. Я решил переписать его на C#, попутно отрефакторив. Недавно я вспомнил про свой старый CharacterMotor, решил еще немного подправить его и поделиться им. Тем более, тема физики персонажа не очень популярная (я вообще не видел никакой информации), хотя довольно интересная.
1. Введение
2. CharacterController
3. CharacterMotor
4. CharacterMotor_Movement
5. CharacterMotor_Jumping
6. FPSInputController
7. MouseLook
8. Заключение
1. Введение
Я взял за основу CharacterMotor из Unity 3. В Unity 4 CharacterMotor не менялся, а вот в Unity 5, это абсолютно новый класс. Новый CharacterMotor, значительно уменьшился в коде, и видимо, и в функционале. Я не особо разбирался в нем и почти не использовал его, но заметил, что скольжение с крутого склона теперь не работает, и вообще качество кода мне не понравилось. Видимо писался он на скорую руку. Также Unity 5 перешел на новый PhysX 3, но в CharacterController я никаких изменений не заметил. Так что, думаю, мой CharacterMotor не устарел.
2. CharacterController
Обычно персонаж не является обычным физическим объектом и работает по своим законам физики. В Unity для персонажа используется CharacterController. Это комбинация коллайдера в форме капсулы и метода Move. Метод Move двигает персонажа в указанную позицию, обрабатывая при этом коллизию. Обработка коллизии тут тоже не обычная, например, коллизия обрабатывается так, чтобы персонаж мог свободно подниматься на небольшие склоны, но не мог на большие, или auto stepping - фича, которая позволяет персонажу подниматься на маленькие препятствия. CharacterController не совместим с RigidBody, это значит, что он не может взаимодействовать с другими физическими объектами. Это создает некоторые проблемы: персонаж не может двигать другой физический объект, а другой объект не может сдвинуть персонажа. Кинематические объекты вообще свободно проходят сквозь персонажа, что делает проблематичным создание лифтов и движущихся платформ.
3. CharacterMotor
Вместо RigidBody мы должны использовать свой класс, в Unity это CharacterMotor. Хотя, CharacterMotor — это намного больше, чем RigidBody. CharacterMotor отвечает за любые движения нашего персонажа, например: ходьба, бег, скольжение, падение, прыжки, движение на платформе, на лифте, подъем по вертикальной лестнице, плавание. Все это реализуется, фактически, с помощью различных ухищрений, а не законов физики. Ведь персонажи обычно ведут себя не по законам, например: персонаж может немного управляться во время прыжка или падения, или просто резко останавливаться или менять направление движения. В Painkiller Дэниел мог ускоряться, просто прыгая без разбега. Конечно это все не сильно нарушает законов игровой физики, и можно было бы попытаться сделать максимально все физически корректно, но ведь проще просто ограничить скорость падения, чем вычислять сопротивление воздуха.
Я разделил мой CharacterMotor на 3 partial класса: CharacterMotor, CharacterMotor_Movement и CharacterMotor_Jumping. В оригинальном классе был еще класс отвечающий за движение на платформе, но я убрал его.
Базовая часть CharacterMotor реализует следующие:
- Событие FixedUpdate, в котором вызываются все функции, необходимые для вычисления скорости. В Оригинальном классе можно было выбирать: использовать Update или FixedUpdate, но я убрал это т.к. движение игрока в FixedUpdate выглядит вполне плавно и требует меньше вычислительной нагрузки.
- Событие OnControllerColliderHit, в котором определяется стоит ли персонаж на земле или нет. Здесь есть одна маленькая деталь, персонаж не может встать на землю, если он находится в состоянии прыжка и его скорость направлена вверх. Это нужно для того, чтобы когда игрок прыгает на крутой склон и касается его, то прыжок продолжался.
- ApplyDownOffset и CancelDownOffset. Иногда персонаж может отрываться от земли. Чтобы предотвратить это, мы как бы применяем дополнительную гравитацию. Метод ApplyDownOffset смещает позицию персонажа вниз. Если после этого смещения персонаж все же не коснулся земли, то это смещение нужно отменить методом CancelDownOffset. Благодаря этому персонаж может с разбегу перейти на очень крутой склон, не оторвавшись от земли. Но можно поспорить хорошо это или плохо, ибо парой это выглядит не реалистично и странно. Возможно будет лучше уменьшить смещение.
+ Показать
− Скрыть
using UnityEngine;
using System.Collections;
using System.Collections.Generic;
[AddComponentMenu ("Character/CharacterMotor")]
[RequireComponent (typeof(CharacterController))]
public partial class CharacterMotor : MonoBehaviour {
public CharacterController controller {
get; private set;
}
internal Vector3 velocity;
internal Collider ground;
internal Vector3 groundPoint, groundNormal;
internal Vector3 inputMoveDirection; // направление движения
internal bool inputAcceleration; // ускоренное движение
internal bool inputJump; // прыжок
internal bool inputJumpHolding; // усиленный прыжок
public bool isGrounded {
get {
return ground != null;
}
}
public bool isSliding {
get {
return isGrounded && groundNormal.y <= Mathf.Cos( controller.slopeLimit * Mathf.Deg2Rad );
}
}
void Awake() {
controller = GetComponent<CharacterController>();
}
void OnEnable() {
velocity = controller.velocity;
}
private void FixedUpdate() {
ApplyMoving();
ApplyGravity();
ApplyJumping();
var prevVelocity = velocity;
Move();
ApplyBraking(prevVelocity, ref velocity);
}
private void Move() {
//Debug.DrawRay( transform.position, velocity, Color.blue );
var offset = ApplyDownOffset();
ground = null;
groundPoint = groundNormal = Vector3.zero;
controller.Move( velocity * Time.deltaTime );
velocity = controller.velocity;
CancelDownOffset( offset );
//if(isGrounded) {
// Debug.DrawRay( groundPoint, groundNormal, Color.red );
//}
}
private float ApplyDownOffset() {
// из-за недостаточной гравитации персонаж может отрываться от земли
// для предотвращения этого, применяем смещение вниз
if(isGrounded && !isJumping) {
float downOffset = Mathf.Max( controller.stepOffset, GetXZ( velocity ).magnitude );
velocity -= Vector3.up * downOffset;
return downOffset;
}
return 0;
}
private void CancelDownOffset(float downOffset) {
// если персонаж не коснулся земли, то мы должны отменить смещение
if(!isGrounded && downOffset != 0) {
transform.position += Vector3.up * downOffset * Time.deltaTime; // offset за кадр
velocity += Vector3.up * downOffset; // offset за секунду
}
}
void OnControllerColliderHit(ControllerColliderHit hit) {
//Debug.DrawRay(hit.point, hit.normal, Color.green);
if( !(isJumping && velocity.y > 0) ) { // во время прыжка персонаж не может встать на землю
if(hit.normal.y > float.Epsilon && IsBelowPart(hit.point.y)) {
if(hit.normal.y > groundNormal.y) { // выбираем самую горизонтальную поверхность
ground = hit.collider;
groundPoint = hit.point;
groundNormal = hit.normal;
}
}
}
}
private bool IsBelowPart(float y) {
float bottom = transform.position.y + controller.center.y - controller.height/2 + controller.radius;
return y < bottom;
}
private static Vector3 GetXZ(Vector3 v) {
return new Vector3( v.x, 0, v.z );
}
} |
4. CharacterMotor_Movement
Эта часть класса отвечает за движения и гравитацию, и реализует следующие:
- ApplyMoving. Эта функция отвечает за ходьбу и скольжение по крутому склону. Кстати, двигаться персонаж может и находясь в воздухе. Движение реализуется не силами или импульсами, а прямым изменением скорости с помощью Vector3.MoveTowards. У этого подхода есть свои плюсы и минусы. Главный плюс - мы напрямую задаем нужную нам скорость. Другая особенность - автоматически реализуется сила трения и сила сопротивления воздуха, но плюс это или минус, затрудняюсь сказать т.к. при нормальной симуляции физики, силы суммируются, но в данной реализации, если персонаж уже имеет скорость и при этом еще и сам движется, то обе эти скорости не будут суммироваться. На мой взгляд эта функция - самое спорное место, т.к. по логике вещей, скольжению здесь не место, ведь скольжение происходит под действием гравитации, а не из-за движение игрока.
- GetDesiredVelocity. Функция вычисляет желаемую скорость ходьбы или бега.
- GetDirectionSpeedFactor. Когда игрок идет боком или задом, то его скорость должна быть меньше.
- GetSlopeSpeedFactor. Когда игрок идет вверх по склону, то его скорость тоже уменьшается.
- GetDesiredSlidingVelocity. Функция вычисляет желаемую скорость скольжения. Когда персонаж находится на крутом склоне, то он должен скользить вниз по склону, при этом игрок может немного управлять своим движением.
- AdjustVelocityToGround. Выравнивание вектора скорости вдоль земли. Эта функция должна обеспечить лучшую соприкасаемость персонажа с землей. Но так как этим занимается ApplyDownOffset, то вероятно в этой функции нет никакого смысла.
- ApplyBraking. Когда персонаж упирается в стену, то надо уменьшить его скорость, чтобы он не бежал вдоль стену, уперевшись в нее носом.
- ApplyGravity. Тут все просто и ясно.
+ Показать
− Скрыть
using UnityEngine;
using System.Collections;
partial class CharacterMotor {
private const float Gravity = 20;
private const float MaxFallSpeed = 20;
private const float WalkSpeed = 6;
private const float RunSpeed = 9;
private const float SlidingSpeed = 0.4f;
private const float SlidingSideSpeed = 1.0f;
private const float GroundAcceleration = 20;
private const float AirAcceleration = 5;
private void ApplyMoving() {
Vector3 desiredVelocity;
if(isSliding) {
desiredVelocity = GetDesiredSlidingVelocity();
} else {
desiredVelocity = GetDesiredVelocity();
}
if(isGrounded) {
desiredVelocity = AdjustVelocityToGround( desiredVelocity, groundNormal );
} else {
desiredVelocity.y = velocity.y;
}
velocity = Vector3.MoveTowards( velocity, desiredVelocity, GetAcceleration( isGrounded ) * Time.deltaTime );
}
private void ApplyGravity() {
if(!isJumping) velocity.y = Mathf.Min( velocity.y, 0 ); // auto stepping может толкать персонажа вверх, предотвращаем это.
velocity.y -= Gravity * Time.deltaTime;
velocity.y = Mathf.Max( velocity.y, -MaxFallSpeed );
}
private static void ApplyBraking(Vector3 prevVelocity, ref Vector3 realVelocity) {
// когда Character упирается в стену, он может начать движение вдоль стены
// чтобы предотвратить это, определяем на сколько перпендикулярно Character уперся в стену и тормозим скорость на этот коэфициент
var hPrevVelocity = GetXZ( prevVelocity ); // горизонтальные скорости
var hRealVelocity = GetXZ( realVelocity );
if(hPrevVelocity != Vector3.zero) {
float braking = Vector3.Dot( hRealVelocity, hPrevVelocity ) / hPrevVelocity.sqrMagnitude; // 1 - векторы одинаковые; 0 - векторы разные
braking = Mathf.Clamp01( braking );
realVelocity.x *= braking;
realVelocity.z *= braking;
}
}
private Vector3 GetDesiredVelocity() {
float speed = GetSpeed( inputAcceleration );
speed *= GetDirectionSpeedFactor( transform.forward, inputMoveDirection );
if(isGrounded) speed *= GetSlopeSpeedFactor( controller.velocity );
return inputMoveDirection * speed;
}
private static float GetDirectionSpeedFactor(Vector3 characterDirection, Vector3 moveDirection) {
// если игрок идет задом или боком, то скорость должна быть меньше
float dir = Vector3.Dot( characterDirection, moveDirection.normalized ); // 1 - идем вперед. 0 - боком. -1 - задом.
dir = Mathf.InverseLerp( -1, 1, dir ); // from [-1, 1] to [0, 1]
return Mathf.Lerp( 0.8f, 1, dir ); // to [0.8, 1] backward / sideways, forward
}
private static float GetSlopeSpeedFactor(Vector3 velocity) {
// уменьшаем скорость, когда игрок идет вверх по склону
// velocity само принимает направление склона, в результате вызова Move
float slopeAngle = Mathf.Asin(velocity.normalized.y) * Mathf.Rad2Deg; // 0 - движемся горизонтально
float t = Mathf.InverseLerp( 0, 90, slopeAngle ); // [0, 90] to [0, 1]
return 1 - t;
}
private Vector3 GetDesiredSlidingVelocity() {
Vector3 slopeDir = GetXZ(groundNormal).normalized;
Vector3 sideDir = new Vector3( slopeDir.z, 0, -slopeDir.x );
float slopeDot = Vector3.Dot( inputMoveDirection, slopeDir ); // +-1 - движемся вдоль направления спуска. 0 - перпендикулярно спуску.
float sideDot = Vector3.Dot( inputMoveDirection, sideDir ); // +-1 - движемся перпендикулярно спуску. 0 - вдоль спуска.
var desiredVelocity = slopeDir; // скольжение в направлении спуска
desiredVelocity += slopeDir * slopeDot * SlidingSpeed; // движение в направлении спуска
desiredVelocity += sideDir * sideDot * SlidingSideSpeed; // движение перпендикулярно спуску
// чем круче склон, тем больше скорость
float slopeAngle = Vector3.Angle( groundNormal, Vector3.up ); // 0 - горизонтальная поверхность. 90 - вертикальная стена.
float speed = Gravity * (slopeAngle / 90);
return desiredVelocity * speed;
}
private static Vector3 AdjustVelocityToGround(Vector3 velocity, Vector3 groundNormal) {
// выравниваем вектор скорости вдоль земли
// это обеспечивает лучшую соприкасаемость игрока с землей
// но т.к. этим занимается ApplyDownOffset, то данный метод вероятно не обязателен
return Vector3.ProjectOnPlane( velocity, groundNormal ).normalized * velocity.magnitude;
}
private static float GetSpeed(bool run) {
return run ? RunSpeed : WalkSpeed;
}
private static float GetAcceleration(bool grounded) {
return grounded ? GroundAcceleration : AirAcceleration;
}
} |
5. CharacterMotor_Jumping
Это последняя часть класса, ответственная за прыжок. Она реализует фактически один ApplyJumping, в котором есть пара особенностей:
- Игрок может регулировать высоту прыжка, отпуская или зажимая кнопку.
- Направление прыжка зависит от нормали земли. Причем можно задать степерь влияния земли на обычной поверхности и крутом склоне.
+ Показать
− Скрыть
using UnityEngine;
using System.Collections;
partial class CharacterMotor {
private const float BaseJumpHeight = 0.5f;
private const float ExtraJumpHeight = 0.75f;
private static readonly float baseJumpForce = GetJumpForce( BaseJumpHeight );
// Как сильно нормаль земли влияет на направление прыжка
// 0 - Прыжок вертикальный (нормаль не влияет), 1 - Прыжок перпендикулярно земли (вдоль нормали)
private const float GroundJumpImpactFactor = 0.1f;
private const float SteepGroundJumpImpactFactor = 0.5f; // Когда игрок скользит по крутому спуску
internal bool isJumping;
private float jumpStartTime;
private Vector3 jumpDir;
//private float startY, maxY;
private void ApplyJumping() {
if(isJumping && isGrounded) { // stop jump
isJumping = false;
//Debug.Log( maxY - startY ); // высота прыжка
}
if(inputJump && isGrounded) { // start jump
isJumping = true;
jumpStartTime = Time.time;
jumpDir = Vector3.Slerp( Vector3.up, groundNormal, GetGroundJumpImpactFactor( isSliding ) );
velocity.y = 0;
velocity += jumpDir * baseJumpForce;
//startY = maxY = transform.position.y;
}
//if(isJumping) {
// maxY = Mathf.Max( maxY, transform.position.y );
//}
if(isJumping && inputJumpHolding) { // extra jump
float jumpHoldingTime = Time.time - jumpStartTime;
if(jumpHoldingTime < ExtraJumpHeight / baseJumpForce) {
velocity += jumpDir * Gravity * Time.deltaTime;
}
}
}
private static float GetJumpForce(float targetJumpHeight) {
return Mathf.Sqrt( 2 * Gravity * targetJumpHeight );
}
private static float GetGroundJumpImpactFactor(bool sliding) {
return sliding ? SteepGroundJumpImpactFactor : GroundJumpImpactFactor;
}
} |
Чтобы сделать полноценного, управляемого персонажа, нам нужно еще пара вспомогательных классов. Первый – обрабатывает ввод. Этот класс очень простой, но в нем есть две особенности:
- Персонаж может прыгнуть, если кнопка прыжка была нажата в последние 0.2 секунды. Это сделано для того, чтобы игрок мог не ловить момент, когда персонаж коснется земли, а мог нажимать кнопку чуть раньше.
- Используя аналоговые джойстики, игрок может регулировать скорость движения персонажа. Скорость с джойстика находится в промежутке от 0 до 1. Это скорость возводится в квадрат, чтобы игрок мог лучше управлять маленькой скоростью или что-то типа того. Т.е. если игрок сдвинул джойстик лишь на 0.5, то скорость будет 0.25. А если джойстик на значении 1, то и будет 1. Сам я это не тестировал, и не уверен удобно ли это вообще.
Кстати, FPS расшифровывается, по идее, как first person shooter.
+ Показать
− Скрыть
using UnityEngine;
using System.Collections;
[AddComponentMenu( "Character/FPSInputController" )]
[RequireComponent( typeof(CharacterMotor) )]
public class FPSInputController : MonoBehaviour {
private CharacterMotor motor;
private float jumpPressedTime = -100;
private bool prevJumping = false;
void Awake () {
motor = GetComponent<CharacterMotor>();
}
void Update () {
if( Input.GetButtonDown("Jump") ) jumpPressedTime = Time.time;
if( !Input.GetButton("Jump") ) jumpPressedTime = -100;
if( !prevJumping && motor.isJumping ) jumpPressedTime = -100; // чтобы предотвратить двойной прыжок
prevJumping = motor.isJumping;
motor.inputMoveDirection = GetInputMoveDirection();
motor.inputJump = Time.time - jumpPressedTime <= 0.2f; // кнопка была нажата в последние 0.2 секунды
motor.inputJumpHolding = Input.GetButton( "Jump" );
motor.inputAcceleration = Input.GetKey( KeyCode.LeftShift );
}
private Vector3 GetInputMoveDirection() {
var h = Input.GetAxis("Horizontal");
var v = Input.GetAxis("Vertical");
Vector3 direction = new Vector3(h, 0, v);
if (direction != Vector3.zero) {
// Делаем input dir более чувствительным к крайним значениям и менее чувствительна к средним.
// Это позволит легче контролировать медленную скоростью при использовании аналогового джойстика
float len = direction.magnitude;
len = Mathf.Min(1, len);
len = len * len;
direction = direction.normalized * len;
return transform.TransformDirection(direction);
}
return Vector3.zero;
}
} |
7. MouseLook
Это последний и простейший класс, ответственный за вращение камеры. Точнее, камера вращается только по оси X, а по оси Y вращается весь персонаж.
+ Показать
− Скрыть
using UnityEngine;
using System.Collections;
[AddComponentMenu( "Character/MouseLook" )]
public class MouseLook : MonoBehaviour {
public enum Axis {
X, Y, XY
}
public Axis axis = Axis.XY;
private const float Sensitivity = 5f;
private const float MinY = -88f, MaxY = 88f;
private Vector2 angles = Vector2.zero;
void OnEnable() {
angles = transform.localEulerAngles;
}
void Update () {
if(Cursor.visible) return;
float x = Input.GetAxis("Mouse X");
float y = Input.GetAxis("Mouse Y");
Vector2 delta = new Vector2(-y, x);
angles += delta * Sensitivity;
angles.x = Mathf.Clamp(angles.x, MinY, MaxY);
if(axis == Axis.Y) angles.x = transform.localEulerAngles.x;
if(axis == Axis.X) angles.y = transform.localEulerAngles.y;
Quaternion targetRotation = Quaternion.Euler(angles);
transform.localRotation = Quaternion.Slerp( transform.localRotation, targetRotation, 40 * Time.deltaTime );
}
} |
8. Заключение
Я переписал эти юнити скрипты практически до неузнаваемости, что-то добавил, что-то удалил, но тем не менее принципиально ничего не изменилось. Оригинальные скрипты для Unity 4 и 5, вы можете посмотреть в корне репозитория. Код, надеюсь, я довел до нормального состояния. Но, как я сказал, принципиально ничего не изменилось, то некоторые вещи мне не нравятся. Также есть еще много вещей, которые можно было бы добавить. Поэтому я буду стараться обновлять репозиторий, и буду рад вашим комментариям и предложениям.
Заметил пару багов. Когда CharacterController стоит на сфере, то нормаль поверхности сферы направлена внутрь сферы. Из-за этого персонаж не понимает, что он стоит на земле. На втором видео, в момент 3:07, видно странное разрешение коллизии, которое приводит к тому, что CharacterController резко толкается вниз, когда напрыгивает на такую платформу.
Официальный гид по CharacterController: http://docs.nvidia.com/gameworks/content/gameworkslibrary/physx/g… trollers.html
Репозиторий: https://bitbucket.org/dddenisss/unity-character-motor