unity-ecs-patterns

Par wshobson · agents

Maîtrisez Unity ECS (Entity Component System) avec DOTS, Jobs et Burst pour le développement de jeux haute performance. À utiliser lors de la création de jeux orientés données, de l'optimisation des performances ou du travail avec un grand nombre d'entités.

npx skills add https://github.com/wshobson/agents --skill unity-ecs-patterns

Patterns ECS Unity

Patterns de production pour la Data-Oriented Technology Stack (DOTS) de Unity, incluant Entity Component System, Job System et Burst Compiler.

Quand utiliser cette compétence

  • Développer des jeux Unity haute performance
  • Gérer efficacement des milliers d'entités
  • Implémenter des systèmes de jeu orientés données
  • Optimiser la logique de jeu limitée par le CPU
  • Convertir du code OOP en ECS
  • Utiliser Jobs et Burst pour la parallélisation

Concepts fondamentaux

1. ECS vs OOP

Aspect OOP traditionnel ECS/DOTS
Disposition Orientée objet Orientée données
Mémoire Dispersée Contiguë
Traitement Par objet Par lot
Scalabilité Mauvaise au nombre Scalabilité linéaire
Idéal pour Comportements complexes Simulation massive

2. Composants DOTS

Entity: ID léger (pas de données)
Component: Données pures (pas de comportement)
System: Logique qui traite les composants
World: Conteneur pour les entités
Archetype: Combinaison unique de composants
Chunk: Bloc mémoire pour entités du même archetype

Patterns

Pattern 1: Configuration ECS basique

using Unity.Entities;
using Unity.Mathematics;
using Unity.Transforms;
using Unity.Burst;
using Unity.Collections;

// Component: Données pures, sans méthodes
public struct Speed : IComponentData
{
    public float Value;
}

public struct Health : IComponentData
{
    public float Current;
    public float Max;
}

public struct Target : IComponentData
{
    public Entity Value;
}

// Composant tag (marqueur de taille zéro)
public struct EnemyTag : IComponentData { }
public struct PlayerTag : IComponentData { }

// Composant buffer (tableau de taille variable)
[InternalBufferCapacity(8)]
public struct InventoryItem : IBufferElementData
{
    public int ItemId;
    public int Quantity;
}

// Composant partagé (entités groupées)
public struct TeamId : ISharedComponentData
{
    public int Value;
}

Pattern 2: Systèmes avec ISystem (Recommandé)

using Unity.Entities;
using Unity.Transforms;
using Unity.Mathematics;
using Unity.Burst;

// ISystem: Non-managé, compatible Burst, performance maximale
[BurstCompile]
public partial struct MovementSystem : ISystem
{
    [BurstCompile]
    public void OnCreate(ref SystemState state)
    {
        // Exiger des composants avant l'exécution du système
        state.RequireForUpdate<Speed>();
    }

    [BurstCompile]
    public void OnUpdate(ref SystemState state)
    {
        float deltaTime = SystemAPI.Time.DeltaTime;

        // Foreach simple - génère automatiquement le job
        foreach (var (transform, speed) in
            SystemAPI.Query<RefRW<LocalTransform>, RefRO<Speed>>())
        {
            transform.ValueRW.Position +=
                new float3(0, 0, speed.ValueRO.Value * deltaTime);
        }
    }

    [BurstCompile]
    public void OnDestroy(ref SystemState state) { }
}

// Avec job explicite pour plus de contrôle
[BurstCompile]
public partial struct MovementJobSystem : ISystem
{
    [BurstCompile]
    public void OnUpdate(ref SystemState state)
    {
        var job = new MoveJob
        {
            DeltaTime = SystemAPI.Time.DeltaTime
        };

        state.Dependency = job.ScheduleParallel(state.Dependency);
    }
}

[BurstCompile]
public partial struct MoveJob : IJobEntity
{
    public float DeltaTime;

    void Execute(ref LocalTransform transform, in Speed speed)
    {
        transform.Position += new float3(0, 0, speed.Value * DeltaTime);
    }
}

Pattern 3: Requêtes d'entités

[BurstCompile]
public partial struct QueryExamplesSystem : ISystem
{
    private EntityQuery _enemyQuery;

