Всем привет, не так давно я перешел на Unigine, и хотел бы поделиться опытом создания системы анимаций персонажа, так как релевантных материалов по теме как-то не нашел, а примеры разработчиков мне показались чересчур сложными. Все примеры будут написаны на C#.

Статья расчитана на людей, которые совсем не знакомы с движком, поэтому распишу поэтапно, как и что нужно делать. Для начала создадим сцену и создадим папку под модели в Asset Browser:

Далее в нужной папке нажимаем ПКМ и в выпадающем меню нажимаем Import New Asset, указываем путь, появляется меню импорта. Оно довольно большое, но по умолчанию все чекбоксы выставлены оптимально, поэтому не вижу смысла на нем останавливаться. Итак, мы импортировали основное тело с риггом и две анимации, которые будем чередовать - idle и walk. Перетащим основной скелет на сцену и посмотрим, что получится.

Отлично, у нас есть меш на сцене и есть пара настроек. В окне Parameters видим тип объекта - ObjectMeshSkinned, а ниже - его данные, такие как mesh, preview animation и прочее. Прямо здесь можно установить анимацию и проиграть ее, для этого в preview animation нажимаем кнопку папки, выбираем модель с анимацией. Затем нажимаем Play.

Но скорее всего вам нужно контролировать анимации из скрипта, поэтому давайте поставим галочку Controlled в параметрах меша, затем создадим компонент AnimController и посмотрим, как можно изменять состояние через код. Для этого в окне Asset Browser нужно выбрать папку, нажать там ПКМ -> Create Code -> C# Component. Открываем появившийся файл в любимом текстовом редакторе или IDE и наблюдаем следующее:

Component(PropertyGuid = "uid компонента")]
public class AnimController : Component
{
  private void Init() {

  }
  private void Update() {
  
  }
}

Если вы знакомы с другими движками, то для вас в этих методах нет ничего необычного - Init() вызывается при инициализации, Update() - каждый кадр. Следующий код был написан по аналогии с примером контроля анимации разработчиков. Для начала нам нужны меш, который мы будем контролировать и анимации, которые будем на него применять:

public ObjectMeshSkinned meshSkinned = null;
[ParameterFile(Filter = ".anim")]
public string idleAnimation = "";
[ParameterFile(Filter = ".anim")]
public string walkAnimation = "";

Теперь повесим этот скрипт на родительскую ноду меша (создается автоматически при перетаскивании модели на сцену), хотя можно и на сам меш - непринципиально. В подменю Node Components And Properties создаем новый компонент и перетаскиваем наш скрипт, должна появиться такая картина:

Здесь нужно перетащить меш на место Mesh Skinned и выбрать нужные анимации, думаю, с этим проблем возникнуть не должно.

Возвращаемся к скрипту. Для начала нужно забиндить слои анимаций. В Unigine нет визуального создания animation state machine, поэтому будем создавать его сами. Для начала зарегистрируем наши idle и walk:

private void Init() {
  // указываем количество слоёв
  meshSkinned.NumLayers = 2;
  // устанавливаем на слои анимации
  meshSkinned.SetAnimation(0, idleAnimation);
  meshSkinned.SetAnimation(1, walkAnimation);
  // включаем слои и устанавливаем им blending веса
  meshSkinned.SetLayer(0, true, 1f);
  meshSkinned.SetLayer(1, true, 0f);
}

Таким образом, наш персонаж научился переключаться на анимацию. Но анимацию нужно еще и проигрывать, а так как в Unigine нет аналогов StartCoroutine или Invoke из Unity (ну, или автор плохо искал), то напишем все руками в Update:

private float currentTime = 0f;
private float animationSpeed = 30f;

private void Update() {
  // устанавливаем для каждого слоя текущий момент
  meshSkinned.SetFrame(0, currentTime * animationSpeed);
  meshSkinned.SetFrame(1, currentTime * animationSpeed);
  // добавляем время между прошлым вызовом Update
  currentTime += Game.IFps;
}

Отлично, теперь анимация будет проигрываться, неплохо бы заиметь функционал, чтобы поменять одну на другую?

