Оптимизация сетевого кода онлайн-шутера — это не только экономия на серверах и трафике, но еще и создание комфорта для игроков. Она особенно важна, когда на одной карте встречается огромное количество пользователей как, например, в батлрояле на 100 человек.
В этой статье расскажу о том, как мы обновили сетевой код для собственной королевской битвы на Unity, ввели систему сетевых чанков, контролируемый рандом и в результате сократили траты на сервера на 20%.
Для тех, кто не читал предыдущую статью про создание карты и оптимизацию графической части, коротко опишу ситуацию.
Мы успешно развивали наш мобильный PvP-шутер, интегрировали фичи, о которых просили пользователи, наращивали аудиторию, все было хорошо и безоблачно. Но одним утром, взглянув на статистику, наш аналитик попросил стакан холодной воды и священника. Мы потеряли 30% онлайна за день — так отразился выход Fortnite на мобильные платформы. Батлрояли захватили сознание игроков, и мы поняли, что нужно срочно браться за разработку собственной реализации режима.
Мы хотели сделать королевскую битву со всеми классическими атрибутами жанра: до 100 игроков прыгают с самолета, лутаются, сражаются и спасаются от сужающейся зоны, а последний оставшийся в живых — побеждает.
Времени на обычный пайплайн (гипотеза-концепт-тз-разработка) у нас не было, поэтому после короткого, но основательного планирования, все отделы начали параллельную разработку. Нужно было как можно быстрее получить играбельный прототип, и буквально через пару недель он оказался у нас на руках.
Раньше максимальный размер сетевой комнаты не превышал 10 игроков, а когда их стало 100, система «каждый игрок отправляет все сетевые события всем игрокам» стала проблемой. Мы быстро поняли, что косты на сервера взлетят до небес, а мобильное соединение просто забьется и не позволит пользователям играть с LTE и 3G.
В итоге мы провели полную ревизию сетевого кода (обновили его даже для старых режимов), внедрили систему чанков и контролируемый рандом для некоторых геймплейных аспектов. Например, у нас около 1000 возможных вариантов сужения зоны, и ни одной ситуации, когда катка заканчивается где-то в воде.
Спойлер: получилось даже лучше, чем изначально планировалось. Игроков не дисконнектит, трафик в батлрояле сравним с трафиком в обычном дезматче, а траты на сервера сократились на 20%.
Теперь по пунктам.
Оптимизация кода
Для обмена сетевыми сообщениями между игроками мы используем Photon Cloud. Разумеется, весь сетевой код и код персонажа взяли прямиком из наших классических игровых режимов. Предполагалось, что батлрояль станет популярным, а значит потянет за собой значительное увеличение трафика, пересылаемого между пользователями. Была вероятность, что мобильная сеть перестанет справляться и игроков начнет часто отсоединять от комнат.
Для начала оптимизировали всю сетевую часть и везде, где возможно, заменили float и int на byte. Например, все места с перечислением, где объектов не могло быть больше 255. Float от 0 до 1 тоже приводим к byte и уже на клиенте расшифровываем.
Провели полную ревизию RPC и потоков (Streams) и удалили все лишнее. Например, у нас был неиспользуемый отправляющийся RPC, который отсылался с каждым выстрелом и должен был отвечать за отображение дульного пламени. Где-то сократили количество отправляемых переменных, а где-то полностью переработали структуру данных.
Оптимизации были направлены на все существующие сетевые сообщения, что в итоге привело к снижению нагрузки во всех режимах игры.
Сетевые чанки
Но оптимизация кода существующих сетевых сообщений была не единственной задачей.
У нас на карте 100 игроков, при этом есть пушки, стреляющие снарядами (проджектайлами), а не просто патронами, которые лишь передают информацию о попадании. Например, летящая с определенной траекторией и скоростью ракета должна одинаково отображаться для всех игроков. Если игрок на одном конце карты выстрелит из такой ракетницы, то абсолютно все будут получать информацию о ее позиции. А если ракетница скорострельная и игроков с ней много, то это сгенерирует кучу трафика.
При условии, что такие выстрелы на расстоянии больше 400 метров не всегда видны (точнее, не видны почти всегда), решили оптимизировать сетевые сообщения удаленных от игрока объектов.
В предыдущей статье я рассказал, как мы сделали систему чанков для оптимизации графики. Это такие квадратные зоны 200 на 200 метров, на которые разбивается вся карта батлрояля — они помогают оптимизировать подгрузку объектов и улучшить производительность. Например, объекты в квадрате, где находится игрок, отрисовываются в максимальном разрешении. В соседних — тоже, а вот следующий ряд квадратов — будет уже в половинчатом разрешении.
Сетевые взаимодействия решили оптимизировать по той же логике. Разбили карту сеткой 15 на 15 и получили 225 чанков. Игроки стали обмениваться информацией только из соседних чанков, не получая бесполезные данные о происходящем на другом конце карты.
Каждому игроку присваивается чанк, исходя из его позиции в игровом мире:
public int GetChunkByPosition(Vector3 position)
{
Vector3 delta = position - chunksTransform.position;
int chunkX = Mathf.Clamp((int)(delta.x / chunkSizeX), 0, chunksWidth);
int chunkY = Mathf.Clamp((int)(delta.z / chunkSizeY), 0, chunksHeight);
return chunkX + chunkY * chunksHeight;
}
За каждым чанком закреплен определенный Interest Group в Photon (индекс чанка + 1), а нулевая группа остается для всех игроков. Когда игрок переходит в другой чанк, то подписывается на него и на соседние — и тогда начинает получать сетевые сообщения, отправленные только в них.
Игрок синхронизирует свой чанк между всеми игроками и устанавливает принадлежащие ему и соседним чанкам группы для прослушивания по сети через метод PhotonNetwork.SetInterestGroups. Все принимаемые игроками рядом сообщения отправляются группе, которая соответствует текущему чанку.
Код получения соседних чанков:
public List<int> GetNeighbourChunks(int chunk, int rad)
{
neighbourChunksBuffer.Clear ();
if (chunk == -1)
return neighbourChunksBuffer;
int cx = chunk % chunksHeight;
int cy = chunk / chunksHeight;
int dist = rad * 2 + 1;
for (int rx = 0; rx < dist; rx++)
{
for (int ry = 0; ry < dist; ry++)
{
int ncx = cx - rad + rx;
int ncy = cy - rad + ry;
if (ncx >= 0 && ncx < chunksWidth && ncy >= 0 && ncy < chunksHeight)
{
neighbourChunksBuffer.Add (ncx + ncy * chunksHeight);
}
}
}
return neighbourChunksBuffer;
}
При этом самые важные сообщения (например, информация для киллфида) пересылаются между всеми игроками вне зависимости от места их нахождения.
Такая система позволила сократить самые тяжелые по трафику вещи вроде передвижения, позиций игроков, стрельбы и так далее — все это стало синхронизироваться внутри нужных чанков.
Так мы ощутимо урезали количество сетевых сообщений. Каких-то конкретных замеров не делали, просто изначально знали, чем все обернется без правильной системы чанков. Главным показателем стало отсутствие изменений общей статистики по трафику после релиза батлрояля — расход в комнатах с 10 игроками примерно сопоставим с трафиком в комнатах на 100.
Сценарии для клиентов
Другой шаг для оптимизации сетевой части был сделан с помощью специальных сценариев. Чтобы снизить количество передаваемой информации по сети, мы пошли на хитрость и часть данных переложили на клиенты.
Например, на карте рандомится около 800 точек с лутом, за каждой из которых закреплена конкретная модель оружия. Это огромное количество данных, которое пришлось бы рассылать всем 100 игрокам. Такой вариант синхронизации спавна лута нас не устраивал, и мы нашли способ лучше.
При создании комнаты (игрового лобби), в ее свойствах записывается уникальное «зерно» для генерации сценария. При подключении к комнате каждый игрок достает его из настроек, и затем на всех клиентах одинаково генерирует «случайный» сценарий. По сути, сто игроков производят одни и те же вычисления отдельно на своих устройствах.
Таким образом, мы не пересылаем огромное количество информации, а каждый клиент с помощью такого контролируемого рандома генерирует тот же самый результат.
Код генерации сундуков с помощью «зерна»:
System.Random random = new System.Random (seed);
for (int l = 0; l < points.Length; l++)
{
for (int i = 0; i < points[l].chests.Length; i++)
{
if (!GadgetsInDrop && points[l].chests[i].typeChest == ChestBatleRoyale.TypeChest.Gadget)
continue;
int pointIndex = random.Next(0, points[l].points.Length);
while (points[l].points[pointIndex].isUsed)
{
pointIndex++;
if (pointIndex >= points[l].points.Length)
{
pointIndex = 0;
}
}
points[l].chests[i].transform.position = points[l].points[pointIndex].transform.position;
points[l].points[pointIndex].isUsed = true;
int summProbability = 0;
for (int k = 0; k < probabilityByType[(int)points[l].chests[i].typeChest].Length; k++)
{
summProbability += probabilityByType[(int)points[l].chests[i].typeChest][k];
}
int raritybonusRand = random.Next(1, summProbability + 1);
summProbability = 0;
int raritybonusIndex = 0;
for (int k = 0; k < probabilityByType[(int)points[l].chests[i].typeChest].Length; k++)
{
summProbability += probabilityByType[(int)points[l].chests[i].typeChest][k];
if (summProbability >= raritybonusRand)
{
raritybonusIndex = k;
break;
}
}
if (weapons.Length > raritybonusIndex)
{
int weaponIndex = random.Next(0, weapons[raritybonusIndex].Length);
points[l].chests[i].weaponBonusName = String.Format("Weapon{0}", weapons[raritybonusIndex][weaponIndex]);
points[l].chests[i].typeItem = ChestBatleRoyale.TypeItem.Weapon;
}
else
{
int gadgetIndex = random.Next(0, gadgets.Length);
points[l].chests[i].weaponBonusName = String.Format("gadget_{0}", gadgets[gadgetIndex]);
points[l].chests[i].typeItem = ChestBatleRoyale.TypeItem.Gadget;
}
if (GadgetsInDrop && points[l].chests[i].typeChest == ChestBatleRoyale.TypeChest.AirDrop)
{
ChestBatleRoyale.AdditionalItem additionalItem = new ChestBatleRoyale.AdditionalItem();
int gadgetIndex = random.Next(0, gadgets.Length);
additionalItem.weaponBonusName = String.Format("gadget_{0}", gadgets[gadgetIndex]);
additionalItem.typeItem = ChestBatleRoyale.TypeItem.Gadget;
points[l].chests[i].AddListAdditionalBonus(new List<ChestBatleRoyale.AdditionalItem>() { additionalItem });
}
}
}
Также «зерно» используется для генерации пути шаттла, на котором летят игроки в начале матча и сужающейся зоны батлрояля.
Про зону расскажу отдельно. Изначально было два пути: сделать полностью рандомно или сценарно. Выбрали второй вариант, чтобы избежать неинтересных игровых ситуаций, как в PUBG, когда финальная зона находится в воде. Поэтому решили конечные и промежуточные зоны назначить вручную и написали отдельный редактор сценариев сужения.
Как это работает: есть пять начальных зон, а дальше зоны меньше, еще меньше и так до самой маленькой. Если меньшая зона находится внутри большей, то она может быть выбрана, как следующая. На карте получилось множество зон разного размера, но выбирается все случайным образом.
Все варианты зон настраиваются в сцене с визуализацией радиусов. В итоге получилось около 1000 разных сценариев сужения, поэтому паттерн разглядеть невозможно. Теперь мы вообще не передаем между клиентами состояние зон и места их нахождения, потому что каждый клиент об этом уже знает.
Код выбора зон с помощью «зерна»:
public void SelectRandomZones(int seed)
{
System.Random random = new System.Random (seed ^ zoneSeed);
selectedZones = new MapSafeZone[zoneParameters.Length];
Transform currentChild = transform;
for (int i = 0; i < selectedZones.Length; i++)
{
if (transform.childCount == 0)
break;
int randomChild = random.Next (0, currentChild.childCount);
Transform newChild = currentChild.GetChild (randomChild);
if (newChild == null)
break;
MapSafeZone zone = newChild.GetComponent<MapSafeZone>();
selectedZones [i] = zone;
currentChild = newChild;
}
}
У нас есть таймер в комнате, который знают все клиенты — от него зависит состояние всех зон. Нет никакой пересылки, есть лишь сгенерированный сценарий по «зерну» и таймер. Так мы и понимаем, когда нужно сужать зону.
Такая система сценариев с «зерном» избавила нас от пересылки большого количества информации и помогла устранить задержки сети.
Релиз
Наш батлрояль вышел примерно через два месяца после старта разработки, и буквально за неделю отыграл значения одновременного онлайна, которые были до падения — около 60 тысяч CCU. Пошла плотная работа с комьюнити, ASO, маркетингом, рекламными материалами. Стали везде пушить, что в игре появился батрояль, дописали слово в название игры в сторе, отредактировали описание и так далее.
На релизе в режим ежедневно играла четверть аудитории — около 250 тысяч игроков. Сейчас это число составляет около 150 тысяч, потому что от классического батлрояля уже все устали. Тот же Fortnite добавляет одиночные миссии, социальные режимы, свободный крафт.
Мы в свою очередь каждый сезон редизайним карту в зависимости от тематики обновления или просто крупных изменений. Например, у нас была карта «Летающая крепость», которая не нравилась игрокам. Ее удалили из игры и разбросали обломки по карте батлрояля, будто она рухнула на землю.
Из-за снижения популярности режима, мы на основе батлрояля параллельно развиваем фриплей. Это что-то вроде GTA Online с тачками и свободным перемещением.
Оглядываясь назад, у нас батлрояль мог появится еще задолго до PUBG и Fortnite — в Pixel Gun 3D уже на старте самым популярным был режим «голодные игры», вдохновленный одноименным фильмом. В нем игроки начинали не в самолете, а в центре карты, затем разбегались и лутались. Но была проблема — игроки потом не могли встретиться друг с другом. Сужающаяся зона устранила бы этот недостаток, но тогда мы решили сосредоточиться на классических режимах. Потом появились современные батлрояли, а дальше вы знаете.
Не всегда получается разглядеть хорошую возможность и тренд, который скоро взорвет рынок — в этом мы пытались разобраться в отдельном материале.