Blitz.Engine: sistema de activos



Antes de comprender cómo funciona el sistema de activos del motor Blitz.Engine , debemos decidir qué es un activo y qué entendemos exactamente por sistema de activo. Según Wikipedia, un activo de juego es un objeto digital, que consiste principalmente en los mismos datos, una entidad indivisible que representa parte del contenido del juego y tiene ciertas propiedades. Desde el punto de vista del modelo de programa, un activo puede aparecer como un objeto creado en algún conjunto de datos. Los activos se pueden almacenar como un archivo separado. A su vez, un sistema de activos es una gran cantidad de código de programa responsable de cargar y operar activos de varios tipos.

De hecho, un sistema de activos es una gran parte del motor del juego, que puede convertirse en un asistente fiel para los desarrolladores de juegos o convertir sus vidas en un infierno. En mi opinión, la decisión lógica fue concentrar este "infierno" en un lugar, protegiendo cuidadosamente a los desarrolladores de otros equipos. Le diremos lo que hicimos en esta serie de artículos. ¡Vamos!

Artículos planificados sobre el tema:

  • Declaración de requisitos y descripción general de la arquitectura
  • Ciclo de vida de los activos
  • Descripción detallada de la clase AssetManager
  • Integración en ECS
  • GlobalAssetCache

Requisitos y razones


Los requisitos del sistema de carga de activos nacieron entre una roca y un lugar duro. Un yunque era el deseo de hacer algo encerrado en sí mismo para que funcionara sin escribir código externo. Bueno, o casi sin escribir código externo. El martillo se hizo realidad. Y esto es lo que terminamos con:

  1. Administración automática de memoria , lo que significa que no hay necesidad de llamar a la función de liberación del activo. Es decir, tan pronto como se destruyen todos los objetos externos que usan el activo, el activo se destruye. La motivación aquí es simple: escriba menos código. Menos código significa menos errores.
  2. , ( AssetManager’a). , . — . , «» .
    , , (). — , . , , . , , . , , .
  3. . : . , .
  4. (shared) . , . , . «» , .
  5. Priorizar la carga de activos . Solo hay 3 niveles de prioridad: Alto, Medio, Bajo. Dentro de la misma prioridad, los activos se cargan en el orden de la solicitud. Imagine una situación: un jugador hace clic en "Para luchar", y comienza la carga de nivel. Junto con esto, la tarea de preparar el sprite de la pantalla de carga cae en la cola de descarga. Pero dado que algunos de los activos de nivel entraron en la cola antes del sprite, el jugador mira la pantalla en negro durante bastante tiempo.

Además, formulamos una regla simple para nosotros: "Todo lo que se puede hacer en el hilo AssetManager debe hacerse en el hilo AssetManager". Por ejemplo, preparar una partición del paisaje y la textura de las normales en función de un mapa de altura, vincular un programa de GPU, etc.

Algunos detalles de implementación


Antes de comenzar a comprender cómo funciona el sistema de carga de activos, debemos familiarizarnos con dos clases que se utilizan ampliamente en el motor Blitz.Engine:

  • Type: información de tiempo de ejecución sobre algún tipo. Este tipo es similar a un tipo Typedel lenguaje C #, con la excepción de que no proporciona acceso a los campos y métodos del tipo. Contiene: nombre del tipo, una serie de signos como is_floating, is_pointer, is_const, etc. El método Type::instance<T>devuelve una constante dentro del inicio de una aplicación const Type*, lo que le permite hacer verificaciones del formularioif (type == Type::instance<T>())
  • Any: le permite empaquetar el valor de cualquier tipo móvil o copiable. El conocimiento de qué tipo está empaquetado se Anyalmacena como constante Type*. Anysabe cómo calcular un hash de acuerdo con su contenido, y también sabe cómo comparar contenidos para la igualdad. En el camino, le permite realizar conversiones del tipo actual a otro. Este es un tipo de replanteamiento de cualquier clase de la biblioteca estándar o biblioteca de impulso.

Todo el sistema de carga de la lista de activos se basa en tres clases: AssetManager, AssetBase, IAssetSerializer. Sin embargo, antes de continuar con la descripción de estas clases, se debe decir que el código externo usa un alias Asset<T>que se declara así:

