Проблема

Недавно мы отказались от загрузки и парсинга JSON в нашем Unity-клиенте в пользу двоичного формата, на основе Flatbuffers. В этой статье вы узнаете:

  • Почему мы это сделали? 

  • Что такое Flatbuffers?

  • Как вам сделать это самим?

  • Какую выгоду вы можете из этого извлечь?

TL;DR:

Вы хотите упростить свою жизнь, интегрируя Flatbuffers в Unity? Лучшего решения вам не найти: gameroasters/flatbuffers-unity

Контекст

Наша последняя игра Wheelie Royale (Appstore/Playstore) загружает много данных с реплеями других игроков. Изначально данные с реплеями передавались в формате JSON. В самых крайних случаях JSON для одного уровня мог достигать до 15 МБ. Даже не смотря на то, что это уже серьезная проблема с точки зрения потребления мобильного трафика, она проявляется еще сильнее при десериализации JSON-данных на не очень мощный устройствах.

Достав свое бюджетное тестовое устройство (Galaxy S4), я обнаружил, что Newtonsoft.JSON потребовалось 20 секунд для десериализации 15 МБ данных. Даже этот показатель уже никуда не годится, не говоря о том, что для некоторых игроков это время может достигать целой минуты, что является абсолютным останавливающим фактором.

Очевидно, нам срочно нужно было найти лучший подход.

Flatbuffers

FlatBuffers — это эффективная кросс-платформенная библиотека сериализации (сайт Flatbuffers)

Изначально это был внутренний проект Google для разработки игр, но он получил некоторую известность, когда Facebook объявила о значительном приросте производительности за счет использования его в своем мобильном приложении (вот эта статья).

Использование Flatbuffers дает нам два основных преимущества:

  1. Данные хранятся в двоичном формате, что положительно сказывается на пропускной способности.

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

Вы можете увидеть это сами на иллюстрации ниже:

Flatbuffers хранятся в виде непрерывного сегмента памяти, что также немного лучше для сборщика мусора, нагрузка который в нашем проекте была увеличена из-за множества небольших аллокаций. Если вы по большей части только читаете свои данные из буфера и вам не нужно их изменять (а это именно наш случай), это сокращает количество аллокаций до нуля (благодаря повторному использованию статического буфера).

Помимо компактного расположения в памяти, Flatbuffers снижает потребление памяти, ожидая, что обе стороны будут знать схему. Позже мы увидим, как мы генерируем код для клиентской и серверной части, чтобы они могли говорить на одном языке.

Сравнение

  • До: Десериализация 15 МБ Json за 20 секунд.

  • После: Парсинг тех же данных, но с использованием Flatbuffers (4 МБ) за 0,5 секунды.

Это повышение скорости в 40 раз!

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

Схема наших Flatbuffers

Ниже приведена упрощенная версия схемы наших Flatbuffers-файлов. В наше проекте мы имеем дело с воспроизведениями прохождения уровня другими игроками - “призраками” (Ghosts). Каждый призрак состоит из ЦЕЛОЙ КУЧИ дельт (Sample), по которым мы воспроизводим его перемещение по уровню.

struct Sample {
  //...
  r: int16;
}

table GhostRecording {
  //...
  deltas: [Sample] (required);
}

table Ghost {
  //...
  recording: [GhostRecording] (required);
}

table Ghosts {
  //...
  items:[Ghost] (required);
}

root_type Ghosts;

Теперь вы можете четко увидеть, почему наш случай был особенно накладным для сборщика мусора в Unity - мы имеем дело с множеством небольших объектов, ассоциируемых по отдельности.

Если вы хотите узнать больше о различиях между таблицами (table) и структурами (struct), вы можете найти все подробности здесь: схема Flatbuffers.

Генерация кода

Но когда дело дошло до процессов, необходимых для того, чтобы внедрить это решение в проект, я был разочарован тем, насколько мало было доступно: не было докера контейнер, чтобы заставить flatc (транспилятор схемы) работать на разных платформах, не было готовой .net библиотеки для Unity, чтобы можно было сразу начать работу.

Поэтому я создал это решение и открыл его исходный код на GitHub нашей компании: gameroasters/flatbuffers-unity

