Прежде чем разбираться в том, как работает ассетная система движка Blitz.Engine, нам необходимо определиться с тем, что такое ассет и что именно мы будем понимать под ассетной системой. Согласно Википедии, игровой ассет — это цифровой объект, преимущественно состоящий из однотипных данных, неделимая сущность, которая представляет часть игрового контента и обладает некими свойствами. С точки зрения программной модели, ассет может выступать в виде объекта, созданного на некотором наборе данных. Ассет может храниться в виде отдельного файла. В свою очередь, ассетная система — это множество программного кода, отвечающего за загрузку и оперирование ассетов различных типов.

Фактически ассетная система — это большая часть игрового движка, которая может стать верным помощником для разработчиков игры или же превратить их жизни в кромешный ад. Логичным, с моей точки зрения, решением было сконцентрировать этот «ад» в одном месте, бережно оберегая от него других разработчиков команды. О том, что у нас получилось, мы и расскажем в этом цикле статей — поехали!

Планируемые статьи на тему:

  • Формулировка требований и обзор архитектуры
  • Жизненный цикл ассета
  • Детальный обзор класса AssetManager
  • Интеграция в ECS
  • GlobalAssetCache

Требования и причины


Требования к системе загрузки ассетов родились между молотом и наковальней. Наковальней выступило желание сделать что-то замкнутое в себе, чтобы оно работало само без написания внешнего кода. Ну, или почти без написания внешнего кода. Молотом же стала реальность. И вот к чему мы в итоге пришли:

  1. Автоматическое управление памятью, а значит отсутствие необходимости вызывать функцию release для ассета. То есть, как только все внешние объекты пользующиеся ассетом разрушены, происходит разрушение ассета. Мотивация здесь простая — писать меньше кода. Меньше кода — меньше ошибок.
  2. Асинхронная подготовка ассетов значит, что максимум работы по подготовке ассетов происходит в специальном потоке (мы называем его потоком AssetManager’a). С одной стороны, подготовку ассетов сложно разбить на стадии. С другой — подготовка может занимать достаточно много времени. Если последнее происходит в главном потоке приложения, то приложение может быть «убито» операционной системой как зависшее.
    Важный момент заключается в том, что мы рассматриваем именно подготовку ассета, а не его чтение с диска (загрузку). В действительности чтение с диска ассета — лишь один из этапов, причём опциональный. Напримеру, у вас может быть ассет, который представляет собой древовидную структуру данных для быстрого поиска точки пересечения геометрии с лучом. Подготовка такого ассета может не грузить геометрию с диска, если последняя уже загружена для отрисовки, а просто получить указатель. Однако в дальнейшем я буду использовать термин загрузка, а не подготовка, поскольку он более привычный.
  3. Автоматическая перезагрузка ассета в случае изменения файлов на дисковом носителе. Изначальный посыл: модификация текста шейдера должна приводить к изменению картинки. Но раз уж мы проектируем систему для перезагрузки шейдеров, то и все остальные типы ассетов перезагружать будет полезно.
  4. Совместное (shared) использование ассетов. Предположим, что в памяти приложения уже существует некоторый загруженный ассет. Он используется, что препятствует его разрушению. Если в этот момент произойдет запрос этого ассета из другого «места» приложения, то будет возвращен указатель на уже загруженный ассет вместо создания второго объекта и его загрузки.
  5. Приоритезация загрузки ассетов. Уровней приоритета всего 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 хранится в виде const Type*. 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 (Совместное использование ассетов) одним из самых жарких вопросов стал «что использовать в качестве идентификатора ассета?». Самым простым и, казалось бы, очевидным решением было бы использовать путь к файлу, который надо загрузить. Однако, такое решение накладывает ряд серьёзных ограничений:

  1. Для создания ассета последний должен быть представлен в виде файла на диске, что отменяет возможность создания runtime ассетов на основе других ассетов.
  2. Нет механизма передачи дополнительной информации. Например, для создания ассета GPUProgram нужен список определений препроцессора (defines). И поскольку требуется совместное использование ассетов, то определения препроцессора должны быть частью идентификатора.
  3. Отсутствует возможность загрузить один и тот же ассет два раза, когда это необходимо.
  4. Отсутствует возможность загрузить два разных ассета из одного файла.

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

  1. Путь к файлу шейдера.
  2. Список определений препроцессора.
  3. Стадия для которой собирается и компилируется шейдер (vertex, fragment, compute).
  4. Имя точки входа.

