Regroupement d'entités du même ensemble de composants dans la mémoire linéaire

11

Nous partons de l' approche de base systèmes-composants-entités .

Créons des assemblages (terme dérivé de cet article) simplement à partir d'informations sur les types de composants . Cela se fait dynamiquement au moment de l'exécution, tout comme nous ajouterions / supprimerions des composants à une entité un par un, mais nommons-le plus précisément car il ne s'agit que d'informations de type.

Ensuite, nous construisons des entités spécifiant l' assemblage pour chacune d'entre elles. Une fois que nous avons créé l'entité, son assemblage est immuable, ce qui signifie que nous ne pouvons pas la modifier directement en place, mais nous pouvons toujours obtenir la signature de l'entité existante sur une copie locale (avec le contenu), y apporter les modifications appropriées et créer une nouvelle entité. de celui-ci.

Maintenant, pour le concept clé: chaque fois qu'une entité est créée, elle est affectée à un objet appelé assemblage bucket , ce qui signifie que toutes les entités de la même signature seront dans le même conteneur (par exemple dans std :: vector).

Désormais, les systèmes se contentent de parcourir tous les domaines de leur intérêt et font leur travail.

Cette approche présente certains avantages:

  • les composants sont stockés dans quelques (précisément: nombre de compartiments) morceaux de mémoire contigus - cela améliore la convivialité de la mémoire et il est plus facile de vider l'état du jeu entier
  • les systèmes traitent les composants de manière linéaire, ce qui améliore la cohérence du cache - au revoir les dictionnaires et les sauts de mémoire aléatoires
  • la création d'une nouvelle entité est aussi simple que de mapper un assemblage à un bucket et de repousser les composants nécessaires à son vecteur
  • la suppression d'une entité est aussi simple qu'un appel à std :: move pour échanger le dernier élément avec celui supprimé, car l'ordre n'a pas d'importance en ce moment

entrez la description de l'image ici

Si nous avons beaucoup d'entités avec des signatures complètement différentes, les avantages de la cohérence du cache diminuent, mais je ne pense pas que cela se produirait dans la plupart des applications.

Il y a aussi un problème avec l'invalidation du pointeur une fois les vecteurs réaffectés - cela pourrait être résolu en introduisant une structure comme:

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;

    //...
};

Donc, chaque fois que pour une raison quelconque dans notre logique de jeu, nous voulons garder une trace d'une entité nouvellement créée, à l'intérieur du compartiment , nous enregistrons un entity_watcher , et une fois que l'entité doit être std :: move'd pendant la suppression, nous recherchons ses observateurs et mettons à jour leur real_index_in_vectorà de nouvelles valeurs. La plupart du temps, cela n'impose qu'une seule recherche de dictionnaire pour chaque suppression d'entité.

Y a-t-il d'autres inconvénients à cette approche?

Pourquoi la solution n'est-elle mentionnée nulle part, bien qu'elle soit assez évidente?

EDIT : J'édite la question pour "répondre aux réponses", car les commentaires sont insuffisants.

vous perdez la nature dynamique des composants enfichables, qui a été créé spécifiquement pour s'éloigner de la construction de classes statiques.

Je ne. Je ne l'ai peut-être pas expliqué assez clairement:

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

C'est aussi simple que de simplement prendre la signature d'une entité existante, de la modifier et de la télécharger à nouveau en tant que nouvelle entité. Pluggable, nature dynamique ? Bien sûr. Ici, je voudrais souligner qu'il n'y a qu'une seule classe "assemblage" et une seule classe "bucket". Les compartiments sont pilotés par les données et créés au moment de l'exécution en quantité optimale.

vous devez parcourir tous les compartiments pouvant contenir une cible valide. Sans structure de données externe, la détection des collisions pourrait être tout aussi difficile.

Eh bien, c'est pourquoi nous avons les structures de données externes susmentionnées . La solution de contournement est aussi simple que d'introduire un itérateur dans la classe System qui détecte quand passer au compartiment suivant. Le saut serait purement transparent à la logique.

