Implementação mais simples do sistema de componentes de entidade

Olá pessoal!

O quarto fluxo “C ++ Developer” começa aqui, um dos cursos mais ativos em nosso país, a julgar pelas reuniões reais, onde não apenas os “cruzados” vêm conversar com Dima Shebordaev :) Em geral, o curso já cresceu para uma das maiores do país, permanece inalterado o fato de a Dima ministrar aulas abertas e selecionarmos materiais interessantes antes do início do curso.

Vamos lá!

Entrada


O Sistema de Componentes de Entidades (ECS, "sistema de componentes de entidades") está agora no auge da popularidade como uma alternativa arquitetônica que enfatiza o princípio da Composição sobre a herança. Neste artigo, não abordarei os detalhes do conceito, pois já existem recursos suficientes sobre esse tópico. Existem várias maneiras de implementar o ECS e, mas, na maioria das vezes, escolho formas bastante complexas que podem confundir iniciantes e levar muito tempo.

Neste post, descreverei uma maneira muito simples de implementar o ECS, cuja versão funcional requer quase nenhum código, mas segue completamente o conceito.



ECS


Falando em ECS, as pessoas geralmente significam coisas diferentes. Quando falo sobre ECS, quero dizer um sistema que permite definir entidades que possuem zero ou mais componentes de dados puros. Esses componentes são processados ​​seletivamente por sistemas lógicos puros. Por exemplo, a posição, velocidade, hitbox e integridade de um componente estão vinculados à entidade E. Eles simplesmente armazenam dados em si mesmos. Por exemplo, um componente de integridade pode armazenar dois números inteiros: um para a integridade atual e outro para o máximo. Um sistema pode ser um sistema de regeneração de integridade que localiza todas as instâncias de um componente de integridade e as aumenta em 1 a cada 120 quadros.

Implementação típica de C ++


Existem muitas bibliotecas que oferecem implementações de ECS. Geralmente, eles incluem um ou mais itens da lista:

  • Herança do componente / sistema base da classe GravitySystem : public ecs::System ;
  • Uso ativo de modelos;
  • Tanto isso quanto outro em algum aspecto do CRTP ;
  • A classe EntityManager , que controla a criação / armazenamento de entidades de maneira implícita.

Alguns exemplos rápidos do Google:


Todos esses métodos têm direito à vida, mas existem algumas desvantagens neles. A maneira como eles processam os dados de maneira opaca significa que será difícil entender o que está acontecendo lá dentro e se a desaceleração do desempenho ocorreu. Isso também significa que você deve estudar toda a camada de abstração e garantir que ela se encaixe bem no código existente. Não se esqueça dos bugs ocultos, que provavelmente estão muito ocultos na quantidade de código que você precisa depurar.

Uma abordagem baseada em modelo pode afetar bastante o tempo de compilação e com que frequência você precisará reconstruir a compilação. Embora os conceitos baseados em herança possam prejudicar o desempenho.

A principal razão pela qual considero excessivas essas abordagens é que o problema que elas resolvem é muito simples. No final, esses são apenas componentes de dados adicionais associados à entidade e seu processamento seletivo. Abaixo, mostrarei uma maneira muito simples de como isso pode ser implementado.

Minha abordagem simples


Essence

Em algumas abordagens, a classe Entity é definida; em outras, elas trabalham com entidades como ID / identificador. Em uma abordagem de componente, uma entidade nada mais é do que os componentes associados a ela e, para isso, uma classe não é necessária. Uma entidade existirá explicitamente com base em seus componentes relacionados. Para fazer isso, defina:

 using EntityID = int64_t; //    , int64_t -   

Componentes da entidade

Componentes são diferentes tipos de dados associados a entidades existentes. Podemos dizer que para cada entidade e, e terá zero e mais tipos de componentes acessíveis. Em essência, essa é uma relação de valor-chave explodida e, felizmente, existem ferramentas de biblioteca padrão na forma de cartões para isso.

Então, eu defino os componentes da seguinte maneira:

 struct Position { float x; float y; }; struct Velocity { float x; float y; }; struct Health { int max; int current; }; template <typename Type> using ComponentMap = std::unordered_map<EntityID, Type>; using Positions = ComponentMap<Position>; using Velocities = ComponentMap<Velocity>; using Healths = ComponentMap<Health>; struct Components { Positions positions; Velocities velocities; Healths healths; }; 

