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中构建既高效又可维护的系统。