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

Flecs 与游戏引擎集成的边界:是组件还是引擎替代品

· 0 分钟阅读
Flecs 与游戏引擎集成的边界:是组件,还是替代品? The Boundary of Engine Integration — Flecs ECS 技术专题

Flecs 与游戏引擎集成的边界:是组件,还是替代品? The Boundary of Engine Integration

为什么"边界"是 Flecs 上手第一个要回答的问题

我见过一个很典型的场面。一位独立开发者,第一次接触 Flecs 之后热血上头,觉得这玩意儿什么都能干——查询快、关系硬、能调度、能序列化、还能写脚本。于是他做了一个决定:把整个游戏的所有东西都塞进一个 World。窗口句柄、纹理、音频流、输入回调、物理引擎对象……全进去。两周后他回来发帖:"Flecs 是不是不适合做游戏?"

不是它不适合。是他把一台货车的发动机,装到了自行车的车架上,还试图让发动机负责转向。

这篇要聊的就是这个问题:Flecs 到底管什么、不该管什么,以及它和引擎该怎么坐一张桌子。这不是 API 问题,是架构问题。你越早想明白这件事,后面写 10 万行代码越顺。

先把话说在前面:Sander Mertens(Flecs 作者)自己在多个场合明确讲过——Flecs 不是游戏引擎。但他也反复演示过:用一个很薄的渲染后端(比如 sokol、bgfx),加上 Flecs,能拼出一个能跑的小游戏。所以这里的关键词不是"能不能",是"谁负责哪一层"。我们今天就把这张分工表画清楚。

顺带说一下呼应:本系列 12 讲渲染、13 讲物理集成、27 讲这一篇边界,其实是一条线——都是回答同一个问题:Flecs 和外部世界怎么握手。第 27 篇是把握手规则讲到最清楚的一篇,也是新手最容易栽跟头的一篇。

第一层:Flecs 到底该管什么——它是"世界状态的中枢"

先把画面感拉出来。想象你在写一个 "割草类" 的小游戏就好——上百只小怪、一个玩家、捡东西、打斗。玩家在屏幕上看到的是:怪物在追我、血条在掉、金币被吸走、屏幕左下角有 buff 倒计时。你问一个策划"这个游戏在跑什么",他会说"游戏在运行"。你问一个 ECS 程序员"这个游戏在存什么",他会说"状态在变"。

这中间的差别就是这个:Flecs 的核心职责,是让"游戏世界的状态"以最高效、最一致的方式被存储、被查询、被改动。就三件事——存、查、调,加上衍生出来的关系与生命周期管理。

具体展开,Flecs 应该管的四类东西:

  • 一切游戏规则相关的数据。血量、攻击力、位置、状态标记、AI 阶段、冷却计时。这些数据是"这一帧必须一致"的。你不能让渲染层看到的位置比逻辑层晚一帧,那就抖了。
  • 实体之间的逻辑关系。谁是谁的子节点、谁持有谁、谁对谁有仇恨。这是 Flecs 独门武器,关系(Relationships)直接改变 Archetype 划分,查询起来快到离谱。
  • 系统执行的顺序和并行边界。哪些逻辑必须先跑,哪些可以同时跑,哪一步之前要合并延迟命令。这是 Pipeline 和 Phase 的活。
  • 跨存档、跨网络的"可序列化状态"。就是那些你希望保存下来、或者发到服务器上同步的数据。

反过来看玩家体验。一个合格的 Flecs 状态中枢能让玩家感觉到什么?响应一致。你按了攻击键,敌人掉血、血条变化、特效出现的顺序永远是对的。因为所有这些事读的是同一份权威数据,没有"渲染读到旧状态"的那种错位。这一点在多人联机或者高帧率下会特别明显。很多人以为 ECS 只是个性能优化,其实它更底层的作用是"让世界状态只有一个真相"。

