________________________________________________________
Maintenant que vous avez compris le pourquoi de lâarchitecture de lâECS, il est temps de sâintĂ©resser plus en dĂ©tail au comment ça marche âïž.
Je vais traiter ici les bases des Ă©lĂ©ments qui composent un ECS et surtout la façon de les penser, qui est trĂšs diffĂ©rente de la façon de construire du code classique POO. Et ça vous demandera un temps dâadaptation non nĂ©gligeable (personnellement ça mâa pris plusieurs mois avant de penser naturellement Data Oriented, mais une fois quâon est dans le moule, câest un bonheur).
Pour ça on va suivre tout du long de cet article un exemple simple dâentitĂ©s reprĂ©sentant des unitĂ©s quâon voudra dĂ©placer. On va sâattaquer Ă la logique mĂȘme de lâECS (liens entre Entities, Components et Systems) et surtout sâinterroger sur comment on conceptualise lâarchitecture avec cette approche Data Oriented, que personnellement je renommerais mĂȘme en Data Driven, vu lâĂ©tat dâesprit dans lequel il nous plonge. Ce qui nous amĂšnera dâailleurs Ă revoir la dĂ©finition basique des termes ECS et Ă les approfondir.
Les Entities
Jâaurais tendance Ă dire quâon a 2 dĂ©finitions associĂ©es Ă une entitĂ© :
-
Technique : câest un simple identifiant unique qui relie entre eux des Components. Les entitĂ©s peuvent alors ĂȘtre regroupĂ©es en Archetypes (des entitĂ©s disposant des mĂȘmes Components), qui seront rangĂ©es en mĂ©moire de maniĂšre contigĂŒe pour en optimiser lâaccĂšs en lecture/Ă©criture.
-
Fonctionnelle : câest une reprĂ©sentation dâun concept mĂ©tier. Une entitĂ© peut reprĂ©senter un GameObject classique (Player, Unit, Projectile, DecorâŠ). Mais dans le cadre dâun ECS, elle peut aussi ĂȘtre un porteur de donnĂ©es centralisĂ©es, type Singleton (avancement de la partie, systĂšme de points, taille de la carteâŠ), remplaçant les classes et constantes static quâon utilise habituellement pour partager de la data au travers du code.
partial struct SYS_Unit_Spawn : ISystem
{
public void OnCreate(ref SystemState state)
{
EntityManager EM = state.EntityManager;
// Crée une entité vide avec un identifiant unique
Entity UnitEntity = EM.CreateEntity();
// Ajout des Components qui caractérisent
// l'entité comme une unité mobile
EM.SetName(UnitEntity, "Unit");
EM.AddComponent(UnitEntity, new LocalTransform());
EM.AddComponent(UnitEntity, new TAG_Unit());
EM.AddComponent(UnitEntity, new COMP_Unit_Movement());
// + tout autre component utile à l'unité
}
}Les Components
Dans le chapitre 1, jâavais dĂ©crit les Components comme de simples âconteneurs de donnĂ©es (des structs)â.
Par exemple sur Unity, on va disposer de 2 grands types de Components, que sont les IComponentData (donnée simple) et les IBufferElementData (liste de données).
// Component unique, sans aucune data, (dés)activable pour pouvoir query les unités
public struct TAG_Unit : IComponentData, IEnableableComponent {}
// Component simple car une unité n'a qu'une seule définition de son mouvement
public struct COMP_Unit_Movement : IComponentData
{
public float3 Direction;
public float Speed;
public bool CanMove;
}
// Buffer car j'aurais besoin d'autant de struct que de types de dégats gérés
public struct BUFF_Unit_Defense : IBufferElementData
{
public DamageType DamageType;
public float DamageReduction;
}Cependant avec cette dĂ©finition, je vous ai un peu (beaucoup đ) menti au nom de la simplification. Car câest passer Ă cĂŽtĂ© de la philosophie gĂ©nĂ©rale de lâECS que de dĂ©finir les Components uniquement comme des porteurs de donnĂ©es. Ils sont bien plus que ça.
En soi, lâensemble des Components reprĂ©sente un bus de donnĂ©es publiques, dans lesquels les Systems vont aller piocher (soit en lecture, soit en Ă©criture) pour produire des traitements. Pour ceux qui ont jouĂ© Ă Factorio, jâaime beaucoup me le reprĂ©senter mentalement comme le Main Bus qui distribue les ressources dans la base.
Autrement dit, un System est aveugle Ă lâĂ©tat dâavancement dâune entitĂ© spĂ©cifique, il nâinteragit quâau travers de la data exposĂ©e de maniĂšre gĂ©nĂ©rique. Ceci impose donc que lâentitĂ© porte au travers de sa data son Ă©tat et sa logique fonctionnelle de lâinstant T pour que le System concernĂ© puisse travailler.
Les Components sont donc Ă la fois Ă©lĂ©ment porteur de data, mais aussi Ă©lĂ©ment de filtrage de data au travers des Queries. Ils dĂ©finissent fondamentalement qui peut interagir avec quoi et surtout pourquoi. Câest un peu contre-intuitif de prime abord, car le principe dâune entitĂ© ECS, câest justement de ne pas porter de traitements et par extension pas de logique, mais le fait que lâentitĂ© doit exposer des Ă©tats soumet lâarchitecture Ă une logique data driven (câest pour ça que je prĂ©fĂšre ce terme Ă data oriented).
Et câest quelque chose dâextrĂȘmement clivant, puisque le POO tend Ă rendre les objets autonomes et fermĂ©s (ce qui est dâailleurs un des fondamentaux du Clean Code, mĂȘme si le âCleanâ est Ă mon humble avis, fort discutableâŠ).
Par nature, lâECS impose de rendre explicite lâintĂ©gralitĂ© de lâĂ©tat dâune entitĂ©, pas juste sa data brute et câest une gymnastique mentale Ă laquelle nous ne sommes pas historiquement habituĂ©s.
Les Queries
Une Query, câest une requĂȘte pour rĂ©cupĂ©rer des entitĂ©s spĂ©cifiques. Pour ça, on dĂ©finit la structure minimale de Components attachĂ©s aux entitĂ©s concernĂ©es. Cette structure doit forcĂ©ment contenir les Components quâun System va lire et/ou modifier, mais elle peut aussi contenir des Components de filtrage qui ne seront pas du tout utilisĂ©s par le System en soi, mais permet Ă lâengine de rĂ©duire le pĂ©rimĂštre Ă traiter selon une logique fonctionnelle.
// Query sur toutes les Unités avec un TAG_Unit Enabled
EntityQuery Query_Units = SystemAPI.QueryBuilder()
.WithAll<TAG_Unit>()
.WithAll<LocalTransform>()
.WithAll<COMP_Unit_Movement>()
.Build();
// Récupération des unités et leur data pour traitement ensuite
NativeArray<Entity> Units = Query_Units.ToEntityArray(Allocator.Temp);
NativeArray<COMP_Unit_Movement> UnitsMovements = Query_Units.ToComponentArray<COMP_Unit_Movement>(Allocator.Temp);
// Imaginons qu'il n'existe qu'une seule et unique unité qu'on voudrait récupérer
Entity MyUniqueUnit = SystemAPI.GetSingletonEntity<TAG_Unit>();
COMP_Unit_Movement MyUniqueMovement = SystemAPI.GetSingleton<COMP_Unit_Movement>();Et câest lĂ oĂč on peut se rendre compte de la puissance de lâECS, parce quâau travers des Components, on peut gĂ©rer non seulement de la data, mais comme je disais auparavant, on peut aussi gĂ©rer des Ă©tats/logique fonctionnelle.
Imaginons par exemple que je ne veuille pas dĂ©truire mes unitĂ©s quand elles meurent, mais juste les sortir de la carte et les dĂ©sactiver jusquâĂ un prochain respawn. Et bien je pourrais simplement dĂ©sactiver leur Component TAG_Unit quand leurs PVs atteignent 0, puis lorsque jâai de nouveau besoin, les rĂ©cupĂ©rer simplement via :
// Query toutes les Unités dont TAG_Unit est Disabled
EntityQuery Query_Units = SystemAPI.QueryBuilder()
.WithDisabled<TAG_Unit>()
.Build();Avec une approche POO classique, jâaurais dĂč gĂ©rer une liste dâunitĂ©s dĂ©sactivĂ©es, aller piocher dedans une unitĂ©, puis la retirer de la liste pour pouvoir enfin mâen servir. Avec une approche ECS, plus besoin de faire un tracking par table/liste/hashmap.
Et Ă partir du moment oĂč on commence Ă raisonner en Ă©tat, on peut alors revoir la façon dâexposer la data. Reprenons mon exemple plus haut :
public struct COMP_Unit_Movement : IComponentData
{
public float3 Direction;
public float Speed;
public bool CanMove;
}Dans une approche POO, avoir ces 3 informations ensemble semble tout Ă fait pertinent. Mais dans lâECS, il serait bien meilleur de faire :
public struct TAG_Unit_IsMovable : IComponentData, IEnableableComponent {}
public struct COMP_Unit_Movement : IComponentData
{
public float3 Direction;
public float Speed;
}Ainsi un System gĂ©rant le mouvement de mes unitĂ©s pourrait filtrer sur TAG_Unit_IsMovable et ne traiter que les unitĂ©s concernĂ©es. LĂ oĂč un POO classique nous aurait obligĂ© Ă rĂ©cupĂ©rer toutes les unitĂ©s et faire un if (CanMove == false) return;, nous obligeant Ă manipuler de la data inutile.
Cependant cette optimisation a aussi un coĂ»t : on dĂ©multiplie les struct quâon manipule et si on nâest pas extrĂȘmement rigoureux dans ses conventions de nommage et la compartimentation, ça peut trĂšs vite devenir un enfer de manager des centaines de Components. La performance de lâECS a un coĂ»t structurel et cognitif non nĂ©gligeable et gĂ©nĂšre une forme de boilerplate de structures qui peut devenir fatiguante Ă gĂ©rer (mais y a un truc un peu hype en ce moment qui est pas mal pour ce genre de tĂąche, ça commence par I et ça finit par A đ€).
Les Systems
Les Systems sont des mini-usines dont lâobjectif est de manipuler un set dâentitĂ©s, quâils rĂ©cupĂšrent via des Queries. Un System par construction est optimisĂ© pour ne manipuler que des types de donnĂ©es stricts (donc on oublie les types nullable ou a gĂ©omĂ©trie variable comme les strings, bref on oublie les objets managĂ©s).
CĂŽtĂ© Unity, lâarchitecture sâappuie donc sur des NativeArrays/NativeLists ou des FixedString64Bytes, lâobjectif Ă©tant de gĂ©rer finement lâallocation mĂ©moire.
partial struct SYS_Unit_Move : ISystem
{
EntityQuery Query_Units;
public void OnCreate(ref SystemState state)
{
Query_Units = SystemAPI.QueryBuilder()
.WithAll<TAG_Unit>()
.WithAll<LocalTransform>()
.WithAll<COMP_Unit_Movement>()
.Build();
}
public void OnUpdate(ref SystemState state)
{
EntityManager EM = state.EntityManager;
// Récupération de la data des unités
NativeArray<Entity> Units = Query_Units.ToEntityArray(Allocator.Temp);
NativeArray<COMP_Unit_Movement> UnitsMovements = Query_Units.ToComponentArray<COMP_Unit_Movement>(Allocator.Temp);
NativeArray<LocalTransform> UnitsTransforms = Query_Units.ToComponentArray<LocalTransform>(Allocator.Temp);
// Boucle simple de mouvements
float DeltaTime = SystemAPI.Time.DeltaTime();
for (int UnitID = 0; UnitID < Units.Length; UnitID++)
{
COMP_Unit_Movement UnitMovement = UnitsMovements[UnitID];
LocalTransform UnitTransfom = UnitsTransforms[UnitID];
UnitTransfom.Position += DeltaTime * UnitMovement.Speed * UnitMovement.Direction;
EM.SetComponent(Units[UnitID], UnitTransfom);
}
// Libération de la mémoire
Units.Dispose();
UnitsMovements.Dispose();
UnitsTransforms.Dispose();
}
}Mais cet exemple est une implĂ©mentation extrĂȘmement basique et peu optimisĂ©e (en plus dâĂȘtre assez lourde Ă gĂ©rer). Parce quâon dispose dâune approche bien plus performante qui exploite le rangement contigu en mĂ©moire : les IJobEntity.
LâidĂ©e est plutĂŽt simple : parallĂ©lisons le traitement sur plusieurs threads, chacun traitant un chunk dâentitĂ©s.
partial struct SYS_Unit_Move : ISystem
{
public void OnCreate(ref SystemState state)
{
// le systÚme ne tourne que s'il existe au moins une unité qui peut bouger
state.RequireForUpdate<TAG_Unit_IsMovable>();
}
public void OnUpdate(ref SystemState state)
{
Job_UnitMove JobMove = new Job_UnitMove
{
DeltaTime = SystemAPI.Time.DeltaTime()
}
// planification parallélisée gérée par Unity automatiquement
state.Dependency = JobMove.ScheduleParallel(state.Dependency);
}
[BurstCompile] // Compilation énervée profitant de l'implémentation stricte de l'ECS/Jobs
[WithAll(typeof(TAG_Unit_IsMovable))] // Filtre sur les unités enabled
partial struct Job_UnitMove : IJobEntity
{
public float DeltaTime;
// Query, sur les components manipulés
// avec en plus une notion de ReadWrite/ReadOnly stricte
public void Execute(RefRW<LocalTransform> Transform,
RefRO<COMP_Unit_Movement> COMP_Movement)
{
COMP_Unit_Movement UnitMovement = COMP_Movement.ValueRO;
Transform.ValueRW.Position += DeltaTime * UnitMovement.Speed * UnitMovement.Direction;
}
}
}En plus on insĂšre le [BurstCompile] qui remplace la compilation C# via Mono/.NET JIT en code machine gĂ©nĂ©rique, par une compilation via LLVM (le mĂȘme backend que Rust/SwiftâŠ) et du code machine optimisĂ©. Câest la raison qui fait quâon nâutilise pas dâobjets managĂ©s et on interdit par conception les exceptions, les allocations de garbage collector⊠On obtient alors la force de lâECS (data contigĂŒe) couplĂ©e Ă une compilation bas niveau forte (SIMD / cache locality) rendant le tout trĂšs performant.
Utiliser Unity dans un contexte ECS se rapproche trĂšs fortement de lâutilisation de langages trĂšs typĂ©s comme Rust (et Bevy est un ECS natif pour cette raison). Et câest aussi une des difficultĂ©s Ă surmonter quand on vient du POO/GameObject classique, on passe sur une implĂ©mentation qui demande une certaine rigueur de code (que personnellement jâapprĂ©cie beaucoup et qui produit un rĂ©sultat bien plus prĂ©dictif).
Penser lâECS
Jâaime beaucoup faire une comparaison entre lâarchitecture ECS et lâarchitecture dâun SGBD classique, parce que dans les 2 cas on applique une philosophie Data Oriented/Driven.
Une entitĂ© peut reprĂ©senter une ligne dâune table, avec sa notion de PrimaryKey lâassociant Ă un objet mĂ©tier unique, et portant des Components qui sont en fait les colonnes de la table. La table est alors une reprĂ©sentation de lâArchetype en mĂ©moire. On peut alors se reprĂ©senter les entitĂ©s avec un COMP_Unit_Movement comme une table :
| Entity | Direction | Speed |
|---|---|---|
| 1 | (0, 0, 1) | 0.5 |
| 2 | (0.5, 0, 0.4) | 0.7 |
| 3 | (1, 0, 0.2) | 0.2 |
Je trouve ça trĂšs pratique comme reprĂ©sentation mentale, parce que si vous avez dĂ©jĂ fait de la requĂȘte SQL, vous adoptez tout de suite certains rĂ©flexes sur comment vous allez compartimenter la data en tables mĂ©tier logique.
Mais une entitĂ© peut aussi reprĂ©senter une table rĂ©fĂ©rentielle unique. Par exemple, mes unitĂ©s peuvent voir leur vitesse de dĂ©placement altĂ©rĂ©e. PlutĂŽt que de stocker un NormalSpeed + CurrentSpeed dans chaque Component de chaque unitĂ©, ce qui nâaurait aucun sens car ça dĂ©multiplierait la mĂȘme data :
| Entity | Direction | CurrentSpeed | NormalSpeed |
|---|---|---|---|
| 1 | (0, 0, 1) | 0.5 | 0.5 |
| 2 | (0.5, 0, 0.4) | 0.7 | 0.5 |
| 3 | (1, 0, 0.2) | 0.2 | 0.5 |
Je peux créer une entité singleton référentielle qui portera le NormalSpeed. Et personnellement, ces structures singletons référentielles, je les nomme justement REF_*.
Je peux donc transformer ma data de mouvement dâunitĂ© ainsi :
//***** COMPONENTS *****
// Filtre de query et état fonctionnelle du mouvement
public struct TAG_Unit_IsMovable : IComponentData, IEnableableComponent {}
// Capacité de mouvement à l'instant T
public struct COMP_Unit_Movement : IComponentData
{
public float3 Direction;
public float SpeedMultiplier;
}
// singleton unique partagé par toutes les unités
public struct REF_Unit_Movement : IComponentData
{
public float NormalSpeed;
}
//***** SYSTEM *****
partial struct SYS_Unit_Move : ISystem
{
EntityManager EM;
public void OnCreate(ref SystemState state)
{
EM = state.EntityManager;
// je déclare un singleton unique au démarrage
Entity REFMovementEntity = EM.CreateEntity();
EM.SetName(REFMovementEntity, "REF_Unit_Movement");
EM.AddComponent(REFMovementEntity, new REF_Unit_Movement
{
NormalSpeed = 0.5f
});
state.RequireForUpdate<TAG_Unit_IsMovable>();
// le systÚme a désormais besoin de l'existence du singleton pour tourner
state.RequireForUpdate<REF_Unit_Movement>();
}
public void OnUpdate(ref SystemState state)
{
Job_UnitMove JobMove = new Job_UnitMove
{
// désormais je passe le Component du singleton dans mon job
REF_Movement = SystemAPI.GetSingleton<REF_Unit_Movement>(),
DeltaTime = SystemAPI.Time.DeltaTime()
};
state.Dependency = JobMove.ScheduleParallel(state.Dependency);
}
[BurstCompile]
[WithAll(typeof(TAG_Unit_IsMovable))] // Filtre d'unité
partial struct Job_UnitMove : IJobEntity
{
[ReadOnly] public REF_Unit_Movement REF_Movement;
public float DeltaTime;
public void Execute(RefRW<LocalTransform> Transform,
RefRO<COMP_Unit_Movement> COMP_Movement)
{
COMP_Unit_Movement UnitMovement = COMP_Movement.ValueRO;
Transform.ValueRW.Position +=
DeltaTime
// vitesse référentielle unique qui ne changera jamais
* REF_Movement.NormalSpeed
// multiplicateur variable dans le temps spécifique à chaque unité, par défaut = 1
* UnitMovement.SpeedMultiplier
* UnitMovement.Direction;
}
}
}Et lâavantage par rapport Ă une constante : je peux modifier la valeur en live du component REF_Unit_Movement sans avoir Ă recompiler le jeu (et en plus ça facilite le tuning et permet de faire Ă©voluer les unitĂ©s au cours de la partie).
Et câest lĂ Ă mon sens toute la beautĂ© (et la difficultĂ©) de lâapproche ECS. Le Data Oriented porte bien son nom, puisque la consommation de la data est directement liĂ©e Ă la façon dont vous lâexposez, ce qui est Ă proprement parler lâapproche inverse du POO qui vise Ă concevoir des objets qui sont des orchestrateurs autonomes.
Dans lâECS, jâaurais tendance Ă dire que le plus important dans le design, câest le Component. Faites un component fourre-tout et vous obtiendrez des systĂšmes monstrueux qui devront gĂ©rer des Ă©lĂ©ments logiques qui nâont aucun rapport entre eux. Au contraire, faites des components trop fins et vous vous noierez dans un amas de structures avec des query de 10 km de long.
La conception des components et des entitĂ©s demande un certain temps dâadaptation pour trouver un Ă©quilibre et de trouver une façon de raisonner avec des systĂšmes logiques. Personnellement jâaime lâapproche KISS (Keep It Stupidly Simple) et je pars du principe quâun System ne doit traiter quâune action trĂšs limitĂ©e, ce qui le rend simple Ă debug et facilite de facto la conception des Components :
- de quoi a besoin un systĂšme qui va faire bouger mes unitĂ©s ? â si les Components query portent des donnĂ©es non utilisĂ©es, peut-ĂȘtre nâont elles rien Ă faire ici
- cette data est partagĂ©e ou a besoin dâĂȘtre reset Ă une valeur dĂ©finie ? â Singleton rĂ©fĂ©rentiel
- cette data est spĂ©cifique Ă chaque unitĂ© ? â Component sur lâentitĂ©
- cette data est-elle rĂ©pĂ©table ? â IBuffer vs IComponent
Dâailleurs comme lâECS est orientĂ© data, jâai aussi adoptĂ© une compartimentation de mes dossiers avec des fichiers dĂ©diĂ©s aux Components, parce que câest important de pouvoir visualiser rapidement toute la data associĂ©e Ă un type dâentitĂ© donnĂ©.
Bref lâECS demande une certaine pratique mais une fois quâon est dedans, on se met Ă optimiser la data trĂšs naturellement (peut-ĂȘtre mĂȘme trop et câest actuellement une des difficultĂ©s que jâĂ©prouve Ă trop over-engineer mes structures alors que jâai 5 entitĂ©s qui se battent en duel dans ma scĂšne, mais je pense que ça mĂ©riterait un article dĂ©diĂ© dâanti-pattern ECS Ă surveiller đ).
En tout cas, câest une approche trĂšs enrichissante dans la maniĂšre de penser une architecture !



