Понимание языка сериализации Unity, YAML

Знаете ли вы, что в редакторе Unity можно редактировать любые типы активов без необходимости работать с языками сериализации, такими как XML или JSON? В большинстве случаев это работает, однако есть случаи, когда необходимо напрямую изменить файлы. В качестве примера можно привести конфликты слияния или повреждение файлов.
Именно поэтому в этой статье мы подробнее расскажем о системе сериализации Unity и поделимся примерами того, чего можно добиться, изменяя файлы Asset напрямую.
Как всегда, создавайте резервные копии своих файлов, а в идеале - используйте контроль версий, чтобы предотвратить потерю данных. Ручное изменение файлов ассетов - рискованная операция и не поддерживается Unity. Файлы ассетов не предназначены для ручного изменения и не выводят полезные сообщения об ошибках, чтобы объяснить, что произошло, если и когда возникают ошибки, что затрудняет исправление ошибок. Если вы лучше поймете, как работает Unity, и подготовитесь к разрешению конфликтов слияния, вы сможете компенсировать ситуации, когда API базы данных активов недостаточно.
YAML, также известный как "YAML Ain't Markup Language", входит в семейство человекочитаемых языков сериализации данных, таких как XML и JSON. Но поскольку он легкий и относительно простой по сравнению с другими распространенными языками, считается, что его легче читать.
Unity использует высокопроизводительную библиотеку сериализации, которая реализует подмножество спецификации YAML. Например, пустые строки, комментарии и некоторые другие синтаксисы, поддерживаемые в YAML, не поддерживаются в файлах Unity. В некоторых случаях формат Unity расходится со спецификацией YAML.
Давайте рассмотрим это на примере фрагмента кода YAML в префабе Cube. Сначала создайте куб по умолчанию в Unity, преобразуйте его в Prefab и откройте файл Prefab в любом текстовом редакторе. Как видно на рисунке 1, первые две строки - это заголовки, которые не будут повторяться в дальнейшем. Первая определяет, какую версию YAML вы используете, а вторая создает макрос "!u!" для префикса URI "tag:unity3d.com,2011:" (о нем речь пойдет ниже).

После заголовков вы встретите ряд определений объектов, таких как GameObjects в Prefab или сцене, компоненты каждого GameObject и, возможно, другие объекты, такие как настройки Lightmap для сцен.

Каждое определение объекта начинается с двухстрочного заголовка, как в нашем примере на рисунке 2: "--- !u!1 &7618609094792682308" следует формату "--- !u!{CLASS ID} &{FILE ID}", который можно проанализировать на две части:
- {CLASS ID}:Это указывает Unity, к какому классу принадлежит объект. Часть "!u!" будет заменена на ранее определенный макрос, в результате чего мы получим "tag:unity3d.com,2011:1" - в данном случае цифра 1 означает ID игрового объекта. Идентификатор класса определяется в исходном коде Unity, но полный их список можно найти здесь.
- &{FILE ID}:Эта часть определяет идентификатор самого объекта, который используется для ссылок на объекты между собой. Он называется File ID, потому что представляет собой идентификатор объекта в конкретном файле. Подробнее о кросс-файловых ссылках читайте далее в этом посте.
Вторая строка заголовка объекта - это название типа объекта (здесь GameObject), что позволяет идентифицировать его при чтении файла.

После заголовка объекта вы можете найти все сериализованные свойства. В нашем примере GameObject на рисунке 2 показаны такие детали, как его имя (m_Name: Cube) и слой (m_Layer: 0). В случае с сериализацией MonoBehaviour вы заметите публичные и приватные поля с атрибутом SerializeField. Этот формат аналогично используется для ScriptableObjects, Animations, Materials и так далее. Обратите внимание, что ScriptableObjects используют MonoBehaviour в качестве типа объекта, а не определяют свой собственный. Это потому, что в одном и том же внутреннем классе MonoBehaviour они тоже находятся.
Благодаря тому, что мы уже рассмотрели, вы можете начать использовать возможности модификации YAML для таких целей, как рефакторинг анимационных дорожек.
Файлы анимации Unity работают путем описания набора дорожек или кривых анимации; по одной для каждого свойства, которое вы хотите анимировать. Как показано на рисунке 4, кривая анимации идентифицирует объект, который ей нужно анимировать, через свойство path, которое содержит имена дочерних GameObject'ов вплоть до конкретного. В этом примере мы анимируем игровой объект "JumpingCharacter" - дочерний объект GameObject "Shoulder", который является дочерним объектом GameObject, в котором находится компонент Animator, воспроизводящий эту анимацию. Чтобы применить одну и ту же анимацию к разным объектам, система анимации использует строковые пути вместо идентификаторов GameObject.

