Flecs ECS 游戏开发技术专题全层级5 / 31 已发布

ECS 思维转型:从 OOP 到 DOD 的认知重建路径

· 0 分钟阅读
ECS 思维转型:从 OOP 到 DOD 的认知重建路径 The Mindset Shift — From Objects to Data-Oriented Design — Flecs ECS 技术专题

ECS 思维转型:从 OOP 到 DOD 的认知重建路径 The Mindset Shift — From Objects to Data-Oriented Design

为什么「换脑子」是这条路上唯一不能外包的部分

三年前有个做横版动作的朋友,半夜给我发消息:「我把玩家类拆成位置、速度、血量、贴图四个组件了,我 ECS 了。」

三周后他把工程甩给我看。我翻了两分钟就笑了——他写了个「玩家系统」,第一行先去找到场上那个唯一的玩家实体,然后把四个组件挨个拽出来操作,改完再塞回去,最后还顺手更新了一下界面。说白了,他写的还是一个类,只不过这个类被拆成了四张便利贴,用的时候再拼回去。

这就是我今天想聊的东西:ECS 真正的门槛从来不在接口怎么调,而在你脑子里那个「东西」的概念要先死一次。

我们这个系列走到第四篇,位置有点特殊。前三篇我们弄明白了这套范式是怎么从《守望先锋》那种大厂需求里长出来的,也把 Flecs、EnTT、Bevy、Unity DOTS 四个框架摆在一张桌子上做了横向对比。可框架选完,真正的坎才刚开始——文档看得懂,示例跑得起来,一到自己项目里,手还是往「管理器」和「基类」那边拐。

我见过太多项目死在半路上,不是性能不够,是脑子没换。有人把 ECS 用成了「带索引的全局字典」,数据散得像撒了一地的乐高;有人写了三十个系统,结果每个系统里只有一个实体,跑得比 OOP 还慢;还有人三个月后把整个架构推平重写回去,在社区留下一句「ECS 就是过度设计」。

所以这篇不讲框架、不讲接口,只讲一件事:从「对象思维」换到「数据思维」,中间要跨过哪几道坎,每道坎上你会怎么摔,摔完之后你能拿到什么。

第 1 层:把「是谁」和「有什么」拆开——继承树的葬礼

先从一个最朴素的问题开始:你游戏里的「火焰史莱姆」,到底是什么?

在继承树的世界里,答案是一条血脉:实体 → 生物 → 敌人 → 元素怪 → 火焰史莱姆。看起来干净利落。然后策划过来说:这只火焰史莱姆要能被玩家骑,要被冰冻减速(但它本身是火,冰抗高所以要打折),死了之后尸体能当燃料烧掉。请问,你把它塞哪儿?

塞在生物下面?那所有生物都能被骑。塞在敌人下面?那友方的火元素坐骑就得另开一条分支。开个接口?接口只声明能力,不存数据,结果你还得在每个类里重新实现一遍,代码复制三份。最后你大概率会加一个布尔标记叫「能不能骑」,再在骑乘系统里判断这个标记——恭喜你,你已经在用组件了,只是它长在类里面,没人承认。

组件思路做的第一件事,就是把「它是什么」换成「它有什么」。火焰史莱姆不再需要身份,它只需要挂三样东西:可骑乘、会被冰冻影响、死亡后留下燃料。策划再加一条「能被玩家策反」,你挂第四样。没有血缘,没有层级,只有清单。

对比维度继承树组件组合
加一种新能力要么改父类(波及所有子类),要么开新分支新增一个组件类型,旧实体完全不受影响
能力组合数受限于树形结构,斜向组合要写一堆特例组合数理论上无上限,实际只受你的设计约束
运行期变化类型在创建时就定了,中途变身很别扭增删组件即可,变身就是换一张清单
团队协作多人改同一个父类容易打架各做各的组件,冲突面小
存档与调试对象图、指针、循环引用一张张数据表,读写都直白

但我要说句实话:省代码不是重点。真正的收益是——你的设计空间变大了。