void SetWalk(bool walk) {
  if (walk) {
    meshSkinned.SetLayerWeight(1, 1);
    meshSkinned.SetLayerWeight(0, 0);
  }
  else {
    meshSkinned.SetLayerWeight(1, 0);
    meshSkinned.SetLayerWeight(0, 1);
  }
}

Вообще говоря, мы могли бы и не использовать веса, а занимать нулевой слой нужной нам анимацией, тогда необязательно было в Init() вызывать SetLayer(), а переключение выглядело бы так:

void SetWalk(bool walk) {
  if (walk) {
    meshSkinned.SetAnimation(0, walkAnimation);
  }
  else {
    meshSkinned.SetAnimation(0, idleAnimation);
  }
}

Но раз уж мы выбрали реализацию с весами, давайте добавим немного плавности. Как я уже говорил, в Unigine нет асинхронных задачек, поэтому нам придется снова все писать ручками. Я использовал следующий подход:

public delegate void AnimTask(ObjectMeshSkinned ob, float now);

private AnimTask blendingTask = null;
private float now = 0f;

public void SetBlendingTask(AnimTask task, float timeBlend) {
    blendingTask = task;
    now = timeBlend;
}

private void SetToWalk(ObjectMeshSkinned meshSkinned, float now) {
    meshSkinned.SetLayerWeight(1, 1f - now);
    meshSkinned.SetLayerWeight(0, now);
}

private void SetToStand(ObjectMeshSkinned meshSkinned, float now) {
    meshSkinned.SetLayerWeight(0, 1f - now);
    meshSkinned.SetLayerWeight(1, now);
}

Тогда нужно и SetWalk() чуть подправить:

public void SetWalk(bool walk) {
  if (walk) {
    SetBlendingTask(SetToWalk, 0.4f);
  }
  else {
    SetBlendingTask(SetToStand, 0.7f);
  }
}

А в Update добавить изменение значения переменной now и вызов поставленной задачи:

private void Update() {
  /* ... */

  if (now > 0f) {
      now -= Game.IFps;
      if (now <= 0f) { now = 0f; }

      blendingTask(meshSkinned, now);
  }
}

Итак, что мы имеем: у нас есть указатель на функцию, которая изменяет веса слоев, этот указатель устанавливается через функцию SetWalk и вызывается каждый кадр, пока время перехода не станет равно нулю. Время перехода убавляется временем обработки кадра.

В итоге, получаем что-то подобное:

[Component(PropertyGuid = "uid компонента")]
public class AnimController : Component
{
    public delegate void AnimTask(ObjectMeshSkinned ob, float now);

	public ObjectMeshSkinned meshSkinned = null;
    private float animationSpeed = 30f;
    private float currentTime = 0.0f;
    [ParameterFile(Filter = ".anim")]
	public string idleAnimation = "";
    [ParameterFile(Filter = ".anim")]
	public string walkAnimation = "";

    private AnimTask blendingTask = null;
    private float now = 0f;

    private void Init() {
        // указываем количество слоёв
        meshSkinned.NumLayers = 2;
        // устанавливаем на слои анимации
        meshSkinned.SetAnimation(0, idleAnimation);
        meshSkinned.SetAnimation(1, walkAnimation);
        // включаем слои и устанавливаем им blending веса
        meshSkinned.SetLayer(0, true, 1f);
        meshSkinned.SetLayer(1, true, 0f);
    }

    private void Update() {
        meshSkinned.SetFrame(0, currentTime * animationSpeed);
        meshSkinned.SetFrame(1, currentTime * animationSpeed);
        currentTime += Game.IFps;

        if (now > 0f) {
            now -= Game.IFps;
            if (now <= 0f) { now = 0f; }

            blendingTask(meshSkinned, now);
        }
    }

    public void SetWalk(bool walk) {
        if (walk) {
            SetBlendingTask(SetToWalk, 0.4f);
        }
        else {
            SetBlendingTask(SetToStand, 0.7f);
        }
    }
    
    public void SetBlendingTask(AnimTask task, float timeBlend) {
        blendingTask = task;
        now = timeBlend;
    }
    
