
第一次打开DOTS那一整套面板面对满屏的Entity、Chunk、SystemGroup很多人第一反应是这玩意儿真的还是Unity吗如果你的项目正被几千个物体卡到十几帧如果你需要在帧内处理几万甚至几十万条数据如果你受够了GameObject组件之间那堆偷偷摸摸的缓存引用那DOTS就是为解决这些事而生的。这套技术栈不是画饼Unity 6里它已经是一等公民而Entities 1.4这个版本号是我实际用下来最省心、最值得折腾的一个版本。这篇文章我会把ECS、Job、Burst三件套拆开揉碎从“为什么需要DOTS”讲起走到完整Demo落地最后把我踩过的坑和调优方法全部倒出来。适合两种人一种是被性能问题追着跑的老Unity开发者另一种是想系统搞懂数据导向设计的进阶学习者。不吹不黑只说实操里验证过的东西。1. 为什么从GameObject迁到DOTS1.1 GameObject结构化伤疤缓存命中率惨案先别急着写代码搞懂动机比学会API重要。传统Unity写法的核心是GameObject MonoBehaviour这套架构用着顺手但一旦实体数量上了规模就原形毕露。原因很简单GameObject组件是散落在内存里的对象每个Transform、Renderer、自定义脚本各自占据一块内存通过指针相互引用。逻辑要遍历一万个物体时CPU得沿着指针在内存里到处跳大部分时间浪费在等待数据上真正干活的时间反而少得可怜。现代CPU和GPU不一样内存访问是有缓存的读取连续内存时预取器能提前加载性能可以差二三十倍。GameObject那套对象散装布局对缓存极不友好。这就是为什么单纯“优化一下代码”救不回来瓶颈往往不在算法复杂度而在内存访问模式。DOTS换了个思路不把数据挂在对象上而是把数据按类型整整齐齐堆在连续内存里。举个例子一万个物体都带Position组件那这一万个Position结构体就紧挨着放在一起。遍历时CPU像读数组一样顺序扫描缓存命中率极高速度自然快。这个思想叫Data-Oriented Design数据导向设计。核心一句话先考虑数据怎么布局、怎么访问再考虑怎么写逻辑。你把它理解成超市货架就明白了。传统做法是调料区、零食区、日用品区各放一点酱油买酱油得跑遍全场DOTS是酱油按品牌整整齐齐码一个货架拿起来就走结账还能多开几个队。1.2 DOTS三件套各自解决什么问题DOTS全称Data-Oriented Technology Stack表面上是一堆包内核其实三个东西协同工作。ECS负责数据组织和逻辑架构。Entity是一个轻量级ID类似索引Component是纯数据没有任何方法System是逻辑统一批量处理数据。这套东西剥离了MonoBehaviour的Update和生命周期回调所有逻辑集中在System里跑。ECS只解决组织和访问模式的问题但单线程跑得再快也有上限。Job System负责多线程调度。ECS的System里写的逻辑可以被拆成Job并行跑。Unity的Job System会自动分析任务间的数据依赖把能并行的任务分配到多个工作线程上然后把结果收回来。对开发者来说大部分时候不需要手动管线程同步安全系统会替你盯着。Burst Compiler负责把计算代码编译成极致高效的原生机器码。Burst底层走的是LLVM路线能把C#代码直接编译成高度优化的二进制还能自动向量化充分利用CPU的SIMD指令。同样的数学计算Burst编译后往往比普通C#快好几倍。三件套叠加起来才是完整的DOTSECS把数据摆整齐Job把计算分到多核Burst让每个核心上的计算跑得飞快。缺哪一个都有明显短板。1.3 Entity 1.4版本到底新在哪Entities 1.x系列跟Unity以前那套DOTS预览版本完全是两个世界。早年间的0.x版本API改名频繁今天用的接口过几天就废弃社区里劝退留言一抓一把。到了1.x官方终于把API稳定下来文档、示例、编辑器工具都跟上了。Entity 1.4指的是com.unity.entities包1.4.x版本线在Unity 66000.x里体验最顺。1.4版本有几个实际体验提升。一是Baking管线更成熟场景里的GameObject转换成Entity数据的流程稳定了SubScene概念也理顺了。二是SystemAPI.Query这套查询语法成为主流写起来比老式的EntityQuery加上一大串委托简单太多。三是IJobEntity模式配合Burst能自动做依赖分析性能上限高心智负担低。很多人问1.4和之前的1.0、1.1、1.2、1.3差多大。我的感受是大方向没变小细节迭代。1.4里一些编辑器窗口、调试工具更完善某些API补齐了重载BUG也少。如果你是从0.x迁移过来的相当于换了套开发范式如果你是从1.0跟到现在的那属于平滑升级。2. ECS三板斧如何在Entity 1.4里落地2.1 Entity、Component、System、World的协作模式先把这五个概念焊死。World是顶级容器Unity运行时默认有一个World里面装所有Entity和System。ECS世界和场景GameObject世界可以共存但数据上是两套需要通过一定机制桥接。Entity本质上是个ID一个整数编号指向World里的某个实体。它没有组件也没有面板你拿到的只是一个句柄。ComponentData是纯粹的struct只存数据比如位置、速度、血量。System是处理数据的逻辑体每帧被调度批量操作一组Component。底层还有一个概念叫Archetype直译“原型”实际上就是“组件组合类型”。举个例子有100个实体都带PositionVelocity它们属于同一个Archetype另外50个实体带PositionVelocityHealth属于另一个Archetype。每个Archetype里的数据会以固定大小的Chunk为单位连续存储在内存中。一个Chunk通常16KBData数组就铺在里面。这就带来一个很重要的推论往实体上AddComponent或RemoveComponent不是改个字段那么简单。它会改变实体所属的Archetype底层发生数据搬运把整个Chunk里所有实体重排一次。这种操作叫结构性变更开销非常大。后面第三、四章会反复提到能不用就不用。SystemAPI是Entities 1.x推荐的查询入口。在ISystem的OnUpdate里你可以直接foreach遍历实体和组件。语法上很像LINQ但底层走的是Chunk迭代没有装箱和GC压力。写久了你会发现这跟用foreach遍历List一样自然。2.2 Baking管线把MonoBehaviour变成ComponentData纯ECS世界不认MonoBehaviour。但总不能让美术和策划同事手写Entity代码所以Unity搞了一套Baking管线本质就是一个转换器场景里你继续摆GameObject、挂Transform、挂自定义Authoring组件烘焙的时候这些数据变成纯ECB世界的Entity和Component。一个Authoring组件就是一个普通的MonoBehaviour放在GameObject上里面定义好策划关心的参数。再写一个配套的Baker类继承BakerAuthoringType在Bake方法里把Authoring数据转成ComponentData。这是DOTS开发的标准工作流也是和传统Unity开发最大的协作差异。写Baker的时候有讲究。比如旋转速度策划在Inspector里填的是度每秒ECS内部最好统一用弧度所以Baker里要做一次Mathf.Deg2Rad转换。不要放过任何一格转换机会运行时代码里少做无谓的换算Burst编译出来的代码也更干净。SubScene是烘焙后数据的载体。你新建一个SubScene把要被DOTS处理的物体丢进去编辑器保存时它就会被提前烘焙成实体数据。运行时直接加载这批烘焙好的数据省去了场景中动态Instantiate的巨额成本。2.3 SystemAPI与IJobEntity写系统与写Job的真正形态Entities 1.x里有两种主流写System的方式直接遍历和IJobEntity。直接遍历适合逻辑简单、数据量中等的情况典型例子using Unity.Burst; using Unity.Entities; using Unity.Mathematics; using Unity.Transforms; public partial struct RotationSpeedSystem : ISystem { [BurstCompile] public void OnCreate(ref SystemState state) { state.RequireForUpdateRotationSpeed(); } [BurstCompile] public void OnUpdate(ref SystemState state) { var deltaTime SystemAPI.Time.DeltaTime; foreach (var (transform, speed) in SystemAPI.QueryRefRWLocalTransform, RefRORotationSpeed()) { transform.ValueRW transform.ValueRO.RotateY(speed.ValueRO.Value * deltaTime); } } }这里SystemAPI.Time.DeltaTime跟Time.deltaTime一个意思受timescale影响适合大多数游戏逻辑。查询语法里RefRW是读写RefRO是只读。写代码时尽量用只读约束能给Job系统更多并行空间。IJobEntity适合计算量更大、需要明确调度并行的情况[BurstCompile] public partial struct RotateJob : IJobEntity { public float DeltaTime; public void Execute(ref LocalTransform transform, in RotationSpeed speed) { transform transform.RotateY(speed.Value * DeltaTime); } } [BurstCompile] public partial struct RotationSpeedSystem : ISystem { [BurstCompile] public void OnCreate(ref SystemState state) { state.RequireForUpdateRotationSpeed(); } [BurstCompile] public void OnUpdate(ref SystemState state) { new RotateJob { DeltaTime SystemAPI.Time.DeltaTime }.Schedule(); } }IJobEntity的Execute方法会在每个匹配的实体上执行一次。你只管写单实体的逻辑Job系统会把这个Job分成多个批次在不同工作线程上并行跑同一份代码结果自动合并。这也是DOTS能榨干多核CPU的关键设计。两种方式选哪个我的建议脚本里数据量几千个用foreach几万个以上且逻辑复杂用IJobEntity。最终性能差距其实主要在Burst加持后是否触发SIMD向量化这需要实验验证不要凭感觉。2.4 Burst编译的限制和正确打开方式Burst很猛但不是万能药。它有几个硬边界第一次接触容易栽。第一Burst编译的代码里不能使用托管对象、string、反射、GC相关的类型。所有数组和容器必须是Unity.Collections里的NativeContainer比如NativeArray、NativeList。List、Dictionary这些经典容器一用就编译报错。第二Burst函数体内不能捕获外部托管变量。Job里的字段只能是struct或NativeContainer不能是class字段。这是不少新手摸不着头脑的地方明明字段都填了怎么Burst一直报错。第三字符串拼接、Debug.Log在Burst里极其受限想打日志得换一种思路。常用做法是把要调试的信息写进NativeArray或者固定大小的缓冲然后在System外输出。第四不是所有代码都值得加Burst。Burst要经过编译和优化编辑器的编译时间会增加频繁修改频繁烧CPU。团队开发时可以Debug配置关掉BurstRelease开。或者在Editor下关闭Safety Checks以后跑能明显减少安全检查开销。一个判断标准如果这个System在Profiler里占据的时间因子足够高比如超过5%帧时间就值得逐行审视确保它Burst化成功确认没有反复踩托管路径。如果只跑百十个实体加不加Burst差别不大别为了Burst而Burst。我自己在编辑器里的习惯是项目设置-Entities-Burst默认打开实时编译Profiler里看到某系统发烫就去Burst Inspector里看它生成的代码检查有没有意外回退到非Burst路径。调完觉得稳定了再把Safety Checks关掉测一遍帧数这才是真实的移动端性能数据。3. 从安装到跑通一个ECS Demo的完整实战3.1 包安装与工程配置力气活从Package Manager开始。我假定你用的是Unity 66000.x LTS在Window-Package Manager里选择Unity Registry搜索并安装以下包com.unity.entities版本选1.4.x最新稳定版com.unity.physics做碰撞检测必装1.x版本和entities配套com.unity.burst、com.unity.collections、com.unity.jobs这些依赖包会自动拉进来不用单独操心。装完以后建议顺手打开Window-Entities-Hierarchy和Component System Groups窗口。这个环节很容易被跳过但后续调试全靠它们。还有什么比看不见Entity现场更痛苦的事没有。配置上有一个关键提醒ECS的System默认跑在World的SimulationSystemGroup里不受Player Settings里头Scripting Backend影响。但为了移动端能发挥最佳性能建议把Project Settings-Player-Scripting Backend设成IL2CPPArchitecture选ARM64Burst在IL2CPP下收益比Mono解释器高太多。3.2 定义Authoring与Baker新建脚本定义一个ComponentData和对应的Authoring。先写数据using Unity.Entities; public struct RotationSpeed : IComponentData { public float Value; }再写MonoBehaviourusing UnityEngine; public class RotationSpeedAuthoring : MonoBehaviour { public float DegreesPerSecond 90f; }最后写Bakerusing Unity.Entities; using UnityEngine; public class RotationSpeedBaker : BakerRotationSpeedAuthoring { public override void Bake(RotationSpeedAuthoring authoring) { AddComponent(new RotationSpeed { Value authoring.DegreesPerSecond * Mathf.Deg2Rad }); } }这段代码做了一件事把Inspector里的度每秒转成弧度每秒然后写进ComponentData。Baker里还能做更多事比如AddComponent到子对象、根据其他Authoring组件算派生数据但最核心的还是数据搬运和单位统一。有了这套你在场景里放下一个Cube挂上RotationSpeedAuthoring放进SubScene里点保存这个Cube运行时就变成带Transform和RotationSpeed两个组件的Entity。注意不是运行时都还挂着一个GameObject那就不叫DOTS了。3.3 写一个Burst加速的旋转系统接着写一个真正的System。沿用刚才代码我已经把两个版本都贴过了。这里补充几个细节OnCreate里用了RequireForUpdate意思是如果没有任意一个实体带RotationSpeed组件这个System就不更新省掉空循环开销。这对于场景初期没有实体、后续动态生成的情况特别重要。ISystem被声明为partial struct这是DOTS的惯例因为实体生成时会通过Source Generator生成一部分代码。如果你忘记partial编译期会报错而且报错信息不像传统Unity那么友好看到“partial”字样时先想到这层。系统更新频率是可以控的多数逻辑每帧一次就好。但如果你做的是全局减速、技能特效这种特殊效果可以在OnUpdate里手动判断状态甚至用ComponentLookup去动态开关某个System的ShouldUpdate这部分属于进阶玩法后面踩坑部分再展开。3.4 按需分配与十万美元等价级的实体创建上万对象动态生成考验的往往不是System而是实体创建的姿势。理论上ECS的Instantiate已经比GameObject的Instantiate快几个数量级但用不对依然会卡。先讲最蠢的姿势在每帧Update里对着几万个实体各执行一遍EntityManager.CreateEntity。这会造成大量结构性变更因为每创建一个新实体如果组件组合一样理论上会填进已有Chunk但创建过程中的安全检查、chunk边界的分配每次都会带来损耗量大时照样卡顿。更好用的是批量创建用EntityManager或ECB一次性生成一批。常见写法是把单一Prefab Entity缓存好然后分批CreateAdditionalEntity或者Instantiate。举例做对象池时预创建一万个Cube实体放进一个NativeList缓存每次需要激活就取N个用完再还回去。这个过程没有频繁的AddComponent和DestroyComponent性能极稳。如果你只是要内存分配不想走引擎管理可以用EntityQuery计算当前实体数量再调用EntityManager.Instantiate带数量参数一次性把新实体铺满。这和常说的“一次批发不要零售”是一个道理。运行时分批批量生成还有一个硬核组件叫EntityCommandBuffer一般Write到System里。ECB的作用是把结构性操作缓存起来等到固定的Playback点批量执行避免在Job中直接触发结构性变更。Job里不能AddComponent、DestroyEntity必须用ECB这一点就是行业铁律不需要问为什么。3.5 性能对比的实测数据我拿一个非常朴素的场景测试一万个Cube每个绕自己的Y轴旋转只算Transform更新不算渲染。传统写法一万个GameObject挂MonoBehaviourUpdate里做旋转Editor帧耗时大约90到120ms主要是脚本调度、Transform同步、缓存不命中的锅。同样的逻辑全部搬到ECSBurst用IJobEntity并行跑帧耗时大概3到5ms块头不一样差了二十多倍。这只是单个系统而已没算渲染部分。如果把这套逻辑打包成SubScene烘焙好再配合BatchRendererGroup或Graphics.RenderMeshInstanced同屏几万三角二十几万三角的渲染也不慌。实体数据量越大DOTS优势越明显因为缓存局部性和并行度一起发挥。有人会说一万个物体本来就不该用GameObject这话对一半。很多项目最初都是拿GameObject原型的等做大了才回头重构DOTS就是那个最终形态。真要把性能榨干DOTSBurstRenderMeshInstanced三件套一起上才是完整形态。4. 踩坑实录与调优经验4.1 经典撞墙碰撞检测的正确解法刚接触DOTS的人最容易翻车的点就是碰撞检测。在传统Unity里写OnCollisionEnter、OnTriggerEnter一行就能搞定的事在纯ECS世界里是不存在的。GameObject的物理系统跟ECS组件毫不相干你查询不到Rigidbody、Collider这些引用。正确的做法是安装com.unity.physics包用Unity Physics。这套物理系统基于DOTS重写数据存储在PhysicsVelocity、PhysicsCollider这些组件里。写碰撞逻辑时不是监听回调而是用一个System查询碰撞事件using Unity.Entities; using Unity.Physics; using Unity.Physics.Systems; [UpdateInGroup(typeof(SimulationSystemGroup))] public partial struct CollisionSystem : ISystem { public void OnUpdate(ref SystemState state) { var simulation SystemAPI.GetSingletonSimulationSingleton(); foreach (var triggerEvent in simulation.AsSimulation().TriggerEvents) { // 处理trigger事件 } } }这种写法一开始不习惯但思维方式其实是更先进物理事件不再是被动调用而是变成数据流由System去批量消费。性能也比经典PhysX管线高不少。如果项目预算够、对物理精度要求高Havok Physics也有DOTS版本不过它是商业授权的比Unity Physics跑得快、功能更全但不开源。还有个细节Unity Physics默认不启用continuous collision detection对高速物体碰撞穿透问题要靠调Collider的CollisionResponse属性或增大Contact Offset解决。千万别在跑起来以后才发现子弹穿墙。4.2 Job调度与安全检查常见异常从普通Unity转来的人第一次跑DOTS多半会遇见一堆莫名其妙的安全异常。最常见的是“NativeArray is not valid and can only be used in the job in which it was created”这类文案。原因通常是你在Job完成后又去访问已经被释放或归属不清的容器。解决方案是把访问操作放进Job的Execute里或者拿JobHandle的Complete等任务结束后再访问。Job System有个安全系统专门拦截数据竞争两个Job同时写同一块数据直接报错。这是保命设计不是找茬别急着关。调试期间一定开着Safety Checks发布时再关。关了以后性能会提升一点但出了诡异问题就不好查了。还有一个高频坑在ISystem的非Burst方法里访问SystemAPI.Query结果编译器报Burst不兼容。你想排查问题的对象偏偏不能被Burst编译。这时可以单独关掉这个System的Burst或者把有问题的代码挪到SystemBase里SystemBase允许托管代码。如果你在System里存了非托管状态比如NativeArray记得在OnDestroy里Dispose不然编辑器停电一样疯狂输出泄漏警告。推荐用World.Unmanaged里的Manage方法注册释放比手动在每个系统写OnDestroy更不易漏。4.3 调试工具链与内存泄漏排查DOTS项目调试完全是一种新习惯。Windows菜单里Entities下面有一排工具强烈建议每个都摸一遍Entities Hierarchy按Chunk显示所有实体及其组件数据能直接查某个实体的字段值。Component System Groups看系统执行顺序查哪几个系统在互相争抢数据。Entities Structural Changes专门看结构性变更发生在哪个时间点、耗时多少这个是性能优化的金矿窗口。内存泄漏在ECS里的表现方式很独特。经典Unity泄漏是Profile里Mono堆越来越高、GC越来越勤ECS世界里更像“实体数量正常内存却持续增长NativeArray满满当当没有释放”。排查顺序我先检查所有继承ISystem的类型里有没有NativeArray、NativeReference字段再看有没有谁的OnDestroy里忘了Dispose。一个简单的辅助手段是查World.LeakedNativeArrays。这个内部数据会记录哪些NativeContainer没被释放。编辑器里开一次看看有没有提示基本能定位。4.4 从ECS项目里薅性能的六条心法第一时刻警惕结构性变更。AddComponent、RemoveComponent、DestroyEntity都是重活儿动态开关组件能用EnableableComponent就用。给组件实现IEnableableComponent后SetEnabled(false)就能让查询自动忽略它不改变Archetype不搬运内存。比如血量归零就禁用Health比RemoveComponent省太多。第二查询时尽量写组件只读。SystemAPI.Query里能用RefRO绝不用RefRW这能提升并行度。不能确定就先用只读跑起来发现真的需要写再改。第三System的ShouldUpdate提前挡住无效帧率。没有匹配实体、没有PlayerTag、场景还没加载就尽早统一关闭别让系统空转。第四数据量大的重复逻辑走IJobEntity单位算力小的杂活直接foreach别过度设计。System本身有调度开销一个小系统处理一百个实体Schedule的损耗可能还没计算省下的多。第五合理分批渲染别上千个实体各自带Renderer。DOTS数据布局适合合批但渲染管线如果不配合GPU照样被吞。ECS原生配合Graphics.RenderMeshInstanced或自定义渲染路径才能把瓶颈真的拉下去。第六别对Burst的编译错误硬扛。写代码时保持“纯数据”意识不要刚写出来一堆class结构再慢慢改。从一开始就限制自己用NativeContainer和structBurst基本不会惹事。这不是妥协是纪律。5. 我的一些实在体会DOTS这套东西门槛高不高坦白讲比传统Unity高。数据结构从对象思维切换成布局思维调度从Update函数切换成System并行调试从打断点切换成查Chunk数据。这套思维转过来需要时间但一旦转过来处理大规模实体、AI集群、城市模拟、粒子特效这类项目真的是质的飞跃。如果你正准备上手我的建议很简单别一上来就重构整个大项目先把一个Scene里的几千个小球跑起来体感一下再慢慢扩。多看看Entities包自带的Samples那里面覆盖了90%的常用模式。写代码时遇到“为什么又报这个安全错误”问题先默认是自己哪里碰了数据竞争别急着骂引擎。最后分享一个小技巧项目里同时存在传统GameObject和DOTS实体时开场让它们各跑各的别在运行时频繁互相查引用。真需要桥接用Baker把数据搬过去一份数据一个来源。这个原则想明白你的DOTS项目就成功了一大半剩下的全是时间问题。