举个我们天天都在做的例子:状态效果。燃烧、中毒、冰冻、眩晕、沉默、护盾。继承树的做法是写一个效果基类,然后每加一种就开一个子类,每个子类里有自己的持续时间和每帧逻辑;策划说「我想要一个会传染的中毒」,你得改代码、编译、测试、出包。组件做法的做法是:效果本身就是一张配置表里的条目,实体身上挂「中毒」这个标签,外加一个「剩余时间 8 秒」的数值,再挂一个「来源实体」。传染?那是「中毒系统」里加一条规则,读一下周围的实体挂没挂同一个标签。策划加一种新效果,可能只需要在表里填一行。

这就是策划视角里最值钱的部分:从「每加一个玩法都要排期改引擎」变成「每加一个玩法只要填一行表」。玩家能感知到的是什么?是版本更新频率。是「这个游戏每次更新都在加新东西」和「这游戏半年才更一次」的区别。

案例拆解:《守望先锋》为什么敢让所有东西都是实体

2017 年暴雪在 GDC 讲过《守望先锋》的玩法架构和网络同步,那套东西可以说是现代 ECS 在商业大作里最出名的一次公开实践。他们做的核心决定非常激进:游戏里所有东西统一成实体——英雄、子弹、运载车、可破坏的场景物件、甚至一些纯逻辑对象,全部是「实体 + 一串组件」,组件只存数据,不存行为。

设计意图很清楚:一个持续更新、每个赛季都要塞新英雄的游戏,最怕的就是引擎被英雄机制绑死。假如按继承树做,每来一个机制奇怪的英雄,程序员就得往引擎里加一条特例分支,加到最后没人敢动那个文件。而按组件做,新英雄就是一组新组件加一组新系统,其他部分一行都不用改。他们分享里提过一个数字:一个典型实体身上挂的组件数量在两位数,复杂的场景物件更多——这说明什么?说明组合是真的在用,不是摆样子。

代价也同样清楚,而且这是很多人抄的时候不肯抄的部分:你失去了「对象」这个调试视角。你在传统的调试器里点开一个敌人,能看到它的全部状态;在 ECS 世界里,你看到的是二十个组件在地上摊着,需要工具帮你拼回来(Flecs 里这个工具叫 Explorer,第 18 篇细讲)。新人进项目,头两周基本都是懵的。暴雪扛得住这个成本,是因为他们团队够大、工具够好;你一个人做独立游戏,就得提前想好这个事怎么补。

玩家侧感知到的是另一件事:英雄更新快、机制差异大、击杀回放准。这三件事是同一个技术决定的下游结果——状态统一成了数据,所以新机制好加、回放好录、网络好同步。你看,玩家永远不会说「这游戏的 ECS 写得真好」,他们只会说「这游戏手感真跟手」。策划的语言就应该是这个。

但组合不是反结构,别一脚踩空

从 OOP 过来的人有个经典追问:「那我的复用怎么办?我记得 OOP 里继承是为了复用。」

答案是:复用不靠继承,靠模板和关系。Flecs 里有「原型(prefab)」这个概念,你可以预先定义一个「精英怪模板」,里面配好了血量、护甲、掉落表,然后用关系把具体实体挂到它下面,让它们继承这些默认值,又允许单独覆盖。这本质上是一种运行期的、可以随时改的复用,比编译期的继承灵活得多,但心智负担也更重——因为它不在代码里,在数据里,你得靠约定和工具去维护(Flecs 的关系系统是第 08 篇的主场,这里先按住)。

所以第一层要带走的一句话是:别问「这东西是什么」,问「这东西有哪些数据、会被哪些规则碰到」。前者是社会身份,后者才是工程事实。

第 2 层:让行为离开对象——从「挨个点名」到「整排扫射」

拆完组件,第二道坎马上来了:行为放哪儿?

OOP 的默认答案是:行为住在数据旁边。每个敌人自己知道自己怎么更新,你只要挨个点名让它更新就行。这个写法在代码上非常自然,自然到你看不出问题,直到你的敌人数量从 30 变成 3000。

想象你在一个巨大的仓库里做菜。传统写法是:先跑去 1 号货架拿盐,回来;再跑去 2 号货架拿酱油,回来;再跑去 3 号货架拿糖,回来。1000 个敌人就是 1000 趟往返,你把时间全花在走路上了。数据导向的做法是:先把今天所有菜要用的调料全搬到一张操作台上,然后一口气做完。走一趟,做完一轮。