С помощью этого докер контейнера очень легко создать свой код сериализации/десериализации. Просто используйте следующую команду:

docker run -it -v $(shell pwd):/fb gameroasters/flatbuffers-unity:v1.12.0 /bin/bash -c "cd /fb && \
	flatc -n --gen-onefile schema.fbs && \
	flatc -r --gen-onefile schema.fbs"

 

Она смонтирует ваш текущий рабочий каталог, в котором должен быть ваш файл schema.fbs, в контейнер и сгенерирует для вас необходимый код для Rust и C# в двух файлах с именами schema.rs и schema.cs.

Недостатки

Flatbuffers не сделает ваш код более читабельным. Вот пример того, как мы считываем из него наших призраков:

var fb_ghosts = GR.WR.Schema.Ghosts.GetRootAsGhosts(new ByteBuffer(data));

var res = new List<Ghost>(fb_ghosts.ItemsLength);
for (var i = 0; i < fb_ghosts.ItemsLength; i++)
{
    var e = fb_ghosts.Items(i);
    if (!e.HasValue) continue;
    var recording = e.Value.Recording.Value;

    var deltas = new List<Sample>(recording.DeltasLength);
    for (var j = 0; j < recording.DeltasLength; j++)
    {
        var delta = recording.Deltas(j);
        var r = delta.Value.R;
        deltas.Add(new Sample(r));
    }

    var ghost = new Ghost();
    ghost.recording = new GhostRecording(); 
    ghost.recording.deltas = deltas;
  
    res.Add(ghost);
}

В отличие от некоторых альтернатив Flatbuffers, он не создает для вас POD-объекты и не выполняет в них десериализацию. Но так и было задумано изначально. На самом деле вы можете обойтись без них, если вам нужен доступ только для чтения.

Мы создаем их только для того, чтобы код оставался совместимым с предыдущим подходом, который десериализовал JSON в POD-объекты.

Альтернативы

Конечно у Flatbuffers есть альтернативы, и я не буду от вас их скрывать:

Вот очень хорошая сравнительная матрица: https://capnproto.org/news/2014-06-17-capnproto-flatbuffers-sbe.html

Основное преимущество protobuf заключается в том, что он выполняет дополнительный шаг по созданию POD-объектов для вас, что еще больше приближает его к тому, к чему вы привыкли при обычной десериализации JSON. Это хороший компромисс между скоростью (Flatbuffers) и удобством (JSON). Еще один приятный момент: protobuf также поддерживает JSON, что значительно упрощает отладку.

Другая альтернатива, cap'n'proto, на самом деле создана тем же парнем, который создал protobuf, и использует тот же подход нулевым аллоцированием памяти, что и Flatbuffers. cap'n'proto еще не поддерживает столько же языков - это единственная причина, по которой я не решился его попробовать (пока).

В конечном счете, лучшего решения не существует, все имеет свою цену. Если ваш приоритет - скорость, то вы врядли сможете найти что-нибудь лучше, чем Flatbuffers.

Дополнительные ресурсы

В преддверии старта курса Unity Game Developer. Basic приглашаем всех заинтересованных на бесплатный урок по теме: "Создание 2D-платформера на Unity. Добавляем персонажей и игровые механики"

- Зарегистрироваться на бесплатный урок

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


  1. AetherNetIO
    15.12.2022 19:15
    -1

    У вас в слове «проэкт» ошибка


  1. MultiTeemer
    15.12.2022 22:00

    Я использую в практике https://github.com/neuecc/Utf8Json и там всё очень неплохо даже. Нет аллокаций и скорость запредельная.


    1. domix32
      16.12.2022 12:16

      15 MB скармливать на мобильном процессоре пробовали?


  1. Suvitruf
    15.12.2022 22:38

    Не хватает кода парсинга на Newtonsoft.JSON. А то может у вас там какие-то базовые ошибки с конвертерами например.


  1. gudvinr
    16.12.2022 00:49
    +1

    И вместо того, чтобы разобраться, почему вообще возникли такие огромные JSON и оптимизировать хранение данных, просто переложили её в другое место.