Agrupando entidades do mesmo componente definido na memória linear

11

Começamos com a abordagem básica de sistemas-componentes-entidades .

Vamos criar assemblages (termo derivado deste artigo) apenas com informações sobre os tipos de componentes . Isso é feito dinamicamente no tempo de execução, assim como adicionaríamos ou removeríamos componentes de uma entidade, um por um, mas vamos dar um nome mais preciso, pois trata-se apenas de informações de tipo.

Em seguida, construímos entidades especificando assemblage para cada uma delas. Depois de criar a entidade, seu conjunto é imutável, o que significa que não podemos modificá-lo diretamente no local, mas ainda podemos obter a assinatura da entidade existente para uma cópia local (junto com o conteúdo), fazer as devidas alterações nela e criar uma nova entidade. disso.

Agora, o conceito-chave: sempre que uma entidade é criada, ela é atribuída a um objeto chamado bucket de assemblage , o que significa que todas as entidades da mesma assinatura estarão no mesmo contêiner (por exemplo, em std :: vector).

Agora, os sistemas interagem com todos os grupos de interesse e realizam seu trabalho.

Essa abordagem tem algumas vantagens:

  • os componentes são armazenados em alguns (precisamente: número de buckets) blocos de memória contíguos - isso melhora a facilidade de memória e é mais fácil despejar todo o estado do jogo
  • sistemas processam componentes de maneira linear, o que significa melhor coerência do cache - adeus dicionários e saltos aleatórios de memória
  • criar uma nova entidade é tão fácil quanto mapear um conjunto para o balde e empurrar os componentes necessários para seu vetor
  • excluir uma entidade é tão fácil quanto uma chamada para std :: move para trocar o último elemento pelo excluído, porque a ordem não importa no momento

insira a descrição da imagem aqui

Se tivermos muitas entidades com assinaturas completamente diferentes, os benefícios da coerência do cache diminuem, mas acho que isso não aconteceria na maioria dos aplicativos.

Também existe um problema com a invalidação do ponteiro depois que os vetores são realocados - isso pode ser resolvido com a introdução de uma estrutura como:

struct assemblage_bucket {
    struct entity_watcher {
        assemblage_bucket* owner;
        entity_id real_index_in_vector;
    };

    std::unordered_map<entity_id, std::vector<entity_watcher*>> subscribers;

    //...
};

Portanto, sempre que, por algum motivo em nossa lógica do jogo, desejamos acompanhar uma entidade recém-criada, dentro do bucket, registramos um entity_watcher e, uma vez que a entidade precisa ser std :: move'd durante a remoção, procuramos seus observadores e atualizamos seus real_index_in_vectorpara novos valores. Na maioria das vezes, isso impõe apenas uma pesquisa de dicionário para cada exclusão de entidade.

Existem mais desvantagens nessa abordagem?

Por que a solução não foi mencionada em nenhum lugar, apesar de ser bastante óbvia?

EDIT : Estou editando a pergunta para "responder as respostas", pois os comentários são insuficientes.

você perde a natureza dinâmica dos componentes conectáveis, que foram criados especificamente para evitar a construção de classe estática.

Eu não. Talvez eu não tenha explicado com clareza suficiente:

auto signature = world.get_signature(entity_id); // this would just return entity_id.bucket_owner->bucket_signature or so
signature.add(foo_component);
signature.remove(bar_component);
world.delete_entity(entity_id); // entity_id would hold information about its bucket owner
world.create_entity(signature); // automatically assigns new entity to an existing or a new bucket

É tão simples quanto pegar a assinatura da entidade existente, modificá-la e fazer o upload novamente como uma nova entidade. Natureza dinâmica e conectável ? Claro. Aqui, gostaria de enfatizar que existe apenas uma classe "assemblage" e uma "bucket". As caçambas são orientadas por dados e criadas em tempo de execução em uma quantidade ideal.

você precisaria passar por todos os buckets que possam conter um destino válido. Sem uma estrutura de dados externa, a detecção de colisões pode ser igualmente difícil.

Bem, é por isso que temos as estruturas de dados externas acima mencionadas . A solução alternativa é tão simples quanto introduzir um iterador na classe System que detecta quando saltar para o próximo bucket. O salto seria puramente transparente para a lógica.

