OOP ECS vs Pure ECS


11

Tout d'abord, je suis conscient que cette question est liée au sujet du développement de jeux mais j'ai décidé de la poser ici car elle se résume vraiment à un problème de génie logiciel plus général.

Au cours du mois dernier, j'ai beaucoup lu sur Entity-Component-Systems et maintenant je suis assez à l'aise avec le concept. Cependant, il y a un aspect qui semble manquer d'une «définition» claire et différents articles ont suggéré des solutions radicalement différentes:

C'est la question de savoir si un ECS doit rompre l'encapsulation ou non. En d'autres termes, son ECS de style OOP (les composants sont des objets à la fois d'état et de comportement qui encapsulent les données qui leur sont propres) par rapport à l' ECS pur (les composants sont des structures de style c qui ne disposent que de données publiques et les systèmes fournissent la fonctionnalité).

Notez que je développe un Framework / API / Engine. Le but est donc qu'il puisse être facilement étendu par celui qui l'utilise. Cela inclut des choses comme l'ajout d'un nouveau type de composant de rendu ou de collision.

Problèmes avec l'approche POO

  • Les composants doivent accéder aux données des autres composants. Par exemple, la méthode de dessin du composant de rendu doit accéder à la position du composant de transformation. Cela crée des dépendances dans le code.

  • Les composants peuvent être polymorphes, ce qui introduit en outre une certaine complexité. Par exemple, un composant de rendu d'image-objet peut remplacer la méthode de dessin virtuel du composant de rendu.

