技术博客

引擎版本演进:3.x 到 4.x 还了哪些架构债

第 21 / 149 篇 · 2025-06-07 · godot · 引擎架构版本演进渲染

Godot 4.0 开发了两年多,打破的 API 兼容性清单有一千多条。这不是重构癖,是还债——3.x 时代的几个核心设计决策(单一 VisualServer、GDScript 1.0、弱类型信号)在小型项目上表现良好,但项目规模上去后都成了天花板。这篇对照讲四个最大的架构变更,以及迁移项目时真正会咬人的点。

债一:VisualServer 的"上帝类"拆分

3.x 的 VisualServer 是一个类管所有视觉相关的事:3D 渲染、2D 渲染、纹理、shader、GI、粒子。单类超过 800 个方法,内部一个全局锁。问题在项目大了之后集中爆发:

  • 扩展无路:想换渲染后端(比如社区想接 Vulkan 替换 GLES3),要改的是这个巨类的内脏,等于重写引擎;
  • 2D 与 3D 互相拖后腿:2D 游戏背着 3D 管线的开销,反之 3D 的优化经常误伤 2D 路径;
  • 多线程不可能:全局锁 + 无命令缓冲,渲染逻辑钉死在主线程。

4.x 的拆法是三个服务器(servers/ 目录):

  • RenderingServer:纯渲染资源与 draw 命令(纹理、mesh、材质、canvas),不知道物理和场景;
  • RenderingDevice:再往下抽象一层的 Vulkan 后端,可独立使用(前面 RenderingDevice 篇拆过);
  • PhysicsServer2D/3D:物理从场景系统里彻底剥离,用 RID 而非节点指针交互。

拆完的标志性行为变化:4.x 里场景树节点不再直接持有渲染/物理对象,Node3D 持有的是 RenderingServer 的 RID,每帧把变换同步过去。这就是"服务器模式"——场景树是编辑器和游戏逻辑的面,服务器是执行体,中间用 RID 句柄解耦(RID 篇讲过句柄的线程安全收益,这里就是那个设计动机的源头)。

债二:GDScript 1.0 的解释器重写

3.x 的 GDScript 是树遍历解释器:解析成 AST 后逐节点解释执行,没有字节码层。性能问题是双重的——每行代码都要走一遍虚函数分派的 AST 访问,加上所有值都是 Variant 装箱。

4.x 重写为编译到字节码的 VM(modules/gdscript/gdscript_vm.cpp,前面 GDScript 编译管线篇讲过五阶段管线)。收益数据:官方基准测试里纯脚本循环快 3~5 倍,加上静态类型标注后更多——typed GDScript 让 VM 跳过 Variant 分派直接生成特化指令。

但重写顺手改了一批语义,这些是迁移时真正咬人的:

  • onready var x 变成 @onready,且执行时机语义微调(4.x 保证在 _ready 前,3.x 文档没说死);
  • yield 变 await,状态机实现完全不同(3.x 的 yield 恢复有 resume 后重复执行的 bug,4.x 的状态机重写顺手修了);
  • instance() 变 instantiate(),Pool*Array 变 Packed*Array;
  • 信号连接从字符串方法名变成 Callable:connect("pressed", self, "_on") → pressed.connect(_on)。

债三:信号系统的 Callable 化

3.x 信号连接存的是"对象指针 + 方法名字符串",发射时按名字查方法。问题:字符串查表慢、重命名方法后连接静默失效、bind 参数语义含糊(binds 数组和 flags 的组合有四种行为)。

4.x 全面 Callable 化(前面 Callable 篇的细节):连接存 Callable 值,bind 参数存 Callable 内部,发射时零查表。连带收益是信号变成一等公民:signal 关键字声明的信号有自己的类型,可以当变量传、可以 await。

迁移咬人点:3.x 代码里大量 connect(sig, target, method, binds, flags) 的 flags 参数(CONNECT_DEFERRED 等)在 4.x 挪到了 connect() 的最后一个参数且枚举值重排,机械转换工具(官方 upgrade tool)能转 90%,剩下的是 flags 组合语义变化的边角案例。

债四:资源与导入系统的 UID 化

3.x 的资源引用纯靠路径:res://scenes/player.tscn。移动文件 = 打断所有引用,大项目里"整理目录结构"是外科手术级操作。

