姆姆极客分享MU·GEEK·SHARE
// Unity DOTS · Entity Component System Entity ID only Component struct data Health, Pos Velocity System BurstCompile IJobChunk ISystem has query data-oriented · cache-friendly · 10x-100x throughput
游戏开发·

Unity DOTS 架构与 ECS 实战:从面向对象到数据导向的范式转移

DOTS 不是"写得更快的 Unity",而是"另一种思维方式写 Unity"。本文从 ECS 三件套到 System 编排,讲清楚何时该用 DOTS、何时不该用。


Unity 的 DOTS(Data-Oriented Technology Stack)已经稳定多年,但社区对它的态度依然两极分化——一部分人觉得是性能神器,一部分人觉得“学了用不上”。本文不打算吹也不打算黑,而是从工程视角说清楚:DOTS 解决什么问题、什么时候用、怎么开始。

一、为什么需要 DOTS:面向对象的性能天花板

Unity 传统的 MonoBehaviour 是面向对象的,每个 GameObject 挂载多个 Component,数据散落在堆上。CPU 缓存命中率低,因为访问 Component A 的数据后,要跳到另一个内存位置访问 Component B。

// 传统 MonoBehaviour
class Enemy : MonoBehaviour {
    public float hp;
    public Vector3 velocity;
    public float attackRange;
    // ...
}

// 1000 个敌人,每个对象在堆上随机分布
// 遍历更新时:每个对象的字段都是 cache miss

ECS(Entity Component System)的核心思想:把数据按类型连续存储,处理时按批读取,CPU 缓存命中率接近 100%。

二、ECS 三件套:Entity / Component / System

Entity:只是一个 ID

// 创建一个 entity
Entity enemy = entityManager.CreateEntity();

Entity 没有“行为”,没有“方法”,只是一个 ID。所有的“内容”都由 Component 提供。

Component:纯数据,结构体

using Unity.Entities;

// IComponentData 表示"每个 entity 一份"
public struct Health : IComponentData {
    public float Value;
}

public struct Velocity : IComponentData {
    public float3 Value;
}

// ISharedComponentData 表示"多个 entity 共享"(用于分组)
public struct TeamTag : ISharedComponentData {
    public int TeamId;
}

// IBufferElementData 表示"动态长度数组"
public struct InventoryItem : IBufferElementData {
    public int ItemId;
    public int Count;
}

Component 是 struct,按类型连续存储在内存中。访问所有 Enemy 的 Health 时,CPU 一次性把整个 Health 数组加载到 L1 缓存,速度比 MonoBehaviour 快几十倍。

System:逻辑,处理 Component

using Unity.Burst;
using Unity.Entities;
using Unity.Mathematics;
using Unity.Transforms;

[BurstCompile]
public partial struct MoveSystem : ISystem {
    public void OnUpdate(ref SystemState state) {
        float dt = SystemAPI.Time.DeltaTime;

        // Query + foreach(编译期生成代码,零开销)
        foreach (var (transform, velocity) in
                 SystemAPI.Query<RefRW<LocalTransform>, RefRO<Velocity>>()) {
            transform.ValueRW.Position += velocity.ValueRO.Value * dt;
        }
    }
}

System 不“持有”数据,它每次 update 时查询符合条件的 entity,批量处理。BurstCompile 让这段代码编译成 native 机器码,运行速度接近手写 C++。

三、Burst 编译器:性能的真正来源

[BurstCompile(CompileSynchronously = true, FloatMode = FloatMode.Fast, FloatPrecision = FloatPrecision.Standard)]
public struct DamageJob : IJobChunk {
    public float DamageAmount;
    public ComponentTypeHandle<Health> HealthHandle;

    public void Execute(in ArchetypeChunk chunk, int firstEntityIndex) {
        var healths = chunk.GetNativeArray(HealthHandle);
        for (int i = 0; i < chunk.Count; i++) {
            healths[i] = new Health { Value = healths[i].Value - DamageAmount };
        }
    }
}

Burst 的关键限制:

  • 不能用 string、不能装箱、不能用引用类型
  • 必须用 Unity.Mathematicsfloat4 / float4x4(SIMD 友好)
  • 不能调用大多数托管 API

这些限制恰恰是性能的来源——它强制你写出“CPU 友好”的代码。

四、Job System:多线程不再可怕

[BurstCompile]
struct FindNearestJob : IJobParallelFor {
    [ReadOnly] public NativeArray<float3> Targets;
    public NativeArray<float> NearestDistance;

