news 2026/10/4 14:16:48

Entitas框架实战:Unity ECS核心概念与快速上手Demo

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
Entitas框架实战:Unity ECS核心概念与快速上手Demo

你翻开任何一个Unity项目,大概率能看到几十个MonoBehaviour,每个都挂着自己的Update,改一个数值可能要跑遍五六个脚本。第一次听说ECS的时候,我也以为这只是个性能优化噱头,直到自己把一个战斗逻辑模块重构为ECS之后,才发现它真正解决的其实是“代码组织方式”的问题。这篇东西我不会跟你绕弯子,目标只有一个:用最小的篇幅让你搞懂ECS是什么,同时把Entitas这个插件从下载、导入到写出第一个Demo的完整过程走一遍。

Entitas不是Unity官方那套DOTS,它是社区里更成熟、也更适合入门的C#版ECS框架。如果你已经被ECS的概念绕晕过,或者想知道为什么大家都在聊Entitas,又不愿意一上来就啃Unsafe代码,这篇应该能帮到你。

1. 先讲清楚 ECS 的三件套,再想代码怎么写

很多资料一上来就甩概念,结果读者看了半小时还在琢磨Entity到底是什么。我换个说法,你只需要记住三个角色:实体、组件、系统。

1.1 实体不是对象,是“有 ID 的一堆标签”

在传统写法里,一个“敌人”是一个类,它同时拥有血量、位置、AI、动画这些字段,再通过继承和多态来扩展。ECS里的Entity完全不是这个思路,它更像一个空壳子,唯一的身份是一个ID。你可以往这个ID上不断挂组件,也可以随时摘下某个组件。

组件就是纯数据,不包含任何逻辑。比如“位置”组件只有x、y、z,“血量”组件只有一个current值。你往实体上挂上“位置”和“移动速度”,这个实体就具备了能被移动的条件;挂上“血量”和“受伤反应”,它就变成了可以战斗的目标。实体是什么,完全由它身上的组件组合决定,不再需要为每一个新类型新建一个类。

1.2 组件是数据盒子,系统是处理数据的流水线

系统是唯一写逻辑的地方。它不关心某个实体是什么“类型”,只关心这个实体有没有它需要的那几个组件。一个移动系统只遍历“同时拥有位置和移动速度”的实体,把这些实体挨个挪一遍;一个伤害系统只遍历“同时有攻击力和目标标记”的实体,处理碰撞后扣血。

这种分离带来的直接好处是:写逻辑的时候你不需要关心对象树,不需要关心继承层次,只需要面向“数据集合”编程。同样一份数据,我可以让移动系统处理,也可以让网络同步系统处理,两个系统互不干扰,这就是ECS最核心的组织方式。

1.3 顺带说下“缓存友好”:为什么有人把 ECS 当性能银弹

很多文章提到ECS都会强调“缓存友好”,意思是组件在内存里是连续存放的。系统遍历一万个“位置”组件时,CPU缓存命中率比在内存里跳来跳去访问一万个对象的字段要高得多。这在大规模实体场景下确实有效果。

但我要泼一盆冷水:Entitas本身并没有把数据排成紧密数组,它内部依然有对象引用和容器开销。你用Entitas获得的主要是逻辑上的解耦和可维护性,真正的极致性能优化应该交给Unity DOTS,它配合Job System和Burst Compiler才能把数据布局做到那么极致。所以如果你是为了性能才想用Entitas,可以再想想;如果你是为了让代码不再乱成一锅粥,那方向是对的。

2. Entitas 和 Unity DOTS 我都碰过,最后选了 Entitas

看到这里你应该明白了,ECS是一种架构思想,不是某个特定插件。Unity官方有DOTS,社区里还有Entitas、LeoECS、Thor等好几个框架。我用DOTS写过原型,也用过Entitas做完整模块,下面聊聊为什么最终项目里选了Entitas。

2.1 Entitas 不是一个“渲染器”,它是一个 C# 层 ECS 框架

Entitas是一个开源的C#库,最早的版本在Unity Asset Store上分发,后来作者把源码放到了GitHub上。它做的事情有两件:一是提供ECS的运行时核心(Context、Entity、Group、Collector、Systems),二是提供了一个强大的代码生成器,根据你自定义的组件自动生成强类型API。

