Проблема
Недавно мы отказались от загрузки и парсинга 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 дает нам два основных преимущества:
Данные хранятся в двоичном формате, что положительно сказывается на пропускной способности.
Доступ к данным осуществляется очень быстро, поскольку они расположены в непрерывной области в памяти.
Вы можете увидеть это сами на иллюстрации ниже:
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. Добавляем персонажей и игровые механики"