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

关系(Relationships):Flecs 最具差异化的核心特性深度解析

· 0 分钟阅读
关系(Relationships):Flecs 最具差异化的核心特性深度解析 Relationships as First-Class Citizens — Flecs ECS 技术专题

关系(Relationships):Flecs 最具差异化的核心特性深度解析 Relationships as First-Class Citizens

为什么「关系」是 Flecs 最不像 ECS、却最像游戏的那块拼图

你做一个太空游戏。玩家在船坞里给自己的护卫舰挂上一门炮塔——玩家的脑子都不用转,这是天经地义的事。

然后你坐回电脑前,发现这件天经地义的事,在 ECS 里没有对应的表达。

炮塔是个实体,飞船是个实体。它们之间的关系,传统 ECS 只能这样表达:在炮塔的组件里塞一个「我的主人是谁」的编号。行吧,能用。于是你写了。三个月后,飞船爆炸,你要清理所有挂在它身上的炮塔;你要做「拆下来的模块继承主人 30% 属性」;你要在 UI 上显示「这炮塔属于哪艘船」。每一个需求,你都得重新手写一遍「遍历所有炮塔、比对主人编号、还要校验这个编号是不是已经被回收复用了」。

说到「回收复用」,我给你讲个真事。我自己的项目里曾经有过这么个 bug:玩家锁定了一个敌人,敌人死了,0.3 秒后玩家的追踪导弹飞出去,稳稳地打中了一只刚刷新的、完全无关的野兔。玩家在论坛上贴了录屏,标题叫「我的导弹有恋尸癖」。原因特别朴素——ECS 的实体句柄不是身份证号,它更像图书馆的索书号。书还回去了,号会被下一本书用掉。你手里攥着一个旧号码,它永远「有效」,只是指向的已经不是原来那个东西了。

这个 bug 的本质不是我们代码写得烂,是框架根本不知道那条边存在。外键是你画在纸上的箭头,ECS 看不见它,所以它没法替你清理、没法替你校验、没法在查询里替你走这条边。

Flecs 干的事,就是把这条箭头从纸上抠下来,焊进数据结构里。你在实体上挂一条「ChildOf → 飞船」,框架从此知道:炮塔属于飞船;飞船没了炮塔该跟着没;查询可以写「所有挂在玩家飞船上的炮塔」,一句话;渲染时炮塔不用自己存一份世界坐标,顺着边往上问就行。

这就是为什么我把它排在「Flecs 最具差异化的核心特性」第一位——不是因为炫技,是因为游戏数据天生就是一张图。谁装备了什么、谁恨谁、谁是谁的模板、谁给谁上了 debuff,全是边。传统 ECS 把这张图拍平成一堆编号,你每用一次都得自己把图重新拼起来。Flecs 选择不拍平,代价是它得替你管更多东西——而这个代价,才是本文真正要聊的。

如果你还没读上一篇《查询系统全解》,建议先扫一眼,因为关系真正的威力有一半在查询侧的向上遍历上;读完了回来,我们一层层拆。等拆到第四层,你会看到一个让我拍过大腿的设计:关系可以被赋予「目标被删除时我该怎么办」的策略。这一条,直接干掉了我项目里三分之一的清理代码。

第 1 层:关系不是「组件里放个编号」,它是两个实体组成的一个组件

先把话说清楚,不然容易绕晕。在 Flecs 里,一条关系不是某种特殊对象,它就是一个组件,只不过这个组件由两个实体组成:一个是「关系名」,一个是「目标」。写成人类能看懂的形式就是 (ChildOf, 飞船) 或者 (IsA, 哥布林模板) 或者 (Buffs, 冰霜法师)。

注意这句话的分量:因为它是组件,所以它就该出现在 archetype 里;因为它在 archetype 里,所以查询能直接匹配它。这就是「一等公民」和「塞个编号」的全部区别——框架能不能在数据层面看见这条边。

行业里三种主流做法,摆在一起看最清楚:

做法长什么样框架能替你做什么玩家能感知到的后果
单向外键在炮塔组件里存一个「主人编号」几乎没有。清理、校验、反查全靠自己主人死了,炮塔变孤儿;编号复用后打错目标
双向字段炮塔存主人编号,飞船存一个子实体列表还是靠你自己同步两边两边不一致时出现「飞船显示挂着 5 个炮塔,实际只有 4 个能开火」
Flecs 关系炮塔身上挂 (ChildOf, 飞船)删除级联、查询匹配、向上遍历、批量清理拆模块、爆炸、界面反查,全都天然成立

