技术博客

SceneTree 的 MainLoop 循环:一帧到底发生了什么

第 22 / 149 篇 · 2025-06-30 · godot · 场景树源码分析主循环

刚开始翻 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 时看到的报错,源头就在这套状态判断。

累加器的工作方式画出来比文字直观:

累加器把不均匀的渲染帧兑换成固定步长的物理帧(physics_fps = 60) 渲染帧(真实流逝时间,不均匀) 帧1: 30ms 帧2: 40ms(掉帧) 帧3: 15ms 帧4: 25ms 物理帧(frame_slice = 16.7ms,攒够一个跑一个) P1 P2 P3(补跑) P4(补跑) P5 帧3 只有 15ms:攒不够一个 slice,本帧不跑物理 长期平均恒等式 物理帧总数 ≈ 总流逝时间 ÷ frame_slice —— 与渲染帧率无关,掉帧靠补跑追回,上限 max_physics_steps = 8

为什么物理步长必须固定:积分稳定性的账

"固定 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;
}

顺序对写代码有直接影响:

  1. 网络 poll 最先,多人同步数据在场景逻辑之前到达;
  2. process_frame 信号其次;
  3. 消息队列 flush——上一帧 call_deferred 排进来的调用此刻执行,所以 deferred 调用永远晚于发起帧的 _process;
  4. _process(false) 驱动整棵树的 _process;
  5. 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() 部分为按源码整理的逻辑骨架,其余为原文节选(有删减)。