技术博客

_process 与 _physics_process 的时序关系:同一帧里谁先谁后

第 26 / 149 篇 · 2025-08-28 · godot · 场景树游戏循环源码分析

"把移动写进 _physics_process 还是 _process"是 Godot 社区月经问题。教科书答案是"物理相关进 physics",但要知道为什么,得先看一帧的物理迭代和 process 回调到底怎么排。翻 4.2 的 main/main.cpp 和 scene/main/scene_tree.cpp,顺序比大多数人想的要严格。

Main::iteration:物理在前,渲染在后

主循环每跑一圈,Main::iteration() 做的事大致是(有删减):

// main/main.cpp
bool Main::iteration() {
    // 1. 推进物理累加器,补跑欠下的物理帧
    double physics_step = 1.0 / ProjectSettings::get_singleton()->get_setting(
            "physics/common/physics_ticks_per_second");
    physics_time += delta;
    int physics_steps = 0;
    while (physics_time > physics_step) {
        PhysicsServer3D::get_singleton()->flush_queries();
        scene_tree->physics_iterate(physics_step);   // 所有 _physics_process
        PhysicsServer3D::get_singleton()->step(physics_step);
        physics_time -= physics_step;
        physics_steps++;
    }

    // 2. 普通帧处理:所有 _process、渲染
    scene_tree->process(delta);

    // 3. 交换缓冲、限制帧率
    RenderingServer::get_singleton()->draw(true, delta);
    ...
}

关键点有三个。第一,物理循环在 process() 之前,且按固定步长 $h = 1/f_{phys}$ 补跑——物理帧永不迟到,最多欠账。第二,物理帧数由累加器决定,不是每帧固定一次:$n = \lfloor (t_{acc} + \Delta t) / h \rfloor$,跑完剩下的零头留着下一帧。第三,物理迭代之后 process(delta) 拿到的 delta 是整个渲染帧的间隔,包含刚才跑掉的物理时间。

由此得到一个重要推论:同一渲染帧里,_physics_process 可能跑 0 次、1 次或多次,_process 恰好跑 1 次(0 次物理帧发生在帧率高于物理率的屏幕上,比如 144Hz 屏配 60Hz 物理时约 60% 的渲染帧没有物理步)。

144/60 的步数抖动

把 $f_{disp} = 144$、$f_{phys} = 60$ 代入:每渲染帧平均应推进 $144/60 = 2.4$ 个物理步。步数只能是整数,累加器把这个分数摊成 2、3、2、3… 的抖动序列。设某帧的累加器余额为 $r \in [0, h)$,本帧推进 $\Delta t = 1/144$ 后:

$$ n = \left\lfloor \frac{r + 1/144}{1/60} \right\rfloor, \qquad r' = r + \frac{1}{144} - n\cdot\frac{1}{60} $$

从 $r_0 = 0$ 起算,$n$ 序列是 2, 3, 2, 3, 3, 2, 3, 2, 3, 3, …,每 12 个渲染帧恰好跑 29 个物理步?不对——12 渲染帧时长 $12/144 = 1/12$ 秒,物理帧 $60/12 = 5$ 步,序列 2,3,2,3 重复(2+3+2+3=10?)。算清楚:5 帧物理摊到 12 帧渲染,平均每渲染帧 5/12 ≈ 0.417?那是 144Hz 屏跑 60Hz 物理反了——每渲染帧 2.4 步没错,12 渲染帧 = 2.4×12 = 28.8 ≈ 29 物理步,序列以 5 个渲染帧为周期:2, 3, 2, 2, 3(和为 12?不对)。重新算:周期是 5 个渲染帧(5/144 秒 = 1/28.8 秒,物理 2 步)。序列 2,2,2,2,3? 5 帧渲染推进 5/144 秒,物理步数总和应为 ⌊…⌋ 之和 = 2。哦对,5 渲染帧只摊 2 物理步:序列是 0,1,0,1,0 或 1,0,1,0,0 之类?$5/144 \approx 0.0347$ 秒,$0.0347 / (1/60) = 2.083$,所以 5 渲染帧跑 2 物理步,多数渲染帧只有 0 或 1 步!

我之前算错了:144Hz 屏每渲染帧平均 2.4 物理步?$144/60=2.4$ 是"每个物理帧对应 2.4 个渲染帧",搞反了。正确关系:一个物理帧 1/60 秒 ≈ 2.4 个渲染帧,所以每个渲染帧平均推进 $1/2.4 = 0.417$ 个物理步,即大多数渲染帧跑 0 个物理步,少数跑 1 个。这才是高刷屏的真相。

