Каждый раз, когда игра сохраняет прогресс, редактор сцены пишет файл уровня, а движок загружает модель персонажа, происходит одно и то же фундаментальное действие: развернутый в памяти граф объектов расплющивается в линейный поток символов или байтов. Позиция игрока, ссылки на материалы, вложенные массивы вершин, указатели на текстуры - все это живет в оперативной памяти в виде узлов, связанных адресами, ни один из которых на диске ничего не значит. Сериализация - это искусство заменить адреса смыслом, а указатели - порядком следования данных. Статья разбирает, как устроен этот процесс от текстового JSON до бинарных чанков glTF, какие ловушки поджидают разработчика на стыке форматов, платформ и версий, и почему неэффективный текстовый формат до сих пор побеждает элегантные бинарные схемы.
Линеаризация графа объектов и суть маршаллинга
Объект в памяти - это не структура, а узел графа. Экземпляр класса Player хранит не саму текстуру, а ссылку на нее; массив вершин меша лежит отдельно; материал ссылается на шейдер, шейдер - на таблицу униформ. Поток байтов на диске линеен: у него есть начало, конец и строгий порядок. Сериализация, которую в системном программировании часто называют маршаллингом, сводится к обходу графа и записи каждого узла ровно один раз в заранее оговоренном порядке, с заменой указателей на смещения, идентификаторы или вложение значений.
Простейший случай - плоский объект без ссылок наружу. Структуру из трех чисел с плавающей точкой можно записать двенадцатью байтами подряд и прочитать их обратно тем же приемом. Но как только появляется вторая ссылка на тот же объект, например два материала, использующих одну текстуру, наивная запись дважды продублирует данные, а при чтении создаст две независимые копии там, где была одна. Зрелые сериализаторы поэтому ведут таблицу уже записанных объектов: первый раз объект пишется целиком и получает порядковый номер, все последующие ссылки превращаются в короткую запись вида "объект номер семь". На этом же приеме построено решение проблемы циклических ссылок, о которой речь пойдет ниже.
Важно отличать сериализацию от дампа памяти. Слепок байтов по адресу объекта содержит указатели, выравнивание, служебные поля виртуальных таблиц - все это бессмысленно в другом процессе и даже в другом запуске того же процесса из-за рандомизации адресов. Настоящий сериализатор пишет семантику, а не топографию памяти.
Текстовые форматы JSON и XML
Текстовые форматы решают задачу самым прямым способом: каждое поле объекта превращается в пару "имя - значение", записанную человекочитаемыми символами. JSON вырос из литерального синтаксиса объектов JavaScript и предлагает минимальный набор: объекты в фигурных скобках, массивы в квадратных, строки, числа, булевы значения и null. XML старше и многословнее: теги, атрибуты, пространства имен, схемы валидации, зато промышленные инструменты вокруг него отлажены десятилетиями.
Главное достоинство текста - отладка бесплатно. Файл сохранения открывается в любом редакторе, различие двух версий видно в обычном diff, сломанное поле находится глазами. Это же достоинство оборачивается ценой. Каждое число хранится строкой цифр, каждое имя поля повторяется в каждом объекте массива, а парсер посимвольно разбирает поток, проверяет экранирование и переводит цифры обратно в двоичное представление. Типичный JSON-парсер тратит на мегабайт данных на порядок больше времени, чем чтение эквивалентной бинарной структуры, а файл раздувается в полтора-два раза. Для конфигов и сейвов редкой игры это приемлемо; для потоковой подгрузки геометрии - уже нет, что и привело к гибридным схемам вроде glTF.
Бинарные альтернативы
Бинарный мир не един, в нем есть спектр компромиссов. На одном краю - сырой struct pack: поля записываются подряд в порядке объявления, без имен, без метаданных. Формат получается предельно плотным и предельно хрупким: читатель должен знать схему наизусть, любое расхождение версий губительно. Так писались сейвы и сетевые пакеты в эпоху, когда каждый байт был на счету.
Protocol Buffers от Google занимают середину: разработчик описывает схему в отдельном файле, компилятор генерирует код, а в поток уходят пары "номер поля - значение" с варинтовым кодированием целых чисел. Неизвестные поля читатель аккуратно пропускает, что дает естественное версионирование. MessagePack идет другим путем - это JSON, переписанный в байтах: типы значений помечаются однобайтовыми тегами, схемы нет, документ остается самоописываемым, но вдвое компактнее и на порядок быстрее в разборе. Наконец, бинарный вариант glTF показывает гибрид: JSON-заголовок описывает сцену, материалы и ссылки, а вся тяжелая геометрия и текстуры лежат в пристроенном бинарном чанке, куда движок может отобразить память и читать вершины почти без копирований. Это честное признание того, что имена полей нужны человеку, а миллионы float-значений - только машине.
Пример на C# для игрока в JSON
Типичный класс состояния выглядит обманчиво просто:
using System.Numerics;
using System.Text.Json;
using System.Text.Json.Serialization;
public class Player
{
public string Name { get; set; } = "scout";
public int Level { get; set; } = 12;
public Vector3 Position { get; set; }
public TextureRef Skin { get; set; } = new TextureRef();
}
public class TextureRef
{
public string AssetId { get; set; } = "skins/scout_blue.dds";
public long ByteLength { get; set; } = 524288;
}
var player = new Player { Position = new Vector3(12.5f, 0.0f, -40.25f) };
string json = JsonSerializer.Serialize(player,
new JsonSerializerOptions { WriteIndented = true });
File.WriteAllText("save.json", json);
Player loaded = JsonSerializer.Deserialize<Player>(File.ReadAllText("save.json"));
На диске появляется примерно такой текст:
{
"Name": "scout",
"Level": 12,
"Position": { "X": 12.5, "Y": 0, "Z": -40.25 },
"Skin": { "AssetId": "skins/scout_blue.dds", "ByteLength": 524288 }
}
Обратите внимание на ключевой проектный ход: текстура сериализуется не пикселями, а ссылкой на ассет. Повторяющийся ресурс записывается один раз в библиотеке ассетов, а в сохранениях фигурирует только его идентификатор. Если же данные текстуры обязаны жить внутри файла, их байты кодируют в base64 и кладут строкой в то самое текстовое поле, о чем ниже.
Циклические ссылки и идентификаторы объектов
Родитель ссылается на дочерний узел, дочерний - обратно на родителя; два округа сцены делят один материал. При обходе в глубину такой граф зациклит наивный сериализатор навсегда. Промышленные решения используют две стратегии. Первая - таблица идентификаторов: каждому встреченному объекту присваивают целочисленный id, повторные встречи пишутся как "$ref": "7" или аналогичный маркер; System.Text.Json включает это через ReferenceHandler.Preserve, BSON и некоторые игровые форматы делают так по умолчанию. Вторая - запрет ссылок в принципе: формат допускает только деревья, а разделяемые ресурсы выносятся в отдельную таблицу, куда все ссылаются по индексу. Именно так устроен glTF: меши, материалы и текстуры лежат в корневых массивах, и "циклов" не бывает, потому что ссылка - это просто номер элемента. Второй подход проще для читателя и жестче для автора данных, зато целиком снимает класс ошибок висячих указателей.
Версионирование схем и судьба старых сохранений
Игра живет годами, поля добавляются и исчезают, а сейв прошлогодней версии обязан открыться. Текстовые и теговые форматы решают это естественным образом: неизвестное поле игнорируется, отсутствующее получает значение по умолчанию. Поэтому в protobuf добавлять поля можно, а менять номера существующих - категорически нельзя. В JSON-подобных схемах помогают явные миграции: в файле хранится номер версии, а загрузчик последовательно накатывает преобразования "из версии три в четыре, из четыре в пять". Практические правила, выстраданные командами:
- Никогда не переиспользовать имя или номер удаленного поля.
- Хранить версию формата в самом файле с первого релиза.
- Писать значения по умолчанию так, чтобы отсутствие поля читалось как осмысленное поведение.
- Держать тестовый набор старых сейвов и прогонять их через каждый релиз загрузчика.
Сырые бинарные дампы структур в этой схеме не участвуют: они смертельно зависят от точной компоновки типа, и версионирование для них означает ручные загрузчики под каждую историческую ревизию.
Эндианность и сетевой порядок байтов
Многобайтовое число можно записать двумя способами: старшим байтом вперед (big endian) или младшим (little endian). Процессоры Intel и AMD исторически little endian, а ранние сетевые протоколы закрепили big endian как "сетевой порядок байтов". Отсюда вечная чехарда: число 0x12345678 на диске от PC выглядит как последовательность 78 56 34 12, а сетевой заголовок того же числа - как 12 34 56 78. Читатель на другой платформе, забывший про перестановку, получает мусор вместо координат. Современные бинарные форматы жестко фиксируют порядок в спецификации: protobuf и glTF выбрали little endian, потому что большинство целевых машин читают его без перестановки, а классические протоколы требуют htons и ntohl на каждое поле. Мир постепенно дрейфует к little endian как стандарту де-факто.
Плавающая точка добавляет вторую ловушку: конверсия float32 в десятичный текст и обратно теряет точность, если не печатать достаточно цифр. Стандарт IEEE 754 гарантирует round-trip для float с девятью значащими цифрами, для double - с семнадцатью; современные библиотеки печатают кратчайшую строку, которая читается обратно в то же самое битовое значение (алгоритмы семейства Ryu и Grisu). Сериализатор, округляющий координаты до шести знаков "для красоты", тихо сдвигает вершины модели, и артефакт всплывает лишь на больших координатах мира.
Выравнивание структур и padding
Компилятор размещает поля структуры не вплотную, а по границам, кратным размеру типа: после байтового флага перед четырехбайтовым целым появятся три байта заполнителя. В памяти это правильно и быстро, но записанная на диск структура тащит с собой мусорные байты padding, зависящие от компилятора и платформы; размер одной и той же структуры может отличаться на x86 и ARM-64. Поэтому правило звучит жестко: sizeof и слепое чтение блока - не формат. Формат задается явной схемой: упакованные поля в фиксированном порядке, либо атрибуты упаковки у структуры, либо полевым кодированием, как в protobuf. Тот, кто хочет совместимости между платформами, описывает байтовую раскладку руками и тестирует ее набором эталонных файлов.
Base64 и бинарные вложения в тексте
Текстовый формат не умеет хранить произвольные байты: нулевые значения и управляющие коды рвут парсер. Base64 решает задачу грубо и надежно - каждые три байта превращаются в четыре печатных символа из алфавита из шестидесяти четырех знаков, потери составляют треть объема. Именно так маленькая текстура оказывается внутри JSON glTF через data URI: строка "data:image/png;base64,..." встраивает картинку прямо в документ сцены, и файл остается самодостаточным. Для нескольких килобайт это удобно; для текстуры на мегабайты разумнее внешний файл или бинарный чанк, потому что base64 раздувает данные, а потом декодер расходует еще и время. Практический ориентир прост: вложения в текстовом формате - мера для иконок и мелких буферов, не для геометрии.
Безопасность десериализации
Десериализация - это выполнение чужих инструкций по построению объектов, и злонамеренный файл может попросить создать не то, что ожидал разработчик. Уязвимость строится на так называемых gadget chains: в процессе восстановления объектов вызываются сеттеры свойств, конструкторы, финализаторы и обратные вызовы, и цепочка безобидных поодиночке методов складывается в выполнение произвольного кода. Классические примеры - BinaryFormatter в .NET, явно помеченный разработчиками как небезопасный навсегда, и полиморфная десериализация с указанием типов в самом потоке. Защита концептуально проста: не десериализовать данные, которым нет доверия, в произвольные типы; использовать схемы без полиморфизма, белые списки разрешенных типов, проверку подписи или контрольной суммы файла до разбора. Сейв игры, пришедший извне, - это входные данные от недоверенного пользователя, со всеми вытекающими требованиями.
Сюда же примыкает выбор между снапшотом памяти и структурной записью. Снапшот быстр - скопировал регион целиком и готово, но он непереносим, неверсионируем и опасен, поскольку читатель получает именно те байты, что прислал автор файла. Структурная запись через явную схему дороже в разработке и медленнее в исполнении, зато переживает смену платформы, компилятора и версии игры. Почти все зрелые проекты рано или поздно мигрируют от первого подхода ко второму - обычно после первого же инцидента с битым сейвом.
Человеческий фактор и странная любовь к JSON
По всем инженерным метрикам JSON проигрывает: он многословен, медленно парсится, не знает ни целочисленных типов, ни дат, ни бинарных данных. И тем не менее он повсюду - в конфигах движков, описаниях материалов, протоколах инструментов. Причины лежат не в машинной, а в человеческой плоскости. Файл, который можно открыть глазами, не требует специального просмотрщика; ошибку в нем видно без отладчика; пример из документации копируется в редактор за секунду. Культура "сначала сделай понятным, потом быстрым" здесь побеждает, и на практике это часто правильный расчет: шейдер сцены грузится один раз, а читают и правят конфиг - сотни. Проекты, где действительно важны байты и миллисекунды, давно нашли гибридную формулу: текст для структуры и ссылок, бинарные блоки для массивов - та самая архитектура, что лежит в основе glTF и многих внутренних форматов студий. Сериализация, в сухом остатке, - это не битва форматов, а осознанный выбор, что именно в данных ценнее: читаемость, плотность, скорость или совместимость сквозь годы.