做了这么多年Unity,最让我头疼的从来不是shader写不出来,而是项目跑到后期,一堆MonoBehaviour互相乱引用,改一个功能牵一发动全身。尤其是需要同时维护几百上千个AI单位、子弹、可破坏物的时候,传统的GameObject + Component模式用起来就像是在给运行中的飞机换引擎。后来我把核心逻辑迁移到了ECS这套写法上,用的是社区里最成熟的Entitas插件,项目结构一下子清爽了很多,性能瓶颈也更好定位了。
这篇文章不扯高深理论,就讲清楚三件事:ECS到底是什么、Entitas怎么下载导入、怎么用一个最基础的小Demo把它跑起来。适合正在被面向对象架构搞到头大、想换个思路写游戏逻辑的Unity开发者,也适合想了解ECS但被Unity官方DOTS那套复杂概念劝退的新手。我尽量用大白话讲,配合可以直接复制的代码,你照着敲一遍,基本就能理解这套东西的套路。
1. ECS到底是什么:从一次项目重构说起
1.1 面向对象组件模式救不了所有人
先回忆一下我们平时怎么写Unity逻辑:建一个PlayerMonoBehaviour,里面Update写移动、攻击、血量、动画;或者拆成Character、Health、Attack几个组件,互相之间通过GetComponent通信。项目小的时候这套东西非常好用,Unity官方和教材也都是这么教的。但随着系统变多,问题就来了:每个组件都可能在Awake和Start里面到处寻找其他组件,十几个组件之间的依赖关系比蜘蛛网还复杂;每个Update都在做自己的事情,要统计某个Buff对速度的影响,你得先把十几个系统全部过一遍。
举一个很常见的例子:你要给所有会移动的敌人加一个“减速50%”的Debuff。传统写法可能需要找到所有敌人,遍历它们的MoveController,修改减速字段,还要小心别影响那些免疫减速的Boss。如果敌人还分近战、远程、飞行,继承树可能已经三层了,这个功能改起来就是一场噩梦。而ECS的思路是:把“敌人”这个对象拆成数据,移动逻辑只认数据,任何带有“速度”和“被减速标记”的实体,系统都会自动处理。加新功能不需要改对象的类结构,只需要加一种数据和一套规则。
1.2 换个思路:Entity、Component、System
ECS的全称是Entity Component System,三个词就是三件套。Entity是实体,本质上是一个ID,它不装任何数据,就像乐高积木的底座;Component是组件,是纯数据结构,比如“位置”“速度”“血量”,就像不同形状的积木块;System是系统,是处理逻辑的函数集合,它会筛选出拥有特定组件的所有实体,统一执行更新。
为了好理解,可以想象一条汽车装配流水线。每个零件是Component,每一辆正在装配的车是Entity,而流水线上不同工位的工人是System。管轮胎的工人看到一辆车上有轴承和轮胎,就会把它装上去;管发动机的工人只关注发动机相关的部件。你不必告诉工人整辆车是什么,他只需要看到自己关心的那部分数据。这个类比放到游戏里非常贴切:一个系统只处理一组组件,其他东西一概不关心。
1.3 数据与逻辑分离到底图什么
这样做最大的好处有三个。第一是解耦,逻辑不再藏在各个组件内部,而是集中在System里,想看某个功能怎么做,直接找对应的System就行。第二是性能,数据在内存里连续摆放,缓存命中率高,Unity官方的DOTS甚至能用Job多线程加速,Entitas虽然偏纯逻辑,但缓存连续这一点比频繁访问GameObject要快得多。第三是可测试性,System只依赖数据不依赖场景对象,可以脱离Unity编辑器写单元测试。
当然,ECS不是什么银弹。它要求你换一种建模方式,一开始会很不习惯,很多在MonoBehaviour里随手就能写的功能,在ECS里要拆成组件和系统,代码量看起来反而变多了。但当你处理的对象数量上千、功能模块相互叠加的时候,这套架构的收益是传统OOP很难比的。
2. 为什么选Entitas,而不是Unity官方的DOTS
2.1 Entitas和DOTS怎么选
先说结论:如果你想深入Unity最新的性能路线,请去研究DOTS(Entity Component System + Job System + Burst),官方提供了完整的底层优化。但DOTS门槛很高,API在版本迭代中经常变,还要配合Unity Physics、Netcode等新包一起用,资料少、坑多,不适合第一次接触ECS的人。Entitas是一个老牌的第三方ECS框架,它通过代码生成帮你把大量模板代码写好了,API很稳,社区资料多,网上到处都是教程,入门的确定性高很多。
下面这张表是我在选择时做过的对比,列几个关键维度:
| 维度 | Entitas | Unity DOTS |
|---|---|---|
| 成熟度 | 2015年就开源了,版本稳定 | 仍在快速迭代,API变动频繁 |
| 学习门槛 | 低,社区教程多 | 高,需要理解Job、Burst、Archetype |
| 代码生成 | 内置Code Generator,写组件自动生成代码 | 需要手动建Authoring,或依赖Source Generator |
| 使用范围 | 偏纯逻辑层,适合游戏逻辑和中等规模管理 | 极致性能,适合大批量实体和物理仿真 |
| 与Unity对象交互 | 灵活,可以挂MonoBehaviour做表现层 | 需要通过GameObjectEntity等方式同步 |
如果你的项目追求的是“大量单位但不需要极致物理仿真”,Entitas几乎是首选。反过来,你要做上百万个粒子的模拟,那还是老实走官方DOTS。
2.2 核心零件扫盲:Context、Entity、Matcher、Group、System
用Entitas前,把这几个名词搞清楚,后面看代码就不慌。
- Context:可以理解成一个容器,保存了一类实体。一般代码生成后会给你GameContext、InputContext等,相当于把世界按照领域划分。你在哪个Context里创建实体,实体就属于哪个领域。
- Entity:就是实体,在代码中对应GameEntity。它拥有哪些组件、当前值是什么,都可以通过访问器直接拿到。比如entity.hasPosition、entity.position.value。
- Matcher:筛选器,用来描述“我要哪些实体”。例如GameMatcher.AllOf(GameMatcher.Position, GameMatcher.Velocity)表示同时拥有位置和速度的实体。
- Group:分组。用Matcher向Context请求一个Group对象,之后Context里新增或销毁匹配的实体时,Group会自动维护列表。遍历时直接拿列表即可,不用每次都扫描全量实体。
- System:逻辑单元。Entitas里常见的接口有IInitializeSystem、IExecuteSystem、ICleanupSystem、ITearDownSystem。Feature是个组合器,把多个System塞到一个Feature里统一管理。
这些概念对应到代码里其实非常直白。比如你想让所有“有位置和速度的实体”移动,就先拿Group,然后在Execute里遍历,改数据。核心思路就是:筛选一组实体,批量处理它们的组件数据。
2.3 什么项目适合上Entitas
以我自己的经验,最适合Entitas的场景有这么几类:大量同构单位的策略、塔防、模拟经营游戏,比如几千个市民、几百个敌人;复杂的Buff和状态系统,比如一个战斗单位身上同时挂着减速、中毒、暴击等十几个Buff,用组件去表达比用类继承清晰得多;卡牌、Roguelike这类逻辑规则频繁变化的游戏,把卡片效果定义成组件和系统,扩展性非常强。
不太适合的场景也提一下:纯UI界面、简单的小工具、团队里只有一个人且完全没人愿意学新架构的项目。ECS的价值需要在规模上体现,如果整个游戏只有三五个对象,折腾这套架构属于杀鸡用牛刀。还有,如果团队其他成员很抗拒新概念,建议先用一个模块做试点,不要一上来就全量重构。
3. 下载、导入和代码生成,按步骤来不出错
3.1 从GitHub下载Entitas插件
Entitas的官方仓库在GitHub上,项目名是saturdays/Entitas,由原作者持续维护。打开仓库的Release页面,找到最新版本下载zip包就行。注意区分源码包和release包,推荐下载标有Source code的zip,或者更简单,直接git clone整个仓库。Entitas本身是MIT协议,可以放心使用,即使以后想改源码也很方便。
这里要提醒一句:网上搜“Entitas下载”很容易搜到很多老版本的转载包,用起来虽然大体一样,但API可能对不上。我用的是比较新的release版本,代码里方法名和旧版本(比如0.x、1.0时代)有细微差别,例如组件添加方法,新版本会自动生成AddPosition、ReplacePosition、RemovePosition,老版本可能直接用AddComponent。建议以官方仓库的最新release为准,不要随便拿一个博客里的附件就用。
3.2 把插件导入Unity工程
下载完解压后,你会得到一个文件夹。常见做法是把整个文件夹拖到Assets目录下,或者放到Assets/Plugins/Entitas,然后让Unity编译。导入完成后,Unity菜单栏会多出来一个Entitas菜单项,里面有Dashboard、Code Generator、Migration等入口。如果菜单没出现,先检查项目是否编译报错,特别是Unity版本太老或太新时,老项目可能缺一些依赖。
如果是从Asset Store导入,步骤更简单,直接在Package Manager里搜Entitas安装。但Asset Store上架的版本可能不是最新的,版本差距过大会导致生成逻辑差异,所以更推荐用GitHub。导入之后建议先开一个空场景,走一遍代码生成流程,确认无报错再开始写正式代码。
3.3 Code Generator配置和代码生成
Entitas最爽的一点是自带代码生成器,省去大量手写模板代码。打开Entitas > Code Generator,左侧是配置项,上面有多个Tab,分别负责定义代码生成的路径、命名空间、生成哪些类型(Component、Context、Entity、Matcher等)和是否生成自定义扩展。
以最常用的配置为例:
- Project Path:填当前Unity项目的Assets路径。
- Assembly Name:填项目自己定义的程序集名,如果没做程序集划分就默认。
- Target Folder:可以选择生成到Assets/Entitas/Generated目录。
- Namespace:可以填你自己的命名空间,比如MyGame.ECS。
配置完成后,点击Generate按钮,生成器会扫描所有实现了IComponent接口、且没有标注[EntitasCodeGenSkip]的类,然后生成对应的Entity扩展类、Matcher类、Contexts类等。生成完如果看到Console里没有报错,这时候打开目录,就能看到一堆后缀是Generated的代码。以后每新增一个Component,都要重新点一次Generate,不然新的访问器和Matcher不会出现。
另外注意,Component的命名要尽量直观,比如PositionComponent生成了Position相关的访问器,HealthComponent生成了Health相关访问器,命名空间不同时,生成的类名可能冲突,建议团队里提前定好规范。
3.4 生成完的目录结构长什么样
正常生成完,会看到大概这样的目录:
Assets/ Entitas/ Generated/ Contexts.cs Game/ GameContext.cs GameEntity.cs GameMatcher.cs GameComponentsLookup.cs Scripts/ (你自己写的业务代码) Components/ PositionComponent.cs VelocityComponent.cs HealthComponent.cs Systems/ MoveSystem.cs HealthSystem.cs GameController.csContexts.cs里有一个静态的sharedInstance,游戏启动时通过它访问不同Context。GameEntity是GameContext里的实体类型,带有一大堆自动生成的组件访问器,比如entity.position、entity.AddPosition()、entity.ReplacePosition()。GameMatcher则是描述筛选条件的入口。这套代码看着多,其实都是模板,不需要改,主要是给System调用的。
到这里,环境就算搭好了。不少新手卡在这一步:明明插件装好了,却不知道那些Generated代码是怎么来的。其实你就记住:写Component类,然后Generate,再写System和Feature,三个步骤循环往复,就是Entitas的日常。
4. 第一个小Demo:用Entitas做一个能跑的滑动小球
4.1 设计三个组件:位置、速度、血量
为了直观,我做了一个最简化的小例子:场景里有一个小球,它会按速度朝某个方向移动,同时有一个血量值在逐渐减少;血量降到0时销毁。外面还加了一个UI Slider控制速度,一个摄像机跟随小球。这样你可以看到纯逻辑和表现层的协作方式。
先写组件。在Assets/Example/Components下新建三个脚本:
using Entitas; [Game] public sealed class PositionComponent : IComponent { public UnityEngine.Vector3 value; }using Entitas; [Game] public sealed class VelocityComponent : IComponent { public UnityEngine.Vector3 value; }using Entitas; [Game] public sealed class HealthComponent : IComponent { public float value; public float maxValue; }注意类的命名一律以Component结尾,这是Entitas代码生成的约定。每个组件都是纯数据,不写逻辑。写完这几行,跑一次Generate,让Entitas生成后面要用的访问器和Matcher。
4.2 写两个System:MoveSystem和HealthSystem
有了数据,再写处理逻辑。MoveSystem负责所有同时拥有Position和Velocity的实体移动,HealthSystem负责血量扣减和销毁。
using Entitas; using UnityEngine; public sealed class MoveSystem : IExecuteSystem { private readonly IGroup<GameEntity> _movers; public MoveSystem(Contexts contexts) { _movers = contexts.game.GetGroup( GameMatcher.AllOf(GameMatcher.Position, GameMatcher.Velocity) ); } public void Execute() { foreach (var entity in _movers.GetEntities()) { var pos = entity.position.value; pos += entity.velocity.value * Time.deltaTime; entity.ReplacePosition(pos); } } }这里有个关键点:MoveSystem打开之后,会同时处理所有带Position和Velocity的实体。不管这个实体是小球、敌人还是子弹,只要满足组件条件,都会被移动。这就是ECS的“规则复用”思维。
HealthSystem也很直接:
using Entitas; public sealed class HealthSystem : IExecuteSystem { private readonly IGroup<GameEntity> _entities; public HealthSystem(Contexts contexts) { _entities = contexts.game.GetGroup(GameMatcher.Health); } public void Execute() { foreach (var entity in _entities.GetEntities()) { var health = entity.health.value; health -= Time.deltaTime * 10f; entity.ReplaceHealth(health, entity.health.maxValue); if (health <= 0f) { entity.Destroy(); } } } }为了演示简单,我直接让血量按时间自动减少,实践中你可以让攻击系统去改血,效果是一样的。Destroy之后Entitas会触发销毁流程,相关Group自动更新,不用我们手动移除。
4.3 用Feature和MonoBehaviour把系统拉起来
System写好了,需要一个被称为Feature的容器把它们装起来,再在MonoBehaviour里初始化、执行。Feature本身继承了Systems基类,可以Add任意System,还能套娃,所以大型项目里可以按模块分Feature。
using Entitas; public sealed class GameSystems : Feature { public GameSystems(Contexts contexts) : base("Game Systems") { Add(new MoveSystem(contexts)); Add(new HealthSystem(contexts)); } }然后是启动入口。我通常会做一个GameController挂到场景里的空物体上,负责创建Contexts、初始化Systems、创建初始实体。
using Entitas; using UnityEngine; public class GameController : MonoBehaviour { private Contexts _contexts; private Systems _systems; private void Start() { // 1. 初始化所有Context _contexts = Contexts.sharedInstance; // 2. 创建系统容器并初始化 _systems = new GameSystems(_contexts); _systems.Initialize(); // 3. 创建一个小球实体,附加初始数据 var entity = _contexts.game.CreateEntity(); entity.AddPosition(Vector3.zero); entity.AddVelocity(new Vector3(2f, 0f, 0f)); entity.AddHealth(100f, 100f); } private void Update() { _systems.Execute(); _systems.Cleanup(); } private void OnDestroy() { _systems.TearDown(); } }注意,Update里必须先Execute再Cleanup。Entitas的Cleanup阶段会处理实体销毁相关的逻辑,只要你在代码里调用了Destroy,实体会在Cleanup时真正清理。初学者如果忘记调用Cleanup,会发现实体一直没消失,排查半天。
4.4 场景搭建:Slider调速、相机跟随、阴影注意点
这一步就是传统Unity活了。场景里加一个小球,挂一个Renderer,另外新建一个空物体挂GameController。小球本身不需要挂任何移动脚本,它的移动完全由ECS里的MoveSystem驱动,但我们得把逻辑数据同步到Transform,否则看到的就是一个纹丝不动但控制台在疯狂输出位置的小球。
同步的方式有很多种,最省事的做法是在实体上挂一个View组件,存GameObject引用。刚才忘了补充,还需要一个ViewComponent:
[Game] public sealed class ViewComponent : IComponent { public GameObject gameObject; }创建实体的地方顺手把View赋上去:
var entity = _contexts.game.CreateEntity(); entity.AddPosition(_ball.transform.position); entity.AddVelocity(new Vector3(2f, 0f, 0f)); entity.AddHealth(100f, 100f); entity.AddView(_ball);再写一个简单的ViewSyncSystem,把Position数据同步给GameObject。这是表现层和逻辑层的桥。
using Entitas; using UnityEngine; public sealed class ViewSyncSystem : IExecuteSystem { private readonly IGroup<GameEntity> _entities; public ViewSyncSystem(Contexts contexts) { _entities = contexts.game.GetGroup( GameMatcher.AllOf(GameMatcher.Position, GameMatcher.View) ); } public void Execute() { foreach (var entity in _entities.GetEntities()) { entity.view.gameObject.transform.position = entity.position.value; } } }然后在GameSystems里把ViewSyncSystem加上。Slider控制速度更简单,Canvas上放一个Slider,挂一个很小的脚本,把Slider的值写到VelocityComponent里,或者动态Add、Replace都可以。相机跟随就继续用普通的LateUpdate去追小球位置,没必要强行用ECS写,表现层能用传统做法就用传统做法,这套架构本来就不排斥混用。
还有阴影问题:我之前在项目里用Entitas做单位系统时,遇到过阴影位置滞后、忽明忽暗的现象。排查下来基本不是ECS的锅,而是阴影距离和阴影类型设置的问题,尤其是URP下实时阴影的级联参数太大或太小都会出现这种观感。如果你在ECS Demo里看到阴影表现不对,先去检查Project Settings > Quality > Shadows,别一上来就怀疑框架。
4.5 完整流程串一遍
把上面几段代码拼到一起,逻辑上就是:创建实体,然后给实体装配组件,MoveSystem改位置,ViewSyncSystem同步Transform,HealthSystem扣血,血量归零销毁实体。跑起来之后,小球匀速向右移动,血条(你可以在UI上做一个Slider绑定Health)肉眼可见下降,最终小球销毁消失。整个过程没有一行游戏逻辑写在MonoBehaviour里,这就是Entitas的工作方式。
如果你在跑的时候发现小球没有移动,先检查两个位置:一是是否生成了代码,Position和Velocity的访问器在旧生成文件里不存在;二是MoveSystem里是否用了Time.deltaTime,有些System是在FixedUpdate里驱动,那边应该用Time.fixedDeltaTime,用错了表现起来就是卡顿式移动。
5. 我踩过的坑和排查实录
5.1 组件命名和代码生成的那些坑
最常见的坑不是写错逻辑,而是忘记Generate。经常是写好了PositionComponent,回到System里敲entity.position,发现根本没有这个属性,一看Console,生成代码还是旧的。这个我踩过不下五次,后来养成习惯:每新增一个Component就立刻Generate一次,顺便看一眼Console有没有报错。
还有命名问题。Entitas的代码生成器会根据类名来生成方法和Matcher,所以命名要规范统一。比如VelocityComponent会生成velocity、AddVelocity、ReplaceVelocity、GameMatcher.Velocity。如果你把类名写成SpeedData,生成出来的方法就是speedData和GameMatcher.SpeedData,团队里根本分不清。我的建议是,数据组件一律用“名词+Component”命名,和Entitas约定保持一致。
5.2 Entity生命周期:销毁、复用和计时器
Entity的销毁并不是马上从内存中消失,而是在Cleanup阶段处理。这会导致一个隐蔽的问题:你在Execute阶段调用Destroy,同一个System里后续代码仍然能访问到entity,因为Cleanup还没执行。如果这时候继续读写组件,偶尔会出现意想不到的状态。稳妥的做法是Destroy之后直接break,跳出遍历循环,别在同一帧继续操作它。
如果需要延迟销毁或者做定时器逻辑,我不会在System里自己维护Dictionary来计时,而是给实体加一个TimerComponent,专门放剩余时间和到期标记,再写一个TimerSystem统一处理。这样时间数据也变成了组件,查询和调试都方便。
5.3 表现层与逻辑层:与UI、摄像机、阴影协作的经验
我见过不少同学把Entitas神化,恨不得所有东西都用ECS写,结果UI刷新、摄像机跟随、动画播放这些事在ECS里绕来绕去,代码反而更乱。我的经验是:逻辑层交给Entitas,表现层照常用MonoBehaviour。比如UI血条,实体血量变化时发一个事件或者通过View脚本去刷新Slider,没必要把UI元素也建模成实体。
摄像机跟随小球,直接在需要跟随的View上挂一个LateUpdate脚本,每帧把transform.position对齐到目标就行。阴影问题也是一样的逻辑:它属于渲染层面,优先检查灯光、阴影距离和URP配置,而不要牵扯到ECS逻辑。把这两层分开之后,调试效率会高很多。数据错了查Entitas,显示错了查表现层,问题边界非常清晰。
5.4 什么量级下性能提升明显
最后聊一个很多人关心的问题:Entitas真的能带来多大性能提升?我个人的实测数据是,在同一个中等规模场景里,三千个敌人的移动逻辑,传统MonoBehaviour用30多毫秒,切到Entitas后用8到10毫秒左右,提升确实明显。但注意,这个数据依赖场景、代码质量和Unity版本,仅供参考。
更本质的是,Entitas的性能优势不仅体现在速度上,还体现在代码的可维护性上。游戏逻辑集中管理后,加一个全局效果,比如全场敌人减速50%,只需要给相关实体打上一个SlowDown组件,再写一个系统处理速度和减速的关系,几行代码搞定,而且不容易影响其他逻辑。这种收益在项目规模上来之后,比那几毫秒的帧时间更值钱。
最后再说一个实用小建议。很多人一开始用Entitas,总想把所有字段都放到组件里,结果一个实体挂七八个组件,创建的时候一大串Add,看着很臃肿。其实组件可以越小越好,位置是位置、速度是速度、血量是血量,每个组件只承担一个职责,系统按需筛选。这样后期加新玩法,往往只是多加一个组件加一个System,旧代码完全不用动。我在几个项目里用Entitas重构过核心战斗和单位管理,最深的体会是:这套框架最大的价值不是快,而是让项目在长了很久之后,改起来依然不慌。如果你准备上手,就从今天这个小Demo开始,把它改造成你自己的东西,比光看文章有用得多。