- PVSM.RU - https://www.pvsm.ru -

Модульные спрайтовые персонажи и их анимация

Эта запись девлога целиком посвящена моей системе анимации персонажей, она наполнена полезными советами и фрагментами кода.

За последние два месяца я создал целых 9 новых действий игрока (такие забавные вещи как блокировка щитом, уворачивание в прыжке и оружие), 17 новых носимых предметов, 3 набора брони (пластинчатый, шёлковый и кожаный) и 6 видов причёсок. Также я завершил создавать всю автоматизацию и инструменты, поэтому всё уже используется в игре. В статье я расскажу, как этого добился!

Модульные спрайтовые персонажи и их анимация - 1

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

Краткое описание

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

Разумеется, такие функции активно используются в 3D- и 2D-играх с пререндеренными спрайтами или в 2D-играх со скелетной анимацией, но насколько я знаю, существует не так много игр, совмещающих созданную вручную анимацию и модульных персонажей (обычно потому, что процесс оказывается слишком монотонным).

Модульные спрайтовые персонажи и их анимация - 2

Я раскопал этот древний GIF моего первого месяца работы с Unity. На самом деле этот модульный спрайт оказался одним из первых моих экспериментов в разработке игр!

Я создал прототип при помощи системы анимаций Unity, а затем для проверки концепции добавил одну рубашку, одну пару штанов, одну причёску и три предмета. Для этого потребовалось 26 отдельных анимаций.

В то время я создавал всю свою анимацию в Photoshop и не заморачивался автоматизацией процесса, поэтому он был очень скучным. Потом я подумал: «Так, основная идея сработала, позже я добавлю новые анимации и снаряжение». Оказалось, что «потом» — это несколько лет спустя.

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

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

Модульные спрайтовые персонажи и их анимация - 3

Модульные спрайтовые персонажи и их анимация - 4

Спойлер: всё получилось замечательно. Ниже я раскрою свои ***секреты***

Модульная система спрайтов

I. Познай свои границы

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

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

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

Аспект отслеживания времени оказался очень важным, и я крайне рекомендую использовать его, чтобы отвечать на вопросы типа: «Сколько врагов могу я позволить себе создать в игре?». Всего после нескольких тестов мне удалось экстраполировать достаточно точную оценку. При дальнейшей работе над анимациями я продолжил следить за временем и пересматривать мои ожидания.

Поделюсь копией моего журнала работы за последние два месяца. Учтите, что это время идёт в добавок к моей обычной работе, где я провожу по 30 часов в неделю:

https://docs.google.com/spreadsheets/d/1Nbr7lujZTB4pWMsuedVcgBYS6n5V-rHrk1PxeGxr6Ck/edit?usp=sharing [1]

II. Смена палитры ради светлого будущего

С умом используя цвета в дизайне спрайтов, можно отрисовать один спрайт и создать множество различных вариаций при помощи смены палитры. Можно менять не только цвета, но и создавать различные включаемые и отключаемые элементы (например, заменой цветов на прозрачность).

Каждый набор брони имеет 3 вариации, а смешивая верхние и нижние части, можно получить множество комбинаций. Я планирую реализовать систему, в которой можно собрать один набор брони для внешнего вида персонажа, а другой — для его характеристик (как в Terraria).

Модульные спрайтовые персонажи и их анимация - 5

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

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

Я не буду объяснять всё в подробностях, а вместо этого расскажу о способах реализации этой техники в Unity, и об их плюсах и минусах.

1. Текстура поиска для каждой палитры

Это наилучшая стратегия для создания вариаций врагов, фонов и всего того, где множество спрайтов иеет одинаковую палитру/материал. Различные материалы нельзя сгруппировать в батчи, даже если они используют одинаковый спрайт/атлас. Работа с текстурами довольно мучительна, но палитры можно изменять в реальном времени, заменяя материалы, с помощью SpriteRenderer.sharedMaterial.SetTexture или MaterialPropertyBlock, если вам нужны разные палитры для каждого экземпляра материала. Вот пример фрагментной функции шейдера:

sampler2D _MainTex;
sampler2D _PaletteTex;
float4 _PaletteTex_TexelSize;
   
half4 frag(v2f input) : SV_TARGET {
 half4 lookup = tex2D(_MainTex, input.uv);
 half4 color = tex2D(_PaletteTex, half2(lookup.r * (_PaletteTex_TexelSize.x / 0.00390625f), 0.5));
 color.a *= lookup.a;
 return color * input.color;
}

2. Массив цветов