这种“写组件 + 一键生成 + 直接调用”的开发体验,和传统OOP差距没那么大,比起直接手写一大堆泛型容器和索引逻辑要舒服得多。尤其对于从没用过ECS的团队,Entitas的学习曲线非常友好。

2.2 Entitas 与 DOTS 的核心差异

我整理过一个对比表,现在翻出来给你参考:

比较项EntitasUnity DOTS
理论性能中等偏高极高,配合Burst/Jobs可以放大百倍
学习成本较低,API直观并有代码生成较高,需要理解SystemBase、EntityQuery、NativeContainer等
API稳定度多年没大变,进入维护期仍在演进,版本间API有变动
调试体验自带实体调试器,查看方便需要熟悉Profiler和Entity Debugger
迭代速度适合中小型玩法快速开发适合大规模模拟、大批量单位运算
适用项目战斗逻辑、技能系统、UI状态、回合制万人同屏、城市模拟、大型实时模拟

这个表格能看出来,Entitas的定位不是“替代DOTS”,而是让你以更低的门槛享受ECS的架构收益。如果你的项目是卡牌、回合制、技能组合、模拟经营,成千上万个实体已经很多了,Entitas完全跑得动;如果要做几万个带物理的单位,那你确实需要DOTS那套。

2.3 什么项目应该认真考虑 Entitas

我个人的判断标准是:项目里有没有大量“不同类型对象共享同一套行为逻辑”的需求。比如你有一堆“可以被击飞”“可以被减速”“可以被眩晕”的对象,在传统OOP里你要给每个基类都加这些字段和接口,很快基类就膨胀到不可维护。用ECS以后,这些状态都是独立组件,任何一个实体只要挂上“眩晕”组件,就会自动被眩晕系统处理。

反过来,如果你的游戏很简单,只有几个脚本,那就别上ECS,普通MonoBehaviour反而更直接。ECS是为复杂状态交互准备的,不是为了装点门面。

3. 下载和导入:放在 Assets 下,生成器正常出菜单就行

讲完选型,直接进入实操环节。Entitas的获取、导入和初始化一共就那么几步,但每一代Unity版本不同,总会有几个小坑。我按我实际的步骤写一遍。

3.1 获取 Entitas 源码的三种途径

Entitas目前主要有三种拿法:

  • 从GitHub仓库下载ZIP压缩包,这是最常用也最保险的方式。
  • 老项目或者想用旧版API的话,去Asset Store搜“Entitas - ECS for C# & Unity”。
  • 用Unity Package Manager直接填Git URL,能解决部分版本依赖问题。

下载ZIP之后,解压你会看到一个Entitas文件夹。这个文件夹就是框架本体,里面包含几个子目录,其中Entitas目录是核心运行时,Entitas.CodeGeneration是生成器,Entitas.Unity封装了Unity编辑器相关的菜单和界面,还有Samples目录放着官方示例。

把整个Entitas文件夹连同里面的子目录一起复制到Unity项目的Assets目录下,等Unity编译完成。如果编译过程没有红色报错,菜单栏顶部会多出一项Entitas。

这里有个很容易忽略的细节:Entitas依赖.NET 4.x,打开Project Settings → Player → Api Compatibility Level,把它设为.NET Framework而不是.NET Standard 2.0,否则代码生成器在某些版本下会报错。老一点的Entitas版本对.NET Standard支持得并不好,为了少折腾,直接切到Framework。

3.2 在 Unity 里第一次生成:Configuration 和菜单

导入完成后,点击菜单栏的Entitas → Preferences,会打开一个生成配置界面。默认情况下它已经为你创建了一个名为Game的Context(你也可以自己加一个Input)。这个配置决定了两件事:你的组件标记是什么,以及生成代码时以哪些Context为维度。

打开项目里的Assets/Scripts,新建一个Components文件夹,在里面建一个空脚本,内容先写上组件标记:

using Entitas; [Game] public sealed class PositionComponent : IComponent { public UnityEngine.Vector3 value; }