在机器里,这个「走路」有个正式的名字叫内存访问。现代处理器的速度远快于内存,所以它会在每次读取的时候,顺手把旁边的一整块数据也搬进高速缓存里——因为你大概率马上就要用。连续存放的数据,一次搬运能顶几十次用;随机散落的数据,每次都得重新跑一趟。ECS 把所有「位置」放在一起、「速度」放在一起,就是让处理器少跑几趟腿。

这件事在玩家那边翻译成一句话:规模上限。

同样是打怪游戏,你的帧预算就 16 毫秒,一毫秒都不能超。如果每个敌人自己更新要花掉微秒级,你场上就只能站几百个;换成批处理,你能站三千个。这中间差的是什么玩法?差的是「幸存者 like 那种铺满屏幕的怪潮」和「三只精英怪慢慢磨」——完全两个游戏。我见过一个团队,玩法原型做出来特别爽,堆到 800 个敌人时卡成 PPT,最后不得不砍掉一半怪的密度,结果爽感直接掉档。他们当时以为是渲染的问题,其实是模拟那一侧在挨个点名。这个坑,我从 OOP 转过来的第一年也踩过,而且踩得比谁都狠——我还专门写了个多线程去「优化」它,结果更慢了,因为我连缓存在哪都没搞清楚。

案例拆解:《城市:天际线 2》——用了 ECS 的壳,装了 OOP 的核

这个案例我必须放进来,因为它太典型了,而且它恰好证明了我上面那句话:ECS 不是免费的午餐。

《城市:天际线 2》2023 年发售时用了 Unity 的 DOTS 技术栈,把市民、车辆、资源、地块都做成了实体,模拟粒度细到每个市民都有独立的受教育程度、健康状况、幸福度、通勤路径。这在宣传期是个卖点:模拟深度上了一个台阶。结果发售那阵子,高端处理器也扛不住——人口一多,模拟速度肉眼可见地慢下来,帧率一起掉。玩家做了一堆梗图,社区里最激烈的一句话是「所以 ECS 到底有什么用」。

我的判断是:主要原因不在 ECS,在于「数据布局和模拟依赖没跟上」。细看它的模拟逻辑,市民的通勤会影响道路占用,道路占用会影响别人的路径选择,别人的选择又反过来影响第一批人的通勤时间——这种强时序、强耦合的需求,天然难并行,你就算把数据摆得再整齐,也只能串着算。更要命的是,如果每帧要读的字段散在好几组不同的组件里、彼此还互相引用,那你的「批处理」就退化成了随机访问,缓存的优势被吃得干干净净。

还有一个很少被提及的原因:实体数量超过设计预期。开发期对着几万人口调优,玩家上来就造十万人口,各种边角情况一起爆。ECS 给你的其实是一张「这一帧你能碰多少数据」的信用卡,额度是你自己算出来的,刷爆了照样崩,而且崩得比 OOP 更难看——因为你会误以为是自己用错了框架。

这个案例的教训,对独立开发者尤其重要:迁移到 ECS 但没转型思维,只会变得更慢。因为你多了一层间接,却一点好处都没拿到,还要重新学调试。真正的转型是——先想清楚「哪些数据每帧都要碰」「哪些规则能批量算」「哪些必须串行」,再决定组件怎么切、系统怎么排。

辅助对比:《异星工厂》没打 ECS 的旗号,却比谁都数据导向

有必要拿一个跨类案例对照一下,因为它能帮你把「ECS」和「数据导向」这两个概念分开。

《异星工厂》的开发团队从来没说过自己做的是 ECS,他们的引擎是自研 C++。但你看他们的做法就明白了:传送带上的物品不是一个个独立对象,而是紧凑数组里的槽位;每一帧的更新是分层的——先统一算所有传送带怎么移动,再统一算所有机械臂怎么抓取,再统一算所有组装机怎么生产。同类的东西一起算,这就是数据导向的核心直觉。结果大家都知道:一座百万级物品的巨型工厂,还能跑得很顺。

