- PVSM.RU - https://www.pvsm.ru -
Эта запись девлога целиком посвящена моей системе анимации персонажей, она наполнена полезными советами и фрагментами кода.
За последние два месяца я создал целых 9 новых действий игрока (такие забавные вещи как блокировка щитом, уворачивание в прыжке и оружие), 17 новых носимых предметов, 3 набора брони (пластинчатый, шёлковый и кожаный) и 6 видов причёсок. Также я завершил создавать всю автоматизацию и инструменты, поэтому всё уже используется в игре. В статье я расскажу, как этого добился!
Надеюсь, эта информация окажется полезной и докажет, что необязательно быть гением, чтобы самостоятельно создать подобные инструменты/автоматизацию.
Изначально я хотел проверить, можно ли совместить наложенные друг на друга спрайты с синхронизованными аниматорами для создания модульного персонажа с заменяемыми причёсками, снаряжением и носимыми предметами. Можно ли объединить нарисованную вручную пиксельную анимацию с по-настоящему настраиваемым персонажем.
Разумеется, такие функции активно используются в 3D- и 2D-играх с пререндеренными спрайтами или в 2D-играх со скелетной анимацией, но насколько я знаю, существует не так много игр, совмещающих созданную вручную анимацию и модульных персонажей (обычно потому, что процесс оказывается слишком монотонным).
Я раскопал этот древний GIF моего первого месяца работы с Unity. На самом деле этот модульный спрайт оказался одним из первых моих экспериментов в разработке игр!
Я создал прототип при помощи системы анимаций Unity, а затем для проверки концепции добавил одну рубашку, одну пару штанов, одну причёску и три предмета. Для этого потребовалось 26 отдельных анимаций.
В то время я создавал всю свою анимацию в Photoshop и не заморачивался автоматизацией процесса, поэтому он был очень скучным. Потом я подумал: «Так, основная идея сработала, позже я добавлю новые анимации и снаряжение». Оказалось, что «потом» — это несколько лет спустя.
В марте этого года я нарисовал дизайн большого количества брони (см. мой предыдущий пост), и заметил, как этот процесс можно сделать более удобным. Я продолжал откладывать реализацию, потому что даже при наличии автоматизации нервничал, что ничего не получится.
Я ожидал, что придётся отказаться от кастомизации персонажа и создать единственного главного героя, как в большинстве игр с ручной анимацией. Но у меня был план действий, и настало время проверить, смогу ли я победить этого монстра!
Спойлер: всё получилось замечательно. Ниже я раскрою свои ***секреты***
Предварительно я провёл много тестов арта и контроля времени, чтобы выяснить, сколько может занять такая работа, и будет ли достижим для меня подобный уровень качества.
Я записал все свои идеи по анимации, собрал их в электронную таблицу и упорядочил по разным критериям, как то полезность, красивость и многократность использования. К моему удивлению, самой первой в этом списке оказалась анимация броска предмета (зелий, бомб, ножей, топоров, шара).
Я придумал численную оценку для каждой анимации и отказался от всего с плохими показателями. Изначально я планировал создать 6 наборов брони, но быстро осознал, что это перебор, и выбросил три типа.
Аспект отслеживания времени оказался очень важным, и я крайне рекомендую использовать его, чтобы отвечать на вопросы типа: «Сколько врагов могу я позволить себе создать в игре?». Всего после нескольких тестов мне удалось экстраполировать достаточно точную оценку. При дальнейшей работе над анимациями я продолжил следить за временем и пересматривать мои ожидания.
Поделюсь копией моего журнала работы за последние два месяца. Учтите, что это время идёт в добавок к моей обычной работе, где я провожу по 30 часов в неделю:
https://docs.google.com/spreadsheets/d/1Nbr7lujZTB4pWMsuedVcgBYS6n5V-rHrk1PxeGxr6Ck/edit?usp=sharing [1]
С умом используя цвета в дизайне спрайтов, можно отрисовать один спрайт и создать множество различных вариаций при помощи смены палитры. Можно менять не только цвета, но и создавать различные включаемые и отключаемые элементы (например, заменой цветов на прозрачность).
Каждый набор брони имеет 3 вариации, а смешивая верхние и нижние части, можно получить множество комбинаций. Я планирую реализовать систему, в которой можно собрать один набор брони для внешнего вида персонажа, а другой — для его характеристик (как в Terraria).
В процессе работы меня приятно удивляли обнаруживаемые любопытные комбинации. Если соединить пластинчатый верх с шёлковым низом, то можно получить нечто в стиле боевого мага.
Лучше всего реализовывать смену палитр, используя в спрайте цвета, кодирующие значение, чтобы в дальнейшем можно было брать их для поиска настоящего цвета из палитры. Я знесь немного упрощаю, так что вот видео, с которого можно начать:
Я не буду объяснять всё в подробностях, а вместо этого расскажу о способах реализации этой техники в Unity, и об их плюсах и минусах.
Это наилучшая стратегия для создания вариаций врагов, фонов и всего того, где множество спрайтов иеет одинаковую палитру/материал. Различные материалы нельзя сгруппировать в батчи, даже если они используют одинаковый спрайт/атлас. Работа с текстурами довольно мучительна, но палитры можно изменять в реальном времени, заменяя материалы, с помощью 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;
}
Я остановился на этом решении, потому что мне нужно было заменять палитры каждый раз, когда меняется внешний вид персонажа (например, при надевании предметов), и создавать некоторые палитры динамически (чтобы отобразить выбранные игроком цвета волос и кожи). Мне показалось, что во время выполнения и в редакторе для этих целей гораздо проще будет работать с массивами.
Код:
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 можно копипастить: просто нажмите на один цвет, скопируйте, нажмите на другой цвет, вставьте — теперь они одинаковы!)
— Применение цвета оверлея ко всей палитре
— Запись палитры в текстуру
Если вы хотите переключать палитры на лету, но в то же время вам необходим батчинг для снижения количества вызовов отрисовки, то можно использовать эту технику. Она может оказаться полезной для мобильных платформ, но использовать её довольно неудобно.
Во-первых, нужно будет упаковать все палитры в одну большую текстуру. Затем вы используете цвет, заданный в компоненте 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;
}
Чудеса замены палитр и слоёв спрайтов. Так много комбинаций.
Для реализации этой функции автоматизация была совершенно необходимой, потому что в результате у меня получилось около 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, стал самой большой проблемой при апгрейде 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&feature=youtu.be&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
Нажмите здесь для печати.