Переименование анимированного объекта в иерархии может привести к одной очень распространенной проблеме: Кривая может сбиться с пути. Хотя обычно эта проблема решается переименованием каждой дорожки анимации в окне "Анимация", бывают случаи, когда к одному объекту применяется несколько анимаций с несколькими кривыми, что делает этот процесс медленным и чреватым ошибками. Вместо этого редактирование YAML позволяет исправить несколько путей анимационных кривых за один раз, используя классическую операцию "поиск и замена" в файлах анимации с помощью текстового редактора, который вам наиболее знаком.

Как уже говорилось, каждый объект в YAML-файле имеет идентификатор, известный как "ID файла". Этот идентификатор уникален для каждого объекта в файле и служит для разрешения ссылок между ними. Подумайте об игровом объекте и его компонентах, компонентах и их игровых объектах, или даже о скриптовых ссылках, например, ссылка компонента "Weapon" на игровой объект "SpawnPoint" в том же префабе.
Формат YAML для этого - "{fileID: FILE ID}" в качестве значения свойства. На рисунке 6 видно, что это преобразование принадлежит игровому объекту с идентификатором 4112328598445621100, поскольку его свойство "m_GameObject" ссылается на него через идентификатор файла. Вы также можете наблюдать примеры нулевых ссылок, таких как "m_PrefabInstance" (учитывая, что его File ID равен нулю). Продолжайте читать, чтобы узнать больше о сборных домах.

Рассмотрим случай переназначения объектов внутри Prefab. Вы можете изменить File ID свойства "m_Father" трансформы на File ID новой целевой трансформы и даже исправить YAML старой родительской трансформы, чтобы удалить этот объект из ее массива "m_Children" и добавить его в свойство "m_Children" новой родительской трансформы.

Чтобы найти конкретное преобразование по имени, вы должны в первую очередь определить его GameObject File ID, выполнив поиск по файлу с искомым m_Name. Только после этого вы сможете найти Transform, чье свойство m_GameObject ссылается на этот File ID.
Если ссылаться на объекты за пределами этого файла, например, скрипт "Оружие" ссылается на префаб "Пуля", все становится немного сложнее. Помните, что идентификатор файла является локальным для файла, то есть он может повторяться в разных файлах. Для того чтобы однозначно идентифицировать объект в другом файле, нам нужен дополнительный идентификатор или "GUID", который идентифицирует весь файл, а не отдельные объекты внутри него. Каждый актив имеет это свойство GUID, определенное в его мета-файле, который можно найти в той же папке, что и оригинальный файл, с точно таким же именем и расширением ".meta".

Для файлов неродных форматов, таких как PNG-изображения или FBX-файлы, Unity сохраняет в метафайлах дополнительные параметры импорта, например максимальное разрешение и формат сжатия текстуры или масштабный коэффициент 3D-модели. Это делается для того, чтобы сохранить расширенные свойства файла отдельно и удобно версифицировать их практически в любой программе контроля версий. Но помимо этих настроек, Unity также сохранит в метафайле общие настройки ассетов, такие как GUID (свойство "GUID") или Asset Bundle (свойство "assetBundleName"), даже для папок или файлов родного формата Unity, таких как Materials.

