Unity DOTS

用Entities 1.4理解ECS数据布局、Job依赖、Burst编译与结构变更,并识别生成代码中的旧API。

难度 高级 时长 标准深度约14分钟
版本 Unity 6.6 / Entities 1.4.8 / Burst 1.8.30
what

Unity DOTS把运行时状态组织成实体与组件,让系统按组件组合批量处理数据;Job System负责调度,Burst负责把兼容代码编译为原生机器码。

when

当大量对象执行相似的CPU工作,而且Profiler已经证明数据访问或主线程吞吐是瓶颈时,再考虑DOTS。

how

先设计组件与查询,再明确Job依赖和结构变更的回放点,最后在目标构建上确认Burst生效并测量结果。

是什么,为什么存在

Unity的数据导向技术栈(Data-Oriented Technology Stack,DOTS)是一组相互配合的技术。Entities包提供实体组件系统(Entity Component System,ECS),C# Job System把工作调度到工作线程,Burst Compiler则把兼容的 .NET IL编译成针对目标CPU优化的机器码。三者可以一起使用,但Burst和Job System并不依赖ECS才能工作。

实体组件系统(Entity Component System) 把标识、数据和行为分开。实体(entity)是带版本信息的句柄;组件(component)保存状态;系统(system)查询特定组件组合并执行逻辑。实体不是一个装着所有字段和方法的对象,也不能简化成“只是一个整数”。

DOTS解决的是批量数据处理问题。传统MonoBehaviour对象可能把同一类字段分散在许多托管对象中,并通过大量独立回调更新;Entities按组件组合组织数据,让系统遍历紧密排列的组件数组。这种布局更适合缓存友好的顺序访问,也让读写集合能够被调度器分析。

这不表示每个Unity项目都应改成ECS。界面、少量独特对象、编辑器工具和以对象关系为主的逻辑,往往继续使用GameObject与MonoBehaviour更清楚。DOTS适合数量多、形状相似、能以数据变换表达的CPU工作;是否值得迁移,要由目标设备上的Profiler数据决定。

工作原理

一个World拥有实体、组件存储和系统。每种唯一的组件类型组合对应一个 原型(archetype) ;同一原型中的实体存放在一个或多个 块(chunk) 中。Entities 1.4的每个chunk为16 KiB,内部为每种组件类型保存一个紧密排列的数组,另有一个实体ID数组。

系统通过查询声明需要读写哪些组件。SystemAPI.Query<RefRW<A>, RefRO<B>>()表示读取并写入A、只读B;WithAll<T>()、WithNone<T>()等条件进一步缩小匹配集合。查询找到的是符合条件的原型与chunk,不是先扫描每个实体再逐个检查类型。

实体、组件与系统

常见的运行时组件是实现IComponentData的非托管struct。小组件不是目的本身;边界应反映系统实际一起读取和写入的数据。把从不同时访问的字段塞进一个大组件,会增加无关数据传输;把每个标量都拆成一种组件,则会增加原型和查询的复杂度。

ISystem是非托管系统接口,生命周期回调接收ref SystemState。OnUpdate中的普通foreach仍由调用该系统的线程同步执行;只有显式调度Job,工作才可能进入工作线程。给系统加上[BurstCompile]也不会自动把循环并行化。

SystemAPI依赖源码生成,因此包含查询的系统和IJobEntity类型要写成partial。源码生成器会建立查询和访问句柄,编译错误有时会出现在Temp/GeneratedCode下。排查时仍应回到声明的组件访问方式,而不是编辑生成文件。

Job调度与依赖

IJobEntity.Execute的参数定义查询与访问模式:ref是读写,in是只读,值传递的Entity提供当前实体句柄。源码生成器把IJobEntity转换为按chunk工作的Job。ScheduleParallel可以把匹配的chunk分给多个工作线程,但实际并行度仍取决于实体数量、chunk数量、批次和其他依赖。