Patryk Czachurski
la source
J'ai également lu l'article de Randy Gaul sur le stockage de tous les composants dans des vecteurs et laissé leurs systèmes les traiter. J'y vois deux gros problèmes: que faire si je veux mettre à jour uniquement un sous-ensemble d'entités (pensez à l'abattage par exemple). À cause de cela, les composants seront couplés à nouveau avec des entités. Pour chaque étape d'itération de composant, je devrais vérifier si l'entité à laquelle il appartient a été sélectionnée pour une mise à jour. L'autre problème est que certains systèmes doivent traiter plusieurs types de composants différents, ce qui supprime à nouveau l'avantage de la cohérence du cache. Des idées sur la façon de traiter ces problèmes?
tiguchi

Réponses:

7

Vous avez essentiellement conçu un système d'objets statiques avec un allocateur de pool et des classes dynamiques.

J'ai écrit un système d'objets qui fonctionne presque de manière identique à votre système d '"assemblages" à l'époque de mes études, bien que j'aie toujours tendance à appeler "assemblages" soit "plans" ou "archétypes" dans mes propres conceptions. L'architecture était plus pénible que les systèmes d'objets naïfs et n'avait aucun avantage mesurable en termes de performances par rapport à certaines des conceptions les plus flexibles auxquelles je les ai comparées. La possibilité de modifier dynamiquement un objet sans avoir besoin de le réifier ou de le réaffecter est extrêmement importante lorsque vous travaillez sur un éditeur de jeu. Les concepteurs voudront glisser-déposer des composants sur vos définitions d'objet. Le code d'exécution peut même avoir besoin de modifier efficacement les composants dans certaines conceptions, bien que je n'aime pas cela personnellement. Selon la façon dont vous liez les références d'objet dans votre éditeur,

Vous obtiendrez une cohérence de cache pire que vous ne le pensez dans la plupart des cas non triviaux. Votre système d'IA, par exemple, ne se soucie pas des Rendercomposants, mais finit par être coincé en les itérant dans le cadre de chaque entité. Les objets en cours d'itération sont plus volumineux et les demandes de mise en cache finissent par tirer des données inutiles, et moins d'objets entiers sont renvoyés à chaque demande). Ce sera toujours mieux que la méthode naïve, et la composition d'objet de la méthode naïve est utilisée même dans les gros moteurs AAA, donc vous n'avez probablement pas besoin de mieux, mais au moins ne pensez pas que vous ne pouvez pas l'améliorer davantage.

Votre approche est la plus logique pour certainscomposants, mais pas tous. Je n'aime pas beaucoup ECS car il préconise de toujours placer chaque composant dans un conteneur séparé, ce qui a du sens pour la physique ou les graphiques ou autres mais pas du tout si vous autorisez plusieurs composants de script ou une IA composable. Si vous laissez le système de composants être utilisé pour plus que des objets intégrés, mais aussi comme un moyen pour les concepteurs et les programmeurs de gameplay de composer le comportement des objets, il peut être judicieux de regrouper tous les composants AI (qui interagiront souvent) ou tous les scripts composants (puisque vous souhaitez tous les mettre à jour en un seul lot). Si vous voulez le système le plus performant, vous aurez besoin d'un mélange de schémas d'allocation et de stockage de composants et prenez le temps de déterminer de manière concluante ce qui est le mieux pour chaque type particulier de composant.

Sean Middleditch
la source
J'ai dit: nous ne pouvons pas changer la signature de l'entité, et je voulais dire que nous ne pouvons pas la modifier directement sur place, mais nous pouvons tout de même obtenir l'assemblage existant dans une copie locale, y apporter des modifications et la télécharger à nouveau en tant que nouvelle entité - et ces les opérations sont assez bon marché, comme je l'ai montré dans la question. Encore une fois - il n'y a qu'une seule classe "bucket". "Assemblages" / "Signatures" / "appelons-le comme nous voulons" peuvent être créés dynamiquement à l'exécution comme dans une approche standard, j'irais même jusqu'à penser à une entité comme une "signature".
Patryk Czachurski
Et j'ai dit que vous ne voulez pas nécessairement vous occuper de la réification. «Créer une nouvelle entité» pourrait signifier potentiellement casser toutes les poignées existantes à l'entité, selon le fonctionnement de votre système de poignées. Votre appel s'ils sont assez bon marché ou non. J'ai trouvé que c'était juste une douleur dans les fesses à gérer.
Sean Middleditch
D'accord, maintenant j'ai votre point sur celui-ci. Quoi qu'il en soit, je pense que même si l'ajout / la suppression était un peu plus cher, cela arrive si occasionnellement qu'il vaut toujours la peine de simplifier considérablement le processus d'accès aux composants, ce qui se produit en temps réel. Ainsi, les frais généraux de «changement» sont négligeables. À propos de votre exemple d'IA, ne vaut-il pas encore ces quelques systèmes qui ont de toute façon besoin de données provenant de plusieurs composants?
Patryk Czachurski
Mon point de vue était que l'IA était un endroit où votre approche est meilleure, mais pour d'autres composants, ce n'est pas nécessairement le cas.
Sean Middleditch
4

Ce que vous avez fait, ce sont des objets C ++ repensés. La raison pour laquelle cela semble évident est que si vous remplacez le mot «entité» par «classe» et «composant» par «membre», il s'agit d'une conception OOP standard utilisant des mixins.

1) vous perdez la nature dynamique des composants enfichables, qui a été créé spécifiquement pour s'éloigner de la construction de classes statiques.

