Dans un
article précédent, j'ai décrit les technologies et les approches que nous utilisons lors du développement d'un nouveau jeu de tir mobile rapide. Parce que c'était une revue et même un article superficiel - aujourd'hui, je vais approfondir et expliquer en détail pourquoi nous avons décidé d'écrire notre propre framework ECS et n'avons pas utilisé ceux existants. Il y aura des exemples de code et un petit bonus à la fin.

Qu'est-ce que l'ECS Ă titre d'exemple
J'ai déjà brièvement décrit ce qu'est Entity Component System, et il y a des articles sur Habré à propos d'ECS (en gros, cependant, des traductions d'articles - voir ma revue des plus intéressants d'entre eux à la fin de l'article, en bonus). Et aujourd'hui, je vais vous dire comment nous utilisons ECS - en utilisant notre exemple de code.
Le diagramme ci-dessus décrit l'essence du lecteur, ses composants et leurs données, ainsi que les systèmes qui fonctionnent avec le lecteur et ses composants. L'objet clé du diagramme est le joueur:
- peut se déplacer dans l'espace - Composants Transform et Movement , MoveSystem ;
- a une certaine santé et peut mourir - composant Health , Damage , DamageSystem ;
- après la mort apparaît au point de réapparition - le composant Transformer pour la position, le RespawnSystem ;
- peut être invulnérable - composant Invincible .
Nous décrivons cela avec un code. Tout d'abord, obtenons des interfaces pour les composants et les systèmes. Les composants peuvent avoir des méthodes auxiliaires communes, le système n'a qu'une seule méthode
Execute , qui reçoit l'état du monde à l'entrée pour le traitement:
public interface IComponent {
Pour les composants, nous créons des classes de stub qui sont utilisées par notre générateur de code pour les convertir en code de composant réellement utilisé. Obtenons quelques blancs pour
Health ,
Damage et
Invincible (pour le reste des composants, ce sera similaire).
[Component] public class Health { [Max(1000)]
Les composants déterminent l'état du monde, ils ne contiennent donc que des données, sans méthodes. En même temps, il n'y a pas de données dans
Invincible , elles sont utilisées en logique comme signe d'invulnérabilité - si l'essence du joueur a cette composante, alors le joueur est désormais invulnérable.
L'attribut
Component est utilisé par le générateur pour rechercher les classes vides pour les composants. Les
attributs Max et
DontSend sont nécessaires comme conseils lors de la sérialisation et de la réduction de la taille de l'état du monde transmis sur le réseau ou enregistrés sur le disque. Dans ce cas, le serveur ne sérialisera pas le champ
Montant et ne l'enverra pas sur le réseau (car les clients n'utilisent pas ce paramètre, il n'est nécessaire que sur le serveur). Et le champ
Hp peut être bien emballé en plusieurs bits, étant donné la valeur maximale de la santé.
Nous avons également une classe
préfabriquée Entity , où nous ajoutons des informations sur tous les composants possibles de toute entité, et le générateur va déjà créer une classe réelle à partir de celle-ci:
public class Entity { public Health Health; public Damage Damage; public Invincible Invincible;
Après cela, notre générateur créera le code des classes de composants
Health ,
Damage et
Invincible , qui sera déjà utilisé dans la logique du jeu:
public sealed class Health : IComponent { public int Hp; public void Reset() { Hp = default(int); }
Comme vous pouvez le voir, les données sont restées dans les classes et des méthodes ont été ajoutées, par exemple,
Réinitialiser . Il est nécessaire pour optimiser et réutiliser les composants dans les pools. D'autres méthodes auxiliaires ne contiennent pas de logique métier - je ne les donnerai pas par souci de concision.
Une classe sera également générée pour l'état du monde, qui contient une liste de tous les composants et entités:
public sealed class GameState {
Et enfin, le code généré pour
Entity :
public sealed class Entity { public uint Id;
La classe
Entity n'est essentiellement qu'un identifiant de composant. La référence aux objets du monde
GameState n'est utilisée que dans les méthodes auxiliaires pour la commodité de l'écriture du code logique métier. Connaissant l'identifiant d'un composant, nous pouvons l'utiliser pour sérialiser les relations entre entités, implémenter des liens dans les composants vers d'autres entités. Par exemple, le composant
Damage contient une référence à l'entité
Victime pour déterminer qui a été endommagé.
Ceci termine le code généré. En général, nous avons besoin d'un générateur pour ne pas écrire à chaque fois des méthodes auxiliaires. Nous décrivons uniquement les composants comme des données, puis le générateur fait tout le travail. Exemples de méthodes d'assistance:
- créer / supprimer des entités;
- ajouter / supprimer / copier un composant, y accéder s'il existe;
- comparer deux états du monde;
- sérialiser l'état du monde;
- compression delta;
- code d'une page Web ou d'une fenêtre Unity pour afficher l'état du monde, les entités, les composants (voir détails ci-dessous);
- et autres
Passons au code système. Ils définissent la logique métier. Par exemple, écrivons le code d'un système qui calcule les dommages subis par un joueur:
public sealed class DamageSystem : ISystem { void ISystem.Execute(GameState gs) { foreach (var damage in gs.Damages) { var invincible = damage.Victim.Invincible; if (invincible != null) continue; var health = damage.Victim.Health; if (health == null) continue; health.Hp -= damage.Amount; } } }
Le système passe par tous les composants de
dommages dans le monde et cherche Ă voir s'il y a un composant
invincible sur un joueur potentiellement endommagé (
victime ). S'il l'est, le joueur est invulnérable, les dégâts ne sont pas accumulés. Ensuite, nous obtenons le composant
Santé de la victime et réduisons la santé du joueur par la taille des dégâts.
Tenez compte des principales caractéristiques des systèmes:
- Un système est généralement une classe sans état, ne contient aucune donnée interne, n'essaie pas de l'enregistrer quelque part, à l'exception des données sur le monde transmises de l'extérieur.
- Les systèmes passent généralement par tous les composants d'un certain type et fonctionnent avec eux. Ils sont généralement appelés par le type de composant ( Damage → DamageSystem ) ou par l'action qu'ils effectuent ( RespawnSystem ).
- Le système implémente une fonctionnalité minimale. Par exemple, si nous allons plus loin, après l' exécution du DamageSystem, un autre RemoveDamageSystem supprimera tous les composants de Damage . Dans la coche suivante, un autre système ApplyDamage basé sur le tir du joueur peut à nouveau bloquer le composant Damage avec de nouveaux dégâts. Et puis le PlayerDeathSystem vérifiera la santé du joueur ( Health.Hp ) et, s'il est inférieur ou égal à 0, il détruira tous les composants du joueur à l'exception de Transform et ajoutera le composant Dead flag.
Au total, nous obtenons les classes suivantes et les relations entre elles:

Quelques faits sur ECS
ECS a ses avantages et ses inconvénients comme une approche du développement et une façon de représenter le monde du jeu, de sorte que chacun décide lui-même de l'utiliser ou non. Commençons par les pros:
- Composition versus héritage multiple. Dans le cas d'un héritage multiple, un tas de fonctionnalités inutiles peuvent être héritées. Dans le cas d'ECS, la fonctionnalité apparaît / disparaît lorsqu'un composant est ajouté / supprimé.
- Séparation de la logique et des données. La possibilité de changer la logique (changer de système, supprimer / ajouter des composants) sans casser les données. C'est-à -dire vous pouvez désactiver le groupe de systèmes responsable d'une certaine fonctionnalité à tout moment, tout le reste continuera à fonctionner et cela n'affectera pas les données.
- Le cycle de jeu est simplifié. Une mise à jour apparaît et l'ensemble du cycle est divisé en systèmes. Les données sont traitées par le «flux» dans le système, quel que soit le moteur (il n'y a pas des millions d'appels de mise à jour , comme dans Unity).
- Une entité ne sait pas quelles classes l'affectent (et ne doit pas savoir).
- Utilisation efficace de la mémoire . Cela dépend de la mise en œuvre d'ECS. Vous pouvez réutiliser les objets et composants d'entité créés à l'aide de pools; vous pouvez utiliser des types de valeurs pour les données et les stocker côte à côte en mémoire ( localité des données ).
- Il est plus facile de tester lorsque les données sont séparées de la logique. Surtout quand on considère que la logique est un petit système avec plusieurs lignes de code.
- Affichez et modifiez l'état du monde en temps réel . Parce que l'état du monde n'est que des données, nous avons écrit un outil qui affiche sur la page web tout l'état du monde dans un match sur le serveur (ainsi que la scène du match en 3D). Tout composant d'une entité peut être affiché, modifié, supprimé. La même chose peut être effectuée dans l'éditeur Unity pour le client.

Et maintenant les inconvénients:
- Vous devez apprendre à penser, concevoir et écrire du code différemment . Pensez en termes d'entités, de composants et de systèmes. De nombreux modèles de conception dans ECS sont implémentés d'une manière complètement différente (voir un exemple d'implémentation du modèle State dans l'un des articles de revue à la fin).
- Plus de code . Discutable. D'une part, en raison du fait que nous divisons la logique en petits systèmes, au lieu de décrire toutes les fonctionnalités dans une classe, il y a plus de classes, mais il n'y a pas beaucoup plus de code.
- L'ordre dans lequel les systèmes sont appelés affecte le fonctionnement de l'ensemble du jeu . Habituellement, les systèmes dépendent les uns des autres, l'ordre de leur exécution est défini par la liste et ils sont exécutés dans cet ordre. Par exemple, d'abord DamageSystem considère les dommages, puis RemoveDamageSystem supprime le composant Damage . Si vous modifiez accidentellement la commande, tout fonctionnera différemment. En général, cela est également vrai pour le cas OOP habituel, si vous modifiez l'ordre des appels de méthode, mais il est plus facile de faire des erreurs dans ECS. Par exemple, si une partie de la logique s'exécute sur le client pour la prédiction, l'ordre doit être le même que sur le serveur.
- Nous devons en quelque sorte connecter les données et les événements de la logique avec la vue . Dans le cas d'Unity, nous avons MVP:
- Modèle - GameState d'ECS;
- Afficher - avec nous, ce sont exclusivement des classes MonoBehavior Unity standard ( Renderer , Text , etc.) et prefabs;
- Le présentateur utilise le GameState pour déterminer les événements d'apparition / disparition d'entités, de composants, etc., crée des objets Unity à partir de préfabriqués et les modifie en fonction des changements de l'état du monde.
Saviez-vous que:- ECS ne concerne pas seulement la localisation des données . Pour moi, c'est plus un paradigme de programmation, un modèle, une autre façon de concevoir le monde du jeu - appelez-le comme vous voulez. La localisation des données n'est qu'une optimisation.
- L'unité n'a pas d'ECS! Souvent, vous demandez aux candidats lors d'un entretien d'équipe - que savez-vous d'ECS? Si vous n’avez pas entendu, vous leur dites, et ils ont répondu: "Ah, c’est comme dans Unity, alors je sais!". Mais non, ce n'est pas comme dans le moteur Unity. Là , les données et la logique sont combinées dans le composant MonoBehaviour , et GameObject (par rapport à une entité dans ECS) a des données supplémentaires - un nom, une place dans la hiérarchie, etc. Les développeurs Unity travaillent actuellement sur une implémentation normale d'ECS dans le moteur, et jusqu'à présent, il semble que ce sera bon. Ils ont embauché des spécialistes dans ce domaine - j'espère que ce sera cool.
Nos critères de sélection pour le cadre ECS
Lorsque nous avons décidé de créer un jeu sur ECS, nous avons commencé à chercher une solution toute faite et avons noté les conditions requises en fonction de l'expérience de l'un des développeurs. Et ils ont peint comment les solutions existantes répondent à nos exigences. C'était il y a un an, en ce moment, quelque chose aurait pu changer. Comme solutions, nous avons considéré:
- Entitas
- Artemis C #
- Ash.net
- ECS est notre propre solution au moment où nous l'avons conçue. C'est-à -dire nos hypothèses et souhaits, ce que nous pouvons faire nous-mêmes.
Nous avons compilé un tableau de comparaison, où j'ai également inclus notre solution actuelle (désignée comme
ECS (maintenant) ):
Couleur rouge - la solution ne prend pas en charge notre exigence, orange - prend en charge partiellement, vert - prend entièrement en charge.Pour nous, l'analogie des opérations d'accès aux composants et de recherche d'entités dans ECS était des opérations dans une base de données SQL. Par conséquent, nous avons utilisé des concepts tels que table (table), jointure (opération de jointure), indices (indices), etc.
Nous décrirons nos exigences et dans quelle mesure les bibliothèques et frameworks tiers leur correspondent:
- ensembles de données séparés (historique, actuel, visuel, statique) - la possibilité d'obtenir et de stocker séparément les états du monde (par exemple, l'état actuel pour le traitement, le rendu, l'historique des états, etc.). Toutes les décisions examinées appuyaient cette exigence .
- ID d'entité sous forme d'entier - prise en charge de la représentation d'une entité par son numéro d'identification. Il est nécessaire pour la transmission sur le réseau et la capacité de connecter des entités dans l'histoire des états. Aucune des solutions considérées comme prises en charge. Par exemple, dans Entitas, une entité est représentée par un objet à part entière (comme un GameObject dans Unity).
- join by ID O (N + M) - prise en charge d'un échantillonnage relativement rapide de deux types de composants. Par exemple, lorsque vous devez obtenir toutes les entités avec des composants de type Dégâts (par exemple, leurs N pièces) et Santé (M pièces) pour calculer et causer des dommages. Il y avait un soutien total à Artemis; dans Entitas et Ash.NET, il est plus rapide que O (N²), mais plus lent que O (N + M). Je ne me souviens plus de l'évaluation maintenant.
- joindre par référence ID O (N + M) - le même que ci-dessus uniquement lorsqu'un composant d'une entité a un lien avec un autre, et que ce dernier a besoin d'obtenir un autre composant (dans notre exemple, le composant Damage sur l'entité auxiliaire se réfère à l'entité joueur Victime et à partir de là , vous devez obtenir le volet Santé ). N'est pris en charge par aucune des solutions envisagées.
- aucune allocation de requête - aucune allocation de mémoire supplémentaire lors de l'interrogation de composants et d'entités à partir de l'état du monde. Dans Entitas, c'était dans certains cas, mais insignifiant pour nous.
- tables de pool - stockage des données mondiales dans des pools, possibilité de réutiliser la mémoire, allocation uniquement lorsque le pool est vide. Il y avait "un" support dans Entitas et Artemis, une absence totale dans Ash.NET.
- comparer par ID (ajouter, supprimer) - prise en charge intégrée des événements de création / destruction d'entités et de composants par ID. Il est nécessaire que le niveau d'affichage (Affichage) affiche / masque les objets, joue les animations, les effets. N'est pris en charge par aucune des solutions envisagées.
- Δ sérialisation (quantification, skip) - compression delta intégrée pour sérialiser l'état du monde (par exemple, pour réduire la taille des données envoyées sur le réseau). Out of the Box n'était pris en charge dans aucune des solutions.
- L'interpolation est un mécanisme d'interpolation intégré entre les états du monde. Aucune des solutions prises en charge.
- réutiliser le type de composant - la possibilité d'utiliser une fois le type de composant écrit dans différents types d'entités. Entitas uniquement pris en charge .
- ordre explicite des systèmes - la possibilité de définir vos propres systèmes d'ordre d'appel. Toutes les décisions sont appuyées.
- éditeur (unité / serveur) - prise en charge de la visualisation et de l'édition des entités en temps réel, à la fois pour le client et pour le serveur. Entitas a uniquement pris en charge la possibilité d'afficher et de modifier des entités et des composants dans l'éditeur Unity.
- copie / remplacement rapide - la possibilité de copier / remplacer des données à moindre coût. Aucune des solutions prises en charge.
- composant comme type de valeur (struct) - composants comme types de valeur. En principe, je voulais atteindre de bonnes performances sur cette base. Aucun système n'était pris en charge, les classes de composants étaient partout.
Exigences facultatives (
aucune des solutions à l'époque ne les supportait ):
- indices - indexation des données comme dans une base de données.
- clés composites - clés complexes pour un accès rapide aux données (comme dans la base de données).
- vérification d'intégrité - la capacité de vérifier l'intégrité des données dans un état du monde. Utile pour le débogage.
- la compression sensible au contenu est la meilleure compression de données basée sur la connaissance de la nature des données. Par exemple, si nous connaissons la taille maximale de la carte ou le nombre maximum d'objets dans le monde.
- types / systèmes limite - restriction du nombre de types de composants ou de systèmes. À l'époque, à Artemis, il était impossible de créer plus de 32 ou 64 types de composants et de systèmes .
Comme on peut le voir dans le tableau, nous voulions nous-mêmes implémenter toutes les exigences, sauf celles facultatives. En fait, pour le moment, nous n'avons
pas fait:
- joindre par ID O (N + M) et joindre par référence ID O (N + M) - la sélection pour deux composants différents occupe toujours O (N²) (en fait, une boucle imbriquée pour ). D'un autre côté, il n'y a pas autant d'entités et de composants pour une correspondance.
- comparer par ID (ajouter, supprimer) - pas nécessaire au niveau du framework. Nous l'avons implémenté à un niveau supérieur dans MVP.
- copie / remplacement rapide et composant comme type de valeur (struct) - à un moment donné, nous avons réalisé que travailler avec des structures ne serait pas aussi pratique qu'avec des classes, et nous nous sommes installés sur les classes - nous avons préféré la commodité du développement plutôt que de meilleures performances. Soit dit en passant, les développeurs d'Entitas ont finalement fait de même .
Dans le même temps, nous avons néanmoins réalisé une des exigences initialement facultatives selon nous:
- compression sensible au contenu - grâce à elle, nous avons pu réduire de manière significative (des dizaines de fois) la taille du paquet transmis sur le réseau. Pour les réseaux de données mobiles, il est très important d'ajuster la taille du paquet dans le MTU afin qu'il ne soit pas «décomposé» en petites parties qui pourraient se perdre, passer dans un ordre différent, puis devoir être assemblé en parties. Par exemple, dans Photon, si la taille des données ne tient pas dans la bibliothèque MTU, il divise les données en paquets et les envoie comme fiables (avec livraison garantie), même si vous les envoyez comme «non fiables» par le haut. Testé avec douleur de première main.
Caractéristiques de notre développement chez ECS
- Chez ECS, nous écrivons exclusivement la logique métier . Pas de travail avec les ressources, les vues, etc. Étant donné que le code logique ECS s'exécute simultanément sur le client dans Unity et sur le serveur, il doit être aussi indépendant que possible des autres niveaux et modules.
- Nous essayons de minimiser les composants et les systèmes . Habituellement, pour chaque nouvelle tâche, nous démarrons de nouveaux composants et systèmes. Mais il arrive parfois que nous modifions les anciens, ajoutions de nouvelles données aux composants et «gonflions» les systèmes.
- Dans notre implémentation ECS, vous ne pouvez pas ajouter plusieurs composants du même type à une seule entité . Par conséquent, si un joueur a été touché plusieurs fois en une seule fois (par exemple, plusieurs adversaires), nous créons généralement une nouvelle entité pour chaque dégât et lui ajoutons un composant Dégâts .
- Parfois, la présentation ne suffit pas des informations contenues dans le GameState . Ensuite, vous devez ajouter des composants spéciaux ou des données supplémentaires qui ne sont pas impliqués dans la logique, mais dont la vue a besoin. Par exemple, la prise de vue est instantanée sur le serveur, une tique vit et visuellement, elle est plus longue sur le client. Par conséquent, pour le client, la prise de vue est ajoutée au paramètre "durée de vie de la prise de vue".
- Nous mettons en œuvre des événements / demandes en créant des composants spéciaux . Par exemple, si un joueur est décédé, nous lui accrochons un composant sans données Dead , qui est un événement pour d'autres systèmes et le niveau de vue que le joueur est décédé. Ou si nous devons faire revivre le joueur sur le point, nous créons une entité distincte avec le composant Respawn avec des informations supplémentaires sur qui revivre. Un RespawnSystem séparé au tout début du cycle de jeu passe par ces composants et crée déjà l'essence du joueur. C'est-à -dire en fait, la première entité est une demande de création de la seconde.
- Nous avons des composants / entités «singleton» spéciaux . Par exemple, nous avons une entité avec ID = 1, sur laquelle pendent des composants spéciaux - les paramètres du jeu.
Bonus
— ECS — . , , , :
- Unity, ECS -- — ECS . mopsicus , ECS, . : Unity ECS , . . «» ECS Unity. ECS-, : LeoECS , BrokenBricksECS , Svelto.ECS .
- Unity3D ECS Job System — , ECS Unity. fstleo , Unity ECS, , - , JobSystem.
- Entity System Framework ? — Ash- ActionScript. , , OOP- ECS-.
- Ash Entity System — , FSM State ECS — , .
- Entity-Component-System — — ECS C++.