ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

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

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目录在里面写一个MoveSystemusing System.Collections.Generic; using Entitas; using UnityEngine; public sealed class MoveSystem : IExecuteSystem { private readonly IGroupGameEntity _movers; private readonly ListGameEntity _buffer new ListGameEntity(); 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 IGroupGameEntity _lifetimes; private readonly ListGameEntity _buffer new ListGameEntity(); 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 ListGameEntity _buffer new ListGameEntity(); 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开始试试。
返回列表