上回说到,通过C编写了一个最基本的GDExtension
也提到Godot通过运行时加载动态链接库实现对引擎功能的扩充
而理论上,加载动态链接库实际上只要调用的符号对得上就可以,这是与语言无关的
也就是说,只要语言可以导出C符号的动态链接库,就可以写GDExtension
而C#恰好就可以通过AOT导出C符号的动态链接库,这就很有意思了
<0x00> 前言
需要注意的是,在我研究到一半的时候,发现有项目在做这个事情了(godot-dotnet)
当然,这个项目还不像Rust或者Go的Binding那么完善稳定,不推荐在生产环境中使用
所以,这篇文章更多的是举一个例子,展示如何用C#编写AOT的代码
代码仓库
需要注意的是,编写AOT代码不仅需要.netSDK,还需要额外的工具来完成符号的导出
比如说Windows平台上,就需要安装Visual Studio与C++工作负载
主要是需要MSVC生成工具,其他平台可以参考微软的这篇教程
<0x01> 一些概念
在这篇文章中,我们需要用到一些unsafe的代码
在unsafe上下文中,我们可以通过指针直接操作内存
代价就是这样没有运行时的内存安全保证
一旦操作不当就可能会引起程序崩溃
不过对于一些本机代码的交互,还是需要用unsafe的
函数指针
在GDExtension中,有非常多的操作是通过函数指针传递的
对于C中如下的函数指针
typedef void (*function_a)(int num);
typedef int (*function_b)(const char* str);
C#中需要如下方式声明
using FunctionAPtr = delegate* unmanaged[Cdecl]<int, void>;
// C的char类型应该对应C#的byte,C#的char类型为2字节,表示UTF16编码
using FunctionBPtr = delegate* unmanaged[Cdecl]<byte*, int>;
有一说一,写法上确实挺抽象的
这里使用的是using声明,作用域仅限当前文件内
如果需要确保类型,就需要单独声明一个struct,比如这样
public unsafe struct FunctionAPtr
{
private FunctionAPtr(delegate* unmanaged[Cdecl]<int, void> ptr)
{
_ptr = ptr;
}
private delegate* unmanaged[Cdecl]<int, void> _ptr;
public static implicit operator FunctionAPtr(delegate* unmanaged[Cdecl]<int, void> ptr) => new (ptr);
}
由于struct的内存分配特性,FunctionAPtr结构位置也即_ptr的位置
这就相当于给指针分配了类型
需要注意的是,在C#中指定方法需要在非托管环境中调用,需要用一个特性标记
// 标记仅允许非托管环境调用,调用约定为Cdecl
[UnmanagedCallersOnly(CallConvs = [typeof(CallConvCdecl)])]
public static void FunctionA(int num)
{
}
delegate* unmanaged[Cdecl]<int, void> ptr = &FunctionA;
fixed代码块
C#中有一个挺少见的关键字fixed
这个是用来指定该代码区域中指定托管对象的内存位置不动
为什么需要这样代码块呢,因为托管对象在托管堆的内存位置是有可能改变的
由于垃圾回收机制的存在,不需要的对象会被清理
而为了避免内存空间碎片化,垃圾回收器会对剩余的对象重新排列
通过fixed关键字,告诉垃圾回收器在这里暂时不要动指定对象的内存位置
下面是一个示例
public class Test
{
public DoSomething()
{
// ...
// 固定this表示的对象
fixed(void* pThis = &this)
{
Method(pThis);
}
// ...
}
}
<0x02> 开始
创建一个项目,这里采用.net10,使用类库项目模板
需要在.csproj文件中启用AOT与unsafe上下文
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<PublishAot>true</PublishAot>
<AllowUnsafeBlocks>true</AllowUnsafeBlocks>
</PropertyGroup>
</Project>
整个入口
首先先复刻一个我们需要的函数入口
// .ExtensionEntry.cs
using System.Runtime.CompilerServices;
using System.Runtime.InteropServices;
public unsafe class ExtensionEntry
{
[UnmanagedCallersOnly(EntryPoint = "aot_init")]
public static bool Init(
nint pGetProcAddress,
nint pLibrary,
nint rInitialization)
{
}
}
其他先不加,尝试生成一下,通过dumpbin看看有没有这个入口