举这个例子是想说明:DOD 是思维,ECS 只是它最流行的一种落地形式。你就算最后没用 Flecs,只要能养成「同类一起算、数据挨着放、别让行为绑在数据上」的直觉,你的项目也会受益。反过来,你就算用了 Flecs,脑子里还是「每个对象自己更新」,那就是把跑车开进了胡同。

落地的参数我放在后面「参数化设计工具」里给,这里先记住一条经验:单帧的游戏逻辑开销最好控制在总帧预算的 40% 以内,剩下的留给渲染、物理、音频。模拟这一侧一旦吃掉 10 毫秒,你就该知道出问题了。

第 3 层:把时间切成段——帧内一致性与延迟命令

第三道坎最隐蔽,很多人用了一年 ECS 都没意识到自己在摔。

先看传统写法里一件非常爽、也非常危险的事:调用链。一颗子弹打中敌人,扣血;血量归零,播死亡动画;动画播完,触发掉落;掉落进背包,触发成就;成就弹窗,暂停游戏。一条链七八层,写的时候特别顺手,因为「一件事自然引出下一件事」。然后你在第三层里把敌人销毁了,第五层里有个系统还在读它的数据,崩了。或者两个子弹在同一帧打中同一个敌人,两边的逻辑都判断「血量大于零所以扣血」,结果敌人吃了两发没死——因为它被扣了两次血,但是两次都读到的是同一份旧数据。

这类 bug 有个共同点:你复现不出来。因为它依赖执行顺序,而执行顺序在某些情况下是不确定的。我见过最折磨人的一个项目,联机测的时候偶尔出现「战利品掉两份」,单机怎么都复现不了,最后查了一周,发现是两个系统在同一个相位里改了同一个组件。

ECS 的解法非常干脆:把「这一帧发生了什么」和「这一帧什么时候生效」分开。

具体到 Flecs 里,有三样东西在干这件事:一是系统按阶段(Phase)顺序执行,你得先声明哪些逻辑在输入之后、哪些在模拟之后、哪些在渲染之前;二是所有的写操作——创建实体、销毁实体、增删组件——先排进队列,到同步点统一合并,这就是延迟命令;三是「变化发生时要做的事」用观察者模型处理,而不是每帧轮询。这三样东西的细节分别属于调度系统、观察者两篇的内容,今天你只需要记住一句话:延迟不是偷懒,是为了让整帧的数据保持一致。

玩家能感知到什么?说出来你可能觉得都是小事,但每一件都是「这游戏做得糙不糙」的判据。

  • 不会出现「我明明打死了它,它又站起来补了一刀」。这是帧内状态不一致的经典症状。
  • 击杀回放、死亡镜头能精确重演。因为状态就是数据,数据能重放。
  • 联机里网络回滚不炸。因为一帧的状态可以整体拍快照、整体回退。
  • 读档不炸。因为存档不再是一张互相指针纠缠的对象网,而是一张张表。

案例继续:《守望先锋》的跟手,一半功劳在时序

接着上面那个案例往下说,因为它是「时序解耦带来玩家体验」的最好注脚。

《守望先锋》是个射击游戏,射击游戏里「跟手」几乎是信仰。玩家在 60 到 80 毫秒延迟下还能打出精确的闪现、拔刀、箭矢预判,靠的不是魔法,靠的是客户端预测加服务器回滚。而客户端预测和回滚的前提是:整个游戏状态必须能被高效地保存、比较、还原。

这正是组件化的顺手礼。如果游戏状态是几百个互相引用的对象,你想拍个快照就得深拷贝整张对象图,还想做差异比较?做梦。而如果状态是一堆扁平的组件数据,快照就是复制几块内存,差异就是逐条比对,回滚就是把旧的那块盖回去。所以你看,ECS 在《守望先锋》里解决的其实不是「性能」问题,而是「网络同步可行性」问题——性能是附赠的。

我在自己的项目里也验证过类似的事。有一次做一个小型联机原型,一百多个单位,用了 Flecs 之后,回滚功能我是从一个「根本不敢想」变成了「两天写完」——因为世界状态整体导出再导入这件事,在数据导向的架构里是免费的。而在之前的 OOP 版本里,光是处理单位身上的引用就够我喝一壶。这个体验上的落差,是我后来一直劝人认真学这套东西的真正原因。

