Дельта компрессия и квантизация объектов в C#

в 18:43, , рубрики: .net, C#, Gamedev, unity, разработка игр
Дельта компрессия и квантизация объектов в C# - 1

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://ru.wikipedia.org/wiki/Maximum_transmission_unit

https://github.com/Levchenkov/NetCode

Автор:
Tidehunter

Источник


* - обязательные к заполнению поля


https://ajax.googleapis.com/ajax/libs/jquery/3.4.1/jquery.min.js