中间那一档「双向字段」,听起来很土,但它是当下最主流的工程答案——Unity DOTS 就是这么干的:一个 Parent 组件指向父,父实体身上再挂一个 Child 列表缓冲。这套方案能用,代价我后面会专门拆,因为《城市:天际线 2》那种量级的模拟游戏,就是在这个代价上跟玩家较劲的。

现在看反面教材。《守望先锋》在 2017 年那场著名的架构分享里,把 ECS 讲得明明白白:他们的实体就是纯整数 ID,组件就是纯数据,所有跨实体的引用都是「我存了一个 ID,我按 ID 去取」。没有 join,没有图遍历,一切都靠手写。

这套东西在他们那个场景下是对的——6v6、二十来个英雄、每局几十秒,实体数量完全可控,手写解引用的开销比框架维护关系的开销还低。但你看它的收益和代价:收益是极致简单、极致可预测,网络同步的时候每个实体都是扁平的一块数据;代价是每一处跨实体访问都要重复三件事——解引用、校验目标还活着、确认类型对不对。这三件事写一次没事,写三百次就是三百个 bug 候选。

而玩家真正能感觉到的差别,往往不在战斗里,在「反查」上。比如你想做一个 UI:鼠标悬停在炮塔上,高亮它所属的整艘飞船。外键方案下你要么每帧扫全表,要么提前建一张反向索引表然后自己维护它。关系方案下,这就是一个「沿着边往上一跳」的动作,跟喝水一样。这类需求在游戏里出现的频率,远比你立项时估计的高——你以为是边角料,其实是玩家最常干的事。

第 2 层:ChildOf——最常用的那条边,也是最贵的那条边

ChildOf 是 Flecs 内建的父子关系。给一个实体挂上 (ChildOf, 某个父实体),它就变成了子实体。这条关系有两个内建脾气:一是排他,一个实体只能有一个父;二是不许成环,你不能让飞船当炮塔的儿子、炮塔又当飞船的爹,框架直接拦下来。

先说爽的部分,因为这部分是真爽。第一,级联删除默认开着——父实体没了,所有挂在它下面的子实体一起消失。飞船爆炸,炮塔不用你管。第二,也是我最喜欢的一点:查询可以「向上走」。你写一个查询,条件是「有炮塔组件,并且祖先里有一个带护盾的实体」,Flecs 会顺着 ChildOf 这条边往上爬,找到那个带护盾的祖先。这意味着子实体不需要复制父级的数据。

听起来抽象,落到玩家身上就特别具体。假设你在做一个船舰建造游戏:飞船有护盾值,炮塔开火时护盾给伤害加成。外键方案里,要么你在炮塔上常驻一份「主人的护盾值」然后天天同步,要么你每帧遍历炮塔再回头查飞船。前者会出现「护盾已经破了但炮塔还在加成」的经典不一致;后者在炮塔上千的时候帧数就往下掉。而向上遍历的方案,是用的时候现查,永远准,也不占额外内存。

官方生态里那套 transform 模块就是这个思路的范本:子实体的位置、旋转、缩放存的是相对父级的局部值,渲染前由一个系统顺着 ChildOf 往下推,算出世界矩阵。飞船整体移动,炮塔的世界坐标自动就对,你一个字节都不用写。

好,爽完了,说痛的地方,这段你要拿笔记。

ChildOf 的目标不同,archetype 就不同。这一句是本文最值钱的一句,我们拆开看。archetype 你可以理解成「组件组合完全相同的实体住的那间宿舍」。Flecs 会把同一间宿舍里的实体排成连续内存,查询的时候整间宿舍一起处理,快得离谱。而 (ChildOf, 飞船 A) 和 (ChildOf, 飞船 B) 是两个不同的组件——因为目标不一样。所以它们是两间不同的宿舍。

算笔账。你有个舰队游戏,200 艘飞船,每艘挂 20 个模块,一共 4000 个模块实体。如果每个模块都老老实实挂 ChildOf,你就拥有了 200 间宿舍,每间住 20 个人。查询「所有模块」要挨个跑这 200 间宿舍。这还只是两层;如果炮塔上还能挂枪管,父级数量就是 4000 起,宿舍数量直接起飞。内存碎片、查询开销、遍历成本,三样一起涨。