4.x 引入 ResourceUID(core/io/resource_uid.h):每个资源分配唯一 uid:// 标识,引用记录 UID 为主、路径为辅。移动文件后 UID 映射表更新,引用不断。这套系统的加载链细节属于资源管线篇的范畴,这里只说架构意义:资源的身份从"位置"变成了"内容身份",这是所有现代引擎(Unity 的 GUID、Unreal 的 Package Name)的标配,Godot 晚了十年但补上了。

代价:UID 数据库(project.godot 同级的 uid_cache)在合并分支、拷贝工程时会撞车,这也是资源篇第 15 条(UID 冲突与工程迁移)存在的理由。

架构对照示意

3.x 与 4.x 的核心架构对照 Godot 3.x VisualServer:800+ 方法的上帝类 2D/3D/粒子/GI 全在一个类里 GDScript:AST 树遍历解释 无字节码层,全部 Variant 装箱 信号:对象指针 + 方法名字符串 发射时按名字查方法表 资源:纯路径引用 移动文件 = 断引用 物理:Godot Physics 绑在场景树上 Godot 4.x RenderingServer / RenderingDevice / PhysicsServer 按域拆分,RID 解耦 GDScript:编译到字节码 VM 静态类型生成特化指令,快 3~5 倍 信号:Callable 一等公民 bind 参数存 Callable 内,零查表 资源:uid:// 身份 + 路径双轨 移动文件引用不断(UID 表更新) 物理:服务器化,Jolt 可替换接入

债四点五:GDNative 到 GDExtension

3.x 的原生扩展体系是 GDNative——C ABI 绑定加一层薄包装,能写原生代码但碰不了引擎内部类,能用的只有公开 API 的子集,性能敏感场景(比如想给渲染管线加自定义 pass)做不到。4.x 换成 GDExtension(前面 GDExtension 篇拆过 ABI 设计):绑定生成自引擎自身的 extension_api.json,理论上引擎类全量可达,扩展能注册新的节点类型、编辑器插件甚至替换服务器实现。

架构意义上这是一次"引擎能力的外包":Godot Jolt 从 GDExtension 插件长成官方内置(4.4),证明了这条路能承载物理引擎级别的复杂度——3.x 的 GDNative 时代这是不可想象的。

其他值得一提的重构

四个主债之外,3.x→4.x 还有几处影响面大但讨论度低的变更:

  • Window 节点化:3.x 的弹窗是 Popup 这类伪窗口控件,嵌在单个 OS 窗口里;4.x Window 成为 Viewport 子类节点,每个 Window 可以映射到独立原生窗口。编辑器 UI、多显示器游戏、画中画调试视图都是受益者,但输入法合成、跨窗口焦点这些 OS 边角问题也跟了进来。
  • AnimationMixer 基类:3.x 的 AnimationPlayer 和 AnimationTree 各有一套轨道求值代码,行为有细微分叉;4.x 抽出共同基类 AnimationMixer 统一求值(动画篇第 1 条的主角),修掉了"同一动画在 Player 和 Tree 里表现不同"的一类 bug。
  • TileMap 重写:3.x TileMap 是单节点存全部格子;4.x 拆成 TileMapLayer 多层节点加 TileSet 独立资源,性能(分区剔除)和编辑体验双赢,代价是 3.x 的 TileMap 场景几乎要手工重建。
  • 输入系统:InputEvent 族重构,action 的强度(strength)语义明确化,手柄死区和触屏多点的一致性改善。

迁移的现实路径

官方提供自动升级工具(编辑器内 Project → Tools → Convert 3.x Project),它能处理的是语法层:方法重命名、信号连接改写、PackedArray 改名。它处理不了的是语义层:

  • yield 和 await 的时序差异(await 恢复点更严格,依赖 3.x yield 的宽松恢复行为的代码会坏);
  • 物理参数的含义变化(摩擦、弹跳的合成规则改了,调好的手感参数要重调);
  • KinematicBody → CharacterBody 不只是改名,move_and_slide 的返回值从滑动后向量变成碰撞布尔,速度改由 velocity 属性读写。

经验法则是:工具转换后,先跑通再逐系统验证行为,UI 和存储层通常平移顺利,物理和动画是重灾区。大项目迁移的实际工时分布里,"工具转换"一般不到 20%,剩下都是语义核对。