举一个把这个原则做到极致的案例:《守望先锋》。Tim Ford 在 GDC 2017 那场著名的 "Overwatch Gameplay Architecture and Netcode" 演讲里讲的架构,本质上就是 ECS 风格:每个英雄、子弹、效果都是一个"实体 + 数据组件",由一串"系统"按固定顺序处理。但请注意——他们的 ECS 只管游戏逻辑与网络同步。渲染是另一套东西,物理是 Anim 和 Netcode 之间的独立管线,UI 更是另一套。玩家看到的"源氏冲刺过去,一刀把天使切了",在这个架构里是:位置组件变化 → 一个击杀判定系统在帧内处理命中 → 状态组件被打上标记 → 网络系统把这个状态变化打包发给所有客户端 → 渲染层去读最新组件决定在哪画。你看,ECS 只占了中间那一环。

所以第一层给个总结:Flecs 是游戏逻辑的"账本",玩家所有可感知的规则后果,都由这本账本记录并驱动。账本写得清,游戏跑得稳;账本混进广告传单、快递单、发票,就成了一锅粥。

第二层:Flecs 不该管的四件事——从显存到主循环

翻车最多的不是"管什么",是"不该管什么"。我在社区里见过太多次同样的问题:把窗口句柄扔进 World 里,然后发现窗口关闭的时机根本对不上系统调度。下面这四类东西,我强烈建议你放在 Flecs 外面。

类别典型内容为什么不该进 World建议归属
显存资源(GPU 侧)纹理句柄、网格缓冲、着色器对象、渲染目标它们的生命周期跟 ECS 世界不同步——常常要到"场景卸载之后"才能销毁,但 ECS 实体销毁是即时的。而且 ECS 序列化它没意义,加载存档不能加载出一张纹素都还在显存里的纹理。资源管理器(引擎自带或自研),ECS 里只存一个整型 ID 或句柄作为引用
平台/生命周期对象窗口、输入设备句柄、系统事件循环、文件句柄、网络 socket这些是"引擎的骨架",产生的时间早于 World、销毁的时间晚于 World。放进 World 会出现"实体销毁了但窗口还活着"或反之的鬼故事。游戏主循环/引擎宿主,Flecs 只是它调用的一个阶段
解算器内部循环物理迭代、路径搜索内层、光线步进这些是"单位时间内跑成百上千次"的热核,把它们拆成一个个系统,反而是拿调度开销去砸性能。作为外部库整体调用,只在入口/出口跟 ECS 交换一次状态
音频混音层音频采样流、混音缓冲、DSP 效果链音频有独立的采样时钟(常常 44.1kHz),跟游戏帧率根本不是一个节拍。硬塞进 ECS 等于让两个节拍器互相打脸。音频引擎独立,ECS 里只发"播放哪个音频事件"的指令

注意我上面写的是"不该进 World",不是"不该用"。纹理句柄不该整个塞进去,但纹理的 ID 该塞进去——因为你渲染一个怪的时候,需要知道它长什么样。物理引擎对象不该塞进去,但它"这个实体是否有刚体"这种标记该塞进去。这就是判据:能被 ECS 存下来当作"状态"的东西进,只是"外部资源指针/生命周期对象"的东西留在外面。

玩家体验上,这一层区分有什么用?最直接的一条是:热重载和存档。你想想一个场景:玩家进入 B 关,前一个场景的所有实体都销毁了,但纹理不能跟着销毁——下一关同一张图还要用。如果你把纹理句柄直接挂在实体上、靠实体销毁来自动清理资源,前一个场景清空了,下一个场景就变成一堆黑块。这不是理论,这是很多新手跳崖的瞬间。

再举一个段落级的例子。Godot 4 + Flecs 的 GDExtension 集成,这是社区里目前讨论最多的实验方向之一。运作方式是:Godot 负责窗口、渲染、音频、输入、场景切换这些"宿主生命期"的东西;Flecs 在 GDExtension 里跑一个独立的 World,装游戏逻辑。Godot 那边的节点只做"显示代理"——每个视觉实体对应一个 Godot 节点(或者一批走 MultiMesh 的实例),每帧从 Flecs 读位置、姿态、动画状态,同步到节点上。玩家的感受几乎"这就是一个 Godot 游戏",但逻辑层获得了 ECS 的数据驱动优势:几千单位同时转向不卡、状态组合自由、读写集中。