Job返回的JobHandle是正确性契约。若本系统写入的数据可能与前序Job重叠,新Job必须依赖state.Dependency;调度后又要把返回句柄写回state.Dependency。漏掉任一边都会让后续系统看不见真实依赖,安全检查可能报错,关闭检查后则可能变成竞态条件。

在每帧中途调用Complete()会让主线程等待。同步有时不可避免,例如主线程马上需要Job结果,但机械地完成每个句柄会破坏调度器重叠工作的机会。先把依赖链传下去,只在确实消费结果的边界完成。

Burst编译边界

Burst Compiler 处理High-Performance C#子集。它适合值类型、NativeArray等原生容器以及Unity.Mathematics运算;一般的托管对象、托管数组和任意虚调用不能进入这条编译路径。[BurstCompile]是请求,不是性能证明。

Editor播放模式与Player构建的编译路径不同。开发时还可能暂时执行托管回退版本,因此“能运行”不等于目标构建已使用Burst。应检查Burst Inspector、编译日志和目标Player的Profiler数据,并确认代码没有因托管字段或不支持的调用退出Burst路径。

结构变更与命令缓冲区

添加或移除组件、创建或销毁实体属于 结构变更(structural change) 。组件组合改变后,EntityManager必须把实体移到另一个原型,并可能创建或释放chunk。直接遍历查询时不能随意执行会使查询存储失效的结构变更。

实体命令缓冲区(EntityCommandBuffer,ECB) 先记录命令,之后在指定系统更新时回放。Job可以通过EntityCommandBuffer.ParallelWriter并行记录命令,而回放负责真正修改实体存储。ECB解决了修改时机与线程安全问题,却不会消除结构变更本身的搬移成本。

频繁切换但组件形状不变的状态可以考虑IEnableableComponent。启用或禁用这类组件不会改变实体的原型,查询会按启用位决定是否匹配。不过,低频且持续时间较长的状态仍可能更适合添加或移除组件,不能把所有标签都机械改成可启用组件。

烘焙把编辑数据变成运行时数据

场景中的MonoBehaviour authoring组件适合给设计师编辑,Baker则把它转换成实体组件。烘焙(baking)发生在导入与构建数据流程中,不是每帧把GameObject同步成Entity。运行时系统应读取烘焙结果,而不是依赖authoring对象继续存在。

Baker需要声明它读取的Unity对象和资源依赖,才能在输入变化时正确重新烘焙。生成代码常把旧版ConvertToEntity工作流与当前Baker API混在一起,这类代码即使看起来像Unity C#,也不属于Entities 1.4的当前工作流。

示例

下面四个示例从烘焙数据开始,依次加入同步查询、并行Job和延迟结构变更。它们需要Unity Editor、Entities与Burst包。当前本地工具链没有Unity Editor,也没有这些程序集,因此按约定标记为未执行,没有伪造控制台输出。

定义组件并烘焙authoring数据

UnitAuthoring只负责编辑时输入。Baker取得动态变换实体,并把速度与可切换的移动状态写入运行时组件。

UnitAuthoring.cs
// # not executed here: Unity Editor 6.6 and Entities 1.4.8 are unavailable.
using Unity.Entities;
using UnityEngine;

public struct MoveSpeed : IComponentData
{
    public float MetersPerSecond;
}

public struct Moving : IComponentData, IEnableableComponent
{
}

public sealed class UnitAuthoring : MonoBehaviour
{
    [Min(0f)] public float moveSpeed = 4f;

    public sealed class Baker : Baker<UnitAuthoring>
    {
        public override void Bake(UnitAuthoring authoring)
        {
            var entity = GetEntity(TransformUsageFlags.Dynamic);
            AddComponent(entity, new MoveSpeed
            {
                MetersPerSecond = authoring.moveSpeed
            });
            AddComponent<Moving>(entity);
        }
    }
}
# not executed here: Unity Editor 6.6 and Entities 1.4.8 are unavailable.

Moving没有字段,但它的启用位有意义。系统可以暂停某个单位而不移除组件,因此暂停与恢复不会把实体搬到不同原型。若状态几乎不改变,普通标签组件也可能更合适。

