Blitz.Engine: Sistema de ativos



Antes de entendermos como o sistema de ativos do mecanismo Blitz.Engine funciona , precisamos decidir o que é ativo e o que exatamente queremos dizer com sistema de ativos. Segundo a Wikipedia, um ativo de jogo é um objeto digital, consistindo principalmente dos mesmos dados, uma entidade indivisível que representa parte do conteúdo do jogo e possui certas propriedades. Do ponto de vista do modelo de programa, um ativo pode aparecer como um objeto criado em algum conjunto de dados. Os ativos podem ser armazenados como um arquivo separado. Por sua vez, um sistema de ativos é um monte de código de programa responsável por carregar e operar ativos de vários tipos.

De fato, o sistema de ativos é uma grande parte do mecanismo de jogo, que pode se tornar um assistente leal para desenvolvedores de jogos ou transformar suas vidas em um inferno. Na minha opinião, a decisão lógica foi concentrar esse "inferno" em um só lugar, protegendo cuidadosamente outros desenvolvedores da equipe. Vamos falar sobre o que fizemos nesta série de artigos - vamos lá!

Artigos planejados sobre o tópico:

  • Declaração de requisitos e visão geral da arquitetura
  • Ciclo de vida do ativo
  • Visão geral detalhada da classe AssetManager
  • Integração no ECS
  • GlobalAssetCache

Requisitos e Motivos


Os requisitos do sistema de carregamento de ativos nasceram entre uma rocha e um local difícil. Uma bigorna era o desejo de fazer algo fechado em si mesmo, para que funcionasse sem escrever código externo. Bem, ou quase sem escrever código externo. O martelo se tornou realidade. E aqui está o que acabamos com:

  1. Gerenciamento automático de memória , o que significa que não há necessidade de chamar a função de liberação do ativo. Ou seja, assim que todos os objetos externos que usam o ativo são destruídos, o ativo é destruído. A motivação aqui é simples - escreva menos código. Menos código significa menos erros.
  2. , ( AssetManager’a). , . — . , «» .
    , , (). — , . , , . , , . , , .
  3. . : . , .
  4. (shared) . , . , . «» , .
  5. Priorize o carregamento de ativos . Existem apenas três níveis de prioridade: Alto, Médio, Baixo. Dentro da mesma prioridade, os ativos são carregados na ordem da solicitação. Imagine uma situação: um jogador clica em "Para batalhar" e o carregamento do nível começa. Junto com isso, a tarefa de preparar o sprite da tela de carregamento cai na fila de download. Mas como alguns dos ativos de nível entraram na fila antes do sprite, o jogador olha para a tela preta por algum tempo.

Além disso, formulamos uma regra simples para nós mesmos: "Tudo o que pode ser feito no thread do AssetManager deve ser feito no thread do AssetManager". Por exemplo, preparando uma partição da paisagem e textura das normais com base em um mapa de altura, vinculando um programa GPU, etc.

Alguns detalhes de implementação


Antes de começarmos a entender como o sistema de carregamento de ativos funciona, precisamos nos familiarizar com duas classes amplamente usadas no mecanismo Blitz.Engine:

  • Type: informações de tempo de execução sobre algum tipo. Esse tipo é semelhante ao tipo Typeda linguagem C #, com a exceção de que não fornece acesso aos campos e métodos do tipo. Contém: nome do tipo, vários sinais como is_floating, is_pointer, is_constetc. O método Type::instance<T>retorna uma constante em um lançamento de aplicativo const Type*, o que permite que você faça verificações no formulárioif (type == Type::instance<T>())
  • Any: permite empacotar o valor de qualquer tipo móvel ou copiável. O conhecimento de que tipo é empacotado é Anyarmazenado como const Type*. Anysabe como calcular um hash de acordo com seu conteúdo e também como comparar o conteúdo para obter igualdade. Ao longo do caminho, permite fazer conversões do tipo atual para outro. Este é um tipo de repensar a classe any da biblioteca padrão ou da biblioteca de reforço.

Tudo lista de ativos sistema de carregamento é baseada em três classes: AssetManager, AssetBase, IAssetSerializer. No entanto, antes de prosseguir com a descrição dessas classes, deve-se dizer que o código externo usa um alias Asset<T>que é declarado assim:

