刚开始翻 Godot 源码的时候,我最想知道的一件事是:_process(delta) 和 _physics_process(delta) 到底是谁在调。文档只告诉你前者跟渲染帧走、后者固定频率,但"固定频率"这四个字背后是一套很具体的累加器逻辑。这篇笔记基于 Godot 4.2.2 的源码(main/main.cpp、scene/main/scene_tree.cpp),把一帧的完整路径走一遍,顺便把"为什么物理非要固定步长"这个更本质的问题说清楚。
入口:平台层和 Main 的分工
Godot 的 main() 不在引擎核心,而在 platform/ 下每个平台自己的入口里。以 Linux 为例,platform/linuxbsd/godot_linuxbsd.cpp 构造出 OS_LinuxBSD,然后交给 main/main.cpp 的 Main::start()。Main::start() 做完初始化后返回一个 MainLoop 实例——编辑器模式下是 EditorNode,跑游戏时就是 SceneTree。
真正的循环在各平台 OS 类的 run() 方法里,形状都一样(伪代码):
void OS_LinuxBSD::run() {
while (!force_quit) {
if (Main::iteration()) {
break; // Main::iteration 返回 true 表示请求退出
}
}
}
也就是说,一帧的所有事情都压缩在 Main::iteration() 的一次调用里。这个函数在 main/main.cpp 里有两百来行,是整个引擎的节拍器。这个结构本身也是一个设计决定:主循环的所有权留在平台层,引擎核心只暴露"走一步"的接口。好处是平台可以按自己的节奏驱动迭代——编辑器可以抢插输入事件,Android 可以把循环挂到 Choreographer 上,headless 服务器可以甩开显示器全速跑。Main::iteration() 是"引擎"和"宿主"之间唯一的握手点。
Main::iteration():累加器驱动物理帧
这个函数里真正要紧的是中间那段物理帧循环。我按 4.2.2 的逻辑整理成骨架(删掉了 movie writer、帧率统计这些旁支):
bool Main::iteration() {
uint64_t ticks = OS::get_singleton()->get_ticks_usec();
uint64_t frame_ticks = ticks - last_ticks; // 距上次 iteration 过了多少微秒
last_ticks = ticks;
int physics_fps = Engine::get_singleton()->get_physics_ticks_per_second();
float frame_slice = 1.0 / physics_fps; // 每个物理帧的步长,默认 1/60 秒
time_accum += 实际流逝时间(经 time_scale 缩放);
// 物理帧:攒够了几个就跑几个,最多 max_physics_steps 个
for (int iters = 0; iters < max_physics_steps; iters++) {
if (time_accum < frame_slice) {
break;
}
Engine::get_singleton()->_in_physics = true;
PhysicsServer2D::get_singleton()->flush_queries();
PhysicsServer3D::get_singleton()->flush_queries();
OS::get_singleton()->get_main_loop()->physics_process(frame_slice * time_scale);
time_accum -= frame_slice;
Engine::get_singleton()->_in_physics = false;
}
// 渲染帧:一帧只调一次,delta 是真实流逝时间
bool exit = OS::get_singleton()->get_main_loop()->process(process_delta * time_scale);
RenderingServer::get_singleton()->sync();
RenderingServer::get_singleton()->draw(true, process_delta * time_scale);
return exit;
}
这段骨架回答了几个常见疑问:
为什么物理帧会"补跑"? time_accum 是累加器。渲染帧掉帧时(比如一帧花了 40ms),累积时间超过两个 frame_slice,循环就连调两次 physics_process。机器很快时则反过来,好几帧渲染才攒够一个物理帧。所谓"固定 60Hz 物理",不是说每秒恰好 60 次,而是靠补跑和跳过让长期平均逼近 60Hz。
连跑 8 次物理帧是怎么回事? 因为有 max_physics_steps(默认 8)兜底,超过就直接丢弃累积时间,避免"掉帧→补跑→更掉帧"的死亡螺旋。ProjectSettings 里的 physics/common/max_physics_steps_per_frame 就是它。
Engine::_in_physics 标志有什么用? 你在 _physics_process 期间它是 true,很多引擎内部判断靠它。比如在错误时机调 intersect_ray 时看到的报错,源头就在这套状态判断。
累加器的工作方式画出来比文字直观:
为什么物理步长必须固定:积分稳定性的账
"固定 60Hz"不是洁癖,是数值积分的硬要求。物理引擎每步做的是显式欧拉(或半隐式欧拉)积分:
$$ v_{t+1} = v_t + a \cdot \Delta t, \qquad x_{t+1} = x_t + v_{t+1} \cdot \Delta t $$
显式积分的误差是 $O(\Delta t)$ 级别累积的,更要命的是误差依赖 $\Delta t$ 的具体取值。同一个抛物线,$\Delta t = 16.7$ms 积出来的落点和 $\Delta t = 33$ms 积出来的不一样。如果物理步长跟着渲染帧率飘,就会出现"144Hz 显示器上跳跃高度和 60Hz 上不同"这种没法向玩家解释的 bug。固定 $\Delta t$ 之后,积分序列变成确定性的:同样的初始条件跑出同样的轨迹,这也是回放系统和帧同步游戏的前提。
约束求解器同样吃固定步长。碰撞穿透的修正量、关节约束的迭代收敛,都是按"每步推进固定时间"调出来的参数;步长翻倍,同样迭代次数下残余误差跟着涨。所以引擎宁可在一帧里补跑两次完整物理,也不愿意跑一次双倍步长的物理——前者的结果和标准 60Hz 模拟完全一致,后者是另一套结果。
死亡螺旋的数学形状也值得一看:设一帧物理计算耗时 $C$,帧预算 $B$。当 $C$ 接近 $B$ 时,补跑让下一帧要还的债变多,物理耗时进一步上升——正反馈。max_physics_steps 的作用是在债务超过 $8 \times \text{frame\_slice}$ 时直接核销,用"游戏时间变慢"换"不卡死"。时间变慢的代价公式很直白:真实时间过了 $T$,游戏内模拟时间最多前进 $8 \times \text{frame\_slice}$,慢动作比例是两者之比。
下到 SceneTree:process() 里的固定动作序列
Main::iteration() 只认 MainLoop 接口。SceneTree 继承 MainLoop,它的 process() 才是把事件分发给节点树的地方。下面是 4.2.2 scene/main/scene_tree.cpp 里的真实代码(删掉了编辑器专属分支):
bool SceneTree::process(double p_time) {
root_lock++;
if (MainLoop::process(p_time)) {
_quit = true;
}
process_time = p_time;
if (multiplayer_poll) {
multiplayer->poll(); // 多人同步的 RPC 在场景逻辑之前到达
}
emit_signal(SNAME("process_frame")); // Timer、Tween 挂在这个信号上
MessageQueue::get_singleton()->flush(); // call_deferred 队列在这里兑现
flush_transform_notifications();
_process(false); // 逐节点发 NOTIFICATION_PROCESS,下面细说
_flush_ugc();
MessageQueue::get_singleton()->flush();
flush_transform_notifications();
_flush_delete_queue(); // queue_free 的节点在这里真正析构
process_timers(p_time, false); // 场景树 Timer
process_tweens(p_time, false); // 场景树 Tween
_call_idle_callbacks();
return _quit;
}
顺序对写代码有直接影响:
- 网络 poll 最先,多人同步数据在场景逻辑之前到达;
process_frame信号其次;- 消息队列 flush——上一帧
call_deferred排进来的调用此刻执行,所以 deferred 调用永远晚于发起帧的_process; _process(false)驱动整棵树的_process;queue_free()的节点不是立刻释放,而是进删除队列,在帧尾_flush_delete_queue()统一析构——这就是为什么 free 掉的节点当帧后面还能被访问到(但已经半死,别这么做)。
这个顺序不是随便排的,它遵循一条不变量:帧的开头收敛外部输入,帧的中间跑游戏逻辑,帧的结尾清理副作用。网络数据先进来,逻辑才能用到最新状态;删除延后到帧尾,逻辑中段的节点引用才不会半路失效。flush_transform_notifications() 出现两次也是同一个思路——逻辑跑完后立即把变换变更通知发掉,让依赖全局变换的代码(比如相机跟随)在帧内拿到的是结算后的值。
physics_process() 基本对称,多了一行 call_group(SNAME("_picking_viewports"), SNAME("_process_picking"))——输入拾取(鼠标悬停检测)是在物理帧做的。
_process(false) 内部:不是递归整棵树
我原本以为是 root->propagate_notification(NOTIFICATION_PROCESS) 那种递归通知(3.x 确实是这么做的),4.x 改成了平铺列表 + 优先级排序。SceneTree 内部维护 process_groups,每个开启处理的节点通过 set_process(true) 把自己注册进所在 process group 的 nodes 数组。每帧 _process_group() 遍历这个数组:
// scene/main/scene_tree.cpp, 4.2.2
void SceneTree::_process_group(ProcessGroup *p_group, bool p_physics) {
p_group->call_queue.flush();
Vector<Node *> &nodes = p_physics ? p_group->physics_nodes : p_group->nodes;
if (nodes.is_empty()) {
return;
}
// process_priority 有变动才重排,增量排序
if (p_group->node_order_dirty) {
nodes.sort_custom<Node::ComparatorWithPriority>();
p_group->node_order_dirty = false;
}
Vector<Node *> nodes_copy = nodes; // 拷贝一份,防止遍历中增删节点崩掉
for (uint32_t i = 0; i < nodes_copy.size(); i++) {
Node *n = nodes_copy[i];
if (!n->can_process() || !n->is_inside_tree()) {
continue; // 暂停模式/process_mode 在这里过滤
}
if (n->is_processing()) {
n->notification(Node::NOTIFICATION_PROCESS);
}
}
}
这个改动很实在:3.x 的递归通知每帧要访问整棵树的所有节点(包括没开 process 的),缓存不友好;4.x 只遍历"开了 process 的节点"组成的紧凑数组,还按 process_priority 排好序。遍历前拷贝一份数组,是因为 _process 里随时可以 queue_free 或 set_process(false) 把自己从原数组里摘掉。另外注意 can_process() 检查——暂停游戏时哪些节点还能跑,就是在这一行过滤的。
从硬件视角看这次改动的收益:递归遍历树是指针追逐(pointer chasing),每个节点的内存地址不可预测,CPU 预取器帮不上忙,每次访问都可能是一次缓存未命中(几十纳秒);平铺数组是连续内存,顺序扫描触发预取,单个节点的访问成本差一个数量级。场景里一千个节点、实际开 process 的只有一百个时,3.x 每帧为九百个不开 process 的节点白付缓存未命中,4.x 只扫描一百个紧凑指针。节点越多、开 process 的比例越低,收益越大。node_order_dirty 的增量排序同理——priority 不常变,排序就不每帧做,把 $O(n \log n)$ 摊还到接近零。
两个实测推论
_process 和 _physics_process 的 delta 性质完全不同。 process() 拿到的是真实流逝时间乘 time_scale;physics_process() 拿到的永远是 frame_slice * time_scale,一个常量。所以在 _physics_process 里用 delta 积分是安全的;在 _process 里做累加计时要小心 time_scale = 0(暂停菜单)时 delta 也归零,计时器会跟着停摆。
call_deferred 是"本帧晚点",不是"下一帧"。 消息队列一帧 flush 不止一次(process 和 physics_process 里各 flush 一次,_process_group 前后也有 flush),物理帧里 deferred 的调用可能在同一渲染帧后半段就执行了。指望它"严格下一帧执行"的代码,在高帧率或开物理插值时会出时序问题——我踩过。
一帧的路径到此走完:平台 run() 循环 → Main::iteration() 的累加器 → SceneTree::process()/physics_process() → 平铺节点数组的通知分发。后面写渲染和物理的文章时会发现,它们都是挂在同一个节拍器下的分支。
本文代码引自 Godot 4.2.2-stable(main/main.cpp、scene/main/scene_tree.cpp),Main::iteration() 部分为按源码整理的逻辑骨架,其余为原文节选(有删减)。