JustAnotherDnDGame 0.1.0
Jeu de rôle tactique au d20, vue de dessus, en C++/Qt
Loading...
Searching...
No Matches
ECS : entités, composants, systèmes

Cette page explique le patron d'architecture Entity-Component-System (ECS ⧉) depuis ses principes, puis détaille l'implémentation maison de Source/Core/Ecs.

Le problème que l'ECS résout

Dans un moteur orienté objet « classique », on modéliserait naturellement un personnage par une classe Character héritant de GameObject, avec des méthodes comme update(), render(), takeDamage(). Très vite, ce modèle par héritage devient un obstacle dans un jeu :

  • un Player a besoin de physique, de rendu, d'entrée ; un Switch (interrupteur de niveau) a besoin de physique et de rendu mais pas d'entrée ; une plateforme mobile a besoin de physique mais ni rendu animé ni entrée. L'héritage simple ne capture pas ces combinaisons : on finit avec une hiérarchie de classes profonde, ou des interfaces vides à implémenter « pour la forme » ;
  • ajouter un comportement à une seule sorte d'objet (par exemple, rendre les interrupteurs destructibles) oblige à modifier une classe existante ou à multiplier les sous-classes ;
  • itérer sur « tous les objets qui bougent » demande de parcourir des objets hétérogènes en testant leur type dynamiquement, ce qui est lent et fragile.

L'ECS répond en séparant radicalement trois notions que l'orienté objet mélange dans une seule classe :

  • Entité : juste un identifiant. Aucune donnée, aucun comportement — un numéro qui désigne « une chose qui existe dans le monde ».
  • Composant : une donnée pure, sans aucune méthode de logique (EX-ARCH-011) — par exemple une position (core::Transform), une vitesse (core::Velocity), une boîte de collision (core::Collider), un visuel (core::Sprite), ou un simple marqueur « ceci est le joueur » (core::Player). Un composant répond à la question « quelle donnée ? », jamais « que fait cette donnée ? ».
  • Système : la logique, qui parcourt toutes les entités possédant un certain ensemble de composants et les fait évoluer — par exemple core::CharacterPhysicsSystem (physique du personnage) ou core::MovementSystem (applique une vitesse à une position).

Un « personnage joueur » n'est alors qu'une entité qui possède les composants Transform, Velocity, Collider, Sprite et Player — une combinaison de données, pas une classe dédiée. Un interrupteur est une entité avec Transform, Collider, Sprite mais sans Player. Ajouter un comportement à un sous-ensemble d'entités revient à écrire un nouveau système qui parcourt les composants pertinents, sans toucher au reste. La règle d'or à retenir : les données vivent dans les composants, la logique vit dans les systèmes ; un composant ne contient jamais de comportement, un système ne stocke jamais d'état de jeu à demeure (il le lit/écrit dans les composants, qui restent la seule source de vérité).

L'entité : core::Entity

Une entité (core::Entity) est un handle générationnel : une paire {index, generation}.

  • index est la position de l'entité dans les tableaux internes — c'est ce qui permet un accès en temps constant.
  • generation est un compteur qui protège contre un piège classique : quand une entité est détruite, son index est recyclé (réutilisé pour une future entité, plutôt que de croître indéfiniment). Sans précaution, un ancien handle vers l'entité détruite désignerait alors, par accident, la nouvelle entité qui a récupéré le même index — un bug silencieux et difficile à diagnostiquer, où du code croit encore manipuler l'ancien personnage. En incrémentant generation à chaque recyclage d'index, un handle périmé (ancienne génération) ne correspond plus à l'entité courante (nouvelle génération) : la comparaison operator== compare les deux champs, donc has()/getComponent() rejettent proprement un handle obsolète au lieu de renvoyer les données d'une entité sans rapport.

Une entité, à elle seule, ne « fait » rien : elle ne devient un personnage, un décor ou un interrupteur que par les composants qu'on lui attache.

Le World