顺带说一句存档

存档是同一件事的另一个侧面。传统对象图存档,你要处理循环引用、悬空指针、版本升级时字段增删、加载顺序。玩家最痛的体验是什么?更新之后老存档废了,几十小时的进度蒸发。

而在 ECS 里,存档就是把世界序列化成一张张表,读的时候缺字段就给默认值。你更新版本,删掉一个组件、加一个组件,老存档照样能读,只是那些老实体少了个新能力——你甚至可以在加载时写一条迁移规则自动补上。这件事的具体做法我们在第 17 篇讲,但思维上的转变在于:当你把状态统一成数据,存档、回放、联机、编辑期热改这四件看似无关的事,会一起变简单。这就是为什么我一直说,ECS 对独立开发者的真实收益,比对大厂还大。

第 4 层:五类 ECS 反模式——它们总会偷偷回来

前三层讲的是「怎么想对」,这一层讲「怎么想歪」。因为这四年来我给同行看工程,看到的错误高度重合,翻来覆去就这五类。它们有个共同特点:你写的时候觉得自己很聪明。

反模式一:上帝系统

症状:一个系统文件三千行,名字叫「游戏逻辑系统」,里面从移动、伤害、AI、掉落、成就一路管到界面刷新。你觉得这是「集中管理」,实际上你是把原来那个巨大的对象,换了个名字叫系统。

玩家能感知到什么?版本更新慢。改一个数值要重新验证整个模块,测试成本飙升,你不敢频繁发版。这在独立游戏里是致命的,因为独立游戏最大的优势就是能快速试错。

修正的方法不是「按功能拆」,是按两件事拆:变化频率和变化来源。「位置每帧都变」和「名字一辈子不变」不该住在同一个系统里;「策划改数值」和「程序改规则」也不该住在同一个系统里。我一般的经验是,如果一个系统超过 300 行,就该拿把刀了。

反模式二:组件里藏行为

症状:组件里塞了一个状态机、一个计时器、一个回调函数指针,甚至塞了个「当前行为」的枚举。组件慢慢长成了一个小对象,你又要开始操心它的构造、销毁、生命周期。

这是最常见的「回魂」现象:你以为你把对象拆了,其实你只是把对象缩小了,然后藏进了组件里。

修正的思路是把三样东西分开放:状态用标签表达,时间用数值表达,行为放系统里。一个敌人当前是「追击」还是「逃跑」,用两个空标签互相切换就够了,不需要一个枚举字段。剩下「追击多久了」用数值。至于「追击的时候该干嘛」,那是系统的事。这样一来,你甚至可以在运行时用工具看到所有正在追击的实体——因为它们共享同一个标签,查询直接给你列出来。

反模式三:组件里存裸引用

症状:组件里存着另一个实体的指针,用来表达「我的目标是谁」「我的父节点是谁」「我骑的是谁」。

短期很爽,长期是灾难:那个实体被销毁了,你的指针就悬空了;存档的时候指针没法存;联机的时候两端的指针值对不上。玩家能感知到的是——随机的、莫名其妙的崩溃,而且越是长时间游玩越容易触发。

修正:用关系表达引用。父子关系、目标关系、骑乘关系,都应该是数据层的一等公民,由框架帮你管生命周期。这是 Flecs 最有辨识度的能力,第 08 篇整篇在讲这个。我这里只想强调思维层面:「引用」本身就是一种数据,它不该以指针的形式寄生在别的数据里。

反模式四:每帧销毁重建

症状:子弹、飘字、特效,用完就销毁,需要就新建。一秒钟创建销毁几百上千个实体。

在 OOP 里这叫「对象池没做」,在 ECS 里这还多一层代价——它会让底层的分块结构反复抖动(Flecs 里叫原型表,第 06 篇细讲),内存分配跟着抖,帧时间曲线像心电图。玩家能感知到的是:平时很顺,一打起来就偶尔顿一下,很难受,但你说不清是哪一帧卡的。

