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

Что такое префаб?

В юнити довольно много удобных инструментов работы с данными, один из которых – префаб. По сути в Unity есть два вида конфигов с визуальным интерфейсом для манипулирования ими. Prefab и ScriptableObject. Если Scriptable Object больше про чистое хранение данных, то Prefab по сути конфиг “аналогичный xaml” в UWP, который позволяет реализовывать концепцию MVVM, и является в ней View. Мне в целом нравится архитектура, когда префабы – это View, компоненты – View-Model, а Scriptable Object – модель. Это довольно удобно. Но в любой другой схеме архитектуры по MVP, MVC и т.п. можно префабы считать за View.

Под капотом префаб или скриптабл обжект – это YAML конфиг. Если в Unity включена текстовая сериализация в настройках редактора то его даже можно открыть и почитать.

Структурно он обычно состоит из ссылок на другие префабы, файлы, скрипты и т.п. и наборы сериализуемых параметров. При этом тут стоит сразу сделать отступление на что такое Prefab Variant. Это такой же YAML конфиг, который похож на префаб в своей сути, но он хранит ссылку на оригинальный префаб m_SourcePrefab и его модификации m_Modification + удалённые компоненты m_RemovedComponents.

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

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

Базовые элементы

Разберём все рекомендации на простом пользовательском интерфейсе. Предположим что у нас скажем есть: магазин и награда за квест. Подобный пример позволит нам получить случаи использования похожие на правду и некоторые примеры того, какие проблемы могут возникать. Как и театр начинается с вешалки, сборка интерфейса всегда должна начинаться со сборки базовых элементов. Кнопки, задники окон, рамки предметов и т.п. Что считать базовым элементом зависит от конкретного интерфейса.

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

Я собрал кнопку из кучи кружков, потому что на самом деле про интерфейс можно сказать так: "Дайте мне один кружок и на нём можно собрать очень много вариантов интерфейса". При этом это очень удобный концепт, так как весить такой интерфейс будет примерно ничего. В дальнейшем кроме каких-то иконок все базовые панели мы так же соберём с помощью одного спрайта-кружка. Вот пример вариантов кнопок:

Теперь соберём по аналогичному принципу фон для окна.

В данном случае у них есть некоторый “общий элемент” bg-circle-shadow. Его конечно же можно вынести в отдельную компоненту, чтобы пакетно красить тени, менять их реализацию и т.п. Но по опыту в разных сущностях лучше не обобщать такие элементы, так как именно из-за этого потом возникают проблемы “я поменял кнопку, а сломались все окна”. Изменение префабов с помощью инструментов Unity — слишком простое действие. Но тем не менее не хочется терять возможность пакетной обработки. Поэтому лучше заранее продумать контракты названий и манипулировать пакетно либо скриптами, либо в ручную, но осознанно меняя в определённых местах. Такие вещи на самом деле не так сложно читать на ревью в мерж реквестах, когда названия совпадают или имеют нечто общее. Так что разбор всего уж совсем на молекулы – это, как и в коде, излишняя декомпозиция, которая ведёт в будущем к неочевидным проблемам. Сущности должны быть разделены логически. И совсем базовые компоненты не должны пересекаться. Ну почти, но мы этого коснёмся чуть позже. 

Из базовых элементов мы собрали всё, кроме “игрового предмета”.

Он аналогичен. Так сказать из того, что можно собрать на стоках, собрали для иллюстрации. Перед тем как начать собирать окна, немного ещё по базовым элементам. Базовые элементы интерфейса – это не эффекты или какие-то поведения на мой взгляд, чтобы с ними было удобно работать, а конкретные базовые сущности. Фоны, кнопки, прогресс бары для интерфейса. Персонажи, мечи, игровые объекты – которые могут повторяться и удобно редактировать пакетом. Основной плюс сборки сразу такого UI кита заключается в том, что дальше окна уже собираются довольно быстро, так как по сути это копирование и изменение значений, но основные ингредиенты окна уже собраны. Итак, начнём собирать окна.

Композиция или Nested Prefabs

Перед сборкой стоит рассказать про композицию и Nested Prefabs. В самой сборке нет ничего особо интересного, она скорее представлена для иллюстрации концепта. Префабы как сущность в движке обладают одной проблемой. Из-за того, как просто ими манипулировать, собирать и управлять, то многие очень халатно подходят к сборке и не учитывают насколько это важный элемент, который при правильном структурировании и аккуратной работе с ним может сэкономить в будущем уйму времени. По сути в Unity сейчас есть два механизма для работы с префабами композиция (Nested Prefabs) и наследование (Prefab Variance) и относится к ним нужно так же, как и к тем же механизмам в коде. Только с ещё с большей осторожностью, так как в префабах в принципе нет “защиты от дурака” Префаб просто позволяет определять View и делать биндинги без кода, но это всё ещё View. И всё ещё те же механизмы встречающиеся в разработке и их проблемы.