这个案例里体现的分工,正是"不该管的不让它管"。Godot 管它擅长的(渲染、UI、音频、编辑器),Flecs 管它擅长的(状态、规则、调度、关系)。你如果反过来,让 Flecs 去管窗口尺寸、让 Godot 节点树去做 AI 状态机,那就是互相伤害。这也是本篇章真正想说的那句老话:互补比替代重要。

第三层:四种集成模式——从"逻辑内核"到"ECS 即引擎"

同样是 Flecs 加引擎,可以拼出好几种完全不同的建筑。我从最保守到最激进排一排,你看看自己落在哪一档。

模式 A:Flecs 是逻辑内核,引擎为主。上面说的 Godot + Flecs GDExtension、Unity 里的 C# 绑定、Unreal 里把 Flecs 当插件,都属于这个。主循环是引擎的,Flecs 被当作引擎内部的一个"游戏逻辑世界"来跑。适合已经有成熟引擎、不想重做渲染管线的团队——大多数独立开发者其实落在这。代价是引擎和 Flecs 之间需要一层粘合层:每帧同步 Entity 到显示对象;输入要转成 ECS 里的事件;引擎的物理要跟 ECS 的位置对齐。

模式 B:Flecs 是中枢,薄层后端辅助。Sander 自己的 flecs 示例项目就是这个流派。窗口、输入、渲染用一个很薄的库(sokol、bgfx、SDL)来搞定,Flecs 负责主循环驱动、状态、系统调度。它和模式 A 的区别是:谁主动。模式 A 是引擎"调" Flecs,模式 B 是 Flecs 驱动整个程序。玩家感受上没差别,但架构上差很多——模式 B 你要自己写资源管理、自己写渲染循环、自己搞音频。好处是薄、轻、快、可控。

讲个真实的案例:Flecs 作者自己的 flecs 示例游戏(一个类 Flappy 的小作品)。整个项目结构极简:sokol 负责开窗、画三角形、读键盘,Flecs 负责小鸟实体、管道实体、分数、死亡状态、每次失败的重启。你去看它的组织方式,会发现它把"主循环该怎么一步一步来"完全交给了 Flecs 的 Pipeline。这种模式对独立开发者有一个巨大好处:你的整个游戏的"心跳"在一处可见。所有系统顺序都在一个 Pipeline 里排着,谁读什么、谁写什么一目了然。代价是你得自己扛后端,比如光照、后处理、UI 框架这些,得自己解决。

模式 C:引擎内置 ECS,ECS 作为引擎的子系统。代表是 Unity DOTS。Entities 包负责数据,Physics、Graphics、Netcode 各自是独立的"上层模块"。你作为使用者,实际上是在用引擎的 ECS,不是用 Flecs。这一档的意义是对比参照:它告诉你,一个成熟引擎怎么把 ECS 塞进自己的管线。DOTS 的分层做法非常值得借鉴——它不会让 ECS 去碰窗口,也不会让 Physics 去碰 ECS 内部存储。它把"状态"和"表现"切得很干净。这一点在做 Flecs 集成时值得照抄。

模式 D:ECS 即引擎。代表是 Bevy。整个世界从开始到结束就是一个 ECS 架构,渲染、音频、UI 都被抽象成插件。听起来很爽,实际上也确实爽——但代价是引擎整体为你绑死了一个架构。这一档是"对边界问题的最激进回答":没有边界了,ECS 就是世界。Bevy 的玩家体验很好,迭代极快,但这套模型对"想复用现有引擎"的人没有参考价值。

把四种模式对照下表,你自己感受一下该选哪档:

维度模式 A(逻辑内核)模式 B(中枢+薄层)模式 C(引擎内置)模式 D(ECS 即引擎)
谁控主循环引擎Flecs 的 Pipeline引擎的 PlayerLoopBevy 自己的执行器
渲染归属引擎薄层库用户自己接引擎的 Render 模块Bevy 的渲染插件
上手难度低(改现有工程)中(要自己搭)中(要学新 API)中(要换整个技术栈)
适合项目规模中小、快速验证独立游戏、工具类中大型、Unity 生态独立团队、长期自研
典型代表Godot GDExtension / Unreal 插件 / Unity C# 绑定作者自家 flecs 示例、sokol + flecsUnity DOTSBevy
谁负责输入引擎薄层库引擎Bevy 插件

