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

Flecs 查询系统全解:Filter、Query、Rule 三层体系

· 0 分钟阅读
Flecs 查询系统全解:Filter、Query、Rule 三层体系 Filter, Query, Rule — Picking the Right Lens for Your World — Flecs ECS 技术专题

Flecs 查询系统全解:Filter、Query、Rule 三层体系 Filter, Query, Rule — Picking the Right Lens for Your World

为什么查询系统是 ECS 真正能打的第一块基石

上一篇我们聊 Archetype,讲的是「仓库怎么摆」——同样组件的实体被码进同一排货架,内存挨着内存,缓存友好,扫描起来像顺着传送带拿东西。那篇发出去之后,后台收到最多的一条留言是:「货架是明白了,可我到底怎么把我要的那批箱子捞出来?」

问得太对了。Archetype 决定了你仓库有多整齐,查询系统决定了你取货的手有多快、有多准。仓库再漂亮,取货得翻遍每一个角落,那就是个昂贵的摆设。我见过不止一个团队,Archetype 设计得漂漂亮亮,结果热路径里每帧现场拼查询条件,帧时间照样炸——他们以为是 ECS 不行,其实是把「眼睛」和「索引卡」搞混了。

先给个画面帮你记住这三层。想象你是一座巨型物流仓的夜班主管。

  • Filter 是那个临时工,你说「去把带蓝色标签和易碎贴的箱子都搬来」,他就真的一排排货架走过去看,不记事、不做记录,下次你还得再说一遍。便宜,灵活,改口就生效。
  • Query 是你养的一张长期索引卡:第一次登记好「哪些货架符合这批条件」,之后每次要货直接照着卡片去对应货架拿,仓库新进货架时卡会自动补登。建立卡有成本,用起来飞快。
  • Rule 是那个会推理的老库管:你问他「这台机器的所有上级零件里,谁带了过载标记」,他能顺着装配关系一路往上爬,把整条父子链捋清楚。能力强,但你不能让他每秒钟回答一千遍,他会累死。

玩家永远看不到这三种东西,但你们组里每个人每天都要写几十条。它才是 ECS 好不好用的分水岭——组件不过是散落的纸条,查询才是把所有纸条串成玩法的那根线。射击游戏每帧在问「谁有位置和速度」「谁的血量见底了」「哪颗子弹撞进了谁的碰撞盒」;RPG 每帧在问「谁挂着中毒状态」「谁的技能冷却转好了」。游戏玩起来是什么样,本质上就是这一堆问句堆出来的。

所以这篇的立场很明确:三层体系不是三种写法,是三种「你愿意在什么时候、花多少算力」的选择题。选错不会立刻报错,它只会在你上线那天,安安静静地把帧时间偷走。下面一层一层拆。

第一层:Filter——当场点名的裸过滤器

机制原理。过滤器是最朴素的一层:你给它一组匹配条件(要同时有哪些组件、要排除哪些组件、要带某条关系的哪一端),它在每次遍历时现场去扫当前世界里所有符合的 Archetype,扫完就丢,不留任何长期记录。你可以把它理解成「每次调用都重新点名」,点完名没有任何花名册留下来。

这件事的直接后果是:它的成本跟Archetype 的总数量直接挂钩,而不是跟你实际命中的实体数量挂钩。世界上有 20 个 Archetype,它就是检查 20 次;有 300 个 Archetype(这在大型项目里一点都不稀奇,尤其是你开始用关系、开始动态加组件之后),它每次遍历都得把 300 个 Archetype 的签名过一遍,才轮到真正的业务逻辑。这就像你每次想找人,都要把全公司花名册从 A 念到 Z。

玩家/开发者能感知到什么。好处是「改了立刻生效」——你改条件,下一次遍历马上按新条件走,不需要销毁重建任何东西。这在原型阶段爽到飞起:你想试「只要带血量和护甲的实体」,改一改,跑,立刻看到结果。做编辑器内工具、做运行时诊断面板、做载入时一次性分类,也都非常适合——这些场景的共同特点是低频、条件多变、结果只需要一次。你要的是一把瑞士军刀,不是一条生产线。

可落地的参数。怎么判断该不该用 Filter?我习惯看两个量:Archetype 总数,和这条查询的调用频率。

