web-dev-qa-db-fra.com

OOP ECS contre Pure ECS

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 dernier mois, j'ai beaucoup lu sur Entity-Component-Systems et je suis maintenant 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, c'est le ECO de style OOP (les composants sont des objets avec à la fois un état et un comportement qui encapsulent les données qui leur sont spécifiques) vs ECS pur (les composants sont des structures de style c qui seules les 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 OOP

  • 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. Il peut y avoir un composant de rendu Sprite qui remplace 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 Sprite crée un nœud de rendu Sprite qui hérite du nœud 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 n'importe quel 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 dire aux systèmes ce qu'il faut 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. Dans 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 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 si 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é sur la base 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 ce post L'objet d'architecture Entity Component System est-il orienté par définition? ce qui explique le problème un peu mieux que moi. Bien qu'étant fondamentalement 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.

11
Adrian Koch

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):

enter image description here

... et ça:

enter image description here

... ou ca:

enter image description here

Les composants peuvent être polymorphes, ce qui introduit en outre une certaine complexité. Par exemple. Il peut y avoir un composant de rendu Sprite qui remplace 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 Sprite crée un nœud de rendu Sprite qui hérite du nœud 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 j'aimerais pouvoir mieux communiquer les émotions par le 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. Cela contredit une partie de ce que j'ai écrit immédiatement ci-dessous à propos de l'importance de déterminer l'ordre d'évaluation à l'avance, mais c'est avec des besoins pratiques de réponse de l'utilisateur plutôt que de "correction" (ce n'est pas nécessairement un problème de couplage temporel mais un problème de conception utilisateur garantissant la sortie des trames les derniers résultats sans être à la traîne).

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 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. Dans 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 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, 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 autrement pu mettre en mémoire cache/mémoriser dans une représentation OOP. C'est un sujet théorique/non pertinent) point à moins que je 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 composant TileMap, 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 aucune dépendance entre les composants dans 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 OOP 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 Particle ou 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 moins de dépendances externes que ce qu'une interface pure comme IMotion a tendance à emporter avec elle. 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 tels que 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 les 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 horrible et coûteux car il aurait tendance à casser chaque classe unique qui implémentait IMotion ainsi que chaque place depuis dans le système qui utilisait IMotion. Pendant ce temps, avec tant de changements douloureux et en cascade, les objets qui ont implémenté IMotion ne faisaient que stocker une matrice 4x4 de flotteurs et toute l'interface était uniquement préoccupée par la façon de transformer et d'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 quels types de transformations et d'effets secondaires 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 OOP dans certains domaines étant donné la façon dont le système se transforme en une sorte de pipeline plat).

enter image description here

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é sur la base 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 à l'avance. 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'autre chose que des composants pertinents (qui ne sont que des données) pour y travailler.

10
Dragon Energy