我的观点是:90% 的独立开发者,落地位置是模式 B 和模式 A 之间。模式 B 适合你从零开始做,想极致轻量;模式 A 适合你已经在用某个引擎、想补一块数据驱动逻辑层。模式 C 和 D 属于"重新开始的大型选择",跟本篇要讨论的"边界"其实关系不大,但它们的存在是用来照镜子的——看清别人怎么切分隔层,你就不会想把窗口塞进 ECS。

第四层:作者的公开表态与路线图——那条没写下来的边界线

Sander Mertens 这个人挺好玩的。他写 Flecs 写了十几年,被问得最多的问题之一就是:"Flecs 会变成游戏引擎吗?"他每次的答案基本都类似:不会,但会提供沿途需要的东西。这话听着有点模棱两可,但你去看路线图的细节就明白了。

Flecs 在做但不算引擎方向的东西,比如:FlecsScript(DSL 声明式描述)、Flecs Explorer(数据浏览器)、管线(Pipeline)、序列化、观察者。这些东西组合起来确实很像"引擎的一部分",但它们都围绕同一个核心:世界状态怎么写、查、持久、调试。它们不涉及"我该怎么画出一张像素"或"我怎么解码这个 .png"。这就划出了那条隐形的线。

反过来,作者明确没打算做的东西:渲染后端、资源加载管线、平台抽象(窗口、图形 API 抽象)、音频、输入、UI 框架。你可以去看他关于"Flecs 与引擎/框架的关系"的一些公开讨论,他的立场相当清楚:这些是". 处"的活儿(后端、平台),交给别人干。

为什么用"处"这个字?你想想,Flecs 关心的东西是"游戏规则如何自我演化",这本质上是跟时间同步的、跟平台无关的、跟外设无关的。而渲染是异步的(GPU 帧率跟游戏逻辑帧率天生不一致),输入是事件驱动的(不跟游戏节拍),音频是自己的时钟。任何把"异步/独立时钟"的东西硬塞进同一个同步世界里的尝试,都会打架。

说个段子。有一次我给一个团队做代码 review,打开他们的代码,发现 World 里居然有一个组件叫"窗口对象",字段是窗口指针和分辨率。旁边还有个系统叫"每帧检测窗口是否关闭"。我当场就乐了。这不是 ECS 的错,这就是没想明白"边界"。ECS 的世界是游戏的世界,游戏的世界不需要知道显示器是哪年买的。

作者的态度其实是在给社区定调:别指望 Flecs 解决一切,也别把它当玩具。它是一个可以撑起一整个游戏逻辑世界的骨架,但它的边界就画在渲染、平台、外设、资源这四堵墙外面。你越过这条线,不是不能用,是它会用起来越来越别扭。

边界判断工具:一张"该不该进 ECS"的打分表

说了这么多原则,你需要一个能上手的工具。我把它做成一张六维打分表:你把一个数据/对象拿去打分,得分低于某个值就坚决放外面。

维度问题0 分1 分2 分
规则性它是否影响游戏规则或玩家可感知状态?完全不
影响
间接影响直接决定
可序列化它是否需要在存档、网络同步、回放里被记录?完全不用部分字段要全部要
生命周期同步它的创建/销毁时机是否跟游戏实体同步?完全异步大致同步一步不差
并行友好它被读写时是否天然适合放在系统里多线程并行?有线程安全
风险
需要加锁无状态读
组件粒度它是否能切成小块的、可组合的组件?一个整体对象
巨大
能切几块能细拆
稳定性它的字段结构是否长期稳定(不随版本频繁变)?频繁变动偶尔变很稳定

