← Blog

Assets de Tags e Camadas

Assets de Tags e Camadas

2026-10-05

Tags e camadas são recursos pequenos com um alcance surpreendentemente grande. Elas decidem quais objetos colidem, quais objetos uma câmera vê e quais objetos o código de gameplay consegue encontrar. Seus dados acabam em cenas, prefabs, controles do editor, arquivos de save e até mensagens de rede.

Eu já havia implementado uma versão convencional no Turian: camadas de física, camadas de renderização, tags e as APIs de filtragem e consulta ao redor delas. Tabelas fixas tornam isso simples. Uma camada vira um índice pequeno, uma máscara vira alguns bits, e o runtime consegue responder às suas perguntas com baixo custo.

Então, ao revisar a implementação, comecei a pensar em como essas tabelas se comportariam conforme a engine crescesse. Física e renderização são apenas dois consumidores. Áudio, rede, sistemas de save e módulos de gameplay poderiam se beneficiar de classificações semelhantes. Um módulo distribuído separadamente também precisa de identidades que sobrevivam à instalação num projeto cujas tabelas já estão preenchidas.

O Turian já tem um sistema de DataAssets para dados compartilhados e editáveis. Isso trouxe uma pergunta: as próprias tags e camadas poderiam ser assets, mantendo uma representação em runtime pequena o bastante para milhares de nós?

Este artigo descreve a arquitetura que escolhi e as decisões por trás dela. A base de estado persistente está implementada e testada; a integração completa com nós, consumidores e fluxos do editor ainda está em andamento.

O que o projeto precisa preservar

Quero atender a três requisitos ao mesmo tempo:

  1. Identidade estável. Renomear uma camada, mudar sua apresentação ou mover um asset deve preservar as referências.
  2. Composição. Um projeto deve conseguir combinar declarações do próprio código, assets criados por designers e módulos desenvolvidos de forma independente.
  3. Runtime leve. Culling e física leem camadas com frequência. Essas leituras devem usar dados compactos, e adicionar um módulo não deve alocar uma tabela grande em cada nó.

O sistema de assets me dá um lugar para identidade e apresentação. O runtime ainda precisa de uma representação escolhida para seus padrões de acesso.

Criação de grupos, valores e tags

O modelo de criação tem quatro peças principais:

Asset O que contém
LayerValueAsset GUID, nome de exibição, cor e descrição de um valor
LayerGroupAsset Um manifesto ordenado de referências a valores e um padrão explícito
TagAsset GUID e metadados de apresentação de uma tag
NodeLayerSettings Grupos ordenados do projeto, tags disponíveis e vínculos dos grupos de física/renderização

Um grupo descreve um espaço de classificação. Por exemplo, um projeto pode definir um grupo de física com Default, Water e Actors, um grupo de renderização com Default, World e Interface, e um grupo de áudio com seus próprios valores.

Um nó seleciona um valor em cada grupo. A máscara de um consumidor seleciona um subconjunto dos valores desse grupo. Tags são associações independentes: um nó pode carregar várias tags sem escolher um único valor de um grupo.

Nomes são metadados de apresentação. Eles podem mudar, e assets independentes podem compartilhar um nome de exibição. Referências identificam assets por GUID. Cada grupo possui seu manifesto de valores, então módulos podem editar seus próprios grupos sem colocar toda declaração numa grande tabela compartilhada.

Os vínculos de física e renderização também são referências explícitas. Um designer pode renomear qualquer um desses grupos sem mudar qual grupo o consumidor usa.

Três representações para três tarefas

A decisão central é dar à criação, à configuração da sessão e às consultas em runtime suas próprias representações:

Etapa Representação Tarefa
Criação e persistência GUIDs de assets e pares de GUIDs de grupo/valor Preservar identidade e intenção de edição
Configuração da sessão Um layout imutável de GUIDs para slots/índices Validar o manifesto e vincular identidades
Runtime Associações em bytes, IDs de tag em ushort e máscaras de bits Responder a consultas frequentes com baixo custo
flowchart LR
    subgraph A["Criação e persistência"]
        direction TB
        A1["Assets e chaves tipadas em código"]
        A2["Identidade GUID e apresentação"]
    end
    subgraph B["Configuração da sessão"]
        direction TB
        B1["LayerInterningService"]
        B2["Validar, internar, gerar impressão digital"]
    end
    subgraph C["Consultas em runtime"]
        direction TB
        C1["NodeLayers — 24 B por nó"]
        C2["IDs de tag ushort e máscaras de 256 bits"]
    end
    A -- "vincular no início da sessão" --> B
    B -- "layout compacto e imutável" --> C

