Je sais que lors de la création d'applications (natives ou Web) telles que celles de l'AppStore Apple ou de l'App Store Google Play, il est très courant d'utiliser une architecture Model-View-Controller.
Cependant, est-il raisonnable de créer également des applications en utilisant l'architecture Component-Entity-System commune aux moteurs de jeu?
design-patterns
architecture
mvc
game-development
applications
Andrew De Andrade
la source
la source
Réponses:
Pour moi, absolument. Je travaille dans les effets visuels et j'ai étudié une grande variété de systèmes dans ce domaine, leurs architectures (y compris CAD / CAM), avide de SDK et de tous les papiers qui me donneraient une idée des avantages et des inconvénients des décisions architecturales apparemment infinies qui pourrait être fait, même les plus subtils n’ont pas toujours un impact subtil.
Les effets visuels sont assez similaires aux jeux dans la mesure où il existe un concept central de "scène", avec des fenêtres qui affichent les résultats rendus. Il y a aussi souvent beaucoup de traitement en boucle centrale qui tourne constamment autour de cette scène dans des contextes d'animation, où il peut y avoir de la physique, des émetteurs de particules engendrant des particules, des mailles animées et rendues, des animations de mouvement, etc., et finalement pour les rendre tout à l'utilisateur à la fin.
Un autre concept similaire à des moteurs de jeu au moins très complexes était la nécessité d'un aspect "concepteur" où les concepteurs pouvaient concevoir des scènes de manière flexible, y compris la possibilité de faire leur propre programmation légère (scripts et nœuds).
Au fil des ans, j'ai trouvé qu'ECS était le mieux adapté. Bien sûr, cela n'est jamais complètement dissocié de la subjectivité, mais je dirais que cela semble fortement poser le moins de problèmes. Cela a résolu beaucoup plus de problèmes majeurs avec lesquels nous luttions toujours, tout en ne nous donnant que quelques nouveaux problèmes mineurs en retour.
POO traditionnel
Des approches POO plus traditionnelles peuvent être très efficaces lorsque vous avez une bonne compréhension des exigences de conception dès le départ, mais pas des exigences de mise en œuvre. Que ce soit par une approche à interfaces multiples plus plate ou une approche ABC hiérarchique plus imbriquée, elle tend à cimenter la conception et à la rendre plus difficile à changer tout en rendant l'implémentation plus facile et plus sûre à changer. Il y a toujours un besoin d'instabilité dans tout produit qui dépasse une seule version, donc les approches OOP ont tendance à biaiser la stabilité (difficulté de changement et manque de raisons de changement) vers le niveau de conception, et l'instabilité (facilité de changement et raisons de changement) au niveau de la mise en œuvre.
Cependant, face à l'évolution des besoins des utilisateurs, la conception et la mise en œuvre peuvent devoir changer fréquemment. Vous pourriez trouver quelque chose de bizarre comme un fort besoin de l'utilisateur final pour la créature analogique qui doit être à la fois végétale et animale en même temps, invalidant complètement le modèle conceptuel entier que vous avez construit. Les approches orientées objet normales ne vous protègent pas ici, et peuvent parfois rendre ces changements imprévus et révolutionnaires encore plus difficiles. Lorsque des domaines très critiques sont impliqués, les raisons des modifications de conception se multiplient encore.
La combinaison de plusieurs interfaces granulaires pour former l'interface conforme d'un objet peut beaucoup aider à stabiliser le code client, mais cela n'aide pas à stabiliser les sous-types qui pourraient parfois éclipser le nombre de dépendances client. Vous pouvez avoir une interface utilisée par seulement une partie de votre système, par exemple, mais avec mille sous-types différents implémentant cette interface. Dans ce cas, le maintien des sous-types complexes (complexes parce qu'ils ont de nombreuses responsabilités d'interface disparates à remplir) peut devenir le cauchemar plutôt que le code les utilisant via une interface. La POO tend à transférer la complexité au niveau objet, tandis qu'ECS la transfère au niveau client ("systèmes"), et cela peut être idéal lorsqu'il y a très peu de systèmes mais tout un tas "d'objets" ("entités") conformes.
Une classe possède également ses données de manière privée et peut donc maintenir elle-même des invariants. Néanmoins, il existe des invariants "grossiers" qui peuvent en fait encore être difficiles à maintenir lorsque les objets interagissent les uns avec les autres. Pour qu'un système complexe dans son ensemble soit dans un état valide, il faut souvent considérer un graphe complexe d'objets, même si leurs invariants individuels sont correctement maintenus. Les approches traditionnelles de style POO peuvent aider à maintenir des invariants granulaires, mais peuvent en fait rendre difficile le maintien d'invariants larges et grossiers si les objets se concentrent sur de minuscules facettes du système.
C'est là que ces types d'approches ou de variantes ECS de construction de blocs lego peuvent être si utiles. De plus, les systèmes étant de conception plus grossière que l'objet habituel, il devient plus facile de maintenir ces types d'invariants grossiers à la vue plongeante du système. De nombreuses interactions d'objets minuscules se transforment en un grand système se concentrant sur une tâche large au lieu de petits objets minuscules se concentrant sur de petites tâches minuscules avec un graphique de dépendance qui couvrirait un kilomètre de papier.
Pourtant, je devais regarder en dehors de mon domaine, dans l'industrie du jeu, pour en savoir plus sur ECS, bien que j'aie toujours été d'un état d'esprit orienté données. Aussi, assez drôle, j'ai presque fait mon chemin vers ECS tout simplement en itérant et en essayant de trouver de meilleurs designs. Je n'ai cependant pas fait tout le chemin et j'ai raté un détail très crucial, à savoir la formalisation de la partie "systèmes", et l'écrasement des composants jusqu'aux données brutes.
J'essaierai de passer en revue comment j'ai fini par m'installer sur ECS, et comment cela a fini par résoudre tous les problèmes avec les itérations de conception précédentes. Je pense que cela aidera à souligner exactement pourquoi la réponse ici pourrait être un «oui» très fort, que ECS est potentiellement applicable bien au-delà de l'industrie du jeu.
Architecture de la force brute des années 80
La première architecture sur laquelle j'ai travaillé dans l'industrie des effets visuels avait un long héritage qui dépassait déjà une décennie depuis que j'ai rejoint l'entreprise. C'était du codage brut C brut par force (pas une inclinaison sur C, comme j'adore C, mais la façon dont il était utilisé ici était vraiment grossière). Une tranche miniature et simpliste ressemblait à des dépendances comme celle-ci:
Et ceci est un diagramme extrêmement simplifié d'une toute petite partie du système. Chacun de ces clients dans le diagramme ("Rendu", "Physique", "Mouvement") obtiendrait un objet "générique" à travers lequel ils vérifieraient un champ de type, comme ceci:
Bien sûr, avec un code beaucoup plus laid et plus complexe que cela. Souvent, des fonctions supplémentaires sont appelées à partir de ces boîtiers de commutateurs, ce qui permet de commuter récursivement le commutateur encore et encore et encore. Ce diagramme et ce code pourraient presque ressembler à ECS-lite, mais il n'y avait pas de forte distinction entité-composant (" cet objet est- il une caméra?", Pas "cet objet fournit-il du mouvement?"), Et aucune formalisation du "système" ( juste un tas de fonctions imbriquées allant partout et mélangeant les responsabilités). Dans ce cas, à peu près tout était compliqué, toute fonction était un potentiel de catastrophe en attente de se produire.
Notre procédure de test ici devait souvent vérifier des choses comme des maillages séparés des autres types d'éléments, même si la même chose arrivait aux deux, car la nature de la force brute du codage ici (souvent accompagnée de beaucoup de copier-coller) était souvent faite il est très probable que ce qui est autrement la même logique exacte pourrait échouer d'un type d'élément à l'autre. Essayer d'étendre le système pour gérer de nouveaux types d'articles était assez désespéré, même s'il y avait un besoin fortement exprimé de la part de l'utilisateur, car c'était trop difficile lorsque nous nous débattions autant pour gérer les types d'articles existants.
Quelques pros:
Quelques inconvénients:
Architecture COM des années 1990
La plupart de l'industrie des effets visuels utilise ce style d'architecture d'après ce que j'ai rassemblé, lisant des documents sur leurs décisions de conception et jetant un œil à leurs kits de développement logiciel.
Ce n'est peut-être pas exactement COM au niveau ABI (certaines de ces architectures ne peuvent avoir que des plugins écrits en utilisant le même compilateur), mais partage beaucoup de caractéristiques similaires avec les requêtes d'interface effectuées sur les objets pour voir quelles interfaces leurs composants prennent en charge.
Avec ce type d'approche, la
transform
fonction analogique ci-dessus est venue ressembler à cette forme:C'est l'approche sur laquelle la nouvelle équipe de cette ancienne base de code a opté, pour éventuellement refactoriser. Et ce fut une amélioration spectaculaire par rapport à l'original en termes de flexibilité et de maintenabilité, mais il y avait encore quelques problèmes que je couvrirai dans la section suivante.
Quelques pros:
Quelques inconvénients:
IMotion
auront toujours exactement le même état et exactement la même implémentation pour toutes les fonctions. Pour atténuer cela, nous commencerions à centraliser les classes de base et les fonctionnalités d'assistance dans tout le système pour les choses qui auraient tendance à être implémentées de manière redondante de la même manière pour la même interface, et éventuellement avec un héritage multiple en cours derrière le capot, mais c'était assez malpropre sous le capot même si le code client le rendait facile.QueryInterface
fonction de base apparaissait presque toujours comme un point chaud moyen à supérieur, et parfois même le point chaud n ° 1. Pour atténuer cela, nous ferions des choses comme le rendu des parties du cache de la base de code une liste d'objets déjà connus pour prendre en chargeIRenderable
, mais cela a considérablement accru la complexité et les coûts de maintenance. De même, cela a été plus difficile à mesurer, mais nous avons remarqué des ralentissements certains par rapport au codage de style C que nous faisions auparavant lorsque chaque interface nécessitait une répartition dynamique. Des choses comme les erreurs de prédiction des branches et les obstacles à l'optimisation sont difficiles à mesurer en dehors d'une petite facette du code, mais les utilisateurs ont généralement remarqué la réactivité de l'interface utilisateur et des choses de ce genre qui empirent en comparant côte à côte les versions précédentes et plus récentes du logiciel. côté pour les zones où la complexité algorithmique n'a pas changé, seules les constantes.Réponse pragmatique: composition
L'une des choses que nous remarquions avant (ou du moins j'étais) qui causait des problèmes était qu'elle
IMotion
pouvait être implémentée par 100 classes différentes mais avec exactement la même implémentation et le même état associés. De plus, il ne serait utilisé que par une poignée de systèmes comme le rendu, le mouvement d'images clés et la physique.Donc, dans un tel cas, nous pourrions avoir comme une relation 3 à 1 entre les systèmes utilisant l'interface à l'interface, et une relation 100 à 1 entre les sous-types implémentant l'interface à l'interface.
La complexité et la maintenance seraient alors considérablement biaisées par la mise en œuvre et la maintenance de 100 sous-types, au lieu de 3 systèmes clients qui en dépendent
IMotion
. Cela a déplacé toutes nos difficultés de maintenance vers la maintenance de ces 100 sous-types, pas les 3 endroits utilisant l'interface. Mise à jour de 3 emplacements dans le code avec peu ou pas de "couplages efférents indirects" (comme dans les dépendances mais indirectement via une interface, pas une dépendance directe), pas de problème: mise à jour de 100 emplacements de sous-types avec une cargaison de "couplages efférents indirects" , assez gros problème *.J'ai donc dû pousser fort mais j'ai proposé d'essayer de devenir un peu plus pragmatique et de relâcher toute l'idée de "pure interface". Cela n'avait aucun sens pour moi de faire quelque chose de
IMotion
complètement abstrait et d'apatride à moins que nous ne voyions un avantage à avoir une riche variété d'implémentations. Dans notre cas,IMotion
avoir une riche variété d'implémentations se transformerait en fait en un véritable cauchemar de maintenance, car nous ne voulions pas de variété. Au lieu de cela, nous essayions de créer une implémentation en un seul mouvement qui soit vraiment bonne contre les exigences changeantes du client, et nous travaillions souvent autour de l'idée d'interface pure en essayant de forcer chaque implémenteurIMotion
à utiliser la même implémentation et le même état associé afin que nous ne le fassions pas. t des objectifs en double.Les interfaces sont ainsi devenues plus comme de larges
Behaviors
associés à une entité.IMotion
deviendrait simplement unMotion
"composant" (j'ai changé la façon dont nous avons défini le "composant" loin de COM à un qui est plus proche de la définition habituelle, d'une pièce constituant une entité "complète").Au lieu de cela:
Nous l'avons transformé en quelque chose de plus comme ceci:
Il s'agit d'une violation flagrante du principe d'inversion de dépendance pour commencer à passer de l'abstrait au concret, mais pour moi, un tel niveau d'abstraction n'est utile que si nous pouvons prévoir un réel besoin dans un avenir futur, hors de tout doute raisonnable et non exercer des scénarios ridicules «et si» complètement détachés de l'expérience utilisateur (ce qui nécessiterait probablement un changement de conception de toute façon), pour une telle flexibilité.
Nous avons donc commencé à évoluer vers cette conception.
QueryInterface
est devenu plus commeQueryBehavior
. De plus, il a commencé à sembler inutile d'utiliser l'héritage ici. Nous avons plutôt utilisé la composition. Les objets se sont transformés en une collection de composants dont la disponibilité pouvait être interrogée et injectée lors de l'exécution.Quelques pros:
Motion
implémentation très centrale et évidente , par exemple, et non réparties sur une centaine de sous-types.Quelques inconvénients:
Un phénomène qui s'est produit est que, puisque nous avons perdu l'abstraction sur ces composants comportementaux, nous en avons eu plus. Par exemple, au lieu d'un
IRenderable
composant abstrait , nous attacherions un objet avec un bétonMesh
ou unPointSprites
composant. Le système de rendu saurait comment effectuer le renduMesh
et lesPointSprites
composants et trouverait les entités qui fournissent ces composants et les dessinent. À d'autres moments, nous avions divers rendus telsSceneLabel
que nous avons découvert que nous avions besoin avec le recul, et nous avons donc attaché unSceneLabel
dans ces cas à des entités pertinentes (éventuellement en plus d'unMesh
). L'implémentation du système de rendu serait ensuite mise à jour pour savoir comment rendre les entités qui les fournissaient, et c'était une modification assez facile à effectuer.Dans ce cas, une entité composée de composants pourrait également être utilisée comme composant d'une autre entité. Nous construirions les choses de cette façon en connectant des blocs lego.
ECS: Systèmes et composants de données brutes
Ce dernier système était pour autant que je l'ai fait moi-même, et nous le bâtissions encore avec COM. J'avais l'impression de vouloir devenir un système à composants d'entité, mais je ne le connaissais pas à l'époque. Je regardais autour d'exemples de style COM qui saturaient mon domaine, alors que j'aurais dû regarder les moteurs de jeu AAA pour une inspiration architecturale. J'ai finalement commencé à faire ça.
Ce qui me manquait, c'était plusieurs idées clés:
J'ai finalement quitté cette entreprise et commencé à travailler sur un ECS en tant qu'indy (toujours en travaillant dessus tout en drainant mes économies), et c'est de loin le système le plus facile à gérer.
Ce que j'ai remarqué avec l'approche ECS, c'est qu'elle a résolu les problèmes avec lesquels je luttais encore ci-dessus. Plus important pour moi, c'était comme si nous gérions des «villes» de taille saine au lieu de petits villages avec des interactions complexes. Ce n'était pas aussi difficile à entretenir qu'une "mégalopole" monolithique, trop grande dans sa population pour être gérée efficacement, mais n'était pas aussi chaotique qu'un monde rempli de minuscules petits villages interagissant les uns avec les autres où il suffit de penser aux routes commerciales entre eux formait un graphique cauchemardesque. ECS a distillé toute la complexité vers des "systèmes" volumineux, comme un système de rendu, une "ville" de taille saine mais pas une "mégalopole surpeuplée".
Les composants devenant des données brutes me semblaient vraiment étranges au début, car cela brise même le principe de masquage des informations de base de la POO. C'était une sorte de remise en question de l'une des plus grandes valeurs que je chérissais au sujet de la POO, qui était sa capacité à maintenir les invariants qui nécessitaient l'encapsulation et la dissimulation d'informations. Mais cela a commencé à devenir un problème, car il est rapidement devenu évident ce qui se passait avec une douzaine de systèmes larges transformant ces données au lieu d'une telle logique dispersée entre des centaines et des milliers de sous-types mettant en œuvre une combinaison d'interfaces. J'ai tendance à y penser comme toujours dans un style OOP, sauf si les systèmes fournissent la fonctionnalité et la mise en œuvre qui accèdent aux données, les composants fournissent les données et les entités fournissent des composants.
Il est devenu encore plus facile , contre-intuitif, de raisonner sur les effets secondaires causés par le système alors qu'il n'y avait qu'une poignée de systèmes volumineux transformant les données en larges passes. Le système est devenu beaucoup plus plat, mes piles d'appels sont devenues moins profondes que jamais pour chaque thread. Je pourrais penser au système à ce niveau de surveillant et ne pas rencontrer de surprises étranges.
De même, il a simplifié même les domaines critiques en termes de performances en ce qui concerne l'élimination de ces requêtes. L'idée de "système" étant devenue très formalisée, un système pouvait souscrire aux composants qui l'intéressaient et se contenter de lui remettre une liste en cache d'entités répondant à ces critères. Chaque individu n'a pas eu à gérer cette optimisation de la mise en cache, elle est devenue centralisée dans un seul endroit.
Quelques pros:
Quelques inconvénients:
Donc, de toute façon, je dirais absolument "oui", avec mon exemple VFX personnel étant un bon candidat. Mais cela reste assez similaire aux besoins du jeu.
Je ne l'ai pas mis en pratique dans des zones plus éloignées complètement déconnectées des préoccupations des moteurs de jeu (les effets visuels sont assez similaires), mais il me semble que beaucoup plus de zones sont de bons candidats pour une approche ECS. Peut-être même qu'un système GUI conviendrait à un, mais j'utilise toujours une approche plus OOP là-bas (mais sans héritage profond contrairement à Qt, par exemple).
C'est un territoire largement inexploré, mais il me semble approprié chaque fois que vos entités peuvent être composées d'une riche combinaison de «traits» (et exactement quel combo de traits qu'ils fournissent étant toujours sujet à changement), et où vous avez une poignée de généralisés systèmes qui traitent des entités qui ont les traits nécessaires.
Il devient une alternative très pratique dans ces cas à tout scénario où vous pourriez être tenté d'utiliser quelque chose comme l'héritage multiple ou une émulation du concept (mixins, par exemple) uniquement pour produire des centaines ou plus de combos dans une hiérarchie d'héritage profonde ou des centaines de combos de classes dans une hiérarchie plate implémentant un combo spécifique d'interfaces, mais où vos systèmes sont peu nombreux (des dizaines, par exemple).
Dans ces cas, la complexité de la base de code commence à se sentir plus proportionnelle au nombre de systèmes au lieu du nombre de combinaisons de types, car chaque type n'est plus qu'une entité composant des composants qui ne sont rien de plus que des données brutes. Les systèmes GUI s'adaptent naturellement à ces types de spécifications où ils peuvent avoir des centaines de types de widgets possibles combinés à partir d'autres types de base ou interfaces, mais seulement une poignée de systèmes pour les traiter (système de disposition, système de rendu, etc.). Si un système GUI utilisait ECS, il serait probablement beaucoup plus facile de raisonner sur l'exactitude du système lorsque toutes les fonctionnalités sont fournies par une poignée de ces systèmes au lieu de centaines de types d'objets différents avec des interfaces héritées ou des classes de base. Si un système GUI utilisait ECS, les widgets n'auraient aucune fonctionnalité, seulement des données. Seule la poignée de systèmes qui traitent des entités de widget aurait une fonctionnalité. La façon dont les événements remplaçables pour un widget seraient gérés me dépasse, mais sur la base de mon expérience limitée jusqu'à présent, je n'ai pas trouvé de cas où ce type de logique ne pourrait pas être transféré de manière centralisée vers un système donné d'une manière qui, dans avec le recul, a produit une solution beaucoup plus élégante que je ne m'attendais jamais.
J'adorerais le voir employé dans plus de domaines, car il m'a sauvé la vie. Bien sûr, cela ne convient pas si votre conception ne se décompose pas de cette façon, des entités agrégeant des composants aux systèmes grossiers qui traitent ces composants, mais s'ils correspondent naturellement à ce type de modèle, c'est la chose la plus merveilleuse que j'ai jamais rencontrée. .
la source
L'architecture Component-Entity-System pour les moteurs de jeu fonctionne pour les jeux en raison de la nature du logiciel de jeu, de ses caractéristiques uniques et de ses exigences de qualité. Par exemple, les entités fournissent un moyen uniforme d'adresser et de travailler avec les éléments du jeu, qui peuvent être radicalement différents dans leur objectif et leur utilisation, mais qui doivent être rendus, mis à jour ou sérialisés / désérialisés par le système de manière uniforme. En incorporant un modèle de composant dans cette architecture, vous leur permettez de conserver une structure de base simple, tout en ajoutant plus de fonctionnalités et de fonctionnalités selon les besoins, avec un faible couplage de code. Il existe un certain nombre de systèmes logiciels différents qui pourraient bénéficier des caractéristiques de cette conception, tels que les applications de CAO, les codecs A / V,
TL; DR - Les modèles de conception ne fonctionnent bien que lorsque le domaine problématique est suffisamment adapté aux fonctionnalités et aux inconvénients qu'ils imposent à la conception.
la source
Si le domaine problématique lui convient bien, certainement.
Mon travail actuel implique une application qui doit prendre en charge une variété de capacités en fonction d'un tas de facteurs d'exécution. L'utilisation d'entités basées sur des composants pour découpler toutes ces capacités et permettre l'extensibilité et la testabilité de manière isolée a été idyllique pour nous.
edit: Mon travail consiste à fournir une connectivité à du matériel propriétaire (en C #). Selon le facteur de forme du matériel, le micrologiciel installé, le niveau de service que le client a acheté, etc., etc., nous devons fournir différents niveaux de fonctionnalités à l'appareil. Même certaines fonctionnalités qui ont la même interface ont des implémentations différentes selon la version de l'appareil.
Les bases de code précédentes ici ont eu des interfaces très larges avec beaucoup non implémentées. Certains ont eu de nombreuses interfaces minces qui ont ensuite été composées statiquement dans une classe bestiale. Certains ont simplement utilisé des dictionnaires string -> string pour le modéliser. (nous avons de nombreux départements qui pensent tous pouvoir faire mieux)
Tout cela a ses défauts. Les interfaces larges sont une douleur et demie pour se moquer / tester efficacement. Ajouter de nouvelles fonctionnalités signifie changer l'interface publique (et toutes les implémentations existantes). De nombreuses interfaces minces ont conduit à un code très moche, mais depuis que nous avons fini par contourner un gros objet gras, les tests ont encore souffert. De plus, les interfaces fines ne géraient pas bien leurs dépendances. Les dictionnaires de chaînes ont les problèmes habituels d'analyse et d'existence ainsi que les trous infernaux de performance, de lisibilité et de maintenabilité.
Ce que nous utilisons maintenant est une entité très mince dont les composants ont été découverts et composés en fonction des informations d'exécution. Les dépendances sont effectuées de manière déclarative et auto-résolues par le framework de composants de base. Les composants eux-mêmes peuvent être testés de manière isolée car ils fonctionnent directement avec leurs dépendances et les problèmes avec les dépendances manquantes sont détectés tôt - et dans un seul endroit plutôt que lors de la première utilisation de la dépendance. De nouveaux composants (ou tests) peuvent être déposés et aucun code existant n'est affecté par celui-ci. Les consommateurs demandent à l'entité une interface avec le composant, nous sommes donc libres de voir avec les différentes implémentations (et comment les implémentations sont mappées aux données d'exécution) avec une relative liberté.
Pour une situation comme celle-ci où la composition de l'objet et ses interfaces peuvent inclure un sous-ensemble (très varié) de composants communs, cela fonctionne très bien.
la source