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

Что такое 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х2048px, и компрессия не настроена или настроена как попало, и атласов нет. 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;
  // ...
}

Ещё немного ссылок

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

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

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


  1. NikS42
    04.02.2022 14:47
    -1

    Все так любят CanvasGroup, но то ли не видят, то ли игнорируют тот факт, что он управляет ИСКЛЮЧИТЕЛЬНО ДОЧЕРНИМИ элементами. Обрезает обход поддерева рейкастером или передает модификатор прозрачности дочерним CanvasRenderer. В итоге, получается не очевидно желаемое полупрозрачное окошко с содержимым, а просто каждый элемент этого окна становится полупрозрачным. На полупрозрачной панельке полупрозрачная кнопка, через которую просвечивает панелька, и сверху на все это накладывается еще и текст на кнопке, по тому же принципу. А уж если кто-то придумает использовать стандартный юнитиевский shadow или outline - это идея сама по себе сомнительная, но в сочетании с CanvasGroup она заиграет новыми красками. Лет 6 уже наблюдаю эту ситуацию, ничего не меняется. Даже в проде все так и работает, анимации меняют прозрачность элементов, а не канвы в целом. Способа добраться до результата отрисовки канвы, чтобы менять его прозрачность(казалось бы, очевидное решение), по крайней мере я не нашел.


    1. elmortem Автор
      04.02.2022 15:45
      +3

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


      1. NikS42
        04.02.2022 18:22

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


        1. elmortem Автор
          04.02.2022 22:20

          Я скорее про то, что это не проблема Unity.


  1. Tar
    04.02.2022 15:59
    +2

    Немного дополню.

    Уже довольно давно появились assembly definitions, части кода можно выделять в отдельные assembly. Это ускоряет процесс разработки (компилируется только измененённые), а также даёт возможность разбить проект на куски и настроить кто от кого зависит.


    AssetPostprocessor'ы лучше скомпилировать и подключать как DLL особенно если используется кеш-сервер (сейчас наверно можно через asmdef). Дело в том, что они не выполняются если в их assembly есть ошибки. И если у вас одновременно новый ассет и ошибки в коде, то на кеш сервер уходит ассет для которого post processor не был вызван. И потом может внезапно выплыть на билд машине.

    Также была забавная проблема с AssetPostprocessor'ом. На одном из релизов вдруг поменялись хеши у кучи asset bundle'ов. Для пользователей это гигабайты перекачки. Оказалось в одной из используемых нами внешних библиотек добавили AssetPostprocessor для сцен. Он как бы ничего особо не делал, просто был. Но Unity ведь не знает об этом. Изменён пост процессинг ресурса, значит ресурс импортится по-другому, вот тебе новый хеш.


    1. eonyanov
      05.02.2022 19:40

      Не такая уж и удобная штука эти asmdef. Можно упороться и уйти в крайность, когда для каждого модуля будет своя либа. Тогда поддерживать это становится одной большой головной болью.


  1. mtrs
    04.02.2022 17:21

    Хороший список. Добавлю только по двум пунктам:

    Animation — это старый анимационный движок, который помечен как legacy, и использовать его уже не ререкомендуют

    На Unite их инженеры как раз рекомендавали Animation использовать для простых анимаций. И акцентировали внимание, что "легаси" тут никого не должно пугать. Даже показывали сравнение перформанса, когда какой использовать в зависимости от количества кривых в анимации.

    При этом наоборот для UI не стоит использовать Animator, так как он сетает dirty флаг и меш перестраивается, даже когда визуально ничего не происходит (например idle стейт)

    3.5. Layout и ContentSizeFitter.

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


  1. yatagarasu
    05.02.2022 01:57
    +1

    4.4. Не используйте стандартный Update

    Неужели с Update все так плохо? Как-то не верится что это ещё не оптимизировали.


    1. SH42913
      05.02.2022 11:20

      Для большинства ситуаций он ок по производительности, но на большом количестве вызовов уже начинает вылезать и, если у тебя 100+ одинаковых объектов c вызовами `Fixed/Late/Update()`, то лучше переделать на менеджер, который будет вызывать у них кастомный ManualUpdate()

      Юнитехи сами писали статейку про это