Archetype 总数Filter 单次遍历的相对开销建议
小于 30几乎无感原型期随便用,别纠结
30 ~ 80可感知,但每帧一两次没事工具、启动期、低频逻辑放心用
80 ~ 200开始肉疼热路径换成缓存查询,或先做一次建档
大于 200每次遍历都在给帧时间上税热路径禁用,除非你确认这条只跑一次

这些数字不是圣经,是经验刻度——真实阈值取决于你的实体规模和硬件。但方向是对的:Archetype 越多,裸过滤器越贵。

真实案例佐证:Flecs 官方那个城市模拟演示。Flecs 仓库里带了一个演示性质的城市/开放场景(大量实体、各种组件组合、还掺了关系),这类项目最能体现分层的重要性。它在载入阶段用裸过滤器做分类非常合理:把「所有带道路标记但没连网的格子」「所有居民但没分配住处的实体」各筛一遍,一次性把索引建起来,这是典型的冷路径。但它的每帧更新——车辆移动、市民状态推进——绝不会用裸过滤器,因为那场景里的 Archetype 数量本来就不低,每帧现场点几百次名纯属自残。同一个项目里,冷路径用 Filter、热路径用下一层,这个分工才是它跑得动的原因。你在自己项目里也该这么想:启动时可以奢侈,运行时必须抠门。

第二层:Query——缓存化的索引卡,热路径的命根子

机制原理。缓存查询把「哪些 Archetype 符合条件」这件事记下来。你在创建它的时候付一次登记成本,之后每次遍历直接照着这张卡片去对应货架搬东西,不再重新点名。更妙的是,当世界动态新增了一个符合条件的新 Archetype(比如某实体第一次同时拿到「位置+速度+眩晕」这组组合),卡片会自动增量补登,你不需要手动维护。这就是它能安心待在每帧循环里的原因。

但天下没有免费的午餐,缓存查询真正的考点不在「建卡」,而在生命周期管理——这是 90% 新手翻车的地方,我后面「争议与坑」那段会专门讲我是怎么栽的。

玩家/开发者能感知到什么。一句话:热路径稳如老狗。你把查询建好一次,之后每帧遍历的成本基本只跟「命中实体数 × 每个实体的组件读写量」相关,跟世界总 Archetype 数脱钩。玩家感知到的就是「这游戏不卡」。反过来,如果你每帧临时建一条查询再销毁,你等于每帧都把索引卡重新登记一遍,成本比裸过滤器还难看——因为你还额外付了创建/销毁的管理开销。这叫用缓存查询的方式,付出了比不用缓存更高的代价,非常黑色幽默。

缓存查询还有个隐藏福利:迭代顺序稳定。同一批 Archetype 每次的遍历顺序是可预期的,这让「按顺序处理」的逻辑(比如一批实体的结算、一批 UI 的层级排序)变得可控。裸过滤器的遍历顺序则更依赖世界当下的状态,你不该依赖它。做存档、做确定性回放(这个系列的[15]网络同步篇会细聊),这点非常关键。

可落地的参数与规矩。我把团队里关于缓存查询的规矩列成表,你们可以照抄改:

规矩说明建议阈值
创建时机在系统初始化/世界初始化时建好,长期持有每个系统对应少量固定查询
复用次数一条查询至少被遍历 N 次才值得建N ≥ 10,否则考虑裸过滤
销毁时机系统关闭或世界销毁时显式回收一个系统最多持有 3~5 条常驻查询
每帧新建除非你明确知道它只遍历一次避免,除非临时工具

真实案例佐证:Unity DOTS 官方 Asteroids 教程。这是个跨框架但对查询设计极有参考价值的样本。它的每一颗小行星、每一发子弹、每一个碰撞判定,背后都是在系统初始化时建好、之后每帧复用的查询。教程里几乎所有系统都是这个结构:系统 = 一条常驻查询 + 一段每帧处理逻辑。这不是巧合,是 DOTS 想让你形成的手感——查询是系统的「固定资产」,不是「耗材」。你在 Flecs 里写系统完全照这个思路:每个系统在自己初始化那一刻把查询建好,之后每帧只管遍历。这个习惯一旦养成,帧时间的抖动会小很多。

顺带说一句和上一篇的联动:缓存查询的「补登」成本,跟你 Archetype 的碎片化程度同呼吸。世界上 Archetype 越碎(组合越杂、加删组件越随性),缓存查询需要维护的条目就越多,每次补登和遍历的簿记开销都会涨。所以查询优化和 Archetype 治理是一体两面,别指望光调查询能救一个碎片成渣的世界。