core::World est le point d'entrée unique de la simulation : il possède les entités, les composants et les systèmes. API essentielle :

  • createEntity() / destroyEntity(e) — cycle de vie d'une entité ; la destruction retire automatiquement tous ses composants, dans toutes les pools (voir IComponentPool ci-dessous, qui existe précisément pour rendre cela possible sans connaître chaque type de composant à l'avance) ;
  • addComponent<T>(e, valeur) / getComponent<T>(e) / hasComponent<T>(e) / removeComponent<T>(e) — gestion des composants d'une entité, un type à la fois ;
  • view<A, B, …>() — une vue sur les entités possédant tous les composants listés (détail ci-dessous).

Chaque type de composant obtient sa propre pool, créée à la demande au premier addComponent<T> : le World ne connaît pas à l'avance la liste des types de composants qui existeront, il les découvre à l'usage (via std::type_index comme clé).

Le stockage : sparse set (core::ComponentPool<T>)

Le besoin est double et a priori contradictoire :

  • itérer vite sur tous les composants d'un type (par exemple, tous les Transform), ce qui demande un tableau contigu en mémoire (pas de trous) pour profiter du cache du processeur ;
  • accéder vite au composant d'une entité précise (« quel est le Transform de l'entité 42 ? »), ce qui demande normalement un tableau indexé directement par index, quitte à laisser des trous pour les entités qui n'ont pas ce composant.

