Flecs 调度系统:Pipeline、Phase 与 System 的执行控制 Scheduling as Game Rules
一帧里的先后顺序就是游戏规则本身——调度器写错,物理和渲染会一起骗你
为什么调度是绕不开的那块基石
先说个我自己的翻车事故。项目做到第三个月,测试群里发来一段录屏:主角贴着墙角走,每隔两三秒就会「嗖」地被弹出去半米,然后卡回原位。美术说是动画问题,策划说是碰撞体给大了,我当时拍胸脯说物理引擎背锅。真凶查了三天——是我把玩家移动系统放在了物理步进后面。移动系统基于上一帧的碰撞结果算出新位置,物理系统再拿这个新位置重新解算,两边各说各话,玩家看到的就是「弹一下再回去」。我删掉一行顺序声明,bug 当场消失。
这件事让我记住一个道理:玩家对 ECS 架构的所有感知,本质上都是对「顺序」的感知。他看不到你有什么组件、什么原型、什么内存布局,他只能看到「我按了跳,角色晚了几帧才动」「子弹明明打中了,伤害结算慢半拍」「敌人死了但尸体还在推我」。这些全是调度问题,不是内容问题。
很多教程把调度系统讲成性能工具:Pipeline 帮我并行、Phase 帮我分线程。这个理解太窄了。在我看来,Flecs 的调度系统首先是一门时序语法——它让你用声明的方式,把「这个世界的因果链条」写清楚。谁先读、谁后写、哪些事情必须排队、哪些可以同时干。写清楚了,游戏就会「讲道理」;写糊涂了,你会在三个月后收到一段弹墙壁的录屏。
这篇是系列的第 9 篇,前面我们刚把关系(Relationships)啃完,那是 Flecs 用来描述「数据之间怎么连」的语言;这一篇讲的是「数据什么时候动」。接下来第 10 篇会顺着这一篇往下,讲真刀真枪的多线程并发。所以这篇更像是一张地基图:Pipeline、Phase、System 这三个词,到底各自管什么、怎么排、排错了会怎样。
先把我的核心主张摆桌上:别把调度当优化,把它当归档。你这辈子会换三次渲染方案、两次物理引擎,但这套「一帧的因果链」如果写对了,能扛住整个项目生命周期。反过来,如果在原型期随手把所有系统丢进同一个更新阶段,等你想并行的时候,会发现连自己都说不清谁该等谁。
第一层:Phase —— 把一帧切成几段可感知的时间
想象你在一家出餐极快的面馆。后厨不是一锅乱炖,而是有几道固定工序:接单、煮面、浇头、装盘、上桌。每道工序内部可以同时干很多活(五碗面一起煮),但工序之间必须排队——没人会把浇头泼在还没煮的面条上。Flecs 的 Phase 就是这几道工序。
Flecs 默认给你一组预置阶段,大致长这样(不同版本名字可能略有出入,以你手上那份文档为准):加载(OnLoad)、启动(OnStart,只在第一帧跑一次)、更新前(PreUpdate)、主更新(OnUpdate)、校验(OnValidate)、更新后(PostUpdate)、写回(OnStore)。你可以把它理解成一条传送带,系统挂在哪个阶段,就决定了它在这一帧的哪个位置被叫号。
为什么非要分这么多段,不能只有一个更新阶段全塞进去?如果只有一段,系统之间谁先谁后就没语法可依——它们同属一个阶段,Flecs 只会把它们当作「无序集合」处理,排到哪算哪。一旦你想开并行,框架就没法证明谁和谁能同时跑。分了阶段,阶段之间是硬边界:上一段全部跑完,才轮到下一段;同一段内部才是候选的并行区。这条边界就是你的第一道保险。
玩家能感受到什么?我拿三个最直观的例子说。
输入在哪一段,决定手感有多「跟手」。把你采集输入的系统放在 PreUpdate,同一帧的主更新里角色就开始动、相机就开始跟——玩家按下去,当帧就见效。你要是把它塞进 PostUpdate,输入采样完这一帧就结束了,角色要到下一帧才挪动,60 帧下就是 16.6 毫秒的额外延迟。听起来不多?格斗游戏和音游里,16 毫秒足够让一个「完美判定」变成「还差一点」。玩家的原话是「这个游戏不跟手」,但没人会告诉你这是阶段归属造成的。
校验段(OnValidate)是给「物理不守规矩」擦屁股的地方。物理步进跑完,位置可能已经穿透墙体了。这一段就是你的最后一道武力:把越界的推回来、把速度和位置对不上的纠正、把叠加的力清零。很多团队没有这一段,直接在渲染前随手补两句,代码散落在五六个系统里,最后谁也不知道最终位置到底被谁改过。专门留一段,好处是「所有修正集中在一起,可查、可度量、可以单独关掉做对照测试」。
写回段是网络和渲染的收口。所有游戏逻辑尘埃落定,才该去序列化发包、才该把最终变换提交给渲染。反过来做,你会发出一个「下一帧会被修正的中间态」,客户端收到的位置永远在抖。
说个真实的对照。Flecs 作者自己维护的那个平台跳跃示例项目(用来验证框架能力的那个,不是商业产品),系统的分层就是标准的阶段骨架:输入采集在更新前,运动与碰撞在主更新,相机跟随和渲染提交在更新后。它没有什么黑魔法,中文社区有人照着抄作业直接跑通了一个能玩的小 Demo。反过来我在一个朋友的横版项目里看到过「所有系统都在主更新、靠改声明顺序凑出来的顺序」,第一个月跑得挺欢,第四个月加了个二段跳和抓墙,顺序立刻成了一团毛线——因为改一个系统的位置,会连带影响另外四个。
| 阶段 | 该放什么 | 不该放什么 | 玩家感知 |
|---|---|---|---|
| OnLoad / OnStart | 资源装载、初始实体摆放、子系统引导 | 每帧都跑的循环逻辑 | 加载时间、开局是否卡顿 |
| PreUpdate | 输入采样、网络收包、时间步推进 | 需要知道碰撞结果的逻辑 | 操作跟不跟手 |
| OnUpdate | AI 决策、移动积分、战斗判定、经济结算 | 直接改渲染状态 | 核心玩法的手感 |
| OnValidate | 位置修正、约束解算、非法状态兜底 | 新玩法逻辑 | 有没有穿墙、抽搐 |
| PostUpdate | 动画采样、相机跟随、LOD 分级 | 修改源头数据 | 画面顺不顺、镜头稳不稳 |
| OnStore | 网络发包、渲染队列提交、统计埋点 | 任何会影响下一帧的写操作 | 联机同步质量、帧率曲线 |
这张表你抄走直接用,八成不会错。但我更想说的是它背后的思路:每个阶段回答一个玩家能问出来的问题。「我按了有没有反应」在 PreUpdate 回答,「世界里发生了什么」在 OnUpdate 回答,「我看到的东西对不对」在 PostUpdate 回答。你要是想不出来某个系统在替玩家回答哪个问题,那它多半放错地方了。
第二层:依赖声明 —— 一帧不是一条线,是一张图
分好阶段只是入门。真正的分水岭是下一件事:同一阶段里的系统,是不是必须排成一列?答案是:绝大多数情况不是,但你必须告诉框架「谁真的必须等谁」。
举个例子。一个典型的主更新里可能同时有:敌人 AI 决策、玩家移动积分、粒子生命衰减、背包合成计时、天气循环推进。这五个系统里,AI 和移动有先后关系(AI 决定往哪走,移动把它走掉),粒子衰减和天气循环互相毫不相干,背包计时和前面四个更没交集。如果全靠手工排序硬凑,你会得到五个格子的单行道,只能一台车一台车过。
Flecs 的做法是:你声明依赖,它来算这个图,把「互不依赖」的系统切进不同的「关卡」(stage,注意这里的 stage 是并行批次的意思,和上面说的 Phase 不是一回事,新手最容易在这两个词上栽跟头)。同一个批次里的系统可以同时开跑,下一批要等上一批全部完成。
这里最容易误解的一点:依赖声明不是「我要排在他后面」,而是「我读的东西是他写出来的」。这两件事听上去一样,实际差很远。前者是命令式的,你随口就能写;后者是诚实的,你必须想清楚数据从哪来。当你用「数据从哪来」这种方式去描述,框架才能推导出更多信息——比如 A 和 B 都读一样的组件、谁也不写,那它俩天生就是一对,应该同时跑。
所以我给团队定的规矩是:一个系统在写之前,先手写一行注释,写清楚「我读什么、我写什么」。这一行注释最后会变成依赖声明。就这么个土办法,帮我们抓出了一堆「隐形耦合」——两个系统看着八竿子打不着,结果都在写同一个全局状态对象,那对不起,你俩别想并行。
看 Unity DOTS 的 Megacity 这个案例,特别能说明问题。这是 Unity 官方 2019 年放出来的 DOTS 演示:一座赛博风的城市,官方口径是几万辆行驶的车加上建筑群,实体规模在十万量级。它不是用一条线排出来的,而是用「系统组」搭出层次:输入组、载具行为组、渲染准备组,组内再按更新顺序属性细分,组的上下级本身就表达了粗粒度的依赖。更关键的是,它把大量计算切成一堆 Job,由引擎根据「谁读谁写」自动串成依赖链——你声明了读,引擎就知道可以和你并排跑;你声明了写,引擎就让你排队。
这个案例里最值得抄的不是它的渲染,是它的组织方式:依赖是分层的。粗粒度上用组划出大块,细粒度上让每个具体系统声明自己的读写。反过来说,如果你想在一个组里塞三十个系统、只靠排序属性手工指定先后,那和写一张巨大的流程图没区别,改一个牵动全图。
那玩家能感受到什么?最直观的是帧时间的平滑度。十万级实体的场景,如果依赖切得好,四核和八核的表现差异会很平滑;如果全都挤在一条线上,你就看着核心数往上加、帧率纹丝不动。另一个感知是「AI 反应速度」——大量 NPC 的行为决策如果各自独立,可以并行算完,你就能给它们更高的决策频率。塔防里那些小怪、开放世界里那些远处的 NPC,能不能做到「每个都在认真思考」,很大程度取决于你敢不敢让它们分开跑。
| 系统 | 读 | 写 | 能否与谁并行 |
|---|---|---|---|
| 敌人 AI 决策 | 感知、自己的行为状态 | 意图指令 | 与粒子、天气、背包计时并行 |
| 玩家移动积分 | 意图指令、速度 | 位置 | 与粒子、天气、背包计时并行,但必须等 AI 之后 |
| 粒子生命衰减 | 粒子自身数据 | 粒子生命周期 | 与上两个互不相干,随时可跑 |
| 背包合成计时 | 配方、计时器 | 完成事件 | 与上面都无关 |
| 全局天气推进 | 时间 | 全局天气状态 | 注意:所有读天气的系统都得等它 |
看最后一行,这就是「全局状态」的原罪:它能让你省下三行代码,也能把整张并行图砍断。这话我在第 22 篇讲大型项目架构的时候还会翻出来说,这里先埋个钉子。
第三层:结构性变化 —— 一帧里的交通管制
前两层都是「顺畅」的情况。真正让人掉头发的是这一层:当组件集合本身要变的时候。
先补个背景,不熟的同学可以回看第 6 篇 Archetype 那章。Flecs 把拥有完全同一套组件的实体放在一块连续内存里,这套组合就叫一个原型。这个设计非常快——遍历时内存是连续的,缓存友好。但它有个代价:一旦你要给实体加减组件,它就属于「换了原型」,得从这块内存搬到另一块内存去。哪怕只是加一个小小的标记标签,物理位置也变了。
为什么这是调度问题?因为你一旦在遍历的中途搬家,当前正在遍历的那块内存就被动了,指针可能失效、批次可能错乱。所以 Flecs 的做法是:不让你当场搬,先记账,等这一轮遍历结束、到了安全点再统一执行。这就是延迟指令(deferred command)——你说「给这个实体加个着火标签」,它先写进小本本,等合适的时机再一次性落地。
好处是安全,代价是时序上的「意外」。我见过太多这种 bug:策划问「为什么我点燃的桶,要到下一帧才炸?」程序查了半天,发现是火焰系统在遍历过程中给桶加了「燃烧中」标签,这个标签下一帧才真正生效,于是火焰系统这一帧看它还是「没烧」。表现上就是慢半拍,还不是稳定慢半拍——帧率越低越明显。玩家的感受是「点火有延迟」,策划的感受是「这引擎怎么这么笨」。
解决思路不是关掉延迟,而是把「结构性变化」当成一种需要排班的资源。给你三条我踩出来的经验:
第一条,集中化。所有会加减组件的操作,尽量收敛到少数几个「生成/销毁」系统里,而不是散落在五十个业务系统。这样你至少知道爆炸半径在哪。我们的做法是建一个「实体变更登记」系统,任何地方想加标签、想销毁实体,都投递到它那儿,由它统一在固定窗口提交。
第二条,挑时间。把批量提交放在阶段边界上,比如主更新跑完之后、写回之前,这样它不会打断任何正在遍历的系统。这也解释了为什么 Flecs 的阶段划分里,边界这么重要——边界不只是给人看的,也是给延迟指令落地的安全窗口。
第三条,量化它。结构性变化是有成本的,一次两次感觉不到,一秒上千次就是帧尖刺。我建议在开发期就把它当指标看:每帧提交了多少次变更、峰值是多少、尖刺出现在哪个阶段。有个真实参考值:在中端移动设备上,单帧集中提交上千次原型迁移,是能明显摸到 3 到 5 毫秒开销的;而同样数量的纯数值写入基本无感。差了一个数量级。这个数字各家硬件不一样,但「结构变更比数值写入贵一到两个数量级」这个结论是通用的。
玩家能感受到什么?帧率曲线上的「毛刺」。你看平均帧率 60,但每秒有两次掉到 45,玩家就会说「卡顿」。这种毛刺最难查,因为它和游戏内容强相关——通常出现在「一大波怪同时死」「玩家一炮炸掉一整个房间的箱子」这种瞬间。而这些瞬间,恰恰是玩家最爽的时候。所以结构性变化的调度,本质上是保护爽点。
一个高频翻车点:很多人以为「我用了延迟指令所以安全了」,于是在遍历里大量加减组件,觉得反正会延迟。结果安全是安全了,一帧攒了八千条待办,到了提交窗口一次性爆炸。延迟指令是缓冲,不是免费。缓冲也有容量,也有结算时刻,结账那一下的痛感一样会传导到玩家手上。
第四层:两套时钟 —— 调度管「什么时候跑」,响应管「跑完通知谁」
最后一层,也是最多人混淆的一层:调度系统和响应式系统(观察者、触发器,第 11 篇会细讲)是两套时钟,它们不共享同一个节拍。
打个比方。调度系统是公司里的排班表:谁几点上班、几点开会、几点下班。响应式系统是工位上的门铃:有人来找你,铃就响。排班表决定你什么时候干活,门铃决定你什么时候被打断。你不可能指望门铃响了,会议就自动提前——它们是两回事。
落到具体,玩家和策划最常问的问题是:「我改了这个组件的值,为什么下游要到下一帧才有反应?」因为负责反应的观察者,可能挂在另一个阶段,甚至要等这批写操作全部落地才轮到它。你要是以为「赋值即触发」,那你会写出一堆「看起来对、跑起来慢一帧」的代码,然后在联机测试里变成「客户端和服务器不一致」。
第二类混淆更隐蔽:同一个系统可能被不同的时钟叫醒不同的次数。比如一个系统按游戏帧跑,另一个按固定物理步长跑,帧率一波动,两者的频率就对不上。60 帧下它们勉强同步,掉到 40 帧就错位,物理和逻辑开始各说各话。这在中低端安卓机上简直是灾难现场。我的处理方式是:所有跟物理、位置、伤害结算相关的系统,一律锁到固定步长那套节奏上,宁可多插一次插值,也不让它们吃浮动的帧率。
第三类:初始化和启动的时序。启动阶段(OnStart)只跑一次,很多团队在这里做「世界初始化」——生成地图、摆放出生点、建立导航网格。问题在于,如果某个系统在启动阶段依赖了另一个系统在主更新里产生的数据,那第一帧就会拿到空值。开局那一瞬间的白屏、角色卡在原地半秒,八成是这类问题。我们的土办法是:启动阶段只做「不依赖任何运行时状态」的事,其余全部推迟到第一帧的主更新里做,用一个「世界已热」的标记位卡住。
玩家能感受到什么?开局体验,和长时间的稳定性。开局那 2 秒的卡顿,玩家会直接判定这个游戏「优化差」;而长时间跑下来偶尔错一帧,玩家感受不到,但你的联机对局会出现「我明明打中了」的争议。
说到这,不得不提 Bevy 的对照思路(跨类辅助,不是主角)。Bevy 把「什么时候跑」这件事做得更显式:它的调度标签可以自定义,系统可以声明自己在某个标签之前、之后、或者打包成一条必须顺序执行的链。这个设计和 Flecs 的思路本质一致——都是让「顺序」变成可声明的一等公民——但分寸不同:Bevy 更倾向于让你自己描述顺序关系,Flecs 更倾向于让你描述数据依赖,由框架去推并行图。前者上手更快、更直观;后者在系统数量上去之后,能自动挖出更多并行空间。没有谁更正确,看你团队规模。我个人的判断是:三十个系统以下,怎么写都行;一百个系统以上,靠人手描述顺序一定会崩,那时候「让框架去推」会救你的命。
参数化设计工具:把一帧的钱花在刀刃上
这一节给你两张能直接拿去用的表。第一张是帧预算分配,第二张是阶段归属的判断清单。
先立个前提:所有数字都是经验区间,硬件、渲染后端、物理引擎不同会漂移,但量级关系是稳的。
| 阶段 | 60 帧预算(16.6ms) | 30 帧预算(33.3ms) | 放超额会怎样 |
|---|---|---|---|
| PreUpdate(输入/收包) | 0.3 到 0.8ms | 0.6 到 1.5ms | 输入延迟,手感发木 |
| OnUpdate(核心玩法) | 5 到 7ms | 11 到 14ms | 帧率主战场,最该做并行的地方 |
| OnValidate(修正) | 0.5 到 1.5ms | 1 到 3ms | 超了说明上游在乱写,别在这补锅 |
| PostUpdate(物理/动画/相机) | 3 到 5ms | 7 到 11ms | 抖动、镜头飘、动画撕裂 |
| OnStore(发包/提交) | 0.5 到 2ms | 1 到 4ms | 联机同步劣化、掉包 |
| 结构性变更提交 | 单帧峰值 ≤1ms | ≤2ms | 超过就是帧尖刺,玩家说「卡」 |
注意最后一行,我特意把它单独列出来。它不是某个阶段,它是「渗透在所有阶段之间的一笔税」。你需要单独给它留预算,而不是让它随缘。
第二张表更实用,是我给团队用的「这个系统该放哪个阶段」判断清单。按顺序问,第一个答「是」的就是答案。
| 序号 | 问自己 | 是 → 归属 |
|---|---|---|
| 1 | 这个系统在采集外部世界的状态(输入、网络、文件)吗? | PreUpdate |
| 2 | 这个系统只在游戏启动时跑一次吗? | OnStart |
| 3 | 它产生的是「游戏世界里的结果」,别人要读吗? | OnUpdate |
| 4 | 它的职责是纠正上游的错误,不产生新玩法吗? | OnValidate |
| 5 | 它只读游戏世界,产出的是「给人看的东西」吗? | PostUpdate |
| 6 | 它只把结果吐出去,不回头改世界吗? | OnStore |
| 7 | 以上都不是,或者我说不清 | 先放 OnUpdate,然后回去想清楚 |
第 7 条看着像偷懒,其实是最重要的一条。说不清归属的系统,通常是职责没想清楚。我宁可它先挤在主更新里,也不愿意给它随便找个角落安置——因为一个位置不明的系统,会在半年后变成所有人都绕不开的暗礁。
再送一个可调参数:并行批次的目标数量。假设你主更新里有 40 个系统,别追求切成 40 个批次(那是串行),也别强求切成 3 个大批次(并行度不够)。经验值是分成「核数 × 1.5 到 2」个批次比较舒服。四核机器切成六到八个批次,能吃到七成左右的并行收益,同时不至于因为批次太碎导致调度开销反噬。这个数字我调过很多次,往下切收益递减得很快。
双路径对照:你现在该读到哪一层
初级路径(刚上手、或者项目还在原型期):你只需要记住三句话。第一句,所有能跑的玩法逻辑都丢进主更新,别急着分阶段——先用起来,一个阶段也能做出完整的游戏。第二句,把「采集输入」和「提交画面」这两件事,老老实实分别放到更新前和更新后,光这两刀就能解决你 80% 的手感问题。第三句,别在遍历过程中给实体加组件减组件,实在要加,攒起来一起加。
这个阶段最忌讳的是「提前优化」。我见过刚学 Flecs 两周的人,花三天研究怎么切成八个并行批次,结果他的游戏里总共就二十个实体——并行省下来的时间还不够线程调度的开销。别笑,我也干过。我甚至给一个只有三只史莱姆的战斗场景开了四线程,跑出来的帧时间比单线程还高零点几毫秒,纯粹是调度本身的开销。
进阶路径(系统数量过五十、开始有明显性能压力、或者准备上多平台):这时候你该做四件事。第一,给你手上每个系统标一遍「读什么、写什么」,标不出来说明你还没想清楚。第二,把阶段边界当硬约束,检查有没有系统跨阶段偷偷改数据。第三,盯住结构性变化的峰值,给它单独留预算,别让它混在平均值里骗你。第四,开始区分「固定步长」和「浮动帧率」两套节奏,凡是和物理、位置、伤害结算相关的,一律锁死固定步长。
进阶路径里我最想喊一句:先量再调。很多人调调度靠猜,觉得这里该并行、那里该拆组,改完发现没好,又改回去。你得先能看见每个阶段花了多少时间、每个批次实际并行了几个系统、结构性变更的峰值出现在哪。看不到就调,等于闭眼拧音量旋钮。第 18 篇讲调试工具链的时候我会把具体怎么看展开,这里先记住「看不见就别动」这条原则。
争议与常见坑:那些看起来对、跑起来错的做法
坑一:以为框架会自动帮你排游戏逻辑的顺序。这是新手最普遍也最致命的误解。Flecs 能推出「谁和谁可以并行」,但它推不出「玩家跳起来之后才该落地」——因为那是游戏规则,不是数据依赖。你不声明,它就当没这回事。我见过一个项目,跳跃系统和落地检测系统被并行调度了,结果有一半的帧里,角色在起跳的同一帧就被判定落地,玩家按跳只能原地抖一下。这个 bug 在开发机上复现率只有 50%,因为取决于线程调度。查了两周。
坑二:并行化的幻觉。你在配置里开了多线程,系统也声明了依赖,看着很美,结果实测只快了 8%。为什么?因为所有系统都在写同一个全局状态——比如「当前游戏时间」「当前关卡状态」「玩家引用」。这些全局对象就像所有系统共用的一个笔记本,谁都写,那谁都别想同时动笔。想吃到并行收益,你得先把这种「人人是作者的共享笔记」拆掉。这话我在第 22 篇讲大项目架构时会再展开,但根子在这里。
坑三:把延迟指令当万能药。前面说过了,缓冲也会满,结账也会痛。更阴险的是「延迟的连锁」:A 系统延迟加了标签,B 系统这一帧看不到标签所以没处理,C 系统基于 B 的结果做了错误决策。三个系统各自都「对」,凑一起就是错的。这种 bug 极难复现,因为只在特定帧序下出现。
还有一个我不太确定的判断,说出来供大家拍砖:校验阶段到底值不值得单列?很多团队的做法是把修正逻辑直接写进物理系统里,省一个阶段。短期看确实省事。但我倾向于单列,原因是「可关闭性」——当你想验证某个 bug 是不是物理穿透导致的,只要把校验段整个关掉,看现象变化,五分钟就能定位。写在物理系统内部就没这个开关了。当然,如果你的游戏物理很简单,这个阶段的收益确实不大。我承认这里没有标准答案。
最后回应一个社区里常见的吐槽:「这套阶段划分是不是过度设计了?我一个小游戏,一个更新阶段不就完了?」我的回答是:对,你的小游戏一个阶段就完了。但请把系统写成「随时可以搬走」的样子。别在系统内部硬编码「我一定是第一个跑的」这种假设。这样等你的项目长大,搬家成本才低。这不是为框架买单,是为你未来的自己买单。
FAQ
Q1:Phase 和 Stage 到底什么区别?我老是搞混。
Phase 是「时间上的段」,像一天里的早中晚,硬顺序,前一段跑完才轮到下一段。Stage 是「同一时间里的批次」,像早高峰地铁的几班车,同班车可以同时开,下一班要等这一班到站。你挂在哪个 Phase 决定了你在一天的什么时候干活,你被分到哪个 Stage 决定了你能和谁同时干活。这俩词在 Flecs 的讨论里都出现,但意思完全不同,我建议你在团队内部干脆换个叫法,比如叫「时段」和「批次」,能省掉一半的沟通成本。
Q2:我把所有系统都放主更新,靠手工调顺序,能撑到什么规模?
撑到三十到五十个系统,基本就到头了。再往上,你会发现「改一个系统的位置会连带影响四五个」,每次加功能都像拆炸弹。这不是 Flecs 的问题,是任何「全局线性顺序」的组织方式都会遇到的墙。到了这个规模,该做的是把系统按职责分组,组内声明读写依赖,让框架去算批次。
Q3:为什么我开了多线程,帧时间几乎没变?
三个可能,按概率排。第一,你的系统都依赖同一个全局状态,等于全挤在一个批次里,多线程没有任何活可干。第二,瓶颈根本不在你那,在渲染提交或者物理引擎内部,你在调度上省下来的时间被别处吃掉了。第三,你的系统数量太少,并行省下的时间还不够线程调度的固定开销。先量清楚瓶颈在哪,再动调度。这句话我说了三次了,因为它值得说三次。
Q4:延迟指令什么时候必须用,什么时候可以不用?
只要你在「遍历某一批实体」的过程中,想改变「这一批实体的组件集合」,就必须用。反过来,如果你只是改组件里的数值(位置、血量、速度),不用延迟,直接写。判断标准很简单:你是改「值」,还是改「有哪些值」。改值是免费的,改结构要排队。
Q5:小团队到底该不该一上来就搞这套?
我的建议是:阶段划分一上来就做(成本极低,收益立刻体现在手感上),依赖声明和并行调度可以往后放。先把「输入在更新前、提交在更新后」这条主干立住,剩下的事情等你的系统数量真的涨起来再说。别让架构超前于内容,也别让内容欠着架构的债。「主干先立、枝节后长」,这八个字我用了很多年。
最后留个问题给你:你有没有想过,你现在游戏里那些「说不清为什么就是不太对劲」的手感问题,会不会其实根本不是数值没调好,而是某个系统的执行位置放错了?去找一个最让你别扭的操作,然后翻一下它在哪个阶段跑的。你可能会发现一个藏了半年的答案。