保存后,等编译完成,再点击菜单栏Entitas → Generate。生成器会把所有带[Game]标记的组件类扫描一遍,在Assets/Generated目录下生成一大堆代码。

我第一次跑生成器的时候还闹了个笑话:生成完了半天找不到新代码在哪,后来才知道Entitas默认输出目录是Assets/Generated,如果你项目里有其他生成目录配置,就按Preferences里的路径找。看到Assets/Generated/Game/Components下面生成了PositionComponent.cs对应的GamePositionComponent.cs等文件,就说明生成成功了。

需要注意的是,这个Generated目录是生成器产物,一般不建议手动改。生成器的核心价值就是把这些强类型API的模板代码自动维护好,你手动改了,下次生成就会被覆盖。

4. 跑一个会动的小球 Demo:从组件定义到系统执行

理论部分说完,我用一个最简单的Demo演示完整的Entitas开发流程:创建若干个小球实体,它们沿x轴方向匀速移动,5秒后自动销毁。这比官方那些复杂示例更直观,也足够让你看清楚“组件-系统-实体”是怎么协作的。

4.1 定义三个组件:Position、Move、Lifetime

在Assets/Scripts/Components目录下,新建三个组件文件:

using Entitas; using UnityEngine; [Game] public sealed class PositionComponent : IComponent { public Vector3 value; }
using Entitas; using UnityEngine; [Game] public sealed class MoveComponent : IComponent { public float speed; public Vector3 direction; }
using Entitas; [Game] public sealed class LifetimeComponent : IComponent { public float value; }

我特意把三个组件拆成三个文件,这是Entitas的推荐做法,因为每个组件对应一个生成的类,文件多了反而好追踪。你顺手也会发现,组件类只要实现IComponent接口,再打上[Game]标记,剩下的交给生成器。

保存后,去菜单栏点一次Entitas → Generate。生成器会做这么几件事:

  • 为GameEntity生成强类型方法,比如AddPosition(Vector3 value)、ReplacePosition(Vector3 value)和属性position。
  • 生成GameContext与GameMatcher,用于查询和筛选实体。
  • 生成Contexts单例类,在运行时统一管理所有上下文。

4.2 代码生成后你会看到什么

生成结束后,你打开Assets/Generated目录,会看到类似这样的层级结构:

Assets/ ├─ Entitas/ ├─ Generated/ │ ├─ Contexts.cs │ ├─ Game/ │ │ ├─ Components/ │ │ │ ├─ GameMoveComponent.cs │ │ │ ├─ GamePositionComponent.cs │ │ │ ├─ GameLifetimeComponent.cs │ │ └─ GameEntity.cs │ │ ├─ GameContext.cs │ │ └─ GameMatcher.cs

我不建议你花太多精力一行行读这些生成代码,你只需要记住几个约定:所有生成的实体类和上下文类都会在编译期被识别,调用时是强类型的,写错了方法名会直接编译报错,不会等到运行时再翻车。

比如你加上了一个HealthComponent,生成之后GameEntity上会立刻多出AddHealth、ReplaceHealth、RemoveHealth方法,以及health属性。这种靠代码生成带来的编译期检查,恰恰是Entitas比不少“字符串匹配组件”方案舒服的地方。

4.3 手写三个系统:移动、生命周期、销毁

创建Assets/Scripts/Systems目录,在里面写一个MoveSystem:

using System.Collections.Generic; using Entitas; using UnityEngine; public sealed class MoveSystem : IExecuteSystem { private readonly IGroup<GameEntity> _movers; private readonly List<GameEntity> _buffer = new List<GameEntity>(); public MoveSystem(Contexts contexts) { _movers = contexts.game.GetGroup( GameMatcher.AllOf(GameMatcher.Position, GameMatcher.Move) ); } public void Execute() { foreach (var entity in _movers.GetEntities(_buffer)) { entity.ReplacePosition( entity.position.value + entity.move.direction * (entity.move.speed * Time.deltaTime) ); } } }

这里有个关键点:系统不是自己去Contexts里遍历所有实体,而是通过GetGroup预先筛选出同时拥有Position和Move的实体组。只要实体加入或移除这两个组件,组集合就会自动更新,开销比每帧全量扫描小很多。