С композицией всё довольно просто. Она не всегда удобна, излишняя композиция ведёт к проблемам которые я описывал для базовых элементов и теней. То есть любой объект должен быть разбит на логические сущности. Если что-то присуще только этому окну – нет смысла выносить это в отдельный префаб. Самый простой способ определить это задать себе вопрос. Если я изменю этот элемент мне придётся менять это окно? Как можно заметить в базовых элементах у нас скажем не было заголовков окон. Если у окон нет некоей обобщённой вёрстки, то чаще всего мы не может рассуждать так:

“Итак, у нас есть заголовки. Хорошим тоном при работе шрифтов, что по всему приложению у нас есть несколько размеров шрифта и для заголовочных он свой. Поэтому вынесем это в префаб, чтобы менять размер шрифта по всему приложению для заголовков”

Идея, имеет место быть. Но в среднем по опыту такой подход ведёт к тому, что разработка узнаёт от тестеров о том, что “а вот тут сломался такой то экран, текст теперь залезает туда”. И это и есть излишняя декомпозиция. Всё зависит от конкретного случая, иногда окна скажем можно разбить на повторяющиеся виджеты, которые просто складываются друг на друга через horizontal layout, если дизайн такие молодцы и так сделали. Но это скорее исключение чем правило.

Соберём из наших компонент префаб окна награды за квест (я бы конечно ещё поиграл со шрифтами и хедером, но это только для статьи так что попрёт)

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

Первое – это добавление в конец названий префаба предполагаемого действия. Это полезно в поиске, в авто-редактировании, в групповом редактировании объектов по принципу их действия и т.п. Тут можно завести удобный для себя контракт.

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

Третье – тут это не так хорошо видно, но организация папок с текстурами. Unity sprite atlas позволяет передавать папку в качестве параметра с текстурами. Поэтому если какие-то текстуры принадлежат какому-то игровому экрану, то лучше класть их в отдельную подпапку. Перед релизом игры может встать задача оптимизации интерфейсов. И тогда это так же сэкономит кучу времени, так как допустим одна из оптимизаций – это сгруппировать текстурные атласы по игровым экранам, чтобы уменьшить число draw call на интерфейс. Так как для того, чтобы интерфейс рисовался в скажем 1 dc одно из требований, чтобы все спрайты этого интерфейса лежали в одном атласе. Я в примере буду во всех экранах использовать одни и те же текстуры, так что у меня этого разделения нет.

Наследование или Prefab Variants

По своей сути же Prefab Variants – это наследование в префабах, которое позволяет расширять функционал наследников со всеми из этого вытекающими. Как базовая рекомендация в таком случае – не делать очень глубокую иерархию наследования. Если относится к префабам, как к коду, где всё довольно просто, чётко и иерархически, то все советы выполняются сами собой. Просто многие обращают на это недостаточно внимания и тратят уйму времени на поддержку подобных решений.

Соберём теперь окно для магазина:

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

Важно: для окна квеста мы создали новый вариант game-item-quest-reward и он унаследован от game-item, так же как и game-item-shop унаследован от game-item. Очень частая ошибка, что если делается первым скажем окно QuestPanel, то там остаётся базовый game-item, или получается цепочка наследования game-item->game-item-quest-reward->game-item-shop, что в свою очередь очень плохо с точки зрения проектировки. Так как игровой предмет магазина по логике не должен зависеть от предмета в окне награды за квест. Они могут опираться оба на один базовый, но ни в коем случае нельзя строить такую зависимость. Хотя интерфейс Unity к этому подталкивает.

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

Ещё стоит обратить внимание, что структурно фон карточки сейчас – это тот же фон окна. Но тогда почему это отдельный префаб? Потому что это отдельная логическая сущность никак не связанная с фоном окна, и не должна изменяться вместе с изменением фона окна. А почему это не вариант, как сделано с игровым предметом? Так можно сделать, тут уже вопрос к тому насколько вы считаете эти сущности разными. Я считаю это рискованным при горизонтальном масштабировании, так как когда таких сущностей не 2-3, а 20-30 трудно следить за их изменениями. А при этом для пакетной обработки можно пройтись по ним скриптом, либо руками.

В заключении

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

В разработке, и префабы не исключение, есть базовый конфликт. С одной стороны хочется, чтобы правки меняли только тот модуль, который исправляется. Это упрощает разработку и не вызывает неочевидных багов, когда правка касается только конкретного модуля. А с другой стороны хочется удобства, чтобы из-за “дублирования” не приходилось одно и тоже при ошибке менять в каждом месте всей системе. И любая разработка – это компромисс между этими двумя стульями.

В общем резюмируя:

  1. Следите за названиями объектов

  2. Не делайте слишком глубокую иерархию вариантов и разделяйте их логически

  3. Для базовых элементов, виджетов собираемых в окно лучше использовать Nested Prefabs и композицию

  4. Не обманывайтесь интерфейсом и простотой редактирования. За префабами надо следить не меньше чем за кодом