对比一下 IsA(下一层的主角):所有实例的边都指向同几个模板,目标是共享的,所以它们住在同一间宿舍。同样的 4000 个敌人,如果都继承自「哥布林模板」,那它们可能就住在两三间宿舍里。一个是线性增长,一个是常数——这个区别,就是你选关系类型时最该先想清楚的事。

再说个更狠的对照:Unity DOTS 那边的真实工程。它的父子是 Parent 组件加父身上的 Child 缓冲,两边必须同步。为了知道「父变了没」,它还得在子实体上挂一个「上一次的父是谁」的组件,靠每帧比对来找变更,然后把子从旧父的缓冲挪到新父的缓冲里。这套流程在单位数量少的时候毫无感觉;但《城市:天际线 2》那种把每个市民、每辆车的行驶、每个家庭的迁徙都做成实体的模拟游戏,一旦这个量级上去,父子关系的维护本身就会变成热点。社区刚发售那阵子对模拟速度的抱怨,本质上吵的就是「这么重的模拟,值不值得」——顺带一提,这也是《ECS 是否被过度神话》那一篇要正面掰扯的问题,这里先按下。

那怎么办?两条路,都很实在。

第一条:认栽,但把父级数量控住。把 ChildOf 留给「父级数量天生就少」的场景——玩家角色、场景根节点、UI 画布、当前屏幕里活着的几个 Boss。这些场景下你撑死几十个父级,宿舍数量完全可控,还白拿级联删除和向上遍历。至于「几千个 NPC 每人挂一堆子实体」这种事,别用 ChildOf,把父级编号塞进普通组件,或者用第四条路。

第二条:让这条边不参与分宿舍。Flecs 4.x 提供了给关系打「不分裂 archetype」标记的能力,挂上之后,这条边不再改变实体的宿舍归属,而是存在旁边的稀疏结构里。查询的时候扫起来会慢一点,但宿舍数量不会炸。这是典型的「拿遍历速度换内存与表数量」的交易,什么时候划算?当你的目标是「每个实体一个」的极端情况,比如每个角色身上都有个独一无二的锁敌目标——你总不能让 500 个敌人分裂成 500 间宿舍吧。

顺便说一句,这也是我在自己项目里翻过的车。当年做一个塔防,每个防御塔挂一堆「子弹挂点」,我没想就上了 ChildOf,跑起来帧数还行就上线了。结果玩家玩到后期摆了两百座塔,敌人一多,帧率开始不稳。我查了半天渲染和寻路,最后发现是 archetype 数量在 Flecs Explorer 里红成一片——二百多间宿舍,每间住三四个。改法很简单:挂点改成塔身上的一个普通数组,五分钟改完,帧数回来了。这教训我记到现在:ChildOf 是按父级分裂的,用之前先数一数你有多少个父。

第 3 层:IsA——把「策划的配置表」变成一条边

IsA 是 Flecs 内建的第二条关系,也是我觉得最被低估的一条。它表达的是「我是某个东西的一个实例」。你在一个实体上挂 (IsA, 哥布林模板),它就自动「借」到了模板身上的所有组件值——血量、速度、掉落表、贴图。你不用复制粘贴,模板改一个数字,全世界所有哥布林立刻跟着变。

这就是游戏行业里人人都懂、但实现方式千奇百怪的东西:预制体、模板、原型、配置表。Unity 的 Prefab 是完整拷贝,你改 Prefab 得重新实例化或者手动 Apply;Unreal 的蓝图继承本质是 OOP 继承,编译期就定死了;而 Flecs 的 IsA 是运行时的数据继承,模板改了,下一条查询立刻看到新值。

这个差别,玩家是真的能摸到的。想象一个运营中的游戏,你的「精英骑士」全服有两万只。策划下午四点跑来说:精英骑士的抗性从 30% 调到 45%,今晚活动要用。拷贝派的做法是改配置表、重启、玩家掉线一批;IsA 派的做法是改模板实体上的那个数值,一秒生效。你说哪个更能救命。

关键机制有三个,逐个说。