    public void OnCreate(ref SystemState state)
    {
        // Construire la requête manuellement pour les cas complexes
        _enemyQuery = new EntityQueryBuilder(Allocator.Temp)
            .WithAll<EnemyTag, Health, LocalTransform>()
            .WithNone<Dead>()
            .WithOptions(EntityQueryOptions.FilterWriteGroup)
            .Build(ref state);
    }

    [BurstCompile]
    public void OnUpdate(ref SystemState state)
    {
        // SystemAPI.Query - approche la plus simple
        foreach (var (health, entity) in
            SystemAPI.Query<RefRW<Health>>()
                .WithAll<EnemyTag>()
                .WithEntityAccess())
        {
            if (health.ValueRO.Current <= 0)
            {
                // Marquer pour destruction
                SystemAPI.GetSingleton<EndSimulationEntityCommandBufferSystem.Singleton>()
                    .CreateCommandBuffer(state.WorldUnmanaged)
                    .DestroyEntity(entity);
            }
        }

        // Obtenir le nombre
        int enemyCount = _enemyQuery.CalculateEntityCount();

        // Obtenir toutes les entités
        var enemies = _enemyQuery.ToEntityArray(Allocator.Temp);

        // Obtenir les tableaux de composants
        var healths = _enemyQuery.ToComponentDataArray<Health>(Allocator.Temp);
    }
}

Pattern 4: Tampons de commande d'entité (Changements structurels)

// Les changements structurels (créer/détruire/ajouter/retirer) requièrent des tampons de commande
[BurstCompile]
[UpdateInGroup(typeof(SimulationSystemGroup))]
public partial struct SpawnSystem : ISystem
{
    [BurstCompile]
    public void OnUpdate(ref SystemState state)
    {
        var ecbSingleton = SystemAPI.GetSingleton<BeginSimulationEntityCommandBufferSystem.Singleton>();
        var ecb = ecbSingleton.CreateCommandBuffer(state.WorldUnmanaged);

        foreach (var (spawner, transform) in
            SystemAPI.Query<RefRW<Spawner>, RefRO<LocalTransform>>())
        {
            spawner.ValueRW.Timer -= SystemAPI.Time.DeltaTime;

            if (spawner.ValueRO.Timer <= 0)
            {
                spawner.ValueRW.Timer = spawner.ValueRO.Interval;

                // Créer une entité (différée jusqu'au point de synchronisation)
                Entity newEntity = ecb.Instantiate(spawner.ValueRO.Prefab);

                // Définir les valeurs de composants
                ecb.SetComponent(newEntity, new LocalTransform
                {
                    Position = transform.ValueRO.Position,
                    Rotation = quaternion.identity,
                    Scale = 1f
                });

                // Ajouter un composant
                ecb.AddComponent(newEntity, new Speed { Value = 5f });
            }
        }
    }
}

// Utilisation parallèle du ECB
[BurstCompile]
public partial struct ParallelSpawnJob : IJobEntity
{
    public EntityCommandBuffer.ParallelWriter ECB;

    void Execute([EntityIndexInQuery] int index, in Spawner spawner)
    {
        Entity e = ECB.Instantiate(index, spawner.Prefab);
        ECB.AddComponent(index, e, new Speed { Value = 5f });
    }
}

Pattern 5: Aspect (Groupement de composants)

using Unity.Entities;
using Unity.Transforms;
using Unity.Mathematics;

// Aspect: Groupe les composants liés pour un code plus lisible
public readonly partial struct CharacterAspect : IAspect
{
    public readonly Entity Entity;

    private readonly RefRW<LocalTransform> _transform;
    private readonly RefRO<Speed> _speed;
    private readonly RefRW<Health> _health;

    // Composant optionnel
    [Optional]
    private readonly RefRO<Shield> _shield;

    // Buffer
    private readonly DynamicBuffer<InventoryItem> _inventory;

    public float3 Position
    {
        get => _transform.ValueRO.Position;
        set => _transform.ValueRW.Position = value;
    }

    public float CurrentHealth => _health.ValueRO.Current;
    public float MaxHealth => _health.ValueRO.Max;
    public float MoveSpeed => _speed.ValueRO.Value;

    public bool HasShield => _shield.IsValid;
    public float ShieldAmount => HasShield ? _shield.ValueRO.Amount : 0f;

    public void TakeDamage(float amount)
    {
        float remaining = amount;

        if (HasShield && _shield.ValueRO.Amount > 0)
        {
            // Le bouclier absorbe les dégâts en premier
            remaining = math.max(0, amount - _shield.ValueRO.Amount);
        }

        _health.ValueRW.Current = math.max(0, _health.ValueRO.Current - remaining);
    }

    public void Move(float3 direction, float deltaTime)
    {
        _transform.ValueRW.Position += direction * _speed.ValueRO.Value * deltaTime;
    }

    public void AddItem(int itemId, int quantity)
    {
        _inventory.Add(new InventoryItem { ItemId = itemId, Quantity = quantity });
    }
}

