Как мы оптимизировали Ragdoll анимацию смерти в Unity

Или как легко превратить Ragdoll в AnimationClip.



Всем привет, мы маленькая инди-студия Drunken Monday. На днях выпустили игру, где нужно бегать по арене и крутить вокруг себя здоровенным топором, стараясь попасть по другим игрокам. Хорошо попал — убил.

Чтобы смерть от топора была эффектной, мы использовали обычную ragdoll анимацию, построенную на физике. И всё было хорошо. Поначалу.

А потом, с увеличением количества персонажей и расчетов, игра начала подтормаживать на старых телефонах. Отключали всю физику — получали 50-60 кадров секунду и абсолютную плавность процесса.
Но отказываться от красивых смертей персонажей уже совсем не хотелось.

Можно было озадачить аниматоров. Мы даже почти отдали эту задачу в работу, как пришла мысль: почему бы не записать несколько ragdoll смертей прямо в Unity, и потом просто показывать нужную анимацию?

Смерти получатся разнообразными, аниматоров подключать не нужно, а главное — все будет быстро и красиво.

Что получилось.gif
image

Реализация


Клип анимации в Unity представлен классом AnimationClip, который содержит множество AnimationCurve, определяющего кривую изменений одного конкретного свойства конкретного объекта, например, свойства localPosition.x. Изменения значений свойства относительно времени описываются множеством структур Keyframe.



Идея простая, для каждого свойства каждого объекта персонажа создать кривую анимации AnimationCurve, и каждый кадр сохранять значения этого свойства на кривой. В конце экспортировать созданный AnimationClip через AssetDatabase.CreateAsset.

Для трекинга каждого объекта персонажа создадим класс AnimationRecorderItem, все кривые свойств отслеживаемого объекта будут описаны через словарь где ключ — название свойства, а значения — кривая анимации.

Properties = new Dictionary<string, AnimationCurve> ();
 
Properties.Add ( "localPosition.x", new AnimationCurve () );
Properties.Add ( "localPosition.y", new AnimationCurve () );
Properties.Add ( "localPosition.z", new AnimationCurve () );
 
Properties.Add ( "localRotation.x", new AnimationCurve () );
Properties.Add ( "localRotation.y", new AnimationCurve () );
Properties.Add ( "localRotation.z", new AnimationCurve () );
Properties.Add ( "localRotation.w", new AnimationCurve () );
 
Properties.Add ( "localScale.x", new AnimationCurve () );
Properties.Add ( "localScale.y", new AnimationCurve () );
Properties.Add ( "localScale.z", new AnimationCurve () );

Каждый кадр для всех свойств будут проставляться текущие значения:

Properties["localPosition.x"].AddKey (new Keyframe (time, _animObj.localPosition.x, 0.0f, 0.0f));
Properties["localPosition.y"].AddKey (new Keyframe (time, _animObj.localPosition.y, 0.0f, 0.0f));
Properties["localPosition.z"].AddKey (new Keyframe (time, _animObj.localPosition.z, 0.0f, 0.0f));
 
Properties["localRotation.x"].AddKey (new Keyframe (time, _animObj.localRotation.x, 0.0f, 0.0f));
Properties["localRotation.y"].AddKey (new Keyframe (time, _animObj.localRotation.y, 0.0f, 0.0f));
Properties["localRotation.z"].AddKey (new Keyframe (time, _animObj.localRotation.z, 0.0f, 0.0f));
Properties["localRotation.w"].AddKey (new Keyframe (time, _animObj.localRotation.w, 0.0f, 0.0f));
 
Properties["localScale.x"].AddKey (new Keyframe (time, _animObj.localScale.x, 0.0f, 0.0f));
Properties["localScale.y"].AddKey (new Keyframe (time, _animObj.localScale.y, 0.0f, 0.0f));
Properties["localScale.z"].AddKey (new Keyframe (time, _animObj.localScale.z, 0.0f, 0.0f));

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

Готовый класс AnimationRecorderItem.cs

Также создадим управляющий класс AnimationRecorder.
При начале работы этот скрипт должен пробежаться по всем детям анимируемого объекта и создать для каждого из них экземпляр AnimationRecorder, а также сразу сформировать и запомнить relativePath под которым он будет записан в AnimationClip.

Согласно документации relativePath формируется так:
Path to the game object this curve applies to. The relativePath is formatted similar to a pathname, e.g. «root/spine/leftArm». If relativePath is empty it refers to the game object the animation clip is attached to.

Код будет выглядеть так:

private List<AnimationRecorderItem> _recorders;
 
void Start ()
{
	Configurate ();
}
 
void Configurate ()
{
	_recorders = new List<AnimationRecorderItem> ();
 
	var allTransforms = gameObject.GetComponentsInChildren< Transform > ();
	for ( int i = 0; i < allTransforms.Length; ++i )
	{
		string path = CreateRelativePathForObject ( transform, allTransforms [ i ] );
		_recorders.Add( new AnimationRecorderItem ( path, allTransforms [ i ] ) );
	}
}
 
private string CreateRelativePathForObject ( Transform root, Transform target )
{
	if ( target == root )
	{
		return string.Empty;
	}
 
	string name = target.name;
	Transform bufferTransform = target;
 
	while ( bufferTransform.parent != root )
	{
		name = string.Format ( "{0}/{1}", bufferTransform.parent.name, name );
		bufferTransform = bufferTransform.parent;
	}
	return name;
}

Далее каждый кадр считаем текущее время анимации и записывает текущие значения свойств:

