Прежде чем разбираться в том, как работает ассетная система движка Blitz.Engine, нам необходимо определиться с тем, что такое ассет и что именно мы будем понимать под ассетной системой. Согласно Википедии, игровой ассет — это цифровой объект, преимущественно состоящий из однотипных данных, неделимая сущность, которая представляет часть игрового контента и обладает некими свойствами. С точки зрения программной модели, ассет может выступать в виде объекта, созданного на некотором наборе данных. Ассет может храниться в виде отдельного файла. В свою очередь, ассетная система — это множество программного кода, отвечающего за загрузку и оперирование ассетов различных типов.
Фактически ассетная система — это большая часть игрового движка, которая может стать верным помощником для разработчиков игры или же превратить их жизни в кромешный ад. Логичным, с моей точки зрения, решением было сконцентрировать этот «ад» в одном месте, бережно оберегая от него других разработчиков команды. О том, что у нас получилось, мы и расскажем в этом цикле статей — поехали!
Планируемые статьи на тему:
- Формулировка требований и обзор архитектуры
- Жизненный цикл ассета
- Детальный обзор класса AssetManager
- Интеграция в ECS
- GlobalAssetCache
Требования и причины
Требования к системе загрузки ассетов родились между молотом и наковальней. Наковальней выступило желание сделать что-то замкнутое в себе, чтобы оно работало само без написания внешнего кода. Ну, или почти без написания внешнего кода. Молотом же стала реальность. И вот к чему мы в итоге пришли:
- Автоматическое управление памятью, а значит отсутствие необходимости вызывать функцию release для ассета. То есть, как только все внешние объекты пользующиеся ассетом разрушены, происходит разрушение ассета. Мотивация здесь простая — писать меньше кода. Меньше кода — меньше ошибок.
- Асинхронная подготовка ассетов значит, что максимум работы по подготовке ассетов происходит в специальном потоке (мы называем его потоком AssetManager’a). С одной стороны, подготовку ассетов сложно разбить на стадии. С другой — подготовка может занимать достаточно много времени. Если последнее происходит в главном потоке приложения, то приложение может быть «убито» операционной системой как зависшее.
Важный момент заключается в том, что мы рассматриваем именно подготовку ассета, а не его чтение с диска (загрузку). В действительности чтение с диска ассета — лишь один из этапов, причём опциональный. Напримеру, у вас может быть ассет, который представляет собой древовидную структуру данных для быстрого поиска точки пересечения геометрии с лучом. Подготовка такого ассета может не грузить геометрию с диска, если последняя уже загружена для отрисовки, а просто получить указатель. Однако в дальнейшем я буду использовать термин загрузка, а не подготовка, поскольку он более привычный. - Автоматическая перезагрузка ассета в случае изменения файлов на дисковом носителе. Изначальный посыл: модификация текста шейдера должна приводить к изменению картинки. Но раз уж мы проектируем систему для перезагрузки шейдеров, то и все остальные типы ассетов перезагружать будет полезно.
- Совместное (shared) использование ассетов. Предположим, что в памяти приложения уже существует некоторый загруженный ассет. Он используется, что препятствует его разрушению. Если в этот момент произойдет запрос этого ассета из другого «места» приложения, то будет возвращен указатель на уже загруженный ассет вместо создания второго объекта и его загрузки.
- Приоритезация загрузки ассетов. Уровней приоритета всего 3: High, Medium, Low. В рамках одинакового приоритета ассеты загружаются в порядке запроса. Представьте себе ситуацию: игрок нажимает «В бой», и начинается загрузка уровня. Вместе с этим в очередь загрузки попадает задача по подготовке спрайта экрана загрузки. Но поскольку часть ассетов уровня попали в очередь раньше спрайта, игрок смотрит на черный экран достаточно продолжительное время.
Кроме того, мы сформулировали для себя простое правило: «Все, что может быть сделано на потоке AssetManager’a, должно быть сделано на потоке AssetManager’a». Например, подготовка разбиения ландшафта и текстуры нормалей на основе карты высот, линковка GPU программы и т.д.
Некоторые детали реализации
Прежде, чем мы начнем разбираться в том, как работает система загрузки ассетов, надо ознакомиться с двумя классами, которые повсеместно используются в движке Blitz.Engine:
Type
: runtime информация о некотором типе. Данный тип схож с типомType
из языка C#, за тем исключением, что не предоставляет доступа к полям и методам типа. Содержит: имя типа, ряд признаков вродеis_floating, is_pointer, is_const
и т.д. МетодType::instance<T>
в рамках одного запуска приложения возвращает постоянныйconst Type*
, что позволяет делать проверки видаif (type == Type::instance<T>())
Any
: позволяет упаковать значение любого movable или copyable типа. Знание о том, какой тип упакован вAny
хранится в виде constType*
.Any
умеет считать хэш по своему содержимому, а также умеет сравнивать содержимое на равенство. Попутно позволяет делать преобразования из текущего типа в другой. Это своего рода переосмысление класса any из стандартной библиотеки или библиотеки boost.
Вся система загрузки ассетов базируется на трех классах:
AssetManager, AssetBase, IAssetSerializer
. Однако, прежде, чем перейти к описанию этих классов, надо сказать, что внешний код использует псевдоним Asset<T>
который объявлен так:Asset = std::shared_ptr<T>
где T — это AssetBase или конкретный тип ассета. Используя везде shared_ptr, мы достигаем выполнения требования номер 1 (Автоматическое управление памятью).
AssetManager
— это конечный класс, не имеющий наследников. Данный класс определяет цикл жизни ассета и рассылает сообщения об изменении состояния ассета. Также AssetManager
хранит дерево зависимостей между ассетами и привязку ассета к файлам на диске, слушает FileWatcher
и реализует перезагрузку ассета. И самое главное — AssetManager
запускает отдельный поток, реализует очередь задач на подготовку ассета и инкапсулирует в себе всю синхронизацию с другими потоками приложения (запрос ассета может быть выполнен из любого потока приложения, включая поток загрузки).При этом
AssetManager
оперирует абстрактным ассетом AssetBase
, делегируя задачи создания и загрузки ассета конкретного типа наследнику от IAssetSerializer
. О том, как это происходит я расскажу подробнее в последующих статьях.В рамках требования номер 4 (Совместное использование ассетов) одним из самых жарких вопросов стал «что использовать в качестве идентификатора ассета?». Самым простым и, казалось бы, очевидным решением было бы использовать путь к файлу, который надо загрузить. Однако, такое решение накладывает ряд серьёзных ограничений:
- Для создания ассета последний должен быть представлен в виде файла на диске, что отменяет возможность создания runtime ассетов на основе других ассетов.
- Нет механизма передачи дополнительной информации. Например, для создания ассета GPUProgram нужен список определений препроцессора (defines). И поскольку требуется совместное использование ассетов, то определения препроцессора должны быть частью идентификатора.
- Отсутствует возможность загрузить один и тот же ассет два раза, когда это необходимо.
- Отсутствует возможность загрузить два разных ассета из одного файла.
Пункт 3 и 4 мы не рассматривали как довод в самом начале, так как не было даже мысли о том, что это может пригодится. Однако эти возможности в последствии сильно облегчили разработку редактора.
Таким образом, мы приняли решение использовать в качестве идентификатора ключ ассета, который на уровне
AssetManager
представлен типом Any
. О том, как интерпретировать Any
, знает наследник IAssetSerializer
. Сам AssetManager
знает лишь связь между типом ключа и наследником IAssetSerializer
. Код, который запрашивает ассет, обычно знает какого типа ассет ему нужен и оперирует ключем конкретного типа. Все это происходит примерно так:
class Texture: public AssetBase
{
public:
struct PathKey
{
FilePath path;
size_t hash() const;
bool operator==(const PathKey& other);
};
struct MemoryKey
{
u32 width = 1;
u32 height = 1;
u32 level_count = 1;
TextureFormat format = RBGA8;
TextureType type = TEX_2D;
Vector<Vector<u8*>> data; // Face<MipLevels<Image>>
size_t hash() const;
bool operator==(const MemoryKey& other);
};
};
class TextureSerializer: public IAssetSerializer
{
};
class AssetManager final
{
public:
template<typename T>
Asset<T> get_asset(const Any& key, ...);
Asset<AssetBase> get_asset(const Any& key, ...);
};
int main()
{
...
Texture::PathKey key("/path_to_asset");
Asset<Texture> asset = asset_manager->get_asset<Texture>(key);
...
Texture::MemoryKey mem_key;
mem_key.width = 128;
mem_key.format = 128;
mem_key.level_count = 1;
mem_key.format = A8;
mem_key.type = TEX_2D;
Vector<u8*>& mip_chain = mem_key.data.emplace_back();
mip_chain.push_back(generage_sdf_font());
Asset<Texture> sdf_font_texture = asset_manager->get_asset<Texture>(mem_key);
};
Метод
hash
и оператор сравнения внутри PathKey
нужны для функционирования соответствующих операций класса Any
, но мы не будем подробно на этом останавливаться.Итак, что происходит в коде выше: в момент вызова
get_asset(key)
ключ будет скопирован во временный объект типа Any
, который, в свою очередь, будет передан в метод get_asset
. Далее AssetManager
возьмет у аргумента тип ключа. В нашем случае это будет: Type::instance<MyAsset::PathKey>
По этому типу он найдет объект сериализатора и делегирует сериализатору все последующие операции (создание и загрузка).
AssetBase
— это базовый класс для всех типов ассетов в движке. Данный класс хранит ключ ассета, текущее состояние ассета (загружен, в очереди и т.д.), а также текст ошибки, если загрузка ассета завершилась неудачей. В действительности внутреннее устройство немного сложнее, но это мы рассмотрим вместе с жизненным циклом ассета.IAssetSerializer
, как видно из названия, — это базовый класс для сущности, которая занимается подготовкой ассета. На самом деле наследник данного класса занимается не только загрузкой ассета:- Аллокация и деаллокация объекта ассета конкретного типа.
- Загрузка ассета конкретного типа.
- Составление списка путей к файлам, на основании которых строится ассет. Этот список нужен для механизма перезагрузки ассета при изменении файла. Возникает вопрос: зачем список путей, а не один путь? Простые ассеты, вроде текстур, действительно могут строиться на основании одного файла. Однако, если мы рассмотрим шейдер, то увидим, что перезагрузка должна происходить не только в случае изменения текста шейдера, но и в случае изменения файла, подключенного в шейдер через директиву include.
- Сохранение ассета на диск. Активно используется как при редактировании ассетов, так и при подготовке ассетов для игры.
- Сообщает типы ключей, которые поддерживает.
И последний вопрос, который я хочу осветить в рамках данной статьи: для чего может понадобится заводить несколько типов ключей на один сериализатор/ассет? Давайте разбираться по очереди.
Один сериализатор — несколько типов ключей
Разберем на примере ассета
GPUProgram
(то есть шейдера). Для того чтобы загрузить шейдер в нашем движке, необходима следующая информация:- Путь к файлу шейдера.
- Список определений препроцессора.
- Стадия для которой собирается и компилируется шейдер (vertex, fragment, compute).
- Имя точки входа.
Собрав эту информацию вместе, мы получаем ключ шейдера, который используется в игре. Однако, в ходе разработки игры или движка часто возникает необходимость вывести на экран, иногда специфическим шейдером, какую-то отладочную информацию. И в этой ситуации бывает удобно написать текст шейдера прямо в коде. Для этого мы можем завести второй тип ключа, который вместо пути к файлу и списка определений препроцессора будет содержать текст шейдера.
Рассмотрим другой пример: текстура. Самый простой способ создать текстуру — загрузить с диска. Для этого нам нужен путь к файлу (
PathKey
). Но мы также можем сгенерировать содержимое текстуры алгоритмически и создать текстуру из массива байт (MemoryKey
). Третьим типом ключа может стать ключ для создания RenderTarget
текстуры (RTKey
).В зависимости от типа ключа могут использоваться различные движки растеризации глифа: stb (StbFontKey), FreeType (FTFontKet) либо самописный генератор signed distance field шрифтов (SDFFontKey).
Анимация ключевыми кадрами может быть загружена (
PathKey
), либо сформирована кодом (MemoryKey
).Один ассет — несколько типов ключей
Представьте себе, что у нас есть
ParticleEffect
ассет, который описывает правила генерации частиц. Кроме того, у нас есть удобный редактор данного ассета. При этом редактор уровней и редактор частиц — это одно многооконное приложения. Это удобно, поскольку можно открыть уровень, разместить в нем источник частиц и смотреть на эффект в окружении уровня, параллельно редактируя сам эффект. Если у нас один тип ключа, то объект эффекта, который используется в мире редактирования эффекта и в мире уровня, один и тот же. Все изменения, произведенные в редакторе эффекта, сразу будут видны в уровне. На первый взгляд может показаться, что это крутая идея, но давайте рассмотрим следующие сценарии:- Мы открыли для редактирования эффект, расположенный на уровне, внесли изменения и закрыли, не сохраняя внесенные изменения. Тем не менее, объект в памяти был изменен и в уровне будет отображаться с изменениями.
- Какая-то из систем запомнила указатель на часть ассета, чтобы быстрее обновлять частицы. В редакторе частиц происходит удаление этой части ассета, и на следующей итерации работы системы мы обращаемся к удаленной области памяти.
Кроме того, возможна ситуация, в который мы из одного файла на диске по двум разным типам ключей создаем два разных типа ассета. По «игровому» типу ключа мы создаем структуру данных, оптимизированную для быстрой работы в игре. По «редакторному» типу ключа мы создаем структуру данных, удобную для редактирования. Примерно таким образом в нашем редакторе реализовано редактирование
BlendTree
для скелетных анимаций. По одному типу ключа ассетная система строит нам ассет с честным деревом внутри и кучей сигналов об изменении топологии, что очень удобно при редактировании, но достаточно медленно в игре. По другому типу ключа сериализатор создает другой тип ассета: ассет не имеет никаких методов, касающихся изменения дерева, а само дерево превращено в массив узлов, где ссылка на узел — индекс в массиве.Эпилог
Подводя итоги, я бы хотел сконцентрировать ваше внимание на решениях, которые сильнее всего повлияли на дальнейшее развитие движка:
- Использование пользовательской структуры в качестве ключа ассета, а не пути к файлу.
- Загрузка ассета только в асинхронном режиме.
- Гибкая схема управления совместным использованием ассета (один ассет — несколько типов ключей).
- Возможность получать ассет одного и того же типа, используя разные источники данных (поддержка нескольких типов ключей в одном сериализаторе).
О том, как именно эти решения повлияли на реализацию как внутреннего кода, так и внешнего, вы узнаете в следующих сериях.
Автор: ExMix
babylon
И тут без интерфейсов не обошлось. Не лучше ли схемами заменить?
BlitzTeam Автор
Где на Ваш взгляд схемы добавят понимания?
babylon
Схемы не для этого. Они для автоматизации. Да можно шаблонить интерфейсы. И так делают весьма часто. Но это всё таки императивный паттерн проектирования который влияет на кодирование не в лучшую сторону. Как по мне. Я делал и так и со схемами.Игры тоже. Недавно наткнулся на правильную мысль, что валидацию (в широком смысле) лучше заменять парсингом. И для этого схемы подходят как нельзя лучше.
ExMix
Если честно не совсем понимаю о чем Вы говорите, но Вы меня заинтриговали. Можете дать пару ссылок, где можно почитать о схемах?
ab6
Что за «схемы»?
babylon
Мне нравится формат Apache Avro.
https://www.tarantool.io/ru/doc/1.10/book/app_server/creating_app/
Вы описываете персонажа схемой. В которой указываете набор ассетов, ресурсов, скинов и др. пропсов персонажей — врагов или героев. И в соответствии со схемой собираете объект. Для управления объектом используются запросы.Их можно легко перепрограммировать, если внезапно набор ассетов меняется. Долго расписывать как это делать. Но интуитивно понятно. Да тогда — во времена флеша ещё не было тарантула, но правильные идеи уже витали в воздухе. Я всегда старался смотреть на приложение с точки зрения данных. На любое приложение игра это или не игра.