Я остановился на этом решении, потому что мне нужно было заменять палитры каждый раз, когда меняется внешний вид персонажа (например, при надевании предметов), и создавать некоторые палитры динамически (чтобы отобразить выбранные игроком цвета волос и кожи). Мне показалось, что во время выполнения и в редакторе для этих целей гораздо проще будет работать с массивами.

Код:

sampler2D _MainTex;
half4 _Colors[32];

half4 frag(v2f input) : SV_TARGET {
 half4 lookup = tex2D(_MainTex, input.uv);
 half4 color = _Colors[round(lookup.r * 255)];
 color.a *= lookup.a;
 return color * input.color;
}

Я представил свои палитры как тип ScriptableObject и использовал для их редактирования инструмент MonoBehaviour. Проработав долгое время над редактированием палитр в процессе создания анимаций в Aseprite, я понял, какие инструменты мне требуются и писал эти скрипты соответствующим образом. Если вы хотите написать собственный инструмент для редактирования палитр, то вот какие функции я обязательно рекомендую реализовать:

— Обновление палитр на различных материалах при редактировании цветов для отображения изменений в реальном времени.

— Присваивание названий и изменение порядка цветов в палитре (используйте поле для хранения индекса цвета, а не его порядка в массиве).

— Выбор и редактирование нескольких цветов за раз. (Совет: поля Color в Unity можно копипастить: просто нажмите на один цвет, скопируйте, нажмите на другой цвет, вставьте — теперь они одинаковы!)

— Применение цвета оверлея ко всей палитре

— Запись палитры в текстуру

3. Единая текстура поиска для всех палитр

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

Во-первых, нужно будет упаковать все палитры в одну большую текстуру. Затем вы используете цвет, заданный в компоненте SpriteRenderer (AKA цвет вершины) для определения строки, которую надо считать из текстуры палитры в шейдер. То есть палитра этого спрайта управляется через SpriteRenderer.color. Цвет вершины — это единственное свойство SpriteRenderer, которое можно менять без нарушения батчинга (при условии, что все материалы одинаковы).

В большинстве случаев лучше всего использовать для управления индексом альфа-канал, потому что вам скорее всего не понадобится куча спрайтов с различной прозрачностью.

Код:

sampler2D _MainTex;
sampler2D _PaletteTex;
float4 _PaletteTex_TexelSize;
   
half4 frag(v2f input) : SV_TARGET {
 half4 lookup = tex2D(_MainTex, input.uv);
 half2 paletteUV = half2(
  lookup.r * _(PaletteTex_TexelSize.x / 0.00390625f),
  input.color.a * _(PaletteTex_TexelSize.y / 0.00390625f)
 )
 half4 color = tex2D(_PaletteTex, paletteUV);
 color.a *= lookup.a;
 color.rgb *= input.color.rgb;
 return color;
}

Модульные спрайтовые персонажи и их анимация - 6

Чудеса замены палитр и слоёв спрайтов. Так много комбинаций.

III. Автоматизируйте всё и применяйте подходящие инструменты

Для реализации этой функции автоматизация была совершенно необходимой, потому что в результате у меня получилось около 300 анимаций и тысячи спрайтов.

Первым моим шагом стало создание экспортёра для Aseprite, чтобы управлять моей безумной схемой слоёв спрайтов при помощи удобного интерфейса командной строки [2]. Это просто скрипт на perl, который обходит все слои и метки в моём файле Aseprite и эспортирует изображения в определённой структуре каталогов и имён, чтобы я смог в дальнейшем их считывать.

Затем я написал импортёр для Unity. Aseprite выводит удобный файл JSON с данными кадров, поэтому можно создавать ассеты анимаций программно. Обработка Aseprite JSON и запись этого типа данных оказались довольно нудными, поэтому я привожу их здесь. Вы можете с лёгкостью загрузить их в Unity с помощью JsonUtility.FromJson<AespriteData>, только не забудьте запустить Aseprite с опцией --format 'json-array'.

Код:

[System.Serializable]
public struct AespriteData {
 [System.Serializable]
 public struct Size {
  public int w;
  public int h;
 }

 [System.Serializable]
 public struct Position {
  public int x;
  public int y;
  public int w;
  public int h;
 }

 [System.Serializable]
 public struct Frame {
  public string filename;
  public Position frame;
  public bool rotated;
  public bool trimmed;
  public Position spriteSourceSize;
  public Size sourceSize;
  public int duration;
 }

 [System.Serializable]
 public struct Metadata {
  public string app;
  public string version;
  public string format;
  public Size size;
  public string scale;
 }


 public Frame[] frames;
 public Metadata meta;
}