第一,继承是「共享 + 覆盖」,不是「复制」。实例默认跟模板待在同一个宿舍里——因为 (IsA, 哥布林模板) 这条边,对所有哥布林实例来说是同一个组件,目标一样嘛。所以两万只哥布林很可能就挤在那么几间宿舍里,查询性能好得让你怀疑人生。这是 IsA 和 ChildOf 最本质的分野:ChildOf 的目标是「一个一个个体的爹」,天然发散;IsA 的目标是「几十个模板」,天然收敛。

第二,覆盖是有代价的,而且代价你猜不到在哪。某个哥布林首领血量要特殊一点,你在它身上写了一次血量,它就「覆盖」了模板的那个值,从此跟模板脱钩。同时——注意这里——它的组件组合变了(多了一个自有血量),它得搬家,从「继承宿舍」搬到「继承 + 自有血量宿舍」。所以「每个实例都覆盖一点东西」这件事,会让你从「两三间宿舍」滑回到「一大堆宿舍」。我见过最惨的情况是:给每个怪随机生成名字,名字作为组件存实体上——恭喜你,两万只怪两万间宿舍,IsA 带来的性能红利全吐回去了。

这里有个很实用的判断标准:会被大量实例覆盖的字段,别放模板上,放一张共享的数值表里,实体只存一个索引。模板用来装「所有人共用」的东西,索引用来表达「我是个特例」。

第三,链别拉太长。IsA 可以套娃:精英哥布林继承哥布林,火焰精英哥布林继承精英哥布林。读一个值的时候,框架得顺着链往上找。两层没感觉,三层还行,五层以上就该停下来问问自己——你是不是把 OOP 的继承树偷偷搬回来了?我在《从 OOP 到 DOD 的认知重建》那篇里骂过继承树,这里得自我批评一句:IsA 用得爽的时候,特别容易滑回「用继承表达分类」的老路。区分「模板」和「类型」很重要:模板是「同一种东西的不同配置」,类型是「本质不同的东西」。前者用 IsA 合适,后者请老老实实用标签组件。

还有个细节值得说:不是所有组件都该被继承。「当前血量」这种运行时状态,你放模板上就是给自己埋雷——新刷出来的怪会带着模板上那个残血值出门。Flecs 里可以给组件打上「禁止继承」的标记,或者干脆在模板上就不放这类组件。这条我踩过:早期版本我把「当前血量」和「最大血量」做成了一个组件,结果所有怪出生都是 0 血然后立刻死,那个下午我盯着屏幕思考了很久人生。

落到玩家体验上,IsA 真正改变的是内容产能。当「一百种怪」变成「五种行为模板 × 二十种数值配置」的时候,你的策划不用等程序,程序不用重编译,一个下午能出三十个变体。这不是技术优化,这是把做内容的成本结构给改了——对一个三五个人的独立团队来说,这比省下几毫秒帧时间重要一百倍。

第 4 层:自定义关系——把玩法规则从代码里搬进数据里

ChildOf 和 IsA 是框架送的,自定义关系才是 Flecs 让你上瘾的地方。因为在这里,你第一次可以把「谁对谁做了什么」这件事,直接写成数据,然后让查询、清理、存档全都自动跟着走。

拿最经典的 buff 系统举例,这东西每个游戏都有,每个游戏都写过,每个游戏都被它坑过。

外键做法:角色身上挂一个减速组件,组件里存一个「施法者编号」。听起来没毛病。然后你遇到第一个需求——角色死了,清掉它身上的 buff。你得遍历所有带减速组件的实体,比对施法者编号。第二个需求——施法者死了,清掉它给别人的 buff。同样一遍遍历。第三个需求——UI 上显示「减速(来自:冰霜法师)」,你得拿着编号再查一次名字。第四个需求——玩家反馈「怪物死了三秒了,我身上还挂着它给的减速」,你翻代码发现某条分支漏了清理。

这个 bug 的画面感太强了:你在打一只精英怪,它临死前给你上了个毒,然后它死了,毒还在,你一边跑一边掉血,看着地上那具尸体骂街。玩家不会觉得「这是清理逻辑漏了」,玩家觉得「这游戏做得真糙」。

关系的写法是这样的:buff 本身是个实体,关系叫「施加于」,边就是 (施加于, 你)。更关键的是——你可以给这条关系本身挂一条策略,规定「目标没了怎么办」。这就是我认为 Flecs 最漂亮的设计之一。