两个降低风险的工程手段:

  • 迁移前给 3.x 项目补行为测试:哪怕只是"主场景跑 100 帧不报错 + 关键数值快照"级别的冒烟测试,迁移后能立刻发现行为漂移,比人肉逐场景点检可靠;
  • 冻结 3.x 分支再迁:迁移期并行开发是灾难——转换工具对 diff 不友好,3.x 侧每多一个提交,迁移分支就要手工追一次。冻结期按周计而不是按月计,倒逼迁移排期。

没还完的债

公平地说,4.x 也留了新债:

  • 多窗口架构(Window 节点化)解决了嵌套视口,但带来了跨窗口拖拽、输入法、屏幕缩放的成串边缘问题,到 4.2 才基本稳定;
  • 渲染器的三后端(Forward+/Mobile/Compatibility)让特性矩阵复杂化,"这个效果在哪个渲染器下可用"成了新的认知负担;
  • GDScript VM 的字节码格式不稳定:4.0 到 4.2 之间字节码变过,导出模板版本必须和脚本编译版本严格匹配,热更方案设计时这是硬约束。

判断一个 4.x 小版本升级的风险,最可靠的信号是 changelog 里 modules/gdscript 和 servers/rendering 的提交密度——这两个域一动,行为变化就会顺着 VM 和 RID 抽象渗到业务层。

4.x 内部的节奏:债还在动态偿还

需要澄清的一个误解:4.0 不是终点而是起点。4.0 到 4.3 之间架构层面的偿还仍在继续:

  • 4.2 拆出 RenderingDeviceDriver(渲染篇第 2 条的主角):RenderingDevice 原本是"抽象层 + Vulkan 实现"的混合体,4.2 把驱动层再拆出来,为 D3D12/Metal 后端铺路——这是 4.0 拆 VisualServer 时没走完的一步;
  • 4.3 的物理插值内置:3.x 时代靠插件(smoothing)做的事进核心,改变的是 SceneTree 和物理服务器的交界协议;
  • 4.4 内置 Jolt:物理服务器抽象在 4.0 就预留了可替换性,但直到 Jolt 进来才真正验证了"服务器实现可换"不是纸面承诺。

也就是说,判断 4.x 的架构债不能只看 3.x 对照,每个小版本都有自己的偿还计划。跟踪 godot-proposals 仓库里被 accept 的架构提案,比看版本号更能预判引擎的走向。

从演进史学到的东西

回看这四笔债,有个共同模式:3.x 的每个"简单设计"都是小规模下的最优解,债是规模变大后才显形的。VisualServer 在 2D 小游戏时代是直截了当的好设计,GDScript 树遍历在脚本只有几百行时性能足够,字符串信号在连接数几十条时没有查表压力。

这对读源码的姿势有指导意义:看到 4.x 里某处设计"过度工程"(比如 RID 层、Callable 的四种形态),先问它解决的是什么规模下的问题,而不是嫌它绕。反过来,自己项目里做技术选型时,"3.x 式简单方案"的适用边界要明确写下来——项目规模跨过边界时,还债的成本是按指数长的。

另一个观察是 4.x 的还债方式全部沿着既有的架构直觉延伸:服务器模式从渲染推广到物理、RID 从渲染资源推广到一切服务器对象、Callable 从信号推广到 tween 和工具回调。新机制很少凭空发明,都是"把被验证过的局部模式提升为全局约定"。这也是为什么读完架构篇的这组文章后,看引擎任何角落都会有似曾相识感——模式就这么几套,复用密度极高。

对想在引擎上做深度定制的团队,演进史还有一个实用启示:顺着 4.x 的架构方向改代码,合并上游的成本最低。在 RID 抽象上加功能、用 GDExtension 做扩展、按服务器模式组织自定义子系统,这些是"顺风"的改法;反过来在场景树里塞全局状态、绕过服务器直接摸渲染句柄,每次升级都会被上游的还债浪潮拍碎。

本文基于 Godot 3.5/3.6 与 4.0~4.2 的源码对照(servers/visual_server.h vs servers/rendering_server.h、modules/gdscript/ 两个世代的实现)以及官方迁移文档。性能倍数为官方基准测试公布数据的范围值。