如何编写GDExtension插件

Godot的GDExtension通过运行时动态加载编译完成的Dll文件
实现对编辑器功能的扩展,并且无需重新编译引擎源代码

虽然Godot官方确实有这样的教程,但在我实际按照教程操作时,发现很多已过时的内容
本文章采用的版本是4.5
从导出符号文件里面的注释,GDExtension的API应该是时常修改的,所以与教程有差别也正常
并且Godot官方的教程存在部分未翻译的情况,所以觉得还是要写点东西记一下
本文章不大会对函数的具体功能做介绍,只是让GDExtension可以跑起来
代码仓库

使用C开发GDExtension

<0x00> 从Godot中导出GDExtension符号

首先要获取GDExtension需要的符号文件
这一步需要确保你的Godot执行文件可以在命令行中找到
(这里推荐通过scoop安装Godot,会自动配置这一块的)
命令行中执行下列语句

godot --dump-gdextension-interface
godot --dump-extension-api

这样会得到两个文件,分别是gdextension_interface.hextension_api.json
gdextension_interface.h包括了编译时需要的符号信息,里面都是C风格的定义
extension_api.json包含了Godot类型的信息,包括类型大小等,编译时不需要,只是供代码编写时使用

<0x01> 配置目录结构与构建系统

我们主要分两个文件夹来组织文件

gdextension_c_example
├── demo    # 编译输出文件夹
└── src     # 源代码文件夹

需要把gdextension_interface.h放到src文件夹中

官方的教程使用Scons作为构建系统,但这个我不熟,所以我换成了我更熟悉的Xmake
因为最后只要能输出相应的动态链接库即可,所以其实构建系统是可以挑自己喜欢的

这里使用到的Xmake.lua如下

add_rules("mode.debug", "mode.release")

target("ExtensionTest")
    set_kind("shared")
    set_toolchains("clang")
    set_targetdir("demo/bin")
    add_files("src/*.c")

这里还指定了使用clang工具链,只是因为我比较喜欢clang,用其他的也问题不大

<0x02> 编写代码

对于GDExtension,Godot通过一个函数入口来初始化调用
函数入口的名字无所谓,只要可以和之后的配置文件对上即可
这里假设入口函数名为gdexample_library_init,入口函数签名是这样的

GDExtensionBool gdexample_library_init(GDExtensionInterfaceGetProcAddress p_get_proc_address,
                                    GDExtensionClassLibraryPtr p_library,
                                    GDExtensionInitialization *r_initialization)
// GDExtensionBool: uint_8
// GDExtensionInterfaceGetProcAddress: void(*(*)(const char*))()
// GDExtensionClassLibraryPtr: void*
// GDExtensionInitialization: (struct)

某种程度上,只写这样一个函数就可以
但这样会导致代码很难维护,所以需要将一些东西分离出来,方便开发

扩展配置文件

首先先在demo文件夹下面创建插件的配置文件gdexample.gdextension

# ./demo/gdexample.gdextension
[configuration]

entry_symbol = "gdexample_library_init"
compatibility_minimum = "4.2"

[libraries]
macos.debug = "res://addon/bin/ExtensionTest.dylib"
linux.debug = "res://addon/bin/ExtensionTest.so"
windows.debug = "res://addon/bin/ExtensionTest.dll"

entry_symbol表示函数入口符号,能对应上即可
下面的[libraries]指示Godot该在哪里找到动态链接库

扩展的其他符号定义

gdextension_interface.h只导出了必须的GDExtensionAPI
而Godot的很多其他部分如StringName的符号定义并未给出
所以需要有一个定义其他符号的文件
创建./src/defs.h文件,内容如下

#pragma once

#include <stdbool.h>
#include <stddef.h>
#include <stdint.h>

#if !defined(GDE_EXPORT)
#if defined(_WIN32)
#define GDE_EXPORT __declspec(dllexport)
#elif defined(__GNUC__)
#define GDE_EXPORT __attribute__((visibility("default")))
#else
#define GDE_EXPORT
#endif
#ifdef BUILD_32
#define STRING_NAME_SIZE 4
#else
#define STRING_NAME_SIZE 8
#endif
typedef struct
{
    uint8_t data[STRING_NAME_SIZE];
} StringName;

#endif // ! GDE_EXPORT

这里主要定义了两个东西,一个是GDE_Export宏,另一个是StringName的符号定义
GDE_Export宏需要添加在需要导出到动态链接库的函数声明中
StringName的类型大小信息可以在extension_api.json中寻找

API绑定

我们可以通过函数入口传入的p_get_proc_address获取需要的函数
为了方便调用与减少代码错误,这里为需要的用到的函数创建符号

// ./src/api.h
#pragma once
#include "gdextension_interface.h"
#include "defs.h"
extern GDExtensionClassLibraryPtr class_library;

extern struct Constructors
{
    GDExtensionInterfaceStringNameNewWithLatin1Chars string_name_new_with_latin1_chars;
} constructors;

extern struct Destructors
{
    GDExtensionPtrDestructor string_name_destructor;
} destructors;

