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

Archetype 架构剖析:Flecs 内存模型的底层逻辑

· 0 分钟阅读
Archetype 架构剖析:Flecs 内存模型的底层逻辑 Archetype Memory Model: How Data Is Laid Out in Flecs — Flecs ECS 技术专题

Archetype 架构剖析:Flecs 内存模型的底层逻辑 Archetype Memory Model: How Data Is Laid Out in Flecs

为什么内存模型是 ECS 的第一块基石

先说一个我自己做过的失败案例,省得你以为我在岸上教游泳。

几年前我接了个 2D 塔防的外包,把项目重构到 Flecs 上。第一周得意得不行:五十座塔、八百个敌人、满屏弹丸,帧时间从十几毫秒掉到个位数,我在群里发截图,说这框架真香。第二周,策划加了三件套状态——燃烧、冰冻、中毒,每种状态做成一枚独立的标记组件,挂上去、过一会儿掉下来。

当天就发现不对劲。打起来的时候帧时间像心电图,平时 6 毫秒,一波团战偶尔蹦到 20 毫秒。我拿性能剖析工具一抓,热点既不在渲染,也不在寻路,而是在“实体从一张表搬到另一张表”上。那一刻我才真正搞懂:Flecs 的性能不是天上掉下来的,是拿“内存排得整整齐齐”换来的。而整齐这件事,是会被策划和我自己亲手打碎的。

Archetype 这套机制,本质上就是这份整齐的价目表。

为什么说它是第一块基石?因为后面你要学的查询(07 篇)、关系(08 篇)、调度(09 篇)、多线程(10 篇),全都长在这一张张表上。查询能整列整列地扫,是因为数据真的整列排着;调度敢并行,是因为不同表之间物理隔离;关系之所以特殊,是因为它会参与表的切分。你要是不懂表,后面学的东西全是浮在空中的。

打个更接地气的比方。传统面向对象像一家老派餐厅,每个厨师自带一整套刀具,从自己的抽屉里掏;ECS 像现代后厨,按工序分工,切菜的一排人站在一排案板后面,手一伸就是下一把刀,不用低头翻抽屉。Archetype 就是那张“工序台”——同一张台上的人,工具一模一样,摆放位置分毫不差。快得离谱。代价是:谁要换工序,就得离开这张台,走到另一张台去,手上的活儿全部重新摆一遍。

这篇就干一件事:带你走进后厨,看看那排案板到底怎么摆的,什么情况下会乱,乱了怎么收拾。

第一层:Archetype 按组件组合分桶,不按实体分

很多人第一次接触 ECS,脑子里有个默认画面:实体身上挂着一串组件,像个背包。不是的。更准确的画面是——实体只是一张编号牌,牌子上写着一句话:“我现在站在 3 号表,第 417 行。”

3 号表是什么?是签名恰好为“位置 + 速度 + 血量”的那张表。所有身上不多不少正好这三个组件的实体,全挤在这一张表里。你要是给其中某个倒霉蛋再加一枚冰冻标记,系统不会在原表上给他多开一列——它会把这个人整行搬到“位置 + 速度 + 血量 + 冰冻”那张新表去,行号重排,然后更新牌子上的字。这就是迁移,也就是后面一切麻烦的源头。

所以,一个世界里有多少张表,取决于你有多少种不同的组件组合,跟你有多少实体没有关系。一万个敌人如果组合完全一样,那也只占一张表、一万行。反过来,你给每个敌人随机挂三种可选状态,理论上就有八种组合,实际跑起来可能长出七八张表。这里的数学很冷酷:可选组件每多一种,组合种类翻一倍。

那表里面长什么样?它是列式排布的:位置一整列,速度一整列,血量一整列,一行就是一个实体。Flecs 在这里有个很实在的设定——表带签名且唯一,同一个组合全世界只会存在一张表,绝不会重复建;表空了会被回收,但表标识会保持稳定。这个设定带来一个直接好处:查询结果可以按“表”来缓存。你查“同时有位置和速度的实体”,系统交给你的是“三张表”,不是“五千个实体”,接下来每帧只需检查这三张表有没有变,不用把五千人重新筛一遍。07 篇讲的查询体系,地基就在这里。

玩家能感受到吗?大多数时候感受不到,这是好事。玩家能感受到的是“稳”:满屏子弹飞过去,帧时间是一条平线。他感受到异常的时候,往往是反过来的两种——某个瞬间卡一下,或者越玩越慢。这两种症状,你往表这个方向查,八成都能查到。