Asset = std::shared_ptr<T>

donde T es un AssetBase o un tipo específico de activo. Usando shared_ptr en todas partes, logramos el cumplimiento del requisito número 1 (Administración automática de memoria).

AssetManager- Esta es una clase finita que no tiene herederos. Esta clase define el ciclo de vida de un activo y envía mensajes sobre cambios en el estado de un activo. También AssetManageralmacena un árbol de dependencia entre activos y un enlace de activos a archivos en el disco, escucha FileWatchere implementa una recarga de activos. Y lo más importante, AssetManagerlanza un subproceso separado, implementa una cola de tareas para preparar el activo y encapsula toda la sincronización con otros subprocesos de aplicación (la solicitud del activo se puede realizar desde cualquier subproceso de aplicación, incluida la secuencia de descarga).

Al mismo tiempo AssetManageropera con un activo abstractoAssetBase, delegando la tarea de crear y cargar un activo de un tipo específico al heredero de IAssetSerializer. Te contaré más sobre cómo sucede esto en artículos posteriores.

Como parte del requisito número 4 (intercambio de activos), una de las preguntas más importantes fue "¿qué usar como identificador de activos?" La solución más simple y aparentemente obvia sería usar la ruta al archivo que se va a descargar. Sin embargo, esta decisión impone una serie de limitaciones serias:

  1. Para crear un activo, este último debe representarse como un archivo en el disco, lo que elimina la capacidad de crear activos en tiempo de ejecución basados ​​en otros activos.
  2. . , GPUProgram (defines). , .
  3. , .
  4. .

No consideramos los párrafos 3 y 4 como un argumento al principio, ya que ni siquiera se pensaba que esto pudiera ser útil. Sin embargo, estas características posteriormente facilitaron enormemente el desarrollo del editor.

Por lo tanto, decidimos usar la clave del activo como un identificador, que en el nivel AssetManagerestá representado por el tipo Any. El Anyheredero sabe interpretar IAssetSerializer. Sí AssetManagersólo conoce la relación entre el tipo de clave y el heredero IAssetSerializer. El código que solicita un activo generalmente sabe qué tipo de activo necesita y funciona con una clave de un tipo específico. Todo va más o menos así:


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);
};

El método hashy el operador de comparación en el interior PathKeyson necesarios para el funcionamiento de las operaciones de clase correspondientes Any, pero no nos detendremos en esto en detalle.

Entonces, qué sucede en el código anterior: en el momento de la llamada, la get_asset(key)clave se copiará en un objeto temporal del tipo Any, que, a su vez, se pasará al método get_asset. A continuación, AssetManagertome el tipo de clave del argumento. En nuestro caso, será:

Type::instance<MyAsset::PathKey>

Por este tipo, encontrará el objeto del serializador y delegará al serializador todas las operaciones posteriores (creación y carga).

AssetBase- Esta es la clase base para todo tipo de activos en el motor. Esta clase almacena la clave del activo, el estado actual del activo (cargado, en cola, etc.), así como el texto de error si falla la carga del activo. De hecho, la estructura interna es un poco más complicada, pero consideraremos esto junto con el ciclo de vida de los activos.

IAssetSerializer, como su nombre lo indica, es la clase base para la entidad que se dedica a la preparación del activo. De hecho, el heredero de esta clase no solo está cargando el activo:

  • Asignación y desasignación de un objeto activo de un tipo específico.
  • Cargando un activo de un tipo específico.
  • Compilar una lista de rutas de archivos en función de la cual se construye el activo. Esta lista es necesaria para el mecanismo de recarga de activos cuando cambia un archivo. Surge la pregunta: ¿por qué la lista de rutas, y no una ruta? Los activos simples, como las texturas, realmente se pueden construir sobre la base de un solo archivo. Sin embargo, si miramos el sombreador, veremos que el reinicio debería ocurrir no solo si el texto del sombreador cambia, sino también si el archivo conectado al sombreador cambia a través de la directiva include.
  • Guardando activo en el disco. Se usa activamente tanto al editar activos como al preparar activos para el juego.
  • Informa los tipos de claves que admite.