extern struct API
{
    GDExtensionInterfaceClassdbRegisterExtensionClass2 classdb_register_extension_class2;
    GDExtensionInterfaceClassdbConstructObject classdb_construct_object;
    GDExtensionInterfaceObjectSetInstance object_set_instance;
    GDExtensionInterfaceObjectSetInstanceBinding object_set_instance_binding;
    GDExtensionInterfaceMemAlloc mem_alloc;
    GDExtensionInterfaceMemFree mem_free;
} api;

void load_api(GDExtensionInterfaceGetProcAddress p_get_proc_address);
// ./src/api.c
#include "api.h"

struct Constructors constructors;
struct Destructors destructors;
struct API api;

GDExtensionClassLibraryPtr class_library = NULL;

void load_api(GDExtensionInterfaceGetProcAddress p_get_proc_address)
{
    // Get helper functions first.
    GDExtensionInterfaceVariantGetPtrDestructor variant_get_ptr_destructor =
        (GDExtensionInterfaceVariantGetPtrDestructor)p_get_proc_address("variant_get_ptr_destructor");

    // API.
    api.classdb_register_extension_class2 = p_get_proc_address("classdb_register_extension_class2");

    // Constructors.
    constructors.string_name_new_with_latin1_chars =
        (GDExtensionInterfaceStringNameNewWithLatin1Chars)p_get_proc_address("string_name_new_with_latin1_chars");

    // Destructors.
    destructors.string_name_destructor = variant_get_ptr_destructor(GDEXTENSION_VARIANT_TYPE_STRING_NAME);
    api.classdb_register_extension_class2 = p_get_proc_address("classdb_register_extension_class2");
    api.classdb_construct_object =
        (GDExtensionInterfaceClassdbConstructObject)p_get_proc_address("classdb_construct_object");
    api.object_set_instance = p_get_proc_address("object_set_instance");
    api.object_set_instance_binding = p_get_proc_address("object_set_instance_binding");
    api.mem_alloc = (GDExtensionInterfaceMemAlloc)p_get_proc_address("mem_alloc");
    api.mem_free = (GDExtensionInterfaceMemFree)p_get_proc_address("mem_free");
}

这样通过全局字段绑定了需要使用的api,方便调用

自定义节点类型

现在可以开始写自定义节点的实现了

// ./src/gdextension.h
#pragma once
#include "gdextension_interface.h"
#include "defs.h"
// Struct to hold the node data.
typedef struct
{
    GDExtensionObjectPtr object; // Stores the underlying Godot object.
} CookieGDExample;

// Constructor for the node.
void gdexample_class_constructor(CookieGDExample *self);
// Destructor for the node.
void gdexample_class_destructor(CookieGDExample *self);
// Bindings.
void gdexample_class_bind_methods();

GDExtensionObjectPtr gdexample_class_create_instance(void *p_class_userdata);
void gdexample_class_free_instance(void *p_class_userdata, GDExtensionClassInstancePtr p_instance);
// ./src/gdextension.c
#include "gdexample.h"
#include "api.h"

void gdexample_class_constructor(CookieGDExample *self)
{
}

void gdexample_class_destructor(CookieGDExample *self)
{
}

void gdexample_class_bind_methods()
{
}

const GDExtensionInstanceBindingCallbacks gdexample_class_binding_callbacks = {
    .create_callback = NULL,
    .free_callback = NULL,
    .reference_callback = NULL,
};

GDExtensionObjectPtr gdexample_class_create_instance(void *p_class_userdata)
{
	// 先创建Sprite2D节点
    // 创建临时的StringName结构
    StringName class_name;
    // 给创建的StringName结构赋值"Sprite2D"
    constructors.string_name_new_with_latin1_chars(&class_name, "Sprite2D", false);
    // 通过ClassDB创建Sprite2D节点
    GDExtensionObjectPtr object = api.classdb_construct_object(&class_name);
    // 销毁临时的StringName数据
    destructors.string_name_destructor(&class_name);

    // 创建扩展类型节点
    // 给扩展类型节点分配内存
    CookieGDExample *self = (CookieGDExample *)api.mem_alloc(sizeof(CookieGDExample));
    // 调用扩展类型节点构造函数,这里演示为空
    gdexample_class_constructor(self);
    // 给扩展类型赋值
    self->object = object;

    // 配置扩展类型节点
    // 给临时StringName结构赋值"GDExample"
    constructors.string_name_new_with_latin1_chars(&class_name, "GDExample", false);
    // 设置创建的object
    api.object_set_instance(object, &class_name, self);
    // 配置object绑定的脚本之类的,跟这里填NULL即可
    api.object_set_instance_binding(object, class_library, self, &gdexample_class_binding_callbacks);
    // 销毁临时的StringName数据
    destructors.string_name_destructor(&class_name);

    return object;
}

void gdexample_class_free_instance(void *p_class_userdata, GDExtensionClassInstancePtr p_instance)
{
    if (p_instance == NULL)
    {
        return;
    }
    CookieGDExample *self = (CookieGDExample *)p_instance;
    gdexample_class_destructor(self);
    api.mem_free(self);
}