策略一共就几档,但每一档对应一类完全不同的玩法语义,这张表你直接抄走:

目标被删除时语义典型场景玩家的感受
连我一起删「我是它的附属品」装备、模块、子实体、挂在角色身上的特效飞船炸了炮塔跟着没,干净
只删这条边,我活着「我只是引用它」锁敌目标、追踪目标、AI 的当前目标目标死了,AI 自动转头找下一个,不会卡死
直接报错「它不该被删,删了就是 bug」数据完整性要求高的引用,比如关卡引用配置开发期就把问题炸出来,别漏到线上

看到第二档没有?「只删这条边,我活着」。这短短一句话,解决了我项目里最长的一段冗余代码——所有「目标死了以后我要做什么」的善后逻辑,全部删掉,改成在关系上声明一次。玩家感知到的差别是:AI 不会对着空气发呆。你可能见过那种游戏,敌人杀了以后,另一个敌人的 AI 在原地抽搐两秒才恢复,那就是目标引用没清干净、每帧在拿一个失效编号做判断的典型症状。

再看一个更有意思的用法:把关系当查询条件。你做一个派系系统,NPC 对玩家的好感度用关系表达——(喜欢, 玩家)、(讨厌, 玩家)。那么「所有恨玩家的 NPC 有哪些」这个问题,在查询里就是一句话的事,写起来跟自然语言几乎一样。而外键方案里,你得给每个 NPC 存一个「对玩家的好感度」数值,然后每次改都是一次遍历。前者是索引,后者是扫描,差距会随着 NPC 数量放大。

但这里我要泼一盆冷水:边不是越多越好,边是有成本的。因为每条边都占 archetype 里的一格。一个角色身上挂 20 条 buff 边、5 条关系边、3 条目标边,还行;你要是给每个角色挂 200 条(MMO 里那种 buff 地狱),archetype 组合会以你想象不到的方式增长,而且每次加 buff、删 buff 都是一次搬家——实体从一间宿舍挪到另一间。频繁搬家是性能的隐形杀手,这个坑放到《性能调优实战》那篇里我们再细算。

我的经验值:单个实体上的关系数量控制在几十条以内,超过一百条就该换数据结构了——把 buff 做成一个实体、用一个子实体挂多条边,或者干脆用普通数组组件存 buff 列表。关系适合表达「少量、语义重要、需要反查」的连接,不适合表达「海量、同质、只需要遍历」的列表。这条界限,比任何性能数字都重要。

参数化设计工具:一张关系配置表 + 一张宿舍预算表

光讲道理不够,你得能直接拿去填。下面两张表是我自己项目里一直在用的,第一张是「我要怎么定义这条关系」,第二张是「我这个用法会不会炸宿舍」。

表一:关系定义配置表。每引入一条新关系,先把这七列填完,填不出来的说明你还没想清楚。

字段取值怎么选建议
关系名ChildOf / IsA / 自定义表达「包含」用 ChildOf,表达「是某种东西的配置」用 IsA,其余自定义自定义关系名用动词或介词,读起来像句子
目标数量级个位数 / 几十 / 上千决定它会不会炸宿舍超过几百个目标就考虑不分裂 archetype
是否排他是 / 否一个实体能不能有多个同类目标「父」「当前目标」排他;「喜欢的东西」不排他
是否允许成环允许 / 禁止层级、继承一律禁止成环层级类默认禁止,省掉无穷递归的调试
目标删除策略连删 / 断边 / 报错附属品连删,引用断边,契约报错这一列填空的项目,最后一定会出清理 bug
是否参与分宿舍参与 / 不参与目标数量小就参与,享受查询加速;数量大就不参与先用参与跑,Explorer 里看见宿舍数红了再改
单实体边数上限数值这是你的自查红线我自己的线是 60 条,超过就换方案

表二:宿舍预算表。这张表是用来在动手之前估算代价的,数字是经验值,你按自己项目的实体量去套。

用法是否按目标分宿舍1000 个实体时的宿舍数估算结论
IsA 到 20 个模板是,但目标共享约 20 间(加上覆盖组合,撑死 40)放心用,查询极快
ChildOf 到 10 个固定父级(玩家、场景根、UI)是约 10 间放心用
ChildOf 到 500 个父级是500 间起危险,改结构或不分裂
自定义关系,目标 30 个阵营是约 30 间可用,天然适合做索引
自定义关系,每个角色一个唯一目标是等于实体数绝对不行,必须不分裂