第三层:Rule——会在关系图上推理的规则引擎

机制原理。前两层匹配的是「组件集合」,Rule 匹配的是规则——它能表达条件之间的逻辑关系(与、或、非、可选),更重要的是能表达关系链上的推理:某个实体是不是继承了另一实体的组件、某个实体的某条关系能不能沿链向上/向下传递、某条逆关系能不能反查。这一层是 Flecs 最有辨识度的东西,也是它和 EnTT 那种「你条件给我,我按签名筛」路线最大的差别。下一篇文章专讲关系,这里先把 Rule 跟查询的关系说清楚。

打个比方:Filter 和 Query 是「你告诉我箱子上贴什么标签」,Rule 是「你告诉我这批箱子之间是什么装配关系,我帮你把整棵装配树捋出来」。前两层在集合上做文章,Rule 在图上做文章。

玩家/开发者能感知到什么。举几个真实会遇到的玩法问题:

  • 「这个技能的所有前置技能里,有没有一个已经点满了?」——这是沿着关系链往上游追。
  • 「这把武器继承了某个基础模板的属性,模板又被上一层改过,最终数值到底是多少?」——这是沿继承关系求值。
  • 「我点中了一个 UI 节点,把它连同所有子节点一起高亮。」——这是沿父子关系往下展开。
  • 「这个 NPC 能看见的东西里,有没有一个是它仇人的盟友?」——这是链式关系推理。

这些用前两层硬写,你会写出一堆手工爬链、手工判空、手工去重的胶水,越写越像面条。Rule 就是为这类「图上的问句」准备的。它让你把意图声明出来,遍历和去重交给引擎。

可落地的参数与代价。Rule 强,但它不适合放在每帧的热循环。它的评估比缓存查询重得多——你在声明一层逻辑推理,引擎要做的工作远不止「照卡片搬货」。我给的经验刻度:

使用场景调用频率建议规划
AI 感知/仇恨判定每帧或每几百毫秒一次可接受,但缩小匹配范围、加空间预筛
配置继承求值载入或数值变更时绝佳场景,求完缓存结果
UI 层级高亮/展开用户交互触发绝佳场景,低频且语义复杂
每帧全量规则求值每帧避开,除非范围极小

真实案例佐证:Flecs 自带的层级与预制体演示。Flecs 的示例里有一类专门演示父子层级和「预制体/模板继承」的场景:一个 UI 或物体的变换会继承父级,一批实体共用同一个模板定义。这类场景里,如果只用缓存查询,你得手动从子往上爬、手动处理继承覆盖,代码很快就糊成一团;而换成规则表达——「给我这个节点继承来的所有组件」或「给我这棵子树里的所有子孙」——逻辑一下就清爽了。而且它天然低频:层级变化时才需要重算,平时把结果缓存着。这就是 Rule 想要的活法:干重的活,但一年不干几次。

反过来,我也见过有人把 Rule 塞进每帧的 AI 循环里做「谁该打谁」的全量判定,几百个 NPC 每帧各跑一遍关系推理,帧时间直接跳崖。那不是 Rule 的错,是把推理机当成了计算器用。记住:Rule 是用「贵」换「表达力」,不是用「贵」换「更快」。

第四层:选型与组合——把三种镜头塞进同一帧,还要加个 Explorer 当监视器

前面三层讲完,真正的功夫在组合。一个成熟的项目里,三种查询同时存在,各司其职,谁也别越界。

冷热分层的骨架。我一般这么铺:载入阶段用 Filter 做「一次性分类建档」——把所有实体按用途筛一遍,把该建索引的建好;运行阶段的热路径全用缓存 Query,且是系统初始化时就建好、全程复用的那种;低频但语义复杂的决策(AI 感知、配置继承、UI 交互)用 Rule,并且尽量把结果缓存起来,别每次现推。

用一张表把三层的分工钉死:

维度FilterQuery(缓存)Rule
匹配对象组件与关系签名组件与关系签名(有缓存)规则表达式,含关系链推理
创建成本无中(一次性)中高(一次性)
单次遍历成本随 Archetype 数上涨低且稳定高
能否表达关系传递/继承有限有限强
推荐频率低频/原型/工具每帧热路径低频决策
典型场景载入分类、编辑器面板运动、状态推进、渲染剔除AI 感知、配置继承、UI 层级

