游戏引擎-帧相位与执行顺序:从约定到可验证契约
一个实时系统可以没有崩溃、没有数据竞争,甚至每个模块的单元测试都通过,却仍然在每一帧里稳定地产生错误。
原因可能非常简单:所有事情都做了,但做事的顺序不对。
先计算最终姿态,再推进动画与物理;先销毁网络对象,再处理携带对象引用的事件;先发布状态,再完成本轮仿真。这些调用在语法上完全合法,局部逻辑也可能各自成立,组合起来却破坏了同一帧的数据关系。
帧相位顺序优化,就是把“某个系统应该在什么时候运行”从注释和个人经验中提取出来,变成可声明、可执行、可验证的工程契约。
这里的优化首先指向正确性与可维护性,不预设一定提高帧率。讨论对象是 CPU 侧的世界更新与任务组织,而不是 GPU 渲染通道的资源屏障。下面使用通用模型和伪代码展开,不依赖特定平台或软件实现。
一、为什么单线程也会出现“时间错误”
1.1 函数执行成功,不代表执行时机正确
设想一个角色管线:每个仿真步先推进动画时钟、提取根运动、更新物理状态,再生成供呈现使用的最终姿态。
用 k 表示已经完成的仿真步,X(k)
表示角色的位置与物理状态,P(k)
表示相应的最终姿态。假设本显示帧需要推进一个仿真步,并更新姿态,正确关系应当是:
1 | 推进到第 k+1 步 |
如果最终姿态生成被放在仿真之前,就可能变成:
1 | 按尚未推进的状态生成 P(k) |
每个数据单独看都合法,却属于不同的更新时刻。视觉上可能表现为动作跟随不一致,也可能被插值、低速运动或姿态降频暂时掩盖。具体症状取决于管线,不能仅凭顺序就断言某一种抖动必然发生。
但问题本身已经明确:消费者运行时,自己需要的本轮结果还没有产生。
这里的例子也不意味着“所有动画都应放在物理后”。驱动物理的根运动需要更早产生;消费物理结果的姿态修正需要更晚执行。应该决定位置的是数据依赖,而不是“动画”这个模块名称。
1.2 局部有序,不等于整帧有序
大型实时应用通常同时存在两套组织方式。
系统层按照职责定义阶段,例如输入、仿真、仿真后处理、呈现;宿主层按照平台组织回调,例如普通更新、物理更新、窗口绘制。
如果一个“仿真后处理”阶段被宿主放进了物理回调之前,即使它内部的任务排序完全正确,整帧仍然错误。
这就是帧序问题难以被局部测试发现的原因:局部测试回答的是“这个函数是否正确”,真正缺失的问题是“调用它时,前置条件是否已经在本轮成立”。
因此,接口不仅包含参数、返回值和对象生命周期,还包含时间语义:读取哪个版本的数据,依赖谁先完成,结果何时允许被消费。
二、相位首先是数据可见性的边界
设计帧相位时,不应先问“需要几个枚举”,而应先问:世界中的哪些变化,必须在什么边界之前完成?
对于一类包含网络、固定步仿真与呈现的交互系统,可以采用下面的示意划分:
1 | 输入收集 → 仿真准备 → 入站交接 → 固定步仿真 |
这不是适用于所有系统的标准答案,而是一种便于推导依赖的参考模型。
| 相位 | 需要回答的问题 |
|---|---|
| 输入收集 | 哪些输入属于本轮更新,哪些留到下一轮? |
| 仿真准备 | 仿真开始前,需要准备哪些意图、控制参数和上下文? |
| 入站交接 | 哪些已接纳的外部变化进入本轮世界,哪些消费与退役工作必须先完成? |
| 固定步仿真 | 如何按确定的步长推进世界状态? |
| 结果整理 | 哪些帧级工作需要消费本轮已经完成的仿真结果? |
| 出站发布 | 哪个版本的状态可以交给外部消费者? |
| 呈现 | 如何将准备好的数据转为图像、声音或界面反馈? |
阶段名表达的是逻辑边界,并不会自动产生内存屏障,也不意味着所有输入、网络、声音和界面代码都必须被机械地装进这些格子。
例如,决定输入是否被界面消费的逻辑通常要早于游戏输入处理;界面的最终绘制则发生得更晚。“都属于界面系统”不是把它们放在同一时刻的理由。
2.1 入站与出站,是方向相反的两条因果链
“网络更新”是一个过于宽泛的名字。
接收输入、应用远端变化、处理对象离开,以及发送本轮仿真结果,都可能被写进网络模块,却不能因此共享同一个执行时机。
入站路径关注的是:本轮仿真允许看见哪些外部变化。出站路径关注的是:已经完成的哪些结果可以被发布。
把入站工作统一移到帧尾,可能推迟变化进入仿真的时机;把出站工作统一移到仿真前,则可能发布尚未更新的结果。真正应该统一的是各自的语义,而不是按模块名称归并调用。
同样,网络数据“何时到达”和“何时成为本轮输入”也不是一回事。异步接收线程可以持续收包,但帧协调器仍应有明确的接纳边界。对于需要严格回放的系统,还必须明确事件属于哪个逻辑 tick,而不只是在哪个显示帧被碰巧读到。
2.2 释放资源,是执行顺序的一部分
某些清理操作看起来适合集中放到帧尾,实际上却有严格的消费窗口。
例如,一条“对象离开”事件携带了对象标识或访问句柄,展示适配器需要先处理附着关系,之后世界才能退役这个对象。一种可行的生命周期是:
1 | 读取并处理入站变化 |
过早释放可能使消费者失去有效对象;过晚释放则可能让应当退出本轮更新的对象继续参加后续处理。
这里存在一个前提:所有需要访问对象的消费者都已完成。如果消费者跨线程或跨帧存活,就不能只依靠“执行到了某个相位”决定释放,还需要引用持有、代际标识或其他生命周期协议。
相位设计的价值,在于把生产、消费、退役的先后关系公开出来,而不是把它们埋在一个名字含糊的
update 函数中。
三、用两层顺序代替一张巨大的执行表
给所有任务分配一个全局序号,然后永远按序执行,是一种容易理解的做法。但系统越复杂,这种表越容易把没有依赖的任务也锁死在一条长链上。
更容易演进的方式,是分开表达跨相位顺序与相位内依赖。
3.1 跨相位:保持少量、稳定的边界
相位层解决粗粒度因果关系,例如“消费本轮仿真结果的工作不能先于仿真”。
相位不宜细化成每个函数一个阶段。若两个任务的区别只是局部依赖,通常应在同一相位内建边,而不是继续扩大整个应用都必须理解的相位集合。
一个有用的判断标准是:新增这个相位,是否形成了多个系统共同依赖的数据交接边界?如果只是为了给一个函数找位置,局部任务依赖可能已经足够。
3.2 相位内:显式表达生产者与消费者
考虑一条示意仿真链:
1 | 准备本步控制输入 |
这里的每条边都有数据原因。后继任务必须等前驱完成,不是因为注册顺序恰好如此。
依赖图通常表达的是偏序:有些任务必须先后执行,有些任务之间没有这种要求。执行器可以通过拓扑排序得到一个合法的线性视图;对于同时就绪的任务,还可以使用稳定标识进行确定的排列,便于复现和比较。
但稳定排列不能替代依赖声明。如果 B 在业务上需要 A
的结果,就应显式记录 A → B,不能靠 A 的编号一直比 B
小。
资源冲突也需要区分方向。例如,两个任务都访问同一个音频设备,可能必须互斥;但“不能同时执行”并不能回答“谁应该先执行”。互斥约束和因果依赖解决的是不同问题。
3.3 裁剪功能时,要保留必要关系
不同宿主可能具有不同能力:有的没有声音,有的不需要界面,有的只运行仿真。
假设原本存在 A → B → C,某种配置关闭了可选任务 B。如果
A、C 仍然需要保持先后关系,裁剪后的图应保留
A → C,而不是把两者变成无关任务。
不过,拓扑上的连通还不够。如果 C 必须消费 B 产生的数据,那么关闭 B 还需要替代数据来源或降级行为;没有这些条件,就应拒绝该配置。依赖投影不能凭空补齐被删除任务的业务结果。
因此,跨宿主统一应当统一执行规则、依赖语义和配置校验,而不是要求所有平台拥有完全相同的任务清单。
四、先把真实回调放对位置
有了规范相位,并不意味着实际执行会自动遵守它。
宿主往往已有窗口事件、固定更新、绘制回调和设备上下文。改造时可以先把这些回调映射到同一条时间轴:
1 | 帧开始 |
这张映射成立的前提,是宿主实际保证这些回调的先后,而不只是名称看起来如此。如果宿主缺少明确的仿真后边界,就需要补充边界,或者调整协调层;不能把“仿真后处理”继续留在普通更新中,只在注释里声称它发生在后面。
这种分段协调不一定比单一大函数差。它可以保留渲染上下文、平台生命周期和事件预算,同时让相位顺序有统一解释。
对于没有窗口的仿真服务,协调器可以直接按逻辑 tick 驱动完整流程。它与图形客户端共享的是先后规则,不一定共享“帧”的时间单位,更不应未经评估就共用过载丢步策略。
五、用运行时游标阻止相位回退
修正调用点解决已有缺陷;运行时守护防止未来改动再次绕回旧路。
5.1 最小的不变量:一帧内只能向前走
设第 i 次进入的相位索引为 p(i),最基本的约束是:
1 | p(i+1) ≥ p(i) |
一个简化的相位守护器可以这样实现:
1 | 开始一帧: |
这里使用“大于等于”,是为了允许同一固定仿真相位重复执行。允许跳过阶段,则便于处理没有对应能力或没有实际工作的情况。
但这也明确了它的局限:单调性检查不证明阶段完整,不证明任务恰好执行一次,也不证明重复执行只发生在允许补步的阶段。需要这些约束时,应再增加相位次数、任务身份或节奏策略的检查。
5.2 游标属于推进中的世界,不属于共享规则
假设两个世界共享一份已构建的执行计划。世界甲走到呈现阶段,世界乙刚准备收集输入。
如果两者共用一个相位游标,乙的合法帧首就可能被判断为甲的非法回退。
所以,共享的执行计划保存规则;每个独立推进的世界保存自己的帧状态。相位状态应由单一协调者管理,它本身不是供多个线程并发争抢的锁。
重置的位置同样重要:只能在真实的逻辑帧首重置,不能在每次阶段调用前都重置。否则所有调用都变成“本帧第一次进入”,检查就失去意义。
5.3 拒绝必须早于副作用
守护器应在任务执行之前运行:
1 | 如果不能进入目标相位: |
如果先执行任务再记录“顺序错误”,世界可能已经受到修改,日志只能帮助事后诊断。
失败值还应沿调用链返回宿主。仅在调试构建中触发断言,不能替代正式构建中的拒绝路径。
发生回退后,可以让本帧保持失败状态,直到开始下一帧,避免后续受控调用继续推进一个已经失去合法顺序的执行过程。但这不是事务回滚:之前的修改不会自动撤销,绕开受控入口的代码也不会被自动停止。宿主仍需决定是取消后续工作、保留上一份可用呈现结果,还是终止当前运行流程。
六、外部适配器可以保留,执行规则不能悬空
并非所有工作都适合立刻交给统一执行器。
平台事件泵、具有消费预算的网络适配器、需要特定线程上下文的设备调用,可能暂时必须留在宿主侧。把它们强行包装成普通任务,可能改变预算、线程归属或对象生命周期。
一种渐进方式是:保留外部执行,但让它拥有受控身份。
1 | 宿主请求执行某个外部任务 |
这把“谁负责调用”与“调用必须遵守什么规则”分离开来。宿主可以保留调用权,但不应同时拥有随意改变生命周期的权利。
同一工作还应只有一个执行者:若由宿主执行,就不要再留一份会被调度器调用的实现;若已经迁入调度器,就应移除宿主的重复路径。
需要注意,身份校验无法证明回调内部行为符合声明,粗粒度相位游标也无法判断两个同相位外部任务是否交换了位置。对于这些情况,可以进一步验证相位内任务游标、依赖完成状态或运行轨迹。
因此,保留外部适配器是一种有边界的迁移策略,不是把任意图外代码都视为已经受到保护。
七、验证完整顺序,而不是分别证明每一段正确
运行时游标能够发现相位回退,但它不知道宿主是否遗漏了某个任务。为此,还需要一份完整的结构契约。
7.1 一个会让局部测试失效的反例
假设 A、B 属于仿真准备阶段,C 属于仿真阶段。规范顺序是:
1 | A → B → C |
宿主却执行成:
1 | C → A → B |
如果测试先按相位拆开再比较,就会得到:
1 | 仿真准备阶段:两边都是 A → B |
测试全部通过,但整帧明显倒序。
问题不是断言数量不够,而是测试预处理时丢掉了需要验证的信息:跨相位的相对位置。
还有一种类似的盲点:测试只从规范计划中挑出“宿主清单已经列出的任务”进行比较。宿主如果漏了一个任务,测试的预期集合也跟着漏掉它,错误便在两边同时消失。
7.2 从规范计划独立生成预期清单
正确做法是先根据当前功能配置生成执行计划,再从计划中提取参与帧执行的任务。只有初始化职责的任务不必进入这份清单;由外部执行的帧任务则不能因为没有普通任务回调而被排除。
随后,比较完整宿主清单与独立生成的预期清单:
1 | 验证宿主清单: |
这能同时发现未知任务、重复任务、相位倒置、相位内漂移和节点遗漏。
测试还应尽量使用生产环境实际采用的配置构建入口。单独维护一张只服务于测试的图,容易产生新的双重定义:测试图正确,真实执行计划已经改变。
7.3 静态清单不等于实际轨迹
清单通常描述的是结构性顺序:固定仿真链只列一次,但真实显示帧可能执行零次或多次;某些任务也可能被本帧的运行条件暂时关闭。
因此,静态清单证明的是“配置声明一致”,不是“每帧实际调用完全一致”。手写清单也不会自动感知宿主漏写了一次函数调用。
如果需要更强的运行时验证,可以记录如下信息:
1 | 世界标识、逻辑帧号、子步号、相位、任务标识、执行或跳过原因 |
再依据本帧启用条件和实际子步数,对照预期执行结构。区分世界、帧和子步非常重要,否则合法的多步仿真很容易被误判为重复调用。
严格比较线性清单还有一个取舍:两个无依赖任务交换位置,也可能被判为漂移。迁移初期,这种严格基线有利于暴露所有变动;进入并行阶段后,则应按依赖和批次验证,允许无依赖任务以不同顺序完成。
八、固定步让“有序执行”多了一个时间维度
显示帧与仿真步是两个不同的时钟。一个显示帧可能没有完整仿真步,也可能需要推进多个步;帧相位设计必须容纳这种差异。
固定步累加器与过载追赶问题,可以参考经典文章 Fix Your Timestep!。这里重点讨论它如何与帧序契约配合。
8.1 重复的是完整仿真链,不是其中某个任务
设固定步长为 h,上一帧余量为
a,本帧经过的时间为
Δt。不考虑上限和失败时,可以理解为:
1 | 可用时间 = a + Δt |
例如取 h ≈ 16.67 ms。从零余量开始,第一帧经过 10
ms,不执行仿真,保留余量;第二帧经过 28 ms,累计约 38
ms,可以推进两步,留下约 4.67 ms。
两步应当这样执行:
1 | 第一个子步:输入准备 → 动画推进 → 根运动 → 物理更新 |
而不是先连续推进两次动画,再连续执行两次物理。后者改变了子步内生产者与消费者之间的配对关系。
这里的“输入准备”是为当前子步整理已经接纳的控制数据,不意味着每个子步都重新采集一次平台输入。输入采集频率与仿真步频率仍是两回事。
这也是相位游标允许相等的原因:连续进入仿真阶段是在同一大阶段内继续推进;进入结果整理后再回到仿真,则意味着下游已经开始消费,上游却又发生了本轮更新。
8.2 明确区分正常零步与执行失败
固定步入口最好不要只返回一个数字。零步既可能表示时间不足,也可能表示输入无效或执行被拒绝。
一个更清晰的示意接口,可以同时返回状态、成功步数和丢弃时间:
1 | 推进固定步(帧时长、持久余量、步长、最大步数): |
这里的失败检查必须发生在成功计数和余量扣减之前。遭到拒绝的调用不能被算成已经完成的仿真步。
同时,这段伪代码并不提供事务性:如果某个任务执行到一半失败,系统仍需独立处理部分修改。扣减时间之前检查返回值,只能保证计数语义清楚,不能自动撤销世界状态。
固定步算法也应只有一个调度层负责。宿主如果先把帧时间拆成多次回调,内部又做一轮独立补步,就可能让补步上限失去“每显示帧最多多少步”的原始含义。外层通常只传递原始帧时长,内部统一决定仿真次数。
8.3 补步上限是策略,不是无代价的保护
上面的示例选择截断累计时间。若步长约为 16.67 ms、最多补四步,待推进的仿真时间上限约为 66.67 ms。这个数字表示模拟时间,不是 CPU 执行预算。
截断可以限制补步数量,但会丢弃一部分待模拟时间,过载时不再完整追上墙钟。
其他系统也可以选择保留积压、降低非必要工作量或采用受控追赶。关键是明确取舍,而不是用一句“防止长帧”掩盖时间策略。要求严格逻辑 tick 的仿真服务,不能直接照搬图形客户端的丢步方案。
即使顺序完全正确,显示时刻与最后一个完整仿真步之间仍可能存在余量。需要平滑呈现时,可以对已完成状态进行插值,但插值应有明确的状态版本,不能通过读取一个正在被仿真修改的世界来凑出中间结果。
还有一个结构约束:如果每个固定子步都需要经过“仿真、结果整理”两个阶段,那么下一子步会出现从后者回到前者的情况。此时应增加子步作用域,区分显示帧级相位与子步级相位,而不是反复重置整帧游标来绕过检查。
九、顺序正确,是安全并行的前提,不是并行已经完成
依赖图提供了并行的可能性,却不会自动提供正确的并行执行。
至少需要保留三层保证。
第一,依赖和资源声明必须真实。漏报组件写入、设备访问或共享缓存修改,可能把实际上有冲突的任务放进同一并行批次。调度器无法弥补错误的元数据。
第二,生产完成必须先于依赖它的消费开始。把任务提交给线程池,不等于任务已经完成。
1 | 对于每个可执行批次: |
如果协调器只是提交任务便继续进入下一相位,那么相位名称虽然递增,数据关系仍然可能倒置。使用栈上上下文的任务,还必须保证上下文存活到任务结束。
第三,跨线程可见性需要真实的同步语义。相位序号和普通游标不会自动建立 happens-before;相关内存访问仍需由正确的同步操作约束。关于冲突访问与数据竞争,可参见 C++ 工作草案:Data races。
另一方面,也不应要求所有并行任务按静态清单的顺序完成。无依赖任务可以先后不同,只要必要依赖、资源安全和结果语义保持成立。
所以,并行优化的方向不是“让所有任务同时运行”,也不是“把所有顺序彻底锁死”,而是保留必须存在的先后,释放不必存在的先后。
对于跨帧消费者,还要继续解决数据发布与生命周期问题。一份已经完成的呈现快照可以与下一轮仿真重叠使用;一个仍在被修改的世界容器,则不能仅凭“上一阶段结束了”就交给异步消费者长期持有。
十、把改造过程也做成可验证的工程流程
10.1 先记录实际执行,再设计规范顺序
开始改造时,首先梳理完整调用链,而不是只阅读模块声明。
记录实际相位、任务身份、输入数据版本、执行次数、线程归属和释放边界。尤其要寻找那些没有进入统一调度体系,却会修改世界、推进时钟或销毁对象的宿主调用。
只有这份事实清单完整,才能区分两类改动:
- 只是收敛入口,预期不改变行为;
- 真正改变执行时机,需要验证新行为是否符合目标。
两者的验收标准不同。不能一边移动消费者到新的时间位置,一边仍声称整个改造完全不改变行为。
随后再逐步建立规范相位、相位内依赖、外部入口和运行时守护。先保留串行执行作为易于理解的基线,在行为与依赖得到验证之后再开放并行,通常更容易定位问题。
10.2 用反例验证契约,用副作用验证拒绝
一套有针对性的验证矩阵,可以包括:
| 场景 | 应验证的结果 |
|---|---|
| 完整合法顺序 | 正常执行,结果与预期一致 |
| 跨相位倒置 | 非法入口被拒绝,目标任务没有执行 |
| 相位内顺序偏离规范 | 静态清单或相位内依赖检查失败 |
| 遗漏、重复或未知任务 | 清单校验明确报告错误 |
| 本帧没有完整仿真步 | 合法完成,不被误判为故障 |
| 一帧包含多个仿真步 | 每个子步的完整任务链保持一致 |
| 两个世界交错推进 | 帧状态与累计时间互不污染 |
| 外部任务进入错误阶段 | 回调计数与世界状态均不发生预期外变化 |
| 回退后继续调用 | 按定义保持失败,直到真正开始新帧 |
| 关闭一项可选能力 | 必要依赖和数据来源仍成立,否则明确拒绝配置 |
其中最重要的一条是:不要只验证返回了失败,还要验证失败调用没有发生副作用。例如记录计数器、检查关键组件是否变化,或者确认释放队列没有被消费。
正式构建也必须保留必要的拒绝逻辑。若测试依赖会被编译选项移除的断言,应确认测试配置仍真正执行了检查,不能把一个没有验证逻辑的成功退出当成通过。
10.3 分开报告正确性、延迟与吞吐
帧序改造可能消除读取旧状态的问题,但不一定缩短执行时间;增加检查甚至可能带来少量额外成本。这并不削弱正确性收益,只说明不同收益需要不同证据。
正确性可以通过固定输入回放、关键状态对比、顺序反例和生命周期测试验证。端到端延迟应跟踪输入接纳、仿真消费、结果发布和呈现的时间点;吞吐与稳定性则需要测量 CPU 帧耗时、补步分布、长尾延迟以及目标设备表现。
如果没有这些测量,就应将结论限定为“执行语义得到统一和保护”,而不是宣称帧率提高或卡顿消失。
测试通过也应标明具体配置和覆盖范围。桌面编译成功不能替代设备验收,短时冒烟不能替代长期运行,而某个顺序用例通过也不能证明所有并发访问都安全。
结语:让时间成为接口的一部分
帧相位顺序优化,表面上是在调整函数位置,实质上是在重新定义系统之间的时间契约。
相位描述数据交接边界,依赖图描述必要因果关系,运行时守护拒绝非法推进,结构契约与轨迹验证则让遗漏和漂移能够被发现。
它不要求所有平台共用一个巨大的主循环,也不要求所有任务永远串行。它要求的是:无论工作由谁执行,都能说明自己为什么可以在这个时刻执行。
一帧的正确性,不只是所有必要工作都运行过,还包括每个消费者都在依赖成立之后,读到了它被允许使用的数据。
当“何时执行”也成为可以检查的接口,顺序就不再依赖记忆。后续的功能扩展、平台适配与并行优化,才有了一条可靠的共同基线。