初始化模块

有上面的工作后,就可以开始初始化模块了

// ./src/init.h

#pragma once
#include "defs.h"
#include "gdextension_interface.h"

void initialize_gdexample_module(void *p_userdata, GDExtensionInitializationLevel p_level);
void deinitialize_gdexample_module(void *p_userdata, GDExtensionInitializationLevel p_level);
GDExtensionBool GDE_EXPORT gdexample_library_init(GDExtensionInterfaceGetProcAddress p_get_proc_address,
                                                  GDExtensionClassLibraryPtr p_library,
                                                  GDExtensionInitialization *r_initialization);
// ./src/init.c

#include "init.h"
#include "api.h"
#include "gdexample.h"

void initialize_gdexample_module(void *p_userdata, GDExtensionInitializationLevel p_level)
{
    if (p_level != GDEXTENSION_INITIALIZATION_SCENE)
    {
        return;
    }

    StringName class_name;
    constructors.string_name_new_with_latin1_chars(&class_name, "GDExample", false);
    StringName parent_class_name;
    constructors.string_name_new_with_latin1_chars(&parent_class_name, "Sprite2D", false);
	
	// 创建类型信息
    GDExtensionClassCreationInfo2 class_info = {
        .is_virtual = false,
        .is_abstract = false,
        .is_exposed = true,
        .set_func = NULL,
        .get_func = NULL,
        .get_property_list_func = NULL,
        .free_property_list_func = NULL,
        .property_can_revert_func = NULL,
        .property_get_revert_func = NULL,
        .validate_property_func = NULL,
        .notification_func = NULL,
        .to_string_func = NULL,
        .reference_func = NULL,
        .unreference_func = NULL,
        .create_instance_func = gdexample_class_create_instance,
        .free_instance_func = gdexample_class_free_instance,
        .recreate_instance_func = NULL,
        .get_virtual_func = NULL,
        .get_virtual_call_data_func = NULL,
        .call_virtual_with_data_func = NULL,
        .get_rid_func = NULL,
        .class_userdata = NULL,
    };

	// 把类型信息注册到ClassDB中
    api.classdb_register_extension_class2(class_library, &class_name, &parent_class_name, &class_info);

    // Bind methods.
    gdexample_class_bind_methods();

    // 销毁两个临时的StringName数据
    destructors.string_name_destructor(&class_name);
    destructors.string_name_destructor(&parent_class_name);
}

void deinitialize_gdexample_module(void *p_userdata, GDExtensionInitializationLevel p_level)
{
}

GDExtensionBool GDE_EXPORT gdexample_library_init(GDExtensionInterfaceGetProcAddress p_get_proc_address,
                                                  GDExtensionClassLibraryPtr p_library,
                                                  GDExtensionInitialization *r_initialization)
{
    class_library = p_library;
    load_api(p_get_proc_address);

    r_initialization->initialize = initialize_gdexample_module;
    r_initialization->deinitialize = deinitialize_gdexample_module;
    r_initialization->userdata = NULL;
    r_initialization->minimum_initialization_level = GDEXTENSION_INITIALIZATION_SCENE;

    return true;
}

GDExtensionClassCreationInfo2算是废弃了,4.5应该用GDExtensionClassCreationInfo5
(不过如果考虑给旧版本开发GDExtension,那应该使用旧版本的函数)

<0x03> 在Godot中使用

只需要创建新项目,然后将demo文件夹直接复制到项目文件中
需要注意gdexample.gdextension内的路径
不同于通过脚本实现的插件需要启用
GDExtension实现的插件在Godot读取到*.gdextension后,会自动开始加载需要的动态链接文件
当然,如果在初始化模块时遇到错误,会导致整个Godot直接崩溃

加载后,就可以在添加新节点中看到扩展定义的节点

使用C#开发GDExtension

上面提到Godot通过运行时加载动态链接库实现对引擎功能的扩充
而理论上,加载动态链接库实际上只要调用的符号对得上就可以,这是与语言无关的
也就是说,只要语言可以导出C符号的动态链接库,就可以写GDExtension
而C#恰好就可以通过AOT导出C符号的动态链接库,这就很有意思了

<0x00> 使用C#开发的前言

需要注意的是,在我研究到一半的时候,发现有项目在做这个事情了(godot-dotnet)
当然,这个项目还不像Rust或者Go的Binding那么完善稳定,不推荐在生产环境中使用
所以,这篇文章更多的是举一个例子,展示如何用C#编写AOT的代码
代码仓库

需要注意的是,编写AOT代码不仅需要.netSDK,还需要额外的工具来完成符号的导出
比如说Windows平台上,就需要安装Visual Studio的C++工作负载
主要是需要MSVC生成工具,其他平台可以参考微软的这篇教程

<0x01> 一些概念

unsafe代码块

在本项目中,我们需要用到一些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节点即说明我们写的扩展是正常运行的

使用 Hugo 构建
主题 StackJimmy 设计