private float _recordingTimer;
private bool _recording = false;
 
void Update ()
{
 
	if ( _recording )
	{
		for ( int i = 0; i < _recorders.Count; ++i )
		{
			_recorders [ i ].AddFrame ( _recordingTimer );
		}
		_recordingTimer += Time.deltaTime;
	}
}

Но функция Update вызывается довольно часто и записывать анимацию каждый кадр довольно избыточно, поэтому ограничим запись.

30 фпс должно быть достаточно для каждого.

Запись будем начинать по нажатию на Пробел.

private const float CAPTURING_INTERVAL = 1.0f / 30.0f;
 
private float _lastCapturedTime;
private float _recordingTimer;
private bool _recording = false;
 
void Update ()
{
	if ( Input.GetKeyDown ( KeyCode.Space ) && !_recording )
	{
		StartRecording ();
		return;
	}
 
	if ( _recording )
	{
		if (_recordingTimer==0.0f||_recordingTimer-_lastCapturedTime>=CAPTURING_INTERVAL)
		{
			for ( int i = 0; i < _recorders.Count; ++i )
			{
				_recorders [ i ].AddFrame ( _recordingTimer );
			}
			_lastCapturedTime = _recordingTimer;
		}
		_recordingTimer += Time.deltaTime;
	}
}
 
public void StartRecording ()
{
	Debug.Log ( "AnimationRecorder recording started" );
	_recording = true;
}

Реализуем экспорт анимации. Создадим объект AnimationClip и заполним собранными значениями.

private void ExportAnimationClip ()
{
	AnimationClip clip = new AnimationClip ();
	for ( int i = 0; i < _recorders.Count; ++i )
	{
		Dictionary<string,AnimationCurve> propertiles = _recorders [ i ].Properties;
		for ( int j = 0; j < propertiles.Count; ++j )
		{
			string name = _recorders [ i ].PropertyName;
			string propery = propertiles.ElementAt ( j ).Key;
			var curve = propertiles.ElementAt ( j ).Value;
			clip.SetCurve ( name, typeof(Transform), propery, curve );
		}
	}
	clip.EnsureQuaternionContinuity ();
 
	string path = "Assets/" + gameObject.name + ".anim";
	AssetDatabase.CreateAsset ( clip, path );
	Debug.Log ( "AnimationRecorder saved to = " + path );
}

Готовый класс AnimationRecorder.cs

И наконец создадим класс-помощник AnimationRecorderRagdollHelper, функцией которого будет остановить анимацию на анимируемом объекте, включить все коллизии, придать объекту ускорение и начать запись анимации. Окончание анимации будем завершать сами. Скрипт начнет работать при запуске сцены, но с заданной задержкой, чтобы не было артефактов из-за инициализации различных объектов.

Готовый класс AnimationRecorderRagdollHelper.cs

Вот и все, вешаем AnimationRecorderRagdollHelper на нашего персонажа, задаем силу удара, и объект, по которому придётся ударить, запускаем сцену — и наблюдаем, как персонаж весело летает по сцене.

Как только холодный труп застынет на земле — жмем Пробел.



Скрипт экспортирует нашу анимацию в корень проекта.



Записываем таким образом по 4-5 анимации для каждого персонажа и включаем их рандомно при смерти.

P. S. Или не совсем рандомно.
Игра мультиплеерная, физика крутится на сервере и оттуда к нам приходит вектор удара. Выбираем анимацию, вектор которой ближе всего к полученному, доворачиваем персонажа в нужную сторону… полетели!

Ссылки


Проект на GitHub

Ролик игры на YouTube с подборкой смертей

Для тех кого заинтересовала игра, ее можно найти в Steam, Google Play, AppStore, Facebook и ВКонтакте.
  • +16
  • 5,4k
  • 6
Поделиться публикацией
Реклама помогает поддерживать и развивать наши сервисы

Подробнее
Реклама
Комментарии 6
  • +3
    1. Неплохо бы указать лицензию на гитхабе.
    2. Можно упростить код, запустив цикл захвата не через Update, а через коротину и делая задержку в бесконечном цикле с нужным fps-ом:
    yield return new WaitForSeconds(1f / fps);
    

    3. Запуск захвата можно делать автоматически, без необходимости нажатия на пробел.
    4. Остановку захвата можно делать автоматически, анализируя состояние флага isSleeping у всех RigidBody на иерархии. Как только все уснут — можно делать дамп анимации и стопать редактор через:
    UnityEditor.EditorApplication.isPlaying = false;
    

    5. Метод Start может быть коротиной, автоматически стартуемой юнити, поэтому AnimationRecorderRagdollHelper можно упростить:
    private IEnumerator Start ()
    {
        yield return new WaitForSeconds ( _startingDelay );
        ....
    

    • 0
      Ну и было бы неплохо WaitForSeconds создать только 1 раз, не плодя лишних объектов в бесконечном цикле.
      • 0
        1. добавлю в ближайшее время.
        по 2-4 согласен, делалось все на скорую руку.
      • 0
        В animator вроде как встроенный record mode есть, можно им записать было.
        • 0
          а как работает коллизия с полом, стенами, другими персонажами, если у вас не рэгдол, а одна большая анимация? или этого нет?
          • 0
            Никак не работает, очевидно. Сделано просто для эффектного полета-падения на плоскую поверхность без новых динамических препятствий.

          Только полноправные пользователи могут оставлять комментарии. Войдите, пожалуйста.