UObject, но вместе с этим добавляет нюансы, связанные с внутренними особенностями системы.Вступление#
Технически репликация UObject называется Sub-Object Replication. Сам по себе UObject реплицироваться не может — он передаётся только как часть AActor, так же как компонент, который не существует отдельно. В прошлых статьях мы уже пользовались этим механизмом, например через UAbilitySystemComponent и UHealthAttribute. Теперь же посмотрим, как реплицировать собственный UObject.
Все эксперименты будут проводится на Unreal Engine 5.7.0, где Iris уже переведена из статуса Experimental в Beta.
Sub-Object Replication#
Здесь разберём, как реплицировать UObject в контексте Iris и нового механизма SubObjectReplicationList. Раньше это работало иначе: у AActor или UActorComponent был метод ReplicateSubobjects, который нужно было переопределить и вручную вызывать Channel->ReplicateSubobject(MySubobject, *Bunch, *RepFlags);. Подробнее про старый и новый подход можно почитать в документации.
Чтобы реплицировать UObject, нужно выполнить несколько шагов:
- Включить использование ObjectReplicationList в DefaultEngine.ini. Насколько я понимаю, это заставляет все саб-объекты реплицироваться через новый механизм. Эта опция обязательна для Iris.
- Переопределить
IsSupportedForNetworking(). - Как и для любого
AActorилиUActorComponent, переопределить методvoid GetLifetimeReplicatedProps(TArray<FLifetimeProperty>& OutLifetimeProps) const. - Для добавления/удаления объектов в список репликации использовать методы
UActorComponent::AddReplicatedSubObject,UActorComponent::RemoveReplicatedSubObject,AActor::AddReplicatedSubObjectиAActor::RemoveReplicatedSubObject.
UObject должен указывать на тот Actor или Component, которому этот объект принадлежит.[SystemSettings]
net.SubObjects.DefaultUseSubObjectReplicationList=1
UCLASS()
class UMyObject : public UObject
{
// ...
bool IsSupportedForNetworking() const override { return true; }
UPROPERTY(Replicated)
int32 MyRepValue;
}
void UMyObject::GetLifetimeReplicatedProps(TArray<FLifetimeProperty>& OutLifetimeProps) const
{
Super::GetLifetimeReplicatedProps(OutLifetimeProps);
FDoRepLifetimeParams Params;
Params.bIsPushBased = true;
Params.Condition = COND_None;
DOREPLIFETIME_WITH_PARAMS_FAST(UMyObject, MyRepValue, Params);
}
Пример Component, который реплицирует свои UObject-ы — например, инвентарь.
UCLASS()
class UMyActorComponent : public UActorComponent
{
// ...
void Add(UMyObject* Object)
{
MyObjects.Add(Object);
AddReplicatedSubObject(Object);
}
UPROPERTY(Replicated)
TArray<TObjectPtr<UMyObject>> MyObjects;
// Не забудьте переопределить для MyObjects
void GetLifetimeReplicatedProps(TArray<FLifetimeProperty>& OutLifetimeProps) const override;
}
А здесь Actor реплицирует UObject-ы самостоятельно.
UCLASS()
class UMyActor : public AActor
{
// ...
void Add(UMyObject* Object)
{
MyObjects.Add(Object);
AddReplicatedSubObject(Object);
}
UPROPERTY(Replicated)
TArray<TObjectPtr<UMyObject>> MyObjects;
// Не забудьте переопределить для MyObjects
void GetLifetimeReplicatedProps(TArray<FLifetimeProperty>& OutLifetimeProps) const override;
}
Пример правильного создания UMyObject с указанием корректного Outer.
void CreateMyObject(UMyActor* Actor)
{
// Actor должен быть Outer-ом Instance
auto Instance = NewObject<UMyObject>(Actor);
Actor->Add(Instance);
}
void CreateMyObject(UMyActorComponent* Component)
{
// or Component должен быть Outer-ом Instance
auto Instance = NewObject<UMyObject>(Component);
Component->Add(Instance);
}
В исходниках Iris я встречал, что в некоторых случаях в качестве Outer может выступать и GetTransientPackage(). Но сам я это не пробовал, так что возможно я неправильно понял логику, или это какой-то частный, внутренний для движка случай.
В документации указано, что вы обязательно должны переопределить RegisterReplicationFragments.
You must implement RegisterReplicationFragments for any object interfacing with the Iris replication system. Any class inherited from AActor implements this function automatically, but any object not inherited from AActor must manually implement this function.
void UMyObject::RegisterReplicationFragments(UE::Net::FFragmentRegistrationContext& Context, UE::Net::EFragmentRegistrationFlags RegistrationFlags)
{
// Build descriptors and allocate PropertyReplicaitonFragments for this object
UE::Net::FReplicationFragmentUtil::CreateAndRegisterFragmentsForObject(this, Context, RegistrationFlags);
}
Это делать не обязательно — CreateAndRegisterFragmentsForObject всё равно будет вызван автоматически, если Context не зарегистрирован.
// Где-то внутри Iris
void CallRegisterReplicationFragments(UObject* Object, FFragmentRegistrationContext& Context, EFragmentRegistrationFlags RegistrationFlags)
{
// Try the user implementation first
Object->RegisterReplicationFragments(Context, RegistrationFlags);
// If no user implementations exist, create fragments automatically
if (!Context.WasRegistered())
{
FReplicationFragmentUtil::CreateAndRegisterFragmentsForObject(Object, Context, RegistrationFlags);
ensure(Context.WasRegistered());
}
}
MaxReplicatedObjectCount#
В Iris существует ограничение на максимальное количество реплицируемых объектов. Под этим подразумевается любой объект репликации, имеющий собственный NetId: каждый реплицируемый AActor, UActorComponent и UObject получает свой NetId.
Например: у нас есть 3 реплицируемых актора, на каждом по 3 реплицируемых компонента, а в каждом компоненте по 3 реплицируемых UObject. В сумме получается:
3 × 3 × 3 = 27 реплицируемых объектов.
Если суммарное количество реплицируемых объектов превысит значение MaxReplicatedObjectCount, игра или сервер упадёт с ошибкой.
NetRefHandleManager: Hit the maximum limit of active replicated objects: 65536. Aborting since we cannot replicate MyObject_C
// Try to grow the NetObjectLists if no more indexes are available.
if (InternalIndex == InvalidInternalIndex)
{
InternalIndex = GrowNetObjectLists();
// If we could not grow anymore, kill the process now. The system cannot replicate objects anymore and the game behavior is undefined.
if (InternalIndex == InvalidInternalIndex)
{
UE_LOG(LogIris, Fatal, TEXT("NetRefHandleManager: Hit the maximum limit of active replicated objects: %u. Aborting since we cannot replicate %s"), MaxActiveObjectCount, Params.ReplicationProtocol->DebugName->Name);
return InvalidInternalIndex;
}
}
По умолчанию MaxReplicatedObjectCount = 65536. Это значение должно быть степенью двойки, и его можно изменить либо в DefaultEngine.ini, либо в коде.
void UTheGameInstance::OverrideIrisReplicationSystemConfig(FNetDriverReplicationSystemConfig& OutConfig, bool bIsServer) const
{
Super::OverrideIrisReplicationSystemConfig(OutConfig, bIsServer);
OutConfig.MaxReplicatedObjectCount = 1 << 17; // 131072
}
Ниже приведено описание дополнительных параметров вместе со значениями по умолчанию.
uint32 MaxReplicatedObjectCount = 65536 (1 << 16)#
The maximum amount of netobjects that can be registered to the replication system
Note that this variable is automatically rounded up to a multiple of 32 so that all available bits in the NetBitArray storage type are used.
uint32 InitialNetObjectListCount = 65536 (1 << 16)#
The default allocated size for lists referencing NetObjects by their internal index (NetBitArray or TArray).
Use 0 to preallocate for all possible replicated objects and never reallocate the lists.
Setting a value smaller than Max minimizes the memory footprint of the replication system when few replicated objects are registered.
The downside is you have to pay a CPU hit when the initial list size is met.
uint32 NetObjectListGrowCount = 16384 (1 << 14)#
The amount by which we increase the size of every NetObjectList (NetBitArray and TArray) when we hit the initial amount.
Use a small value if you want to keep the memory footprint of the system to a minimum.
But be aware that increasing the NetObjectList’s is costly and may increase memory fragmentation so you’ll want to do pay the reallocation cost as little as possible.
Note that this variable is automatically rounded up to a multiple of 32 so that all available bits in the NetBitArray storage type are used.
uint32 PreAllocatedMemoryBuffersObjectCount = 65536 (1 << 16)#
The amount of netobjects to preallocate internal memory buffers for (NetChunkedArray types).
These arrays hold the biggest memory blocks in the replication system and can grow independently of the NetObjectLists.
Using a large amount of preallocated memory provides faster cache-friendly CPU operations but has the downside of holding into much more memory than might actually be needed. Reduce this value if you are operating on a memory constrained platform.
uint32 MaxReplicationWriterObjectCount = 0#
The maximum amount of netobjects that can replicate properties to remote connection.
Can be much lower on clients where very few netobjects have authority and support property replication (often just 1 player controller)
When set to 0 it will follow the MaxReplicatedObjectCount and InitialNetObjectListCount limits
uint32 MaxDeltaCompressedObjectCount = 2048#
The maximum amount of netobjects that can be added to the delta compression manager
uint32 MaxNetObjectGroupCount = 2048U#
The maximum amount of filter groups that can be created. @see UReplicationSystem::CreateGroup
Можно заметить, что InitialNetObjectListCount и PreAllocatedMemoryBuffersObjectCount по умолчанию стоят на отметке 65536. В описании к ним прямо говорится, что желательно избегать реаллокаций и сразу использовать большие буферы. Но тут важно быть МАКСИМАЛЬНО ОСТОРОЖНЫМ с размером этих преаллоцированных структур.
Например, в прошлых тестах выделенный сервер потреблял около 1 GB оперативной памяти. Но при увеличении MaxReplicatedObjectCount, InitialNetObjectListCount и PreAllocatedMemoryBuffersObjectCount до 524288 (1 << 19), расход памяти выходил за пределы 2 GB, доступных VM.
И память — не единственная проблема. Настолько крупные буферы создают постоянную и ощутимую нагрузку на CPU.
Чтобы проверить влияние настроек, я взял ту же тестовую сцену из прошлой статьи: Лес десяти тысяч деревьев (но HP вычитать не будем), плюс 100 игроков, которые бегут в центр карты. Единственное отличие — значения InitialNetObjectListCount и PreAllocatedMemoryBuffersObjectCount.
Конфигурация выглядит вот так:
OutConfig.MaxReplicatedObjectCount = 1 << 18; // 262144
OutConfig.InitialNetObjectListCount = OutConfig.MaxReplicatedObjectCount;
OutConfig.PreAllocatedMemoryBuffersObjectCount = OutConfig.MaxReplicatedObjectCount;
По графикам видно, что только из-за увеличения преаллокированных буферов FPS просел с 50+ до примерно 33 — и это при абсолютно той же сцене.
Сначала я подумал, что это какая-то ошибка, как в 100 игроков в одном месте.