Y la última pregunta que quiero destacar en el marco de este artículo: ¿por qué podría necesitar tener varios tipos de claves en un serializador / activo? Vamos a resolverlo por turnos.

Un serializador: varios tipos de claves


Tomemos un ejemplo de un activo GPUProgram(es decir, un sombreador). Para cargar un sombreador en nuestro motor, se requiere la siguiente información:

  1. La ruta al archivo del sombreador.
  2. Lista de definiciones de preprocesador.
  3. La etapa para la que se ensambla y compila el sombreador (vértice, fragmento, cálculo).
  4. El nombre del punto de entrada.

Al reunir esta información, obtenemos la tecla de sombreador, que se utiliza en el juego. Sin embargo, durante el desarrollo de un juego o motor, a menudo es necesario mostrar cierta información de depuración en la pantalla, a veces con un sombreador específico. Y en esta situación, puede ser conveniente escribir el texto del sombreador directamente en el código. Para hacer esto, podemos obtener el segundo tipo de clave, que en lugar de la ruta al archivo y la lista de definiciones de preprocesador contendrá el texto del sombreador.

Considere otro ejemplo: textura. La forma más fácil de crear una textura es cargarla desde el disco. Para hacer esto, necesitamos la ruta al archivo ( PathKey). Pero también podemos generar contenidos de textura algorítmicamente y crear una textura a partir de una matriz de bytes ( MemoryKey). El tercer tipo de clave puede ser una clave para crear una RenderTargettextura ( RTKey).

Dependiendo del tipo de clave, se pueden usar varios motores de rasterización de glifos: stb (StbFontKey), FreeType (FTFontKet) o un generador de fuentes de campo de distancia firmado y autofirmado (SDFFontKey).

La animación de fotogramas clave se puede cargar ( PathKey) o generar mediante código ( MemoryKey).

Un activo: varios tipos de claves


Imagine que tenemos un ParticleEffectactivo que describe las reglas para la generación de partículas. Además, tenemos un editor conveniente para este activo. Al mismo tiempo, el editor de niveles y el editor de partículas son una aplicación de múltiples ventanas. Esto es conveniente porque puede abrir un nivel, colocar una fuente de partículas en él y observar el efecto en el entorno del nivel, mientras edita el efecto en sí. Si tenemos un tipo de clave, entonces el objeto de efecto que se usa en el mundo de edición de efectos y en el mundo de nivel es el mismo. Todos los cambios realizados en el editor de efectos serán visibles inmediatamente en el nivel. A primera vista, esto puede parecer una idea genial, pero veamos los siguientes escenarios:

  1. , , , . , .
  2. - , . , .

Además, es posible una situación en la que creamos dos tipos diferentes de activos a partir de un archivo en un disco usando dos tipos diferentes de claves. Utilizando el tipo de clave "juego", creamos una estructura de datos optimizada para un trabajo rápido en el juego. Usando el tipo de clave "editorial", creamos una estructura de datos que es conveniente para la edición. De esta manera, nuestro editor implementa la edición BlendTreepara animaciones esqueléticas. Basado en un tipo de clave, el sistema de activos nos crea un activo con un árbol honesto dentro y un montón de señales sobre el cambio de topología, lo cual es muy conveniente cuando se edita, pero es bastante lento en el juego. Según un tipo diferente de clave, el serializador crea otro tipo de activo: el activo no tiene métodos para cambiar el árbol, y el árbol en sí mismo se convierte en una matriz de nodos, donde el enlace al nodo es un índice en la matriz.

Epílogo


En resumen, me gustaría concentrar su atención en las soluciones que más han influido en el desarrollo posterior del motor:

  1. Usar una estructura personalizada como clave de activo, no una ruta de archivo.
  2. Carga de activos solo en modo asíncrono.
  3. Un esquema flexible para administrar el intercambio de activos (un activo - varios tipos de claves).
  4. La capacidad de recibir activos del mismo tipo utilizando diferentes fuentes de datos (soporte para varios tipos de claves en un serializador).

Aprenderá cómo exactamente estas decisiones influyeron en la implementación del código interno y externo en la próxima serie.

Autor:Exmix

All Articles