生命周期系统负责倒计时:

using System.Collections.Generic; using Entitas; using UnityEngine; public sealed class LifetimeSystem : IExecuteSystem { private readonly IGroup<GameEntity> _lifetimes; private readonly List<GameEntity> _buffer = new List<GameEntity>(); public LifetimeSystem(Contexts contexts) { _lifetimes = contexts.game.GetGroup(GameMatcher.Lifetime); } public void Execute() { foreach (var entity in _lifetimes.GetEntities(_buffer)) { var remaining = entity.lifetime.value - Time.deltaTime; if (remaining <= 0f) { entity.Destroy(); } else { entity.ReplaceLifetime(remaining); } } } }

注意我在两个系统里都用了_buffer这个列表作为缓存容器,这是遍历group时的通用操作。原因后面专门讲,这里你先记住这个习惯。

4.4 用 Feature 组织系统跑起来

有了组件和系统,还需要一个入口把它们串起来。我创建一个GameController挂到一个空物体上:

using Entitas; using UnityEngine; public sealed class GameController : MonoBehaviour { private Systems _systems; private void Start() { var contexts = Contexts.sharedInstance; _systems = new Feature("Game") .Add(new MoveSystem(contexts)) .Add(new LifetimeSystem(contexts)); _systems.Initialize(); for (var i = 0; i < 10; i++) { var entity = contexts.game.CreateEntity(); entity.AddPosition(new Vector3(i * 0.5f, 0f, 0f)); entity.AddMove(2f, new Vector3(1f, 0f, 0f)); entity.AddLifetime(5f); } } private void Update() { _systems.Execute(); _systems.Cleanup(); } }

这段代码里,Contexts.sharedInstance是生成出来的单例入口。实体创建后通过AddPosition、AddMove这样的生成方法挂上组件,系统就会自动识别它们。

Feature是Entitas里一种特殊的Systems容器,它比普通Systems多了一个功能:可以在Unity的Hierarchy窗口里显示系统列表和每帧耗时。运行时你可以打开Hierarchy的“Entitas”视图,直接看到每个系统的执行时间和实体数量,这对调试特别有用。

运行项目,你会看到十个小球从不同起点出发,沿着x轴匀速移动,5秒后依次消失。消失的顺序取决于它们的生命周期是否先到达0,这正好演示了“同一种组件可以被多个系统同时处理”的效果。

5. 这几个坑是我实际踩过的:越早知道越省时间

Entitas这个框架整体很稳,但有几个使用习惯上的问题最容易让新手卡住。下面这些全是我在项目里踩过的,今天一次性写出来。

5.1 group 遍历过程中不要直接销毁实体

我第一次写Destroy逻辑时,直接在foreach里调用entity.Destroy(),结果运行时随机报错,有时是“Collection was modified”,有时直接卡死。原因很简单:GetEntities()返回的内部集合是共享的,遍历过程中销毁实体,相当于一边往集合里删数据一边读集合,自然出问题。

Entitas建议通过带缓冲列表的方式来遍历:

