get_tree().paused = true 一行代码暂停整个游戏,是 Godot 新手最先学会的"魔法"之一。但做暂停菜单时马上会遇到反直觉的事:RigidBody 还在往下掉,Tween 停了,_process 停了而某个" always "节点还在跑。这些行为的差异全部来自两个设计的叠加:有效 process_mode 的向上解析,以及"闸门只拦节点回调、不拦服务器"。这篇把 SceneTree::paused 到单个节点的判定链走完。
闸门:SceneTree 的一道布尔
SceneTree 只有一个 paused 标志(scene_tree.h 的 data.paused)。它自己什么都不停——每帧的 _process_group 循环照常遍历处理组,真正的过滤在每个节点被调用之前:
// scene/main/scene_tree.cpp 的节点遍历处,示意
if (n->can_process_notification(NOTIFICATION_PROCESS)) {
n->notification(NOTIFICATION_PROCESS);
}
can_process_notification 最终落到 Node::can_process()(node.cpp),它做两件事:看树有没有 paused,看自己的有效 process_mode 允不允许在 paused 下运行。非暂停时一切照旧,所以这层检查平时零开销感知。
有效 mode:沿 owner 链向上解析
process_mode 枚举(node.h):
enum ProcessMode {
PROCESS_MODE_INHERIT = 0, // 继承父节点
PROCESS_MODE_PAUSABLE = 1, // 暂停时停
PROCESS_MODE_WHEN_PAUSED = 2, // 暂停时才跑
PROCESS_MODE_ALWAYS = 3, // 永远跑
PROCESS_MODE_DISABLED = 4, // 永远不跑
};
INHERIT 的存在让"有效 mode"成了一个需要向上解析的量。Node::can_process 的实现(有删减):
bool Node::can_process() const {
ProcessMode mode = data.process_mode;
// 沿父链向上找第一个非 INHERIT
const Node *node = data.parent;
while (node && mode == PROCESS_MODE_INHERIT) {
mode = node->data.process_mode;
node = node->data.parent;
}
if (mode == PROCESS_MODE_INHERIT) {
mode = PROCESS_MODE_PAUSABLE; // 到根还没定,按默认可暂停
}
switch (mode) {
case PROCESS_MODE_DISABLED: return false;
case PROCESS_MODE_ALWAYS: return true;
case PROCESS_MODE_PAUSABLE: return !data.tree->is_paused();
case PROCESS_MODE_WHEN_PAUSED: return data.tree->is_paused();
}
}
三个值得注意的细节。第一,解析是逐次向上的,没有缓存——树很深、mode 链很长时,每次 can_process 都是一次父链行走。好在暂停状态下处理组本身被跳过得多,成本不显眼。第二,解析沿父链走,不是 owner 链——别把这两个链搞混,owner 管持久化和 unique name,父链管处理传播。第三,根节点都没有显式设过的话,默认语义是"全体可暂停",这也是 paused = true 能一键停全场的原因。
WHEN_PAUSED 的用途比名字窄:它不是"暂停时自动多干活",只是"暂停时不停"。暂停菜单的根节点设 ALWAYS(或 WHEN_PAUSED),菜单里的动画、输入照常;游戏世界保持默认 INHERIT 全停。
两道边界:物理不停,动画默认停
物理服务器不进这个闸门。paused 停掉的是节点的 _physics_process 回调和物理相关的处理,但 PhysicsServer3D::step() 在 Main::iteration 的物理循环里照常执行——RigidBody 没有 _physics_process 也照样积分下落。想真正冻结物理世界,要么给每个 RigidBody 设 freeze,要么 physics server 的 set_active(false)。这是"暂停了但石头还在滚"的唯一原因,别在节点回调里找。
反过来动画默认跟着节点走:AnimationPlayer 的 process_callback 默认是 ANIMATION_PROCESS_IDLE(挂在 _process 链上),节点被暂停它就停。想暂停菜单里有动画,把菜单里 AnimationPlayer 的 mode 设 ALWAYS,或者它的 process_callback 改 ANIMATION_PROCESS_MANUAL 自己推。Tween 同理——挂在默认处理链上,节点停它就停。
输入也过闸门:_input、_unhandled_input 都受 can_process 约束,暂停时默认全停。暂停菜单想接收按键,菜单根节点设 ALWAYS 即可,ui_cancel 切回。
状态切换的通知
有效 pause 状态变化时,引擎沿子树发通知:NOTIFICATION_PAUSED(14)/ NOTIFICATION_UNPAUSED(15),在 set_process_mode 和 SceneTree::set_pause 的传播路径上触发。这给了节点一个同步钩子的机会:游戏里我常用来在 _notification 里拦这两个,把正在播的环境音静音、把 shader 时间暂停,比在每个 _process 里判断 get_tree().paused 干净。
还有一个和 DISABLED 配套的:NOTIFICATION_DISABLED(28)/ ENABLED(29),DISABLED 模式下的节点收到 28,恢复时收到 29。语义和 PAUSED 平行,但触发条件不同(它看的是 can_process 的永久禁用位),两者都会让你的 _process 停下来,排查"节点为什么不跑"时要分开验证。
决策图
最后把一个常见架构建议落到实处:暂停菜单不要塞进游戏场景的节点里再设 mode,直接挂在 autoload 或场景的固定 ALWAYS 分支,切换场景时它跟着树走,逻辑和归属都简单。paused 是全局闸,菜单属于"不管世界停不停都要活着"的那一层,从结构上就别让它和游戏世界共享暂停语义。
本文代码引自 Godot 4.2.2-stable 的 scene/main/node.h、scene/main/node.cpp、scene/main/scene_tree.cpp,均有删减。