Kanda SDK 0.6.0
Loading...
Searching...
No Matches
Data-Oriented Design

When working with Unity DOTS (Data-Oriented Technology Stack), it's essential to follow certain preferences and best practices to maximize performance and maintainability. This document outlines these practices, focusing on key principles of data-oriented design.

Key Practices and Preferences

Data-oriented design focuses on optimizing data layout and access patterns to improve performance and scalability. The following practices help achieve these goals within Unity DOTS.

Prefer ISystem

Use ISystem for defining systems with idiomatic foreach constructs for processing entities. This is the approach recommended by Unity as it maximises performance assuming you don't need to access managed data.

public partial struct MovementSystem : ISystem
{
// ISystem callbacks can be Burst compiled
[BurstCompile]
public void OnUpdate(ref SystemState state)
{
// Idiomatic foreach handles caching of the query for you with codegen
foreach (var (velocity, translation) in SystemAPI.Query<RefRW<Velocity>, RefRW<Translation>>())
{
translation.ValueRW.Value += velocity.ValueRO.Value * SystemAPI.Time.DeltaTime;
}
}
}

Use Burst

When possible, use Burst compilation with the [BurstCompile]attribute. This can yield significantly better performance in many cases when processing Collections.

Prefer Jobs

Utilize Unity's Job system to parallelize work and improve performance for performance-sensitive systems. Jobs enable safe and efficient multi-threading, reducing the workload on the main thread.

[BurstCompile]
public struct MoveJob : IJobEntity
{
public float DeltaTime;
public void Execute(ref Translation translation, in Velocity velocity)
{
translation.Value += velocity.Value * DeltaTime;
}
}

Entity Relationships

Efficiently managing entity relationships is crucial for performance. The following guidelines help optimize data access patterns and minimize random memory access.

Prefer data on single entity

Prefer packing data tightly on single entities with systems that operate on them. This allows for existential processing, reducing branching, and minimizing random memory access to enhance performance.

Example:

public struct Position : IComponentData
{
public float3 Value;
}
public struct Velocity : IComponentData
{
public float3 Value;
}
public partial struct MovementSystem : ISystem
{
public void OnUpdate(ref SystemState state)
{
// Query entities where position and velocity components are packed together
foreach (var (position, velocity) in SystemAPI.Query<RefRW<Position>, RefRO<Velocity>>())
{
position.ValueRW.Value += velocity.ValueRO.Value * SystemAPI.Time.DeltaTime;
}
}
}

Write-only to target entity

If you can't pack everything on a single entity, prefer writing only to your target entity and reading only from other entities. This allows for multi-threading across the entity query.

public struct FollowTarget : IComponentData
{
public Entity Target;
}
public partial struct FollowTargetSystem : ISystem
{
public void OnUpdate(ref SystemState state)
{
var targetPositions = SystemAPI.GetComponentLookup<Position>(isReadOnly: true);
foreach (var (position, followTarget) in SystemAPI.Query<RefRW<Position>, RefRO<FollowTarget>>())
{
// This work can run parallel across the query because we are only reading from other entities
if (targetPositions.TryGetComponent(followTarget.ValueRO.Target, out var targetPosition))
{
position.ValueRW.Value = targetPosition.Value;
}
}
}
}

Least Preferred: Writing to other entities

Writing relationships to other entities should be avoided when possible but is sometimes necessary. This approach requires random access and prevents multi-threading across the entity query. However, scheduled Jobs can still run parallel to other Jobs that do not overlap the same data.

public struct Health : IComponentData
{
public int Value;
}
public struct DamageTarget : IComponentData
{
public Entity TargetEntity;
}
public partial struct DamageSystem : ISystem
{
public void OnUpdate(ref SystemState state)
{
var healthLookup = SystemAPI.GetComponentLookup<Health>(isReadOnly: false);
foreach (var (damageTarget, entity) in SystemAPI.Query<DamageTarget>().WithEntityAccess())
{
// This work cannot run parallel across the query because we are writing to other entities
if (healthLookup.TryGetComponent(damageTarget.TargetEntity, out var health))
{
health.Value -= 10;
healthLookup[damageTarget.TargetEntity] = health;
}
}
}
}
Note
If you need it, EntityCommandBuffer can be used to schedule changes in parallel and play them back later.

Component Design

Designing components efficiently ensures that data is relevant and accessible, which enhances performance and maintainability.

Small Components

Design small components with only the data relevant to that component and its domain. It's acceptable to have many small components used in different configurations, as this is preferable to bloating a single component with unnecessary data.

public struct Position : IComponentData
{
public float3 Value;
}
public struct Rotation : IComponentData
{
public quaternion Value;
}

Use Tag and Enableable Components

Tag and enableable components optimize performance and flexibility in ECS by allowing for existential processing.

Tag Components

Tag components mark entities for specific processing, reducing branching and simplifying queries.

public struct EnemyTag : IComponentData { }
[BurstCompile]
public partial struct EnemySystem : ISystem
{
public void OnUpdate(ref SystemState state)
{
// When a grouping of data signifies a specific thing or feature, we can use tags to query it
foreach (var (enemy, entity) in SystemAPI.Query<RefRW<EnemyComponent>>().WithEntityAccess().WithAll<EnemyTag>())
{
// Process enemies
}
}
}

Enableable components control entity states without adding or removing components, enhancing performance.

public struct EnemyActive : IEnableableComponent { }
[BurstCompile]
public partial struct EnemyActivationSystem : ISystem
{
public void OnUpdate(ref SystemState state)
{
foreach (var (enemy, entity) in SystemAPI.Query<RefRW<EnemyComponent>, RefRW<EnemyActive>>().WithEntityAccess())
{
bool shouldActivate = /* Logic to activate enemy */;
SystemAPI.SetComponentEnabled<EnemyActive>(entity, shouldActivate);
}
}
}
[BurstCompile]
public partial struct ActiveEnemySystem : ISystem
{
public void OnUpdate(ref SystemState state)
{
foreach (var (enemy, entity) in SystemAPI.Query<RefRW<EnemyComponent>>().WithEntityAccess().WithAll<EnemyActive>())
{
// Process active enemies
}
}
}

Use tag components for marking entities and enableable components for toggling states to improve ECS efficiency and flexibility.

System Coupling

Minimizing system coupling ensures that systems remain independent, enhancing parallelization and simplifying system management.

Self-Contained Systems

Aim for systems that are completely self-contained with minimal interaction with other systems. This maximizes parallelization and simplifies system management.

Messaging Over Direct Interaction

If system interaction is necessary, consider asynchronous approaches. Push an entity, component, or message onto a message bus and let other systems handle it later. This method preserves the benefits of parallelization and decouples system dependencies.

public struct Health : IComponentData
{
public float Value;
}
public struct DamageEvent : IComponentData
{
public Entity DamagedEntity;
public float DamageAmount;
}
public partial struct DamageEventHealthSystem : ISystem
{
public void OnUpdate(ref SystemState state)
{
// Handle damage events...
}
}

By adhering to these data-oriented design principles and practices, you can ensure that your Unity DOTS projects are performant, maintainable, and scalable.