在ISystem中同步查询

这个系统在自己的OnUpdate中直接遍历查询。RefRW<LocalTransform>声明写入变换,RefRO<MoveSpeed>声明只读速度;禁用Moving的实体不会匹配默认查询。

MovementSystem.cs
// # not executed here: Unity Editor 6.6 and Entities 1.4.8 are unavailable.
using Unity.Burst;
using Unity.Entities;
using Unity.Mathematics;
using Unity.Transforms;

[BurstCompile]
public partial struct MovementSystem : ISystem
{
    [BurstCompile]
    public void OnUpdate(ref SystemState state)
    {
        var deltaTime = SystemAPI.Time.DeltaTime;

        foreach (var (transform, speed) in
                 SystemAPI.Query<RefRW<LocalTransform>, RefRO<MoveSpeed>>()
                     .WithAll<Moving>())
        {
            transform.ValueRW.Position += new float3(
                0f,
                0f,
                speed.ValueRO.MetersPerSecond * deltaTime);
        }
    }
}
# not executed here: Unity Editor 6.6 and Entities 1.4.8 are unavailable.

这段循环是否需要改成Job,不能从语法判断。实体数量少时,调度开销可能抵消收益;主线程已经超预算、工作量足够大且访问模式允许并行时,才值得调度并测量。

调度并保留依赖

速度积分可以写成IJobEntity。系统把已有依赖传入ScheduleParallel,并保存新句柄,使下一项有冲突的工作能等待它。

VelocitySystem.cs
// # not executed here: Unity Editor 6.6 and Entities 1.4.8 are unavailable.
using Unity.Burst;
using Unity.Entities;
using Unity.Mathematics;
using Unity.Transforms;

public struct Velocity : IComponentData
{
    public float3 MetersPerSecond;
}

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

    private void Execute(ref LocalTransform transform, in Velocity velocity)
    {
        transform.Position += velocity.MetersPerSecond * DeltaTime;
    }
}

[BurstCompile]
public partial struct VelocitySystem : ISystem
{
    [BurstCompile]
    public void OnUpdate(ref SystemState state)
    {
        state.Dependency = new IntegrateVelocityJob
        {
            DeltaTime = SystemAPI.Time.DeltaTime
        }.ScheduleParallel(state.Dependency);
    }
}
# not executed here: Unity Editor 6.6 and Entities 1.4.8 are unavailable.

这里没有调用Complete()。只要后续访问也通过ECS依赖系统声明,调度器会在真正需要时建立等待关系。若主线程代码立即读取这些变换,读取边界才需要完成对应依赖。

从并行Job记录结构变更

死亡标记会改变组件组合,因此Job不直接调用EntityManager。它用ECB的并行写入器记录命令,并把chunk查询索引作为排序键;EndSimulationEntityCommandBufferSystem随后回放这些命令。

DeathSystem.cs
// # not executed here: Unity Editor 6.6 and Entities 1.4.8 are unavailable.
using Unity.Burst;
using Unity.Entities;

public struct Health : IComponentData
{
    public float Value;
}

public struct Dead : IComponentData
{
}

[BurstCompile]
[WithNone(typeof(Dead))]
public partial struct MarkDeadJob : IJobEntity
{
    public EntityCommandBuffer.ParallelWriter Commands;

    private void Execute(
        [ChunkIndexInQuery] int chunkIndex,
        Entity entity,
        in Health health)
    {
        if (health.Value <= 0f)
            Commands.AddComponent<Dead>(chunkIndex, entity);
    }
}

[BurstCompile]
public partial struct DeathSystem : ISystem
{
    [BurstCompile]
    public void OnUpdate(ref SystemState state)
    {
        var commands = SystemAPI
            .GetSingleton<EndSimulationEntityCommandBufferSystem.Singleton>()
            .CreateCommandBuffer(state.WorldUnmanaged)
            .AsParallelWriter();

        state.Dependency = new MarkDeadJob { Commands = commands }
            .ScheduleParallel(state.Dependency);
    }
}
# not executed here: Unity Editor 6.6 and Entities 1.4.8 are unavailable.