    private void SetToWalk(ObjectMeshSkinned meshSkinned, float now) {
        meshSkinned.SetLayerWeight(1, 1f - now);
        meshSkinned.SetLayerWeight(0, now);
    }
    
    private void SetToStand(ObjectMeshSkinned meshSkinned, float now) {
        meshSkinned.SetLayerWeight(0, 1f - now);
        meshSkinned.SetLayerWeight(1, now);
    }
}

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

В заключение, хотелось бы пару слов сказать про сам движок. Он мне понравился визуальными возможностями, неплохой производительностью и более-менее понятной документацией, однако, поначалу она была очень неудобной в использовании. Примеры разработчиков часто перегружены, чего только стоит 3д платформер, где компонент управления персонажем занимает почти 700 строк и содержит в своем названии "Simplified", что похоже на злую иронию. В силу понятно каких событий выбор лицензионного ПО ограничен, а Unigine является российской разработкой, поэтому я надеюсь на развитие как самого движка (а он действительно развивается, судя по появляющимся фичам), так и его коммьюнити, ну и надеюсь, что этой статьей я сделал вклад в это самое коммьюнити.

Если у вас есть предложения по реализации контроля анимации не через делегат - буду рад прочитать в комментариях.

листаем SimplifiedPlayerController.cs в 4 утра
листаем SimplifiedPlayerController.cs в 4 утра

Комментарии (4)


  1. Magistr_AVSH
    13.11.2022 10:58
    +1

    1. Для чего нужно переходить на Unigine? Вопрос без подвоха, сам интересуюсь, причем вы явно с юнити переключлись на него (он не очень то уже и российский вообще-то).

    2. В Unigine же есть .NET 6, а значит нормально работающие Task-async-await, и большинство API thread-safe (не уверен насчет анимации) - т.е. анимации сразу можно сделать через таски.


    1. khmheh Автор
      13.11.2022 15:25
      +1

      1. Я давно искал замену юнити, потому что он мне прежде всего не нравится картинкой и быстродействием. Пробовал сначала движки на Rust (лучше всех себя показал rg3d, ныне Fyrox, но он пока даже не в бета-тесте), потом попробовал Flax, но оттолкнуло, что, во-первых, он плохо работает на линуксе (у меня это основная система) а во-вторых, опять же, роялти - непонятно, как мне выплачивать их за границу. Unigine мне очень понравился в плане визуала прежде всего, всякие SSR, SSRTGI и проч. То, что он не очень-то и российский я, видать, упустил, можете ссылку какую-нибудь скинуть, где про это прочитать?
        2. Я не шарпист ни разу, прочитал сейчас про асинхронность в нем и не понял, каким образом мне впихнуть Task-async-await в Update? По идее, мне следует создать асинхронный рантайм (потому что все функции управления, которые мне дает движок синхронные), который будет проходить по всем асинхронным задачам и управлять их выполнением (проверять, закончилась ли задача и чем), но хорошая ли это идея, учитывая, что сам движок управляет кучей потоков в рамках своего управления игрой? Может, правда что-то не улавливаю, можете написать пример?


      1. Magistr_AVSH
        14.11.2022 11:47

        1. Они уехали, и офис в РФ закрыли. Сотрудники я так понимаю в Армении, Дубае и так далее. Но это сейчас обычное дело. По поводу графики и быстродействия - я понял, а проблему адаптации готового контента из ассетстора и т.д., для вас не проблема?

        2. Тут я точно не уверен, я еще не погружался очень подробно в движок (хотя и хочу), насколько я вижу в документации (https://developer.unigine.com/en/docs/latest/code/fundamentals/thread_safety/) анимации видимо Main-loop dependent, так что async-await тут нельзя действительно использовать.
          Но можно реализовать свою версию, аналог https://github.com/Cysharp/UniTask для юнити, которая по сути заменяет корутины (которые лишь старая реализация асинхронности - когда писалась юнити async-await в C# был в зачаточном состоянии)


        1. khmheh Автор
          14.11.2022 12:16

          1. Ассетстором не пользовался, какая там проблема?

          2. Мне легче думать, что раз уж разработчики не предоставляют такой интерфейс, значит так надо (мне лень), но спасибо за наводку, может и правда пригодится.