修正上面公式:每渲染帧 $\Delta t = 1/144 \approx 6.94$ms,物理步 $h = 16.67$ms,余额每帧涨 6.94ms,涨过 16.67ms 才触发一步——所以是连续 2 帧 0 步、1 帧 1 步的 5 帧周期模式:0,0,1,0,1(和为 2 步/5 帧,2/5 帧 = 0.417 步/帧,自洽)。60Hz 屏则是恒定的 1,1,1。

这不是瑕疵,是固定步长的定义性特征:物理帧的离散网格和渲染帧的离散网格互不对齐,抖动是数学必然,不是实现 bug。

插值:把物理帧平滑回渲染帧

4.3 内置的 physics interpolation 解决的就是这个网格错位。原理一句话:物理在固定网格上积分,渲染不直接用物理状态,而是用相邻两个物理状态的插值。设上一物理帧状态 $x_0$、当前 $x_1$,累加器余额占步长的比例

$$ \alpha = \frac{r}{h} \in [0, 1) $$

渲染帧画出来的位置是 $\hat{x} = x_0 + \alpha (x_1 - x_0)$。$\alpha$ 从 0 爬到接近 1 的 2~3 个渲染帧里,画面上物体匀速移动,物理侧始终踩着固定网格积分——抖动被搬进连续域消化掉了。Godot 的实现(scene/main/scene_tree.cpp 的 interpolated_transform 相关、servers 侧的 transform interpolation buffer)对 Node3D 缓存前后两帧的 global transform,在渲染前按 $\alpha$ 混合。

代价要说清楚:插值让渲染延迟不到一个物理步(平均 $h/2 \approx 8$ms),对操作手感敏感的游戏(音游、格斗)这是能感觉到的延迟;而且插值的是显示状态,碰撞和判定用的还是未插值的物理状态,两者差最多一个物理步,做"看着碰到了却没判定"的调试时要知道这 16ms 的存在。

delta 的两个含义

同一帧里两个回调拿到的 delta 完全不同,这是"移动代码写错回调"出 bug 的根源:

  • _physics_process(delta):$delta \equiv h = 1/60$ 秒(默认),跨帧恒定。积分 $v \cdot h$ 累一百次和累一次结果一致,行为可复现,这是确定性模拟的前提。
  • _process(delta):$delta$ 是真实帧间隔,抖动大(高刷屏 6.9ms、掉帧时可能 50ms)。这里积分 $v \cdot \Delta t$ 每次步长不同,结果不可复现,而且走 _process 的移动绕过了物理服务器——move_and_slide 只在物理帧里和服务器同步。

结论也就顺理成章:凡是"下一帧位置 = 这帧位置 + 速度×时间"的积分,放进 _physics_process;凡是"跟着帧率走的视觉逻辑"(UI 动画、相机跟随的软阻尼、粒子参数)放 _process。相机跟随放 _process 还有个隐藏收益:它跟着渲染帧走,画面和相机天然对齐,不会和物理步数的抖动共振出果冻感。

时序图

渲染帧 144Hz,间隔约 6.9ms 物理帧 60Hz,间隔约 16.7ms;多数渲染帧不触发物理步 物理步(橙)跑在 _process(蓝)之前,一帧可 0~2 步;渲染用 alpha = 余额/步长 插值

一个容易忽略的顺序细节:物理循环内部还有次序——flush_queries(把上一帧脚本改的空间查询提交)→ _physics_process 回调 → PhysicsServer::step(真正的碰撞求解)。所以 _physics_process 里改的 transform 要下一个物理步才进碰撞检测,指望"本帧改位置本帧出碰撞结果"会落空。

最后给个省电场景的反直觉建议:锁 30fps 渲染、60Hz 物理的游戏,每渲染帧要补 2 个物理步,逻辑帧耗时翻倍;这时不如把 physics_ticks_per_second 降到 30 配合插值,物理服务器的开销直接减半,画面靠插值补——物理率的选择是性能和手感的权衡,不是越高越好。

本文代码引自 Godot 4.2.2-stable 的 main/main.cpp、scene/main/scene_tree.cpp;插值部分参考 4.3 起内置的 physics interpolation 实现,均有删减。