回放合并并行命令流时,排序键才决定命令顺序。它不是实体ID,也不保证跨帧稳定。业务逻辑若依赖确定顺序,应使用明确且稳定的数据键,而不是把ChunkIndexInQuery当成身份。

陷阱

修复方法: 先用Unity Profiler找到具体的CPU、同步或分配热点。保留同一工作负载与目标设备,对比修改前后的Player构建;没有数据时只描述访问模式,不写倍数或帧率。

修复方法: Job或遍历期间用ECB延迟结构变更。高频开关状态评估IEnableableComponent,低频持久状态再保留普通添加与移除,并在Profiler中检查结构变更成本。

修复方法: 把前序依赖传给调度调用,再把结果写回state.Dependency。只有立即消费结果时才Complete(),不要用每帧同步来掩盖缺失的依赖声明。

修复方法: 把Burst边界放在非托管数据变换周围,使用合适的原生容器。把日志、对象查找和其他托管工作留在边界外,并在目标构建中确认Burst实际启用。

修复方法: 写清楚生产命令的系统、回放系统和首次消费结果的系统。ECB创建的临时实体只能在同一个缓冲区的后续命令中安全引用,回放后再通过运行时数据取得真实状态。

修复方法: 要求代码明确目标为Entities 1.4.8,并逐个在该版本API或官方样例中核对符号。迁移旧代码时按升级指南处理,不要只替换类型名后期待行为不变。

深入 原型与chunk的代价模型

原型与chunk的代价模型

原型由组件类型集合定义,与组件值无关。两个实体即使生命值不同,只要组件类型完全相同,就属于同一原型;添加Dead后,类型集合变化,实体会移动到另一个原型。世界运行期间创建的原型会保留到该World销毁,因此无约束地组合许多标签可能让原型数量膨胀。

chunk对每种组件保存一个数组,同一实体在这些数组中使用相同索引。顺序遍历一个查询时,CPU可以连续读取系统需要的字段。组件越大,一个16 KiB chunk能容纳的实体越少;把很少访问的大字段放进热组件,会减少每个chunk的实体数并增加内存流量。

删除或迁移实体后,Entities可以把chunk末尾实体移到空位。因此chunk内顺序不是稳定业务顺序,数组位置也不是持久ID。需要确定性排序时,应持有显式键并在合适的边界排序,不能依赖当前迭代次序。

共享组件会按值影响实体分组,值种类过多会切碎chunk。它适合确实按共享值筛选或批处理的数据,不是普通组件的免费压缩形式。做决定前,先在Archetypes窗口和Profiler中查看占用、空闲空间与查询行为。

可启用组件与查询掩码

IEnableableComponent保留组件存储,只改变启用状态。默认查询把禁用组件视为不匹配,因此系统可以用同一组件类型集合表达高频状态切换。这样减少结构搬移,但系统仍要处理启用掩码,数据也仍占据chunk空间。

写入某种可启用组件的Job可能让主线程查询等待,即使Job只切换启用位而不修改字段。调度器按声明的写访问保护正确性。若查询意外产生同步点,应检查访问模式与执行时机,而不是关闭安全限制。

变化过滤器(change filter)记录的是chunk级版本,不会逐实体证明某个值真的不同。获得写权限的系统可能更新变化版本,即使最终写回同样的值。因此它适合跳过肯定没变的chunk,不能代替逐实体的业务变更检测。

调度、依赖与同步点

ECS调度器根据组件类型与读写模式建立依赖。多个只读Job可以重叠,写入者必须与相同组件的读者或写入者排序。state.Dependency聚合当前系统应该继承的关系,赋回的新句柄则把本系统工作公开给后续系统。

依赖只表达执行先后,不表达业务完成通知。一个Job完成后,ECB命令仍要等对应缓冲系统回放,结构变化才可见。把“Job已完成”误当成“实体已经改变”,是跨系统读取旧状态的常见原因。

