OOP ECS vs Pure ECS

11

Em primeiro lugar, estou ciente de que esta pergunta está relacionada ao tópico de desenvolvimento de jogos, mas decidi perguntar aqui, já que realmente se trata de um problema mais geral de engenharia de software.

Durante o mês passado, li bastante sobre sistemas de componentes de entidades e agora me sinto bastante à vontade com o conceito. No entanto, há um aspecto que parece estar faltando uma "definição" clara e diferentes artigos sugeriram soluções radicalmente diferentes:

Esta é a questão de saber se um ECS deve quebrar o encapsulamento ou não. Em outras palavras, é o ECS do estilo OOP (componentes são objetos com estado e comportamento que encapsulam os dados específicos a eles) versus o ECS puro (componentes são estruturas de estilo c que possuem apenas dados e sistemas públicos fornecem a funcionalidade).

Observe que estou desenvolvendo um Framework / API / Engine. Portanto, o objetivo é que ele possa ser estendido facilmente por quem estiver usando. Isso inclui coisas como adicionar um novo tipo de componente de renderização ou colisão.

Problemas com a abordagem OOP

  • Os componentes devem acessar dados de outros componentes. Por exemplo, o método de desenho do componente de renderização deve acessar a posição do componente de transformação. Isso cria dependências no código.

  • Os componentes podem ser polimórficos, o que introduz ainda mais complexidade. Por exemplo, pode haver um componente de renderização de sprite que substitua o método de desenho virtual do componente de renderização.

Problemas com a abordagem pura

  • Como o comportamento polimórfico (por exemplo, para renderização) precisa ser implementado em algum lugar, ele é terceirizado nos sistemas. (por exemplo, o sistema de renderização do sprite cria um nó de renderização do sprite que herda o nó de renderização e o adiciona ao mecanismo de renderização)

  • A comunicação entre sistemas pode ser difícil de evitar. Por exemplo, o sistema de colisão pode precisar da caixa delimitadora que é calculada a partir de qualquer componente de renderização de concreto existente. Isso pode ser resolvido permitindo que eles se comuniquem via dados. No entanto, isso remove atualizações instantâneas, pois o sistema de renderização atualizaria o componente da caixa delimitadora e o sistema de colisão o usaria. Isso pode gerar problemas se a ordem de chamada das funções de atualização do sistema não estiver definida. Existe um sistema de eventos que permite que os sistemas gerem eventos nos quais outros sistemas podem assinar seus manipuladores. No entanto, isso só funciona para informar aos sistemas o que fazer, ou seja, funções nulas.

  • Existem sinalizadores adicionais necessários. Veja um componente de mapa de blocos, por exemplo. Teria um tamanho, tamanho de bloco e campo de lista de índice. O sistema de mapa de blocos lidaria com a respectiva matriz de vértices e atribuiria as coordenadas de textura com base nos dados do componente. No entanto, recalcular o mapa de mosaico inteiro a cada quadro é caro. Portanto, seria necessária uma lista para acompanhar todas as alterações feitas para atualizá-las no sistema. Da maneira OOP, isso pode ser encapsulado pelo componente do mapa de blocos. Por exemplo, o método SetTile () atualiza a matriz de vértices sempre que for chamada.

Embora eu veja a beleza da abordagem pura, eu realmente não entendo que tipo de benefícios concretos ela teria sobre uma OOP mais tradicional. As dependências entre os componentes ainda existem, embora ocultas nos sistemas. Também precisaria de muito mais aulas para atingir o mesmo objetivo. Isso me parece uma solução um tanto excedente de engenharia que nunca é uma coisa boa.

Além disso, não sou muito interessado em desempenho, de modo que toda essa ideia de design orientado a dados e erros de caixa não me interessa. Eu só quero uma bela arquitetura ^^

Ainda assim, a maioria dos artigos e discussões que li sugerem a segunda abordagem. PORQUE?

Animação

Por fim, quero fazer a pergunta de como lidaria com a animação em um ECS puro. Atualmente, defini uma animação como um functor que manipula uma entidade com base em algum progresso entre 0 e 1. O componente de animação possui uma lista de animadores que possui uma lista de animações. Em sua função de atualização, aplica as animações atualmente ativas à entidade.

Nota:

