3.x 里想开第二个窗口,得绕到 OS 单例和 ViewportContainer 的组合拳,窗口本身不在场景树里,挂逻辑节点要借助外部句柄。4.x 把 Window 做成 Viewport 的子类、直接作为节点挂进场景树,这是 3→4 场景树一侧最大的结构调整之一。值得拆开看:改动本身不大,但它把"窗口"这个 OS 概念彻底收编进了节点系统。
继承方向为什么是 Window is-a Viewport
scene/main/window.h 里的继承关系:
// scene/main/window.h(Godot 4.2.2,有删减)
class Window : public Viewport {
// 窗口标题、尺寸、模式(全屏/无边框)、
// 弹窗标记、透明背景……全是 OS 窗口属性
};
方向反过来(Viewport has-a Window)在技术上也能讲通,但 Godot 选了 is-a,理由是内容的归属:一个窗口的内容树必须渲染到窗口自己的交换链上,而 Viewport 恰好就是"一棵子树的渲染目标"——有独立的 canvas 变换、独立的相机、独立的渲染缓冲。让 Window 继承 Viewport,等于宣布"窗口就是一种带 OS 壳的渲染目标",第二个窗口的内容树直接挂在自己节点下,走和主窗口完全相同的渲染路径(RenderingServer 的 viewport RID),没有任何特殊通道。
代价写在类设计里:Window 既要处理 OS 事件(NOTIFICATION_WM_*、拖放、IME),又要继承 Viewport 的全部渲染语义,类职责明显变宽。window.cpp 一千多行里一半是平台事件转发,另一半是尺寸/内容缩放换算,两个领域的代码挤在一个类里——这是这次收编付出的真实成本。
主循环里的变化
3.x 的主循环假设"只有一个根 Viewport",由 SceneTree 直接持有,OS 窗口事件通过全局回调注入。4.x 的根节点改成了 Window(SceneTree 内部的 root 就是一棵 Window),事件分发路径变成:
OS 窗口事件
→ 对应的 Window 节点(哪个窗口收到的就归哪个)
→ Window 转成 InputEvent 注入自己的 Viewport 输入管线
→ 沿场景树做 pick 和 _unhandled_input 冒泡
窗口天然获得节点生命周期的红利:queue_free() 关窗口、_ready 里初始化第二窗口、窗口也可以被 PackedScene 实例化。多窗口工具(编辑器就是个多窗口程序:主窗口、代码编辑器浮窗、各种弹窗)不再需要 OS 层特判。
嵌入窗口与 SubViewport 的分工
不是每个 Window 都对应一个 OS 原生窗口。Window 有个 visible 之外的关键属性——是否作为独立 OS 窗口创建。不想弹原生窗口、只想在 UI 里嵌一块独立渲染区域时,用的是 SubViewport(scene/main/viewport.h,Viewport 的非窗口子类)配 SubViewportContainer:渲染目标是父控件的一块矩形,不参与 OS 窗口管理。真正的 Window 节点有两种落地方式:
- 原生窗口:OS 分配独立句柄,独立交换链,任务栏可见。
- 嵌入窗口:在特定父 Window 内部用子矩形模拟,适合工具类的弹出面板。
这个三件套(Window / SubViewport / SubViewportContainer)把 3.x 里 Viewport 承担的"既是渲染目标又是窗口"双重身份拆开:渲染目标归 Viewport 体系,窗口壳归 Window,嵌入显示归 Container。职责比 3.x 清晰,代价是要理解三个类的分工——刚迁移时最常见的错误就是想用 Window 做 UI 内嵌区域(应该用 SubViewport)。
4.x 还没解决的
窗口做成节点不等于多窗口的所有问题都消失了。跨窗口拖拽节点、把 popup 安全地弹到多显示器边缘、Wayland 下弹窗定位,这些在 window.cpp 和平台驱动里都有特判分支。另外每个 Window 都有独立的渲染缓冲分配,开十几个窗口时显存开销线性上涨——渲染管线篇会展开每个 Viewport 一套缓冲的成本。
本文代码引自 Godot 4.2.2-stable 的 scene/main/window.h、scene/main/window.cpp、scene/main/viewport.h,均有删减。