Unity «Best» Practices
Что такое AssetPostprocessor и чем Animation отличается от Animator? Почему не стоит доверять OnTriggerExit и зачем вам CanvasGroup? Чем хорош GameObject.Find и как вас спасут Property?
Далее в статье обсудим это, а также другие «особенности» работы с движком Unity.
И первое, с чего хотелось бы начать: не обновляйте Unity без крайней необходимости! Всё обязательно сломается. Если вы не решаете этим какую-то важную проблему — просто оставьте его в покое и не обновляйте.
Ассеты
1.1. Самый первый момент при работе с Unity: нужно настроить сериализацию ассетов и префабов в текстовом виде для Git и добавить в игнор всякие системные директории, которые полезны Unity, но не нужны в репозитории. Под нож идут Library, Temp, Logs и obj. Как настроить, чтобы у нас была не бинарная сериализация, а текстовая, показано на скриншоте ниже:
К сожалению, из-за текстовой сериализации ассетов в YAML мёржить будет всё равно больно, но хотя бы репа весить будет меньше.
1.2. Не используйте папку Resources. К сожалению, этому никто не следует, в том числе потому что сама Unity этого не делает. Хотя она пропагандируюет использование Asset Bundles, все равно есть такие инструменты, как TextMeshPro, которые будут это игнорировать и использовать папку Resources.
Всё, что находится в этой папке, попадает в билд вне зависимости от того, используете вы эти ассеты или нет. Так в билд обычно прилетает много мусора: старых или тестовых ассетов. Всё это занимает лишнее место и увеличивает размер билда. А если вы где-то в бандлах ссылаетесь на эти ассеты, у вас появятся дубли. Словом, возникает немало проблем, так что если у вас есть возможность от нее отказаться, откажитесь.
1.3. Класс AssetPostprocessor — очень полезная вещь для автоматизации обработки ассетов, но ей мало кто пользуется. Он позволяет при закидывании новых ассетов в Unity как-то их перемолоть: выставить правильные настройки, создать заранее какие-то компоненты, пресеты — методов у него много. На скриншоте пример, как он работает с Game Objects:
Таким образом, при помощи AssetPostprocessor можно упростить работу с ассетами.
1.4. Важно следить за размером и компрессией текстур. Особенно если вы отдаете работу с текстурными ассетами художникам, в какой-то момент вы можете обнаружить, что и маленькие иконки оружия у вас размером 2048×2048 px, и компрессия не настроена или настроена как попало, и атласов нет. AssetPostprocessor может помочь такие вещи найти и заалертить или автоматизировать. Компрессию и формирование атласов тоже можно делать автоматически — по группам объектов, директориям или как-либо еще.
Также стоит обратить внимание на класс AssetImporter, позволяющий создавать свои типы ассетов по расширению файлов.
1.5. Используйте SpriteAtlas. Ввели их сравнительно недавно, но они довольно удобные. Полезный инструмент, чтобы уменьшить количество draw calls. В Unity он работает из коробки, хотя раньше приходилось использовать внешние решения вроде TexturePacker или создавать атласы вручную и потом при импорте руками бить их на спрайты.
1.6. Ещё следует помнить, что десериализация Unity недостаточно быстрая. Особенно это касается старых версий Unity. Поэтому, если вы разрабатываете игру с большим количеством статических данных, имеет смысл подумать над отказом от ScriptableObject.
Мы используем свою систему конфигов на базе бинарного протокола. Некоторые наши проекты создают такие конфиги при билде из тех же ScriptableObject, которые в итоге в билд не попадают. К сожалению, сделать свою более быструю реализацию префабов нам в своё время не удалось: получалось быстрее на больших префабах, но медленнее на мелких из-за особенностей добавления и инициализации компонентов в рантайме. К тому же, в последних версиях Unity был ряд фиксов, связанных с загрузкой ассетов, так что, возможно, оно уже того и не стоит.
Объекты и иерархия
2.1. Ещё один довольно очевидный совет: не спавните объекты в корень сцены, делайте контейнеры для разных типов объектов, чтобы не создавать хаос в иерархии. Еще такие контейнеры может быть удобно использовать для пулинга, чтобы не менять родителя.
2.2. Используйте Pooling объектов — это позволит меньше дёргать Garbage Collector. В 2021 году Unity представили свои инструменты для пулинга — UnityEngine.Pool. Но если вы используете более старую версию Unity, вам придётся писать свою реализацию пулов или искать библиотеку в Asset Store. В самом простейшем случае пулинга GameObject — это активация/деактивация заранее инстанцированных из префаба в отдельный контейнер объектов.
Хитрость: при инстанцировании префаба для преспавна имеет смысл его предварительно деактивировать, чтобы объекты создавались выключенными и не дёргали внутренние события Unity, вроде OnEnable.
2.3. Animation vs Animator. Animation — это старый анимационный движок, который помечен как legacy, и использовать его уже не рекомендуют, но все равно в некоторых случаях он остается довольно полезным. Animator — более громоздкая штука, которая имеет внутри себя стейт-машину с кучей ништяков, вроде блендинга и параметров, но долго инициализируется при активации объекта. Отсюда следует правило, что не нужно использовать Animator для простых анимаций — используйте для этого Animation или твины.
Хитрость: допустим, вы сделали AnimationClip для Animation и поняли, что вам этого недостаточно, и нужен Animator. А переделывать клип не хочется. А они несовместимы. Но если переключиться в Inspection в режим Debug, то там будет скрытый параметр legacy, который можно снять и продолжить работать уже в Animator. Вся несовместимость только на уровне скомпилированного клипа — в редакторе форматы клипов идентичны.
Интерфейсы
3.1. GameObject в сцене грузятся быстрее, чем инстанцируются из префабов. Соответственно, для их уничтожения тоже стоит просто удалить сцену, в то время как для удаления объекта, инстанцированного из префаба, нужно удалить не только инстансы, но и сам префаб, который занимает место в памяти. Это полезно для больших интерфейсных окон, которые существуют в единственном экземпляре, и нет необходимости тратить время и память на предварительную загрузку префаба. Хотя менеджить такие объекты становится сложнее, и чаще всего никто не заморачивается.
Но если вы решили, что хотите грузить объект через сцену, а сцену хотите удалить, тогда в обычном случае объект тоже удалится. Если вы хотите этого избежать, используйте DontDestroyOnLoad. Тогда Unity создаст системную сцену и переместит такие объекты туда, и они не будут удаляться при смене сцен. Работает даже для объектов, созданных в рантайме или инстанцированных из префабов. Полезно, например, для загрузочного экрана, который никогда не удаляется и показывается/скрывается при необходимости.
3.2. Делайте отдельные Canvas для ваших окон.Проблема, с которой столкнулись мы сами: если все держать в одном Canvas, он будет полностью пересчитываться при каждом изменении объектов внутри канваса. Особенно это касается динамического анимированного интерфейса с партиклами.
3.3. CanvasScaler — самый полезный инструмент для работы с интерфейсами, который помогает настроить скейл вашего интерфейса и как он будет себя вести при разных разрешениях и соотношениях сторон экрана. Это особенно актуально при разработке для мобильных платформ, но и для ПК/консолей решит проблему с настройкой отображения интерфейса.
3.4. Если вы хотите управлять альфой нескольких интерфейсных элементов, как единым объектом, используйте CanvasGroup. У него есть своя альфа, есть свои настройки по прокликиванию мыши. Если вы меняете альфу CanvasGroup, меняются и все ее дочерние графические элементы.
3.5. Layout и ContentSizeFitter. Как сделать так, чтобы ваше окно само увеличилось от размера контента? По логике, вы должны просто добавить компонент ContentSizeFitter, и он все сделает сам. Но в Unity это так не работает. Поэтому, если вам нужно, чтобы размер менял внешний объект, то вам нужна именно связка Layout & ContentSizeFitter. Также по непонятным причинам иногда при добавлении или активации объектов внутри Layout размеры могут не пересчитываться. Тогда приходит на выручку Canvas.ForceUpdateCanvases или LayoutRebuilder.ForceRebuildLayoutImmediate — хотя это грязные хаки, которых нужно стараться избегать.
3.6. Анимация интерфейса, Particle System и 3D-модели — это боль, с которой каждый выкручивается, как может.
Canvas существует в системе рендеринга Unity как некий отдельный объект, внутри которого правила рендеринга другие: здесь он происходит последовательно по иерархии объектов, унаследованных от RectTransform. Объекты, унаследованные от обычного Transform, рендерятся по Sorting Layer. Соответственно, Particle System или любой иной Renderer на объекте с Transform тоже рендерится по Sorting Layer. И научить Unity работать с такими объектами между двумя интерфейсными объектами — довольно проблематичная штука.
Плохой способ: можно сделать два Canvas и поместить Particle System между ними. Но это сильно усложнит иерархию окон, особенно если партиклов нужно несколько или нужно их как-то анимировать совместно с другими объектами интерфейса.
Из коробки нет реализации партиклов для интерфейса. Есть несколько ассетов в Asset Store, но нам ни один не понравился. В итоге мы используем свою реализацию рендера частиц на базе стандартной Particle System (она знакома аниматорам) и MaskableGraphic.OnPopulateMesh с возможностью задать частоту перестройки меша. И даже при редкой перестройке такие партиклы достаточно сильно роняют FPS и напрягают GC, так что стараемся не злоупотреблять ими в интерфейсе.
С анимациями проблема в перестройке канваса каждый кадр, так что тут можно только посоветовать размещать сложные анимации в отдельных сабканвасах.
Для рендеринга внутри интерфейса каких-то сложных игровых объектов — например, 3D-моделей или объектов на базе SpriteRenderer — обычно используют RenderTexture. Отдельной камерой рендерят нужные объекты в текстуру, а её уже используют как источник в RawImage — аналоге Image, рендерящем не спрайты, но текстуры.
В общем, всё это дорого, и лучше стараться этого избегать. Но если очень хочется, то вы теперь знаете, куда копать.
Кодинг
4.1. Не используйте GameObject.Find, FindWithTag и т.д. GameObject.Find занимается тем, что ищет по неким заданным параметрам объекты, которые сейчас есть на сцене. В том числе он может искать среди отключенных объектов, так что это очень долго. Делайте свои кеши объектов по необходимости. Единственное, где GameObject.Find хоть как-то оправдан — редакторские скрипты, где вам не важна скорость работы.
4.2. Забудьте про Invoke и SendMessage у MonoBehaviour. Все это очень медленно и не очень отлажено.
4.3. Не используйте GetComponent в Update. GetComponent — штука дорогая, поэтому лучше кешируйте все необходимые компоненты заранее. Причем в идеале лучше даже кешировать их не на этапе загрузки, а сериализовывать в сам префаб.
4.4. Не используйте стандартный Update. Все стандартные события Unity очень медленные. И если для событий, вызывающихся один раз за жизненный цикл объекта, это терпимо, то для событий, вызывающихся 30+ раз в секунду — неприемлемо. Лучше вместо этого сделать свой сервис, который будет вызывать у зарегистрированных в нём объектов свой публичный метод обновления.
4.5. Не двигайте Rigidbody в Update, не двигайте Transform в FixedUpdate. Все равно физика будет обрабатываться в FixedUpdate, а трансформы — в Update. Изменение скорости, применение сил — всё это нужно делать в FixedUpdate.
4.6. Не конкатенируйте строки в Update. Не будите GC, пусть спит. Если вам это нужно для таймера — отсчитывайте секунду и меняйте, не чаще.
4.7. Не доверяйте OnDestroy/OnTriggerExit. Очень неочевидная штука: они попросту могут не вызываться в некоторых случаях. Например, если ваш объект сначала скрыт, а потом удален — тогда OnDestroy и OnTriggerExit не вызовутся. Тут придётся писать (или найти в Интернете) своё решение этой проблемы.
4.8. Используйте «мягкие» ссылки на ассеты (AssetReference или свой аналог). Это тоже одна из главных проблем Unity для крупных проектов. Ведь если вы сошлётесь прямой ссылкой на какой-то префаб или ассет, он потянет за собой все, на что ссылается он. Идея в том, чтобы максимально отдалить момент загрузки при указании ссылки на ассет. Жёсткие ссылки стоит использовать только для указания частей префаба или в качестве исключения для ссылок на ассеты с полным пониманием, к чему это приведёт. Например, это может привести к дублированию ассетов в бандлах.
4.9. Используйте асинхронную загрузку ассетов, если это возможно. Такое размазывание загрузки положительно скажется на плавности вашей игры, открытии интерфейсов, особенно с большими списками. При этом асинхронная загрузка не исключает предзагрузку по необходимости.
4.10. SerializeReference — сравнительно новая система, позволяющая сериализовать данные любых классов по интерфейсу или базовому классу. В Unity долгое время ничего такого не было, кроме кастомной сериализации в префабах. Но есть ряд проблем, которые для некоторых являются блокером для использования этой системы.
Наименьшая проблема: там есть только API, редакторы таких данных нужно писать самостоятельно, хотя на GitHub и в Asset Store есть бесплатные реализации. В целом там ничего сложного нет, редактор пишется за вечер.
Другая проблема, что существует только ссылка на запись: вы можете записать что-то в объект, но читать в редакторском скрипте вы его не сможете. Нужно находить способы это обходить.
Далее идёт проблема с тем, как Unity сериализуют такие данные: всё хранится в одном массиве в конце файла ассета. Индексы могут неконтролируемо меняться, что в дальнейшем может привести к проблеме с мержем таких ассетов.
И последнее: атрибут переименования полей FormerlySerializeAs до сих пор не работает с SerializeReference, а переименование самого класса, сериализованного таким способом, приведёт к исключению при десериализации и невозможности как-то редактировать такой ассет. В остальном система очень удобная и гибкая, и я бы не отказывался от неё — лучше пинать Unity в сторону её улучшения.
4.11. Используйте Non-Allocate версии API. Раньше в Unity было много проблем с аллокациями при вызове стандартных методов движка. В какой-то момент они реализовали методы-саттелиты, не выделяющие память для своей работы. Вам самим придётся позаботится об этом, создав необходимые кеши. Но оно того стоит.
4.12. [Conditional («define»)] полезен для отключения методов. Мы так отключаем логи.
4.13. В Unity не всё может являться тем, чем кажется. Например, Particle System разбита на модули, и геттеры модулей возвращают структуру, которую можно редактировать для изменения модуля, но нельзя сеттить. Выглядит это примерно так:
var main = ParticleSystem.main;
main.startDelay = 5.0f;
В этот момент IDE может немного взбунтоваться. Анализаторы кода предлагают удалить код с неиспользуемой структурой, но всё хорошо, в Unity так можно.
4.14. Используйте Dependency Injection. Как минимум, разберитесь с Zenject — его используют с Unity чаще всего, но можете поискать альтернативные варианты или даже написать свою реализацию, сути это не меняет.
4.15. Unity активно развивается — постоянно добавляются различные крутые штуки. Традиционно на старте всё это очень забагованное, неудобное или просто бессмысленное, но стоит за всем этим следить и присматривать на будущее полезные для вашей работы инструменты, например:
Memory Profiler;
Новая Input System — очень удобна для настройки под разные девайсы;
Render Pipeline — позволяет довольно глубоко нырнуть в рендеринг и перестроить под себя и оптимизировать его;
Burst, Jobs, и т. д.
Инструменты
Инструменты и возможности их написания в Unity — одно из главных преимуществ движка по сравнению с тем же Unreal Engine. Но кастомные редакторы не всегда быстро работают, поэтому не стоит злоупотреблять большими списками сложных данных, сложных контролов в Inspector и т.д.
Самый популярный инструмент для работы с редакторами — Odin Inspector. Он сильно упрощает жизнь, но использует те же инструменты Unity для работы с редакторами, поэтому на больших данных работает также очень медленно.
Editor — кастомное окно, где можно производить рендеринг от простых элементов интерфейса до более сложных контролов. Пример окна редактора:
А это пример редактора диалогов:
Есть готовые библиотеки для быстрого создания специализированных редакторов. Например, xNode позволяет создавать редакторы на базе нод.
Helpers Property — для форматирования в инспекторе, создания контекстных меню и прочего.
Пример меню для создания ScriptableObject в одну строчку:
[CreateAssetMenu(menuName = "Gameplay/GameEvents/Game Event", fileName = "gameEvent.asset")]
public class GameEventConfig : ConfigBase
{
// ...
}
// ...
public class ConfigBase : ScriptableObject { /* ... */ }
Также стоит присмотреться к HideInInspector, Header, Space, Min, Area, ContextMenu, ExecuteInEditMode, RequireComponent, RuntimeInitializeOnLoadMethod и т. д.
PropertyDrawer — для написания своих атрибутов и их кастомного рендера.
Пример рисования строкового поля для ключа локализации с кнопкой копирования и просмотром локализации:
public class Item : ConfigBase
{
[LocalizationString]
public class string TitleKey;
[LocalizationString]
public class string DescriptionKey;
// ...
}
Ещё немного ссылок
На этом я заканчиваю свой материал, но напоследок хочу оставить пару ссылок, которые дадут еще больше информации по теме:
А также можно ознакомиться со списком вакансий в нашей студии: делай игры с нами, делай игры, как мы, делай игры лучше нас.