Собрав эту информацию вместе, мы получаем ключ шейдера, который используется в игре. Однако, в ходе разработки игры или движка часто возникает необходимость вывести на экран, иногда специфическим шейдером, какую-то отладочную информацию. И в этой ситуации бывает удобно написать текст шейдера прямо в коде. Для этого мы можем завести второй тип ключа, который вместо пути к файлу и списка определений препроцессора будет содержать текст шейдера.

Рассмотрим другой пример: текстура. Самый простой способ создать текстуру — загрузить с диска. Для этого нам нужен путь к файлу (PathKey). Но мы также можем сгенерировать содержимое текстуры алгоритмически и создать текстуру из массива байт (MemoryKey). Третьим типом ключа может стать ключ для создания RenderTarget текстуры (RTKey).

В зависимости от типа ключа могут использоваться различные движки растеризации глифа: stb (StbFontKey), FreeType (FTFontKet) либо самописный генератор signed distance field шрифтов (SDFFontKey).

Анимация ключевыми кадрами может быть загружена (PathKey), либо сформирована кодом (MemoryKey).

Один ассет — несколько типов ключей


Представьте себе, что у нас есть ParticleEffect ассет, который описывает правила генерации частиц. Кроме того, у нас есть удобный редактор данного ассета. При этом редактор уровней и редактор частиц — это одно многооконное приложения. Это удобно, поскольку можно открыть уровень, разместить в нем источник частиц и смотреть на эффект в окружении уровня, параллельно редактируя сам эффект. Если у нас один тип ключа, то объект эффекта, который используется в мире редактирования эффекта и в мире уровня, один и тот же. Все изменения, произведенные в редакторе эффекта, сразу будут видны в уровне. На первый взгляд может показаться, что это крутая идея, но давайте рассмотрим следующие сценарии:

  1. Мы открыли для редактирования эффект, расположенный на уровне, внесли изменения и закрыли, не сохраняя внесенные изменения. Тем не менее, объект в памяти был изменен и в уровне будет отображаться с изменениями.
  2. Какая-то из систем запомнила указатель на часть ассета, чтобы быстрее обновлять частицы. В редакторе частиц происходит удаление этой части ассета, и на следующей итерации работы системы мы обращаемся к удаленной области памяти.

Кроме того, возможна ситуация, в который мы из одного файла на диске по двум разным типам ключей создаем два разных типа ассета. По «игровому» типу ключа мы создаем структуру данных, оптимизированную для быстрой работы в игре. По «редакторному» типу ключа мы создаем структуру данных, удобную для редактирования. Примерно таким образом в нашем редакторе реализовано редактирование BlendTree для скелетных анимаций. По одному типу ключа ассетная система строит нам ассет с честным деревом внутри и кучей сигналов об изменении топологии, что очень удобно при редактировании, но достаточно медленно в игре. По другому типу ключа сериализатор создает другой тип ассета: ассет не имеет никаких методов, касающихся изменения дерева, а само дерево превращено в массив узлов, где ссылка на узел — индекс в массиве.

Эпилог


Подводя итоги, я бы хотел сконцентрировать ваше внимание на решениях, которые сильнее всего повлияли на дальнейшее развитие движка:

  1. Использование пользовательской структуры в качестве ключа ассета, а не пути к файлу.
  2. Загрузка ассета только в асинхронном режиме.
  3. Гибкая схема управления совместным использованием ассета (один ассет — несколько типов ключей).
  4. Возможность получать ассет одного и того же типа, используя разные источники данных (поддержка нескольких типов ключей в одном сериализаторе).

О том, как именно эти решения повлияли на реализацию как внутреннего кода, так и внешнего, вы узнаете в следующих сериях.

Автор: ExMix