Сетевая оптимизация для Unreal Engine 4
Не так давно в официальной группе UE4 в vk я спрашивал, какие темы были бы интересны сообществу, чтобы о них рассказать :) Одним из популярных запросов стала работа с сетью на движке.
В начале я не планировал как-то раскрывать или упоминать эту тему, но потом подумал, что оформить «Best Practices» было бы неплохо даже для себя и своей команды.
Так что, если вам интересно, как мы делали сеть для нашей Armored Warfare: Assault, добро пожаловать под кат.
Нельзя представить себе Unreal Engine в отрыве от Unreal Tournament, лейтмотивом проходящим через все версии движка. Как следствие, одной из сильных сторон UE4 является мощнейший сетевой инструментарий, интегрированный в движок на базисном уровне. По моей личной оценке, единственный движок, который столь же щепетильно подходил бы к вопросам на том же уровне — это движок Quake 3.
Наличие качественной технологии не лишает нас необходимости думать при разработке. К сожалению, мне доводилось видеть немало проектов, где безответственное отношение к вопросам организации работы сети приводило к плачевным результатам.
Данная статья — ни разу не «руководство для начинающих» или «детальное описание, как всё работает». Нет, это определенный утрированный взгляд на принципы, которые позволяют эффективно оптимизировать сеть.
Как всё устроено
Для начала работы над мультиплеером в UE4 потребуется понимание трех доступных путей коммуникации:
- Репликация переменных: от сервера на клиент, и не иначе. Клиент ни при каких условиях не может изменить переменную так, чтобы она поменяла свое значение на сервере.
- RPC: от сервера на клиент.
- RPC: от клиента на сервер.
Третий путь — единственный, чтобы сообщить какие либо данные с клиента на сервер. Репликация переменных (первый путь) используется для синхронизации состояний экторов между сервером и клиентами. Отправка RPC с сервера на клиент (второй путь) — событийная модель для отправки специфических данных.
Коротко, работает всё так. У каждого реплицируемого эктора есть параметр NetUpdateFrequency, который задаёт, сколько раз в секунду эктор будет проверять своё состояние на предмет «чем бы мне обменяться по сети». По умолчанию этот параметр равен безумным 100.f, что означает: если ваш эктор реплицируется, попытки синхронизации и отправки данных будут каждый тик.
Последствия очевидны и печальны: забить сеть пакетами становится элементарной задачей. Нагрузка на CPU сервера возрастает. Всё лагает, «ничего не работает», «персонаж телепортируется», «другие игроки дрожат», и прочее в том же духе на, казалось бы, простейших проектах.
Так мы приходим к самому первому правилу: выстави адекватные NetUpdateFrequency для всех реплицируемых экторов.
Что такое «адекватные» — вопрос, как всегда, открытый. Для хардкорного квейкообразного шутера всё, что касается персонажа — его движение и оружие, — должно синхронизироваться с максимальной частотой. Но это специфический случай, и если вы работаете над таким проектом, «базовых» знаний и подходов вам не хватит — копайте глубже, если хотите получить качественный продукт.
В некоем «усредненном» случае — аркады, MOBA, мобильные игрушки, а также «медленные шутеры» а-ля танки и прочие — частота сетевых обновлений может и должна быть гораздо ниже. У себя в AW: Assault мы используем частоту обновления состояния танка 10 раз в секунду. Я также знаю проекты, которые работают исходя из частоты сетевых обновлений главного персонажа 6–8 раз в секунду.
Прочие объекты — различные «точки захвата», «флаги», «патроны», «игровые состояния» — могут обновляться еще реже. Хороший пример: по умолчанию движковый класс PlayerState реплицируется всего раз в секунду, и это правильно. Если внезапно какое-то изменение в состоянии эктора должно быть доставлено максимально быстро, всегда есть возможность вызвать ForceNetUpdate ().
Замечу, что компоненты эктора наследуют его частоту сетевых обновлений, поэтому сразу приходящее на ум «разделение» одного эктора на части с разной частотой обновлений — задача нетривиальная. Точнее так: если какой-то компонент требует иной частоты, чем способен жить эктор в целом, надо аккуратно вырезать его в отдельную сущность. Если же компонент может жить в сети «медленнее», чем его владелец, при этом владелец обновляется не каждый тик — это нормальная ситуация.
Правило второе: reliable RPC должны быть серьезно обоснованы. У нас в отделе ходит шутка, что каждая reliable rpc функция выдаётся «под роспись» руководителя. В каждой шутке доля шутки.
Надо держать в голове, что это дорого. Очень. Накладные расходы на RPC как таковую не так велики, сколько возможные последствия. Обращаться аккуратно, как со склянкой нитроглицерина. Особенно в случае multicast. Самое страшное, что может у вас случиться после того, как вы перестали «телепортироваться» — отключение клиента от сервера из-за переполнения такой штуки, как reliable buffer. Построенная на таких эвентах сетевая архитектура становится гиперчувствительна к пингу и потере пакетов.
Простой пример: вы хотите отправить с сервера на клиент уведомление о каком-то действии. Допустим, вы делаете симулятор алкоголика онлайн, и у вашего персонажа каждый тик регенирует печень, существующая как отдельный компонент. Всем клиентам очень надо знать каждый шаг «лечения», чтобы красиво показать это на экране (multicast, reliable). Каждый тик вы отсылаете RPC LiverHealed (float HealedHeath). Тестируете в редакторе на двух клиентах: красота, все довольны. И вот, живая ситуация: клиент залагал, потеря пакетов, надо отправить все накопившиеся за полсекунды RPC, и вы обнаруживаете, как больной радостно вылетает с сервера.
Очевидно, что как минимум не надо слать RPC каждый тик сервера, если NetUpdateFrequency в разы меньше — это просто застолбит очередь. Надо аккумулировать эти значения и отсылать реже. И еще раз подумать, а точно ли это reliable данные, и если так, нельзя ли обойтись репликацией переменной, а если не так — сделать unreliable. Во многих случаях также стоит подумать, не может ли клиент сам рассчитать, произошло ли событие, на основании имеющихся у него данных об игровом мире (то же самое лечение печени персонажа — это изменение её health’ов за клиентский тик).
Концептуальные ловушки
Или скорее пара вещей, о которых стоит сказать.
- При занулении переменной — реплицируемого UObject’а, — OnRep не будет вызван и перестанет вызываться на клиенте в дальнейшем. Звучит как баг, и если честно, я не помню, почему оно работает именно так. Достаточно об этом знать и учитывать при разработке.
- Вызов RPC на сервере не выполнит саму функцию на сервере, только отошлёт команду выполнить её на клиенте. Если необходимо выполнить её и на сервере, надо вручную вызвать Implementation.
- Если эктор обновляется по сети редко (например, PlayerState), дважды с опаской применяйте там reliable RPC. За время между синхронизациями вы легко можете забить буфер, если не будете об этом думать. В идеале, такие специальные сетевые классы должны тикать и обновляться по сети с однопорядковой частотой.
Сетевые трики и приёмы
Направлены как на оптимизацию нагрузки на сеть, так и на оптимизацию нагрузки на CPU.
Глобальные стейты
Кстати, правило третье: если можешь обойтись без репликации экторов — сделай это.
Два примера из нашего AW: Assault, которые реализуют это правило, но по-разному:
- Разрушаемые деревья. Они некритичны для геймплея, поэтому просто не реплицируются. Каждый клиент уронит своё дерево сам, на основании доступных у него данных. Совершенно не страшно, что реальная картина мира в этом плане разнится между сервером, клиентами и между клиентами.
- Разрушаемые объекты окружения. Заборы, машины, заграждения. Они напрямую влияют на геймплей, за ними можно «прятаться», пока объект не разрушен. Поэтому требуется, чтобы эти объекты на всех клиентах отражали реальное положение дел. Для синхронизации используется этот самый глобальный стейт.
На втором примере всю концепцию можно описать так:
/** Array of bit masks for minimization of space used for destructible actors states. Replication handled by OnRep_DestructableMasks method */
UPROPERTY(ReplicatedUsing=OnRep_DestructableMasks)
TArray DestructedActorsMasks;
/** Handles replication of destructible actors masks */
UFUNCTION()
void OnRep_DestructableMasks();
Каждый int32 кодирует состояние целых 32-х объектов. На сцене у нас может быть более тысячи таких объектов. В случае кодирования до 1024 объектов можно было бы обойтись всего двумя int32 (перемножение битовых масок), но мы пока оставили репликацию массива, т.к. даже текущее решение работает, не нагружая сеть. На этапе загрузки карты передача по сети условных сорока интов — не слишком большие данные, а во время боя не так много объектов умирают одновременно. Заботу об оптимальной репликации массива при изменении некоторых его полей берет на себя движок.
Данный подход работает на основании того, что статические объекты имеют четкий порядок загрузки и на сервере, и на клиенте. Их можно закэшировать в массив и реплицировать только индексы измененных объектов. И пускай сейчас реализована лишь бинарная логика (жив/разрушен), такой подход будет оправдан, даже если появятся дополнительные поля (например, «здоровье» и «пробиваемость»), т.к. это однородные экторы, живущие в большом количестве на карте.
Хотите сделать полностью разрушаемый мир из стен? Работайте с ними через такие стейты. Несколько интов в виде битовых масок дадут возможность закодировать тысячи разрушаемых объектов. «Стандартный» же путь через репликацию каждого такого эктора отдельно легко убьет вам как сеть, так и CPU сервера (на проверку кого там надо реплицировать и кому, а кого — нет).
Упаковка данных
В общем-то, пунктом выше я эту тему уже затронул, только опосредованно: там мы упаковываем информацию о состоянии тысяч объектов на сцене в массив битовых масок. Однако, тот же самый приём стоит применять и для флагов внутри реплицируемых экторов.
На определенном этапе мы пришли к тому, что обычных реплицируемых булевских флагов в танке стало более десятка. Это обычные геймплейные состояния «тонет ли танк», «поджог», «цель залочена», «умер» и другие.
В итоге, нашим кор-программистом был написан прокси-класс для репликации таких состояний. Использование выглядит так:
RepOwnerFlags
.Add(&bEnableLockTarget)
.Add(&bCanMove)
.Add(&bIsDrowning)
.Add(&bInWater)
.Build();
RepPublicFlags
.Add(&bIsDying, this, "OnRep_IsDying")
.Add(&bIsMoving, this, "OnRep_IsMoving")
.Add(&bIsTurning, this, "OnRep_IsTurning")
.Add(&bIsInFire)
.Add(&bIsEngineBurning, this, "OnRep_IsEngineBurning")
.Add(&bHasMinimapObservers)
.Build();
RepOwnerFlags и RepPublicFlags — реплицируемые переменные класса, работающие как обёртка над uint64. Сами же переменные стали обычными, не реплицируемыми с точки зрения движка булями:
/** Notifies of death */
UFUNCTION()
void OnRep_IsDying();
/** Identifies if pawn is in its dying state */
UPROPERTY(VisibleAnywhere, BlueprintReadOnly, Category = Death)
bool bIsDying;
Хорошей идеей будет также паковать bool’ы в uint8:1 внутри реплицируемых структур, если их несколько.
И да, аналогичное правило действует и для RPC функций. Отправляешь несколько булей — пакуй их. С флоатами в вектор и int8 (по-возможности) вместо int32 та же ситуация.
Жирные экторы
Легко представить ситуацию, когда стартовая репликация эктора занимает приличное время. Например, данные о броне танка в нашем случае (внутри танк состоит из большого количества кусочков, со своими параметрами, зависящими от прокачки).
Когда на репликацию от эктора поступает большой кусок данных, сеть считается перегруженной, и никакие другие экторы всё это время также не реплицируются.
В этом случае хорошим способом избежать сатурации сети будет разместить эти данные по частям в массиве и реплицировать кусками, с проверкой на вероятную сатурацию:
bool AMyActor::ReplicateSubobjects(class UActorChannel **Channel, class FOutBunch **Bunch, FReplicationFlags **RepFlags)
{
bool WroteSomething = Super::ReplicateSubobjects(Channel, Bunch, RepFlags);
auto NetDriver = Channel->Connection->GetDriver();
for (int32 i = 0; i < MyDataArray.Num(); i++)
{
// Check for saturation
if (((Channel->Connection->QueuedBits) + Channel->Connection->SendBuffer.GetNumBits() + Bunch->GetNumBits()) >= 0)
{
return WroteSomething;
}
auto DataObject = MyDataArray[i];
if (DataObject != nullptr)
{
WroteSomething |= Channel->ReplicateSubobject(DataObject, **Bunch, **RepFlags);
}
}
return WroteSomething;
}
MyDataArray — это те самые данные. Такой подход позволяет избежать «зависания» всей сети при создании эктора по сети.
Стоит учесть, что в этом случае эктор будет создан, показан и оживлён до того, как отреплицируются все его свойства. Если эти свойства определяют его внешний вид и поведение, хорошей идеей будет скрыть его до окончания полной сетевой инициализации.
Условие, а не результат
С точки зрения оптимизации сети будет правильным реплицировать клиентам условие, порождающее вычисления, а не конечный результат таких вычислений. Например, информация о точке, куда «смотрит» персонаж, может породить все расчеты о вращении и прицеливании, которые можно просчитать и сгладить на клиенте.
На другой стороне медали — оптимизация клиента. Чем меньше считает клиент, тем приятнее играть на тех же мобилках.
Квантизация векторов
Очень редко требуется реплицировать вектор с полной точностью. Поэтому для репликации векторов стоит использовать специальные классы, оптимизированные под это дело: FVector_NetQuantize, FVector_NetQuantize10, FVector_NetQuantize100 и FVector_NetQuantizeNormal.
Из личной практики: точность выше FVector_NetQuantize100 не требовалось ни разу, в абсолютном большинстве для сети используются FVector_NetQuantize и FVector_NetQuantizeNormal.
(Не) использование сетевой релевантности
Если вы не знаете, зачем вам сетевая релевантность для игровых персонажей, лучше её отключите. Настройки «по умолчанию» в движке рассчитаны больше для квейкообразных шутеров (уши UT, о которых я уже говорил). Они не подходят для игр с открытым (визуально) миром, да и случай с «жирными» экторами тоже не очень приятен.
Сервер на основании NetCullDistanceSquared (расстояние в квадрате) между эктором и игроком решает, должен ли этот эктор существовать на конкретном клиенте. Если расстояние между ними больше, эктор будет удален с клиента, если меньше — заново создан на клиенте. Это операция проводится с таймаутом в 5 секунд (значение RelevantTimeout по умолчанию).
Ключевое здесь — заново создан. В случае жирного эктора данный процесс может как забить сеть, так и привести к фризу на время спавна объекта (если его ресурсы успели выгрузиться из памяти). В случае открытого мира это еще и появление посреди локации «из ниоткуда». Если ваши экторы постоянно бегают «на границе» (по-умолчанию она проходит в 150 метрах от вашего персонажа), этот процесс может быть неприятным.
В это же время правильно настроенная релевантность может существенно сэкономить вам траффик и нервы, если геймплей позволяет провести такую настройку. В случае нашего AWA сетевая релевантность не используется никак: в отличие от привычной многим схемы «танк виден на клиенте, только когда обнаружен», у нас вы можете увидеть танк противника через всю карту.
Конечно, управлять релевантностью вы можете не только на основании встроенного механизма, но и переопределив AActor: IsNetRelevantFor(…). Иногда это необходимо не столько из-за соображений оптимизации сети, сколько для защиты от читеров. На примере MOBA: персонажи, скрытые в тумане войны, не должны реплицироваться на клиент, чтобы избежать самых банальных мапхаков. Нет данных — нет конфеток.
Отладка сети
Плейтесты
Играйте. Тестируйте. Проверяйте. Не в редакторе и тепличных условиях, а в реальных — с собранным клиентом и развёрнутым в боевых условиях сервером. Локальный сервак на девелорской машине — плохая идея, особенно если потом вы планируете использовать виртуалки, где обычно ~4000 flops производительности.
И да, каким бы удобным ни было тестирование сети в редакторе, реальная сборка, особенно если речь идет о консолях или мобильных устройствах, это всё равно отдельная вселенная. Поведение клиента в неидеальных условиях будет иным.
Network Profiler
В комплекте с движком идёт прекрасная утилитка: NetworkProfiler
Буквально недавно вот так мы искали причину дисконнекта на одном конкретном танке:
Профайлер имеет свои странности и ограниченно работает на мобильных устройствах, но даёт понимание общей картины ваших сетевых данных.
Наиболее полезен он для выявления подозрительно «жирных» данных и сетевых спайков. Использовать обязательно.
Симуляция плохой сети
Плейтесты могут выявить только очевидные проблемы с сетью. К сожалению, настоящие проблемы начинаются тогда, когда ваш платящий пользователь сидит в далекой деревне, у него есть Wi-Fi-роутер и трехметровая антенна на доме, через которую идёт интернет на этот роутер, и вышка сотовой связи в трех километрах. С пакетами в такой конфигурации может случиться что угодно, при том, что пинг будет достаточно адекватный.
UE4 «из коробки» умеет симулировать различные условия сети, такие как задержку пинга, потерю пакетов или их неправильный порядок. Подробнее об этом написано здесь: Finding Network-based Exploits
В эпиковской статье приводится в пример конфигурирование сети через указание параметров в ini, для тестирования это не слишком удобно. Тем более, что для полноценного тестирования удобнее иметь несколько преcетов и переключаться между ними в runtime, без перезапуска редактора.
Делается это так: в папке UE4/Engine/Binaries/ вы создаете файл, например, network_bad.txt, такого содержания
Net PktLoss=1
Net PktOrder=0
Net PktDup=0
Net PktLag=120
Net PktLagVariance=0
p.netshowcorrections 1
Теперь вы можете прямо в консоли редактора вызвать exec network_bad.txt и применить описанные настройки. Как вы уже поняли, это просто набор консольных команд, «упакованных» в файл.
Мониторинг траффика
Поднимаете дедикейтед. Устраиваете на нём плейтест. Смотрите траффик на отдельно взятом порту. Оцениваете, сколько в среднем траффика на вход и на выход.
Этот пункт весьма очевиден, но почему-то многие им пренебрегают.
Послесловие
Я постарался кратко рассказать обо всех вещах, которые я считаю принципиально важными на общем уровне при работе над мультиплеером на анриале. Можно бесконечно говорить о лагокомпенсации, сетевой интерполяции/экстраполяции, особенностях сетевых архитектур, но всё это будет базироваться на тех же самых принципах и подходах, описанных выше. Да и заслуживает отдельной статьи.
Наш результат на AW: Assault — возможность играть на 3G сети без каких-либо проблем и значимых лагов. Даже Edge (правда, при стабильном соединении) можно назвать достаточным. На мой взгляд, это весьма достойные цифры для мультиплеера на 16 игроков. Помимо толщины канала, мы также весьма не критичны к пингу, в отличие от многих других игр.
Если вам есть что дополнить, опровергнуть или обсудить — добро пожаловать в комментарии! :)