private readonly List<GameEntity> _buffer = new List<GameEntity>(); foreach (var entity in _group.GetEntities(_buffer)) { // 这里可以安全地替换组件、销毁实体 }

GetEntities(_buffer)会把当前组内的实体快照复制到你的列表里,之后迭代的是一个独立副本,这时再调用Destroy()就安全了。我项目里只要涉及系统内销毁实体的,统一都这么干。

5.2 一切皆组件之后,Inspector 调试反而变难了

用传统MonoBehaviour时,字段直接暴露在Inspector里,运行时可读可改。用Entitas之后,实体是代码里动态创建的数据,Inspector里根本看不到。开发初期我经常面临一个尴尬:游戏跑起来,某个实体的状态不对,但不知道它内部组件是什么值。

解决思路有三种:

  • 使用Entitas自带的窗口,在编辑器里查看实体的组件列表和值。
  • 给关键实体额外挂一个简单的MonoBehaviour调试脚本,在Update里同步读取组件数据。
  • 在实体上增加一个DebugEntity组件,记录它的业务ID、生成时间、状态描述,方便在Profiler里定位。

我目前项目里用的是第二种和第三种组合,毕竟Entitas的调试窗口在编辑器里开着会有反射开销,不常驻。

5.3 代码生成的“键”和文件丢失问题

Entitas生成的代码是基于组件定义一次性生成的。如果你在团队协作里,有人新增了组件但没有重新生成,或者生成器输出目录被Git忽略导致其他人拉到代码后缺少生成文件,整个项目会直接编译失败,而且报错信息往往指向“找不到AddXxx方法”。

这个问题的根因是:团队约定没有跟上。我们后来在项目里写了一条硬性规则:新增或修改组件后必须执行一次Entitas → Generate,并且把Assets/Generated目录加入版本控制,不允许忽略。只有生成后的代码进入版本库,队友拉下来才能直接用。

5.4 别为了 ECS 而 ECS:合理的组件拆分边界

ECS容易让人上头,看到什么都想拆成组件。比如一个小球,你给它拆了“半径”“颜色”“材质”“是否发光”“是否加粗”五个组件,看起来挺酷,但系统要获取这些数据时就得多一次组件查找,组件越多,性能反而下降。

组件的合理粒度应该这样把握:同一帧、同一系统要一起处理的数据,尽量放同一个组件。比如“移动”和“位置”天然是成对出现的,放在一起完全没问题;而“眩晕”“减速”这些状态,即使很少出现,也值得单独拆,因为很多系统要按需查询它们。

5.5 表现层和逻辑层的桥接要提前设计

ECS处理的是纯数据逻辑,但Unity里最终展示还是需要GameObject、Transform、Animator这些对象。如果你不做处理,小球移动到位置100,但场景里的模型还停在原点,表现和逻辑就对不上了。

常见做法是:实体持有Transform或GameObject的引用组件,系统在改变逻辑位置后,同步更新Transform。这个桥接层放哪个系统里,要先想清楚。我推荐单独建一个SyncTransformSystem,最后执行,保证逻辑全部计算完再写到表现层。

另外要注意,实体销毁时不一定会自动销毁对应的GameObject,需要在销毁逻辑里手动处理,否则也会留下无主的场景对象。

这些坑没有一个是Entitas自身的缺陷,更多是架构切换过程中的习惯问题。但跨过这些之后,你会明显感觉到,系统的职责边界清晰了,改一个技能效果不再需要翻遍十个脚本,新增一套敌人AI也只需要写新组件和新系统,老的系统完全不受影响。这就是ECS带给我的真实收益。如果你也准备把手头某个逻辑复杂的模块重构一遍,不妨从Entitas开始试试。

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

插件加载失败排查指南:plugin.json、TypeScript SDK 与 CLI 实战

1. 从“plugins”这个词说起&#xff1a;它到底在解决什么问题如果你最近在折腾 Cursor、Codex CLI、ZCode CLI 这类工具&#xff0c;大概率会在某个时刻撞上plugins这个词。它可能出现在配置文件里&#xff0c;可能出现在启动日志里&#xff0c;也可能出现在某个报错信息里——…

作者头像 李华
网站建设 2026/10/4 14:14:33

设计形态学与第三自然:让形态“长”出来的生成设计实践

团队工位一角常年堆着两样东西&#xff1a;一摞刻着连续曲面切片的草模&#xff0c;一本翻烂了的《On Growth and Form》。有人第一次来会误以为这是生物实验室&#xff0c;其实那本Thompson的经典书旁边就放着犀牛模型、KeyShot渲染图和一个写着"第三自然"的白板。这…

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

办公 AI 助手到底值不值得用?从任务收益到真实局限的完整拆解:TaoToken 统一 Key 接入 TraeWork 的 Work 模式与 Code 模式实测

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/10/4 14:07:49

Coding Agent长期记忆:从易失忆到可持久化的实战拆解

我自己用 Coding Agent 半年多&#xff0c;最大的感受不是它多能写代码&#xff0c;而是它实在太容易“失忆”。上午让它修完一个 Bug&#xff0c;下午换个会话再让它优化同一段逻辑&#xff0c;它能给你写出一版与上午完全冲突的方案。长期记忆这件事&#xff0c;正在成为 Cod…

作者头像 李华