打分建议区间:

  • 总分 ≥ 10:放心进 ECS。这就是游戏规则的核心状态,血条、位置、AI 阶段、关系标记都属于这里。
  • 总分 6–9:把"要的字段"拆出来,做一个轻量组件,其余保留在外面。比如纹理——进 ECS 的只是一个整型句柄,显存对象本身留外面。
  • 总分 ≤ 5:坚决留在 ECS 外面。窗口、音频流、物理求解器内部对象、GPU 对象,都在这一档。

再叠加几个可调参数,帮你给项目定位:

项目参数偏向模式 B(Flecs 中枢)偏向模式 A(逻辑内核)
团队人数1–3 人3 人以上、有前端或渲染专长者
现有引擎经验无,或很弱已有 Godot / Unity / Unreal 项目
渲染需求复杂度2D 或简单 3D、像素风3D 有 PBR、有后处理、有阴影
UI 复杂度简单重 UI、有编辑器需求
目标平台PC、Web 上简单游戏移动、主机、3A 周边

更具体一点,我还会建议给"粘合层"定一个预算。比如:Flecs 和一个引擎(或者一个薄层)之间的双向同步,每一帧的同步次数控制在实体数的 1 到 2 倍以内。什么意思?假设你有 5000 个实体,那粘合层每帧最多跟渲染/物理之间来回 5000–10000 次数据交换。超过这个量,说明你粘合层写得不够聚合。要么把同位置的实体分批处理,要么干脆把字段拷贝做成一次性批操作。

双路径对照:你该从哪开始

初级用户速览(第一次接触 Flecs 的独立开发者):

先别急着选模式。你真正要问自己的只有一个问题:你现在手上有没有一个在跑的引擎游戏?

有的话,走模式 A。做法简单说就是:在现有工程里开一个 Flecs World,先别急着接很多系统,先做一件事——把"会动的东西"(角色、敌人、子弹)从引擎的 GameObject/Node 里抽出来,用 Flecs 管状态,引擎只负责渲染。跑起来,你感受一下同步成本。如果一帧稳定,继续加。如果不对,回头再看看是不是塞了不该塞的东西。

没有的话,走模式 B。去翻 Sander 自己的 flecs 示例,用一个薄薄的库(sokol 就很够用),把"开窗、读键盘、画三角形"这三件事写好。然后所有游戏逻辑都在 Flecs 里。你会惊讶地发现:一个能玩的小游戏,其实 2000 行内就能跑起来。别一上来就做引擎该做的所有事。先做能跑的那个。

总之一句话:初级用户别碰"Flecs 当引擎"这种话。你把它当下一个"大脑"就够了。

进阶深入(已经踩过坑、想给项目定架构的人):

你要思考的是"粘合层的形式"。三种典型写法:

第一,拉模式(Pull):渲染每帧主动来 Flecs 读数据,写一份"渲染快照"。优点是逻辑层完全不知道渲染的存在,缺点是有一次完整的拷贝。

第二,推模式(Push):Flecs 一改数据,就推一份变更给渲染。优点是零拷贝,缺点是逻辑层得知道渲染层的存在,容易泄露边界。

第三,事件模式:用观察者或自定义事件让两边解耦。这一档最灵活,也最容易失控。我的经验是:中小项目优先用拉模式,边界最干净;规模上来了再上事件模式。

顺带一句:这个"粘合层选哪种"跟本系列第 11 讲观察者那篇是强呼应的,你现在不用想全,知道有这几张牌就行。

争议与常见坑:这些做法听起来对,结果不对

来点具体翻车示例,都是我或者身边朋友真踩过的。

坑一:把渲染需要的"插值位置"和"逻辑位置"混在一处。逻辑帧率常常 60,渲染帧率可能是 144。你要是只有一个位置组件,渲染读到的是最新的逻辑位置,144Hz 刷新下面就出现了"抽搐"。正确做法是 ECS 存逻辑位置(上一帧、这一帧两个),渲染层自己做插值。别让 ECS 帮你去算渲染插值,那是渲染的活。

坑二:把"窗口是否关闭"当作游戏状态。这跟把"多重宇宙"塞进单个角色没区别。窗口生命周期是大管家的活,World 关不掉窗口,也不应该关心窗口。

