Em um
artigo anterior, descrevi as tecnologias e abordagens que usamos ao desenvolver um novo jogo de tiro em dispositivos móveis. Porque foi uma revisão e até um artigo superficial - hoje vou aprofundar e explicar detalhadamente por que decidimos escrever nossa própria estrutura de ECS e não usamos as existentes. Haverá exemplos de código e um pequeno bônus no final.

O que é o ECS como exemplo
Eu já descrevi brevemente o que é o Sistema de Componentes de Entidades e existem artigos no Habré sobre o ECS (basicamente, no entanto, traduções de artigos - veja minha revisão dos mais interessantes no final do artigo, como um bônus). E hoje vou falar sobre como usamos o ECS - usando nosso exemplo de código.
O diagrama acima descreve a essência do
Player , seus componentes e seus dados, e os sistemas que funcionam com o Player e seus componentes. O principal objeto do diagrama é o player:
- pode se mover no espaço - Componentes de transformação e movimento , MoveSystem ;
- tem alguma saúde e pode morrer - componente Health , Damage , DamageSystem ;
- após a morte aparecer no ponto de respawn - o componente Transform da posição, o RespawnSystem ;
- pode ser invulnerável - componente invencível .
Nós descrevemos isso com um código. Primeiro, vamos obter interfaces para componentes e sistemas. Os componentes podem ter métodos auxiliares comuns, o sistema possui apenas um método
Execute , que recebe o estado do mundo na entrada para processamento:
public interface IComponent {
Para componentes, criamos classes stub usadas pelo nosso gerador de código para convertê-las em código de componente realmente usado. Vamos pegar alguns espaços em branco para
Saúde ,
Dano e
Invencível (para o restante dos componentes será semelhante).
[Component] public class Health { [Max(1000)]
Os componentes determinam o estado do mundo, portanto, eles contêm apenas dados, sem métodos. Ao mesmo tempo, não há dados no
Invincible , eles são usados na lógica como um sinal de invulnerabilidade - se a essência do jogador tiver esse componente, ele será invulnerável.
O atributo
Component é usado pelo gerador para encontrar as classes em branco para os componentes. Os
atributos Max e
DontSend são necessários como dicas ao serializar e reduzir o tamanho do estado do mundo transmitido pela rede ou salvo no disco. Nesse caso, o servidor não serializa o campo
Valor e o envia pela rede (porque os clientes não usam esse parâmetro, ele é necessário apenas no servidor). E o campo
Hp pode ser bem empacotado em vários bits, considerando o valor máximo de integridade.
Também temos uma classe
pré-fabricada de Entidade , na qual adicionamos informações sobre todos os componentes possíveis de qualquer entidade, e o gerador já criará uma classe real a partir dela:
public class Entity { public Health Health; public Damage Damage; public Invincible Invincible;
Depois disso, nosso gerador criará o código das classes de componente
Health ,
Damage e
Invincible , que já serão usadas na lógica do jogo:
public sealed class Health : IComponent { public int Hp; public void Reset() { Hp = default(int); }
Como você pode ver, os dados permaneceram nas classes e os métodos foram adicionados, por exemplo,
Redefinir . É necessário otimizar e reutilizar componentes em conjuntos. Outros métodos auxiliares não contêm lógica de negócios - não os darei por brevidade.
Também será gerada uma classe para o estado do mundo, que contém uma lista de todos os componentes e entidades:
public sealed class GameState {
E, finalmente, o código gerado para a
Entidade :
public sealed class Entity { public uint Id;
A classe
Entity é essencialmente apenas um identificador de componente. A referência aos objetos do mundo
GameState é usada apenas em métodos auxiliares para a conveniência de escrever código de lógica de negócios. Conhecendo o identificador de um componente, podemos usá-lo para serializar relacionamentos entre entidades, implementar links em componentes para outras entidades. Por exemplo, o componente
Dano contém uma referência à entidade
Vítima para determinar quem foi danificado.
Isso termina o código gerado. Em geral, precisamos de um gerador para não escrever métodos auxiliares todas as vezes. Nós apenas descrevemos os componentes como dados, então o gerador faz todo o trabalho. Exemplos de métodos auxiliares:
- criação / exclusão de entidades;
- adicione / remova / copie um componente, acesse-o, se existir;
- compare dois estados do mundo;
- serializar o estado do mundo;
- compressão delta;
- código de uma página da web ou janela do Unity para exibir o estado do mundo, entidades, componentes (veja detalhes abaixo);
- e outros
Vamos seguir para o código do sistema. Eles definem a lógica de negócios. Por exemplo, vamos escrever o código de um sistema que calcula o dano a um jogador:
public sealed class DamageSystem : ISystem { void ISystem.Execute(GameState gs) { foreach (var damage in gs.Damages) { var invincible = damage.Victim.Invincible; if (invincible != null) continue; var health = damage.Victim.Health; if (health == null) continue; health.Hp -= damage.Amount; } } }
O sistema passa por todos os componentes de
Dano no mundo e procura ver se há um componente
Invencível em um jogador potencialmente danificado (
Vítima ). Se ele é, o jogador é invulnerável, o dano não é acumulado. Em seguida, obtemos o componente de
saúde da vítima e reduzimos a saúde do jogador pelo tamanho do dano.
Considere os principais recursos dos sistemas:
- Um sistema geralmente é uma classe sem estado, não contém dados internos, não tenta salvá-lo em algum lugar, exceto dados sobre o mundo transmitido de fora.
- Os sistemas geralmente passam por todos os componentes de um determinado tipo e trabalham com eles. Eles geralmente são chamados pelo tipo de componente ( Damage → DamageSystem ) ou pela ação que eles executam ( RespawnSystem ).
- O sistema implementa funcionalidade mínima. Por exemplo, se formos além, depois que o DamageSystem for executado, outro RemoveDamageSystem removerá todos os componentes do Damage . No próximo tick, outro ApplyDamageSystem baseado no disparo do jogador pode novamente pendurar o componente Damage com novos danos. E o PlayerDeathSystem verificará a saúde do jogador ( Health.Hp ) e, se for menor ou igual a 0, destruirá todos os componentes do jogador, exceto Transform e adicionará o componente Dead flag.
No total, obtemos as seguintes classes e os relacionamentos entre elas:

Alguns fatos sobre ECS
A ECS tem seus prós e contras como uma abordagem ao desenvolvimento e uma maneira de representar o mundo do jogo, para que todos decidam por si mesmos se devem usá-lo ou não. Vamos começar com os profissionais:
- Composição versus herança múltipla. No caso de herança múltipla, várias funcionalidades desnecessárias podem ser herdadas. No caso do ECS, a funcionalidade aparece / desaparece quando um componente é adicionado / removido.
- Separação de lógica e dados. A capacidade de alterar a lógica (alterar sistemas, remover / adicionar componentes) sem interromper os dados. I.e. você pode desativar o grupo de sistemas responsáveis por uma certa funcionalidade a qualquer momento, todo o resto continuará funcionando e isso não afetará os dados.
- O ciclo do jogo é simplificado. Uma atualização aparece e todo o ciclo é dividido em sistemas. Os dados são processados pelo "fluxo" no sistema, independentemente do mecanismo (não há milhões de chamadas de atualização , como no Unity).
- Uma entidade não sabe quais classes o afetam (e não deveria saber).
- Uso eficiente de memória . Depende da implementação do ECS. Você pode reutilizar objetos e componentes de entidade criados usando conjuntos; você pode usar tipos de valor para dados e armazená-los na memória lado a lado ( localidade dos dados ).
- É mais fácil testar quando os dados são separados da lógica. Especialmente quando você considera que a lógica é um sistema pequeno com várias linhas de código.
- Veja e edite o estado do mundo em tempo real . Porque como o estado do mundo é apenas dados, escrevemos uma ferramenta que exibe na página da web todo o estado do mundo em uma correspondência no servidor (assim como a cena da correspondência em 3D). Qualquer componente de qualquer entidade pode ser visualizado, modificado e excluído. O mesmo pode ser feito no editor do Unity para o cliente.

E agora os contras:
- Você precisa aprender a pensar, projetar e escrever código de maneira diferente . Pense em termos de entidades, componentes e sistemas. Muitos padrões de design no ECS são implementados de uma maneira completamente diferente (veja um exemplo de implementação do padrão State em um dos artigos de revisão no final).
- Mais código . Discutível. Por um lado, devido ao fato de dividirmos a lógica em pequenos sistemas, em vez de descrever toda a funcionalidade em uma classe, há mais classes, mas não há muito mais código.
- A ordem na qual os sistemas são chamados afeta a operação de todo o jogo . Geralmente, os sistemas são dependentes um do outro, a ordem de sua execução é definida pela lista e eles são executados nessa ordem. Por exemplo, primeiro o DamageSystem considera o dano e o RemoveDamageSystem remove o componente Damage . Se você acidentalmente alterar a ordem, tudo funcionará de maneira diferente. Em geral, isso também se aplica ao caso usual de POO, se você alterar a ordem das chamadas de método, mas no ECS é mais fácil cometer um erro. Por exemplo, se parte da lógica for executada no cliente para previsão, o pedido deverá ser o mesmo que no servidor.
- De alguma forma, precisamos conectar os dados e eventos da lógica à visualização . No caso do Unity, temos o MVP:
- Modelo - GameState da ECS;
- Veja - conosco, estas são exclusivamente classes MonoBehavior Unity padrão ( Renderer , Texto etc.) e pré-fabricadas;
- O Presenter usa o GameState para determinar os eventos de aparência / desaparecimento de entidades, componentes etc., cria objetos do Unity a partir de pré-fabricados e os altera de acordo com as mudanças no estado do mundo.
Você sabia que:- O ECS não se refere apenas à localidade dos dados . Para mim, isso é mais um paradigma de programação, um padrão, outra maneira de projetar o mundo do jogo - chame como quiser. A localidade dos dados é apenas uma otimização.
- A unidade não tem ECS! Muitas vezes você pergunta aos candidatos em uma entrevista em equipe - o que você sabe sobre ECS? Se você não ouviu, diga a eles e eles responderam: "Ah, é como no Unity, então eu sei!". Mas não, não é como no mecanismo do Unity. Lá, dados e lógica são combinados no componente MonoBehaviour , e o GameObject (se comparado com uma entidade no ECS) possui dados adicionais - um nome, um local na hierarquia etc. Os desenvolvedores da unidade estão atualmente trabalhando em uma implementação normal do ECS no mecanismo, e até agora parece que será bom. Eles contrataram especialistas nesse campo - espero que seja legal.
Nossos critérios de seleção para a estrutura ECS
Quando decidimos criar um jogo no ECS, começamos a procurar uma solução pronta e anotamos os requisitos com base na experiência de um dos desenvolvedores. E eles pintaram como as soluções existentes atendem aos nossos requisitos. Foi há um ano, no momento, algo poderia ter mudado. Como soluções, consideramos:
- Entitas
- Artemis C #
- Ash.net
- O ECS é nossa própria solução no momento em que o concebemos. I.e. nossas suposições e desejos, o que podemos fazer por nós mesmos.
Compilamos uma tabela para comparação, onde também incluí nossa solução atual (designada como
ECS (agora) ):
Cor vermelha - a solução não atende aos nossos requisitos, laranja - suporta parcialmente, verde - suporta totalmente.Para nós, a analogia das operações para acessar componentes e procurar entidades no ECS eram operações em um banco de dados sql. Portanto, usamos conceitos como tabela (tabela), junção (operação de junção), índices (índices) etc.
Descreveremos nossos requisitos e em que medida as bibliotecas e estruturas de terceiros corresponderam a eles:
- conjuntos de dados separados (histórico, atual, visual, estático) - a capacidade de obter e armazenar separadamente estados mundiais (por exemplo, o estado atual para processamento, renderização, histórico de estado etc.). Todas as decisões consideradas apoiaram esse requisito .
- ID da entidade como número inteiro - suporte para representar uma entidade por seu número identificador. É necessário para a transmissão pela rede e a capacidade de conectar entidades na história dos estados. Nenhuma das soluções consideradas suportadas. Por exemplo, no Entitas, uma entidade é representada por um objeto completo (como um GameObject no Unity).
- junção por ID O (N + M) - suporte para amostragem relativamente rápida de dois tipos de componentes. Por exemplo, quando você precisa obter todas as entidades com componentes do tipo Dano (por exemplo, suas peças N) e Saúde (peças M) para calcular e causar danos. Havia suporte total em Artemis; no Entitas e no Ash.NET, é mais rápido que O (N²), mas mais lento que O (N + M). Não me lembro da avaliação agora.
- junção pela referência de identificação O (N + M) - o mesmo que acima, somente quando o componente de uma entidade possui um link para outro, e o último precisa obter outro componente (no nosso exemplo, o componente Dano na entidade auxiliar refere-se à entidade jogador Vítima e a partir daí você precisa obter o componente Saúde ). Não é suportado por nenhuma das soluções consideradas.
- sem alocação de consulta - sem alocações de memória extra ao consultar componentes e entidades do estado do mundo. Na Entitas, foi em certos casos, mas insignificante para nós.
- tabelas de pool - armazenamento de dados mundiais em pools, capacidade de reutilizar memória, alocação apenas quando o pool estiver vazio. Havia "algum" suporte no Entitas e Artemis, uma ausência completa no Ash.NET.
- compare by ID (add, del) - suporte interno para eventos de criação / destruição de entidades e componentes por ID. É necessário que o nível de exibição (Exibir) mostre / oculte objetos, reproduza animações, efeitos. Não é suportado por nenhuma das soluções consideradas.
- Δ serialização (quantização, pular) - compactação delta integrada para serializar o estado do mundo (por exemplo, para reduzir o tamanho dos dados enviados pela rede). O Out of the Box não foi suportado em nenhuma das soluções.
- A interpolação é um mecanismo interno de interpolação entre estados do mundo. Nenhuma das soluções suportadas.
- reutilizar o tipo de componente - a capacidade de usar o tipo de componente escrito uma vez em diferentes tipos de entidades. Somente Entitas suportado .
- ordem explícita de sistemas - a capacidade de definir seus próprios sistemas de ordem de chamada. Todas as decisões são suportadas.
- editor (unidade / servidor) - suporte para visualizar e editar entidades em tempo real, tanto para o cliente quanto para o servidor. Entitas suportou apenas a capacidade de visualizar e editar entidades e componentes no editor do Unity.
- cópia / substituição rápida - a capacidade de copiar / substituir dados de maneira barata. Nenhuma das soluções suportadas.
- componente como tipo de valor (struct) - componentes como tipos de valor. Em princípio, eu queria alcançar um bom desempenho com base nisso. Nenhum sistema era suportado; as classes de componentes estavam por toda parte.
Requisitos opcionais (
nenhuma das soluções da época os suportava ):
- índices - indexando dados como em um banco de dados.
- chaves compostas - chaves complexas para acesso rápido aos dados (como no banco de dados).
- verificação de integridade - a capacidade de verificar a integridade dos dados em um estado do mundo. Útil para depuração.
- A compactação com reconhecimento de conteúdo é a melhor compactação de dados com base no conhecimento da natureza dos dados. Por exemplo, se soubermos o tamanho máximo do mapa ou o número máximo de objetos no mundo.
- limite de tipos / sistemas - restrição ao número de tipos de componentes ou sistemas. Na Artemis naquela época, era impossível criar mais de 32 ou 64 tipos de componentes e sistemas .
Como pode ser visto na tabela, nós mesmos queríamos implementar todos os requisitos, exceto os opcionais. De fato, no momento
não fizemos:
- junção pelo ID O (N + M) e junção pela referência de ID O (N + M) - a seleção para dois componentes diferentes ainda ocupa O (N²) (na verdade, um loop for aninhado). Por outro lado, não existem muitas entidades e componentes para uma correspondência.
- comparar por ID (adicionar, del) - não é necessário no nível da estrutura. Implementamos isso em um nível mais alto no MVP.
- cópia / substituição rápida e componente como tipo de valor (struct) - em algum momento, percebemos que trabalhar com estruturas não seria tão conveniente quanto com as classes, e decidimos por classes - preferimos a conveniência do desenvolvimento em vez do melhor desempenho. A propósito, os desenvolvedores do Entitas fizeram o mesmo no final .
Ao mesmo tempo, percebemos um dos requisitos inicialmente opcionais em nossa opinião:
- compactação com reconhecimento de conteúdo - por isso conseguimos reduzir (dezenas de vezes) significativamente o tamanho do pacote transmitido pela rede. Para redes de dados móveis, é muito importante ajustar o tamanho do pacote na MTU para que não seja "dividido" em pequenas partes que podem se perder, ser executadas em uma ordem diferente e depois serem montadas em partes. Por exemplo, no Photon, se o tamanho dos dados não couber na biblioteca MTU, os dados serão divididos em pacotes e os serão enviados como confiáveis (com entrega garantida), mesmo se você os enviar como "não confiáveis" de cima. Testado com dor em primeira mão.
Características do nosso desenvolvimento na ECS
- Na ECS, escrevemos exclusivamente a lógica de negócios . Nenhum trabalho com recursos, visualizações etc. Como o código lógico do ECS é executado simultaneamente no cliente no Unity e no servidor, ele deve ser o mais independente possível de outros níveis e módulos.
- Tentamos minimizar componentes e sistemas . Geralmente, para cada nova tarefa, iniciamos novos componentes e sistemas. Às vezes, porém, modificamos os antigos, adicionamos novos dados aos componentes e "inflamos" os sistemas.
- Em nossa implementação do ECS, você não pode adicionar vários componentes do mesmo tipo a uma entidade . Portanto, se um jogador foi atingido várias vezes em um tick (por exemplo, vários oponentes), geralmente criamos uma nova entidade para cada dano e adicionamos um componente de dano a ele.
- Às vezes, a apresentação não é suficiente das informações que estão no GameState . É necessário adicionar componentes especiais ou dados adicionais que não estão envolvidos na lógica, mas que a visualização precisa. Por exemplo, o instantâneo é instantâneo no servidor, um tick fica vivo e, visualmente, é mais demorado no cliente. Portanto, para o cliente, a injeção é adicionada ao parâmetro "vida útil da injeção".
- Implementamos eventos / solicitações criando componentes especiais . Por exemplo, se um jogador morreu, penduramos nele um componente sem dados mortos , que é um evento para outros sistemas e o nível de exibição em que o jogador morreu. Ou, se precisarmos reviver o jogador novamente, criaremos uma entidade separada com o componente Respawn com informações adicionais sobre quem reviver. Um RespawnSystem separado no início do ciclo do jogo passa por esses componentes e já cria a essência do jogador. I.e. de fato, a primeira entidade é uma solicitação para criar a segunda.
- Temos componentes / entidades "singleton" especiais . Por exemplo, temos uma entidade com ID = 1, na qual componentes especiais dependem - as configurações do jogo.
Bônus
— ECS — . , , , :
- Unity, ECS -- — ECS . mopsicus , ECS, . : Unity ECS , . . «» ECS Unity. ECS-, : LeoECS , BrokenBricksECS , Svelto.ECS .
- Unity3D ECS Job System — , ECS Unity. fstleo , Unity ECS, , - , JobSystem.
- Entity System Framework ? — Ash- ActionScript. , , OOP- ECS-.
- Ash Entity System — , FSM State ECS — , .
- Entity-Component-System — — ECS C++.