技术博客

物理帧率与渲染帧率:physics_ticks_per_second 的同步语义

第 72 / 149 篇 · 2025-11-22 · godot · 物理引擎帧同步源码分析

上一篇讲了物理插值怎么抹平物理与渲染之间的频率差,但插值只是显示层。真正把两种帧率对齐的是引擎主循环里的固定步长累加器,它决定每个渲染帧执行几次物理步进、多出来的时间怎么办。这篇拆开 Main::iteration 到物理步进的这段逻辑,把 physics_ticks_per_second 这个设置的真实语义讲透。

累加器模型

引擎用的是一个教科书式的 fixed-timestep accumulator(main/main.cpp 的 Main::iteration → SceneTree::physics_process 一带)。核心数据结构只有两个:剩余时间累加器 $A$(accumulator),固定步长 $\Delta t = 1 / f_{\text{physics}}$。

每个渲染帧,逻辑是:

A += 本帧实际耗时 d(有上限,见下文)
while A >= Δt:
    执行一次物理步进
    A -= Δt
渲染用的插值系数 α = A / Δt

渲染帧间隔 $d$ 是浮动的(16.6ms、7ms、甚至 100ms 卡顿),物理步长 $\Delta t$ 恒定。$A$ 的作用是把不规则的渲染时间流切成规则的物理步:物理世界永远以 $\Delta t$ 的粒度前进,与渲染帧率解耦。

代入数字:物理 60Hz($\Delta t = 16.67$ms),渲染 144Hz($d \approx 6.94$ms)。每个渲染帧 $A$ 增加 6.94ms,凑够 16.67ms 才走一步。运行下来平均每 2.4 个渲染帧走一次物理步——不是均匀的"2 步、1 步"交替,而是 $A$ 的滚动决定哪帧走哪帧不走:有时连续两个渲染帧各走一步($A$ 超阈值两次),有时三个渲染帧才走一步。这就是上一篇插值系数 $\alpha$ 不均匀跳动的来源,也是插值必须存在的原因。

三档配置的时序行为

配置 行为 适用
物理 60 / 渲染 60 $A$ 每帧恰好 $\approx \Delta t$,每渲染帧一步,$\alpha \approx 0$ 主机、锁帧平台游戏
物理 60 / 渲染 144 平均 2.4 渲染帧一步,$\alpha$ 在 0~0.6 间滚动 PC 高刷屏,配插值
物理 120 / 渲染 144 平均 1.2 渲染帧一步,偶发单帧双步 手感敏感游戏

中间那档有个反直觉的点:提高物理帧率到 120 不一定比 60 更"准"。120Hz 下 $\Delta t$ 减半,离散化误差(第 6 篇 CCD 那节的 $v\Delta t$)同步减半,但 solver 每步的收敛误差近似恒定,总迭代次数翻倍后累积的数值阻尼反而可能让堆叠更"肉"。实测经验:堆叠稳定性先看迭代数,再看帧率。

追帧上限与螺旋死亡

累加器有个必须有的保护:如果一帧卡了 200ms,$A$ = 200ms,难道连走 12 步物理吗?那样物理耗时又拖长本帧,下一帧 $A$ 更大——死亡螺旋(spiral of death)。引擎的做法是给单渲染帧的物理步数设上限(physics/common/max_physics_steps_per_frame,默认 8),并且给单帧累加时间设上限(超过上限的 $A$ 直接丢弃)。60Hz 物理下 8 步上限意味着:帧耗时超过 133ms 后,物理世界会比现实时间慢——掉帧时物理"降速"而不是"追帧",这是正确取舍:追帧会放大卡顿,降速只损失短暂的时间精度。

相关的还有 physics/common/physics_jitter_fix,一个 0~1 的参数,控制"渲染时刻对齐物理步"的程度:为 0 时完全用累加器的自然相位(配合插值最平滑),为 1 时强制对齐(消除视觉抖动但引入微卡顿)。一般用法是:开了物理插值就把它调 0,不开就保持默认。

谁决定"这一帧走几步":从 main 到 step

调用链上值得记的位置:Main::iteration() 按平台帧率回调被触发,内部先算 $d$,然后 SceneTree::physics_process() 检查累加器,循环调 PhysicsServer3D::step($\Delta t$),每步之后 flush 延迟调用队列(第 15 篇消息队列的时机就在这)。物理步数由累加器决定,不是脚本能干预的——所以"这一帧物理跑了几次"是从时间推导的,帧率不稳的游戏逻辑别假设"每渲染帧一次物理"。

一个实践结论:网络同步游戏(第 19 篇确定性的前提)依赖固定 $\Delta t$ 做锁步,physics_ticks_per_second 就是逻辑帧率的合同,改它等于改协议。渲染帧率随便浮动无所谓,物理帧率必须全端一致。

累加器:144Hz 渲染时间流被切成 60Hz 物理步 渲染帧 d≈6.94ms 物理步 Δt=16.67ms step 1 step 2 step 3 A(累加器,ms): 帧1: A=6.9,不足,物理不走(α=0.42) 帧2: A=13.9,不足,物理不走(α=0.83) 帧3: A=20.8 ≥16.7 → 走 1 步,A=4.2(α=0.25) 帧4: A=11.1,不足(α=0.67) 帧5: A=18.1 → 走 1 步,A=1.4 ……

本文基于 Godot 4.x main/main.cpp 的 Main::iteration 累加器逻辑与 SceneTree::physics_process,参数为 physics/common/physics_ticks_per_second、max_physics_steps_per_frame、physics_jitter_fix。