Acabei de ler este post O objeto de arquitetura do Entity Component System é orientado por definição? o que explica o problema um pouco melhor do que eu. Embora basicamente esteja no mesmo tópico, ainda não fornece respostas sobre por que a abordagem de dados puros é melhor.

Adrian Koch
fonte
11
Talvez uma pergunta simples, mas séria: você conhece as vantagens / desvantagens dos ECS? Isso explica principalmente o 'porquê'.
Caramiriel
Bem, eu entendo a vantagem de usar componentes, ou seja, composição, em vez de herança, para evitar o diamante da morte por meio de múltiplas herança. O uso de componentes também permite manipular o comportamento em tempo de execução. E eles são modulares. O que não entendo é por que a divisão de dados e funções é desejada. Minha implementação atual é no github github.com/AdrianKoch3010/MarsBaseProject
Adrian Koch
Bem, eu não tenho experiência suficiente com ECS para adicionar uma resposta completa. Mas a composição não é usada apenas para evitar o Departamento de Defesa; você também pode criar entidades (exclusivas) em tempo de execução que são mais difíceis de gerar usando uma abordagem OO. Dito isto, a divisão de dados / procedimentos permite que os dados sejam mais fáceis de raciocinar. Você pode implementar serialização, salvar estado, desfazer / refazer e coisas assim de maneira fácil. Como é fácil raciocinar sobre os dados, também é mais fácil otimizá-los. É provável que você possa dividir as entidades em lotes (multithreading) ou até mesmo descarregá-las em outro hardware para obter todo o seu potencial.
Caramiriel
"Pode haver um componente de renderização de sprite que substitui o método de desenho virtual do componente de renderização." Eu diria que não é mais o ECS se você exige / requer isso.
Wondra

Respostas:

10

Essa é difícil. Vou tentar abordar algumas das perguntas com base em minhas experiências particulares (YMMV):

Os componentes devem acessar dados de outros componentes. Por exemplo, o método de desenho do componente de renderização deve acessar a posição do componente de transformação. Isso cria dependências no código.

Não subestime a quantidade e a complexidade (não o grau) dos acoplamentos / dependências aqui. Você pode observar a diferença entre isso (e este diagrama já é ridiculamente simplificado para níveis semelhantes a brinquedos, e o exemplo do mundo real teria interfaces entre elas para afrouxar o acoplamento):

insira a descrição da imagem aqui

... e isto:

insira a descrição da imagem aqui

... ou isto:

insira a descrição da imagem aqui

Os componentes podem ser polimórficos, o que introduz ainda mais complexidade. Por exemplo, pode haver um componente de renderização de sprite que substitua o método de desenho virtual do componente de renderização.

Então? O equivalente analógico (ou literal) de um vtable e despacho virtual pode ser chamado pelo sistema, e não pelo objeto que oculta seu estado / dados subjacentes. O polimorfismo ainda é muito prático e viável com a implementação "pura" do ECS quando o vtable analógico ou ponteiro (s) de função se transforma em "dados" das sortes para o sistema chamar.

Como o comportamento polimórfico (por exemplo, para renderização) precisa ser implementado em algum lugar, ele é terceirizado nos sistemas. (por exemplo, o sistema de renderização do sprite cria um nó de renderização do sprite que herda o nó de renderização e o adiciona ao mecanismo de renderização)

Então? Espero que isso não pareça sarcasmo (não é minha intenção, embora eu tenha sido acusado disso com frequência, mas gostaria de poder comunicar emoções melhor através do texto), mas a "terceirização" do comportamento polimórfico nesse caso não implica necessariamente um adicional custo à produtividade.

A comunicação entre sistemas pode ser difícil de evitar. Por exemplo, o sistema de colisão pode precisar da caixa delimitadora que é calculada a partir de qualquer componente de renderização de concreto existente.

Este exemplo parece particularmente estranho para mim. Não sei por que um renderizador retornaria dados à cena (geralmente considero os renderizadores somente leitura neste contexto) ou para um renderizador descobrir AABBs em vez de algum outro sistema para fazer isso tanto para renderizador quanto para renderizador. colisão / física (talvez eu esteja ficando com o nome do "componente de renderização" aqui). No entanto, não quero ficar muito preocupado com esse exemplo, pois percebo que esse não é o ponto que você está tentando fazer. Ainda assim, a comunicação entre sistemas (mesmo na forma indireta de leitura / gravação no banco de dados central do ECS com sistemas que dependem diretamente das transformações feitas por outros) não deve ser frequente, se for necessário. Este'

