Unity ECS

🔊 S'abonner au flux RSS

________________________________________________________

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.

Factorio Main Bus

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 :

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

EntityDirectionCurrentSpeedNormalSpeed
1(0, 0, 1)0.50.5
2(0.5, 0, 0.4)0.70.5
3(1, 0, 0.2)0.20.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Ă©.

Organisation fichiers projet

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 !