修正:预先创建一批,用「激活」和「休眠」两个标签切换。休眠的实体不参与大部分系统,成本接近于零;需要的时候把标签一换就复活。或者用原型(prefab)批量生成同一类实体。这个套路在弹幕、特效、伤害飘字上收益巨大,我见过一个项目光是把飘字改成池化,帧时间的抖动就小了一半。

反模式五:过度拆分

症状:位置拆成 X、Y、Z 三个组件,每个系统只读自己关心的那一个,美其名曰「最小知识」。

这是在用 ECS 的外形做反 ECS 的事。前面讲了,连续存放才有缓存优势,你把三个永远一起用的数拆成三张表,等于每次都跑三趟货架。玩家能感知到的是:你的 ECS 版本比 OOP 版本还慢,然后你在社区发帖说「ECS 是骗局」。

修正:按「一起读、一起写」聚合。位置就是一组三个数,速度就是一组三个数,别拆。拆分的标准是变化频率和读写模式,不是「字段语义上是不是独立」。

反模式你在工程里会看到什么玩家能感觉到什么修正方向
上帝系统单个系统几百上千行,什么都管版本更新慢、试错成本高按变化频率与来源切分,单系统控制在 300 行内
组件里藏行为组件带枚举、计时器、回调状态难排查,调试成本高状态用标签、时间用数值、行为进系统
裸引用组件里存别的实体的指针随机崩溃、存档和联机出问题用关系表达引用,交给框架管生命周期
每帧销毁重建子弹特效飘字全走新建销毁战斗中偶发掉帧、顿挫池化加激活标签,或走原型批量生成
过度拆分永远一起用的字段被拆成多个组件比 OOP 还慢,规模上不去按读写模式聚合,别按语义拆

这五条我贴在工位上过。说句实话,第五条我到现在偶尔还会犯——优化到兴头上的时候,人总想把东西切得更细,切着切着就切成了粉末。

三张表:把「感觉」变成可以调的旋钮

光有原则不够,策划出身的我最讨厌「你自己把握」这四个字。下面三张表是我自己在项目里用的,不是标准答案,但能让你有个起点。

表 A:组件粒度决策

判断项倾向合并倾向拆分
字段是否总是一起读写是(位置就是一组三个数)否(位置每帧变,名字几乎不变)
变化频率频率接近相差一个数量级以上
被多少系统共享只有一两个系统用五六个系统都要读
是否是纯标记——是(空组件最便宜,别给标记加字段)
是否带状态机语义——拆出去当标签,别塞进数据组件

建议区间:单个业务组件的字段数控制在 1 到 8 个,超过 12 个基本可以确定该拆了;一个实体身上挂 5 到 20 个组件是比较健康的范围,超过 30 个你就要回头看看是不是又在造对象;一个中小型项目(独立团队、单款游戏)的组件类型总量落在 30 到 120 种之间,低于 20 种通常意味着你还没真的放弃继承,高于 200 种通常意味着你在给每个功能造一次性组件。

表 B:系统与调度切分

参数建议区间说明
单个系统每帧耗时不超过总帧预算的 10% 到 15%60 帧下就是 1.6 到 2.4 毫秒,超了就该拆或该优化
系统总数20 到 60 个低于 15 个多半是上帝系统,高于 100 个先检查是不是过度碎片化
阶段(Phase)数量3 到 6 个典型是输入、模拟、后处理、渲染准备,别搞十几个
每帧同步点2 到 4 个同步点太密等于放弃批处理收益
延迟命令积压量每帧 500 条以内上千条通常意味着你在每帧重建实体,回头看反模式四
游戏逻辑占总帧时间40% 以内剩下留给渲染、物理、音频、系统开销

表 C:迁移节奏(给已经有 OOP 代码的人)

阶段做什么保留什么建议占比
0 双轨起步新功能一律用 ECS 写,旧代码不动所有旧系统2 到 4 周
1 数据先搬把旧对象里的字段搬成组件,行为留在原地读这些组件所有旧行为逻辑2 到 6 周
2 热点迁移只迁性能热点和变化最频繁的系统不热、不常改的旧代码按需,可以永远停在这
3 收尾删旧代码,统一命名约定——1 到 2 周