// Utiliser l'aspect dans le système
[BurstCompile]
public partial struct CharacterSystem : ISystem
{
    [BurstCompile]
    public void OnUpdate(ref SystemState state)
    {
        float dt = SystemAPI.Time.DeltaTime;

        foreach (var character in SystemAPI.Query<CharacterAspect>())
        {
            character.Move(new float3(1, 0, 0), dt);

            if (character.CurrentHealth < character.MaxHealth * 0.5f)
            {
                // Logique pour santé basse
            }
        }
    }
}

Pattern 6: Composants Singleton

// Singleton: Exactement une entité avec ce composant
public struct GameConfig : IComponentData
{
    public float DifficultyMultiplier;
    public int MaxEnemies;
    public float SpawnRate;
}

public struct GameState : IComponentData
{
    public int Score;
    public int Wave;
    public float TimeRemaining;
}

// Créer le singleton à la création du monde
public partial struct GameInitSystem : ISystem
{
    public void OnCreate(ref SystemState state)
    {
        var entity = state.EntityManager.CreateEntity();
        state.EntityManager.AddComponentData(entity, new GameConfig
        {
            DifficultyMultiplier = 1,0f,
            MaxEnemies = 100,
            SpawnRate = 2,0f
        });
        state.EntityManager.AddComponentData(entity, new GameState
        {
            Score = 0,
            Wave = 1,
            TimeRemaining = 120f
        });
    }
}

// Accéder au singleton dans le système
[BurstCompile]
public partial struct ScoreSystem : ISystem
{
    [BurstCompile]
    public void OnUpdate(ref SystemState state)
    {
        // Lire le singleton
        var config = SystemAPI.GetSingleton<GameConfig>();

        // Écrire le singleton
        ref var gameState = ref SystemAPI.GetSingletonRW<GameState>().ValueRW;
        gameState.TimeRemaining -= SystemAPI.Time.DeltaTime;

        // Vérifier l'existence
        if (SystemAPI.HasSingleton<GameConfig>())
        {
            // ...
        }
    }
}

Pattern 7: Baking (Conversion de GameObjects)

using Unity.Entities;
using UnityEngine;

// Composant authoring (MonoBehaviour en éditeur)
public class EnemyAuthoring : MonoBehaviour
{
    public float Speed = 5f;
    public float Health = 100f;
    public GameObject ProjectilePrefab;

    class Baker : Baker<EnemyAuthoring>
    {
        public override void Bake(EnemyAuthoring authoring)
        {
            var entity = GetEntity(TransformUsageFlags.Dynamic);

            AddComponent(entity, new Speed { Value = authoring.Speed });
            AddComponent(entity, new Health
            {
                Current = authoring.Health,
                Max = authoring.Health
            });
            AddComponent(entity, new EnemyTag());

            if (authoring.ProjectilePrefab != null)
            {
                AddComponent(entity, new ProjectilePrefab
                {
                    Value = GetEntity(authoring.ProjectilePrefab, TransformUsageFlags.Dynamic)
                });
            }
        }
    }
}

// Baking complexe avec dépendances
public class SpawnerAuthoring : MonoBehaviour
{
    public GameObject[] Prefabs;
    public float Interval = 1f;

    class Baker : Baker<SpawnerAuthoring>
    {
        public override void Bake(SpawnerAuthoring authoring)
        {
            var entity = GetEntity(TransformUsageFlags.Dynamic);

            AddComponent(entity, new Spawner
            {
                Interval = authoring.Interval,
                Timer = 0f
            });

            // Baker le buffer des prefabs
            var buffer = AddBuffer<SpawnPrefabElement>(entity);
            foreach (var prefab in authoring.Prefabs)
            {
                buffer.Add(new SpawnPrefabElement
                {
                    Prefab = GetEntity(prefab, TransformUsageFlags.Dynamic)
                });
            }

            // Déclarer les dépendances
            DependsOn(authoring.Prefabs);
        }
    }
}