Учитывая это, вы можете однозначно идентифицировать объект, объединив GUID в метафайле и File ID объекта в YAML, как показано на рисунке 10. Более конкретно, вы можете видеть, что YAML сгенерировал переменную "bulletPrefab" скрипта Weapon, которая ссылается на корневой объект GameObject с идентификатором файла 4551470971191240028 префаба с GUID afa5a3def08334b95acd2d70ee44a7c2.

Вы также можете увидеть третий атрибут под названием "Тип". Тип используется для определения того, из какой папки должен быть загружен файл: из папки Assets или из папки Library. Обратите внимание, что он поддерживает только следующие значения, начиная с 2 (учитывая, что 0 и 1 устарели):
- Тип 2: Активы, которые могут быть загружены редактором непосредственно из папки Assets, например материалы и файлы .asset
- Тип 3: Активы, которые были обработаны и записаны в папку Library и загружены оттуда редактором, например, префабы, текстуры и 3D-модели.
Еще один фактор, который следует подчеркнуть в отношении сериализации скриптов, - это то, что тип YAML для каждого скрипта один и тот же; только MonoBehaviour. Ссылка на реальный скрипт содержится в свойстве "m_Script", используя GUID мета-файла скрипта. Благодаря этому вы сможете наблюдать за тем, как относятся к каждому сценарию, просто как к активу.

Варианты использования этого сценария включают, но не ограничиваются ими:
- Поиск всех случаев использования актива путем поиска его GUID во всех других активах
- Замена всех использований этого актива на другой GUID актива во всем проекте
- Замена одного актива другим, имеющим другое расширение (например, замена MP3-файла на WAV-файл), путем удаления исходного актива, присвоения новому точно такого же имени с новым расширением и переименования мета-файла исходного актива с новым расширением.
- Исправление потерянных ссылок при удалении и повторном добавлении одного и того же актива путем замены GUID новой версии на GUID старой версии
При использовании экземпляров префабов в сцене или вложенных префабов внутри другого префаба, игровые объекты и компоненты префаба не сериализуются в использующем их префабе, а добавляется объект PrefabInstance. Как видно на рисунке 12, PrefabInstance имеет два ключевых свойства: "m_SourcePrefab" и "m_Modifications".

Как вы могли заметить, "m_SourcePrefab" - это ссылка на ассет Nested Prefab. Теперь, если вы будете искать его идентификатор файла во вложенном активе Prefab, вы его не найдете. В данном случае "100100000" - это File ID объекта, созданного во время импорта префаба, называемого Prefab Asset Handle, который не будет существовать в YAML.
Кроме того, "m_Modifications" включает в себя набор модификаций или "переопределений", внесенных в исходный префаб. На рисунке 12 мы переопределяем оси X, Y и Z исходного локального положения трансформируемого объекта внутри вложенного префаба, который можно определить по идентификатору файла в свойстве target. Обратите внимание, что рисунок 12 был сокращен для удобства чтения. Настоящий PrefabInstance, как правило, имеет больше записей в разделе m_Modifications.
Теперь вы можете задаться вопросом, если у нас нет объектов вложенных префабов во внешнем префабе, как мы можем ссылаться на объекты вложенных префабов? Для таких сценариев Unity создает в префабе объект-"заполнитель", который ссылается на соответствующий объект во вложенном префабе. Эти объекты-заместители помечены тегом "stripped", что означает, что они упрощены и имеют только те свойства, которые необходимы для работы в качестве объектов-заместителей.

На рисунке 13 аналогичным образом показано, что у нас есть Transform, помеченный тегом "stripped", который не имеет обычных свойств Transform (например, "m_LocalPosition"). Вместо этого свойства "m_CorrespondingSourcePrefab" и "m_PrefabInstance" заполняются таким образом, чтобы ссылаться на объект Nested Prefab Asset и объект PrefabInstance в файле, к которому он принадлежит. Над ним видна часть другой трансформации, чей "m_Father" ссылается на эту трансформацию-заместитель, делая этот GameObject дочерним объектом объекта Nested Prefab. По мере того как вы начнете ссылаться на большее количество объектов во вложенных префабах, в YAML будет добавляться все больше этих объектов-заместителей.
Удобно, что нет никакой разницы, когда речь идет о вариантах сборных конструкций. Базовый префаб варианта - это просто PrefabInstance с Transform, у которого нет родителя, что означает, что он является корневым объектом варианта. На рисунке 14 видно, что свойство "m_TransformParent" экземпляра PrefabInstance ссылается на "fileID: 0.” Это означает, что у него нет отца, что делает его корневым объектом.

