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

Комментарии (18)


  1. SadOcean
    11.01.2023 02:11
    +1

    Спасибо, полезная статья.

    А как в случае дельта записи достать информацию, какой байт изменился?

    В байт влезает номер поля?


    1. Tidehunter Автор
      11.01.2023 11:36

      Название и номера полей не записываются. Тут важен порядок записи и чтения, он должен совпадать.

      Тут можно посмотреть внутреннюю реализацию чтения дельты на примере float.


      1. SadOcean
        13.01.2023 02:27
        +1

        Спасибо, так понятно.

        Номера нет, но есть маркер изменения(бит)

        Поэтому если читаешь по порядку, можно понять, какой изменен


    1. sami777
      11.01.2023 11:49

      Тоже самое сделать получателю - сравнить с предыдущими полученными данными.


      1. SadOcean
        13.01.2023 02:32

        Нет, вы видимо не поняли вопрос.

        Понятно, что получателю, чтобы получить новые данные нужны предыдущие и дельта.

        Вопрос в том, как отличить состояние "поле не изменилось" от состояния "поле изменилось на х"

        И выше ответили, на самом деле там есть бит изменения.

        Если значение не изменилось - то ридер прочитаете 0 и пропустит поле (использует предыдущее значение)

        Если там 1 - то он прочитает следующие х бит как квантизованную дельту поля


  1. okovalevski
    11.01.2023 11:37
    +1

    Дельта это хорошо, пока потери пакетов не произойдет.


    1. Tidehunter Автор
      11.01.2023 11:47

      Для рассчета дельты нужно использовать состояния миров, которые имеются у обоих участников коммуникации (клиента и сервера).

      Например, текущий тик сервера равен 12 и он знает, что клиент подтвердил получение пакета с состоянием игры, которое соответствует тику номер 10. Тогда сервер может смело рассчитывать дельту на основе 10 и 12 тиков и отправлять эту дельту клиенту. Если, по какой-то причине, пакет теряется и на сервере наступает новый тик, то сервер будет рассчитывать дельту между 10 и 13 тиком.

      Т.е. сервер должен рассчитывать дельту между состоянием миров для текущего тика и для тика, который клиент подтвердил получение.


  1. anzay911
    11.01.2023 12:23
    +1

    Интересно, где реализовано такое:

    • Что-то предсказуемо движется - сервер подтверждает это каждый квант, затрачивая на пересылку один бит в структуре.

    • Предсказание не удалось - сервер отправляет полные координаты или дельту с последней синхронизации.


    1. Tidehunter Автор
      11.01.2023 12:42

      Готовой реализации нет. Но можно написать вот такой метод расширения:

      Пример
      public static class Extentions
      {
          public static void WriteValue(this BitWriter writer, float baseline, float updated, float expectedDiff)
          {
              var defaultFloatPrecision = 0.000001f;
              var actualDiff = updated - baseline;
              
              if (Math.Abs(expectedDiff - actualDiff) < defaultFloatPrecision)
              {
                  writer.Write(false);
              }
              else
              {
                  writer.Write(true);
                  writer.Write(updated);
              }
          }
      
          public static float ReadFloat(this BitReader reader, float baseline, float expectedDiff)
          {
              var isChanged = reader.ReadBool();
              if (isChanged)
              {
                  return reader.ReadFloat();
              }
      
              return baseline + expectedDiff;
          }
      }


    1. bezarius
      11.01.2023 21:57

      В недетерминированном мире постоянно будет происходить ошибка предсказания банально из за floating-point error.

      В детерминированном мире достаточно передачи инпута. В случае десинка можно накатить стейт.

      Из готовых реализаций знаком с Quantum, довольно неплохая реализация.


      1. SadOcean
        13.01.2023 02:33

        В теории на одинаковых архитектурах можно построить синхронный floating point.

        На практике просто используют числа с фикс знаками после запятой


  1. Tresimeno
    11.01.2023 12:38
    -1

    Интересна применимость этой билиотеки и подхода вообще в HFT на биржах


    1. aaabramenko
      11.01.2023 16:51

      HFT строится близко к инфраструктуре биржи. Проблема интересна, т.к. помимо тиковых значений сделок передаëтся ещë биржевой стакан (массив заявок (цена и объëм) на покупку/продажу). float-типы скорее всего не используются, а используется что-то типа Decimal, но с меньшим диапазоном, либо вообще передаются целочисленные значения, т.к. для каждого инструмента (почти) известен первоначальный номинал и шаг цены (откуда тек.цена = номинал + шаг цены * целочисленное знач, считается уже на клиенте).

      Да, любопытно. Подобные способы "сжатия" актуальны не только в HFT, но и в обычных пользовательских терминалах (QUIK и проч.).


  1. bayan79
    11.01.2023 14:22

    Хорошая статья на почитать вечерком! И было бы супер, если бы описание работы кода предшествовало самому коду. Ибо смотреть реализацию после объяснения - благое дело, а вот читать объяснение, после того как ты уже понял код - увы...


  1. ryanl
    12.01.2023 11:47

    Крутяк, полезная статья, за ссылки на Github большое спасибо и за серию статей отдельное cпасибо - вот это прямо кладезь для тех, кто заинтересовался в сетевом программировании.


  1. forever_live
    12.01.2023 17:28

    А что заставляет разработчика кода, который должен быть быстрым, использовать float вместо int, если точно известно, что значения всегда находятся в диапазоне 0f - 100f с шагом 0.1f?


    1. Tidehunter Автор
      12.01.2023 18:21

      Замечу, что типа int тут тоже много, достаточно short'а.

      Приведу пару преимуществ использования float'а перед short'ом.

      1. Использование float удобно тем, что не нужно руками производить конвертацию каждый раз, когда понадобится использование дробного числа вместо целочисленного.

      2. Никто не отменял сценарий, когда нам понадобится изменить ограничения в большую сторону и, в связи с этим, мы перестаним влазить в short. Менять тип с одного на другой - не самое приятное удовольствие.


      1. Tidehunter Автор
        12.01.2023 19:34

        Забыл добавить. Библиотека поддерживает работу и с целочисленными типами.