Isso pode gerar problemas se a ordem de chamada das funções de atualização do sistema não estiver definida.

Isso absolutamente deve ser definido. O ECS não é a solução completa para reorganizar a ordem de avaliação do processamento do sistema de todos os sistemas possíveis na base de código e recuperar exatamente o mesmo tipo de resultado para o usuário final que lida com quadros e FPS. Essa é uma das coisas, ao projetar um ECS, que eu, pelo menos, sugiro fortemente que seja antecipado de alguma forma antecipadamente (embora com muito espaço para respirar e perdoe para mudar de idéia mais tarde, desde que não esteja alterando os aspectos mais críticos da solicitação de invocação / avaliação do sistema).

No entanto, recalcular o mapa de mosaico inteiro a cada quadro é caro. Portanto, seria necessária uma lista para acompanhar todas as alterações feitas para atualizá-las no sistema. Da maneira OOP, isso pode ser encapsulado pelo componente do mapa de blocos. Por exemplo, o método SetTile () atualiza a matriz de vértices sempre que for chamada.

Eu não entendi direito, exceto que é uma preocupação orientada a dados. E não há armadilhas para representar e armazenar dados em um ECS, incluindo memorização, para evitar essas armadilhas de desempenho (as maiores com um ECS tendem a se relacionar a coisas como sistemas que consultam por instâncias disponíveis de tipos de componentes específicos, que é um dos aspectos mais desafiadores da otimização de um ECS generalizado). O fato de a lógica e os dados serem separados em um ECS "puro" não significa que você repentinamente precise recompilar coisas que, de outra forma, poderiam ser armazenadas em cache / memorizadas em uma representação OOP. Esse é um ponto discutível / irrelevante, a menos que eu tenha encoberto algo muito importante.

Com o ECS "puro", você ainda pode armazenar esses dados no componente de mapa de blocos. A única grande diferença é que a lógica para atualizar essa matriz de vértices mudaria para um sistema em algum lugar.

Você pode até se apoiar no ECS para simplificar a invalidação e a remoção desse cache da entidade, se criar um componente separado como TileMapCache. Nesse ponto, quando o cache é desejado, mas não está disponível em uma entidade com um TileMapcomponente, você pode computá-lo e adicioná-lo. Quando é invalidado ou não é mais necessário, você pode removê-lo através do ECS sem precisar escrever mais código especificamente para essa invalidação e remoção.

As dependências entre os componentes ainda existem, embora ocultas nos sistemas

Não há dependência entre componentes em um representante "puro" (não acho certo dizer que as dependências estão sendo ocultadas aqui pelos sistemas). Dados não dependem de dados, por assim dizer. A lógica depende da lógica. E um ECS "puro" tende a promover a lógica a ser escrita de uma maneira que dependa do subconjunto mínimo absoluto de dados e lógica (geralmente nenhum) que um sistema requer para funcionar, o que é diferente de muitas alternativas que geralmente incentivam dependendo da muito mais funcionalidade do que o necessário para a tarefa real. Se você estiver usando o ECS puro, uma das primeiras coisas que você deve apreciar são os benefícios da dissociação, enquanto questiona simultaneamente tudo o que você aprendeu a apreciar no OOP sobre encapsulamento e ocultação de informações específicas.

Ao dissociar, quero dizer especificamente o quão pouca informação seus sistemas precisam para funcionar. Seu sistema de movimento nem precisa saber sobre algo muito mais complexo como um Particleou Character(o desenvolvedor do sistema nem precisa necessariamente saber que essas idéias de entidade existem no sistema). Ele só precisa saber sobre os dados mínimos, como um componente de posição, que pode ser tão simples quanto alguns carros alegóricos em uma estrutura. São ainda menos informações e menos dependências externas do que uma interface pura IMotiontende a levar consigo. É principalmente devido a esse conhecimento mínimo que cada sistema exige que funcione, o que torna o ECS muitas vezes tão perdoador para lidar com mudanças inesperadas de projeto em retrospectiva, sem enfrentar quebras de interface em cascata por todo o lugar.