有aot_init就说明已经把入口符号导出了
分离代码
为了方便管理,这里将不同的代码放在不同的文件中
./GdExtensionApi/GodotApi.cs负责保存需要的Api函数指针./GdExtensionApi/GodotEnum.cs定义需要的枚举类型./GdExtensionApi/GodotFunction.cs定义需要的函数指针类型封装./GdExtensionApi/GodotStruct.cs定义需要的结构./GdExtensionApi/GodotValue.cs定义需要的数值类型
<0x03> 实现
具体流程实际上还是与C的示例是一致的
也是通过入口,获取所有需要的Api函数指针
然后再进行一些操作
由于C#没有这些符号定义,所以最花时间的就是定义C#中的符号
自己定义的符号通过一些方式还可以融合C#的语法糖,也还行
StringName定义
GDExtension中,获取StringName实际上获取的是StringName的指针
其内容的生存期由Godot本身管理
也就是说,在我们的代码中需要告诉Godot什么时候销毁这个东西
所以会在C的代码中看到这样的代码
StringName class_name;
// 创建StringName,内容为"Sprite2D"
constructors.string_name_new_with_latin1_chars(&class_name, "Sprite2D", false);
// 一些操作
// 指示销毁
destructors.string_name_destructor(&class_name);
这会导致局部暂时使用StringName会有些麻烦,需要记得销毁
C#中有个using语法,表示在作用域内使用的变量,离开作用域后自动销毁
这依赖于IDisposable接口的实现
我们可以在自己定义的StringName中实现这个接口
public unsafe struct StringName : IDisposable
{
private StringName(string str)
{
From(str);
}
// 需要加载的函数指针
public static delegate* unmanaged[Cdecl]<StringName*, byte*, GdExtensionBool, void> ConstructPtr;
public static delegate* unmanaged[Cdecl]<StringName*, void> DestructPtr;
public static implicit operator StringName(string str) => new(str);
private void* _ptr = null;
private void From(string str)
{
fixed (StringName* pThis = &this)
{
ConstructPtr(pThis, (byte*)Marshal.StringToHGlobalAnsi(str), false);
}
}
public void Dispose()
{
fixed (StringName* pThis = &this)
{
DestructPtr(pThis);
}
}
}
这样的实现,在代码中需要使用StringName时,就方便许多了
using StringName className = "Sprite2D";
其他类型的封装
在本示例中,其他类型的封装大多是重复劳动
主要使用的技巧是隐式类型转换,比如说这样
public unsafe struct GdExtensionObjectPtr
{
private GdExtensionObjectPtr(void* ptr)
{
_ptr = ptr;
}
private readonly void* _ptr;
public static implicit operator GdExtensionObjectPtr(void* ptr) => new(ptr);
public static implicit operator void*(GdExtensionObjectPtr ptr) => ptr._ptr;
}
定义了void*与GdExtensionObjectPtr类型的互相隐式转换
在需要的地方就可以非常方便得转换不同类型的指针封装
具体的话可以看仓库代码
枚举类型的传递
GDExtension中也存在一些枚举类型,这些类型在C#中怎么对应呢
好消息是,在C与C#中,枚举本质上与int类似
传递不同的枚举值相当于传递不同的int值
也就是可以直接对应,只要C#中的定义与C中的定义一致即可
具体详见仓库代码
编写自定义节点
有了上面的定义基础,我们就可以用C#编写一个简单的节点了
上次用C写的是继承Sprite2D的节点,这次写一个继承Control的节点
// ./AotExample.cs
using System.Runtime.CompilerServices;
using System.Runtime.InteropServices;
using CSharpAotExtension.GodotExtensionApi;
namespace CSharpAotExtension;
public unsafe struct AotExample
{
private static readonly GdExtensionInstanceBindingCallbacks BindingCallbacks;
private GdExtensionObjectPtr _objectPtr;
[UnmanagedCallersOnly(CallConvs = [typeof(CallConvCdecl)])]
public static GdExtensionObjectPtr CreateInstance(void* pClassUserdata)
{
using StringName controlName = "Control";
using StringName exampleName = "AotExample";
var control = GdExtensionClassDb.ConstructObject(&controlName);
var self = (AotExample*)GdExtensionCore.MemAlloc(sizeof(AotExample));
self->_objectPtr = control;
GdExtensionCore.ObjectSetInstance(control, &exampleName, self);
fixed (GdExtensionInstanceBindingCallbacks* pCallbacks = &BindingCallbacks)
{
GdExtensionCore.ObjectSetInstanceBinding(control, ExtensionEntry.ClassLibrary, self, pCallbacks);
}
return control;
}
[UnmanagedCallersOnly(CallConvs = [typeof(CallConvCdecl)])]
public static void FreeInstance(void* pClassUserdata, GdExtensionClassInstancePtr pInstance)
{
if (pInstance.IsNull())
{
return;
}
GdExtensionCore.MemFree(pInstance);
}
}
然后在加载过程中注册这个类型即可
// ./ExtensionEntry.cs
// ...
[UnmanagedCallersOnly(CallConvs = [typeof(CallConvCdecl)])]
private static void InitializeModule(void* pUserdata, GdExtensionInitializationLevel level)
{
if (level != GdExtensionInitializationLevel.Scene)
{
return;
}
using StringName className = "AotExample";
using StringName parentClassName = "Control";
delegate* unmanaged[Cdecl]<void*, GdExtensionObjectPtr> createInstancePtr = &AotExample.CreateInstance;
delegate* unmanaged[Cdecl]<void*, GdExtensionClassInstancePtr, void> freeInstancePtr = &AotExample.FreeInstance;
GdExtensionClassCreationInfo2 classInfo = new()
{
IsVirtual = false,
IsAbstract = false,
IsExposed = true,
CreateInstanceFunc = createInstancePtr,
FreeInstanceFunc = freeInstancePtr,
};
GdExtensionClassDb.RegisterExtensionClass2(ClassLibrary, &className, &parentClassName, &classInfo);
}
// ...
<0x04> 编译与运行
publish到目标位置后,同样,需要一个.gdextension文件来说明函数入口与动态库位置
(是publish,不是build,build的话入口符号可能不会导出)
配置完成后,启动Godot

出现AotExample节点即说明我们写的扩展是正常运行的