Godot 的 C# 支持在 3.x 和 4.x 之间经历了一次很少有人注意的换底:用户看到的都是"写 C# 脚本",底下从"把 Mono 运行时嵌进引擎进程"换成了"用标准 .NET hosting API 启动 CoreCLR"。这个决定顺带解锁了 C# 热重载和 iOS AOT 两条路,这篇把两种方案的调用链逐层拆开看。
3.x:Mono 嵌入宿主
3.x 的实现在 modules/mono。引擎把 libmono 链进二进制,用 Mono 的 embedding API 直接操作运行时:创建 domain、加载程序集、用 mono_runtime_invoke() 调用托管方法。每个脚本类的入口(_Ready、_Process 这些虚函数)都注册成一个 MonoMethod 指针,引擎每帧按需 invoke。
这套方案有一个沉重的附属品:胶水代码生成。托管层不能直接碰引擎的 C++ 类,于是构建时跑一个 glue generator,给 core/object 里的每个类生成一份 C# 包装(godot_api 那一堆 Object.Types.cs)。生成的包装类持有指向原生对象的 IntPtr,每个方法展开成 mono_icall 调用。胶水层的问题不在正确性,在于它和引擎版本硬耦合——每次引擎 API 变动都要重新生成、重新编译,升级成本极高,也限制了社区给非 Mono 后端做绑定的可能。
4.0:标准 hosting,不再嵌入
4.0 起整个 mono 模块被移除,换成新的 modules/mono(名字没变,内容全换)。新方案用 .NET 官方的 hosting 三部曲:
// modules/mono/godotsharp_dirs.cpp 附近,简化
void *hostfxr = load_hostfxr(); // 通过 nethot 定位
hostfxr_initialize_for_runtime_config(config_path, params, &context);
hostfxr_get_runtime_delegate(context, hdt_load_assembly, &load_assembly);
load_assembly("Godot.Bridge.dll", ...); // 加载托管入口
关键变化有三个:
不再持有运行时所有权。引擎只是"启动并寄宿" CoreCLR,进程里跑的是标准 .NET 6+ 运行时,行为和在系统上 dotnet run 一个程序一致。调试、profiler(dotnet-counters)、崩溃转储全部用标准工具链,3.x 时代那些 mono 专用的排障手段可以全扔了。
可卸载的 AssemblyLoadContext。托管程序集加载进一个 collectible 的 ALC,AssemblyLoadContext.Unload() 之后连同其中所有类型被 GC 回收。这是 4.2 C# 热重载的技术前提:编辑器里保存脚本 → 编译出新程序集 → 新 ALC 加载 → 旧 ALC 标脏卸载。3.x 的 Mono 嵌入做不到安全的程序集卸载,热重载在 3.x 只存在于 GDScript。
绑定层换成 GDExtension 风格。托管代码调引擎不再走 mono_icall,而是走 GDExtension 的 C 函数表(GDExtensionInterface)。这对 ABI 稳定性的要求和原生 GDExtension 完全一致——引擎升级不破坏已有托管程序集的二进制兼容,胶水代码从"构建时生成"变成了"随 .NET SDK 分发的 NuGet 包"。
marshaling 成本到底在哪
C# 调引擎 API 的边界成本和 GDScript 那篇的三层税是同一个坐标系,但 C# 明显便宜,原因是类型传输按内存布局分级:
| C# 侧类型 | 传输方式 | 边界成本 |
|---|---|---|
Vector3、Transform2D 等 blittable 结构体 |
按字节布局直接拷贝,12 字节一个寄存器对 | 接近 C++ 函数调用 |
string |
UTF-16 ↔ UTF-32 转换,每次分配 | 中,含一次堆分配 |
GodotObject 及子类 |
只搬 IntPtr 句柄,包装类按句柄缓存 | 低 |
Variant 任意值 |
装箱为 24 字节结构 + 类型标签判断 | 高 |
写 C# 的性能原则因此很直白:热路径上传结构体,不传字符串和 GodotObject 包装。一次 Position 属性读写是两个 blittable 拷贝,和 C++ 没差别;一次 GetNode("Path/To/Node") 是字符串转换加路径解析,每次调用的成本能差出两个数量级。实测过一个每帧 300 次节点属性读写的场景,全部换成缓存的结构体后,C# 版本的帧时间和 GDExtension C++ 版本打平,差距全在字符串路径上。
还没解决的:iOS 与 AOT
CoreCLR 的 JIT 在 iOS 上被平台禁止,4.x 的 iOS 导出用 .NET AOT(Mono AOT 也是选项之一)。AOT 的限制是反射和泛型实例化空间在编译期封闭,部分依赖运行时代码生成的库(某些序列化器)会踩坑。桌面平台没有这个问题,.NET 6+ 的 JIT 质量足够好,纯 C# 数值代码和原生 C++ 的差距在 1.x 倍以内。
一个遗留的权衡值得记住:托管启动比 GDScript 贵。hostfxr 初始化加程序集加载在桌面冷启动要 100~200ms,启动敏感的轻量小游戏(工具类、game jam 作品)用 GDScript 反而整体更快——宿主方案没有对错,只有和被寄宿者是否匹配。