Patryk Czachurski
fonte
Também li o artigo da Randy Gaul sobre como armazenar todos os componentes em vetores e permitir que seus sistemas os processem. Vejo dois grandes problemas lá: e se eu quiser atualizar apenas um subconjunto de entidades (pense em abate, por exemplo). Por esse motivo, os componentes serão acoplados novamente às entidades. Para cada etapa da iteração do componente, eu precisaria verificar se a entidade à qual ele pertence foi selecionada para uma atualização. O outro problema é que alguns sistemas precisam processar vários tipos de componentes diferentes, retirando novamente a vantagem da coerência do cache. Alguma idéia de como lidar com esses problemas?
tiguchi

Respostas:

7

Você projetou essencialmente um sistema de objetos estáticos com um alocador de pool e com classes dinâmicas.

Eu escrevi um sistema de objetos que funciona quase de forma idêntica ao seu sistema de "assemblages" nos meus tempos de escola, embora eu sempre costumo chamar "assemblages" de "projetos" ou "arquétipos" em meus próprios projetos. A arquitetura era mais problemática do que os sistemas de objetos ingênuos e não apresentava vantagens mensuráveis ​​de desempenho em relação a alguns dos projetos mais flexíveis com os quais eu a comparava. A capacidade de modificar dinamicamente um objeto sem a necessidade de reificá-lo ou realocá-lo é extremamente importante quando você estiver trabalhando em um editor de jogos. Os designers desejam arrastar e soltar componentes nas definições de seu objeto. O código de tempo de execução pode até precisar modificar componentes eficientemente em alguns designs, embora eu pessoalmente não goste. Dependendo de como você vincula as referências de objeto no seu editor,

Você terá uma coerência pior do cache do que pensa na maioria dos casos não triviais. Seu sistema de IA, por exemplo, não se preocupa com os Rendercomponentes, mas acaba ficando preso repetindo-os como parte de cada entidade. Os objetos que estão sendo iterados são maiores e as solicitações de cacheline acabam atraindo dados desnecessários e menos objetos inteiros são retornados a cada solicitação). Ainda será melhor que o método ingênuo, e a composição de objetos do método ingênuo é usada mesmo em grandes mecanismos AAA, então você provavelmente não precisa de melhor, mas pelo menos não pense que não pode melhorar ainda mais.

Sua abordagem faz mais sentido para algunscomponentes, mas não todos. Eu não gosto muito do ECS, porque defende sempre colocar cada componente em um contêiner separado, o que faz sentido para física ou gráficos ou outros enfeites, mas não faz sentido algum se você permitir vários componentes de script ou IA de composição. Se você permitir que o sistema de componentes seja usado para mais do que apenas objetos internos, mas também como uma maneira de designers e programadores de jogo comporem o comportamento dos objetos, pode fazer sentido agrupar todos os componentes de IA (que geralmente interagem) ou todos os scripts componentes (desde que você deseja atualizá-los todos em um lote). Se você deseja o sistema com melhor desempenho, precisará de uma combinação de esquemas de alocação e armazenamento de componentes e dedicar tempo para descobrir de maneira conclusiva qual é o melhor para cada tipo específico de componente.

Sean Middleditch
fonte
Eu disse: não podemos alterar a assinatura da entidade e quis dizer que não podemos modificá-la diretamente no local, mas ainda assim podemos apenas obter o conjunto existente em uma cópia local, fazer alterações e fazer o upload novamente como uma nova entidade - e estes as operações são bem baratas, como mostrei na pergunta. Mais uma vez - há apenas uma classe "bucket". "Assemblages" / "Assinaturas" / "vamos chamá-lo como quisermos" podem ser criados dinamicamente em tempo de execução como em uma abordagem padrão; eu chegaria a pensar em uma entidade como uma "assinatura".
Patryk Czachurski
E eu disse que você não quer necessariamente lidar com a reificação. "Criar uma nova entidade" pode significar quebrar todos os identificadores existentes na entidade, dependendo de como o sistema de identificadores funciona. Sua ligação é barata o suficiente ou não. Eu achei que era apenas uma dor de bunda para ter que lidar.
61313 Sean Middleditch
Ok, agora eu entendi seu ponto de vista. De qualquer forma, acho que, mesmo que a adição / remoção fosse um pouco mais cara, acontece tão ocasionalmente que ainda vale a pena simplificar bastante o processo de acesso aos componentes, o que acontece em tempo real. Portanto, a sobrecarga de "mudar" é insignificante. Sobre o seu exemplo de IA, ainda não vale a pena esses poucos sistemas que precisam de dados de vários componentes?
Patryk Czachurski
Meu argumento foi que a IA era um lugar onde sua abordagem é melhor, mas para outros componentes não é necessariamente assim.
Sean Middleditch
4