На стороне Unity серьёзные проблемы у меня возникли в двух местах: в загрузке/нарезке спрайтшита и в построении клипа анимации. Мне бы очень помог понятный пример, поэтому вот фрагмент кода из моего импортёра, чтобы вы не так мучились:

Код:

TextureImporter textureImporter = AssetImporter.GetAtPath(spritePath) as TextureImporter;
textureImporter.spriteImportMode = SpriteImportMode.Multiple;

SpriteMetaData[] spriteMetaData = new SpriteMetaData[aespriteData.frames.Length];

// Slice the spritesheet according to the aesprite data.
for (int i = 0; i < aespriteData.frames.Length; i++) {
 AespriteData.Position spritePosition = aespriteData.frames[i].frame;

 spriteMetaData[i].name = aespriteData.frames[i].filename;
 spriteMetaData[i].rect = new Rect(spritePosition.x, spritePosition.y, spritePosition.w, spritePosition.h);
 spriteMetaData[i].alignment = (int)SpriteAlignment.Custom; // Same as "Pivot" in Sprite Editor.
 spriteMetaData[i].pivot = new Vector2(0.5f, 0f); // Same as "Custom Pivot" in Sprite Editor. Ignored if alignment isn't "Custom".
}

textureImporter.spritesheet = spriteMetaData;
AssetDatabase.ImportAsset(spritePath, ImportAssetOptions.ForceUpdate);

Object[] assets = AssetDatabase.LoadAllAssetsAtPath(spritePath); // The first element in this array is actually a Texture2D (i.e. the sheet itself).
for (int i = 1; i < assets.Length; i++) {
 sprites[i - 1] = assets[i] as Sprite;
}


// Create the animation.   
AnimationClip clip = new AnimationClip();
clip.frameRate = 40f;
float frameLength = 1f / clip.frameRate;

ObjectReferenceKeyframe[] keyframes = new ObjectReferenceKeyframe[aespriteData.frames.Length + 1]; // One extra keyframe is required at the end to express the last frame's duration.

float time = 0f;
for (int i = 0; i < keyframes.Length; i++) {
 bool lastFrame = i == keyframes.Length - 1;

 ObjectReferenceKeyframe keyframe = new ObjectReferenceKeyframe();
 keyframe.value = sprites[lastFrame ? i - 1 : i];
 keyframe.time = time - (lastFrame ? frameLength : 0f);

 keyframes[i] = keyframe;

 time += lastFrame ? 0f : aespriteData.frames[i].duration / 1000f;
}

EditorCurveBinding binding = new EditorCurveBinding();
binding.type = typeof(SpriteRenderer);
binding.path = "";
binding.propertyName = "m_Sprite";
AnimationUtility.SetObjectReferenceCurve(clip, binding, keyframes);

AssetDatabase.CreateAsset(clip, "Assets/Animation/" + name + ".anim");
AssetDatabase.SaveAssets();

Если вы этого ещё не делали, то поверьте — начать создавать собственные инструменты очень легко. Самый простой трюк заключается в размещении в сцене GameObject с привязанным к нему MonoBehaviour, которое имеет атрибут [ExecuteInEditMode]. Добавьте кнопку, и вы готовы к бою! Помните, что ваши личные инструменты не обязаны выглядеть хорошо, они могут быть чисто утилитарными.

Код:

[ExecuteInEditMode]
public class MyCoolTool : MonoBehaviour {
   
    public bool button;

    void Update() {
        if (button) { button = false; DoThing(); }
    }
}

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

Код:

string path = "Assets/Whatever/Sprite.png";
Texture2D texture = AssetDatabase.LoadAssetAtPath<Texture2D>(path);
TextureImporter textureImporter = AssetImporter.GetAtPath(path) as TextureImporter;
if (!textureImporter.isReadable) {
 textureImporter.isReadable = true;
 AssetDatabase.ImportAsset(path, ImportAssetOptions.ForceUpdate);
}

Color[] pixels = texture.GetPixels(0, 0, texture.width, texture.height);
for (int i = 0; i < pixels.Length; i++) {
 // Do something with the pixels, e.g. replace one color with another.
}

texture.SetPixels(pixels);
texture.Apply();
textureImporter.isReadable = false; // Make sure textures are marked as un-readable when you're done. There's a performance cost to using readable textures in your project that you should avoid unless you plan to change a sprite at runtime.

byte[] bytes = ImageConversion.EncodeToPNG(texture);
File.WriteAllBytes(Application.dataPath + path.Substring(6), bytes);
AssetDatabase.ImportAsset(path, ImportAssetOptions.ForceUpdate);

Как я перерос возможности Mecanim: жалоба