Хотя вы можете использовать эти знания для замены вложенного префаба или базового префаба варианта на другой, такая модификация может быть рискованной. Действуйте осторожно и на всякий случай имейте запасной вариант.
Начните с замены всех ссылок на GUID текущего базового префаба на GUID нового, как в объекте PrefabInstance, так и в объектах-заместителях. Обязательно обратите внимание на идентификаторы файлов объектов-заместителей. Их свойства "m_CorrespondingSourceObject" ссылаются не только на актив, но и на объекты внутри него через их идентификаторы файлов. Вполне вероятно, что идентификаторы файлов объектов в текущем префабе будут отличаться от идентификаторов в новом префабе - и если вы их не исправите, то потеряете переопределения, ссылки, объекты и другие данные.
Как видите, изменение базового или вложенного префаба не так просто, как может показаться. Это одна из главных причин, почему он не поддерживается в редакторе.
Существует несколько сценариев, когда в YAML можно оставить устаревшие объекты и ссылки; одним из классических случаев является удаление переменных в скриптах. Если вы добавите скрипт оружия в префаб игрока, вам придется установить ссылку на префаб пули в существующий префаб, а затем удалить переменную префаба пули из скрипта оружия. Если вы не измените и не сохраните Player Prefab снова, повторно сериализовав его в процессе, ссылка на пулю останется в YAML. Другой пример касается объектов-заместителей вложенных префабов, которые не удаляются при удалении объекта из исходного префаба, что, опять же, можно исправить, изменив и сохранив префаб. Наконец, повторная сериализация активов может быть принудительно выполнена с помощью сценариев с API AssetDatabase.ForceReserializeAssets.
Но почему Unity автоматически не обрезает устаревшие ссылки в перечисленных выше сценариях? Это связано в первую очередь с производительностью; чтобы не пересортировывать все активы каждый раз, когда вы изменяете один скрипт или базовый префаб. Еще одна причина - предотвращение потери данных. Допустим, вы по ошибке удалили свойство скрипта (например, Bullet Prefab) и хотите его восстановить. Вам нужно только вернуть изменения в свой скрипт. Пока у вас есть переменная с тем же именем, что и удаленная, ваши изменения не будут потеряны. То же самое произойдет, если вы удалите префаб Bullet Prefab, на который ссылаетесь. Если вы восстановите префаб в том виде, в котором он был, включая метафайл, ссылка будет сохранена.
Обычно это не является проблемой во время выполнения, поскольку, когда Unity создает Player или Addressables, эти устаревшие объекты и ссылки очищаются. Но даже в этом случае есть некоторые случаи, когда устаревшие ссылки могут вызвать проблемы - а именно, использование чистых пакетов активов. При расчете зависимостей между бандлами активов учитываются устаревшие ссылки, которые могут создавать ненужные зависимости между бандлами, загружая больше, чем требуется во время выполнения. Об этом стоит подумать при использовании пакетов активов. Создайте или используйте любой существующий инструмент для удаления ненужных ссылок.
Хотя в большинстве случаев вы можете полностью игнорировать YAML, знакомство с ним полезно для понимания системы сериализации Unity. Несмотря на то, что крупные рефакторы и чтение или изменение YAML напрямую с помощью инструментов обработки активов могут быть быстрыми и эффективными, настоятельно рекомендуется по возможности искать решения на основе Unity Asset Database API. Он также особенно полезен для решения проблем слияния в системе контроля версий. Мы рекомендуем вам изучить инструмент Smart Merge, который может автоматически объединять конфликтующие префабы, а также прочитать больше о YAML в нашей официальной документации.