1. Предыстория
Я работаю в компании которая пишет на Unreal Engine шутер от первого лица. Недели две назад в игре начало проявляться странное поведение. Игровая логика начала считать что все персонажи игры находятся в одной и той же команде.
У нас информация о командной принадлежности хранится в компоненте персонажа:
За счёт использования точки останова по памяти первопричину удалось найти достаточно быстро. Оказалось что у всех создаваемых на сцене экземпляров блупринта персонажа BP_Character_Player
указатели TeamMemberComponent
хранят один и тот же адрес.
Это был адрес компонента у которого в качестве Outer указывался Class Default Object (дальше - CDO) родительского для BP_Character_Player
класса BP_Character
(если конкретнее - объект под названием Default_BP_Character_С)
.
Я собрался с духом и погрузился на несколько дней в изучение проблемы.
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
. Из-за этого указатели не связывались с компонентами созданного экземпляра, а продолжали указывать на родительские компоненты по-умолчанию.
Дальнейшее углубление в код показало что значение всех полей кроме проблемного поля 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.
Данное изменение исправляет проблему.
4. Зачем делать публикацию без глубокого разбора?
Да, у меня не вышло направленно воспроизвести проблему. Пробовал разные вариации сохранения блупринтов - проблема не наблюдалась. Я имею лишь смутные догадки о её первопричинах. В свози с известными обстоятельствами, у меня нет сил дальше разбираться в деталях. Найти развёрнутые публикации по теме также не вышло.
Собственно, отсутствие популярных публикаций по теме - одна из ключивых причин по которым я взял на себя смелость поделиться проблемой. Обсудить её стоит хоть как-нибудь - ведь она может возникать даже на базе кода из официального примера работы с компонентами на сайте Epic Games (то есть, на базе кода для обучения новичков).
Думаю, небольшое количество обсуждений проблемы связано с захламлённостью и запутанностью системы инициализации и загрузки состояний UObject-ов в Unreal. Мало кому хватает сил в полной мере разобраться во всех нюансах и после никто не хочется делиться сырыми предположениями.
В процессе отладки я вспомнил что с описываемой проблемой сталкивались ребята с прошлого проекта. Тогда мне, можно сказать, повезло - она бахнула когда я был занят менее Unreal-специфичной частью проекта. Столкнувшись с ней сейчас, я связался с бывшими коллегами и оказалось что они так же не до конца разобрались в проблеме. Решили как и я: Transient помогает - и ладно. Не могу их винить за подобную позицию - глубоко отлаживать код сериализации в Unreal действительно тяжело.
Мне эта ситуация навеяла аналогию. Мы словно электрики работающие с линейкой электромоторов без понимания нюанса: что нужно дёрнуть где-то внутри плохо документированный рычажок снимающий напряжение с цепи. Время от времени нас бьёт таком и каждый, покопавшись в цепи, методом научного тыка дёрнув нужный рычаг такой "уу, ну ясно, надо бы всегда так делать теперь". Но другим не рассказывает - потому что рычажок не ясно почему помогает.
Подытоживая: сравнив риск получить по голове за сырой материал и вероятность принести пользу сообществу запустив обсуждение темы - я выбрал второе. Заранее приношу извинения тем кого может задеть подобный неглубокий подход.
Если кто что знает о проблеме - поделитесь информацией. От этого будет польза для всего сообщества.
Комментарии (3)
tm1218
22.05.2023 17:43ведь их, очевидно, можно связываться по FName-именам инстансов.
здесь опечатка: связывать
semenyakinVS Автор
22.05.2023 17:43Спасибо, поправлю.
Просьба комментаторам: давайте чтобы замечания по редакторскими правками не мешались с обсуждением наполнения публикации - пишите правки в личку, буду исправлять.
Ещё раз спасибо за то что заметили опечатку)
sergegers
Картина Кузьмы Петрова-Водкина: Купание педального коня.