Asset = std::shared_ptr<T>

onde T é um AssetBase ou um tipo específico de ativo. Usando shared_ptr em qualquer lugar, alcançamos o atendimento ao requisito número 1 (Gerenciamento automático de memória).

AssetManager- Esta é uma classe finita que não tem herdeiros. Essa classe define o ciclo de vida de um ativo e envia mensagens sobre alterações no estado de um ativo. Ele também AssetManagerarmazena uma árvore de dependência entre ativos e uma ligação de ativos a arquivos em disco, escuta FileWatchere implementa uma recarga de ativos. E o mais importante, ele AssetManagerlança um encadeamento separado, implementa uma fila de tarefas para preparar o ativo e encapsula toda a sincronização com outros encadeamentos de aplicativos (a solicitação de ativos pode ser feita a partir de qualquer encadeamento de aplicativos, incluindo o fluxo de download).

Ao mesmo tempo, AssetManageropera com um ativo abstratoAssetBase, delegando a tarefa de criar e carregar um ativo de um tipo específico para o herdeiro IAssetSerializer. Vou contar mais sobre como isso acontece nos artigos subsequentes.

Como parte do requisito número 4 (compartilhamento de ativos), uma das perguntas mais importantes foi "o que usar como identificador de ativos?" A solução mais simples e aparentemente óbvia seria usar o caminho para o arquivo a ser baixado. No entanto, esta decisão impõe uma série de limitações sérias:

  1. Para criar um ativo, este último deve ser representado como um arquivo em disco, o que remove a capacidade de criar ativos de tempo de execução com base em outros ativos.
  2. . , GPUProgram (defines). , .
  3. , .
  4. .

Não consideramos os parágrafos 3 e 4 como um argumento desde o início, pois não havia sequer um pensamento de que isso pudesse ser útil. No entanto, esses recursos posteriormente facilitaram bastante o desenvolvimento do editor.

Assim, decidimos usar a chave do ativo como um identificador, que no nível AssetManageré representado pelo tipo Any. O Anyherdeiro sabe interpretar IAssetSerializer. Ela mesma AssetManagerconhece apenas a relação entre o tipo de chave e o herdeiro IAssetSerializer. O código que solicita um ativo geralmente sabe que tipo de ativo ele precisa e opera com uma chave de um tipo específico. Tudo é mais ou menos assim:


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

O método hashe o operador de comparação internos PathKeysão necessários para o funcionamento das operações de classe correspondentes Any, mas não vamos nos deter sobre isso em detalhes.

Então, o que acontece no código acima: no momento da chamada, a get_asset(key)chave será copiada para um objeto temporário do tipo Any, que, por sua vez, será passado para o método get_asset. Em seguida, AssetManagerpegue o tipo de chave do argumento. No nosso caso, será:

Type::instance<MyAsset::PathKey>

Por esse tipo, ele encontrará o objeto serializador e delegará ao serializador todas as operações subseqüentes (criação e carregamento).

AssetBase- Esta é a classe base para todos os tipos de ativos no mecanismo. Essa classe armazena a chave do ativo, o estado atual do ativo (carregado, na fila etc.), bem como o texto do erro se o carregamento do ativo falhar. De fato, a estrutura interna é um pouco mais complicada, mas consideraremos isso junto com o ciclo de vida do ativo.

IAssetSerializer, como o nome indica, é a classe base da entidade que está preparando o ativo. De fato, o herdeiro dessa classe não está apenas carregando o ativo:

  • Alocação e desalocação de um objeto de ativo de um tipo específico.
  • Carregando um ativo de um tipo específico.
  • Compilar uma lista de caminhos de arquivo com base nos quais o ativo é construído. Essa lista é necessária para o mecanismo de recarregamento de ativos quando um arquivo é alterado. Surge a pergunta: por que a lista de caminhos, e não um caminho? Recursos simples, como texturas, podem realmente ser construídos com base em um único arquivo. No entanto, se observarmos o sombreador, veremos que a reinicialização ocorrerá não apenas se o texto do sombreador for alterado, mas também se o arquivo conectado ao sombreador for alterado através da diretiva de inclusão.
  • Salvando ativo em disco. É usado ativamente tanto na edição de ativos quanto na preparação de ativos para o jogo.
  • Informa os tipos de chaves que ele suporta.