Le sparse set ⧉ concilie les deux avec deux tableaux :

  • un tableau dense (_components) : les composants, sans aucun trou, dans l'ordre où ils ont été ajoutés — c'est lui que les systèmes parcourent ; un tableau _entities parallèle retient quelle entité possède chaque composant dense ;
  • un tableau creux (_sparse), indexé directement par Entity::index : pour chaque entité potentielle, il donne la position de son composant dans le tableau dense (ou une valeur sentinelle INVALID_POSITION si elle n'a pas ce composant). Ce tableau peut avoir des trous — peu importe, on n'itère jamais dessus, on ne fait que le lire à un index précis.

has(entity) et get(entity) passent donc par le tableau creux (accès en O(1), temps constant) ; l'itération complète passe par le tableau dense (cache-friendly, aucune entité absente à sauter).

Ajout et suppression : swap-and-pop

add(entity, component) ajoute simplement en fin de tableau dense et enregistre sa position dans le tableau creux — coût O(1).

remove(entity) est plus subtil : retirer un élément au milieu d'un tableau dense en décalant tout ce qui suit coûterait O(n). La technique du swap-and-pop l'évite :

  1. on repère la position removed du composant à retirer dans le tableau dense ;
  2. on écrase cette position avec le dernier élément du tableau (_components[removed] = _components[last]), pour l'entité et le composant ;
  3. on met à jour le tableau creux de l'entité déplacée (celle qui était en dernière position) pour qu'il pointe désormais vers removed ;
  4. on retranche le dernier élément (pop_back) — devenu un doublon.

Résultat : le tableau dense reste sans trou en coût O(1), au prix de changer l'ordre d'itération (l'élément déplacé change de position). Comme les systèmes ne dépendent jamais de cet ordre, ce n'est jamais un problème.

Exemple pas à pas

Imaginons trois entités A, B, C ayant chacune un composant Velocity, dans cet ordre d'insertion : dense = [Va, Vb, Vc], entités = [A, B, C]. On retire le composant de B (position 1) :

  1. removed = 1, last = 2 ;
  2. _components[1] = _components[2] → dense devient [Va, Vc, Vc] ; _entities[1] = C ;
  3. le tableau creux de C est mis à jour : il pointe maintenant vers la position 1 ;
  4. pop_back() → dense final = [Va, Vc], entités = [A, C].

C a « pris la place » de B dans le tableau dense — l'itération reste dense et rapide, et get(C) continue de fonctionner grâce au tableau creux mis à jour.

⚠️ Conséquence pour qui écrit un système : une référence obtenue par get() (ou dans une vue) est invalidée par tout add() ou remove() ultérieur sur la même pool (le tableau dense peut réallouer ou déplacer ses éléments). Ne jamais conserver une telle référence au-delà de telles opérations.

Les vues : core::View<Components...>

Un système a typiquement besoin d'itérer sur « toutes les entités qui ont à la fois tel et tel composant » (par exemple Transform et Velocity pour un déplacement). World::view<A, B, …>() construit une core::View qui joint les pools demandées et n'expose que l'intersection — les entités présentes dans toutes.

Pour rester efficace, la vue ne parcourt pas la pool la plus grande en testant les autres : elle choisit comme « pilote » la plus petite des pools demandées (celle avec le moins de composants) et ne teste l'appartenance aux autres pools que pour les entités de ce pilote. Le coût de l'itération est ainsi borné par la plus petite population parmi les types demandés, jamais par la plus grande — l'intersection ne peut de toute façon pas être plus grande que son plus petit opérande.

Deux syntaxes équivalentes :

for (auto [entity, transform, velocity] : world.view<Transform, Velocity>()) {
transform.position += velocity.value * dt;
}
// équivalent, forme fonctionnelle :
world.view<Transform, Velocity>().each(
[dt](core::Entity, core::Transform& t, core::Velocity& v) { t.position += v.value * dt; });
Handle générationnel identifiant une entité de l'ECS.
Definition Entity.h:24
Position, échelle et rotation d'une entité, en unités monde (EX-ARCH-020).
Definition Transform.h:22
Vector2 position
Position du repère de l'entité, en unités monde (origine haut-gauche, Y-bas).
Definition Transform.h:24
Vitesse linéaire d'une entité, en unités monde par seconde.
Definition Velocity.h:21
Vector2 value
Déplacement par seconde, en unités monde.
Definition Velocity.h:23

C'est exactement le motif de tous les systèmes du moteur : une vue, une lambda, la logique de mise à jour. core::MovementSystem n'est rien de plus qu'une enveloppe autour de cet exemple.

⚠️ Contrat d'itération : à l'intérieur d'un each ou d'une boucle sur une vue, on ne modifie que la valeur des composants obtenus. Ajouter/retirer un composant ou détruire une entité pendant l'itération invaliderait la vue (les pools sous-jacentes peuvent bouger, cf. swap-and-pop ci-dessus) — ce type de modification structurelle doit être différé après l'itération.

Les systèmes et l'ordre d'exécution

World::addSystem(std::unique_ptr<ISystem>) enregistre un système ; World::update(fixedDelta) exécute tous les systèmes enregistrés, dans l'ordre d'enregistrement, une fois par pas de temps fixe (Boucle de jeu et pas de temps fixe). Cet ordre est significatif et fait partie du contrat de déterminisme (EX-NFR-002) : deux systèmes qui lisent et écrivent les mêmes composants doivent s'exécuter dans un ordre stable pour produire toujours le même résultat (voir l'« ordre d'un pas » détaillé dans Physique du personnage pour un exemple concret à l'intérieur d'un seul système).

Certains systèmes (comme core::CharacterPhysicsSystem) ont besoin de données supplémentaires que l'ISystem générique ne transporte pas (la grille de collision, l'intention d'entrée) : ils exposent alors leur propre méthode update(...) avec une signature dédiée, appelée directement par l'orchestration plutôt que via le mécanisme générique addSystem/World::update. Dans les deux cas, le principe reste identique : la logique parcourt des vues et modifie des composants.

core::AnimationSystem (LOT-18) illustre concrètement pourquoi l'ordre compte : il lit Player::grounded, calculé par CharacterPhysicsSystem pour dériver le clip d'animation actif (repos/course/saut). hmi::GameSession::update l'appelle donc après la physique, dans le même pas — l'inverser lirait l'état du pas précédent (décalage d'une frame). C'est aussi un exemple de système qui ne modifie aucun état de simulation au sens strict (position, vitesse) : il ne fait que projeter un état déjà déterminé (Player/Velocity) vers un état de présentation (core::Animation), consommé ensuite par HMI pour choisir la bonne région d'atlas (Rendu 2D : de l'ECS à l'écran).

Voir aussi