get_tree().change_scene_to_file("res://level2.tscn") 是关卡切换的默认答案,但它的内部行为是同步拆旧建新:旧场景立刻从树上摘除并释放,新场景立刻实例化并挂树。这个"立刻"在大多数情况下无所谓,在物理回调、信号回调里就是事故——issue #85852 里那一串 This function can't be used during the in/out signal 和 Condition "locked" is true 就是这么来的。这篇按 scene_tree.cpp 的代码把切换过程拆开,并给出稳态写法。
current_scene:一个指针,不是树的特殊状态
先建立心智模型:SceneTree 里 current_scene 只是一个 Node *(scene_tree.h),指向"当前关卡"的根。它和场景树里其他节点没有结构上的区别——都是 root 窗口的子节点。change_scene_to_file 的全部工作就是换这个指针指向谁:
// scene/main/scene_tree.cpp,有删减
Error SceneTree::change_scene_to_file(const String &p_path) {
Ref<PackedScene> scene = ResourceLoader::load(p_path);
ERR_FAIL_COND_V(scene.is_null(), ERR_CANT_OPEN);
return change_scene_to_packed(scene);
}
Error SceneTree::change_scene_to_packed(const Ref<PackedScene> &p_scene) {
Node *new_scene = p_scene->instantiate();
ERR_FAIL_NULL_V(new_scene, ERR_CANT_CREATE);
call_deferred("_change_scene", new_scene); // 注意:延迟到消息队列
return OK;
}
注意第二段:change_scene_to_packed 本身是同步实例化新场景(这一步可能耗时几毫秒),但真正换场景的那步 _change_scene 走 call_deferred 推迟到当前帧的flush 消息队列阶段执行。这个延迟是 4.x 才加的保护,它把"换场景"和"当前正在执行的回调"隔开——但不是万灵药,实例化阶段仍然同步,且在物理回调里第一行就开始跑。
_change_scene 本体(有删减):
void SceneTree::_change_scene(Node *p_to) {
ERR_FAIL_COND(p_to == nullptr);
if (current_scene) {
root->remove_child(current_scene); // ① 旧场景立即摘除
memdelete(current_scene); // ② 立即释放,不是 queue_free
current_scene = nullptr;
}
current_scene = p_to;
root->add_child(p_to); // ③ 新场景挂树,触发 enter_tree/ready
}
三个事实直接来自这段代码。第一,旧场景是 memdelete 立即释放,不是 queue_free 的帧尾延迟——切场景的瞬间,旧场景所有节点、脚本状态、未发出的信号全部消失,没有任何告别机会。第二,新场景的 _ready 在旧场景已经没了之后跑,别指望在 _ready 里和旧场景做交接。第三,新场景挂树的 enter_tree/ready 级联是同步执行的,change_scene_to_file 返回时(更确切说 deferred 的 _change_scene 返回时),新场景已经 ready 完毕。
为什么从物理回调里调会炸
issue #85852 的报错链路值得走一遍。在 Area3D 的 body_entered 里调 change_scene_to_file,时序是:
- 物理服务器 step 过程中产生进入事件,Area3D 的 monitoring 列表处于
locked状态(area_3d.cpp的_clear_monitoring开头有ERR_FAIL_COND(locked)); body_entered信号发出 → 你的回调执行 →change_scene_to_file→ 同步实例化 + deferred 排队;- 同一帧后续
_change_scene执行,remove_child触发 Area3D 从树脱离,其NOTIFICATION_EXIT_TREE处理里要清理 monitoring——但 monitoring 的锁还挂着(物理 step 没走完),于是Condition "locked" is true。
根因一句话:场景切换会触发整棵子树的 exit_tree 级联,而 exit_tree 里的清理动作和物理服务器的当前状态可能互锁。物理回调不是唯一雷区,任何"引擎内部正在迭代某张列表"的回调里强拆场景都可能撞上锁,比如 _on_tree_exiting 里再切场景。
官方 issue 里给出的修复也最简单:把调用延迟到引擎迭代的缝隙里——
# 物理回调 / 信号回调里的安全写法
get_tree().change_scene_to_file.bind("res://level2.tscn").call_deferred()
# 或
call_deferred("switch_level") # 方法内部再调 change_scene_to_file
call_deferred 进 MessageQueue,在帧尾的 flush 阶段执行,那时物理 step、信号分发都已结束,所有内部锁归位。如果你在别处见过"必须 deferred"的经验之谈而不知道为什么,原因就是这个锁时序。
手动切换:什么时候不用 change_scene
change_scene_to_file 的两个隐含行为——旧场景立即释放、新场景挂为 root 直接子节点——在不少场景里反而是束缚。两种常用替代:
过场 UI 常驻的做法:不追求"一个时刻只有一个场景",而是把 current_scene 当可变关卡容器用。切关时自己 current_scene.queue_free()(比 memdelete 温和,帧尾释放,本帧余下逻辑还能引用旧场景做淡出),新关卡 add_child 进去,current_scene = new_level。过渡黑屏、加载进度条挂在这层之外,不随关卡生灭。change_scene 系 API 做不了这个,因为它硬编码了"root 直接子节点 + 立即释放"。
additive 场景(大地图分区加载、roguelike 的房间拼接):根本不该用 change_scene,直接 add_child 多个关卡分支,自己管理生命周期。change_scene 的语义是"整树替换",additive 是"局部增删",混用会把 owner/持久化语义搅乱。
还有一个细节:切场景后 get_tree().current_scene 指向新根,但旧场景的 autoload 引用不受影响(autoload 是 root 的兄弟层,不是 current_scene 的子树)——全局状态该放 autoload 就放,别因为"要切场景"就把全局数据挂到关卡节点上随切换陪葬。
切换时序图
实践清单:切换调用统一走 deferred;需要交接逻辑就放旧场景的 _exit_tree 或全局信号里,别依赖切换后还能摸到旧场景;过场和常驻 UI 挂场景外;大场景切换的实例化卡顿用加载界面盖住(change_scene_to_packed 配合 ResourceLoader.load_threaded_request)。
本文代码引自 Godot 4.2.2-stable 的 scene/main/scene_tree.cpp;锁冲突报错取自官方 issue #85852 的实测记录,触发版本 4.2 stable。