顺带提醒一句容易被忽略的:玩家感知不到表,但能感知数量。同样是八百个敌人,分成一张表还是分成二百张表,屏幕上看起来一模一样,帧时间曲线却可能是两个游戏。

第二层:连续内存省下来的不是指令,是等待

得把“为什么快”讲明白,不然后面讲代价就是空的。

现代处理器取数据不是一个字节一个字节地拿,是一块一块地搬,一块通常是 64 字节,叫缓存行。你读第一个浮点数的时候,紧挨着它后面的十几个兄弟已经顺手被拖进缓存了。所以一千个位置如果是紧挨着排的,总共才占 4KB,遍历一遍几乎全在一级缓存里,快得像翻自己的笔记本。

反过来,传统对象散落在堆上,每个带着虚表指针和一堆不相干的字段。你只想挪一下位置,每次访问都可能是一次“在内存里随机跳”。一级缓存的访问是几个时钟周期,主存是几百个周期。理论上差几十倍,实际上因为处理器会预判、会乱序执行,真实差距通常落在三到十倍之间——具体差多少,取决于你数据排得多整齐、访问多有规律。这个区间比很多“性能神话”宣传的要保守,但我宁愿你信这个保守的数,后面落地时不容易失望。

第二个常被忽略的点是“不读无关数据”。列式布局下,更新位置的系统只碰位置和速度两列,血量、模型、AI 状态一个都不进缓存。而对象是整体打包的,你读位置的时候顺手把整个衣柜都搬了出来。行话叫缓存污染,说人话就是:你只是想拿件外套。

但这里必须泼一盆冷水,不然就成软文了。这份收益有个硬前提:同一张表里的实体,干的活得真一样。如果每个实体都有独一份的特殊逻辑,那把它们排在一起没有任何意义,你只是把不同的东西强行归在一张表里,还得为这份归并付出迁移成本。这也解释了为什么 ECS 在弹幕、粒子、塔防小兵、RTS 部队、模拟经营这类“同构实体海量”的场景里特别灵,而在“二十个手工打磨的 Boss”这种地方基本没感觉——后者你就算全用传统写法,性能也完全够,何必折腾自己。

案例一:作者那套塔防演示,为什么是 archetype 的理想样本

Flecs 作者桑德·默滕斯公开过一整套塔防演示,还专门做过多个版本的对照:传统面向对象写法一版,ECS 写法一版,中间形态也有。这个样本值得细看,因为它把 archetype 的优势和边界同时摆在了明面上。

结构上,塔是实体,敌人是实体,弹丸是实体,路径点是实体,连界面元素都能做成实体。组件种类很少,位置、朝向、血量、射程、伤害、渲染信息,十几样顶天了。这意味着什么?意味着表很少,但每张表的行数很多。敌人那种“血量 + 位置 + 速度 + 渲染”的组合,可能一躺就是几百上千行。这正是 archetype 最舒服的形态:表少、行多、同构。

作者公布的对照里,实体数量上去之后,ECS 版本的帧时间几乎是一条平线,传统实现开始往上爬坡。注意因果关系别搞反了:不是“用了 ECS 所以快”,是“这个场景里同类实体足够多、组件组合足够统一,所以数据排得足够整齐”。你要是把同样的逻辑写成对象池,性能未必差到哪里去。ECS 赢的是数据布局,不是编程哲学。

这套演示里还有一个细节特别值得学:它没有给敌人挂一堆“中毒”“减速”“眩晕”的独立状态组件,这些状态被压进了少数组件里的枚举和计时字段。看着像偷懒,实际上是在保护表结构。这正是我第二周才明白的道理,人家从第一版就绕开了。

案例二:守望先锋的 ECS,稠密布局的三个好处和一个紧箍咒

暴雪的 Tim Ford 在 GDC 上那场讲《守望先锋》游戏架构与网络同步的分享,是 ECS 圈里被引用最多的材料之一。可惜大多数人只记住了“他们用了 ECS”,没记住他们为什么用、以及为此付出了什么。

他们要解决的问题很具体:6v6 的多人射击,服务器和客户端每一帧都要处理大量实体状态,还要做网络同步和回滚。ECS 在里面的核心好处是:同类实体的数据紧挨着排、布局是确定的。这带来两个直接收益。一是遍历快,帧预算紧的时候能实打实省出时间。二是网络快照好做——内存是稠密的,序列化的时候可以按表整块取,不用一个一个对象去问“你这帧变了没”。这一点经常被低估:在射击游戏这种每一帧都要打包状态的场景里,“能整块拷”几乎是决定性的。

