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.Mathematics的float4/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 重要得多。