Flecs Explorer:把查询变成了能看见的东西。这一层必须单独夸。命题里专门点了「查询调试工作流」,因为它解决了一个极痛的问题——查询写错时它不报错,它只是悄悄返回空集,或者多返回了你不想看到的实体。你盯着业务逻辑查半天,最后发现是条件写反了一个「非」。

Flecs 自带的 Explorer 可视化工具能直接看到:当前世界上有哪些 Archetype、每条查询实际命中了哪些、命中的实体是什么、组件的值长什么样。这个工作流的价值在于把「查询」从一行看不见的声明,变成一张看得见的名册。我现在的习惯是:任何新查询上线前,先在 Explorer 里核对一遍命中集合——是不是我要的那批、有没有多出来的、有没有该在的没在。这一步花两分钟,能省掉后面两小时的怀疑人生。做关系相关查询尤其重要,父子链、继承链这种「少一环和多一环」的 bug,肉眼根本看不出来,但 Explorer 里一眼就现形。

组合的进阶心法:让查询变成系统的接口契约。当你把「这个系统关心哪些实体」用一条常驻查询明确表达出来,系统之间就靠查询条件解耦了——A 系统只管往实体上挂组件,B 系统只管查「带某组件的实体」,俩系统互不认识,靠世界的组件状态对话。这正是 ECS 数据驱动的精髓,也是我在[04]那篇讲思维转型时反复强调的东西:组件是数据契约,查询是系统的入口声明。你把这条声明写清楚,整个架构就清楚了。

参数化设计工具:查询配置总表与可调旋钮

前面散着讲了不少阈值,这里集中成一张可以直接贴到团队 wiki 的配置总表。数字都是经验区间,别当物理常数,重点是搞清楚每个旋钮在调什么。

旋钮含义建议区间调错了会怎样
查询复用阈值一条查询至少被遍历多少次才建缓存版≥ 10 次调太低 → 缓存管理开销吃掉收益
系统常驻查询数单个系统长期持有的缓存查询条数单系统 3~5 条太多 → 缓存维护簿记上升,难定位
热路径 Archetype 上限热路径可接受的 Archetype 总数越高越要警惕碎片化过高 → 缓存补登与遍历抖动变明显
Rule 调用频率规则求值的触发间隔按玩法定,尽量低频每帧全量 → 帧时间跳崖
Rule 结果缓存 TTL规则求值结果缓存多久事件驱动失效优先无缓存 → 重复推理浪费算力
查询命中集合校验点上线前在 Explorer 核对命中集合的节点数每条新查询 1 次省略 → 静默 bug 上线

用法很简单:新写一条查询,先问「它会跑多少次」。超过 10 次且不是一次性的,走缓存;一次性的、工具用的,走裸过滤;带关系链推理的,走规则并考虑缓存结果。然后拿这张表当体检清单,上线前过一遍,基本上能拦掉八成查询相关的性能坑。

双路径:你是哪种读者,就按哪种路走

初级速览(刚摸 Flecs,先把东西跑起来)。记住三句话就够撑过第一个项目:热路径一律用缓存查询,并且在系统初始化时一次建好、之后别再动它;载入、编辑器、临时工具这些低频场景,怎么方便怎么来,裸过滤器没问题;一旦你想表达「继承来的」「父级的」「所有子孙的」这类关系语义,先别急着写手动爬链,去看看规则能不能直接表达。这三句能让你避开新手期最致命的性能坑。剩下的细节,等你被坑过一次自然会回来查。

进阶深入(已经在写真实项目,帧时间开始敏感)。你要关注三件事。第一,缓存查询的生命周期治理:你的每条查询是谁建的、谁销毁的、有没有在热路径里偷偷新建的,最好能一次性审计出来——这是团队规模上来后最容易被忽视的技术债。第二,Rule 的评估预算:给所有规则求值排个优先级和频率上限,别让某个 AI 模块的推理悄悄吃掉每帧一大块预算,这在实体数一涨就暴露。第三,查询与 Archetype 碎片化的耦合:你每加一个动态组件组合,都在给世界里塞新 Archetype,缓存查询的补登成本会跟着涨;所以查询调优不是孤立动作,它和[06]那篇讲的碎片治理必须一起抓。你能把这三点管住,查询系统就从一个「会用的工具」变成「撑得住规模的骨架」。

常见坑:这五个地方,我基本都栽过