Na configuração da sessão, LayerInterningService valida e tira um snapshot das configurações resolvidas. Grupos recebem slots inteiros. O padrão de cada grupo recebe o índice de valor zero, seguido pelos demais valores na ordem do manifesto. Tags recebem IDs compactos em ordem crescente de GUID.

Com essas tabelas prontas, ler uma associação em runtime exige apenas um slot e um byte. Nome de exibição, cor, descrição e objeto do asset permanecem como dados de criação compartilhados.

Isso segue o mesmo princípio de identidade estável que motivou o Open Asset Package: referências devem sobreviver a mudanças na forma como os dados são nomeados ou organizados. Aqui, a etapa extra é traduzir essas identidades num layout compacto para uma sessão ativa.

Registro em código sem chaves de string

A criação de assets também precisa funcionar com módulos escritos em C#. A API de registro aceita tipos com atributos TypeId explícitos:

using MASS4.Attributes;
using Turian.Engine.Core;

[TypeId("ea3c1636-272b-453b-8a73-2187d01581a5")]
public sealed record Physics : ILayerGroupKey;

[TypeId("89414f36-2137-4d82-b7ea-cced74c4864e")]
public sealed record Water : ILayerValueKey<Physics>;

[TypeId("812a8d5a-ad5e-4f26-ae8a-6d5e6d120d91")]
public sealed record Player : ITagKey;

var settings = new LayerRegistrationBuilder()
    .Group<Physics>(group => group.Default().Value<Water>())
    .Tag<Player>()
    .Build();

settings.PhysicsGroup = settings.Groups[0];

Os GUIDs deste exemplo identificam as chaves. Manter o mesmo TypeId preserva a identidade ao renomear um tipo ou namespace. Nenhuma instância dos marcadores é criada. Water declara seu grupo por meio de ILayerValueKey<Physics>, permitindo que o compilador rejeite seu registro em outro grupo.

A API atual de marcadores usa classes e record classes. Chaves readonly record struct continuam sendo uma limitação conhecida, porque o atributo TypeId compartilhado se aplica a classes.

Um asset de designer pode usar o mesmo GUID de asset que o TypeId de uma chave em código. Isso enriquece intencionalmente a identidade do código: um asset de valor ou tag correspondente fornece dados de apresentação, e um asset de grupo correspondente fornece seu manifesto completo e seu padrão. O TypeId da própria classe DataAsset tem outra tarefa: identificar o tipo do conteúdo serializado.

16 slots inline e excedente esparso

Uma versão inicial propunha um limite rígido de 16 grupos. A vantagem era óbvia: um byte por grupo, 16 bytes de dados de associação. O problema apareceu assim que considerei os módulos. Um sistema que usa apenas alguns valores ainda consumiria um desses 16 slots do projeto inteiro.

O cache de associações implementado mantém os primeiros 16 grupos inline e armazena atribuições adicionais que não sejam o padrão num array esparso ordenado.

Num runtime de 64 bits, NodeLayers ocupa 24 bytes: 16 bytes de associação inline e uma referência de oito bytes para um array. Essa referência é nula quando não há atribuições excedentes. Registrar mais grupos, por si só, não adiciona alocações por nó. Um grupo excedente sem atribuição resolve para seu índice padrão zero.

Cada entrada excedente ocupa oito bytes, incluindo padding, além do cabeçalho do array e do custo da alocação. Leituras excedentes usam busca binária. Edições copiam o pequeno array, preservando cópias de valor independentes da struct; atribuições que não mudam nada não alocam.

Isso torna a ordem dos grupos uma decisão de desempenho. Física e renderização devem ficar perto do início, porque suas associações são lidas em muitos nós a cada frame. A construção do layout avisa se algum desses consumidores explicitamente vinculados ficar fora dos primeiros 16 slots. Se o profiling identificar um grupo excedente muito acessado, seu consumidor deve mover as associações para uma coluna pertencente à cena, indexada pelo handle do nó.

Os 24 bytes descrevem o cache de associações. Tags, máscaras, estado GUID persistente e o restante do nó têm seus próprios custos de armazenamento. Separar identidade de execução não torna o estado persistente gratuito; permite manter os metadados de apresentação compartilhados e escolher o armazenamento em runtime deliberadamente.

