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