
Cтатья затрагивает тему сериализации данных, которые передаются по unreliable каналам.
В первую очередь это касается реалтайм игр, которые критичны к сетевым задержкам, имеют активное общение клиента и сервера, например, 10 — 60 раз в секунду и используют UDP протокол.
В статье вы узнаете, как с помощью дельта компрессии и квантизации можно уменьшить размер объектов и, тем самым, уменьшить размер сериализованных данных. Попутно мы познакомимся с библиотекой для битовой сериализации данных NetCode.
Особенностью реалтайм игр является то, что они требовательны ко времени, в течении которого получают актуальное состояние мира от сервера. Мало кому могут нравится большие временные задержки на отдельные действия пользователя во время игры. Тут очень важную роль играют: качество интернет соединения, расстояние между клиентом и сервером. Кроме того, немаловажную роль выполняет и сериализация данных, передаваемых по сети. Ведь именно способ сериализации определяет размер сетевых пакетов. В свою очередь размер пакетов, важен не только по причине ограничения серверного интернет-канала, но и потому, что большие пакеты подвергаются фрагментации, а потеря одного фрагмента приводит к потере всего пакета.
Проблема
В процессе работы у вас наверняка возникали вопросы:
-
как сжать передаваемые данные,
-
как сделать размер пакетов таким, чтобы они не подвергались фрагментации во время передачи,
-
как уменьшить серверный трафик?
Чтобы погрузиться в проблематику реалтайм игр и, в частности, разобраться с проблемой размера пакетов, рекомендую сначала ознакомиться со статьей Snapshot Compression из цикла статей Gaffer On Games. На основе информации и подходов из этой статьи мы будем работать над оптимизацией размера пакетов.
Представьте себе ситуацию: сервер тикает с определенной частотой и в каждом тике рассылает актуальное состояние мира всем игрокам. Для упрощения, рассмотрим рассылку информации только о положении игроков в пространстве.
Для этого, например, можно использовать следующую структуру:
public struct TransformComponent { public Vector3 Position; public float Yaw; public float Pitch; }
Размер указанной структуры — 20 байт. Position имеет тип Vector3, который содержит 3 float поля для каждой оси координат (X, Y и Z), Yaw и Pitch тоже имеют тип float. Что в итоге дает 5 float полей, каждый размером в 4 байта, при этом суммарный размер равен 20 байт (5 полей * 4 байта).
Убедиться в том, что размер структуры именно такой можно с помощью функции SizeOf:
Console.WriteLine(Unsafe.SizeOf<TransformComponent>()); // 20
Тогда, если у нас шутер 5 на 5, то в результате получится 200 байт на пакет (20 байт * 10 игроков).
Вроде не страшно и не критично, но при этом рассмотрен только один компонент. А в состояние игры, которое мы отправляем по сети, входят и другие компоненты: скорость, здоровье, амуниция и т.д. При этом нам нужно уложиться в 1500 байт для отправляемого пакета по UDP согласно MTU, чтобы избежать фрагментации.
А если у нас шутер с режимом королевская битва на 100 человек, то в итоге получается 2 000 байт (20 байт * 100 игроков), которые показывают, что даже с 1 компонентом не укладываемся в параметры MTU.
Конечно, можно использовать зоны интереса, что позволит уменьшить количество передаваемых сущностей, но сути это не меняет.
Например, LiteNetLib по умолчанию использует MTU равный 1024 байт, пруф. И если вы отправляете по unreliable каналу пакет размером 1025 байт, то получите исключение.
Можно ли уменьшить размер? Согласно вышеупомянутой статье Snapshot Compression, можно. Предлагаю воспользоваться приемом квантизации, а именно, ограничить допустимые значения для полей компоненты.
Квантизация
Определение квантизации можно найти в Википедии. Если совсем коротко, то квантизация — процесс преобразования вещественных чисел в целые.
Рассмотрим на примере данный процесс.
Представим, что у нас есть игровое поле 100 на 100 и его координаты могут принимать дробное значение. Также предположим, что точности в 0.1 единицы нам будет достаточно. С такими исходными данными нам подходит тип float, его диапазон значений от ±1.5 x 10−45 до ±3.4 x 1038 и размер составляет 4 байта. Но дело в том, что весь диапозон нам не нужен, да и точность в 7 знаков после запятой для нас это перебор.
Можно ли оптимизировать хранение значений и сколько бит нам нужно для хранения? Достаточно хранить всего 1000 значений для каждой оси (100 * 10), т.е. 10 бит на ось или 20 бит для каждого объекта на нашем игровом поле. В случае использования переменных типа float без квантизации, у нас было бы 64 бита для каждого объекта (32 * 2).
Квантизировать можно не только типы с плавающей точкой float и double, но и целочисленные.
Например, в нашей игре нужно хранить угол поворота в градусах — от 0 и до 360. Для хранения потребуется 9 бит. Значит 8 битового типа byte нам не хватит, поэтому наиболее подходящий тип это ushort 16 бит, максимальное значение которого 65535. Но нам нужно только 9 бит. Квантизация как раз нам позволит использовать только 9 из 16 бит.
Самый маленький тип в C# это byte, как вы можете догадаться, его размер равен 1 байту. Поэтому для работы с отдельными битами надо использовать битовые операции. К самим битовым операциям претензий нет, но хотелось бы упростить нелегкую жизнь разработчиков и предложить более удобный инструмент: библиотеку NetCode.
Напомню, что тип bool в памяти тоже занимает 1 байт, поэтому можем забыть про массив из bool.
NetCode
NetCode это библиотека с открытым исходным кодом. Она предназначена для сериализации объектов, которые должны быть переданы по сети, и нацелена на уменьшение размера передаваемого массива. Высокая производительность и отсутствие аллокаций ключевые особенности этой библиотеки. Все то, что мы так любим и ценим в нашей работе.
Библиотека не предоставляет набор функций для работы с отдельными битами числа и не умеет находить количество установленных битов, зато позволяет записывать в массив байтов определенное количество бит из битового представления числа:
var bitWriter = new BitWriter(); bitWriter.WriteBits(bitCount: 3, value: 0b_101010); // 0b_010 bitWriter.WriteBits(bitCount: 3, value: 0b_1111); // 0b_111 Console.WriteLine(bitWriter.BitsCount); // 6 bitWriter.Flush(); Console.WriteLine(bitWriter.BitsCount); // 8 byte[] data = bitWriter.Array; // data[0] == 0b_111010 Console.WriteLine(Convert.ToString(value: data[0], toBase: 2)); // 111010
В приведенном выше примере мы выполнили следующее:
-
2 раза записали по 3 бита (010 и 111), хотя сами исходные числа содержали больше значимых битов (101010 и 1111 соответственно),
-
вывели в консоль информацию о количестве записанных битов,
-
записали внутренний буфер в итоговый массив,
-
опять вывели в консоль информацию о количестве записанных битов,
-
и вывели на консоль представление итогового массива.
В результате, получен массив, первый байт которого содержит наши записанные биты 010 и 111.
Побитовая запись числа, конечно, хорошо, но мы сюда пришли не за этим.
Квантизация с помощью NetCode
Рассмотрим пример по квантизации значений:
var bitWriter = new BitWriter(); bitWriter.Write(value: 1f, min: 0f, max: 100f, precision: 0.1f); Console.WriteLine(bitWriter.BitsCount); // 10 bitWriter.Flush(); Console.WriteLine(bitWriter.BitsCount); // 16 var data = bitWriter.Array; var bitReader = new BitReader(data); var value = bitReader.ReadFloat(min: 0f, max: 100f, precision: 0.1f); Console.WriteLine(value); // 1
В этом примере мы делаем следующее:
-
записываем float переменную со значением 1f, с ограничениями от 0 до 100 и точностью в 0.1 ,
-
выводим в консоль информацию о количестве записанных битов,
-
записываем внутренний буфер в итоговый массив,
-
опять выводим информацию о количестве записанных битов,
-
полученный массив передаем в конструктор класса BitReader,
-
читаем float значение с ограничениями от 0 до 100 и точностью в 0.1,
-
получаем исходное значение.
Как результат, мы записали дробное число с помощью 10 битов и успешно прочитали это число назад.
Таким образом, можно увидеть, что библиотека позволяет записать всего одну строку кода для записи одного значения в массив:
bitWriter.Write(value: 1f, min: 0f, max: 100f, precision: 0.1f);
Давайте вернемся к нашему примеру с компонентой, которая отвечает за позиционирование игроков в пространстве, и посмотрим, как библиотека NetCode сможет нам помочь.
Напомню, что наша компонента имеет вид:
public struct TransformComponent { public Vector3 Position; public float Yaw; public float Pitch; }
и допустим, что наше игровое поле ограничено:
-
-100 < X < 100,
-
-10 < Y < 10,
-
-100 < Z < 100,
при этом пусть точность перемещения составляет 0.1 единицы (попугаев, метров или футов).
Также наложим ограничения на углы поворота:
-
0 < Yaw, Pitch < 360,
точность угла поворота пусть будет 0.1 градус.
Тогда сериализация примет вид:
var bitWriter = new BitWriter(); var positionXZLimit = new FloatLimit(min: -100f, max: 100f, precision: 0.1f); var positionYLimit = new FloatLimit(min: -10f, max: 10f, precision: 0.1f); var rotationLimit = new FloatLimit(min: 0f, max: 360f, precision: 0.1f); var transformComponent = new TransformComponent { Position = new Vector3(10f, 5f, 10f), Pitch = 30f, Yaw = 60f }; bitWriter.Write(value: transformComponent.Position.X, limit: positionXZLimit); bitWriter.Write(value: transformComponent.Position.Y, limit: positionYLimit); bitWriter.Write(value: transformComponent.Position.Z, limit: positionXZLimit); bitWriter.Write(value: transformComponent.Yaw, limit: rotationLimit); bitWriter.Write(value: transformComponent.Pitch, limit: rotationLimit); bitWriter.Flush(); Console.WriteLine(bitWriter.BytesCount); // 7
Таким образом, мы:
-
создаем ограничения с помощью класса FloatLimit,
-
создаем объект нашей сериализуемой структуры,
-
записываем координаты и повороты,
-
записываем внутренний буфер в итоговый массив,
-
выводим в консоль количество байт итогового массива.
Размер сериализованных данных составляет 7 байт. Результат неплохой. Но можно еще лучше!
Дельта
Дельта значений, она же дифф значений, она же разность значений.
Мы можем пойти дальше и отправлять только те данные, которые изменились. Это и называется дельта компрессия.
Например, у игрока изменились только координаты, а наклон и поворот остались прежними:
var before = new TransformComponent { Position = new Vector3(10f, 5f, 10f), Pitch = 30f, Yaw = 60f }; var after = new TransformComponent { Position = new Vector3(10.5f, 5.5f, 10.5f), Pitch = 30f, Yaw = 60f }; var bitWriter = new BitWriter(); var positionXZLimit = new FloatLimit(min: -100f, max: 100f, precision: 0.1f); var positionYLimit = new FloatLimit(min: -10f, max: 10f, precision: 0.1f); var rotationLimit = new FloatLimit(min: 0f, max: 360f, precision: 0.1f); bitWriter.WriteValueIfChanged( baseline: before.Position.X, updated: after.Position.X, limit: positionXZLimit); bitWriter.WriteValueIfChanged( baseline: before.Position.Y, updated: after.Position.Y, limit: positionYLimit); bitWriter.WriteValueIfChanged( baseline: before.Position.Z, updated: after.Position.Z, limit: positionXZLimit); bitWriter.WriteValueIfChanged( baseline: before.Yaw, updated: after.Yaw, limit: rotationLimit); bitWriter.WriteValueIfChanged( baseline: before.Pitch, updated: after.Pitch, limit: rotationLimit); bitWriter.Flush(); Console.WriteLine(bitWriter.BytesCount); // 5
В данном примере мы делаем следующее:
-
создаем переменную before, которая содержит информацию до изменений,
-
создаем переменную after, которая содержит информацию после некоторых изменений,
-
создаем ограничения с помощью класса FloatLimit,
-
записываем координаты и повороты,
-
записываем внутренний буфер в итоговый массив,
-
выводим в консоль количество байт итогового массива.
Итоговый размер сериализованных данных будет зависеть от количества измененных полей. В нашем случае изменилась только позиция и размер массива данных составляет 5 байт.
Если поля структуры одинаковы, т.е. не было никаких изменений в данных, то будет записано столько бит, сколько полей. В нашем случае это 5 бит.
И это еще не все. Можно ввести ограничения на изменения значений, т.е. можно квантизировать дельту.
Квантизация дельты
Предположим, что игрок 90% времени перемещается пешком и изменения координат не превышает 1 (одного) юнита за 1 тик:
-
-1 < deltaX, deltaY, deltaZ < 1,
при этом точность перемещения, как и раньше, будет составлять 0.1 единицы.
Тогда наша сериализация примет вид:
var before = new TransformComponent { Position = new Vector3(10f, 5f, 10f), Pitch = 30f, Yaw = 60f }; var after = new TransformComponent { Position = new Vector3(10.5f, 5.5f, 10.5f), Pitch = 30f, Yaw = 60f }; var bitWriter = new BitWriter(); var positionXZLimit = new FloatLimit(min: -100f, max: 100f, precision: 0.1f); var positionYLimit = new FloatLimit(min: -10f, max: 10f, precision: 0.1f); var rotationLimit = new FloatLimit(min: 0f, max: 360f, precision: 0.1f); var diffPositionLimit = new FloatLimit(min: -1f, max: 1f, precision: 0.1f); bitWriter.WriteDiffIfChanged( baseline: before.Position.X, updated: after.Position.X, limit: positionXZLimit, diffLimit: diffPositionLimit); bitWriter.WriteDiffIfChanged( baseline: before.Position.Y, updated: after.Position.Y, limit: positionYLimit, diffLimit: diffPositionLimit); bitWriter.WriteDiffIfChanged( baseline: before.Position.Z, updated: after.Position.Z, limit: positionXZLimit, diffLimit: diffPositionLimit); bitWriter.WriteValueIfChanged( baseline: before.Yaw, updated: after.Yaw, limit: rotationLimit); bitWriter.WriteValueIfChanged( baseline: before.Pitch, updated: after.Pitch, limit: rotationLimit); bitWriter.Flush(); Console.WriteLine(bitWriter.BytesCount); // 3
В данном примере мы делаем следующее:
-
создаем переменную before, которая содержит информацию до изменений,
-
создаем переменную after, которая содержит информацию после некоторых изменений,
-
создаем ограничения с помощью класса FloatLimit,
-
записываем координаты и повороты,
-
записываем внутренний буфер в итоговый массив,
-
выводим в консоль количество байт итогового массива.
Итоговый размер сериализованных данных будет зависеть не только от количества измененных полей, но и от того, насколько сильно изменились поля. Если наше предположение о том, что координаты игрока изменились в промежутке [-1, 1], окажется верным, то размер данных составит 3 байта. Если мы допускаем ошибку в оценке, т.е. координаты игрока по какой-то причине (например, он использовал телепорт) изменились сильнее, то размер составит 5 байт, как и в предыдущем примере.
Полный пример сериализатора и десериализатора
var serializer = new TransformComponentSerializer(); var deserializer = new TransformComponentDeserializer(); var before = new TransformComponent { Position = new Vector3(10f, 5f, 10f), Pitch = 30f, Yaw = 60f }; var after = new TransformComponent { Position = new Vector3(10.5f, 5.5f, 10.5f), Pitch = 30f, Yaw = 60f }; var serializedComponent = serializer.Serialize(before, after); Console.WriteLine(serializedComponent.Length); // 3 var updated = deserializer.Deserialize(before, serializedComponent.Array); serializedComponent.Dispose(); Console.WriteLine(updated); // Position: <10.5, 5.5, 10.5>, Yaw: 60, Pitch: 30 public record struct TransformComponent (Vector3 Position, float Yaw, float Pitch ); public struct SerializedComponent { private readonly ArrayPool<byte> _arrayPool; public byte[] Array { get; } public int Length { get; } public SerializedComponent(ArrayPool<byte> arrayPool, byte[] array, int length) { _arrayPool = arrayPool; Array = array; Length = length; } public void Dispose() { _arrayPool.Return(Array); } } public static class Limits { public static readonly FloatLimit Rotation = new FloatLimit(0, 360, 0.1f); public static readonly Vector3Limit AbsolutePosition = new Vector3Limit(new FloatLimit(-100f, 100f, 0.1f), new FloatLimit(-10f, 10f, 0.1f), new FloatLimit(-100f, 100f, 0.1f)); public static readonly Vector3Limit DiffPosition = new Vector3Limit(new FloatLimit(-1f, 1f, 0.1f), new FloatLimit(-1f, 1f, 0.1f), new FloatLimit(-1f, 1f, 0.1f)); } public class TransformComponentSerializer { private const int MTU = 1500; private readonly BitWriter _bitWriter = new BitWriter(); private readonly ArrayPool<byte> _arrayPool = ArrayPool<byte>.Shared; public SerializedComponent Serialize(TransformComponent baseline, TransformComponent updated) { var array = _arrayPool.Rent(MTU); _bitWriter.SetArray(array); _bitWriter.WriteDiffIfChanged(baseline.Position.X, updated.Position.X, Limits.AbsolutePosition.X, Limits.DiffPosition.X); _bitWriter.WriteDiffIfChanged(baseline.Position.Y, updated.Position.Y, Limits.AbsolutePosition.Y, Limits.DiffPosition.Y); _bitWriter.WriteDiffIfChanged(baseline.Position.Z, updated.Position.Z, Limits.AbsolutePosition.Z, Limits.DiffPosition.Z); _bitWriter.WriteValueIfChanged(baseline.Yaw, updated.Yaw, Limits.Rotation); _bitWriter.WriteValueIfChanged(baseline.Pitch, updated.Pitch, Limits.Rotation); _bitWriter.Flush(); return new SerializedComponent(_arrayPool, _bitWriter.Array, _bitWriter.BytesCount); } } public class TransformComponentDeserializer { private readonly BitReader _bitReader = new BitReader(); public TransformComponent Deserialize(TransformComponent before, byte[] array) { _bitReader.SetArray(array); TransformComponent result = default; result.Position = new Vector3( _bitReader.ReadFloat(before.Position.X, Limits.AbsolutePosition.X, Limits.DiffPosition.X), _bitReader.ReadFloat(before.Position.Y, Limits.AbsolutePosition.Y, Limits.DiffPosition.Y), _bitReader.ReadFloat(before.Position.Z, Limits.AbsolutePosition.Z, Limits.DiffPosition.Z)); result.Yaw = _bitReader.ReadFloat(before.Yaw, Limits.Rotation); result.Pitch = _bitReader.ReadFloat(before.Pitch, Limits.Rotation); return result; } }
Заключение
За 3 простых шага (квантизация, дельта компрессия и квантизация дельты) и с помощью библиотеки NetCode, нам удалось сжать передаваемый компонент с 20 байт до 3 байт.
Материалы
https://gafferongames.com/post/snapshot_compression/
ссылка на оригинал статьи https://habr.com/ru/post/709954/
Добавить комментарий