这里最重要的一行是阶段 2 的「可以永远停在这」。真的,不是所有旧代码都值得搬。判断标准就两条:它是不是性能热点,它是不是经常改。两条都不满足,你就让它在那儿待着,它不碍事。架构洁癖是独立开发者最贵的奢侈品,我买过,很亏。

初级速览 vs 进阶深入

初级路径:用五步做你的第一个完整系统

不要一上来就重构整个项目。挑一条最经典的链路——受伤、死亡、掉落——把它走通,你就摸到这套思维的形状了。

第一步,先定义「能被伤害」这件事。不要写一个「可受伤」的基类,而是定义一个血量组件,里面就一个当前值和一个上限。谁挂了它,谁就能被伤害。这一步的坑:很多人会忍不住往里面塞护甲、抗性、减伤、护盾、无敌帧,结果第一天就造了个大组件。忍住,先只放两个数。

第二步,写一个只做「扣血」的系统。它遍历所有同时有血量和「最近受到伤害」标记的实体,把伤害减掉,然后清掉标记。注意,这个系统不负责死亡、不负责播放音效、不负责飘字。它的世界里只有一件事。

第三步,写第二个系统,只做「血量归零时挂上死亡标签」。它不改血量,只加标签。为什么要分成两步?因为「死了」是个状态,状态可以被别的东西观察,而「扣血」是个过程,过程不需要被观察。这个区分是新手最容易糊掉的地方。

第四步,写第三个系统,处理有死亡标签的实体:产出掉落、播放动画、然后移除实体。移除这一步放进延迟队列,别当场删——你永远不知道还有哪个系统正拿着它。

第五步,给界面接一个观察者。血条不用每帧轮询,只在血量变化的那一刻刷新一次。你会发现代码一下子清爽了很多,因为「什么时候该更新」这件事被交还给了数据本身。

走完这五步,你大概会花掉一个周末。然后你会遇到第一个真实的困惑:「我这个游戏里的 Boss 有八个阶段,每个阶段机制完全不同,这也能用标签拼?」答案是能,但需要一点技巧——这就是进阶路径要聊的。

进阶路径:什么时候该转,什么时候不该转

给已经在项目里的人三条判断。

第一,判断你的项目到底需不需要 ECS。我的经验尺子是:如果你场上同屏实体数量的峰值稳定在几百以内,行为高度独特、几乎无法批处理(比如一个只有十几个角色的剧情向解谜游戏),团队不超过两个人、周期不到半年——那你用不用 ECS 都行,用了大概率是给自己找麻烦。反过来说,只要你有一个「数量会爆炸」的系统(弹幕、怪潮、粒子、模拟经营里的市民、策略游戏里的单位),这套思维就回本了。

第二,用「绞杀」的方式迁移,别推平重来。新功能走新架构,旧代码慢慢被包围、被替换、被饿死。我在一个中型项目里做过推平重写,那三个月是我职业生涯里最黑暗的三个月——功能没增加,bug 翻了三倍,投资人天天问进度。这个教训我写在这儿,希望你别再交一次学费。

第三,注意组织层面的转型,这个比技术难。三个人用 ECS,最大的问题不是性能,是「同一个组件在不同人手里有不同含义」。我见过一个项目,「状态」这个组件被三个系统分别理解成「眩晕状态」「动画状态」「网络同步状态」,跑起来之后谁都说不清出了什么问题。解法很土:一份组件词典,写清楚每个组件解决什么问题、谁在写、谁在读、字段的语义边界。这份文档比任何架构图都值钱。

几个我必须诚实回答的批评

「ECS 让代码更难读。」这个批评是成立的,我不打算洗。传统 OOP 里,你打开一个文件就能看到「敌人」的全部;ECS 里,一个功能被拆散在五六个系统里,你得靠搜索和工具去拼。对没参与架构的人来说,学习成本是实打实的。能缓解的手段有三样:系统命名必须表达意图(叫「处理死亡掉落」而不是「实体系统二」),组件词典必须维护,调试工具必须用起来(Flecs 的 Explorer 就是干这个的)。但缓解不等于消除。这笔账你要自己算:你换到的是组合自由度和规模上限,付出的是可读性。如果你的项目根本用不到前者,那就别换。