2) la cohérence de la mémoire est la plus importante dans un type de données, pas dans un objet unifiant plusieurs types de données en un seul endroit. C'est l'une des raisons pour lesquelles les composants + systèmes ont été créés, pour s'éloigner de la fragmentation de la mémoire classe + objet.

3) cette conception revient également au style de classe C ++ parce que vous pensez à l'entité comme un objet cohérent lorsque, dans une conception de composant + système, l'entité est simplement une étiquette / ID pour rendre le fonctionnement interne compréhensible pour les humains.

4) il est tout aussi facile pour un composant de se sérialiser qu'un objet complexe de sérialiser plusieurs composants en lui-même, sinon en fait plus facile à suivre en tant que programmeur.

5) la prochaine étape logique dans ce chemin consiste à supprimer les systèmes et à mettre ce code directement dans l'entité, où il dispose de toutes les données dont il a besoin pour travailler. Nous pouvons tous voir ce que cela implique =)

Patrick Hughes
la source
2) Peut-être que je ne comprends pas complètement la mise en cache, mais disons qu'il existe un système qui fonctionne avec 10 composants par exemple. Dans une approche standard, le traitement de chaque entité signifie accéder à la RAM 10 fois, car les composants sont dispersés dans des endroits aléatoires de la mémoire, même si des pools sont utilisés - car différents composants appartiennent à différents pools. Ne serait-il pas «important» de mettre en cache l'entité entière à la fois et de traiter tous les composants sans manquer un seul cache, sans même avoir à faire des recherches dans le dictionnaire? Aussi, j'ai fait un montage pour couvrir le 1) point
Patryk Czachurski
@Sean Middleditch a une bonne description de cette panne de mise en cache dans sa réponse.
Patrick Hughes
3) Ce ne sont en aucun cas des objets cohérents. Au sujet du composant A étant juste à côté du composant B en mémoire, c'est juste la "cohérence de la mémoire", pas la "cohérence logique", comme John l'a souligné. Les godets, lors de leur création, pouvaient même mélanger les composants en signature dans l'ordre et les principes souhaités seraient toujours maintenus. 4) il peut être tout aussi facile de "garder une trace" si nous avons suffisamment d'abstraction - ce dont nous parlons est simplement un schéma de stockage fourni avec des itérateurs et peut-être une carte de décalage d'octets peut rendre le traitement aussi facile que dans une approche standard.
Patryk Czachurski
5) Et je ne pense pas que quoi que ce soit dans cette idée pointe dans cette direction. Ce n'est pas que je ne veux pas être d'accord avec vous, je suis juste curieux de savoir où cette discussion peut mener, bien que de toute façon cela conduira probablement à une sorte de "mesure" ou à la fameuse "optimisation prématurée". :)
Patryk Czachurski
@PatrykCzachurski mais vos systèmes ne fonctionnent pas avec 10 composants.
user253751
3

Garder les mêmes entités ensemble n'est pas aussi important qu'on pourrait le penser, c'est pourquoi il est difficile de penser à une raison valable autre que "parce que c'est une unité". Mais comme vous faites vraiment cela pour la cohérence du cache par opposition à la cohérence logique, cela pourrait avoir du sens.

Une des difficultés que vous pourriez rencontrer est l'interaction entre les composants de différents compartiments. Il n'est pas très simple de trouver quelque chose sur lequel votre IA peut tirer, par exemple, vous devez passer par tous les compartiments qui pourraient contenir une cible valide. Sans structure de données externe, la détection des collisions pourrait être tout aussi difficile.

Pour continuer à organiser des entités ensemble pour une cohérence logique, la seule raison pour laquelle je pourrais avoir à garder des entités ensemble est à des fins d'identification dans mes missions. J'ai besoin de savoir si vous venez de créer un type d'entité A ou B, et je contourne cela par ... vous l'avez deviné: en ajoutant un nouveau composant qui identifie l'assemblage qui a mis cette entité ensemble. Même alors, je ne rassemble pas tous les composants pour une grande tâche, j'ai juste besoin de savoir ce que c'est. Je ne pense donc pas que cette partie soit extrêmement utile.

John McDonald
la source
Je dois admettre que je ne comprends pas très bien votre réponse. Qu'entendez-vous par «cohérence logique»? Concernant les difficultés d'interactions, j'ai fait un montage.
Patryk Czachurski
"Cohérence logique", comme dans: Il est "logique" de garder tous les composants qui composent une entité Tree proches les uns des autres.
John McDonald