Pattern 8: Jobs avec Native Collections

using Unity.Jobs;
using Unity.Collections;
using Unity.Burst;
using Unity.Mathematics;

[BurstCompile]
public struct SpatialHashJob : IJobParallelFor
{
    [ReadOnly]
    public NativeArray<float3> Positions;

    // Écriture thread-safe sur hash map
    public NativeParallelMultiHashMap<int, int>.ParallelWriter HashMap;

    public float CellSize;

    public void Execute(int index)
    {
        float3 pos = Positions[index];
        int hash = GetHash(pos);
        HashMap.Add(hash, index);
    }

    int GetHash(float3 pos)
    {
        int x = (int)math.floor(pos.x / CellSize);
        int y = (int)math.floor(pos.y / CellSize);
        int z = (int)math.floor(pos.z / CellSize);
        return x * 73856093 ^ y * 19349663 ^ z * 83492791;
    }
}

[BurstCompile]
public partial struct SpatialHashSystem : ISystem
{
    private NativeParallelMultiHashMap<int, int> _hashMap;

    public void OnCreate(ref SystemState state)
    {
        _hashMap = new NativeParallelMultiHashMap<int, int>(10000, Allocator.Persistent);
    }

    public void OnDestroy(ref SystemState state)
    {
        _hashMap.Dispose();
    }

    [BurstCompile]
    public void OnUpdate(ref SystemState state)
    {
        var query = SystemAPI.QueryBuilder()
            .WithAll<LocalTransform>()
            .Build();

        int count = query.CalculateEntityCount();

        // Redimensionner si nécessaire
        if (_hashMap.Capacity < count)
        {
            _hashMap.Capacity = count * 2;
        }

        _hashMap.Clear();

        // Obtenir les positions
        var positions = query.ToComponentDataArray<LocalTransform>(Allocator.TempJob);
        var posFloat3 = new NativeArray<float3>(count, Allocator.TempJob);

        for (int i = 0; i < count; i++)
        {
            posFloat3[i] = positions[i].Position;
        }

        // Construire la hash map
        var hashJob = new SpatialHashJob
        {
            Positions = posFloat3,
            HashMap = _hashMap.AsParallelWriter(),
            CellSize = 10f
        };

        state.Dependency = hashJob.Schedule(count, 64, state.Dependency);

        // Nettoyage
        positions.Dispose(state.Dependency);
        posFloat3.Dispose(state.Dependency);
    }
}

Conseils de performance

// 1. Utiliser Burst partout
[BurstCompile]
public partial struct MySystem : ISystem { }

// 2. Préférer IJobEntity à l'itération manuelle
[BurstCompile]
partial struct OptimizedJob : IJobEntity
{
    void Execute(ref LocalTransform transform) { }
}

// 3. Planifier en parallèle quand c'est possible
state.Dependency = job.ScheduleParallel(state.Dependency);

// 4. Utiliser ScheduleParallel avec itération par chunk
[BurstCompile]
partial struct ChunkJob : IJobChunk
{
    public ComponentTypeHandle<Health> HealthHandle;

    public void Execute(in ArchetypeChunk chunk, int unfilteredChunkIndex,
        bool useEnabledMask, in v128 chunkEnabledMask)
    {
        var healths = chunk.GetNativeArray(ref HealthHandle);
        for (int i = 0; i < chunk.Count; i++)
        {
            // Traiter
        }
    }
}

// 5. Éviter les changements structurels dans les chemins critiques
// Utiliser les composants activables plutôt que ajouter/retirer
public struct Disabled : IComponentData, IEnableableComponent { }

Bonnes pratiques

À faire

  • Utiliser ISystem plutôt que SystemBase - Meilleure performance
  • Compiler avec Burst partout - Accélération massive
  • Traiter par lot les changements structurels - Utiliser ECB
  • Profiler avec Profiler - Identifier les goulots
  • Utiliser Aspects - Regroupement lisible des composants

À ne pas faire

  • Ne pas utiliser de types managés - Casse Burst
  • Ne pas faire de changements structurels dans les jobs - Utiliser ECB
  • Ne pas sur-architecturer - Commencer simple
  • Ne pas ignorer l'utilisation des chunks - Grouper les entités similaires
  • Ne pas oublier la libération - Les collections natives fuient

Skills similaires