坑三:试图让 Flecs 去"调度"外部库的迭代。比如物理求解器内部要做一百次迭代,你想把每次迭代做成一个 System。听起来很优雅,实际上你把一个 3ms 的紧凑循环,拆成了 100 次调度加同步开销。正确地做法是把物理求解当成一个单元,只在入口/出口和 ECS 交换一次。物理集成我们第 13 篇专门讲。

坑四:把 Flecs 当作编辑器工具。有朋友试着拿 Flecs Explorer 当"关卡编辑器"。Explorer 是调试器,不是编辑器。它没有图层、没有对齐、没有撤销栈——它做的是让你看见状态,不是让你设计内容。这个和本系列第 26 篇 FlecsScript 的边界其实是同一件事的两面:DSL/Explorer 是状态工具,不是引擎。

坑五:把"Flecs 就是引擎"当作卖点去说服团队。我见过太多这种讨论,把一个很老实的数据层说成能打天下的神仙框架。说白了:Flecs 让你有机会重新思考"游戏逻辑该怎么写",不是让你绕开所有引擎该做的事。你要是拿这句话去忽悠人,三个月后全团队骂你。

FAQ

Q1:那到底该不该用引擎?如果用的是 Godot,还有必要上 Flecs 吗?

A:看你的项目规模。如果你做一个几十分钟流程的 2D 小品,Godot 的节点树完全够用,别折腾。如果你的逻辑层开始出现几百上千个单位同屏、组合爆炸的状态机、复杂的关系图,那 Flecs 会明显给你减负。Godot 负责所有你看得见、听得见、摸得着的部分;Flecs 负责你看不见的那套"游戏规则"。

Q2:Flecs 有 UI 吗?能做菜单吗?

A:没有 UI 系统,也基本不建议。Flecs 的世界里你可以存"当前菜单状态",弹出哪个、选了哪个——但菜单的绘制、按钮布局、动画、文字渲染,这些交给引擎或你选的 UI 库。不要把 UI 做成 ECS 组件然后自己画。那是另一个深坑,和本篇边界无关,但会掉进去。

Q3:那作者做 FlecsScript,会不会就是要往引擎方向走了?

A:不是。FlecsScript 是"描述状态"的声明式语法,不是"描述视觉"。它描述的是"有个实体,它是玩家,它在这个位置,它有这个状态",它还描述不了"这个实体上面有一张贴图,贴图是紫红渐变"。所以 DSript 和 Explorer 一样,是状态层工具。第 26 篇专门聊这个。

Q4:那是不是意味着我该用"薄层"(sokol、bgfx)这种方案?

A:看团队。我一个人或者两个人,做 2D、像素独立游戏,我强烈推荐模式 B——薄层 + Flecs。你能完整掌控整个程序,包体小、迭代快。如果你团队有专门的渲染或者 UI 人,或者你要做 3D、跨平台、复杂的美术管线,直接上模式 A,用现成引擎。

Q5:那"ECS 是不是过度神话"这件事,你怎么看?

A:这个我们在第 25 篇会专门开一篇讲。这里先给个预告:ECS 是很好的状态架构,但它不是所有问题的最优解。你的商店系统、你的音频配置、你的 UI 布局,不一定要 ECS。判断标准还是那张打分表——总分低的,别硬套。Flecs 的最大价值,是让你清楚哪些东西属于"游戏规则",哪些不属于。你搞清楚这条线,就算不用 Flecs,你的代码也会变好。

最后抛一个问题

你想想一个场景:如果有一天你决定不用任何引擎,就从 Flecs 加一个薄层做游戏,你第一个卡住的点会是什么?我猜大多数人的答案会是"渲染和资源加载"——因为那才是真正被引擎惯坏了的部位。而反过来,如果你已经在用引擎,第一个想扔进 Flecs 的东西是什么?如果答案是"窗口句柄",回来把这张打分表再读一遍。

欢迎来信说说你的版本,我很想看看别人都在哪条边界上摔过。

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