1 << 16)
1 << 18)Посмотрев на данные профайлера, видно, что рост времени распределён довольно равномерно по всем категориям — это особенность Iris, о которой стоит помнить. Нужно внимательно следить за количеством реплицируемых объектов: MaxReplicatedObjectCount может быть большим, но как только реальное число объектов превышает размер текущих буферов, они расширяются на величину NetObjectListGrowCount, съедая и RAM, и CPU. При этом буферы назад не сжимаются, даже если общее количество реплицируемых объектов уменьшилось — например, когда с карты исчез мусор, раскиданный игроками. Поэтому чем меньше разумное значение MaxReplicatedObjectCount, тем лучше.
Тестирование Sub-Object Replication#
У меня есть собственный плагин InventorySystem, где каждый айтем представлен отдельным UObject, а UInventoryComponent хранит список принадлежащих ему предметов. В нашем тестовом мире уже растёт 10k+ деревьев, и я добавлю на каждое из них по InventoryComponent, положив внутрь по три яблока — то есть по три UObject.
Подсчитаем примерное количество реплицируемых объектов. На 10201 дереве находится по одному UHealthAttribute, UAbilitySystemComponent, UInventoryComponent и по три UAppleItem. В сумме получается:
10201 (ATree) + 10201 (UHealthAttribute) + 10201 (UAbilitySystemComponent) + 10201 (UInventoryComponent) + 30603 (UAppleItem) = 71407 реплицируемых объектов.
Плюс 100 игроков, у которых тоже есть UInventoryComponent и по три UAppleItem.
Чтобы визуально проверить, что объекты действительно реплицируются, я меняю масштаб деревьев: по умолчанию они имеют скейл 0.5 по оси Z, а когда в их инвентаре появляется яблоко — скейл становится 1. Напоминаю, что радиус репликации составляет 150 метров, поэтому деревья за пределами этого радиуса будут короткими, а те, что внутри, увеличатся.
В этом тесте нас интересует только влияние большого количества реплицируемых сущностей на производительность. Никаких дополнительных данных — вроде свежести яблок или количества в стаке — меняться не будет. Смотрим только статическую нагрузку.
Так как 71407 превышает стандартный пул 65536, придётся его увеличивать. Но из-за того, что сам факт увеличения пула уже повышает расход CPU, наша точка отсчёта (когда на деревьях и игроках нет UInventoryComponent и UAppleItem) всё равно будет работать на увеличенных и заранее подготовленных буферах. Это нормально: нам важно сравнить именно влияние количества реплицируемых объектов.
OutConfig.MaxReplicatedObjectCount = 1 << 17; // 131072
OutConfig.InitialNetObjectListCount = OutConfig.MaxReplicatedObjectCount;
OutConfig.PreAllocatedMemoryBuffersObjectCount = OutConfig.MaxReplicatedObjectCount;
Как можно заметить, дополнительные 40 000+ неизменяющихся объектов напрямую не повлияли на производительность — за это можно поблагодарить PushModel. Но косвенно нагрузка всё же выросла, потому что нам пришлось увеличить размер буфера.
Выводы#
Нужно держать количество реплицируемых объектов под контролем. Если буферы однажды разрастутся, это превратится в постоянное пенальти к производительности. Насколько я понял, конфигурацию можно разделять для клиента и сервера, ведь клиент обычно знает о гораздо меньшем количестве реплицируемых объектов.
В ходе экспериментов я поднимал размер преаллоцируемых буферов до 2 миллионов и в итоге получал около 40 FPS в редакторе — на пустой карте, где реплицируется всего один персонаж и контроллер.
Напоследок я провёл эксперимент, просто увеличив MaxReplicatedObjectCount в надежде, что NetObjectListGrowCount «сам всё компенсирует», ведь превышение лимита было всего на ~20 000 объектов.
