Unity DOTS 把运行时状态组织成实体与组件,让系统按组件组合批量处理数据;Job System 负责调度,Burst 负责把兼容代码编译为原生机器码。
当大量对象执行相似的 CPU 工作,而且 Profiler 已经证明数据访问或主线程吞吐是瓶颈时,再考虑 DOTS。
先设计组件与查询,再明确 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 取得动态变换实体,并把速度与可切换的移动状态写入运行时组件。
// # 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 的实体不会匹配默认查询。
// # 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,并保存新句柄,使下一项有冲突的工作能等待它。
// # 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 随后回放这些命令。
// # 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 的代价模型
原型由组件类型集合定义,与组件值无关。两个实体即使生命值不同,只要组件类型完全相同,就属于同一原型;添加 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 道找错题