但他们也踩了坑,而且是在公开分享里明说的:组件如果能在运行期随便增删,内存布局就会不停变化,性能和行为的可预测性都会崩。他们的应对非常工程化——让绝大多数组件在实体创建时就定死,运行期的状态变化尽量用组件内部的字段和标记位来表达,而不是靠加一个新组件。说白了,他们把“动态性”从表的层面压到了字段的层面。

这个取舍对独立开发者尤其有参考价值,因为你不具备大厂那种“发现不对就重构三个月”的余裕。你在项目第一天就可以给自己立条规矩:实体的组件组合在生成时定型,运行期只改值、不改结构。这条规矩能帮你躲掉八成的碎片问题,而且几乎不影响玩法表达——一个敌人从“正常”到“中毒”,真的需要在类型层面变成另一种生物吗?多数时候不需要,一个状态字段就够了。

顺便说句公道话,这套做法也不是没有代价:它逼你在设计阶段就得想清楚“这个东西到底是状态还是种类”。这对策划的抽象能力要求更高。但这种思考本身是好事,很多设计上的含糊,被这种约束一逼就清楚了。

第三层:碎片化——帧时间尖刺的真正来源

现在讲代价。这一层会把前面所有美好的画面打碎,也是最容易出问题的地方。

先算单次迁移的成本。给一个实体加组件,不是“在原表上多开一格”,而是:把这一行所有列的数据拷进新表、在新表里占一行、更新实体牌子上指向的指针和行号、旧表那行腾出来(通常是把该表最后一行搬过来填坑,避免留洞)。所以成本大致正比于“这个实体身上所有组件的总字节数”,再加一笔索引维护的固定开销。组件越多、越大,搬一次越贵。

然后是表爆炸,这是更隐蔽的杀手。可选组件每多一种,组合种类翻倍。现实里当然不会真的指数爆炸,但哪怕你只有十来种可选标记,跑起来也可能出现几十上百张表。而每张表都有固定开销:表头、列信息、索引、元数据。更麻烦的是长尾——大部分表只有一两个实体,开销却和大会员一样大。

最要命的是第三种情况,我管它叫“反复迁移”:一个组件加上去,一两百毫秒后又删掉,来回倒腾。受击闪白、选中高亮、眩晕、燃烧、护盾、正在施法,你在界面上看到的是个小图标,在内存层看到的是一场搬家。

给我那个塔防算笔账:一百个敌人被一发范围伤害同时打中,每个闪白 200 毫秒。这一瞬间就是一百次表迁移,200 毫秒后又是一百次。如果这是一波接一波的持续伤害,意味着每秒可能有两百到四百次行搬迁。每次搬一行看起来不多,但架不住它密集、且发生在最激烈的时候。帧时间图上那个尖,就是这么来的。

而且这玩意儿有恶性循环:迁移过程会产生临时分配,分配多了触发内存回收,回收又让帧更抖。你以为自己在做玩法,其实在给内存分配器做压力测试。

讲到这里,你应该能提炼出一个反直觉的规律:Archetype 的成本不在“实体多”,而在“组合变化多”。一万个一模一样的敌人几乎不花钱;一百个各带三种随机状态的敌人,反而更难伺候。这个结论值得你贴在显示器边上——它会改变你设计玩法的方式。

一句话记住:让表变贵的是“结构在变”,不是“数量在涨”。所以性能问题出现时,先别急着减少实体数量,先看看是不是有组件在频繁地挂上摘下。

第四层:治理与取舍——让表数别长出花,也接受它治不好的时候

先说 Flecs 自己做了哪些优化,不然显得这框架很脆。

最关键的一个叫表迁移图,你可以理解成一张地铁线路图。世界里的每张表是一个站,加某个组件就是从这站坐一条边到另一站。第一次从“位置 + 速度”加到“血量”,系统得算一次新组合、找表或建表;但这条边算出来之后会被记住,下次同样的迁移直接走,不再重复推导。这个优化的意义是把“每次重新算”变成“查一次线路图”。它救的是计算开销,但救不了搬数据——那部分开销物理上省不掉。

第二个是空表回收与复用:表空了不会立刻销毁,长期空着会被回收;同一组合再来时,表标识还能保持稳定,这对查询缓存很重要,否则你的缓存查询会莫名其妙地一直失效。

