news 2026/10/14 10:02:18

Unity DOTS入门:ECS+Job+Burst性能优化与5000实体Demo实战

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
Unity DOTS入门:ECS+Job+Burst性能优化与5000实体Demo实战

说实话,我第一次认真接触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 + MonoBehaviourECS + Job System + Burst
数据组织对象各自持有散落数据同类数据连续存放在内存块中
逻辑归属组件自带方法,每帧逐个调用System统一批量处理
缓存友好差,数据散布在不同地址好,顺序读取连续内存
多线程通常在主线程UpdateJob 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的最小使用方式如下:

  1. 新建一个普通Unity场景,命名为EntitiesScene,保存到Assets下。
  2. 再新建一个主场景,在主场景中创建一个空物体,挂上SubScene组件,把EntitiesScene拖入组件的SceneAsset字段。
  3. 双击SubScene组件,进入该子场景的编辑视图,在里面摆放带Authoring组件的预制体和游戏对象。
  4. 运行时,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里摆放与运行

实操步骤:

  1. 创建一个Cube预制体,给它挂上RotationSpeedAuthoring,速度可以设成1或2。
  2. 在SubScene场景中放一个空物体,挂上SpawnerAuthoring,把Cube预制体拖到Target字段,count填5000。
  3. 运行场景,注意主场景里必须有SubScene组件并指向了保存过的实体场景。
  4. 如果一切正常,运行后你会看到一大片旋转的立方体。

有一个常见坑是: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频繁GCECS希望使用无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 我建议的学习路线

如果你决定继续往深处走,我推荐按这个顺序来,别贪多:

  1. 把这篇Demo完整跑通,熟悉Authoring、Baker、ISystem三段式。
  2. 看官方Sample里最简单的HelloCube,对比一下官方写法和自己代码的差异。
  3. 尝试把旋转改成平移,再加上速度组件,体会“加一个组件就多一个新系统”的组合思路。
  4. 学习IJobEntity,把主线程遍历改成并行Job,再看性能变化。
  5. 学习Chunk和Archetype的底层结构,理解“为什么连续内存这么快”。
  6. 最后再接触SubScene的进阶用法、实体场景流式加载和网络同步这类高阶话题。

第3步特别重要。很多人刚上手时容易用“一个Builder生成所有物体”的思路去写ECS,但ECS的精髓是组件组合。把速度、位置、朝向分别拆成组件,再按需组合,才能体现出数据导向的优势。

6.3 顺手分享一个实用的调试习惯

最后分享一个我自己的习惯:在跑ECS项目时,顺手在场景里放一个Text或者用一个调试面板,把当前World里的实体数量打出来。代码也很简单,用World.DefaultGameObjectInjectionWorld.EntityManager.UniversalQuery.CalculateEntityCount()就能拿到总数。有了这个数字,你很快就能判断出:是不是Prefab没烤上、是不是生成了两次、是不是System在无限创建实体。这种小工具在入门阶段比任何教程都管用。

DOTS的学习曲线是陡的,但它的陡不是概念本身的难度,而是旧资料、版本变化和开发思维三重叠加造成的。把它拆开之后,每一步其实都可以在某一个周末里吃掉。希望这份从痛点出发的入门过程,能让你少走一些我走过的弯路。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/10/14 10:02:05

IFRS学习路径与核心准则应用解析

1. IFRS学习路径与课程体系解析在哈萨克斯坦国立大学&#xff08;KazNU&#xff09;攻读会计学硕士期间&#xff0c;我系统学习了国际财务报告准则&#xff08;IFRS&#xff09;体系。作为全球主流的会计准则框架&#xff0c;IFRS在120多个国家被采用&#xff0c;其学习过程需要…

作者头像 李华
网站建设 2026/10/14 10:01:11

HiPhy:面向物理一致性的分层对齐视频生成框架

1. 这不是又一个“文生视频”模型&#xff1a;HiPhy解决的是物理世界建模的底层断层最近在几个AI顶会的workshop上反复听到同行提起HiPhy&#xff0c;不是因为它刷出了什么惊人的FID分数&#xff0c;而是大家在讨论“为什么我们训练出来的视频总像动画片&#xff1f;”——镜头…

作者头像 李华
网站建设 2026/10/14 10:00:23

Python单例模式5种实现与最佳实践

1. 单例模式的核心价值与应用场景 在Python开发中&#xff0c;单例模式&#xff08;Singleton Pattern&#xff09;是最常用的设计模式之一。它的核心思想是确保一个类在整个程序运行期间只有一个实例存在&#xff0c;并提供一个全局访问点。这种模式在需要控制资源访问、配置管…

作者头像 李华
网站建设 2026/10/14 10:00:09

UE5机械臂控制:骨骼绑定与运动实现详解

1. 项目概述&#xff1a;UE5机械臂控制基础在虚幻引擎5中实现机械臂运动控制是工业仿真、游戏开发等领域的基础需求。这个项目聚焦于最核心的第一步——建立控制点与机械臂骨骼之间的绑定关系。不同于简单的静态模型&#xff0c;机械臂的联动性要求我们精确处理每个关节的旋转轴…

作者头像 李华
网站建设 2026/10/14 9:59:59

多无人机任务分配:基于粒子群优化的动态协同调度方法

简介&#xff1a;本资源是一套面向算法工程师、智能无人系统研究者及高校相关专业学生的多无人机协同任务分配实战项目&#xff0c;聚焦解决农业监测、物流配送等场景下多UAV任务高效调度的工程难题。项目基于Python实现粒子群优化&#xff08;PSO&#xff09;算法&#xff0c;…

作者头像 李华
网站建设 2026/10/14 9:59:58

龙岗发布OpenClaw与OPC扶持政策,一人公司迎来官方支持

最近龙岗区那份《支持OpenClaw&OPC发展若干措施》征求意见稿&#xff0c;在创业圈和开发者社区里炸开了锅。"一人公司"这种过去听起来像概念的东西&#xff0c;现在直接和官方政策文件挂钩了&#xff0c;这在国内还是头一遭。我花了一晚上把原文和措施清单反复读…

作者头像 李华