Um byte seleciona um valor; uma máscara seleciona um subconjunto

Cada grupo suporta 256 valores. O byte de um nó é um índice nesses valores. A máscara de um consumidor tem um bit para cada valor, armazenado em quatro palavras ulong inline.

Essa distinção importa. Oito flags independentes têm 256 combinações possíveis. Já o byte de associação seleciona um entre 256 valores, enquanto a máscara de 256 bits do consumidor pode descrever qualquer subconjunto deles. Múltiplas associações dentro do mesmo grupo exigiriam outra representação de nó e outra API.

Tags usam todo o espaço ushort de 65.536 IDs. Zero é um ID válido. O sucesso da busca é retornado separadamente, então nenhum ID precisa ser reservado como sentinela de valor ausente. Um módulo que declara 8.192 tags deixa 57.344 IDs para outros módulos e o conteúdo do projeto.

Essas continuam sendo escolhas explícitas de capacidade. Manifestos inválidos são rejeitados antes que um layout utilizável seja construído. A quantidade de grupos não tem um limite fixo de 16 slots por projeto, embora suas tabelas compartilhadas e quaisquer entradas excedentes atribuídas ainda consumam memória.

Índices de runtime têm prazo de validade

Suponha que os valores não padrão de um grupo sejam Water e Actors. Inserir outro valor antes deles pode deslocar seus índices em bytes na próxima sessão. Seus GUIDs permanecem iguais.

Isso nos dá uma regra estrita de persistência: índices de runtime pertencem à sessão e nunca são persistentes. Cenas, prefabs, saves de gameplay e máscaras criadas armazenam identidades. Elas resolvem essas identidades novamente ao serem carregadas num novo layout.

NodeLayerState armazena pares explícitos de GUIDs de grupo/valor e GUIDs de tags. LayerMaskState armazena um GUID de grupo e os GUIDs dos valores selecionados. O vínculo produz NodeLayers, IDs compactos de tags e CompiledLayerMask. A serialização JSON dos caches de associações e máscaras de runtime é rejeitada, com orientação para salvar os objetos de estado GUID.

O ciclo de salvar e carregar o gameplay já está testado, incluindo a inserção de um valor entre o salvamento e o carregamento. A persistência automática de Node em cenas/prefabs e a clonagem ainda precisam se conectar a essa base.

Padrões preservam a intenção

Há uma diferença sutil entre seguir o padrão de um grupo e escolher explicitamente o valor que por acaso é seu padrão hoje.

Imagine que o padrão seja X. Um nó não tem atribuição; outro seleciona X explicitamente. Depois, o designer muda o padrão para Y. O nó sem atribuição deve virar Y. O nó com atribuição explícita deve continuar X.

Os dois nós inicialmente resolvem para o índice de runtime zero, então esse byte não consegue preservar a distinção. O estado persistente precisa manter o par explícito de GUIDs. Uma ação separada de seguir o padrão ou redefinir limpa a referência. Esse comportamento é coberto por um teste de regressão e deve permanecer visível no fluxo do Inspector.

Conteúdo excluído traz uma preocupação semelhante de edição. Um nó que referencia um valor ausente recorre ao padrão do grupo conhecido, mantendo o GUID órfão. Uma máscara mantém o GUID de um valor ausente, mas não acrescenta nenhum bit para ele. Tags ausentes mantêm suas identidades sem acrescentar associações em runtime. Cada identidade ausente gera um aviso por snapshot. Se o conteúdo voltar, essas referências podem ser resolvidas novamente.

Determinismo exige entradas explícitas

As listas ordenadas do asset de configurações e as chamadas explícitas ao builder são os pontos de entrada do registro. A descoberta de grupos nunca depende da ordem de percurso do sistema de arquivos, de enumeração do banco de assets ou de descoberta de assemblies. A inicialização de módulos também deve usar uma sequência explícita de registro.

O registro explícito também é uma restrição do NativeAOT, não apenas uma preferência de determinismo. O Turian é distribuída como binários compilados antecipadamente, e a alternativa usual — procurar atributos de marcadores nos assemblies carregados durante a inicialização — é exatamente o padrão que quebra com trimming: tipos desaparecem do binário a menos que anotações do linker os preservem, e essas anotações se multiplicam a cada módulo. A API do builder leva a descoberta para o tempo de compilação. O runtime vê a sequência explícita de registro, e os GUIDs TypeId são literais de string comuns que o compilador preserva sem ajuda.

