Godot 的架构宣传里常有一句"MainLoop 可替换":SceneTree 只是 MainLoop 的一个实现,理论上你能写自己的主循环。这让不少人(包括曾经的我)动过念头:插件里 new 一个 SceneTree 跑并行逻辑,或者嵌套一个"子游戏"。现实是 SceneTree 和半个引擎深度耦合,单树假设遍布各处。这篇把耦合点逐个摆出来,顺带解释 SubViewport 为什么才是官方给"第二个画面"的答案。
MainLoop 的替换点在哪
入口确实干净。Main::start() 的末尾把主循环对象定下来(main/main.cpp,有删减):
// 游戏:root 是 ProjectSettings 里定的主场景
main_loop = scene_tree; // SceneTree 是默认实现
...
main_loop->initialize(); // 之后 Main::iteration 每帧调
// main_loop->process / physics_process
MainLoop 接口本身只有 initialize / process / physics_process / finalize 四个虚函数加几个通知。替换成纯逻辑的自定义循环(不做渲染、不接输入)是可行的——headless 服务器、批处理工具就这么干。但 SceneTree 不是普通 MainLoop:它一 initialize,就开始注册渲染视口、接管输入分发、往 OS 层挂回调。问题的规模不在接口,在实现。
单树假设的四个锚点
视口与渲染。SceneTree 的 initialize 会创建 root Window(viewport),渲染服务器的合成器(compositor)以这棵树为单位组织渲染。RenderingServer 的场景渲染入口 render_scene 绑定的是 viewport 的 RID,而 viewport RID 由 SceneTree 的 root 持有。两棵树意味着两套 root viewport,合成器该画哪棵?Godot 没有"多活动视口树"的概念,渲染循环按一棵树设计。这不是不能改,是改了要动渲染架构。
输入分发。OS 层的输入事件进引擎后,统一交给 SceneTree 的 root window 做命中测试和冒泡(Viewport::push_input 一路传到 _input / gui 输入)。事件队列挂在 SceneTree 实例上,root window 一棵。第二个 SceneTree 拿不到事件源——除非你再接一套 OS 回调,而 OS 单例(OS::get_singleton())的输入注册只有一个槽。
全局可达性。SceneTree::get_singleton() 存在(scene_tree.cpp 的静态指针),引擎内部和 GDScript 里 Engine.get_main_loop() / SceneTree::get_singleton() 四处都是。两套树时 singleton 指谁?节点内部的 data.tree 指针是每节点正确的,但凡是不经节点直接摸树的代码(输入映射查询、时间标尺、Tween 的默认绑定树、 deferred 队列归属)都默认那唯一一棵。两套树不会崩在第一行,而是随机崩在某个走 singleton 的深处调用。
消息队列与延迟调用。MessageQueue(call_deferred 的载体)是全局一份,但它的 flush 挂在 SceneTree::process 里,按树的主循环节拍走。第二棵树的迭代节拍没有消息队列服务,跨树 call_deferred 的时序无从定义。
插件场景:为什么"new 一个"必炸
编辑器插件最常见的错误想法:在 @tool 脚本里 var my_tree = SceneTree.new() 然后 my_tree.initialize(),想跑一个隔离的场景做预览。炸点按顺序通常是:
initialize里创建 root window 要向渲染服务器注册视口,而编辑器进程的渲染服务器已经有一棵活动树,视口状态打架;- 输入事件被主树分走,新树永远收不到
_input; SceneTree::get_singleton()仍指编辑器主树,新树里的节点走get_tree()拿到的是新树自己(data.tree 正确),但任何库代码走 singleton 就串树了;- 插件卸载时新树没走 finalize,ObjectDB 报一堆泄漏。
编辑器自己的主树就是你正在编辑的那棵——插件的正确姿势始终是往这棵树里挂节点(dock、预览面板都是节点),而不是另起炉灶。
SubViewport:官方给的"第二棵树"
游戏里真正常见的需求不是"第二个主循环",是"第二个画面":小地图、AVG 对话框的头像渲染、监控屏幕。答案早就内置:SubViewport(scene/main/viewport.h 的 Viewport 子类)。
var minimap := SubViewport.new()
minimap.size = Vector2i(400, 300)
add_child(minimap)
minimap.add_child(minimap_camera) # 里面的世界独立渲染
# 显示到 UI 上
var rect := SubViewportContainer.new()
rect.stretch = true
add_child(rect)
rect.add_child(minimap)
SubViewport 的关键性质:它是当前 SceneTree 内部的一个独立渲染目标,有自己的相机、自己的世界(world_3d 可以独立),但消息队列、输入(可开关 handle_input_locally)、主循环节拍全部复用所在树。渲染服务器把它当一个额外 viewport 渲染进纹理,合成进主帧——不需要第二棵树,没有 singleton 冲突,没有消息队列归属问题。这就是为什么"上帝视角小地图"用 SubViewport 三十行搞定,而手写第二 SceneTree 三个月填不完坑。
要注意的限制也来自同一设计:SubViewport 不直接吃全局输入(它默认本地处理,gui 输入要经 SubViewportContainer 转发),它不参与主场景树的 _process 之外的生命周期(本质还是树内节点)。这些是"复用一棵树"的对价,换来的是一切基础设施白拿。
架构图
结论收敛成一句:想换掉主循环,写不碰渲染输入的纯逻辑 MainLoop,这是支持的;想要第二个"世界",用 SubViewport 或场景切换,别碰第二个 SceneTree。引擎的单树假设不是疏漏,是把复杂度集中一处换来的——理解它比对抗它省力。
本文代码引自 Godot 4.2.2-stable 的 main/main.cpp、scene/main/scene_tree.h、scene/main/scene_tree.cpp、scene/main/viewport.h,均有删减。