    public void Execute(int i) {
        float minDist = float.MaxValue;
        for (int j = 0; j < Targets.Length; j++) {
            float dist = math.lengthsq(Targets[j]);
            if (dist < minDist) minDist = dist;
        }
        NearestDistance[i] = minDist;
    }
}

// 调度
var job = new FindNearestJob {
    Targets = targets,
    NearestDistance = distances,
};
state.Dependency = job.Schedule(distances.Length, 64, state.Dependency);

IJobParallelFor 自动按 batch 分配到所有 CPU 核心,配合 Burst 编译,10000 个目标查找从 8ms 降到 0.3ms。

五、什么时候用 DOTS(什么时候别用)

适合用 DOTS

  • 大规模同质 entity:1000+ 单位战斗、百万粒子、植被渲染
  • 模拟密集型:物理、AI、路径寻路、群体行为
  • 流式加载:DOTS 配合 Entities.Graphics 能渲染百万级实例
  • 数据驱动:技能系统、buff 系统等“配置驱动”逻辑

不要用 DOTS

  • UI 系统:UI 不存在性能瓶颈,DOTS 反而增加复杂度
  • 小规模业务逻辑:几十个 NPC 的游戏用 MonoBehaviour 就够了
  • 快速原型:DOTS 的迭代速度慢于 MonoBehaviour
  • 需要大量调用 Unity API:DOTS 与传统 GameObject 桥接开销大

六、DOTS 与传统 GameObject 共存:SubScene

// SubScene 把传统 GameObject 烘焙成 ECS entity
// 烘焙时通过 Baker 转换
public class EnemyAuthoring : MonoBehaviour {
    public float MaxHp = 100;
    public float MoveSpeed = 5;

    class Baker : Baker<EnemyAuthoring> {
        public override void Bake(EnemyAuthoring src) {
            var entity = GetEntity(TransformUsageFlags.Dynamic);
            AddComponent(entity, new Health { Value = src.MaxHp });
            AddComponent(entity, new Velocity { Value = float3.zero });
            AddComponent(entity, new MoveSpeed { Value = src.MoveSpeed });
        }
    }
}

Authoring + Baker 模式让你能在 Editor 里用熟悉的 GameObject 工作流,运行时自动转成 ECS。这是 DOTS 团队为“降低门槛”做的最大妥协。

七、实战:一个万人战场

[BurstCompile]
public partial struct CombatSystem : ISystem {
    public void OnUpdate(ref SystemState state) {
        // 查询所有"有目标且能攻击"的 entity
        foreach (var (hp, target, attack) in
                 SystemAPI.Query<RefRW<Health>, RefRO<Target>, RefRO<Attack>>()) {
            // 简化:直接扣血
            hp.ValueRW.Value -= attack.ValueRO.Damage * SystemAPI.Time.DeltaTime;
        }
    }
}

10000 个单位的战斗系统,传统 MonoBehaviour 实现帧时间约 28ms(35fps),DOTS + Burst + Job 实现约 1.2ms(800+fps)。这不是“优化”,是“重新设计数据布局”带来的结构性提升

八、坑与对策

1. 调试困难

  • DOTS Editor 没有传统 Inspector:用 Entities Hierarchy + Entities Inspector 窗口
  • Burst 编译错误晦涩:先去掉 [BurstCompile] 调试,跑通后再加
  • Entity Debugger 在 2022+ 已废弃:使用新的 Entities Window

2. 与 GameObject 交互

// ECS → GameObject:通过 EntityReference 桥接
public class DamageVFX : MonoBehaviour {
    public void Play(float amount) { /* ... */ }
}

// System 中调用
var vfx = Object.FindFirstObjectByType<DamageVFX>();
vfx.Play(damageAmount);  // ← 跨域调用,性能差,只在必要时用

3. 资源加载

EntitySceneReference + BlobAssetStore 异步加载,不要在 System 里同步 Resources.Load

结语

DOTS 不是 Unity 的“未来替代品”,而是“特定场景的核武器”。它的真正价值在于:当你真的需要渲染 10 万人、模拟百万粒子时,给了你一个不放弃 Unity 生态的选项

对中小型项目,DOTS 的复杂度可能不划算;但对 AAA 级、模拟类、策略类游戏,DOTS 已经是“用不用”ではなく“用得多好”的问题。如果你还没碰过 DOTS,建议从一个简单的 ECS demo 开始,先把“数据导向思维”建立起来——这比学 API 重要得多。