主线程API有时会自动完成冲突Job,这种隐式同步在功能上正确,却可能造成难找的等待尖峰。Profiler中看到等待时,应追踪是谁首次请求了受保护的数据。把Complete()移到更早位置只会搬动等待,不会减少工作。

并行化也有固定成本。创建Job、构建或更新查询、切分批次和合并依赖都要工作。一个很小的循环留在Burst编译的ISystem.OnUpdate中可能更快也更清楚,只有测量才能确定分界点。

ECB的时间与顺序

ECB把“决定修改”与“执行修改”分开。Job记录时,目标实体可能在回放前已被其他系统销毁或改形,因此命令的前置条件必须覆盖整个间隔。需要时使用查询过滤、系统排序或回放前验证,不能假设记录时成立的条件永远成立。

ParallelWriter的命令带排序键,回放时据此合并并行写入。ChunkIndexInQuery适合官方模式中的批次排序,但不是跨帧稳定的序号。若两条命令的业务语义依赖先后,最好让数据模型显式表达优先级,或先归并结果再由单一阶段记录命令。

选择BeginSimulation、EndSimulation或其他ECB系统会改变结果可见时间。较晚回放能延长并行窗口,却可能让消费方等到下一阶段甚至下一帧。系统组与[UpdateBefore]、[UpdateAfter]应表达实际数据依赖,不能只为了让日志顺序看起来正确。

ECB可以临时引用它自己创建的实体。这个占位句柄能用于同一缓冲区后续的AddComponent或SetComponent命令,但在回放前不是真正存在于EntityManager中的实体。把它存入普通运行时集合并立刻查询,属于生命周期错误。

烘焙与运行时边界

authoring类型为人和编辑器表达数据,运行时组件为系统表达访问模式。两边不必一一对应:一个Baker可以从层级、Prefab和资源生成多个组件或实体,也可以把不需要运行时存在的编辑字段折叠成预计算结果。

烘焙需要可重复。相同输入应生成等价实体数据,不能依赖播放模式时间、随机全局状态或场景外的隐式单例。Baker读取其他对象或资源时应声明依赖,否则资源改变后,增量烘焙可能保留旧结果。

托管组件能桥接必须留在对象世界的数据,但会限制Burst、调度和序列化选择。先把纯数据与托管引用分开,再把少量桥接工作放到明确系统中。若每个实体都要频繁往返同步GameObject,迁移边界可能选错了。

SubScene与baking并不会自动修复糟糕的数据模型。组件边界仍要根据查询和写入关系设计,实体引用也需要处理目标尚未加载或已经卸载的情况。流式加载测试应覆盖这些暂时无效的引用。

用测量决定是否采用DOTS

旧草稿中的固定帧率、内存量和倍数没有测试工程、硬件、构建设置或采样方法,因此全部删除。DOTS的性能取决于组件大小、实体数量、访问模式、同步点、Burst状态和目标CPU。没有这些上下文,任何统一倍数都无法复现。

有效对比要固定场景输入、Player构建、目标设备、帧率策略和采样区间。至少记录主线程与工作线程时间、等待时间、GC分配、结构变更位置和实体规模。Editor数据适合定位,最终结论应来自接近发布配置的构建。

先验证行为一致,再比较性能。两个实现若更新顺序、碰撞精度或可见实体数量不同,较快的数字没有比较意义。性能测试还应预热Burst编译与资源加载,避免把一次性成本混入稳定帧。

若瓶颈在GPU、渲染提交、网络或磁盘,重写ECS系统可能不改变帧时间。DOTS是CPU数据处理工具集,不是通用加速开关。Profiler指向哪里,优化边界就应从哪里开始。

延伸阅读

检查点

4个问题 · 1道输出预测题 · 1道找错题

前置内容 Unity开发基础 Ecs architecture 即将上线
下一篇 Job system 即将上线 Game profiling 即将上线 MonoBehaviour生命周期 Compute shaders 即将上线
复制为Markdown 面试题库 在GitHub上编辑 报告错误 讲清楚了吗?