Blitz.Engine: Ассетная система
Прежде чем разбираться в том, как работает ассетная система движка 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
в рамках одного запуска приложения возвращает постоянныйconst Type*
, что позволяет делать проверки видаif (type == Type::instance
()) Any
: позволяет упаковать значение любого movable или copyable типа. Знание о том, какой тип упакован вAny
хранится в виде constType*
.Any
умеет считать хэш по своему содержимому, а также умеет сравнивать содержимое на равенство. Попутно позволяет делать преобразования из текущего типа в другой. Это своего рода переосмысление класса any из стандартной библиотеки или библиотеки boost.
Вся система загрузки ассетов базируется на трех классах: AssetManager, AssetBase, IAssetSerializer
. Однако, прежде, чем перейти к описанию этих классов, надо сказать, что внешний код использует псевдоним Asset
который объявлен так:
Asset = std::shared_ptr
где 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> data; // Face>
size_t hash() const;
bool operator==(const MemoryKey& other);
};
};
class TextureSerializer: public IAssetSerializer
{
};
class AssetManager final
{
public:
template
Asset get_asset(const Any& key, ...);
Asset get_asset(const Any& key, ...);
};
int main()
{
...
Texture::PathKey key("/path_to_asset");
Asset asset = asset_manager->get_asset(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& mip_chain = mem_key.data.emplace_back();
mip_chain.push_back(generage_sdf_font());
Asset sdf_font_texture = asset_manager->get_asset(mem_key);
};
Метод hash
и оператор сравнения внутри PathKey
нужны для функционирования соответствующих операций класса Any
, но мы не будем подробно на этом останавливаться.
Итак, что происходит в коде выше: в момент вызова get_asset(key)
ключ будет скопирован во временный объект типа Any
, который, в свою очередь, будет передан в метод get_asset
. Далее AssetManager
возьмет у аргумента тип ключа. В нашем случае это будет:
Type::instance
По этому типу он найдет объект сериализатора и делегирует сериализатору все последующие операции (создание и загрузка).
AssetBase
— это базовый класс для всех типов ассетов в движке. Данный класс хранит ключ ассета, текущее состояние ассета (загружен, в очереди и т.д.), а также текст ошибки, если загрузка ассета завершилась неудачей. В действительности внутреннее устройство немного сложнее, но это мы рассмотрим вместе с жизненным циклом ассета.
IAssetSerializer
, как видно из названия, — это базовый класс для сущности, которая занимается подготовкой ассета. На самом деле наследник данного класса занимается не только загрузкой ассета:
- Аллокация и деаллокация объекта ассета конкретного типа.
- Загрузка ассета конкретного типа.
- Составление списка путей к файлам, на основании которых строится ассет. Этот список нужен для механизма перезагрузки ассета при изменении файла. Возникает вопрос: зачем список путей, а не один путь? Простые ассеты, вроде текстур, действительно могут строиться на основании одного файла. Однако, если мы рассмотрим шейдер, то увидим, что перезагрузка должна происходить не только в случае изменения текста шейдера, но и в случае изменения файла, подключенного в шейдер через директиву include.
- Сохранение ассета на диск. Активно используется как при редактировании ассетов, так и при подготовке ассетов для игры.
- Сообщает типы ключей, которые поддерживает.
И последний вопрос, который я хочу осветить в рамках данной статьи: для чего может понадобится заводить несколько типов ключей на один сериализатор/ассет? Давайте разбираться по очереди.
Один сериализатор — несколько типов ключей
Разберем на примере ассета GPUProgram
(то есть шейдера). Для того чтобы загрузить шейдер в нашем движке, необходима следующая информация:
- Путь к файлу шейдера.
- Список определений препроцессора.
- Стадия для которой собирается и компилируется шейдер (vertex, fragment, compute).
- Имя точки входа.
Собрав эту информацию вместе, мы получаем ключ шейдера, который используется в игре. Однако, в ходе разработки игры или движка часто возникает необходимость вывести на экран, иногда специфическим шейдером, какую-то отладочную информацию. И в этой ситуации бывает удобно написать текст шейдера прямо в коде. Для этого мы можем завести второй тип ключа, который вместо пути к файлу и списка определений препроцессора будет содержать текст шейдера.
Рассмотрим другой пример: текстура. Самый простой способ создать текстуру — загрузить с диска. Для этого нам нужен путь к файлу (PathKey
). Но мы также можем сгенерировать содержимое текстуры алгоритмически и создать текстуру из массива байт (MemoryKey
). Третьим типом ключа может стать ключ для создания RenderTarget
текстуры (RTKey
).
В зависимости от типа ключа могут использоваться различные движки растеризации глифа: stb (StbFontKey), FreeType (FTFontKet) либо самописный генератор signed distance field шрифтов (SDFFontKey).
Анимация ключевыми кадрами может быть загружена (PathKey
), либо сформирована кодом (MemoryKey
).
Один ассет — несколько типов ключей
Представьте себе, что у нас есть ParticleEffect
ассет, который описывает правила генерации частиц. Кроме того, у нас есть удобный редактор данного ассета. При этом редактор уровней и редактор частиц — это одно многооконное приложения. Это удобно, поскольку можно открыть уровень, разместить в нем источник частиц и смотреть на эффект в окружении уровня, параллельно редактируя сам эффект. Если у нас один тип ключа, то объект эффекта, который используется в мире редактирования эффекта и в мире уровня, один и тот же. Все изменения, произведенные в редакторе эффекта, сразу будут видны в уровне. На первый взгляд может показаться, что это крутая идея, но давайте рассмотрим следующие сценарии:
- Мы открыли для редактирования эффект, расположенный на уровне, внесли изменения и закрыли, не сохраняя внесенные изменения. Тем не менее, объект в памяти был изменен и в уровне будет отображаться с изменениями.
- Какая-то из систем запомнила указатель на часть ассета, чтобы быстрее обновлять частицы. В редакторе частиц происходит удаление этой части ассета, и на следующей итерации работы системы мы обращаемся к удаленной области памяти.
Кроме того, возможна ситуация, в который мы из одного файла на диске по двум разным типам ключей создаем два разных типа ассета. По «игровому» типу ключа мы создаем структуру данных, оптимизированную для быстрой работы в игре. По «редакторному» типу ключа мы создаем структуру данных, удобную для редактирования. Примерно таким образом в нашем редакторе реализовано редактирование BlendTree
для скелетных анимаций. По одному типу ключа ассетная система строит нам ассет с честным деревом внутри и кучей сигналов об изменении топологии, что очень удобно при редактировании, но достаточно медленно в игре. По другому типу ключа сериализатор создает другой тип ассета: ассет не имеет никаких методов, касающихся изменения дерева, а само дерево превращено в массив узлов, где ссылка на узел — индекс в массиве.
Эпилог
Подводя итоги, я бы хотел сконцентрировать ваше внимание на решениях, которые сильнее всего повлияли на дальнейшее развитие движка:
- Использование пользовательской структуры в качестве ключа ассета, а не пути к файлу.
- Загрузка ассета только в асинхронном режиме.
- Гибкая схема управления совместным использованием ассета (один ассет — несколько типов ключей).
- Возможность получать ассет одного и того же типа, используя разные источники данных (поддержка нескольких типов ключей в одном сериализаторе).
О том, как именно эти решения повлияли на реализацию как внутреннего кода, так и внешнего, вы узнаете в следующих сериях.
Автор: ExMix