E a última pergunta que quero abordar na estrutura deste artigo: por que você precisa ter vários tipos de chaves em um serializador / ativo? Vamos resolver isso por sua vez.

Um serializador - vários tipos de chaves


Vamos dar um exemplo de um ativo GPUProgram(ou seja, um sombreador). Para carregar um shader em nosso mecanismo, são necessárias as seguintes informações:

  1. O caminho para o arquivo shader.
  2. Lista de definições de pré-processador.
  3. O estágio para o qual o sombreador é montado e compilado (vértice, fragmento, computação).
  4. O nome do ponto de entrada.

Reunindo essas informações, obtemos a chave shader, usada no jogo. No entanto, durante o desenvolvimento de um jogo ou mecanismo, geralmente é necessário exibir algumas informações de depuração na tela, às vezes com um sombreador específico. E nessa situação, pode ser conveniente escrever o texto do sombreador diretamente no código. Para fazer isso, podemos obter o segundo tipo de chave, que em vez do caminho para o arquivo e a lista de definições de pré-processador conterão o texto do sombreador.

Considere outro exemplo: textura. A maneira mais fácil de criar uma textura é carregá-la do disco. Para fazer isso, precisamos do caminho para o arquivo ( PathKey). Mas também podemos gerar o conteúdo da textura por algoritmo e criar uma textura a partir de uma matriz de bytes ( MemoryKey). O terceiro tipo de chave pode ser uma chave para criar uma RenderTargettextura ( RTKey).

Dependendo do tipo de chave, vários mecanismos de rasterização de glifos podem ser usados: stb (StbFontKey), FreeType (FTFontKet) ou um gerador de fonte de campo de distância assinado autoassinado (SDFFontKey).

A animação do quadro-chave pode ser carregada ( PathKey) ou gerada pelo código ( MemoryKey).

Um ativo - vários tipos de chaves


Imagine que temos um ParticleEffectativo que descreve as regras para geração de partículas. Além disso, temos um editor conveniente para esse ativo. Ao mesmo tempo, o editor de níveis e o editor de partículas são um aplicativo com várias janelas. Isso é conveniente porque você pode abrir um nível, colocar uma fonte de partículas nele e observar o efeito no ambiente do nível, enquanto edita o próprio efeito. Se tivermos um tipo de chave, o objeto de efeito usado no mundo da edição de efeitos e no mundo dos níveis será o mesmo. Todas as alterações feitas no editor de efeitos serão visíveis imediatamente no nível. À primeira vista, isso pode parecer uma ideia interessante, mas vamos olhar para os seguintes cenários:

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

Além disso, é possível uma situação em que criamos dois tipos diferentes de ativos a partir de um arquivo em um disco usando dois tipos diferentes de chaves. Utilizando a chave do tipo "jogo", criamos uma estrutura de dados otimizada para um trabalho rápido no jogo. Usando o tipo de chave "editorial", criamos uma estrutura de dados que é conveniente para edição. Dessa maneira, nosso editor implementa a edição BlendTreede animações esqueléticas. Com base em um tipo de chave, o sistema de ativos nos cria um ativo com uma árvore honesta e vários sinais sobre a alteração da topologia, o que é muito conveniente na edição, mas bastante lento no jogo. De acordo com um tipo diferente de chave, o serializador cria outro tipo de ativo: o ativo não possui métodos para alterar a árvore e a própria árvore é transformada em uma matriz de nós, em que o link para o nó é um índice na matriz.

Epílogo


Resumindo, gostaria de concentrar sua atenção nas soluções que mais influenciaram o desenvolvimento do mecanismo:

  1. Usando uma estrutura customizada como uma chave de ativo, não um caminho de arquivo.
  2. Carregamento de ativos apenas no modo assíncrono.
  3. Um esquema flexível para gerenciar o compartilhamento de ativos (um ativo - vários tipos de chaves).
  4. A capacidade de receber ativos do mesmo tipo usando fontes de dados diferentes (suporte para vários tipos de chaves em um serializador).

Você aprenderá como exatamente essas decisões influenciaram a implementação do código interno e do externo na próxima série.

Autor:Exmix

All Articles