说实话,我第一次认真接触Unity的DOTS,是在一次很普通的性能优化排查里。一个单位数量最多只有两三千的模拟场景,用MonoBehaviour写每帧Update,结果在目标配置的设备上帧率掉到二十几,Profiler里清清楚楚写着GC Alloc和Transform访问占了很大一部分。当时我就在想,同样是给一堆对象做位置更新,怎么就能慢成这个样子。后来顺着官方给出的路线去了解DOTS,发现这个被誉为“下一代数据导向技术栈”的东西,核心其实不神秘:ECS、Job System、Burst Compiler。但真到入门时,不少人被“数据导向”“Chunk内存”这些词吓住了,或者被网上旧版本的教程带偏,花了一晚上连第一个能跑的Sample都没跑起来。
这篇文章就是写给你的——那些已经会了Unity基础、写过不少MonoBehaviour,但面对DOTS总觉得“概念都懂,一动手就废”的人。我把入门过程中真正影响成败的版本问题、SubScene问题、API写法和性能原理都揉开讲,并给一份可以直接照着敲的5000实体旋转Demo。看完之后,你至少能独立跑起一个ECS项目,并且知道它为什么快。
1. 性能瓶颈究竟在哪儿:从“GameObject数量上千就开始卡”说起
很多初学者把DOTS当成某种“更高级的Unity插件”,但真正驱动它诞生的,是传统GameObject方案在大规模实体面前暴露出的三个硬伤。不理解这三个硬伤,后面看代码会始终隔一层。
1.1 一个Loop的真相:你写的不是游戏逻辑,而是一堆分布在各处的散装数据
先看一段Unity开发者最熟悉的代码:
public class RotationBehaviour : MonoBehaviour { public float speed = 1f; private void Update() { transform.Rotate(0f, speed * Time.deltaTime, 0f); } }假设场景里挂了5000个这样的组件。表面上你写的是“每个对象每帧旋转自己”,但CPU实际执行的是:先通过Unity引擎内部的对象管理器,挨个找到这5000个MonoBehaviour的虚表、Update调用入口,再分别访问它们各自挂在堆上的Transform对象,拿位置、改旋转、再写回去。每个对象都独立,意味着这些数据在内存里根本不相邻。CPU每次读取都要把一块数据从内存搬到缓存,如果数据分散在不同地址,缓存命中率就很低,效率自然上不去。
更隐蔽的问题出在GC。MonoBehaviour每帧Update里一旦发生装箱、临时数组分配、字符串拼接,或者某些隐式的迭代器分配,帧率立刻被GC拖垮。5000个对象的Update可能看起来每个都很轻,合在一起就是持续的小规模GC压力。
用生活里的例子类比:传统OOP方式像每个员工都带着自己的档案袋,干活时从工位翻出档案袋,处理完再塞回去。老板需要看五千个人的资料时,只能一个个工位跑过去。面向数据的思路是,把所有人的档案先按类型归档到一排排连续的卡槽里,机器扫过去就是连续读取,一次处理一大片。DOTS要解决的,就是这件事。
1.2 什么时候真的需要DOTS,什么时候不需要
如果只是做一个小品级游戏,场景里同时存在的动态物体不超过几百个,用MonoBehaviour完全没有问题,强行上DOTS反而会因为代码组织方式的变化而拖慢开发。DOTS最适合的场景有几种:
- 同屏大量同类实体,比如成千上万的单位、粒子、树木、弹幕。
- 需要把大量计算放到工作线程并行执行。
- 对GC极其敏感,追求长时间运行稳定不掉帧的玩法。
- 有复杂且数据密集的模拟逻辑,比如群体寻路、细胞自动机、批量物理模拟。
我建议的判断标准很简单:先在Profiler里测量,找到真正的CPU和GC瓶颈;如果瓶颈来自大量组件的Update和数据结构分散,再考虑引入DOTS。不要因为“听起来厉害”就重构整个项目。
2. ECS三个词拆成大白话
DOTS这个缩写里,最核心也最劝退人的就是ECS。三个字母分别是Entity、Component、System。网上有很多解释把它们说得过于玄学,我想换一种角度:把它们当成一个仓库管理系统的三样东西。
2.1 Entity、Component、System分别是什么
Entity在Unity DOTS里其实只是一个ID,一个整数编号,类似于仓库里托盘的编号。它本身没有位置、没有速度、没有生命值,也没有行为。它存在的意义只是让不同的数据可以“挂”在同一个编号下。
Component是一块纯数据。在ECS里,它对应的是一个值类型结构体(struct),例如位置、速度、碰撞半径、血量、颜色。它只用来描述状态,不包含任何逻辑。这跟传统MonoBehaviour的最大区别在于:传统组件是“数据加方法”的混合体,ECS里的Component就是一张卡片。
System是真正干活的逻辑。它不拥有数据,而是每帧去查询“哪些实体拥有我需要的Component组合”,然后批量处理。比如旋转系统只关心同时拥有LocalTransform和RotationSpeed的实体,遍历到一批就改一批。
用仓库来类比:Entity是托盘上的编号;Component是贴在托盘上的标签卡片;System是分拣线上的工人,只处理自己负责的标签类型,而且可以多个人同时在不同流水线上处理不同卡片。仓库不需要知道“这个托盘是给哪个客户展示用的”,只需要知道卡片上有哪些信息,该送到哪条处理线。
2.2 为什么“Component是数据、System是行为”这条纪律如此重要
传统面向对象里,“对象”是数据和行为的混合体。一个敌人既懂得怎么移动,又懂得怎么攻击,还知道自己血量多少。逻辑上很直觉,但性能上很吃亏,因为不同类型的行为和数据必须绑定在同一个对象实例上,没法批量操作。
ECS把这层绑定拆开了。移动逻辑被抽到MovementSystem,攻击逻辑被抽到AttackSystem,血量数据单独放一个Health组件。这样一来,如果某类实体根本没有Health组件,攻击系统根本不需要看到它;如果下一帧有一千个实体都需要移动,MovementSystem就非常干净地把一千份Position和Velocity连续读出来,批量修改。数据连续,行为复用,各流水线各干各的活,这就为后面要说的缓存友好和多线程并行铺好了路。
对比一下:
| 对比维度 | 传统GameObject + MonoBehaviour | ECS + Job System + Burst |
|---|---|---|
| 数据组织 | 对象各自持有散落数据 | 同类数据连续存放在内存块中 |
| 逻辑归属 | 组件自带方法,每帧逐个调用 | System统一批量处理 |
| 缓存友好 | 差,数据散布在不同地址 | 好,顺序读取连续内存 |
| 多线程 | 通常在主线程Update | Job System把任务拆到多线程 |
| GC压力 | 容易产生分配和垃圾 | 主要使用值类型和Native容器,GC压力极低 |
3. 动手前必须先搞清楚的版本和SubScene问题
如果说ECS概念是“入门第一堵墙”,那版本混乱和SubScene就是“第一道坑”。很多人照着网上教程敲了半天,代码一个都编译不过,不是自己写错了,而是教程太旧。
3.1 包怎么装:Entities 1.0与0.x资料怎么认
在Unity 2022.3及以上版本中,打开Package Manager,搜索并安装这几个包:
- Entities(核心ECS框架)
- Burst(高性能编译器)
- Collections(Native容器扩展)
- Mathematics(数学类型库)
这几个包安装后,就可以创建ISystem、Baker、IComponentData等代码了。如果你用的是Entities 1.0之后的API,写出来的代码会有非常明显的特征:
- 逻辑代码写在
partial struct实现的ISystem中,而不是老的ComponentSystem类。 - 查询实体使用
SystemAPI.Query<T>(),而不是老的Entities.ForEach或IJobForEach。 - 场景中的MonoBehaviour数据转换实体,使用
Baker<T>,而不是老的EntityManager手动搬砖。
网上大量2019到2021年之间的教程,API几乎都是0.x时代的写法,比如ComponentSystem、IJobForEach、EntityCommandBuffer的并行写法、.ToComponentDataArray<T>()等。这些代码在最新的Entities版本里大部分都已经删掉或改名,直接抄会得到一堆编译错误。我的建议不是“别看老教程”,而是“老教程只用来理解思路,代码一定要以官方最新API为准”。识别教程新旧最快的方法:出现Baker和ISystem字样,基本是新版;出现IJobForEach,基本是旧版,直接跳过代码部分。
3.2 SubScene是入门第一道坎,但绕不开
SubScene是Entities 1.0工作流里非常重要的一环。它的作用是把你用GameObject摆放好的场景内容,在运行时烘焙(Bake)成实体数据。没有它,你的Authoring组件永远不会变成Entity,你的System查不到任何东西。
很多新手在入门时试图绕开SubScene,直接用运行时代码创建实体。怎么说呢,纯代码流程确实可行,但起步阶段的坑更多:你可能要手动处理渲染组件、渲染边界、世界注册等问题,非常容易绕晕。相比之下,走标准Authoring + Baker + SubScene流程,是用官方推荐的思路,前期最顺。
SubScene的最小使用方式如下:
- 新建一个普通Unity场景,命名为EntitiesScene,保存到Assets下。
- 再新建一个主场景,在主场景中创建一个空物体,挂上
SubScene组件,把EntitiesScene拖入组件的SceneAsset字段。 - 双击SubScene组件,进入该子场景的编辑视图,在里面摆放带Authoring组件的预制体和游戏对象。
- 运行时,SubScene中的内容会被烘焙成实体,进入默认Entity World。
要注意:SubScene里的场景文件必须保存过,否则不会触发烘焙。如果运行时发现实体根本没有生成,先检查SubScene的Auto Load Scene是否勾选,再看EntitiesScene是否真的保存成功。这听起来很基础,但确实是很多人第一个Demo失败的原因。
4. 一个能跑起来的5000实体旋转Demo(带完整代码)
概念聊得再多,不如跑通一个Demo。这一节我给出一个完整的入门案例:生成5000个立方体实体,让它们绕Y轴以不同速度旋转。如果跑通了,你就完成了从GameObject思维到ECS思维的第一轮切换。
4.1 目标与最小结构
这个Demo需要四个文件:
- 一个数据组件:RotationSpeed
- 一个Authoring脚本:RotationSpeedAuthoring
- 一个Baker:RotationSpeedBaker
- 一个旋转System:RotationSystem
- 外加一个生成器系统:SpawnerSystem
结构上,Cube预制体挂RotationSpeedAuthoring,Spawner挂SpawnerAuthoring,两者都放进SubScene。运行时,Baker把Authoring数据转成实体组件,SpawnerSystem负责实例化5000个Cube,RotationSystem负责驱动旋转。
4.2 Authoring、Baker与System三段式代码
先写数据组件和Authoring:
using Unity.Entities; public struct RotationSpeed : IComponentData { public float radiansPerSecond; }using Unity.Entities; using UnityEngine; public class RotationSpeedAuthoring : MonoBehaviour { public float radiansPerSecond = 1f; }然后写Baker,把Authoring上的MonoBehaviour数据转换成RotationSpeed组件:
using Unity.Entities; public class RotationSpeedBaker : Baker<RotationSpeedAuthoring> { public override void Bake(RotationSpeedAuthoring authoring) { var entity = GetEntity(TransformUsageFlags.Dynamic); AddComponent(entity, new RotationSpeed { radiansPerSecond = authoring.radiansPerSecond }); } }接着写旋转System。这里用的是ISystem结构体方式:
using Unity.Burst; using Unity.Entities; using Unity.Mathematics; using Unity.Transforms; [BurstCompile] public partial struct RotationSystem : ISystem { public void OnUpdate(ref SystemState state) { var dt = SystemAPI.Time.DeltaTime; foreach (var (transform, speed) in SystemAPI.Query<RefRW<LocalTransform>, RefRO<RotationSpeed>>()) { var rotationDelta = quaternion.RotateY(speed.ValueRO.radiansPerSecond * dt); transform.ValueRW.Rotation = math.mul(rotationDelta, transform.ValueRO.Rotation); } } }这一段已经用到了两个关键API:SystemAPI.Query负责查询同时拥有LocalTransform和RotationSpeed的实体;RefRW和RefRO分别代表可读写和只读的组件访问方式。旋转的逻辑就是把当前朝向绕Y轴转一个角度。
4.3 批量生成5000个实体
为了让你看到“批量”的意义,写一个Spawner组件和一个SpawnerSystem。先定义数据:
using Unity.Entities; public struct Spawner : IComponentData { public Entity prefab; public int count; }using Unity.Entities; using UnityEngine; public class SpawnerAuthoring : MonoBehaviour { public GameObject target; public int count = 5000; }using Unity.Entities; public class SpawnerBaker : Baker<SpawnerAuthoring> { public override void Bake(SpawnerAuthoring authoring) { var entity = GetEntity(TransformUsageFlags.None); AddComponent(entity, new Spawner { prefab = GetEntity(authoring.target, TransformUsageFlags.Dynamic), count = authoring.count }); } }SpawnerSystem在第一次运行时执行一次,把实体批量实例化并摆放到网格中:
using Unity.Collections; using Unity.Entities; using Unity.Mathematics; using Unity.Transforms; public partial struct SpawnerSystem : ISystem { private bool _spawned; public void OnCreate(ref SystemState state) { state.RequireForUpdate<Spawner>(); _spawned = false; } public void OnUpdate(ref SystemState state) { if (_spawned) return; var ecb = new EntityCommandBuffer(Allocator.Temp); foreach (var spawner in SystemAPI.Query<RefRO<Spawner>>()) { var count = spawner.ValueRO.count; var grid = (int)System.Math.Ceiling(System.Math.Sqrt(count)); for (int i = 0; i < count; i++) { var entity = ecb.Instantiate(spawner.ValueRO.prefab); var x = i % grid * 1.5f; var z = i / grid * 1.5f; var pos = new float3(x, 0f, z); ecb.SetComponent(entity, LocalTransform.FromPosition(pos)); } } ecb.Playback(state.EntityManager); ecb.Dispose(); _spawned = true; } }这里用EntityCommandBuffer是因为不能在遍历查询的时候直接修改Entity结构。ECB把需要做的操作先排进缓冲区,最后统一Playback执行。LocalTransform.FromPosition用起来比手动构造完整结构更简洁。
4.4 在SubScene里摆放与运行
实操步骤:
- 创建一个Cube预制体,给它挂上
RotationSpeedAuthoring,速度可以设成1或2。 - 在SubScene场景中放一个空物体,挂上
SpawnerAuthoring,把Cube预制体拖到Target字段,count填5000。 - 运行场景,注意主场景里必须有SubScene组件并指向了保存过的实体场景。
- 如果一切正常,运行后你会看到一大片旋转的立方体。
有一个常见坑是:SubScene里的Cube尽量不要在Inspector里用默认的Transform旋转,因为实体最终的初始旋转由烘焙结果决定,你后续还会在System里控制旋转。这里让初始方向保持默认即可。
5. 性能为什么能差出几十倍:Burst、Job和内存布局的三连击
我的测试方式是同一台电脑、同一个5000Cube场景,先跑一遍纯MonoBehaviour版本,再跑一遍上面的DOTS版本。为了公平,两者的旋转逻辑完全一样,都只绕Y轴旋转。具体帧率会因为设备和场景略有不同,但数量级的差距是可以稳定复现的:MonoBehaviour版本CPU占用明显偏高,还伴随持续的GC Alloc;DOTS版本开启Burst后,CPU耗时降到很低,Profiler里几乎看不到GC增量。如果游戏开了垂直同步,DOTS版本很容易顶着上限跑,这时候用Profiler看CPU时间区别会更明显。
5.1 三件事分别做了什么
很多人以为“ECS快全靠把类拆成结构体”,其实只对了一部分。真正快是三件事同时作用的结果。
第一是内存布局。ECS会把拥有相同组件组合的实体放进连续的内存块(Chunk)里。同一种组件的数据在内存里一块一块排着,CPU读取时就像读数组一样顺畅。这个在之前的概念部分说过了,它是基础。
第二是Job System。传统逻辑跑在主线程,写代码时你不需要考虑线程冲突,但主线程再快也只能用到一个核。Job System允许你把System里的遍历工作拆成多个任务,分发到不同线程并行跑。比如5000个实体的旋转,可以切成4块,每块1250个,四个线程同时处理,理论耗时能压到原来的四分之一以下。
第三是Burst Compiler。这是经常被忽略的点。Burst可以把C#代码编译成高度优化的机器码,自动做SIMD向量化,循环向量宽度直接提升。同样一段旋转计算,用Burst编译后单线程也能比普通C#快出好几倍。上面Demo里的[BurstCompile]就是开启它的开关。
5.2 别把功劳全记在ECS头上
我见过不少人在项目里只把类改成struct、把MonoBehaviour改成ISystem,却没有开启Burst,也没有使用Job,性能提升并不明显,然后得出结论“DOTS是骗人的”。实际上,ECS的连续内存布局给了Burst和Job发挥的空间,三者组合起来才是完整的DOTS。
入门阶段,你可以先不开Job,但至少把Burst打开。等理解了SystemAPI.Query的逻辑,再尝试把遍历改成并行Job。这里有一个简单的进阶路径:先用主线程ISystem跑通,再在System内部用IJobEntity或者Entities.Query.AsParallel()做并行化。不要一上来就梭哈并行,因为并行调度会带来新的数据依赖问题。
5.3 如何在Profiler里确认收益
运行Demo后,打开Profiler窗口,关注两块:
- CPU Usage区域里的
Entity Systems开销,对比MonoBehaviour版本里的PlayerLoop和脚本Update。 - 内存区域里的GC Alloc,DOTS版本通常非常平稳,几乎不增长。
另外打开Jobs窗口,可以查看工作线程的使用率。如果你的System没有并行化,Jobs窗口里会看不到明显的多线程任务;如果并行化了,可以看到任务被分散到多个工作线程。这样你能用数据确认“性能到底提升在哪里”。
6. 上手阶段最容易踩的五个坑,以及我的学习路线
这一节算是我从自己踩坑经历里提炼出的浓缩版。每个坑都曾经让我卡住过一晚上,提前知道能省很多时间。
6.1 五个坑
| 坑 | 现象 | 原因 | 解决 |
|---|---|---|---|
| 教程API过时 | 代码编译不过 | 0.x API在Entities 1.0中删除 | 认准ISystem、Baker、SystemAPI.Query,老教程只看思路 |
| 改了Authoring数值但运行没变化 | 实体表现和Inspector数据不一致 | Baking在运行时不会自动重新执行 | 修改数据后重新进入Play Mode,必要时开启Domain Reload |
| SubScene没有生成实体 | System查不到数据 | 场景没保存或子场景未自动加载 | 保存子场景,勾选Auto Load Scene |
| 在System里用List、Dictionary | 频繁GC | ECS希望使用无GC容器 | 改用NativeList、NativeHashMap,或用EntityCommandBuffer |
| 把所有System都设为每帧执行 | 系统浪费在全量更新上 | 默认每帧Update会查询所有实体 | 用RequireForUpdate、state.Enabled、System Group控制更新频率 |
其中第一个坑和新手关系最大。你去任何一个技术社区搜“Unity DOTS”,排在前面的可能都是旧文章。这不是谁的错,而是DOTS本身经历了大版本重构。我后来养成了一个习惯:看到一篇DOTS教程,先拉到代码部分,发现ComponentSystem和Entities.ForEach直接切换到思路,不再浪费时间照着改。
还有一个容易被忽视的坑是:在System里写C#的时候,下意识用了new List<SomeData>()。这个在普通脚本里没问题,但在高频System里会分配堆内存,造成GC。ECS体系里更合适的做法是用NativeList这类非托管容器,用完记得释放。
6.2 我建议的学习路线
如果你决定继续往深处走,我推荐按这个顺序来,别贪多:
- 把这篇Demo完整跑通,熟悉Authoring、Baker、ISystem三段式。
- 看官方Sample里最简单的HelloCube,对比一下官方写法和自己代码的差异。
- 尝试把旋转改成平移,再加上速度组件,体会“加一个组件就多一个新系统”的组合思路。
- 学习IJobEntity,把主线程遍历改成并行Job,再看性能变化。
- 学习Chunk和Archetype的底层结构,理解“为什么连续内存这么快”。
- 最后再接触SubScene的进阶用法、实体场景流式加载和网络同步这类高阶话题。
第3步特别重要。很多人刚上手时容易用“一个Builder生成所有物体”的思路去写ECS,但ECS的精髓是组件组合。把速度、位置、朝向分别拆成组件,再按需组合,才能体现出数据导向的优势。
6.3 顺手分享一个实用的调试习惯
最后分享一个我自己的习惯:在跑ECS项目时,顺手在场景里放一个Text或者用一个调试面板,把当前World里的实体数量打出来。代码也很简单,用World.DefaultGameObjectInjectionWorld.EntityManager.UniversalQuery.CalculateEntityCount()就能拿到总数。有了这个数字,你很快就能判断出:是不是Prefab没烤上、是不是生成了两次、是不是System在无限创建实体。这种小工具在入门阶段比任何教程都管用。
DOTS的学习曲线是陡的,但它的陡不是概念本身的难度,而是旧资料、版本变化和开发思维三重叠加造成的。把它拆开之后,每一步其实都可以在某一个周末里吃掉。希望这份从痛点出发的入门过程,能让你少走一些我走过的弯路。