Собранные префабы вы найдёте в этом репозитории, где можно посмотреть на организацию. Спасибо за внимание!

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


  1. overtest
    09.09.2022 16:42
    +1

    Мне в целом нравится архитектура, когда префабы – это View, компоненты – View-Model, а Scriptable Object – модель.

    Непонятно, что вы имеете в виду под термином "компоненты". В Unity компонент - это, по сути, любой скрипт, навешиваемый на GameObject, т.е. наследуемый от MonoBehaviour. Префаб - это прототип некотого GameObject-а, который содержит 1 или более компонентов. Про "Scriptable Object – модель" - тоже непонятно, по тексту ничего не нашел, как и про MVVM, которая упоминается в самом начале, но дальше тоже ничего. Т.е. ваше объяснение префаба в терминах MVVM в самом начале вносит путанницу в терминологию Unity и вызывает вопросы, но никак не раскрывается дальше.


    1. DyadichenkoGA Автор
      09.09.2022 16:47

      Наследник Monobehaviour навешиваемый на GO — это и есть компонент в данном контексте. И можно писать в таком стиле, что данные компоненты будут View-Model. Можно писать в другом стиле. Я скорее не понял суть вопроса :) Есть такой стиль архитектуры для проекта. Это не раскрывается, потому что как в юнити пишутся разные архитектурные схемы, почему любой крупный проект это всегда гибрид, и где лучше реактивный подход, а где другой — это не тема статьи :)


      1. overtest
        09.09.2022 17:16

        Можете не объяснять, т.к. действительно не тема статьи, но у меня с вашего описания возникло такое рассуждение, что если префаб - это ничто иное как GameObject, тогда любой GO - это тоже View. Если любой компонент - это ViewModel, тогда чистый View - это голый GameObject (пускай Transfrom не считается), получается, все View-префабы - динаково пустые GameObject-ы :)


        1. DyadichenkoGA Автор
          09.09.2022 17:25

          Смотрите. Я безо всякого, просто правда не понял суть вопроса :) Не совсем. Префаб GameObject лишь с точки зрения кода, а с точки зрения конфига нет. Так как он сериализует все параметры висящих на нём компонент и т.п. ViewModel же осуществляет биндинг этих данных в код для проброса грубо говоря, как в том же самом xaml в UWP. Ну то есть GameObject — это бидинг по сути, как мы из кода обращаемся к конфигу. Так как он (конфиг) хранит все параметры и все ссылки на компоненты. Так как с точки зрения самого кода можно пойти ещё глубже до понятия самого скелетного скелета в Unity, это UnityEngine.Object, который обобщает вообще все виды объектов в редакторе.

          Ну то есть если вы откроете именно файл с расширением .prefab, там же будут не только данные о трансформе, но и ссылки на все компоненты и что важно их параметры. Поэтому .prefab != GameObject. Класс GameObject можно воспринимать как удобный биндинг, который позволяет из кода навигироваться по ViewModel, задавая View более сложные поведения. Но View он не является. На примере пользовательского интерфейса, ViewModel — это Transform, Image, Button, TMP_Text. Если расписывать в целом схему, то GameObject опять таки тут будет обобщающим биндингом для доступа к этим компонентам, которые являются визуальной частью биднящую параметры отрисовки к модулю рендера, то есть той же ViewModel. И тут даже нет конфликта логики, так как View всё ещё остаются параметры записанные в виде текста (ну или бинарной информации) в файл .prefab. Проще всего это понимать просто через UWP и xaml, так как по сути там всё немного прозрачнее. Тут просто Unity текстовый конфиг заменило на решение в котором во главе стоит визуальный редактор)

          Надеюсь понятно объяснил)


          1. overtest
            12.09.2022 12:16

            Думаю, я понял вашу идею, но мне, видимо, непросто воспринимать Unity через призму MVVM. Всякий раз, когда пытаюсь это сделать, чувствую, что расходую кучу мыслительной энергии на построение ментальной модели, которая никак не поддержана ни редактором, ни движком, поэтому падает продуктивность, проект становится более путанным, сложным для восприятия. Для себя лично пришел, что MVVM это не про Unity (а может и не про геймдев), что это больше для формочек. Во ViewModel есть "зависимые" свойства, которые уведомляют View через "связывание" об изменении своих значений. XAML заточен под MVVM, хорошо читаем, концепция связывания хорошо воспринимается через визуал, как и разделение View и ViewModel, чего совсем нельзя сказать об YAML. В Unity сложно однозначно определиться, является ли компонент View или ViewModel. По мне, так Transform - это чистый View, также, например, как какой-то XAML-компонент, отвечающий за положение панели относительно родителя, у которого есть Left, Top, Width, Height, к которому подвязывается ViewModel.


    1. DyadichenkoGA Автор
      09.09.2022 16:49

      Это просто иллюстрация к тому, что не надо забивать на то, как организовано View, что в проектах я вижу слишком часто) Некоторые не пользуются префабами как View) И пишут в стиле андроид разработки без XML :) Чисто code-first интерфейсы. А есть такой подход где наследники SO — это модель, компоненты наследники Monobehavior — это View-Model, а префабы — View. Получается стиль почти написания UWP приложений. Ток на других форматах :)