1. Предыстория

Я работаю в компании которая пишет на Unreal Engine шутер от первого лица. Недели две назад в игре начало проявляться странное поведение. Игровая логика начала считать что все персонажи игры находятся в одной и той же команде.

У нас информация о командной принадлежности хранится в компоненте персонажа:

Структура классов
Структура классов

За счёт использования точки останова по памяти первопричину удалось найти достаточно быстро. Оказалось что у всех создаваемых на сцене экземпляров блупринта персонажа BP_Character_Player указатели TeamMemberComponent хранят один и тот же адрес.

Это был адрес компонента у которого в качестве Outer указывался Class Default Object (дальше - CDO) родительского для BP_Character_Player класса BP_Character (если конкретнее - объект под названием Default_BP_Character_С).

Загадочный Outer
Загадочный Outer

Я собрался с духом и погрузился на несколько дней в изучение проблемы.

2. Поверхностный разбор проблемы

Дебаг показал что некорректные указатели попадают в экземпляры BP_Character_Player при заполнении полей этих экземпляров из значения соответствующих полей CDO. Происходило это в функции FObjectInstancingGraph::GetInstancedSubobject(). В отличие от указателей на другие компоненты, для поля TeamMemberComponent не проходила следующая проверка:

UObject* FObjectInstancingGraph::GetInstancedSubobject( ... )
{
    ...
   bool bShouldInstance = ... ||  >>> SourceSubobject->IsIn(SourceRoot) <<<;
    ...  
}

Примечание: На проекте используется версия движка 4.27

Проверка не проходила потому что в CDO класса BP_Character_Player указатель TeamMemberComponent указывал не на этот CDO, а на CDO базового класса BP_Character. Из-за этого указатели не связывались с компонентами созданного экземпляра, а продолжали указывать на родительские компоненты по-умолчанию.

Default Subobjects: правильные и не очень
Default Subobjects: правильные и не очень

Дальнейшее углубление в код показало что значение всех полей кроме проблемного поля Team Member Component для CDO класса BP_Character_Player (Default_BP_Character_С) читались... с диска, из файла блупринта BP_Character_Player, если конкретнее - в методе UStruct::SerializeVersionedTaggedProperties().

Собственно, на этом месте стало приблизительно ясно с чем может быть связана проблема. Вопросы вызывал уже сам по себе факт хранения и вычитки из файла блупринта информации об указателях связанных с Default Subobject - ведь их, очевидно, можно связывать по FName-именам инстансов. Зачем хранить на диске указатели на объекты которые создаются в конструкторе?

Подебажив ещё немного в логике сериализации, обнаружил код который проверяет нужно ли выполнять сериализацию / десериализацию полей:

FProperty::ShouldSerializeValue(FArchive& Ar)

Это всё что отделяет ваши поля от того чтобы они сохранялись на диске:

bool FProperty::ShouldSerializeValue( FArchive& Ar ) const
{
	if (Ar.ShouldSkipProperty(this))
	{
		return false;
	}
    //Через побитовые операции проверяется текущее состояние PropertyFlags.
	if (!(PropertyFlags & CPF_SaveGame) && Ar.IsSaveGame())
	{
		return false;
	}

	const uint64 SkipFlags = CPF_Transient | CPF_DuplicateTransient | CPF_NonPIEDuplicateTransient | CPF_NonTransactional | CPF_Deprecated | CPF_DevelopmentAssets | CPF_SkipSerialization;
	if (!(PropertyFlags & SkipFlags))
	{
		return true;
	}

	bool Skip =
			((PropertyFlags & CPF_Transient) && Ar.IsPersistent() && !Ar.IsSerializingDefaults())
		||	((PropertyFlags & CPF_DuplicateTransient) && (Ar.GetPortFlags() & PPF_Duplicate))
		||	((PropertyFlags & CPF_NonPIEDuplicateTransient) && !(Ar.GetPortFlags() & PPF_DuplicateForPIE) && (Ar.GetPortFlags() & PPF_Duplicate))
		||	((PropertyFlags & CPF_NonTransactional) && Ar.IsTransacting())
		||	((PropertyFlags & CPF_Deprecated) && !Ar.HasAllPortFlags(PPF_UseDeprecatedProperties) && (Ar.IsSaving() || Ar.IsTransacting() || Ar.WantBinaryPropertySerialization()))
		||  ((PropertyFlags & CPF_SkipSerialization) && (Ar.WantBinaryPropertySerialization() || !Ar.HasAllPortFlags(PPF_ForceTaggedSerialization)))
		||  (IsEditorOnlyProperty() && Ar.IsFilterEditorOnly());

	return !Skip;
}

Напрашивался костыль для решения проблемы.

3. Решение проблемы

Решение: Явно помечать указатели на Default Subobject как Transient.

Небольшой костыль в UProperty
Небольшой костыль в UProperty

Данное изменение исправляет проблему.

4. Зачем делать публикацию без глубокого разбора?

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

Собственно, отсутствие популярных публикаций по теме - одна из ключивых причин по которым я взял на себя смелость поделиться проблемой. Обсудить её стоит хоть как-нибудь - ведь она может возникать даже на базе кода из официального примера работы с компонентами на сайте Epic Games (то есть, на базе кода для обучения новичков).

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

В процессе отладки я вспомнил что с описываемой проблемой сталкивались ребята с прошлого проекта. Тогда мне, можно сказать, повезло - она бахнула когда я был занят менее Unreal-специфичной частью проекта. Столкнувшись с ней сейчас, я связался с бывшими коллегами и оказалось что они так же не до конца разобрались в проблеме. Решили как и я: Transient помогает - и ладно. Не могу их винить за подобную позицию - глубоко отлаживать код сериализации в Unreal действительно тяжело.

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

Подытоживая: сравнив риск получить по голове за сырой материал и вероятность принести пользу сообществу запустив обсуждение темы - я выбрал второе. Заранее приношу извинения тем кого может задеть подобный неглубокий подход.

Если кто что знает о проблеме - поделитесь информацией. От этого будет польза для всего сообщества.

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


  1. sergegers
    22.05.2023 17:43

    Картина Кузьмы Петрова-Водкина: Купание педального коня.


  1. tm1218
    22.05.2023 17:43

     ведь их, очевидно, можно связываться по FName-именам инстансов.

    здесь опечатка: связывать


    1. semenyakinVS Автор
      22.05.2023 17:43

      Спасибо, поправлю.

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

      Ещё раз спасибо за то что заметили опечатку)