第三个是允许给单个组件标记为稀疏存储。这相当于在 archetype 的地盘上开了个后门:某些高度动态、增删频繁、又不需要和别人一起遍历的组件,可以不走表迁移,直接挂稀疏存储。这个机制在 08 篇讲关系时会更重要,因为关系在 Flecs 里的存储方式本身就带着这层考虑。

然后是治理清单,四条,都是我踩过坑之后留下来的:

一,把“状态”压进字段。中毒、燃烧、眩晕,用组件里的枚举加计时器表达,别做独立标记组件。这条能干掉最大的碎片来源。

二,创建期定型。实体的组件组合在生成时就确定,运行期只改值。如果你发现某个玩法非要在运行期加组件,先问一句:能不能用一个“待激活”的字段代替?

三,批量迁移。要给一批实体加同一个组件,一次做完,别在循环里一个个来。批量能让系统把“多次搬家”合并成“一次搬一片”,索引维护的重复开销全省了。这一条在 23 篇讲性能调优时会展开成完整方法论。

四,监控表数量。给自己定个阈值,超了就查。原型期几十张表很正常,中型项目几百张也能接受,上千张且大部分只有一两个实体,那基本可以确定是设计问题,不是框架问题。

该认输的时候:archetype 与稀疏集的分工

得说句公道话,archetype 不是万能的。业界另一条主流路线是稀疏集,EnTT 是代表。它的思路完全不同:每种组件一个稠密数组,再加一张索引表;加组件就是把数据塞进数组末尾、记下位置,删除就是挪一下。所以增删几乎是常数时间,根本不会因为组合多而炸表。

代价在遍历。你要同时读位置和速度,就得在两个数组之间来回跳,缓存局部性不如 archetype 那种一整行排好的方式。实体越多、组合越稳定,这个差距越明显;实体数量中等、组合变化剧烈时,archetype 的搬家成本反而更痛。

所以真实项目里经常混着用:主数据走 archetype,高频增删的标记类走稀疏存储。Flecs 允许你这么干,前提是你清楚自己在干什么。别把所有组件都标成稀疏,那样你就等于把本文讲的所有好处主动退回去了。

我自己的经验分界线是这样的:一个组件如果同时满足“频繁增删”“几乎不参与批量遍历”“数量不大”,那它是稀疏存储的好人选;只要它出现在热路径的查询里,就老实待在表里。

参数化设计工具:一张表盯住你的内存模型

下面这张监控清单是我自己在项目里用的。数值是经验区间,不是金科玉律,你可以按规模缩放,但量级参考价值是有的。

指标它说明什么健康区间(中型项目)越界信号
活跃表数量世界里有多少种组件组合100~500上千,且多数表只有一两个实体
平均每表实体数表用得均不均匀20 以上长尾小表占比超过三成
单帧表迁移次数这一帧有多少行在搬家低于实体总数的 5%战斗高峰出现 2 毫秒以上的尖刺
临时标记存活时长反复迁移的频率0.2 秒以下的标记改成字段每帧都在加和删
单组件字节大小一列有多宽控制在 128 字节以内把大块配置表或字符串塞进组件
批量操作粒度一次操作覆盖多少实体能上 64 就别一个一个来循环里逐个增删组件

再补三个调参上的手感。

组件大小。表按行存,一行多宽,直接决定每搬一次家搬多少东西。我见过有人把整张物品配置塞进组件,一个实体好几百字节,迁移一次顶别人十次。大块数据放引用、放外部资源池,表里只留个标识。

均匀度比数量重要。三十张表、每张几百行,比五张表、其中四张只有一行,要健康得多。前者是正常分类,后者是设计漏洞。

那个 0.2 秒的阈值。这是经验值,不是定理。判断标准其实有两个:状态持续多久,以及同一时刻有多少实体在加删。一次迁移不疼,一帧一千次迁移就是灾难。你要是拿不准,就按“最坏情况下同时命中多少实体”来估,而不是按平均值。

双路径:你现在该做什么

初级读者,三条今天就能用的规矩。一,实体生成时把组件一次配齐,别在运行期东加西加。二,状态用字段,不用独立标记组件。三,凡是“给一批实体做同样的事”,都想想能不能批量做。这三条不需要你理解底层,照做就能避开大部分坑。