A abordagem "impura" que você sugere diminui um pouco esse benefício, já que agora sua lógica não está localizada estritamente em sistemas onde as mudanças não causam quebras em cascata. A lógica agora seria centralizada em algum grau nos componentes acessados ​​por vários sistemas que agora precisam atender aos requisitos de interface de todos os vários sistemas que poderiam usá-la, e agora é como se todo sistema precisasse ter conhecimento de (depender de) mais informações estritamente necessárias para trabalhar com esse componente.

Dependências de dados

Uma das coisas controversas sobre o ECS é que ele tende a substituir o que poderia ser dependências para abstrair interfaces com apenas dados brutos, e isso geralmente é considerado uma forma de acoplamento menos desejável e mais restrita. Mas nos tipos de domínios como jogos nos quais o ECS pode ser muito benéfico, geralmente é mais fácil projetar a representação de dados antecipadamente e mantê-la estável do que projetar o que você pode fazer com esses dados em algum nível central do sistema. Isso é algo que tenho observado dolorosamente, mesmo entre veteranos experientes em bases de código, que utiliza mais uma abordagem de interface pura no estilo COM com coisas como IMotion.

Os desenvolvedores continuavam encontrando motivos para adicionar, remover ou alterar funções a essa interface central, e cada alteração era horrível e dispendiosa, pois tenderia a quebrar todas as classes implementadas IMotione todos os locais do sistema usado IMotion. Enquanto isso, o tempo todo com tantas mudanças dolorosas e em cascata, os objetos implementados IMotionestavam apenas armazenando uma matriz 4x4 de flutuadores e toda a interface se preocupava apenas em como transformar e acessar esses flutuadores; a representação dos dados era estável desde o início e muita dor poderia ter sido evitada se essa interface centralizada, tão propensa a mudanças com necessidades imprevistas de projeto, nem sequer existisse.

Tudo isso pode parecer quase tão nojento quanto variáveis ​​globais, mas a natureza de como o ECS organiza esses dados em componentes recuperados explicitamente por tipo através de sistemas faz com que seja, enquanto os compiladores não podem impor algo como ocultar informações, os lugares que acessam e se modificam os dados são geralmente muito explícitos e óbvios o suficiente para manter efetivamente os invariantes e prever que tipo de transformações e efeitos colaterais ocorrem de um sistema para o outro (na verdade, de maneiras que podem ser discutivelmente mais simples e previsíveis do que o OOP em certos domínios, considerando como o sistema se transforma em uma espécie plana de oleoduto).

insira a descrição da imagem aqui

Por fim, quero fazer a pergunta de como lidaria com a animação em um ECS puro. Atualmente, defini uma animação como um functor que manipula uma entidade com base em algum progresso entre 0 e 1. O componente de animação possui uma lista de animadores que possui uma lista de animações. Em sua função de atualização, aplica as animações atualmente ativas à entidade.

Somos todos pragmáticos aqui. Mesmo em gamedev, você provavelmente terá idéias / respostas conflitantes. Até o mais puro ECS é um fenômeno relativamente novo, território pioneiro, para o qual as pessoas não formularam necessariamente as opiniões mais fortes sobre como esfolar gatos. Minha reação instintiva é um sistema de animação que incrementa esse tipo de progresso da animação em componentes animados para o sistema de renderização exibir, mas isso está ignorando tantas nuances para o aplicativo e o contexto específicos.

Com o ECS, não é uma bala de prata e ainda me encontro com tendências para adicionar novos sistemas, remover alguns, adicionar novos componentes, alterar um sistema existente para pegar esse novo tipo de componente etc. Não entendo coisas certas desde o primeiro momento. Mas a diferença no meu caso é que não estou mudando nada central quando falho em antecipar determinadas necessidades de design com antecedência. Não estou obtendo o efeito ondulante das quebras em cascata que exigem que eu percorra todo o lugar e mude muito código para lidar com alguma nova necessidade que surge, e isso economiza bastante tempo. Também estou achando mais fácil para o meu cérebro, porque quando me sento com um sistema específico, não preciso saber / lembrar muito mais sobre qualquer outra coisa além dos componentes relevantes (que são apenas dados) para trabalhar nele.

Dragon Energy
fonte