DOTS技术栈的核心思想是"数据优先,计算其次"。传统Unity开发习惯以对象为中心组织代码——每个玩家挂一个Player脚本,每个敌人挂一个Enemy脚本,逻辑分散在各个MonoBehaviour里。DOTS换了个思路:先把数据按类型排整齐,再让计算去遍历这些数据。这样做的好处是CPU能更高效地处理大量数据,在需要处理成百上千甚至上万个实体时,性能差距非常明显。
但DOTS的学习曲线确实陡峭。从MonoBehaviour迁移过来时,很多开发者会踩到不少坑:组件设计不合理、Job依赖写错、数据布局导致性能反而下降。下面从实际问题出发,一步步梳理常见的开发难点和优化方案。
一、DOTS技术栈的基本组成
1.1 ECS:把"对象"拆成"数据块"
ECS由三部分组成:Entity(实体)只是一个数字ID,没有任何数据;Component(组件)是纯数据容器,没有方法,只存字段;System(系统)负责遍历实体、读取数据、做计算并写回。
// 技术栈:Unity DOTS (ECS)
using Unity.Entities;
using Unity.Mathematics;
using Unity.Burst;
using Unity.Collections;
using Unity.Jobs;
// ========================================
// 组件:纯数据容器,不带任何逻辑
// ========================================
/// <summary>位置组件 - 存储世界坐标</summary>
public struct Position : IComponentData
{
public float3 Value;
}
/// <summary>速度组件 - 存储移动方向和速度</summary>
public struct Velocity : IComponentData
{
public float3 Value;
}
/// <summary>最大速度限制组件</summary>
public struct MaxSpeed : IComponentData
{
public float Value = 5.0f;
}
/// <summary>生命值组件</summary>
public struct Health : IComponentData
{
public int Current;
public int Max;
}
// ========================================
// System:负责遍历和计算
// ========================================
/// <summary>移动系统 - 每帧遍历所有有位置和速度的实体</summary>
public class MovementSystem : SystemBase
{
protected override void OnUpdate()
{
// 获取当前帧的时间步长
float deltaTime = SystemAPI.Time.DeltaTime;
// 批量查询同时拥有Position和Velocity的实体
var query = SystemAPI.EntityQueryConstructSettings(
ComponentType.ReadOnly<Position>(),
ComponentType.ReadOnly<Velocity>()
);
// 使用EntityCommandBuffer做写入,延迟到帧末统一执行
var ecb = SystemAPI.GetEntityCommandBuffer();
foreach (var entity in SystemAPI.Iter(ref query))
{
// 读取当前位置
var pos = SystemAPI.GetComponentData<Position>(entity);
// 读取当前速度
var vel = SystemAPI.GetComponentData<Velocity>(entity);
// 更新位置:新位置 = 旧位置 + 速度 × 时间步长
float3 newPos = pos.Value + vel.Value * deltaTime;
// 通过ECB写回,避免在遍历中直接修改数据
ecb.SetComponentData(entity, new Position { Value = newPos });
}
}
}
1.2 Job System:把计算扔给后台线程
Job System是让CPU多核并行的关键。传统MonoBehaviour的Update方法跑在主线程,DOTS的System可以通过Job分配到后台线程,让主线程继续处理输入、UI等任务。
// 技术栈:Unity DOTS (Job System)
using Unity.Entities;
using Unity.Burst;
using Unity.Jobs;
using Unity.Collections;
using Unity.Mathematics;
// ========================================
// Job结构体必须是struct,不能是class
// IJobEntity表示逐实体Job
// [BurstCompile]表示会被编译成原生代码
// ========================================
[BurstCompile]
public struct ApplyForceJob : IJobEntity
{
public float ForceMultiplier;
public float TimeScale;
/// <summary>对每个匹配的实体执行一次</summary>
/// <returns>返回true继续处理,false停止</returns>
public bool Execute(ref Position pos, ref Velocity vel, in MaxSpeed maxSpeed)
{
// 计算当前速度长度
float currentSpeed = math.length(vel.Value);
// 如果超过最大速度,归一化并限制
if (currentSpeed > maxSpeed.Value)
{
vel.Value = math.normalize(vel.Value) * maxSpeed.Value;
}
// 更新位置
pos.Value += vel.Value * ForceMultiplier * TimeScale;
return true;
}
}
// ========================================
// 在System中调度Job
// ========================================
public class ForceApplicationSystem : SystemBase
{
protected override void OnUpdate()
{
float deltaTime = SystemAPI.Time.DeltaTime;
var query = SystemAPI.EntityQueryConstructSettings(
ComponentType.ReadWrite<Position>(),
ComponentType.ReadWrite<Velocity>(),
ComponentType.ReadOnly<MaxSpeed>()
);
// 调度Job,ScheduleBatchedEntityJobs批量处理
var jobHandle = SystemAPI.ScheduleBatchedEntityJobs(
new ApplyForceJob
{
ForceMultiplier = 1.0f,
TimeScale = deltaTime
},
count: 100 // 每批处理100个实体
);
// 阻塞等待Job完成
jobHandle.Complete();
}
}
1.3 Burst Compiler:让C#变成汇编级优化
Burst Compiler是Unity的即时编译优化器,能把标注了[BurstCompile]的C#代码编译成针对当前CPU的微优化汇编代码。在数值密集型计算中效果尤为明显,一个向量运算Burst编译后可能快3-5倍,加上SIMD优化在某些场景下能快10倍以上。
1.4 Native Collections:绕过GC的数据容器
Unity默认的List、Dictionary等托管类型会产生GC压力。DOTS提供了NativeArray、DynamicBuffer等原生集合类型,直接管理非托管内存,不触发GC。
// 技术栈:Unity DOTS (Native Collections)
using Unity.Collections;
using Unity.Mathematics;
using Unity.Jobs;
using Unity.Burst;
// ========================================
// 使用NativeArray替代List
// ========================================
public class PhysicsSimulation : IDisposable
{
// NativeArray直接分配在非托管内存,不受GC管理
NativeArray<float3> positions;
NativeArray<float3> velocities;
public void Initialize(int capacity)
{
// Allocator.TempJob:临时分配,Job调度时自动释放
positions = new NativeArray<float3>(capacity, Allocator.TempJob);
velocities = new NativeArray<float3>(capacity, Allocator.TempJob);
}
public void Simulate(float deltaTime, int count)
{
// 使用Job并行处理,避免主线程循环
var job = new SimulatePhysicsJob
{
Positions = positions,
Velocities = velocities,
DeltaTime = deltaTime
};
job.Schedule(count, 8).Complete(); // 8个批次并行执行
}
public void Dispose()
{
// 必须显式释放,否则内存泄漏
positions.Dispose();
velocities.Dispose();
}
}
[BurstCompile]
public struct SimulatePhysicsJob : IJobParallelFor
{
[NativeDisableParallelForRestriction]
public NativeArray<float3> Positions;
[NativeDisableParallelForRestriction]
public NativeArray<float3> Velocities;
public float DeltaTime;
public void Execute(int index)
{
// 重力加速度向下
Velocities[index] += new float3(0, -9.8f, 0) * DeltaTime;
Positions[index] += Velocities[index] * DeltaTime;
}
}
二、面向数据的架构设计原则
2.1 组件要小、要纯
组件是"热数据",会被CPU频繁读写。如果组件设计不合理,内存访问效率会大幅下降。
第一,组件只存数据,不写逻辑。不能写方法、写属性,组件就是纯数据块。第二,尽量让相关数据排在同一个组件里。如果System每次都要读两个不同组件,CPU需要两次内存跳转;合在一个组件里就能一次读取连续内存。第三,拆分会改变数据的组件。如果System A只写位置不读速度,System B只写速度不读位置,分开存放可以避免写时复制开销。
2.2 Entity的生命周期
Entity的创建和销毁通过Archetype管理。给Entity添加或删除组件时,它可能从一个Archetype迁移到另一个,触发数据搬迁。频繁添加移除组件会导致大量Archetype迁移,严重拖慢性能。最佳实践是保持组件组合不变,需要状态变化时用空标记组件表示切换。
2.3 数据布局:SoA vs AoS
传统C#按对象排列数据(Array of Structures),DOTS按组件类型分块存储(Structure of Arrays):所有位置组件排一起,所有速度组件排一起。遍历时CPU只需读一段连续内存,不需要跳跃查找,效率极高。理解了这一点,就能明白"组件要小"和"相关数据放一起"为什么重要——它们直接影响CPU内存访问模式。
三、常见开发问题与解决方案
3.1 组件数据不连续导致性能下降
新手最常遇到的性能问题。System遍历Entity时,如果数据分散在不同Archetype中,CPU需要频繁在不同内存区域跳跃,缓存命中率很低。
// 技术栈:Unity DOTS (Entity Query Optimization)
using Unity.Entities;
using Unity.Mathematics;
using Unity.Collections;
// ========================================
// 优化前:逐个访问,数据可能分散
// ========================================
public class NaiveQuerySystem : SystemBase
{
protected override void OnUpdate()
{
var query = SystemAPI.EntityQueryConstructSettings(
ComponentType.ReadOnly<Position>(),
ComponentType.ReadOnly<Health>()
);
// 逐个获取,每次都是随机访问
foreach (var entity in SystemAPI.Iter(ref query))
{
var pos = SystemAPI.GetComponentData<Position>(entity);
var health = SystemAPI.GetComponentData<Health>(entity);
// CPU缓存命中率低,性能差
}
}
}
// ========================================
// 优化后:IJobChunk按内存块遍历
// ========================================
[BurstCompile]
public struct ChunkAccessJob : IJobChunk
{
public float3 CheckTarget;
public void Execute(ArchetypeChunk chunk, int chunkIndex, int firstEntityIndex)
{
// 获取当前Chunk中所有Position的NativeArray
var chunkPositions = chunk.GetNativeArray(0);
// 获取当前Chunk中所有Health的NativeArray
var chunkHealths = chunk.GetNativeArray(1);
// 遍历当前Chunk内所有实体,数据连续,缓存友好
for (int i = 0; i < chunk.Count; i++)
{
float3 dir = CheckTarget - chunkPositions[i].Value;
float distance = math.length(dir);
// 距离小于10且血量大于0的实体...
if (distance < 10 && chunkHealths[i].Current > 0)
{
// 处理逻辑
}
}
}
}
public class OptimizedQuerySystem : SystemBase
{
protected override void OnUpdate()
{
var job = new ChunkAccessJob
{
CheckTarget = new float3(0, 0, 10)
};
var query = SystemAPI.EntityQueryConstructSettings(
ComponentType.ReadOnly<Position>(),
ComponentType.ReadOnly<Health>()
);
var handle = SystemAPI.ScheduleBlockForProcessing(ref query, new JobHandle(), job);
handle.Complete();
}
}
3.2 频繁GC导致帧率不稳定
即使使用DOTS,如果在System中创建托管对象,GC压力依然会回来。最常见场景是字符串拼接、动态数组或LINQ。
// 技术栈:Unity DOTS (GC-Free Practices)
using Unity.Entities;
using Unity.Mathematics;
using Unity.Collections;
using Unity.Jobs;
// ========================================
// ❌ 错误:产生GC压力的写法
// ========================================
public class BadGcSystem : SystemBase
{
protected override void OnUpdate()
{
// ❌ 字符串拼接,每次循环分配新字符串
string log = "";
var query = SystemAPI.EntityQueryConstructSettings(
ComponentType.ReadOnly<Position>()
);
foreach (var entity in SystemAPI.Iter(ref query))
{
var pos = SystemAPI.GetComponentData<Position>(entity);
log += $"Entity {entity} at {pos.Value}\n";
}
// ❌ 创建List,触发GC
var tempList = new System.Collections.Generic.List<float3>();
tempList.Add(new float3(1, 2, 3));
// ❌ LINQ遍历,内部产生大量托管对象
var result = SystemAPI.Iter(ref query)
.Select(e => SystemAPI.GetComponentData<Position>(e).Value);
}
}
// ========================================
// ✅ 正确:零GC的Job实现
// ========================================
[BurstCompile]
public struct GCFreeCollectJob : IJobChunk
{
// NativeArray接收结果,不产生GC
[NativeSetAccessor] public NativeArray<float3> OutputPositions;
[NativeSetAccessor] public NativeArray<int> OutputIndices;
[NativeDisableParallelForRestriction] public NativeArray<int> Counter;
public void Execute(ArchetypeChunk chunk, int chunkIndex, int firstEntityIndex)
{
var positions = chunk.GetNativeArray(0);
for (int i = 0; i < chunk.Count; i++)
{
float3 pos = positions[i].Value;
if (pos.x > 0)
{
// 原子递增,避免多线程竞争
int index = NativeCollections.Atomic.Add(Counter, 0, 1);
OutputPositions[index] = pos;
OutputIndices[index] = firstEntityIndex + i;
}
}
}
}
// ========================================
// 零GC的System
// ========================================
public class GoodGcFreeSystem : SystemBase
{
protected override void OnUpdate()
{
var query = SystemAPI.EntityQueryConstructSettings(
ComponentType.ReadOnly<Position>()
);
int count = query.CalculateMatchCount();
// 预分配NativeArray
var outputPositions = new NativeArray<float3>(count, Allocator.TempJob);
var outputIndices = new NativeArray<int>(count, Allocator.TempJob);
var counter = new NativeArray<int>(1, Allocator.TempJob);
// 调度Job
var job = new GCFreeCollectJob
{
OutputPositions = outputPositions,
OutputIndices = outputIndices,
Counter = counter
};
var handle = SystemAPI.ScheduleBlockForProcessing(ref query, new JobHandle(), job);
handle.Complete();
// 处理结果...
// ✅ 必须释放临时NativeArray
outputPositions.Dispose();
outputIndices.Dispose();
counter.Dispose();
}
}
3.3 Job依赖写错导致数据竞争
两个Job同时修改同一块数据会出现竞态条件,导致数据不一致或崩溃。这是DOTS中最容易出bug的地方。
// 技术栈:Unity DOTS (Job Dependency Management)
using Unity.Entities;
using Unity.Jobs;
using Unity.Mathematics;
using Unity.Burst;
// ========================================
// 定义两个有依赖关系的Job
// ========================================
/// <summary>第一个Job:计算方向向量</summary>
[BurstCompile]
public struct CalculateDirectionJob : IJobEntity
{
public float3 Target;
/// <summary>WriteAfter标记:写入在其他Job之后</summary>
public void Execute(ref Position pos, [WriteAfter] ref Velocity vel)
{
float3 dir = Target - pos.Value;
vel.Value = math.normalize(dir) * 5.0f;
}
}
/// <summary>第二个Job:应用速度</summary>
[BurstCompile]
public struct ApplyVelocityJob : IJobEntity
{
public float DeltaTime;
public void Execute(ref Position pos, in Velocity vel)
{
pos.Value += vel.Value * DeltaTime;
}
}
// ========================================
// 正确调度:串行依赖(安全但慢)
// ========================================
public class SequentialSystem : SystemBase
{
protected override void OnUpdate()
{
float deltaTime = SystemAPI.Time.DeltaTime;
var query = SystemAPI.EntityQueryConstructSettings(
ComponentType.ReadWrite<Position>(),
ComponentType.ReadWrite<Velocity>()
);
// 第一步:计算方向
var dirHandle = SystemAPI.ScheduleBlockForProcessing(
ref query,
new JobHandle(),
new CalculateDirectionJob { Target = new float3(10, 0, 0) }
);
// 第二步:应用速度(依赖方向计算完成)
// ← 关键:传入dirHandle作为依赖,确保顺序执行
var applyHandle = SystemAPI.ScheduleBlockForProcessing(
ref query,
dirHandle,
new ApplyVelocityJob { DeltaTime = deltaTime }
);
applyHandle.Complete();
}
}
// ========================================
// 进阶优化:合并Job消除依赖
// ========================================
[BurstCompile]
public struct CombinedMoveJob : IJobEntity
{
public float DeltaTime;
public float3 Target;
/// <summary>合并计算方向和应用移动,无需依赖管理</summary>
public void Execute(ref Position pos, [WriteAfter] ref Velocity vel)
{
// 内联计算方向,避免跨Job依赖
float3 dir = Target - pos.Value;
vel.Value = math.normalize(dir) * 5.0f;
pos.Value += vel.Value * DeltaTime;
}
}
public class CombinedSystem : SystemBase
{
protected override void OnUpdate()
{
float deltaTime = SystemAPI.Time.DeltaTime;
var query = SystemAPI.EntityQueryConstructSettings(
ComponentType.ReadWrite<Position>(),
ComponentType.ReadWrite<Velocity>()
);
// 一个Job搞定所有逻辑,无需依赖链
var handle = SystemAPI.ScheduleBlockForProcessing(
ref query,
new JobHandle(),
new CombinedMoveJob { DeltaTime = deltaTime, Target = new float3(10, 0, 0) }
);
handle.Complete();
}
}
3.4 ComponentDataFromEntity缓存失效
ComponentDataFromEntity基于缓存,用错地方性能下降且可能返回脏数据。
// 技术栈:Unity DOTS (ComponentDataFromEntity Best Practices)
using Unity.Entities;
using Unity.Mathematics;
using Unity.Jobs;
// ========================================
// ✅ 正确用法:OnCreate中初始化,生命周期内复用
// ========================================
public class CacheUsageSystem : SystemBase
{
ComponentDataFromEntity<Position> positionCache;
ComponentDataFromEntity<Health> healthCache;
protected override void OnCreate()
{
// 第一个参数true表示创建缓存
// 缓存每个OnUpdate开始时自动刷新
positionCache = SystemAPI.GetComponentDataFromEntity<Position>(true);
healthCache = SystemAPI.GetComponentDataFromEntity<Health>(true);
}
protected override void OnUpdate()
{
var job = new CheckHealthJob
{
HealthData = healthCache,
PositionData = positionCache
};
var query = SystemAPI.EntityQueryConstructSettings(
ComponentType.ReadOnly<Health>(),
ComponentType.ReadOnly<Position>()
);
var handle = SystemAPI.ScheduleBlockForProcessing(ref query, new JobHandle(), job);
handle.Complete();
}
}
// ========================================
// ❌ 常见错误用法
// ========================================
public class BadCacheSystem : SystemBase
{
protected override void OnUpdate()
{
// ❌ 错误1:循环中反复创建缓存,极差
var query = SystemAPI.EntityQueryConstructSettings(
ComponentType.ReadOnly<Position>()
);
foreach (var entity in SystemAPI.Iter(ref query))
{
var cache = SystemAPI.GetComponentDataFromEntity<Position>(true);
var pos = cache[entity];
cache.Dispose();
}
// ❌ 错误2:Job之后用缓存读数据,可能过期
// 缓存只在OnUpdate开始时刷新
// Job修改后的数据缓存不会同步
}
}
// ========================================
// ✅ 在Job中高效使用缓存
// ========================================
[BurstCompile]
public struct CheckHealthJob : IJobEntity
{
public ComponentDataFromEntity<Health> HealthData;
public ComponentDataFromEntity<Position> PositionData;
public void Execute(Entity entity)
{
// 通过缓存高效随机访问
var health = HealthData[entity];
var pos = PositionData[entity];
if (health.Current > 0)
{
// 存活实体处理逻辑
}
}
}
四、性能优化方案
4.1 批量处理优化
逐个处理实体在DOTS中效率极低。应该把相同类型操作合并成批量处理,减少CPU调度开销。使用ScheduleBatchedEntityJobs或IJobChunk按内存块处理,CPU缓存命中率显著提升。
4.2 内存布局优化
通过合并组件减少内存跳转。把Position和Velocity合并成一个TransformData组件,一个内存区域就能同时读取位置和速度。但要注意写入分离:如果一个System频繁修改Position但很少读Velocity,分开存放更合适,避免不必要的写时复制。
4.3 架构层面优化策略
最重要的是理解"数据流"。System之间怎么传数据?是否有不必要的中间状态?能否在一个Job中完成多步操作?架构初期就要规划数据流向,避免后期重构。另外,对于计算密集的纯逻辑,考虑把Job合并而不是拆分,减少依赖管理复杂度。
五、应用场景分析
DOTS特别适合以下场景:
大规模实体模拟:开放世界中成千上万NPC、车辆、动物同时活动。传统MonoBehaviour架构每个实体一个实例,主线程遍历调用Update,几百个就卡顿。DOTS用一个System处理所有NPC,配合Job并行,轻松支撑数万实体。
物理和粒子系统:流体模拟、刚体碰撞、粒子发射等大量数学计算场景。Burst Compiler对这些场景加速效果最明显。
AI行为系统:大量NPC的寻路、状态机、感知系统。用Jobs并行计算感知范围、路径规划,主线程只做最终决策。
不太适合的场景:UI系统、动画系统、以及与MonoBehaviour强交互的系统。强制用DOTS重写这些系统,增加复杂度却收益不大。
六、技术优缺点
优点:
第一,性能上限高。正确处理大量实体时远超MonoBehaviour架构,一个DOTS系统可处理数万到数十万实体,MonoBehaviour几百个就卡顿。
第二,并行能力。Job System分布计算到多核CPU,Burst编译优化后接近手写C/C++水平。
第三,内存效率高。Native Collections绕过GC,内存使用可预测,移动端优势明显。
第四,面向数据架构天然适合GPU计算,未来扩展空间大。
缺点:
第一,学习曲线陡峭。ECS思维完全不同于OOP,需要几周到几个月适应。
第二,调试困难。Job内部调试工具不友好,经常靠Debug.Log定位问题。
第三,生态不够成熟。第三方插件和教程少,遇到问题难以找到解决方案。
第四,迁移成本高。从MonoBehaviour迁移不是简单替换,而是架构层面重新设计,投入大量时间和资源。
七、注意事项
第一,不要在OnUpdate中做重逻辑。OnUpdate是主线程方法,只负责调度Job和协调数据,真正计算放在Job中。
第二,NativeArray一定要释放。忘记Dispose()会导致看不见的内存泄漏,内存持续增长但不报GC。建议在OnDestroy统一释放。
第三,Job依赖要写清楚。两个Job同时修改同一组件数据必须用WriteAfter或依赖链保证顺序。不写依赖不报错但产生随机性错误,极难排查。
第四,谨慎使用ComponentDataFromEntity。它基于缓存,Job之后修改数据缓存不会自动更新。需要最新数据时用GetComponentData而不是缓存。
第五,Profile工具要用好。DOTS性能问题靠Unity的DOTS Profiler定位,显示每个System耗时、内存使用、Job调度信息。
第六,考虑混合架构。不是所有系统都需要DOTS。少量实体或强交互系统保持MonoBehaviour,只在计算密集型部分用DOTS。
八、文章总结
Unity DOTS的核心价值在于"面向数据"的架构思想。通过按类型组织数据、让计算并行化、绕过GC管理内存,DOTS在高性能场景下展现远超传统架构的能力。但它也带来学习成本、调试难度和迁移投入,不是万能银弹。
实际项目中建议采用渐进式迁移策略:先从计算密集型模块入手(AI系统、物理模拟),逐步扩展。架构设计初期就要考虑数据布局,避免后期重构高成本。
记住三个核心原则:组件小而纯、Job并行化、内存可控化。掌握这三点,就能在DOTS中构建既高效又可维护的系统。
评论
围绕“Unity DOTS技术栈落地实践:面向数据架构下ECS开发的常见问题与优化方案”参与讨论