还有个参数容易被忽略:层级深度。向上遍历的代价跟你爬几层直接相关。我的建议是 ChildOf 链控制在 3 到 4 层,IsA 链控制在 3 层以内。超过这个数,你多半是在用关系做「分类」而不是做「结构」,那是标签组件的活儿。

最后补一个隐形参数:每帧的关系增删次数。加一条边、删一条边,都是一次实体搬家。我的红线是单个实体每帧不超过 1 到 2 次。你想想那种「每帧重新计算 AI 目标然后重新挂关系」的写法——500 个敌人,每帧搬 500 次家,这个开销足够让一个本来 60 帧的场景掉到 45。解法很简单:先比较,变了再写。这条在观察者那一篇里还有更优雅的做法,但最土的办法往往最有效。

双路径:你现在就想用,和一个季度后你会踩的坑

初级用户速览——三条能立刻用上的规矩。

第一,「同类」用 IsA,「从属」用 ChildOf。哥布林、精英哥布林、火焰哥布林,这是同一类东西的不同配置,用 IsA;飞船和炮塔,这是包含关系,用 ChildOf。分不清的时候问一句:父级删了,子级该不该跟着消失?该,就是 ChildOf。

第二,用 ChildOf 之前先数父级。你要挂的父级是「玩家」「场景根」「UI 画布」这种个位数、几十个的量,尽管用;如果父级是「场上每个单位」,刹一脚,先想想别的方案。

第三,自定义关系一定要填「目标删除策略」。不填就等于默认「断边」,听起来安全,但你真正的意图往往是「连删」。这一个字段填对了,能省掉你后面整整一轮线上事故。你试着给项目里所有关系都补上这一列,你会发现有一半的清理代码可以直接删——那种感觉,像收拾完房间扔掉三大袋垃圾。

进阶深入——一个季度后你会遇到的问题。

第一个问题是宿舍数量失控。它不会在开发期暴露,会在内容量上来以后悄悄出现。唯一的办法是随身带着测——把 Flecs Explorer 打开,看宿舍数量的曲线,跟你的实体数量曲线对一下。如果两条线斜率差不多,说明你的关系在按实体分裂,得治了。

第二个问题是「关系当外键」的滥用。很多人第一次用关系会兴奋,把所有跨实体引用都改过来,包括那些根本不需要反查的连接。这是错的。判断标准只有一个:这条边,我需要反查吗?需要清理吗?需要作为查询条件吗?三个都不需要,就老老实实存个编号。关系不是免费的,它占的是你宿舍的金贵的格子。

第三个问题是批量操作。想清掉所有指向某个角色的边,不要遍历——框架提供了一次性批量清理的能力,效率差着数量级。这类操作在角色死亡、场景切换、关卡卸载的时候会集中爆发,写错了就是一次卡顿。具体哪些批量操作用在什么时机,跟调度系统那篇会串起来讲。

第四个问题,也是最容易忽略的:关系会不会被存档?会的,而且默认就存,这是好事也是坑。好事是存档天然完整;坑是如果你的关系里有「指向运行时临时实体」的边,读档回来会指向空气。存档那一篇会专门讲怎么给关系打标记、怎么在序列化时过滤。这里只提醒一句:设计关系的时候顺手想一下它该不该进存档,比事后补要省十倍力气。

争议与常见坑:关系是不是把 ECS 又拽回了关系型数据库

坑一:以为关系是免费的。它占 archetype 的格子,它会让实体搬家,它的目标数量会放大你的宿舍数。任何一条边,都是你拿内存和查询速度换来的语义清晰度。这笔交易经常划算,但它从来不是白给。

社区里有一条批评,我觉得说得挺中肯:「关系让 Flecs 变得像一个关系型数据库,而 ECS 的初衷是简单快速。」

我的回应是半同意。同意的一半:关系的复杂度是真的。一个刚学 ECS 的人,你让他理解组件和查询已经够呛,再告诉他「组件还能是两个实体组成的、还能带策略、还能向上遍历」,他大概率会把文档关掉。这也是《学习曲线争议》那篇里被反复提到的问题,我不否认。