O que você fez foi reprojetar objetos C ++. A razão pela qual isso parece óbvio é que, se você substituir a palavra "entidade" por "classe" e "componente" por "membro", esse é um projeto OOP padrão usando mixins.

1) você perde a natureza dinâmica dos componentes conectáveis, criados especificamente para evitar a construção de classe estática.

2) a coerência da memória é mais importante dentro de um tipo de dados, não dentro de um objeto que unifica vários tipos de dados em um único local. Esse é um dos motivos pelos quais os componentes + foram criados, para evitar a fragmentação da memória da classe + do objeto.

3) esse design também reverte para o estilo de classe C ++ porque você está pensando na entidade como um objeto coerente quando, em um design de componente + sistema, a entidade é apenas uma tag / ID para tornar o funcionamento interno compreensível para os seres humanos.

4) é tão fácil para um componente serializar a si mesmo quanto um objeto complexo serializar vários componentes dentro de si, se não for realmente mais fácil acompanhar como programador.

5) o próximo passo lógico nesse caminho é remover Systems e colocar esse código diretamente na entidade, onde ele possui todos os dados necessários para trabalhar. Todos nós podemos ver o que isso significa =)

Patrick Hughes
fonte
2) talvez eu não entenda completamente o cache, mas digamos que exista um sistema que funcione com, digamos, 10 componentes. Em uma abordagem padrão, processar cada entidade significa acessar a RAM 10 vezes, porque os componentes estão espalhados em lugares aleatórios na memória, mesmo que sejam usados ​​conjuntos - porque componentes diferentes pertencem a conjuntos diferentes. Não seria "importante" armazenar em cache a entidade inteira de uma só vez e processar todos os componentes sem uma única falha de cache, sem precisar fazer pesquisas no dicionário? Além disso, eu fiz uma edição para cobrir a) do ponto 1
Patryk Czachurski
@Sean Middleditch tem uma boa descrição dessa quebra de cache em sua resposta.
Patrick Hughes
3) Eles não são objetos coerentes de forma alguma. Sobre o componente A estar próximo ao componente B na memória, é apenas "coerência da memória", não "coerência lógica", como apontou John. As caçambas, em sua criação, poderiam até embaralhar os componentes em assinatura para qualquer ordem e princípios desejados ainda seriam mantidos. 4) pode ser igualmente fácil "acompanhar" se tivermos abstração suficiente - o que estamos falando é apenas um esquema de armazenamento fornecido com iteradores e talvez um mapa de deslocamento de byte pode tornar o processamento tão fácil quanto em uma abordagem padrão.
Patryk Czachurski 07/07
5) E não acho que nada nessa idéia aponte para essa direção. Não é que eu não queira concordar com você, apenas estou curioso para onde essa discussão pode levar, embora de qualquer maneira provavelmente leve a um tipo de "medir" ou a conhecida "otimização prematura". :)
Patryk Czachurski 07/07
@PatrykCzachurski, mas seus sistemas não funcionam com 10 componentes.
user253751
3

Manter entidades unidas não é tão importante quanto você imagina, e é por isso que é difícil pensar em um motivo válido que não seja "porque é uma unidade". Mas como você está realmente fazendo isso por coerência de cache, em oposição à coerência lógica, pode fazer sentido.

Uma dificuldade que você pode ter é a interação entre componentes em diferentes buckets. Não é muito fácil encontrar algo em que sua IA possa disparar, por exemplo, você precisaria passar por todos os baldes que possam conter um alvo válido. Sem uma estrutura de dados externa, a detecção de colisões pode ser igualmente difícil.

Para continuar organizando entidades em conjunto por coerência lógica, a única razão pela qual eu poderia ter para manter entidades unidas é para fins de identificação em minhas missões. Eu preciso saber se você acabou de criar o tipo de entidade A ou tipo B, e eu posso contornar isso ... adivinhou: adicionando um novo componente que identifica o conjunto que une essa entidade. Mesmo assim, não estou reunindo todos os componentes para uma grande tarefa, só preciso saber o que é. Portanto, não acho que essa parte seja terrivelmente útil.

John McDonald
fonte
Tenho que admitir que não entendo bem sua resposta. O que você quer dizer com "coerência lógica"? Sobre dificuldades nas interações, eu fiz uma edição.
Patryk Czachurski
"Coerência lógica", como em: faz "sentido lógico" manter todos os componentes que compõem uma entidade da Árvore juntos.
9788 John McDonald