「双轨地狱」是迁移期真正的杀手。一半代码在 OOP 里,一半在 ECS 里,两边都要维护,数据要同步。这个状态如果超过两个月,团队士气会崩。我的建议是给迁移设一个硬期限,到期没迁完的部分就地封存,打上「永不迁移」的标签,别再碰它。

别抄别人的组件划分。网上有很多「标准组件清单」,位置、速度、血量、精灵、碰撞体,看起来很有道理。但组件该多细,取决于你的系统怎么读它,跟别人的项目一点关系都没有。我看到过有人照抄一份清单,结果自己最核心的玩法系统要读七个组件才能算出一次攻击——那不是组件的错,是划分没跟着玩法走。

最后补一句我自己的偏见:我认为「ECS 适不适合你」这个问题,八成不是技术问题,是游戏类型问题。你的玩法如果天然就是「大量相似个体做相似的事」,这套思维会让你爽到飞起;你的玩法如果是「少数独特个体做独特的事」,它会让你天天骂娘。这话不好听,但能帮你省下半年。

FAQ

Q1:项目已经做了一半,全是 OOP,还来得及迁吗?

来得及,但别全迁。先做一件事:找出你场上数量最多的那类东西(子弹、怪、单位、粒子),只把它们迁过去。通常这一块就能吃掉你 70% 的性能问题和 90% 的玩法扩展痛点。剩下的 UI、流程、存档管理这些「只有一个实例」的东西,留在 OOP 里毫无问题——它们本来就该是单例。

Q2:组件到底该多细?有没有一把通用的尺子?

尺子是有的,但不通用于所有人:看系统怎么读。两个字段如果永远被同一个系统一起读、一起写,就放一个组件里;如果它们的更新频率差一个数量级,就拆开。别用「语义上是不是同一类东西」当标准,那套是 OOP 的直觉,在这儿会害你。具体的量化区间在前面的表 A 里。

Q3:为什么我用了 ECS 反而更慢了?

八成是这四件事之一:组件拆得太碎,缓存全废;每帧销毁重建实体,内存抖动;所有逻辑挤在一个系统里,批处理的收益没吃到;或者你在系统里频繁调用外部引擎的接口,把本来能批量的事变成了逐条调用。还有一种情况,是你的规模根本没到——几百个实体的时候,ECS 的调度开销确实可能比直接遍历还高,这很正常,别慌。

Q4:状态机、行为树这些东西还要不要?放在哪?

要,而且很有用。它们在 ECS 里的位置是「决策层」——状态机负责决定「这个单位现在该干什么」,出来的是一个标签,然后各个系统根据标签去执行。反过来做,把状态机塞进组件里让组件自己跑,就掉进反模式二了。至于更复杂的 AI,我们在第 14 篇会专门聊几种范式和 ECS 的契合度。

Q5:我不会 C,能用 Flecs 吗?从哪儿开始?

能。Flecs 有 Lua、Python、C# 的绑定,第 16 篇整篇在讲。但我得提醒你:语言绑定解决的是「怎么写」,解决不了「怎么想」。你要是脑子里还是对象那一套,换成 Lua 写一样会写成带索引的全局字典。所以顺序应该是——先用一周把这篇里那五步走一遍,哪怕用最笨的方式;等你觉得「哦,原来是这么回事」了,再去挑语言和工具。

最后留个问题给你,也是我自己一直在想的:我们花这么大力气把游戏世界拆成数据,到底是为了让机器算得更快,还是为了让「改起来更便宜」这件事变得理所当然?我的答案是后者——性能的红利会随着硬件进步被稀释,但「一个策划下午就能试一个新玩法」这种能力,是永远不会过时的。你呢,你上一次因为架构太硬而砍掉一个好玩的想法,是什么时候?

文章标签
Flecs ECSEntity Component SystemC++ ECSArchetype ECSSparse Set ECSEnTTBevy ECSUnity DOTSECS 框架对比Flecs RelationshipsFlecs QueryFlecs Pipeline
更多专题全部专题
觉得有价值?点赞或收藏支持内容持续产出。
← 返回专题:Flecs ECS 游戏开发技术专题