不同意的一半:游戏数据本来就是图。你的存档里有什么?角色穿装备、装备带词条、角色属于阵营、阵营敌对另一个阵营、角色接了任务、任务挂在 NPC 身上。这是一张图,不是一张表。你不用关系表达它,它也不会消失,它只是从数据层挪到了代码层——变成三千行手写的解引用和清理逻辑。复杂度守恒,你只是选在哪儿纳税。我宁可把它交给框架,因为框架不会忘。

坑二:把关系用成 OOP 继承。IsA 套五层、「火焰精英远古哥布林」继承「远古哥布林」继承「精英哥布林」……看到这种结构,停手。IsA 是用来表达「同一种东西的不同配置」,不是用来表达「分类」。分类请用标签组件——一个「精英」标签,一个「火焰」标签,组合起来,比继承链灵活十倍,还不会让你的属性读取爬五层楼梯。

坑三:每帧增删关系。这是性能上最隐蔽的一刀。看着只是「更新一下目标」,实际上是一次 archetype 搬家。如果你有 300 个敌人每帧重算目标,那就是每帧 300 次搬家,内存那边会哭。写之前先比较,值没变就别动。

坑四:不看宿舍数量就上线。我的原话是:用了关系但不看宿舍数量的项目,等于闭着眼睛开车。这不是危言耸听,我上面讲的那个塔防就是活例子。养成习惯,内容量涨一波就去看一眼,比出事后从头查要便宜太多。

最后说个我自己的判断局限。我这些年做的东西,实体规模基本在几千到几万这个区间,没做过真正意义上十万实体同时在跑的项目。所以我说的「几百个父级就危险」这条线,在更大规模下可能还要往下压,也可能框架已经做了我没注意到的优化。你要是做过更极端的场景,欢迎拿数据来打我的脸——我特别乐意被这种数据打脸,因为那意味着我又能学到新东西。

FAQ

Q1:ChildOf 和 IsA 到底怎么选?我老是分不清。

问自己一个问题:这个东西是不是独立存在的东西?炮塔脱离了飞船还能被别的飞船捡走继续用,那它就是个独立实体,用 ChildOf 表达「现在归谁管」。而「精英哥布林」不是独立存在的东西,它就是哥布林的一种配置,用 IsA。再补一句:ChildOf 的父级删了子级默认跟着没,IsA 的模板删了实例还在(只是丢了继承值)。这个行为差异,基本能帮你确认选得对不对。

Q2:关系会不会很慢?我听说 ECS 用户都在追求极致的遍历性能。

分场景。读的时候——向上遍历、按边过滤——非常快,因为这些操作走的是引擎维护的索引,不用扫全表。慢的是写:加一条边、删一条边都是搬家。所以判断标准是:读多用关系,写多存编号。绝大多数游戏逻辑是「每帧读一次、偶尔写一次」,这种就是关系的甜区。

Q3:我项目只有两百个实体,需要搞关系吗?

性能上完全不需要,两百个实体你怎么写都不会卡。但你还是要用,理由不是性能,是清理逻辑。两百个实体也会出现「怪物死了 debuff 还在」的 bug,而这类 bug 对玩家的体验伤害,跟实体数量没关系。用小规模的代码量去换「这类 bug 从根上不会发生」,我觉得值。

Q4:多对多怎么表达?比如一个物品被多个角色收藏。

就是把关系反过来挂:每个收藏它的角色身上挂一条 (收藏, 那个物品)。想反查「谁收藏了这个物品」,用反向查询就行,框架两边都能走。但注意我前面说的边数上限——如果一个热门物品被十万人收藏,那你要反查的就是十万条边,这时候老实建一张索引表更合适。多对多永远要看基数,基数一大,什么方案都得让路。

Q5:关系会被存档吗?我不想把临时状态存进去。

默认会存,而且是完整存——这是好事,说明你的存档天然反映了世界结构。如果你有不该存的关系(比如指向「当前帧的临时碰撞对象」),就在设计关系的时候就给它打上标记,让序列化跳过。这一块跟存档系统那篇是连着的,核心思路一句话:关系的生命周期应该跟实体一样被认真设计,而不是随缘。

最后一个开放问题,留给你自己想:如果你现在把项目里所有的跨实体引用都改成关系,会有多少行清理代码消失?又会新出现多少个宿舍?想清楚这两个数字的人,才真的懂了关系。

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