O layout da sessão também carrega uma impressão digital SHA-256 versionada. Ela abrange as identidades ordenadas de grupos, valores e tags com seus slots e índices de runtime, além dos vínculos de física e renderização. Metadados de apresentação não a afetam. O protocolo de bytes é documentado e testado contra um conjunto de dados de referência gerado independentemente da implementação C#.

Essa impressão digital permite que um peer ou replay baseado em índices determine se seus dados compactos têm o mesmo significado. Uma divergência recusa a compatibilidade. Saves comuns baseados em GUID podem se vincular a um layout alterado; trocas de índices compactos exigem acordo sobre o layout e o conteúdo.

Mudanças no editor devem respeitar o mesmo limite. O fluxo planejado mostra as impressões digitais ativa e proposta, identifica peers conectados que precisam de revalidação e oferece reiniciar/refazer vínculos ou adiar. Adiar mantém o snapshot da sessão existente e deixa um aviso visível de mudança pendente. Uma sessão ativa nunca deve reinterpretar bytes existentes por meio de tabelas substitutas.

A capacidade da CPU não determina a largura na GPU

O renderer atualmente usa dados de camada de 32 bits no caminho de iluminação. Inicialmente, tratei máscaras de 256 bits como algo que exigiria uma conversão coordenada de CPU, persistência, uniforms, shaders e SPIR-V.

Uma análise mais próxima esclareceu que a GPU precisa da máscara do grupo de renderização. Outros grupos podem ter suas próprias larguras sem mudar os uniforms de renderização.

CompiledLayerMask.TryGetLegacyBits() permite o empacotamento atual quando seu grupo declara no máximo 32 valores. Ele rejeita grupos maiores mesmo quando uma seleção específica usa apenas os bits inferiores: o renderer ainda precisa interpretar toda associação válida nesse grupo.

Isso permite avançar na integração de CPU e persistência enquanto o grupo de renderização cabe no caminho atual da GPU. Grupos de renderização maiores exigem rejeição explícita no vínculo com o renderer até que o trabalho de uniforms e shaders esteja pronto. Truncar silenciosamente faria a filtragem depender de qual lado do limite CPU/GPU executou a verificação.

O que o código de gameplay deve manter

Mudar o armazenamento de tags deve preservar o comportamento de consulta que os programadores já usam. Consultas selecionam nós ativos, respeitam a atividade dos ancestrais e excluem nós destruídos. Uma consulta de resultado único retorna null quando não encontra nada; uma consulta de todos os resultados retorna uma lista vazia.

Dentro de uma cena, os resultados seguem a ordem de registro dos nós. Consultas do gerenciador de cenas visitam as cenas carregadas na ordem de carregamento e depois os nós persistentes. Mudanças de tags e de ativação preservam a posição de um registro, e associações duplicadas de tags não duplicam resultados.

A API pública de consulta planejada aceita GUIDs e chaves de tag tipadas, resolvendo-os para IDs ushort dentro do registro da sessão. A mudança de armazenamento deve preservar os hooks existentes para edições de tags, mudanças de pai, destruição e descarregamento de cenas. O Play Mode deve criar registros independentes para sua cópia da cena.

Onde a implementação está

Os assets de criação, o registro tipado, o enriquecimento de identidades, os layouts imutáveis, o cache compacto de associações, os objetos de estado GUID, o tratamento de órfãos, as máscaras, a proteção por impressão digital e os testes de validação estão implementados.

O trabalho restante conecta essas peças à inicialização da sessão, à serialização e clonagem de Node, ao armazenamento do registro de tags e às consultas públicas, aos consumidores de física/renderização, aos controles do editor, aos templates e ao carregamento de peers/replays. O caminho mais largo da GPU é necessário quando o grupo de renderização ultrapassa 32 valores.

As contrapartidas são concretas: a configuração faz validação e resolução de identidades; leituras excedentes custam mais que leituras inline; edições excedentes alocam; IDs compactos têm limites de capacidade; e uma mudança de layout precisa de um limite explícito.

O que gosto nesta arquitetura é a clareza com que ela atribui responsabilidades. Assets preservam identidade e intenção do designer. A configuração da sessão valida e vincula esses dados. Consumidores em runtime usam associações e máscaras compactas. Isso me dá uma forma de expandir tags e camadas para mais sistemas mantendo pequenos os dados acessados com frequência — e de tornar explícitas as falhas de compatibilidade quando os layouts mudam.

#architecture#engine#csharp#gamedev