一-11 那篇讲过 GDExtension 的 ABI 设计(函数表加版本协商),这篇把视角换到运行时:扩展编译进二进制之后,一次实际的跨边界调用是怎么从 C++ 代码走到引擎里的,每一跳的成本是什么。读完你应该能自己判断"这段逻辑放在扩展里还是脚本里"。
函数表的本质:一张填满函数指针的结构体
扩展被加载时,引擎递过来一个 GDExtensionInterfaceGetProcAddress 回调,扩展用它逐个取出函数地址,填满自己手里的结构体。这张表里大约有 300 个入口,按功能分几组:
- 对象操作:
object_set_instance、object_get_instance_binding、classdb_construct_object——创建和操纵引擎对象。 - Variant 协议:
variant_new_copy、variant_call、variant_evaluate——扩展侧的 Variant 全部通过这些入口构造和运算。 - 内存与工具:
mem_alloc、print_error、get_library_path——基础设施。
ABI 那篇说过为什么用 C 函数表而不是虚函数或 C++ 类:跨编译器、跨标准库、跨语言(Rust、Zig 的绑定都从这张表起步),C ABI 是唯一所有人都能稳定生成的接口。
一次方法调用的完整路径
扩展里写一句 node->set_position(Vector2(1, 2)),godot-cpp 展开后实际发生的是:
扩展代码
→ godot-cpp 内联包装(Node::set_position 是个 inline,转去调函数表项)
→ p_interface->object_method_bind_ptrcall(method_bind, instance, args, ret)
→ 引擎侧 MethodBind::ptrcall(直接走函数指针版本,跳过 Variant 装箱)
→ Node::_set_position 的 C++ 实现
注意中间那步叫 ptrcall 而不是 call。call 系列走 Variant 参数数组,要装箱解包;ptrcall 直接传 void* 参数数组,按 C++ 参数类型原样传输。godot-cpp 生成的绑定优先走 ptrcall 路径,所以一次属性设置的真实成本是:一次间接调用(函数表查表早已内联成直接指针)+ 引擎侧 MethodBind 的少量校验 + 目标函数本身。和引擎内部两个 C++ 模块互相调用的差距,主要就是那一次间接跳转,纳秒级。
反过来,引擎调扩展(虚函数回调、引擎信号连到扩展方法)走另一条路:classdb_register_extension_class 时扩展交出一批函数指针,引擎把它们包成 ExtensionMethodBind 挂进 ClassDB。引擎侧调过来时同样优先 ptrcall。
godot-cpp 在表上加了什么
裸表能写字,但没人想直接写。godot-cpp 提供三层增值:
- 类型包装:
godot::Vector2、godot::Node这些类把表调用包成普通成员函数,构造析构走表的内存入口管理。 - 绑定宏:
GDCLASS/ClassDB::bind_method复刻了引擎原生模块的绑定 API,扩展类注册进 ClassDB 后和原生类在脚本眼里完全同构——脚本MyNode.new()和Node.new()走同一套实例化路径。 - 生命周期胶水:扩展持有的引擎对象通过 instance binding 关联,扩展被卸载时引擎按 binding 清理,不会留悬垂指针。
第三层有个容易误解的点:扩展里持有一个 godot::Node* 不是"拥有"它,只是把引擎对象的地址借过来用,生命周期仍归场景树。这和 GDScript 侧的规则一致,内存模型那篇的口诀在这里原样适用。
开销的真实构成
把跨边界的成本拆开,只有两项是 GDExtension 独有的:
间接跳转。函数表调用无法内联进扩展的编译单元,编译器也看不见引擎侧实现,做不了跨模块优化。一次调用多几纳秒,热循环里累计可观。
Variant 往返。扩展和引擎交换非 blittable 数据(String、Array、Dictionary)时,数据要在两边的表示之间转换一次。Vector2 这类 POD 结构体按字节拷贝,零成本;String 每次过界一次 UTF-8/UTF-32 转换加一次堆分配。
结论对应到实践:扩展适合做算法密集、输入输出是小结构体的活(几何计算、寻路打分、解析器),不适合做每帧和场景树大量对话的活(那种活调用的数量本身就是瓶颈,放在哪边都慢,该用批量 API 减次数)。同一个函数在扩展里跑比在 GDScript 里快 20~40 倍的前提是数据不过界——每迭代一次就调一次引擎 API 的循环,扩展身份救不了它。