# Unity DOTS

Source: https://codewiki.com/zh/gamedev/unity-dots/

> - **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, RefRO>()` 表示读取并写入 `A`、只读 `B`；`WithAll()`、`WithNone()` 等条件进一步缩小匹配集合。查询找到的是符合条件的原型与 chunk，不是先扫描每个实体再逐个检查类型。

```mermaid
flowchart LR
    A["Authoring GameObject"] -->|Baker| B["Entity + components"]
    B --> C["Archetype"]
    C --> D["16 KiB chunks"]
    D -->|EntityQuery| E["ISystem"]
    E -->|Schedule| F["IJobEntity workers"]
    F -->|compatible IL| G["Burst native code"]
    F -->|commands| H["ECB playback"]
    H --> B
```

### 实体、组件与系统

常见的运行时组件是实现 `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 取得动态变换实体，并把速度与可切换的移动状态写入运行时组件。

<!-- quick -->

```csharp
// file: 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);
        }
    }
}
```

```text
# not executed here: Unity Editor 6.6 and Entities 1.4.8 are unavailable.
```

<!-- /quick -->

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

### 在 `ISystem` 中同步查询

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

```csharp
// file: 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);
        }
    }
}
```

```text
# not executed here: Unity Editor 6.6 and Entities 1.4.8 are unavailable.
```

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

### 调度并保留依赖

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

```csharp
// file: 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);
    }
}
```

```text
# not executed here: Unity Editor 6.6 and Entities 1.4.8 are unavailable.
```

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

### 从并行 Job 记录结构变更

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

```csharp
// file: 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);
    }
}
```

```text
# not executed here: Unity Editor 6.6 and Entities 1.4.8 are unavailable.
```

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

## 陷阱

> **陷阱:** 只因代码用了 ECS、Job 或 `[BurstCompile]` 就声称它更快，会把架构选择建立在没有测量的假设上。

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

> **陷阱:** 在查询遍历期间直接添加或移除组件，可能使当前视图失效；每帧反复切换组件还会让实体不断跨原型搬移。

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

> **陷阱:** 调度 Job 时既不传入 `state.Dependency`，也不保存返回句柄，会丢失与其他系统的读写顺序。

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

> **陷阱:** Burst Job 捕获 `string`、托管数组、`GameObject` 或普通 `List`，会使代码无法按预期进入 Burst 编译路径。

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

> **陷阱:** 把 ECB 当成立即执行的 `EntityManager`，会在回放前读取尚不存在的实体或组件；回放点选错还会多延迟一帧。

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

> **陷阱:** 生成代码混用旧教程中的 `Translation`、`ConvertToEntity` 或 `Entities.ForEach` 与 Entities 1.4 的 `LocalTransform`、Baker 和 `SystemAPI`。

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

<!-- deep -->

## 原型与 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 指向哪里，优化边界就应从哪里开始。

<!-- /deep -->

[检查点: gamedev/unity-dots](https://codewiki.com/zh/gamedev/unity-dots/#checkpoint)

## 延伸阅读

- [Unity 官方示例：项目设置](https://raw.githubusercontent.com/Unity-Technologies/EntityComponentSystemSamples/master/EntitiesSamples/Docs/project_setup.md)
- [Unity 官方示例：实体与组件](https://raw.githubusercontent.com/Unity-Technologies/EntityComponentSystemSamples/master/EntitiesSamples/Docs/entities-components.md)
- [Unity 官方示例：系统](https://raw.githubusercontent.com/Unity-Technologies/EntityComponentSystemSamples/master/EntitiesSamples/Docs/systems.md)
- [Unity 官方示例：实体与作业](https://raw.githubusercontent.com/Unity-Technologies/EntityComponentSystemSamples/master/EntitiesSamples/Docs/entities-jobs.md)
- [Unity 官方示例：实体命令缓冲区](https://raw.githubusercontent.com/Unity-Technologies/EntityComponentSystemSamples/master/EntitiesSamples/Docs/entity-command-buffers.md)
- [Unity 官方示例：烘焙](https://raw.githubusercontent.com/Unity-Technologies/EntityComponentSystemSamples/master/EntitiesSamples/Docs/baking.md)