进阶读者,往下看三点。第一,表迁移图的边缓存会怎样影响你的查询缓存失效——如果你频繁改动表结构,缓存查询会反复重建,那个开销比你想的大。第二,稀疏存储该给哪些组件开,前面给了判断标准,自己拿项目验证。第三,关系是怎么参与表切分的。这点最阴险:在 Flecs 里,一个关系对会被当成独立的组件类型,所以不同的关系目标会切出不同的表。这意味着“给每个敌人随机指定一个不同的追踪目标”这种看起来很自然的写法,可能悄悄给你造出几百张表。这个坑留到 08 篇专门拆,你先记住有这么回事。

常见坑与争议:几个我反复见到的误解

误解一:ECS 一定快。不是。如果你的瓶颈在物理、在渲染提交、在 AI 决策,archetype 能帮你的可能只是个位数百分比。它是放大器,不是万能药。用它之前先想清楚你的瓶颈在哪。

误解二:组件越多越 ECS。恰恰相反。组件越碎、组合越随机,表越碎,性能越难看。真正地道的 ECS 设计是克制的——能不拆就不拆,能放字段就别放组件。

误解三:表多了框架就该背锅。表多通常是设计问题。你把本来可以是字段的东西做成了组件,然后怪框架不会收拾。这就像把冰箱塞满剩菜然后怪冰箱小。

误解四:每个实体有唯一数据会让表爆炸。不会。表只看组件类型,不看值。一万个实体血量各不相同,还是同一张表。这个误解传播得特别广,值得再说三遍。

还有一条争议值得摆到台面上:有人说 ECS 这套内存模型是“用复杂度换性能,独立游戏根本不需要”。这话有一半是对的。如果你的游戏跑不满 60 帧是因为渲染,或者总共就几百个实体,那确实不用为表结构操心。但只要你的实体数量上了四位数、而且它们长得都差不多,这套东西带来的帧时间稳定性,是你写多少局部优化都换不回来的。

我自己的立场很明确:独立开发者更该懂这套东西,不是因为要榨性能,而是因为它逼你在设计阶段就回答一个尖锐的问题——你的实体,到底分成几类?这个问题答清楚了,代码结构会跟着清楚;答不清楚,用什么框架都是糊的。

FAQ

问:我给每个实体都加一个唯一的标识组件,会不会让表爆炸?
不会。表只按“组件类型组合”划分,不按值。一万个实体的标识各不相同,它们仍然在同一张表里。这是最常见的误解之一,理解这一条,你对表的心智模型就建立起来了。

问:表多了,查询会不会变慢?
会,但慢在“找表”阶段,不在“遍历”阶段。查询每帧都要检查候选表有没有变化,表越多这份簿记越重。解法是用缓存查询、缩小匹配范围,而不是把所有表强行合并。这块 07 篇会讲透。

问:我到底该用 archetype 还是稀疏集?
看两个数:同类实体有多少(越大越偏 archetype),组件组合变化有多频繁(越频繁越偏稀疏)。Flecs 两种都支持,但默认是 archetype,别没事就改。默认值能覆盖八成场景,剩下两成你自然会知道。

问:为什么我加实体的时候会卡一下?
大概率是三种情况之一:新建了一张表(一次性开销)、触发了大量行迁移、或者内存分配器在扩容。定位方法很朴素——把新建实体的操作集中到加载阶段,战斗中要生成就先建好池子。

问:关系会让表爆炸吗?
会,而且这是个真坑。关系对在 Flecs 里算独立的组件类型,不同的关系目标会切出不同的表。所以关系要用在“数量可控”的维度上,别拿它当随机的动态标签。这条我们留到 08 篇细讲。

最后问你一句

我常拿这个问题当聊天的开场白:你有没有数过,你的世界里现在有几张表?

大部分人的答案是“没数过”。这不是懒。是 ECS 把这层复杂度藏得太好了——你写起来像在操作对象,跑起来才知道底下是一排分门别类的货架。而货架排得好不好,决定了你的游戏是越跑越稳,还是越打越卡。

所以别把 archetype 当成一个可以略过的实现细节。它是你和内存之间的一份协议:你把同类的东西放一起,它给你一条平直的帧时间曲线;你非要让每个实体都独一无二,它就用搬家和卡顿提醒你,谁才是老大。

下一篇(07)我们往上走一层,看看查询系统怎么在这堆表上做筛选。你会看到,查询的大部分聪明才智,其实都是在跟“表会变”这件事较劲。

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