Isso é suficiente para indicar entidades através de componentes, conforme o esperado do ECS. Por exemplo, para criar uma entidade com uma posição e integridade, mas sem velocidade, você precisa:

 //given a Components instance c EntityID newID = /*obtain new entity ID*/; c.positions[newID] = Position{0.0f, 0.0f}; c.healths[newID] = Health{100, 100}; 

Para destruir uma entidade com um determinado ID, simplesmente as .erase() de cada cartão.

Sistemas

O último componente que precisamos é de sistemas. Essa é a lógica que trabalha com os componentes para obter um comportamento específico. Como gosto de simplificar as coisas, uso funções normais. O sistema de regeneração de saúde mencionado acima pode ser simplesmente a próxima função.

 void updateHealthRegeneration(int64_t currentFrame, Healths& healths) { if(currentFrame % 120 == 0) { for(auto& [id, health] : healths) { if(health.current < health.max) ++health.current; } } } 

Podemos colocar a chamada para essa função em um local apropriado no loop principal e transferi-la para o armazenamento do componente de integridade. Como o repositório de integridade contém apenas registros para entidades que têm integridade, ele pode processá-los isoladamente. Isso também significa que a função pega apenas os dados necessários e não toca no irrelevante.

Mas e se o sistema funcionar com mais de um componente? Diga um sistema físico que mude de posição com base na velocidade. Para fazer isso, precisamos cruzar todas as chaves de todos os tipos de componentes envolvidos e iterar sobre seus valores. Nesse ponto, a biblioteca padrão não é mais suficiente, mas escrever auxiliares não é tão difícil. Por exemplo:

 void updatePhysics(Positions& positions, const Velocities& velocities) { //   ,   N   //   ID,    . std::unordered_set<EntityID> targets = mapIntersection(positions, velocities); // target'     ,   //  ,       . for(EntityID id : targets) { Position& pos = positions.at(id); const Velocity& vel = velocities.at(id); pos.x += vel.x; pos.y += vel.y; } } 

Ou você pode escrever um auxiliar mais compacto que permita acesso mais eficiente via iteração, em vez de pesquisar.

 void updatePhysics(Positions& positions, const Velocities& velocities) { //   ,    //  .        //    . intersectionInvoke<Position, Velocity>(positions, velocities, [] (EntityID id, Position& pos, const Velocity& vel) { pos.x += vel.x; pos.y += vel.y; } ); } 

Assim, nos familiarizamos com a funcionalidade básica de um ECS regular.

Os benefícios


Essa abordagem é muito eficaz, pois é criada do zero, sem restringir a abstração. Você não precisa integrar bibliotecas externas ou adaptar a base de código às idéias predefinidas de quais entidades / componentes / sistemas devem ser.
E como essa abordagem é completamente transparente, você pode criar utilitários e auxiliares. Essa implementação cresce com as necessidades do seu projeto. Provavelmente, para protótipos simples ou jogos para jogos jam'ov, você terá o suficiente da funcionalidade descrita acima.

Portanto, se você é novo em todo esse campo da ECS, uma abordagem tão direta ajudará a entender as idéias principais.

Limitações


Mas, como em qualquer outro método, existem algumas limitações. Na minha experiência, é precisamente essa implementação usando unordered_map em qualquer jogo não trivial que levará a problemas de desempenho.

Iterar interseções de chave em várias instâncias de unordered_map com várias entidades não é escalável porque você está realmente pesquisando N*M , em que N é o número de componentes sobrepostos, M é o número de entidades correspondentes e unordered_map não unordered_map muito bom em cache. Esse problema pode ser corrigido usando um armazenamento de valores-chave mais adequado para iteração, em vez de unordered_map .

Outra limitação é a caldeira. Dependendo do que você faz, identificar novos componentes pode se tornar tedioso. Pode ser necessário adicionar um anúncio não apenas na estrutura de componentes, mas também na função de geração, serialização, utilitário de depuração etc. Eu me deparei com isso sozinho e resolvi o problema gerando código - defini componentes em arquivos json externos e, em seguida, gerei componentes C ++ e funções auxiliares no estágio de construção. Tenho certeza de que você pode encontrar outros métodos baseados em modelos para corrigir quaisquer problemas que você encontrar.

O FIM

Se você tiver perguntas e comentários, pode deixá-los aqui ou ir a uma aula aberta com Dima , ouvi-lo e perguntar por aí.

Source: https://habr.com/ru/post/pt422981/


All Articles