坑一:把所有查询都建成缓存查询,以为「缓存 = 更快」。这是我早期的头号翻车。有次我把编辑器面板里的十几条临时查询全改成了缓存,心想稳了,结果打开面板动不动卡一下——因为每次面板刷新都在建/销毁一堆缓存查询,管理开销比裸过滤还难看。后来才想明白:缓存适合长期复用,不适合一次性的临时查询。建卡本身是要花钱的,用完就扔的查询根本没机会把成本摊薄。

坑二:在每帧循环里现场新建缓存查询。这比上一条更常见也更隐蔽。代码看起来干净利落,运行起来帧时间莫名抖动。原因同上:每帧建一次、销毁一次,你付了创建和销毁的全套成本,却只遍历了一次。判断标准就一句话——如果一条查询活不过一帧,它就不该是缓存查询。

坑三:忘了销毁缓存查询,慢慢泄漏。缓存查询不是凭空漂浮的,它绑定在世界生命周期上。你要是在某个可关闭的子系统里建了查询,子系统关了却没回收,这些查询就一直在后台维持着索引,既不报错,又悄悄占资源。跑久了表现为「帧时间缓慢爬升」,极难定位。我现在养成的习惯是:建查询的地方和销毁查询的地方,在代码结构上必须挨着,别让它们隔着几百行。

坑四:把规则塞进每帧热循环。前面提过,这里再强调一次。规则是用来表达复杂关系推理的,它的强项是「把复杂说清楚」,不是「把简单算得快」。每帧全量评估规则,等于用消防车去送外卖——不是不行,是荒唐。要放热循环,先问:范围能不能缩小?结果能不能缓存?能不能改成事件触发?

坑五:依赖裸过滤器的遍历顺序。裸过滤器的遍历顺序不该被依赖,可偏偏有人在里面写「按顺序处理」的结算逻辑,测试环境跑得挺好,一换机器、一加实体就错乱。要可控顺序,用缓存查询的稳定迭代,或者你干脆自己加个排序组件。顺序这种东西,能明说就别靠猜。

说句实话,这五个坑没有一个是「技术不会」导致的,全是「手感没长出来」。ECS 的查询系统就像手动挡的车,参数你都看得懂,但什么时候换挡,得开坏几次才明白。

FAQ:几个真被问到的问题

问:裸过滤器到底还有没有存在的必要?缓存查询不是全面碾压吗?不是。裸过滤器的价值在「轻」和「即时」——它没有创建成本,条件随便改,适合语言层面的一次性判断和工具场景。缓存查询全面碾压的是「高频复用」这个前提,一旦前提不成立,裸过滤器反而更划算。别把工具当教条。

问:我怎么知道自己是不是 Archetype 太碎了,把查询拖慢了?最直接的信号是:同样的实体数量,你的帧时间比预期抖,而且随着你动态加删组件,抖动越来越明显。这时候打开 Explorer 看世界的 Archetype 列表,如果数量远超你「设计上该有」的组合数,那就是碎片化在作妖。治本要从组件设计下手(少用动态组合、多用标签表达状态),治标可以临时给查询加更严的条件收窄范围。查询和碎片化是连体婴,别分开治。

问:Rule 能不能完全替代缓存查询,我用规则写所有逻辑行不行?理论上你能,但你会在第一个帧敏感的模块上付出代价。规则是拿来换表达力的,不是换速度的。热路径用规则,就像用大炮打蚊子——声势浩大,效率感人。老老实实分层。

问:缓存查询的「补登」是怎么触发、我要不要手动管?不用手动管,世界新增符合条件的新 Archetype 时会自动增量维护。但你要理解它的存在,因为它意味着缓存查询的成本不是恒定的,而是随世界复杂度浮动的。碎片化越严重,补登越频繁,你就越该关注第六条表的那个旋钮。

问:这篇文章讲的三层,跟[08]要讲的关系是什么关系?关系是查询系统最锋利的那块刀刃。Filter 和 Query 能按关系签名匹配,Rule 能沿关系链推理——关系让查询从「筛集合」升级成「筛图」。下一篇我们把关系掰开揉碎,你会回来重读这一篇的第三层。

最后留个开放的问题给你:你有没有想过,你的游戏里那些「明明逻辑没问题但手感就是不对」的地方,有多少其实是查询条件写得太宽了?查询决定了系统看到多大的世界,而不是游戏本身有多大。这一句话,够你重新审视一遍现有代码了。

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