Со временем прототип системы модульных спрайтов, который я создал с помощью Mecanim, стал самой большой проблемой при апгрейде Unity, потому что API постоянно сильно менялся и был плохо задокументирован. В случае простого конечного автомата было бы разумно иметь возможность запрашивать состояния каждого клипа или менять клипы во время выполнения. Но нет! Из соображений производительности Unity запекает клипы в их состояния и заставляет нас использовать для их смены неуклюжую систему переопределений.

Сам по себе Mecanim не такая уж плохая система, но мне кажется, что ему не удаётся реализовать свою основную заявленную особенность — простоту. Идея разработчиков заключалась в том, чтобы заменить то, что казалось сложными и мучительным (скриптинг) чем-то простым (визуальным конечным автоматом). Однако:

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

— Простым случаям использования мешают обобщённые требования системы. Чтобы проиграть одну-две анимации, вам нужно создавать новый контроллер и назначать состояния/переходы. Разумеется, присутствует и излишняя трата ресурсов.

— Забавно, что в результате вам всё равно приходится писать код, ведь чтобы конечный автомат делал что-то интересное, нужен скрипт, вызывающий Animator.SetBool и подобные ему методы.

— Для многократного использования конечного автомата с другими клипами нужно дублировать его и заменять клипы вручную. В дальнейшем вам придётся вносить изменения в нескольких местах.

— Если вы хотите изменять то, что находится в состоянии во время выполнения, то у вас проблемы. Решением будет или плохой API, или безумный граф с одним узлом для каждой возможной анимации.

Рассказ о том, как разработчики Firewatch попали в ад визуального скриптинга [3]. Самое забавное в том, что когда докладчик показывает наиболее простые примеры, они всё равно выглядят безумно. Зрители в буквальном смысле стонут на 12:41 [4]. Добавьте огромные затраты на обслуживание, и вы поймёте, почему я сильно не люблю эту систему.

Многие из этих проблем даже не вина разработчиков Mecanim, а просто естественный результат несовместимых идей: нельзя создать общую и в то же время простую систему, а описывать логику при помощи изображений сложнее, чем просто словами/символами (кто-нибудь помнит флоучарты UML?). Я вспомнил фрагмент из доклада Зака Макклендона на Practice NYC 2018 [5], и, если найдётся время, рекомендую вам посмотреть видео целиком!

Однако я разобрался. Визуальный скриптинг всегда порицается агрессивными «пиши свой собственный движок» нердами, не понимающими потребностей художника. К тому же нельзя отрицать, что бОльшая часть кода выглядит как непостижимый технический жаргон.

Если вы уже немного программист и делаете игры со спрайтами, то вам возможно стоит подумать дважды. Когда я начинал, то меня был уверен, что никогда не смогу написать что-то связанное с движком лучше, чем разработчики Unity.

И знаете что? Оказалось, что аниматор спрайтов — это просто скрипт, меняющий спрайт через заданное количество секунд. Как бы то ни было, мне всё-таки пришлось написать собственный. С тех пор я добавил ещё события анимации и другие функции под мой конкретный проект, но базовая версия, которую я написал за полдня, покрывает 90% моих потребностей. Она состоит всего из 120 строк и её можно бесплатно скачать отсюда: https://pastebin.com/m9Lfmd94 [6]. Благодарю за то, что прочитали мою статью. До встречи!

Автор: PatientZero

Источник [7]


Сайт-источник PVSM.RU: https://www.pvsm.ru

Путь до страницы источника: https://www.pvsm.ru/razrabotka-igr/331387

Ссылки в тексте:

[1] https://docs.google.com/spreadsheets/d/1Nbr7lujZTB4pWMsuedVcgBYS6n5V-rHrk1PxeGxr6Ck/edit?usp=sharing: https://docs.google.com/spreadsheets/d/1Nbr7lujZTB4pWMsuedVcgBYS6n5V-rHrk1PxeGxr6Ck/edit?usp=sharing

[2] интерфейса командной строки: https://www.aseprite.org/docs/cli/

[3] ад визуального скриптинга: https://www.youtube.com/watch?v=8VgQ5PpTqjc&amp;feature=youtu.be&amp;t=640

[4] 12:41: https://youtu.be/8VgQ5PpTqjc?t=761

[5] доклада Зака Макклендона на Practice NYC 2018: https://youtu.be/PkZoGDKy_L4?t=2757

[6] https://pastebin.com/m9Lfmd94: https://pastebin.com/m9Lfmd94

[7] Источник: https://habr.com/ru/post/468991/?utm_source=habrahabr&utm_medium=rss&utm_campaign=468991