Problèmes avec l'approche pure

  • Étant donné que le comportement polymorphe (par exemple pour le rendu) doit être implémenté quelque part, il est juste externalisé dans les systèmes. (par exemple, le système de rendu de sprite crée un noeud de rendu de sprite qui hérite du noeud de rendu et l'ajoute au moteur de rendu)

  • La communication entre les systèmes peut être difficile à éviter. Par exemple, le système de collision peut avoir besoin de la boîte englobante qui est calculée à partir de tout composant de rendu concret. Cela peut être résolu en les laissant communiquer via les données. Cependant, cela supprime les mises à jour instantanées car le système de rendu mettrait à jour le composant de boîte englobante et le système de collision l'utiliserait ensuite. Cela peut entraîner des problèmes si l'ordre d'appel des fonctions de mise à jour du système n'est pas défini. Il existe un système d'événements qui permet aux systèmes de déclencher des événements auxquels d'autres systèmes peuvent abonner leurs gestionnaires. Cependant, cela ne fonctionne que pour indiquer aux systèmes quoi faire, c'est-à-dire annuler les fonctions.

  • Des indicateurs supplémentaires sont nécessaires. Prenez un composant de carte de tuiles par exemple. Il aurait un champ de taille, de taille de tuile et de liste d'index. Le système de carte de tuiles gérerait le réseau de sommets respectif et attribuerait les coordonnées de texture en fonction des données du composant. Cependant, recalculer l'intégralité du tilmap à chaque image est coûteux. Par conséquent, une liste serait nécessaire pour garder une trace de toutes les modifications apportées pour ensuite les mettre à jour dans le système. De la manière OOP, cela pourrait être encapsulé par le composant de carte de tuiles. Par exemple, la méthode SetTile () mettrait à jour le tableau de sommets chaque fois qu'il est appelé.

Bien que je vois la beauté de l'approche pure, je ne comprends pas vraiment quel type d'avantages concrets cela aurait par rapport à une POO plus traditionnelle. Les dépendances entre les composants existent toujours bien qu'elles soient cachées dans les systèmes. J'aurais également besoin de beaucoup plus de cours pour atteindre le même objectif. Cela me semble être une solution un peu trop technique qui n'est jamais une bonne chose.

De plus, je ne suis pas tellement intéressé par les performances, donc cette idée de conception orientée données et de manque de cashe n'a pas vraiment d'importance pour moi. Je veux juste une belle architecture ^^

Pourtant, la plupart des articles et des discussions que j'ai lus suggèrent la deuxième approche. POURQUOI?

Animation

Enfin, je veux poser la question de savoir comment je gérerais l'animation dans un ECS pur. Actuellement, j'ai défini une animation comme un foncteur qui manipule une entité en fonction d'une progression entre 0 et 1. Le composant d'animation a une liste d'animateurs qui a une liste d'animations. Dans sa fonction de mise à jour, il applique ensuite toutes les animations actuellement actives à l'entité.

Remarque:

Je viens de lire cet article Est-ce que l'objet d'architecture Entity Component System est orienté par définition? ce qui explique le problème un peu mieux que moi. Bien qu'étant essentiellement sur le même sujet, il ne donne toujours aucune réponse quant à la raison pour laquelle l'approche des données pures est meilleure.


1
Peut-être une question simple mais sérieuse: connaissez-vous les avantages / inconvénients des ECS? Cela explique principalement le «pourquoi».
Caramiriel le

Eh bien, je comprends l'avantage d'utiliser des composants, c'est-à-dire la composition plutôt que l'héritage, pour éviter le diamant de la mort grâce à l'héritage multiple ect. L'utilisation de composants permet également de manipuler le comportement au moment de l'exécution. Et ils sont modulaires. Ce que je ne comprends pas, c'est pourquoi la division des données et des fonctions est souhaitée. Mon implémentation actuelle est sur github github.com/AdrianKoch3010/MarsBaseProject
Adrian Koch

Eh bien, je n'ai pas suffisamment d'expérience avec ECS pour ajouter une réponse complète. Mais la composition n'est pas seulement utilisée pour éviter le DoD; vous pouvez également créer des entités (uniques) au moment de l'exécution qui sont (plus) difficiles à générer à l'aide d'une approche OO. Cela dit, la division des données / procédures permet de raisonner plus facilement sur les données. Vous pouvez implémenter facilement la sérialisation, l'état d'enregistrement, l'annulation / la restauration et des choses de ce genre. Comme il est facile de raisonner sur les données, il est également plus facile de les optimiser. Vous pouvez très probablement diviser les entités en lots (multithreading) ou même les décharger sur un autre matériel pour atteindre son plein potentiel.
Caramiriel

"Il peut y avoir un composant de rendu d'image-objet qui remplace la méthode de dessin virtuel du composant de rendu." Je dirais que ce n'est plus ECS si vous le faites / l'exigez.
wonderra

Réponses:


10

Ceci est une question difficile. Je vais juste essayer de répondre à certaines des questions basées sur mes expériences particulières (YMMV):

Les composants doivent accéder aux données des autres composants. Par exemple, la méthode de dessin du composant de rendu doit accéder à la position du composant de transformation. Cela crée des dépendances dans le code.

Ne sous-estimez pas la quantité et la complexité (pas le degré) de couplage / dépendances ici. Vous pourriez être en train de regarder la différence entre cela (et ce diagramme est déjà ridiculement simplifié à des niveaux semblables à des jouets, et l'exemple du monde réel aurait des interfaces entre les deux pour desserrer le couplage):

entrez la description de l'image ici

... et ça:

entrez la description de l'image ici

... ou ca:

entrez la description de l'image ici

Les composants peuvent être polymorphes, ce qui introduit en outre une certaine complexité. Par exemple, un composant de rendu d'image-objet peut remplacer la méthode de dessin virtuel du composant de rendu.

Donc? L'équivalent analogique (ou littéral) d'une répartition virtuelle et virtuelle peut être invoqué via le système plutôt que l'objet cachant son état / données sous-jacent. Le polymorphisme est encore très pratique et réalisable avec l'implémentation ECS "pure" lorsque la table analogique ou le ou les pointeurs de fonction se transforment en "données" de toutes sortes pour que le système puisse les invoquer.

Étant donné que le comportement polymorphe (par exemple pour le rendu) doit être implémenté quelque part, il est juste externalisé dans les systèmes. (par exemple, le système de rendu de sprite crée un noeud de rendu de sprite qui hérite du noeud de rendu et l'ajoute au moteur de rendu)

Donc? J'espère que cela ne se présente pas comme un sarcasme (ce n'est pas mon intention bien que j'en ai été souvent accusé mais je souhaite que je puisse mieux communiquer les émotions par le biais du texte), mais le comportement polymorphe "d'externalisation" dans ce cas n'engendre pas nécessairement un supplément coût à la productivité.

La communication entre les systèmes peut être difficile à éviter. Par exemple, le système de collision peut avoir besoin de la boîte englobante qui est calculée à partir de n'importe quel composant de rendu concret.

Cet exemple me semble particulièrement bizarre. Je ne sais pas pourquoi un moteur de rendu renverrait des données sur la scène (je considère généralement les moteurs de rendu en lecture seule dans ce contexte), ou pour qu'un moteur de rendu détermine les AABB au lieu d'un autre système pour le faire à la fois pour le moteur de rendu et collision / physique (je suis peut-être accroché au nom du "composant de rendu" ici). Pourtant, je ne veux pas trop m'attarder sur cet exemple car je me rends compte que ce n'est pas le point que vous essayez de faire valoir. Néanmoins, la communication entre les systèmes (même sous la forme indirecte de lecture / écriture dans la base de données centrale ECS avec des systèmes dépendant plutôt directement des transformations effectuées par d'autres) ne devrait pas avoir besoin d'être fréquente, si nécessaire. Cette'

Cela peut entraîner des problèmes si l'ordre d'appel des fonctions de mise à jour du système n'est pas défini.

Cela doit absolument être défini. L'ECS n'est pas la solution ultime pour réorganiser l'ordre d'évaluation du traitement du système de chaque système possible dans la base de code et renvoyer exactement le même type de résultats à l'utilisateur final traitant des trames et des FPS. C'est l'une des choses, lors de la conception d'un ECS, que je suggérerais au moins fortement devrait être anticipée quelque peu à l'avance (bien qu'avec beaucoup de marge de manœuvre pour pardonner de changer d'avis plus tard, à condition que cela ne modifie pas les aspects les plus critiques de la commande de invocation / évaluation du système).

Cependant, recalculer l'intégralité du tilmap à chaque image est coûteux. Par conséquent, une liste serait nécessaire pour garder une trace de toutes les modifications apportées pour ensuite les mettre à jour dans le système. De la manière OOP, cela pourrait être encapsulé par le composant de carte de tuiles. Par exemple, la méthode SetTile () mettrait à jour le tableau de sommets chaque fois qu'il est appelé.

Je n'ai pas bien compris celui-ci, sauf qu'il s'agit d'une préoccupation axée sur les données. Et il n'y a aucun piège quant à la représentation et au stockage des données dans un ECS, y compris la mémorisation, pour éviter de tels pièges de performances (les plus grands avec un ECS ont tendance à se rapporter à des choses comme les systèmes interrogeant les instances disponibles de types de composants particuliers, ce qui est l'un des les aspects les plus difficiles de l’optimisation d’un ECS généralisé). Le fait que la logique et les données soient séparées dans un ECS "pur" ne signifie pas que vous devez soudainement recalculer des choses que vous auriez pu autrement mettre en cache / mémoriser dans une représentation OOP. C'est un point théorique / non pertinent à moins que je ne passe sous silence quelque chose de très important.

Avec l'ECS «pur», vous pouvez toujours stocker ces données dans le composant de carte de tuiles. La seule différence majeure est que la logique de mise à jour de ce tableau de sommets se déplacerait quelque part vers un système.

Vous pouvez même vous appuyer sur l'ECS pour simplifier l'invalidation et la suppression de ce cache de l'entité si vous créez un composant distinct comme TileMapCache. À ce stade, lorsque le cache est souhaité mais n'est pas disponible dans une entité avec un TileMapcomposant, vous pouvez le calculer et l'ajouter. Lorsqu'il est invalidé ou n'est plus nécessaire, vous pouvez le supprimer via l'ECS sans avoir à écrire plus de code spécifiquement pour une telle invalidation et suppression.

Les dépendances entre les composants existent toujours bien qu'elles soient cachées dans les systèmes

Il n'y a pas de dépendance entre les composants d'un représentant "pur" (je ne pense pas qu'il soit tout à fait juste de dire que les dépendances sont cachées ici par les systèmes). Les données ne dépendent pas des données, pour ainsi dire. La logique dépend de la logique. Et un ECS «pur» a tendance à promouvoir la logique à écrire de manière à dépendre du sous-ensemble minimal absolu de données et de logique (souvent aucun) qu'un système nécessite pour fonctionner, contrairement à de nombreuses alternatives qui encouragent souvent en fonction beaucoup plus de fonctionnalités que nécessaire pour la tâche réelle. Si vous utilisez le droit ECS pur, l'une des premières choses que vous devriez apprécier est les avantages du découplage tout en remettant en question simultanément tout ce que vous avez appris à apprécier dans la POO sur l'encapsulation et plus précisément la dissimulation d'informations.

Par découplage, j'entends spécifiquement le peu d'informations dont vos systèmes ont besoin pour fonctionner. Votre système de mouvement n'a même pas besoin de connaître quelque chose de beaucoup plus complexe comme un Particleou Character(le développeur du système n'a même pas nécessairement besoin de savoir que de telles idées d'entités existent même dans le système). Il a juste besoin de connaître les données minimales nues comme un composant de position qui pourrait être aussi simple que quelques flottants dans une structure. C'est encore moins d'informations et de dépendances externes que ce qu'une interface pure a IMotiontendance à emporter. C'est principalement en raison de cette connaissance minimale que chaque système nécessite de travailler, ce qui rend l'EC souvent indulgent pour gérer les changements de conception très imprévus avec le recul sans faire face à des ruptures d'interface en cascade partout.

L'approche «impure» que vous suggérez diminue quelque peu cet avantage puisque maintenant votre logique n'est pas strictement localisée aux systèmes où les changements ne provoquent pas de ruptures en cascade. La logique serait désormais centralisée dans une certaine mesure dans les composants accessibles par plusieurs systèmes qui doivent désormais répondre aux exigences d'interface de tous les différents systèmes qui pourraient l'utiliser, et maintenant, c'est comme si chaque système avait alors besoin de connaître (dépendre) de plus les informations dont il a strictement besoin pour travailler avec ce composant.

Dépendances des données

L'un des éléments controversés de l'ECS est qu'il tend à remplacer ce qui pourrait autrement être des dépendances à des interfaces abstraites avec uniquement des données brutes, et cela est généralement considéré comme une forme de couplage moins souhaitable et plus stricte. Mais dans les types de domaines comme les jeux où ECS peut être très bénéfique, il est souvent plus facile de concevoir la représentation des données à l'avance et de la maintenir stable que de concevoir ce que vous pouvez faire avec ces données à un niveau central du système. C'est quelque chose que j'ai douloureusement observé même chez les vétérans chevronnés dans des bases de code qui utilisent davantage une approche d'interface pure de style COM avec des choses comme IMotion.

Les développeurs ont continué à trouver des raisons d'ajouter, de supprimer ou de modifier des fonctions à cette interface centrale, et chaque changement était effroyable et coûteux car il aurait tendance à casser chaque classe implémentée IMotionainsi que chaque place dans le système utilisé IMotion. Pendant ce temps, avec tant de changements douloureux et en cascade, les objets mis en œuvre ne IMotionfaisaient que stocker une matrice 4x4 de flotteurs et toute l'interface se souciait simplement de savoir comment transformer et accéder à ces flotteurs; la représentation des données était stable depuis le début, et beaucoup de douleur aurait pu être évitée si cette interface centralisée, si susceptible de changer avec des besoins de conception imprévus, n'existait même pas en premier lieu.

Tout cela peut sembler presque aussi dégoûtant que les variables globales, mais la nature de la façon dont ECS organise ces données en composants récupérés explicitement par type via les systèmes le rend ainsi, tandis que les compilateurs ne peuvent rien imposer comme la dissimulation d'informations, les endroits qui accèdent et mutent les données sont généralement très explicites et suffisamment évidentes pour maintenir efficacement les invariants et prédire le type de transformations et d'effets secondaires qui se produisent d'un système à l'autre (en fait, d'une manière qui peut sans doute être plus simple et plus prévisible que la POO dans certains domaines, étant donné le système se transforme en une sorte de pipeline plat).

entrez la description de l'image ici

Enfin, je veux poser la question de savoir comment je gérerais l'animation dans un ECS pur. Actuellement, j'ai défini une animation comme un foncteur qui manipule une entité en fonction d'une progression entre 0 et 1. Le composant d'animation a une liste d'animateurs qui a une liste d'animations. Dans sa fonction de mise à jour, il applique ensuite toutes les animations actuellement actives à l'entité.

Nous sommes tous pragmatiques ici. Même dans gamedev, vous aurez probablement des idées / réponses contradictoires. Même l'ECS le plus pur est un phénomène relativement nouveau, un territoire pionnier, pour lequel les gens n'ont pas nécessairement formulé les opinions les plus fortes sur la façon de dépouiller les chats. Ma réaction instinctive est un système d'animation qui incrémente ce type de progression d'animation dans les composants animés pour que le système de rendu s'affiche, mais cela ignore tellement de nuances pour l'application et le contexte particuliers.

Avec l'ECS, ce n'est pas une solution miracle et je me retrouve toujours avec des tendances à entrer et à ajouter de nouveaux systèmes, à en supprimer, à ajouter de nouveaux composants, à changer un système existant pour récupérer ce nouveau type de composant, etc. tout va bien du premier coup. Mais la différence dans mon cas est que je ne change rien de central lorsque je n'arrive pas à anticiper certains besoins de conception dès le départ. Je n'obtiens pas l'effet d'entraînement des ruptures en cascade qui m'obligent à aller partout et à changer tellement de code pour gérer un nouveau besoin qui surgit, et c'est tout à fait un gain de temps. Je trouve également cela plus facile pour mon cerveau parce que lorsque je m'assois avec un système particulier, je n'ai pas besoin de savoir / me souvenir de beaucoup d'autres choses que les composants pertinents (qui ne sont que des données) pour y travailler.

En utilisant notre site, vous reconnaissez avoir lu et compris notre politique liée aux cookies et notre politique de confidentialité.
Licensed under cc by-sa 3.0 with attribution required.