GDExtension C 範例

前言

這是一個如何直接使用 C 語言操作 GDExtension 的簡單範例。請注意,API 並非設計給你直接使用,因此即使只是簡單範例,步驟也會相當繁瑣冗長。不過,這可以作為其他語言繫結的參考。如果你只是要繫結第三方函式庫,直接用 API 其實也沒問題。

在這個範例中,我們將建立一個自訂節點,依使用者給定的參數在畫面上移動一個精靈(Sprite)。雖然範例本身很簡單,但能展示 GDExtension 如何實作自訂類別、方法、屬性與訊號,也能讓你更理解 GDExtension API 的運作方式。

設定專案

你需要先準備以下幾項:

  • Godot 4.2(或更新版本)的可執行檔,

  • C 編譯器,

  • SCons 作為建置工具。

由於這是直接使用 API,因此不需要 godot-cpp 儲存庫

檔案結構

為了方便管理檔案,我們會主要分成兩個資料夾:

gdextension_c_example/
|
+--demo/                  # game example/demo to test the extension
|
+--src/                   # source code of the extension we are building

我們還需要從 Godot 原始碼中取得 gdextension_interface.h 標頭檔。你可以直接從 Godot 執行檔使用以下指令取得:

godot --dump-gdextension-interface

這會在目前資料夾產生該標頭檔,你可以將它複製到範例專案的 src 資料夾中。

最後,我們還需要一份 Godot API 參考的 JSON 檔案。這個檔案不會被程式直接引用,而是讓我們手動查詢、取得一些必要資訊。

要取得這個 JSON 檔,只要執行 Godot 執行檔即可:

godot --dump-extension-api

產生出來的 extension_api.json 檔案會在目前資料夾。建議複製到範例資料夾以方便使用。

備註

本範例針對 Godot 4.2 製作,但應該也能在更新版本運作。如果你需要支援其他版本,請務必從對應版本的 Godot 取得標頭檔及 JSON 檔。

建置系統

使用建置系統(build system)可以讓我們在撰寫 C 程式碼時輕鬆許多。這裡我們選擇 SCons,因為 Godot 本身也使用它。

以下的 SConstruct 範例是一個簡單的建置腳本,能自動針對你目前的作業系統(Linux、macOS 或 Windows)編譯擴充。這裡預設是除錯用(未最佳化)版本,並假設是 64 位元建置(某些範例程式碼會用到)。其他建置型態或跨平台編譯的說明就不在本教學範圍之內。請將此檔案存放在專案根目錄。

#!/bin/env python
from SCons.Script import Environment
from os import path
import sys

env = Environment()

# Set the target path and name.
target_path = "demo/bin/"
target_name = "libgdexample"

# Set the compiler and flags.
env.Append(CPPPATH=["src"])  # Add the src folder to the include path.
env.Append(CFLAGS=["-O0", "-g"])  # Make it a debug build.

# Use Clang on macOS.
if sys.platform == "darwin":
    env["CC"] = "clang"

# Add all C files in "src" folder as sources.
sources = env.Glob("src/*.c")

# Create a shared library.
library = env.SharedLibrary(
    target=path.join(target_path, target_name),
    source=sources,
)

# Set the library as the default target.
env.Default(library)

這樣寫會自動包含 src 資料夾下的所有 C 檔案,因此新增原始碼時無需修改這個建置腳本。

初始化擴充元件

第一個要處理的程式碼片段就是初始化擴充元件。這部分讓 Godot 能正確辨識我們的 GDExtension 提供哪些類別、外掛等內容。

src 資料夾中建立 init.h 檔案,內容如下:

#ifndef INIT_H
#define INIT_H

#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);

#endif // INIT_H

這裡宣告的函式其簽名都符合 GDExtension API 的要求。

請注意這裡有包含 defs.h。這個檔案是我們自訂的輔助工具,用來簡化擴充元件的程式碼。目前它只定義了 GDE_EXPORT,這是一個巨集,用來讓這些函式能正確地在動態連結庫中公開,以便 Godot 可以呼叫。這能幫助我們抽象掉不同編譯器的細節。

src 資料夾中建立 defs.h 檔案,內容如下:

#ifndef DEFS_H
#define DEFS_H

#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
#endif // ! GDE_EXPORT

#endif // DEFS_H

這裡也一併包含了一些標準標頭檔,讓後續操作更方便。只要引入 defs.h,這些標頭檔也會自動包含進來。

接下來,我們來實作剛剛宣告的那些函式。請在 src 資料夾中建立 init.c,並加入以下程式碼:

#include "init.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)
{
    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;
}

這段程式碼是設定 Godot 所期望的初始化資料。初始化與反初始化的函式都在這裡註冊,Godot 會在需要時呼叫。此外,還可以設定初始化等級(每個擴充元件可不同)。因為我們要加自訂節點,所以 SCENE 等級就足夠了。

我們稍後會補上 initialize_gdexample_module() 函式的內容,用於註冊自訂類別。

基本類別

為了建立一個實際的節點,我們首先會建立一個 C 結構(struct)來儲存資料與函式,這些函式將作為「方法」。我們打算讓這個自訂節點繼承自 Sprite2D

src 資料夾中建立 gdexample.h 檔案,內容如下:

#ifndef GDEXAMPLE_H
#define GDEXAMPLE_H

#include "gdextension_interface.h"

#include "defs.h"

// Struct to hold the node data.
typedef struct
{
    // Metadata.
    GDExtensionObjectPtr object; // Stores the underlying Godot object.
} GDExample;

// Constructor for the node.
void gdexample_class_constructor(GDExample *self);

// Destructor for the node.
void gdexample_class_destructor(GDExample *self);

// Bindings.
void gdexample_class_bind_methods();

#endif // GDEXAMPLE_H

這裡要注意的是 object 欄位,它儲存一個指向 Godot 物件的指標,還有 gdexample_class_bind_methods() 函式,這個函式會註冊我們自訂類別的中繼資料(屬性、方法、訊號)。後者其實可以直接在註冊類別時做,但分開會讓結構更清晰,也方便讓每個類別自己註冊自己的中繼資料。

object 欄位之所以必要,是因為我們的類別要繼承自 Godot 內建的類別。由於我們無法直接繼承(畢竟我們不是直接操作原始碼,而且 C 沒有類別),所以我們會請 Godot 建立一個它已知型態的物件,然後掛上我們的擴充。這樣在呼叫父類別方法時就能取得對應的物件指標。

接著來建立這個標頭檔對應的原始碼檔案。在 src 資料夾中建立 gdexample.c,並加入以下程式碼:

#include "gdexample.h"

void gdexample_class_constructor(GDExample *self)
{
}

void gdexample_class_destructor(GDExample *self)
{
}

void gdexample_class_bind_methods()
{
}

目前這些函式還沒有要實作的內容,所以暫時先留空。

下一步是註冊我們的類別。不過要註冊時,我們需要建立一個 StringName,而這又需要從 GDExtension API 取得相關函式。因為這類動作會重複多次,還會用到其他功能,讓我們來建立一個包裝 API 來簡化這些繁瑣的步驟。

包裝 API

我們先在 src 資料夾中建立一個 api.h 檔案:

#ifndef API_H
#define API_H

/*
This file works as a collection of helpers to call the GDExtension API
in a less verbose way, as well as a cache for methods from the discovery API,
just so we don't have to keep loading the same methods again.
*/

#include "gdextension_interface.h"

#include "defs.h"

extern GDExtensionClassLibraryPtr class_library;

// API methods.

struct Constructors
{
    GDExtensionInterfaceStringNameNewWithLatin1Chars string_name_new_with_latin1_chars;
} constructors;

struct Destructors
{
    GDExtensionPtrDestructor string_name_destructor;
} destructors;

struct API
{
    GDExtensionInterfaceClassdbRegisterExtensionClass2 classdb_register_extension_class2;
} api;

void load_api(GDExtensionInterfaceGetProcAddress p_get_proc_address);



#endif // API_H

之後我們會在這個檔案裡加入更多輔助功能。目前只定義了一個能用 C 字串(Latin-1 編碼)建立 StringName 的函式指標,和一個負責釋放 StringName 記憶體的函式,這樣才不會造成記憶體洩漏,以及註冊類別所需的函式,這是我們此階段的主要目標。

這裡也保留一個 class_library 的參考。這是在初始化擴充元件時 Godot 提供給我們的,註冊自訂內容時要交給 Godot,如此 Godot 才知道是哪個擴充元件在呼叫相關功能。

這裡還有一個函式專門從 GDExtension API 載入這些函式指標。

接著來撰寫這個標頭檔對應的原始碼檔案。在 src 資料夾裡新增 api.c,並加入下列程式碼:

#include "api.h"

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 = p_get_proc_address("string_name_new_with_latin1_chars");

    // Destructors.
    destructors.string_name_destructor = variant_get_ptr_destructor(GDEXTENSION_VARIANT_TYPE_STRING_NAME);
}

這裡最重要的是 p_get_proc_address。這是 GDExtension API 在初始化時傳給我們的一個函式,你可以用它來依名稱取得 API 上的特定函式。我們會把取得的結果快取起來,這樣就不用在每個地方都保存 p_get_proc_address,只要用我們的包裝器即可。

一開始我們會請求 variant_get_ptr_destructor() 這個函式。這個函式只在這裡用得到,所以不加到我們的包裝器,只在本地變數快取。這裡的型別轉換是為了消除編譯器警告。

接著取得建立 StringName 的函式,這正是我們前面提到需要的功能。這個函式會被存放到 constructors 結構裡。

然後我們利用剛取得的 variant_get_ptr_destructor() 函式,搭配 gdextension_interface.h API 的列舉值,查詢 StringName 的解構(釋放)函式。其他型態的解構函式也能用同樣方式取得,但這裡只拿我們範例會用到的部分。

最後,我們取得 classdb_register_extension_class2() 函式,這是註冊自訂類別時會用到的。

備註

你可能會好奇函式名稱中的 2。這代表這是第二版的函式。舊版還保留是為了向下相容,但既然我們有新版可用,建議直接用新的,因為本範例不打算支援舊版 Godot。

你可以從 gdextension_interface.h 標頭檔看到每個函式是在哪個 Godot 版本引入的。

這裡也定義了 class_library 變數,會在初始化時設定。

說到初始化,現在我們要修改 init.c 檔案,把剛剛新增的內容補齊:

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);

    ...

這裡我們設定所需的 class_library,並呼叫剛加進來的 load_api() 函式。也別忘了在檔案頂端引入新的標頭檔:

#include "init.h"

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

既然都到這裡了,也順便註冊我們的新自訂類別。請將 initialize_gdexample_module() 函式內容補上:

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

    // Register class.
    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,
    };

    api.classdb_register_extension_class2(class_library, &class_name, &parent_class_name, &class_info);

    // Bind methods.
    gdexample_class_bind_methods();

    // Destruct things.
    destructors.string_name_destructor(&class_name);
    destructors.string_name_destructor(&parent_class_name);
}

類別資訊的 struct 這段是這裡最重要的內容。除了 create_instance_funcfree_instance_func 之外,其他欄位都不是必填。這兩個函式我們還沒實作,等下會補上。另外要注意,這個初始化函式會根據等級(level)重複呼叫,我們只在 SCENE 等級時進行類別註冊。

另一個還沒定義的是 StringName。這是一個不透明的 struct,用來在我們的擴充元件裡存放 Godot 的 StringName 資料。我們會在 defs.h 裡正確定義它:

...
// The sizes can be obtained from the extension_api.json file.
#ifdef BUILD_32
#define STRING_NAME_SIZE 4
#else
#define STRING_NAME_SIZE 8
#endif

// Types.

typedef struct
{
    uint8_t data[STRING_NAME_SIZE];
} StringName;

#endif // DEFS_H

如註解所述,這些型態的大小可以從我們先前生成的 extension_api.json 檔案裡的 builtin_class_sizes 屬性找到。這裡預設沒定義 BUILD_32,因為我們假設都是用 64 位元 Godot。如果你需要 32 位元,記得可以在 SConstruct 裡加上 env.Append(CPPDEFINES=["BUILD_32"])

// Types. 註解暗示我們之後會再加更多型態到這個檔案,這裡先不展開。

這裡的 StringName struct 只是用來存放 Godot 的資料,所以裡面的內容我們不用太在意。實際上它只是個指向堆積資料的指標。當我們需要自行配置 StringName 的記憶體時(例如註冊類別時),就會用到這個 struct。

回到類別註冊部分,我們需要實作建立與釋放物件的函式。這些是自訂類別專用的,所以宣告在 gdexample.h

...
// 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);
...

在實作這些函式前,我們還需要幾個 API。我們需要記憶體配置與釋放的方式。雖然可以用傳統的 malloc(),但這裡改用 Godot 的記憶體管理函式。另外還必須有建立 Godot 物件並與自訂實例綁定的方法。

所以現在來修改 api.h,加上這些新函式:

...
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;

然後在 api.c 裡修改 load_api() 函式,把這些新函式指標抓出來:

...
void load_api(GDExtensionInterfaceGetProcAddress p_get_proc_address)
{
    ...
    // API.
    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");
}

現在可以回到 gdexample.c,實作這些新函式,記得要引入 api.h 標頭檔:

#include "gdexample.h"

#include "api.h"

...

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

GDExtensionObjectPtr gdexample_class_create_instance(void *p_class_userdata)
{
    // Create native Godot object;
    StringName class_name;
    constructors.string_name_new_with_latin1_chars(&class_name, "Sprite2D", false);
    GDExtensionObjectPtr object = api.classdb_construct_object(&class_name);
    destructors.string_name_destructor(&class_name);

    // Create extension object.
    GDExample *self = (GDExample *)api.mem_alloc(sizeof(GDExample));
    gdexample_class_constructor(self);
    self->object = object;

    // Set the extension instance in the native Godot object.
    constructors.string_name_new_with_latin1_chars(&class_name, "GDExample", false);
    api.object_set_instance(object, &class_name, self);
    api.object_set_instance_binding(object, class_library, self, &gdexample_class_binding_callbacks);
    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;
    }
    GDExample *self = (GDExample *)p_instance;
    gdexample_class_destructor(self);
    api.mem_free(self);
}

建立物件時,首先會建立一個新的 Sprite2D 物件(我們類別的父類別)。然後配置自訂 struct 的記憶體,呼叫建構子,並將 Godot 物件指標存進 struct(如前所述)。

接著將我們自訂 struct 綁定為實例資料。這樣 Godot 就能知道這個物件是自訂類別的實例,才會正確呼叫我們的自訂方法,並且回傳這些資料。

請注意,這裡回傳的是我們建立的 Godot 物件,而不是自訂 struct。

至於 gdextension_free_instance(),只需呼叫解構子並釋放我們自訂資料配置的記憶體。Godot 物件本身不需我們解構,這會由引擎自動處理。

範例專案

現在我們已經能建立和釋放自訂物件,該來實際測試了。請打開 Godot 並在 demo 資料夾中建立一個新專案。如果你之前已經編譯過擴充元件,專案管理員可能會警告資料夾非空,這次可以放心無視這個警告。

如果你還沒編譯這個擴充元件,現在就該編譯了。打開終端機(或命令提示字元),切換到擴充元件的根目錄,執行 scons。這個擴充很簡單,應該會很快編譯完畢。

然後,在 demo 資料夾中建立 gdexample.gdextension 檔案。這是一個 Godot 資源檔,用來描述這個擴充元件,讓引擎能正確載入它。內容如下:

[configuration]

entry_symbol = "gdexample_library_init"
compatibility_minimum = "4.2"

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

可以看到 gdexample_library_init() 就是我們在 init.c 定義的函式名稱。這兩者必須完全相同,因為 Godot 就是靠這個名稱來呼叫擴充元件的進入點。

我們同時將相容性最低版本設為 4.2,因為本範例是以此版本為目標。之後的 Godot 版本也應該能運作。如果你將來用到新版 Godot 的新功能,記得要把這個值設成相對應的版本。更多資訊可參考 版本相容性

[libraries] 區段中,我們設定了不同平台下共用函式庫的路徑。這裡只有除錯版本,因為本範例是針對除錯用途。你可以用 feature tags 來細分設定,例如提供釋出版本、加入更多目標作業系統,或同時提供 32 位元和 64 位元執行檔。

你也可以在這個檔案中加入函式庫相依性和自訂類別圖示,不過這部分不屬於本教學範圍。

存檔後回到 Godot 編輯器,擴充元件應會自動載入。目前不會看到什麼變化,因為擴充元件只註冊了一個新類別。要使用這個類別,請在場景中加一個 Node2D 當根節點(建議移到檢視區中央),再新增子節點。在「建立新節點」對話框搜尋 "GDExample"(就是我們的類別名稱),應該會看到它出現在清單中。如果沒看到,代表 Godot 沒有正確載入擴充元件,可以考慮重啟編輯器,並檢查前述步驟是否有遺漏。

我們的自訂類別是從 Sprite2D 繼承的,因此在屬性面板會有 Texture 屬性。請把它指向專案建立時 Godot 自動產生的 icon.svg。將這個場景存成 main.tscn,然後執行。也可以順便設為主場景,方便測試。

../../../_images/gdextension_c_running.webp

完成!我們已經讓自訂節點在 Godot 執行起來了。不過現在它還沒有任何行為,與一般 Sprite2D 節點沒什麼差別。接下來我們會加入自訂方法與屬性,讓它能做些事情。

自訂方法

在擴充元件中很常見的事情就是為自訂類別建立方法,並將這些方法公開給 Godot API。我們會先建立一組 getter 和 setter,這樣之後才能把屬性綁定到這些方法。

首先,請在 struct 裡加上新的欄位來保存 amplitudespeed 的值,我們之後要讓節點有行為時會用到。請修改 gdexample.h,在 GDExample struct 裡加這些欄位:

...

typedef struct
{
    // Public properties.
    double amplitude;
    double speed;
    // Metadata.
    GDExtensionObjectPtr object; // Stores the underlying Godot object.
} GDExample;

...

同一個檔案裡,在解構子之後,宣告這些 getter 和 setter。

...

// Destructor for the node.
void gdexample_class_destructor(GDExample *self);

// Properties.
void gdexample_class_set_amplitude(GDExample *self, double amplitude);
double gdexample_class_get_amplitude(const GDExample *self);
void gdexample_class_set_speed(GDExample *self, double speed);
double gdexample_class_get_speed(const GDExample *self);

...

In the gdexample.c file, we will initialize these values in the constructor and add the implementations for those new functions, which are quite trivial:

void gdexample_class_constructor(GDExample *self)
{
    self->amplitude = 10.0;
    self->speed = 1.0;
}

void gdexample_class_set_amplitude(GDExample *self, double amplitude)
{
    self->amplitude = amplitude;
}

double gdexample_class_get_amplitude(const GDExample *self)
{
    return self->amplitude;
}

void gdexample_class_set_speed(GDExample *self, double speed)
{
    self->speed = speed;
}

double gdexample_class_get_speed(const GDExample *self)
{
    return self->speed;
}

為了讓 Godot 呼叫這些簡單函式時能正常運作,我們還需要一些包裝器來協助我們正確地和引擎之間轉換資料。

首先,我們要為 ptrcall 建立包裝器。Godot 在已知型別完全符合時會使用 ptrcall(不需用 Variant)。我們會需要兩種包裝器:一種是沒參數、回傳 double``(給 getter 用),另一種是接收一個 ``double 參數且不回傳值(給 setter 用)。

請在 api.h 檔案中宣告這些包裝函式:

void ptrcall_0_args_ret_float(void *method_userdata, GDExtensionClassInstancePtr p_instance, const GDExtensionConstTypePtr *p_args, GDExtensionTypePtr r_ret);
void ptrcall_1_float_arg_no_ret(void *method_userdata, GDExtensionClassInstancePtr p_instance, const GDExtensionConstTypePtr *p_args, GDExtensionTypePtr r_ret);

這兩個函式都符合 gdextension_interface.h 裡定義的 GDExtensionClassMethodPtrCall 型別。這裡用 float 當名稱,是因為在 Godot 中 float 實際上是 double 精度,所以我們沿用這個慣例。

然後在 api.c 檔案中實作這些函式:

void ptrcall_0_args_ret_float(void *method_userdata, GDExtensionClassInstancePtr p_instance, const GDExtensionConstTypePtr *p_args, GDExtensionTypePtr r_ret)
{
    // Call the function.
    double (*function)(void *) = method_userdata;
    *((double *)r_ret) = function(p_instance);
}

void ptrcall_1_float_arg_no_ret(void *method_userdata, GDExtensionClassInstancePtr p_instance, const GDExtensionConstTypePtr *p_args, GDExtensionTypePtr r_ret)
{
    // Call the function.
    void (*function)(void *, double) = method_userdata;
    function(p_instance, *((double *)p_args[0]));
}

method_userdata 參數是我們給 Godot 的自訂值,這裡我們會傳入要呼叫的函式指標。因此會先轉型成正確函式型別,再傳入參數執行,最後設回傳值。

p_instance 參數就是我們自訂類別的實例,是在建立物件時用 object_set_instance() 綁定進去的。

p_args 是參數陣列。請注意它存的是「值的指標」。所以在傳給我們的函式時會解參考。參數數量會在綁定方法時指定(我們很快會做),如果有預設參數也會包含在內。

最後,r_ret 是回傳值的指標,跟參數一樣會是正確的型別。如果函式沒回傳值,就不能設定它。

注意這裡型別和參數數量都是完全固定的。如果要支援其他型別或參數數量,還得建立更多包裝器。這也可以用程式碼產生器自動化,不過這不在本教學範圍內。

雖然 ptrcall 適用於型別完全精確的情境,但有時 Godot 不一定知道型別(比如從 GDScript 等動態語言呼叫),這時就會用一般的 call。所以綁定時我們還是必須提供這類包裝器。

請在 api.h 裡再宣告兩個包裝器:

void call_0_args_ret_float(void *method_userdata, GDExtensionClassInstancePtr p_instance, const GDExtensionConstVariantPtr *p_args, GDExtensionInt p_argument_count, GDExtensionVariantPtr r_return, GDExtensionCallError *r_error);
void call_1_float_arg_no_ret(void *method_userdata, GDExtensionClassInstancePtr p_instance, const GDExtensionConstVariantPtr *p_args, GDExtensionInt p_argument_count, GDExtensionVariantPtr r_return, GDExtensionCallError *r_error);

這兩個包裝器型別是 GDExtensionClassMethodCall,跟前面略有不同。它接收的是 Variant 指標(不是精確型別),還會收到參數數量與一個錯誤結構,讓你在有錯誤時能設回去。

為了檢查型別、與 Variant 互動,我們還需要幾個 GDExtension API 的函式。因此要擴充我們的包裝 struct:

struct Constructors {
    ...
    GDExtensionVariantFromTypeConstructorFunc variant_from_float_constructor;
    GDExtensionTypeFromVariantConstructorFunc float_from_variant_constructor;
} constructors;

struct API
{
    ...
    GDExtensionInterfaceGetVariantFromTypeConstructor get_variant_from_type_constructor;
    GDExtensionInterfaceGetVariantToTypeConstructor get_variant_to_type_constructor;
    GDExtensionInterfaceVariantGetType variant_get_type;
} api;

這些成員的名稱就說明了一切。有幾個是建立、取得浮點數 Variant 的建構子,還有一些輔助函式能取得這些建構子,以及能查詢 Variant 型別的函式。

跟之前一樣,請在 api.cload_api() 裡把這些函式指標抓出來:

void load_api(GDExtensionInterfaceGetProcAddress p_get_proc_address)
{
    ...

    // API.
    ...
    api.get_variant_from_type_constructor = (GDExtensionInterfaceGetVariantFromTypeConstructor)p_get_proc_address("get_variant_from_type_constructor");
    api.get_variant_to_type_constructor = (GDExtensionInterfaceGetVariantToTypeConstructor)p_get_proc_address("get_variant_to_type_constructor");
    api.variant_get_type = (GDExtensionInterfaceVariantGetType)p_get_proc_address("variant_get_type");
    ...

    // Constructors.
    ...
    constructors.variant_from_float_constructor = api.get_variant_from_type_constructor(GDEXTENSION_VARIANT_TYPE_FLOAT);
    constructors.float_from_variant_constructor = api.get_variant_to_type_constructor(GDEXTENSION_VARIANT_TYPE_FLOAT);
    ...
}

現在我們有了這些函式指標,就能在同一個檔案中實作 call 包裝器:

void call_0_args_ret_float(void *method_userdata, GDExtensionClassInstancePtr p_instance, const GDExtensionConstVariantPtr *p_args, GDExtensionInt p_argument_count, GDExtensionVariantPtr r_return, GDExtensionCallError *r_error)
{
    // Check argument count.
    if (p_argument_count != 0)
    {
        r_error->error = GDEXTENSION_CALL_ERROR_TOO_MANY_ARGUMENTS;
        r_error->expected = 0;
        return;
    }

    // Call the function.
    double (*function)(void *) = method_userdata;
    double result = function(p_instance);
    // Set resulting Variant.
    constructors.variant_from_float_constructor(r_return, &result);
}

void call_1_float_arg_no_ret(void *method_userdata, GDExtensionClassInstancePtr p_instance, const GDExtensionConstVariantPtr *p_args, GDExtensionInt p_argument_count, GDExtensionVariantPtr r_return, GDExtensionCallError *r_error)
{
    // Check argument count.
    if (p_argument_count < 1)
    {
        r_error->error = GDEXTENSION_CALL_ERROR_TOO_FEW_ARGUMENTS;
        r_error->expected = 1;
        return;
    }
    else if (p_argument_count > 1)
    {
        r_error->error = GDEXTENSION_CALL_ERROR_TOO_MANY_ARGUMENTS;
        r_error->expected = 1;
        return;
    }

    // Check the argument type.
    GDExtensionVariantType type = api.variant_get_type(p_args[0]);
    if (type != GDEXTENSION_VARIANT_TYPE_FLOAT)
    {
        r_error->error = GDEXTENSION_CALL_ERROR_INVALID_ARGUMENT;
        r_error->expected = GDEXTENSION_VARIANT_TYPE_FLOAT;
        r_error->argument = 0;
        return;
    }

    // Extract the argument.
    double arg1;
    constructors.float_from_variant_constructor(&arg1, (GDExtensionVariantPtr)p_args[0]);

    // Call the function.
    void (*function)(void *, double) = method_userdata;
    function(p_instance, arg1);
}

這些包裝函式雖然比較長,但很容易理解。首先會檢查參數數量對不對,不對就設錯誤結構然後 return。有一個參數的那個還會檢查型別對不對,這很重要,因為 Variant 型別不對時取值會導致崩潰。

之後會用先前設好的建構子把參數取出來。沒參數的那個則是在呼叫函式後設回傳值。注意他們用的是 double 指標,因為建構子就是吃這種型別。

在實際綁定方法前,我們需要一個能建立 GDExtensionPropertyInfo 物件的方法。雖然可以直接在綁定函式裡寫,但我們會重複多次,還會用來綁定屬性,所以最好先做一個輔助函式。

請在 api.h 檔案中加入這兩個輔助函式:

// Create a PropertyInfo struct.
GDExtensionPropertyInfo make_property(
    GDExtensionVariantType type,
    const char *name);

GDExtensionPropertyInfo make_property_full(
    GDExtensionVariantType type,
    const char *name,
    uint32_t hint,
    const char *hint_string,
    const char *class_name,
    uint32_t usage_flags);

void destruct_property(GDExtensionPropertyInfo *info);

第一個函式是第二個的簡化版,通常我們不需要填所有屬性參數,用預設值就好。我們還有個解構 PropertyInfo 的函式,因為我們要建立 String 和 StringName,這些都記得要正確釋放。

說到這裡,我們還需要建立和釋放 String 的方式,所以會再擴充目前這個 struct,並加入一個新的 API 函式來真正綁定我們的自訂方法。

struct Constructors
{
    ...
    GDExtensionInterfaceStringNewWithUtf8Chars string_new_with_utf8_chars;
} constructors;

struct Destructors
{
    ...
    GDExtensionPtrDestructor string_destructor;
} destructors;

struct API
{
    ...
    GDExtensionInterfaceClassdbRegisterExtensionClassMethod classdb_register_extension_class_method;
} api;

在實作前,我們先回頭到 defs.h,把 String 型別的大小和幾個列舉值加進去:

// The sizes can be obtained from the extension_api.json file.
#ifdef BUILD_32
#define STRING_SIZE 4
#define STRING_NAME_SIZE 4
#else
#define STRING_SIZE 8
#define STRING_NAME_SIZE 8
#endif

...

typedef struct
{
    uint8_t data[STRING_SIZE];
} String;

// Enums.

typedef enum
{
    PROPERTY_HINT_NONE = 0,
} PropertyHint;

typedef enum
{
    PROPERTY_USAGE_NONE = 0,
    PROPERTY_USAGE_STORAGE = 2,
    PROPERTY_USAGE_EDITOR = 4,
    PROPERTY_USAGE_DEFAULT = PROPERTY_USAGE_STORAGE | PROPERTY_USAGE_EDITOR,
} PropertyUsageFlags;

雖然大小和 StringName 一樣,但用不同名稱更清楚。

這裡的 enum 只是給數字一個有意義的名字。相關資訊可在 extension_api.json 找到。這裡只放本教學需要用到的值,讓內容精簡一點。

現在回到 api.c,我們要載入剛剛加進去的新 API 函式指標。

void load_api(GDExtensionInterfaceGetProcAddress p_get_proc_address)
{
    ...
    // API
    ...
    api.classdb_register_extension_class_method = p_get_proc_address("classdb_register_extension_class_method");

    // Constructors.
    ...
    constructors.string_new_with_utf8_chars = p_get_proc_address("string_new_with_utf8_chars");

    // Destructors.
    ...
    destructors.string_destructor = variant_get_ptr_destructor(GDEXTENSION_VARIANT_TYPE_STRING);
}

接著我們可以實作建立 PropertyInfo struct 的函式。

GDExtensionPropertyInfo make_property(
    GDExtensionVariantType type,
    const char *name)
{

    return make_property_full(type, name, PROPERTY_HINT_NONE, "", "", PROPERTY_USAGE_DEFAULT);
}

GDExtensionPropertyInfo make_property_full(
    GDExtensionVariantType type,
    const char *name,
    uint32_t hint,
    const char *hint_string,
    const char *class_name,
    uint32_t usage_flags)
{

    StringName *prop_name = api.mem_alloc(sizeof(StringName));
    constructors.string_name_new_with_latin1_chars(prop_name, name, false);
    String *prop_hint_string = api.mem_alloc(sizeof(String));
    constructors.string_new_with_utf8_chars(prop_hint_string, hint_string);
    StringName *prop_class_name = api.mem_alloc(sizeof(StringName));
    constructors.string_name_new_with_latin1_chars(prop_class_name, class_name, false);

    GDExtensionPropertyInfo info = {
        .name = prop_name,
        .type = type,
        .hint = hint,
        .hint_string = prop_hint_string,
        .class_name = prop_class_name,
        .usage = usage_flags,
    };

    return info;
}

void destruct_property(GDExtensionPropertyInfo *info)
{
    destructors.string_name_destructor(info->name);
    destructors.string_destructor(info->hint_string);
    destructors.string_name_destructor(info->class_name);
    api.mem_free(info->name);
    api.mem_free(info->hint_string);
    api.mem_free(info->class_name);
}

簡易版的 make_property() 只是用一些預設值呼叫完整版本。這些值的意義超出本教學範圍,詳細請見 Object 類別 介紹方法與屬性綁定的頁面。

完整版本內容會多一點。首先會分別建立 StringStringName,配置記憶體並呼叫建構子。然後建立 GDExtensionPropertyInfo struct,設好所有欄位,最後回傳這個 struct。

destruct_property() 很單純,就是呼叫之前建立的物件解構子並釋放記憶體。

現在再回到 api.h,來加上實際綁定方法所需的函式:

// Version for 0 arguments, with return.
void bind_method_0_r(
    const char *class_name,
    const char *method_name,
    void *function,
    GDExtensionVariantType return_type);

// Version for 1 argument, no return.
void bind_method_1(
    const char *class_name,
    const char *method_name,
    void *function,
    const char *arg1_name,
    GDExtensionVariantType arg1_type);

接著回到 api.c 檔案實作這些函式:

// Version for 0 arguments, with return.
void bind_method_0_r(
    const char *class_name,
    const char *method_name,
    void *function,
    GDExtensionVariantType return_type)
{
    StringName method_name_string;
    constructors.string_name_new_with_latin1_chars(&method_name_string, method_name, false);

    GDExtensionClassMethodCall call_func = call_0_args_ret_float;
    GDExtensionClassMethodPtrCall ptrcall_func = ptrcall_0_args_ret_float;

    GDExtensionPropertyInfo return_info = make_property(return_type, "");

    GDExtensionClassMethodInfo method_info = {
        .name = &method_name_string,
        .method_userdata = function,
        .call_func = call_func,
        .ptrcall_func = ptrcall_func,
        .method_flags = GDEXTENSION_METHOD_FLAGS_DEFAULT,
        .has_return_value = true,
        .return_value_info = &return_info,
        .return_value_metadata = GDEXTENSION_METHOD_ARGUMENT_METADATA_NONE,
        .argument_count = 0,
    };

    StringName class_name_string;
    constructors.string_name_new_with_latin1_chars(&class_name_string, class_name, false);

    api.classdb_register_extension_class_method(class_library, &class_name_string, &method_info);

    // Destruct things.
    destructors.string_name_destructor(&method_name_string);
    destructors.string_name_destructor(&class_name_string);
    destruct_property(&return_info);
}

// Version for 1 argument, no return.
void bind_method_1(
    const char *class_name,
    const char *method_name,
    void *function,
    const char *arg1_name,
    GDExtensionVariantType arg1_type)
{

    StringName method_name_string;
    constructors.string_name_new_with_latin1_chars(&method_name_string, method_name, false);

    GDExtensionClassMethodCall call_func = call_1_float_arg_no_ret;
    GDExtensionClassMethodPtrCall ptrcall_func = ptrcall_1_float_arg_no_ret;

    GDExtensionPropertyInfo args_info[] = {
        make_property(arg1_type, arg1_name),
    };
    GDExtensionClassMethodArgumentMetadata args_metadata[] = {
        GDEXTENSION_METHOD_ARGUMENT_METADATA_NONE,
    };

    GDExtensionClassMethodInfo method_info = {
        .name = &method_name_string,
        .method_userdata = function,
        .call_func = call_func,
        .ptrcall_func = ptrcall_func,
        .method_flags = GDEXTENSION_METHOD_FLAGS_DEFAULT,
        .has_return_value = false,
        .argument_count = 1,
        .arguments_info = args_info,
        .arguments_metadata = args_metadata,
    };

    StringName class_name_string;
    constructors.string_name_new_with_latin1_chars(&class_name_string, class_name, false);

    api.classdb_register_extension_class_method(class_library, &class_name_string, &method_info);

    // Destruct things.
    destructors.string_name_destructor(&method_name_string);
    destructors.string_name_destructor(&class_name_string);
    destruct_property(&args_info[0]);
}

這兩個函式很類似。首先會用方法名稱建立一個 StringName``(因為函式結束後不需要保留,所以放在堆疊即可)。接著建立本地變數來保存 ``call_funcptrcall_func,指向我們剛剛定義的輔助函式。

接下來兩者稍有不同。第一個會為回傳值建立一個屬性(名稱留空,因為用不到)。另一個則為參數建立屬性陣列(這裡只有一個元素)。這裡還有一個中繼資料陣列,如果參數有特殊需求(例如 int 是 32 位元而不是預設的 64 位元)可以用到。

然後會根據每種情境建立 GDExtensionClassMethodInfo,再建立類別名稱的 StringName,將方法與類別關聯起來。接著用 API 函式把這個方法綁定到類別。最後把所有建立的物件解構掉,避免記憶體洩漏。

備註

這些綁定輔助函式用的是我們前面寫的 call 輔助函式,所以只能接受 Godot 的 FLOAT 型別(在 C 裡等於 double)。如果你要支援其他型別,必須檢查參數與回傳型別再挑對應的 callback。這裡沒做只是為了讓範例不要太冗長。

現在有了綁定方法的機制,我們可以在自訂類別中真正把方法綁定起來。請在 gdexample.c 裡把 gdexample_class_bind_methods() 函式補齊:

void gdexample_class_bind_methods()
{
    bind_method_0_r("GDExample", "get_amplitude", gdexample_class_get_amplitude, GDEXTENSION_VARIANT_TYPE_FLOAT);
    bind_method_1("GDExample", "set_amplitude", gdexample_class_set_amplitude, "amplitude", GDEXTENSION_VARIANT_TYPE_FLOAT);

    bind_method_0_r("GDExample", "get_speed", gdexample_class_get_speed, GDEXTENSION_VARIANT_TYPE_FLOAT);
    bind_method_1("GDExample", "set_speed", gdexample_class_set_speed, "speed", GDEXTENSION_VARIANT_TYPE_FLOAT);
}

這個函式已經在初始化流程中被呼叫,所以這裡就可以先告一段落。有了前面的基礎建設,這個函式就變得非常直覺。你可以發現如果直接在這裡寫所有綁定程式會很冗長,也很難維護。用這種方式,未來要再加新方法也會方便許多。

現在編譯後重開範例專案,表面上看不出差異,因為只是多了兩個方法。你可以在編輯器的說明搜尋 GDExample,確認它們確實有被註冊到說明頁面裡。

../../../_images/gdextension_c_methods_doc.webp

自訂屬性

現在我們已經有 getter 和 setter 綁定好,可以繼續建立真正的屬性,讓它們顯示在 Godot 編輯器的屬性面板。

由於前面已經做了大量準備,現在只要做幾件小事就能綁定屬性了。首先,請在 api.h 加入一個新的 API 函式:

struct API {
    ...
    GDExtensionInterfaceClassdbRegisterExtensionClassProperty classdb_register_extension_class_property;
} api;

同時在這裡宣告一個綁定屬性的函式:

void bind_property(
    const char *class_name,
    const char *name,
    GDExtensionVariantType type,
    const char *getter,
    const char *setter);

api.c 檔案中載入這個新的 API 函式指標:

void load_api(GDExtensionInterfaceGetProcAddress p_get_proc_address)
{
    // API
    ...
    api.classdb_register_extension_class_property = p_get_proc_address("classdb_register_extension_class_property");

    ...
}

然後在同一個檔案裡實作我們的新輔助函式:

void bind_property(
    const char *class_name,
    const char *name,
    GDExtensionVariantType type,
    const char *getter,
    const char *setter)
{
    StringName class_string_name;
    constructors.string_name_new_with_latin1_chars(&class_string_name, class_name, false);
    GDExtensionPropertyInfo info = make_property(type, name);
    StringName getter_name;
    constructors.string_name_new_with_latin1_chars(&getter_name, getter, false);
    StringName setter_name;
    constructors.string_name_new_with_latin1_chars(&setter_name, setter, false);

    api.classdb_register_extension_class_property(class_library, &class_string_name, &info, &setter_name, &getter_name);

    // Destruct things.
    destructors.string_name_destructor(&class_string_name);
    destruct_property(&info);
    destructors.string_name_destructor(&getter_name);
    destructors.string_name_destructor(&setter_name);
}

這個函式和綁定方法的函式類似,主要差別是不用額外的 struct,只要直接用我們輔助函式建立的 GDExtensionPropertyInfo 就好,非常直覺。它會用 C 字串建立 StringName,用輔助函式產生屬性資訊 struct,再呼叫 API 函式將屬性註冊到類別裡,最後把所有建立的物件都釋放掉。

完成這個步驟後,我們可以到 gdexample.c 檔案裡擴充 gdexample_class_bind_methods() 函式:

void gdexample_class_bind_methods()
{
    bind_method_0_r("GDExample", "get_amplitude", gdexample_class_get_amplitude, GDEXTENSION_VARIANT_TYPE_FLOAT);
    bind_method_1("GDExample", "set_amplitude", gdexample_class_set_amplitude, "amplitude", GDEXTENSION_VARIANT_TYPE_FLOAT);
    bind_property("GDExample", "amplitude", GDEXTENSION_VARIANT_TYPE_FLOAT, "get_amplitude", "set_amplitude");

    bind_method_0_r("GDExample", "get_speed", gdexample_class_get_speed, GDEXTENSION_VARIANT_TYPE_FLOAT);
    bind_method_1("GDExample", "set_speed", gdexample_class_set_speed, "speed", GDEXTENSION_VARIANT_TYPE_FLOAT);
    bind_property("GDExample", "speed", GDEXTENSION_VARIANT_TYPE_FLOAT, "get_speed", "set_speed");
}

scons 重新編譯擴充元件後,你會發現 Godot 編輯器除了說明文件頁有新屬性外,只要選擇 GDExample 節點,Inspector 側邊欄裡也會出現對應的屬性。

../../../_images/gdextension_c_inspector_properties.webp

綁定虛擬方法

我們的自訂節點現在有了可調整行為的屬性,但還沒實際做什麼事情。這一節我們要綁定虛擬方法 _process(),讓自訂精靈可以開始動起來。

請在 gdexample.h 檔案裡新增一個代表自訂 _process() 方法的函式宣告:

// Methods.
void gdexample_class_process(GDExample *self, double delta);

我們也會在自訂 struct 裡加一個「private」欄位來記錄經過的時間。這裡所謂「private」只是指它不會被綁定到 Godot API,實際上在 C 裡還是 public,因為 C 沒有存取修飾子。

typedef struct
{
    // Private properties.
    double time_passed;
    ...
} GDExample;

gdexample.c 裡的建構子要初始化這個新欄位:

void gdexample_class_constructor(GDExample *self)
{
    self->time_passed = 0.0;
    self->amplitude = 10.0;
    self->speed = 1.0;
}

然後可以先寫一個最簡單版的 _process 方法實作:

void gdexample_class_process(GDExample *self, double delta)
{
    self->time_passed += self->speed * delta;
}

這裡暫時只會更新剛剛那個記錄時間的欄位。等方法綁定好後再回來補上真正的行為。

虛擬方法的綁定方式和一般方法有些不同。我們不是直接註冊這個方法本身,而是註冊一個特殊函式,Godot 會呼叫它來詢問我們的擴充有沒有實作某個虛擬方法。引擎會傳一個 StringName 進來,所以下面我們會寫一個輔助函式來判斷它和某個 C 字串是否相等。

請在 api.h 檔案中宣告這個輔助函式:

// Compare a StringName with a C string.
bool is_string_name_equal(GDExtensionConstStringNamePtr p_a, const char *p_b);

同時在這個檔案裡加一個新 struct,用來保存自訂運算子的函式指標:

struct Operators
{
    GDExtensionPtrOperatorEvaluator string_name_equal;
} operators;

然後在 api.c 裡從 API 載入這個函式指標:

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

    ...

    // Operators.
    operators.string_name_equal = variant_get_ptr_operator_evaluator(GDEXTENSION_VARIANT_OP_EQUAL, GDEXTENSION_VARIANT_TYPE_STRING_NAME, GDEXTENSION_VARIANT_TYPE_STRING_NAME);
}

如你所見,這裡我們會需要一個新的本地輔助函式來抓這個運算子的函式指標。

有了這些準備,我們可以在同一個檔案裡實作這個比較用的輔助函式:

bool is_string_name_equal(GDExtensionConstStringNamePtr p_a, const char *p_b)
{
    // Create a StringName for the C string.
    StringName string_name;
    constructors.string_name_new_with_latin1_chars(&string_name, p_b, false);

    // Compare both StringNames.
    bool is_equal = false;
    operators.string_name_equal(p_a, &string_name, &is_equal);

    // Destroy the created StringName.
    destructors.string_name_destructor(&string_name);

    // Return the result.
    return is_equal;
}

這個函式會用傳進來的字串建立一個 StringName,然後用運算子函式指標和目標做比較,最後回傳結果。注意這個運算子的回傳值是 out 參數,這在 API 很常見。

現在回到 gdexample.h,加上兩個要給 Godot API 用的 callback 函式宣告:

void *gdexample_class_get_virtual_with_data(void *p_class_userdata, GDExtensionConstStringNamePtr p_name);
void gdexample_class_call_virtual_with_data(GDExtensionClassInstancePtr p_instance, GDExtensionConstStringNamePtr p_name, void *p_virtual_call_userdata, const GDExtensionConstTypePtr *p_args, GDExtensionTypePtr r_ret);

其實註冊虛擬方法有兩種方式。只有一種有 get 部分,就是你直接給 Godot 一個函式指標,Godot 會直接呼叫它。這樣每個虛擬方法都要一個輔助函式,不太方便。我們這裡用第二種方式,可以回傳任意資料,Godot 會再呼叫另一個 callback 並回傳這份資料與呼叫資訊。我們只要把自訂函式指標當作 custom data 傳進去,所有虛擬方法都只要一個 callback 就能搞定。雖然這個範例只用到一個方法,但這種寫法未來要擴充會輕鬆許多。

所以現在就到 gdexample.c 裡實作這兩個函式:

void *gdexample_class_get_virtual_with_data(void *p_class_userdata, GDExtensionConstStringNamePtr p_name)
{
    // If it is the "_process" method, return a pointer to the gdexample_class_process function.
    if (is_string_name_equal(p_name, "_process"))
    {
        return (void *)gdexample_class_process;
    }
    // Otherwise, return NULL.
    return NULL;
}

void gdexample_class_call_virtual_with_data(GDExtensionClassInstancePtr p_instance, GDExtensionConstStringNamePtr p_name, void *p_virtual_call_userdata, const GDExtensionConstTypePtr *p_args, GDExtensionTypePtr r_ret)
{
    // If it is the "_process" method, call it with a helper.
    if (p_virtual_call_userdata == &gdexample_class_process)
    {
        ptrcall_1_float_arg_no_ret(p_virtual_call_userdata, p_instance, p_args, r_ret);
    }
}

有了前面的輔助函式,這兩個函式的實作就很直觀了。

第一個 callback 只要檢查 function name 是不是 _process,是的話就回傳我們實作的函式指標,否則回傳 NULL,代表這個方法沒被覆寫。這裡不會用到 p_class_userdata,因為這個方法只針對一個類別,也沒有額外的資料。

第二個 callback 也很類似。如果是 _process(),就用傳進來的函式指標呼叫 ptrcall 輔助函式並把參數傳下去,否則什麼都不做,因為我們沒實作其他虛擬方法。

剩下唯一沒做的就是在註冊類別時把這些 callback 綁上。請到 init.c 裡,把 class_info 初始化時的 NULL 改成這些 callback:

void initialize_gdexample_module(void *p_userdata, GDExtensionInitializationLevel p_level)
{
    ...

    GDExtensionClassCreationInfo2 class_info = {
        ...
        .get_virtual_call_data_func = gdexample_class_get_virtual_with_data,
        .call_virtual_with_data_func = gdexample_class_call_virtual_with_data,
        ...
    };

    ...
}

這樣就已經綁定好虛擬方法了。重新編譯並執行範例專案,_process() 就會被呼叫。雖然目前還看不出變化,因為這個函式什麼都沒做,接下來我們會讓自訂節點依一定規律移動,讓效果看得見。

要讓節點開始有動作,我們需要呼叫 Godot 的方法。不只是呼叫 GDExtension API,而是像腳本一樣呼叫引擎內建的方法。這就需要額外做些準備。

首先,請在 defs.h 檔案中加入 Vector2 的型別,這樣我們在方法裡才能用它:

// The sizes can be obtained from the extension_api.json file.
...
#ifdef REAL_T_IS_DOUBLE
#define VECTOR2_SIZE 16
#else
#define VECTOR2_SIZE 8
#endif

...

// Types.

...

typedef struct
{
    uint8_t data[VECTOR2_SIZE];
} Vector2;

REAL_T_IS_DOUBLE 這個 define 只有你用雙精度編譯 Godot 時才需要,一般預設不需要。

現在請到 api.h 檔案,在 API struct 裡加一些欄位,包括一個新的 struct 來保存要呼叫的引擎方法。

struct Constructors
{
    ...
    GDExtensionPtrConstructor vector2_constructor_x_y;
} constructors;

...

struct Methods
{
    GDExtensionMethodBindPtr node2d_set_position;
} methods;

struct API
{
    ...
    GDExtensionInterfaceClassdbGetMethodBind classdb_get_method_bind;
    GDExtensionInterfaceObjectMethodBindPtrcall object_method_bind_ptrcall;
} api;

然後在 api.c 裡抓取這些 Godot 的函式指標:

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

    // API.
    ...
    api.classdb_get_method_bind = (GDExtensionInterfaceClassdbGetMethodBind)p_get_proc_address("classdb_get_method_bind");
    api.object_method_bind_ptrcall = p_get_proc_address("object_method_bind_ptrcall");

    // Constructors.
    ...
    constructors.vector2_constructor_x_y = variant_get_ptr_constructor(GDEXTENSION_VARIANT_TYPE_VECTOR2, 3); // See extension_api.json for indices.

    ...
}

這裡值得注意的是 Vector2 的建構子,我們請求 index 3。因為有多個不同參數的建構子,必須指定要哪一個。這個 index 代表吃兩個 float(x,y)參數的建構子。這個 index 可以從 extension_api.json 找到。也別忘了要寫一個新的本地輔助函式來抓取。

請注意這裡我們還沒取得 methods struct,因為這個函式在初始化流程太早呼叫,這時類別還沒註冊完成。

因此我們會利用初始化等級的 callback,在註冊自訂類別時再抓這些資料。請在 init.c 加入這段:

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

    // Get ClassDB methods here because the classes we need are all properly registered now.
    // See extension_api.json for hashes.
    StringName native_class_name;
    StringName method_name;

    constructors.string_name_new_with_latin1_chars(&native_class_name, "Node2D", false);
    constructors.string_name_new_with_latin1_chars(&method_name, "set_position", false);
    methods.node2d_set_position = api.classdb_get_method_bind(&native_class_name, &method_name, 743155724);
    destructors.string_name_destructor(&native_class_name);
    destructors.string_name_destructor(&method_name);

    ...
}

這裡我們會分別為想要取得的類別和方法建立 StringName,然後用 GDExtension API 取得他們的 MethodBind,這個物件代表一個已綁定的方法。我們要取得的是 Node2Dset_position 方法,雖然我們用的是 Sprite2D,但它繼承自 Node2D

看起來很隨機的那個數字其實是方法簽名的雜湊值。這樣即使未來 Godot 版本方法簽名有變化,引擎還是能用相容性機制找到對應的方法。這也是 Godot 能載入舊版擴充元件的原因之一。這個雜湊值可以從 extension_api.json 找到。

做好這些準備後,我們終於可以在 gdexample.c 裡實作自訂的 _process() 方法:

...

#include <math.h>

...

void gdexample_class_process(GDExample *self, double delta)
{
    self->time_passed += self->speed * delta;

    Vector2 new_position;

    // Set up the arguments for the Vector2 constructor.
    double x = self->amplitude + (self->amplitude * sin(self->time_passed * 2.0));
    double y = self->amplitude + (self->amplitude * cos(self->time_passed * 1.5));
    GDExtensionConstTypePtr args[] = {&x, &y};
    // Call the Vector2 constructor.
    constructors.vector2_constructor_x_y(&new_position, args);

    // Set up the arguments for the set_position method.
    GDExtensionConstTypePtr args2[] = {&new_position};
    // Call the set_position method.
    api.object_method_bind_ptrcall(methods.node2d_set_position, self->object, args2, NULL);
}

這裡會先用 speed 屬性更新累積時間,再用這個值算出 xy,同時根據 amplitude 屬性調整,這樣就能產生移動路徑的效果。這裡會用到 sin()cos(),所以要引入 math.h 標頭檔。

接著會先設一個參數陣列用來建立 Vector2,呼叫建構子產生位置向量。然後再設一個參數陣列,把這個向量傳進 set_position(),透過先前取得的 bind 呼叫。

這裡沒有配置任何記憶體,所以不用額外釋放。

現在重新編譯擴充元件並重開 Godot,你會在編輯器裡看到自訂精靈一直在動。

../../../_images/gdextension_c_moving_sprite.gif

你可以試著調整 SpeedAmplitude 屬性,看看精靈的移動會有什麼變化。

註冊與發送訊號

為了讓這份教學完整,我們來看看如何註冊自訂訊號並在適當時機發送它。如你所想,這會需要 API 裡幾個新的函式指標,以及更多輔助函式。

api.h 檔案中要加兩樣東西:一個 API 函式用來註冊訊號,另一個輔助函式用來包裝訊號綁定。

struct API
{
    ...
    GDExtensionInterfaceClassdbRegisterExtensionClassSignal classdb_register_extension_class_signal;
} api;

...

// Version for 1 argument.
void bind_signal_1(
    const char *class_name,
    const char *signal_name,
    const char *arg1_name,
    GDExtensionVariantType arg1_type);

這裡我們只寫一個帶有一個參數的版本,因為這就是本範例需要用到的。

接著到 api.c,載入這個新函式指標並實作輔助函式:

void load_api(GDExtensionInterfaceGetProcAddress p_get_proc_address)
{
    // API.
    ...
    api.classdb_register_extension_class_signal = p_get_proc_address("classdb_register_extension_class_signal");

    ...
}

void bind_signal_1(
    const char *class_name,
    const char *signal_name,
    const char *arg1_name,
    GDExtensionVariantType arg1_type)
{
    StringName class_string_name;
    constructors.string_name_new_with_latin1_chars(&class_string_name, class_name, false);
    StringName signal_string_name;
    constructors.string_name_new_with_latin1_chars(&signal_string_name, signal_name, false);

    GDExtensionPropertyInfo args_info[] = {
        make_property(arg1_type, arg1_name),
    };

    api.classdb_register_extension_class_signal(class_library, &class_string_name, &signal_string_name, args_info, 1);

    // Destruct things.
    destructors.string_name_destructor(&class_string_name);
    destructors.string_name_destructor(&signal_string_name);
    destruct_property(&args_info[0]);
}

這個函式和綁定方法的輔助函式很像,主要差別是不用另外填 struct,只要傳入需要的名稱和參數陣列即可。最後的 1 代表這個訊號有一個參數。

有了這個,就可以在 gdexample.c 綁定訊號:

void gdexample_class_bind_methods()
{
    ...
    bind_signal_1("GDExample", "position_changed", "new_position", GDEXTENSION_VARIANT_TYPE_VECTOR2);
}

要發送訊號時,需要在自訂節點上呼叫 emit_signal() 方法。這個方法是 vararg``(可變參數),所以不能用 ``ptrcall。必須用一般 call,這就需要建立 Variant,而這又需要再做幾個額外步驟。

首先,在 defs.h 檔案裡加上 Variant 的定義:

...

// The sizes can be obtained from the extension_api.json file.
...
#ifdef REAL_T_IS_DOUBLE
#define VARIANT_SIZE 40
#define VECTOR2_SIZE 16
#else
#define VARIANT_SIZE 24
#define VECTOR2_SIZE 8
#endif

...

// Types.

...

typedef struct
{
    uint8_t data[VARIANT_SIZE];
} Variant;

我們會和 Vector2 一起設定 Variant 的大小,然後用它來建立一個足以儲存 Variant 資料的不透明 struct。同樣地,萬一是雙精度 Godot 也有設 fallback,但官方 Godot 編譯預設都是單精度。

emit_signal() 這邊會傳兩個參數:第一個是要發送的訊號名稱,第二個是訊號要傳給連接者的參數(我們綁定時設的是 Vector2)。我們會寫一個輔助函式,可以用這些型別呼叫 MethodBind。它其實有回傳值(錯誤碼),但這裡不需要處理,所以就先略過。

api.h,我們要在現有的 struct 裡再加幾個欄位,並新增一個 call 用的輔助函式:

struct Constructors
{
    ...
    GDExtensionVariantFromTypeConstructorFunc variant_from_string_name_constructor;
    GDExtensionVariantFromTypeConstructorFunc variant_from_vector2_constructor;
} constructors;

struct Destructors
{
    ..
    GDExtensionInterfaceVariantDestroy variant_destroy;
} destructors;

...

struct Methods
{
    ...
    GDExtensionMethodBindPtr object_emit_signal;
} methods;

struct API
{
    ...
    GDExtensionInterfaceObjectMethodBindCall object_method_bind_call;
} api;

...

// Helper to call with Variant arguments.
void call_2_args_stringname_vector2_no_ret_variant(
    GDExtensionMethodBindPtr p_method_bind,
    GDExtensionObjectPtr p_instance,
    const GDExtensionTypePtr p_arg1,
    const GDExtensionTypePtr p_arg2);

現在到 api.c,把這些新函式指標載入,然後實作輔助函式。

void load_api(GDExtensionInterfaceGetProcAddress p_get_proc_address)
{
    // API.
    ...
    api.object_method_bind_call = p_get_proc_address("object_method_bind_call");

    // Constructors.
    ...
    constructors.variant_from_string_name_constructor = api.get_variant_from_type_constructor(GDEXTENSION_VARIANT_TYPE_STRING_NAME);
    constructors.variant_from_vector2_constructor = api.get_variant_from_type_constructor(GDEXTENSION_VARIANT_TYPE_VECTOR2);

    // Destructors.
    ...
    destructors.variant_destroy = p_get_proc_address("variant_destroy");

    ...
}

...

void call_2_args_stringname_vector2_no_ret_variant(GDExtensionMethodBindPtr p_method_bind, GDExtensionObjectPtr p_instance, const GDExtensionTypePtr p_arg1, const GDExtensionTypePtr p_arg2)
{
    // Set up the arguments for the call.
    Variant arg1;
    constructors.variant_from_string_name_constructor(&arg1, p_arg1);
    Variant arg2;
    constructors.variant_from_vector2_constructor(&arg2, p_arg2);
    GDExtensionConstVariantPtr args[] = {&arg1, &arg2};

    // Add dummy return value storage.
    Variant ret;

    // Call the function.
    api.object_method_bind_call(p_method_bind, p_instance, args, 2, &ret, NULL);

    // Destroy the arguments that need it.
    destructors.variant_destroy(&arg1);
    destructors.variant_destroy(&ret);
}

這個輔助函式有一些樣板程式碼,但很直覺。它會用堆疊記憶體建立兩個 Variant 存參數,然後放進指標陣列。接著再設一個用來存回傳值的 Variant,這個不用初始化,因為 call 會直接寫進去。

然後就用我們的 instance 和參數呼叫 MethodBind。最後的 NULL 本來應該給一個 GDExtensionCallError 結構指標,可以用來處理呼叫錯誤(例如參數型別錯誤)。但這裡為了簡單起見就不處理了。

最後要把建立的 Variant 都釋放掉。嚴格說 Vector2 那個不需要釋放,但全部釋放會比較清楚。

還有要記得抓取 emit_signal 的 MethodBind,這個動作要寫在 init.c,就在前面抓 set_position 的 MethodBind 之後:

void initialize_gdexample_module(void *p_userdata, GDExtensionInitializationLevel p_level)
{
    ...

    constructors.string_name_new_with_latin1_chars(&native_class_name, "Object", false);
    constructors.string_name_new_with_latin1_chars(&method_name, "emit_signal", false);
    methods.object_emit_signal = api.classdb_get_method_bind(&native_class_name, &method_name, 4047867050);
    destructors.string_name_destructor(&native_class_name);
    destructors.string_name_destructor(&method_name);

    // Register class.
    ...
}

這裡我們重用 native_class_namemethod_name 變數,不用再多宣告新的。

現在到 gdexample.h,我們要加兩個欄位:

typedef struct
{
    // Private properties.
    ..
    double time_emit;
    ..
    // Metadata.
    StringName position_changed; // For signal.
} GDExample;

第一個欄位用來記錄自上次發送訊號以來經過的時間,因為我們會定期發送訊號。另一個欄位則用來快取訊號名稱,避免每次都要新建 StringName。

gdexample.c 裡,請修改建構子和解構子,處理這兩個新欄位:

void gdexample_class_constructor(GDExample *self)
{
    ...
    self->time_emit = 0.0;

    // Construct the StringName for the signal.
    constructors.string_name_new_with_latin1_chars(&self->position_changed, "position_changed", false);
}

void gdexample_class_destructor(GDExample *self)
{
    // Destruct the StringName for the signal.
    destructors.string_name_destructor(&self->position_changed);
}

要記得釋放 StringName,這樣才不會有記憶體洩漏。

現在可以在 gdexample_class_process() 裡真正發送訊號了:

void gdexample_class_process(GDExample *self, double delta)
{
    ...

    self->time_emit += delta;
    if (self->time_emit >= 1.0)
    {
        // Call the emit_signal method.
        call_2_args_stringname_vector2_no_ret_variant(methods.object_emit_signal, self->object, &self->position_changed, &new_position);
        self->time_emit = 0.0;
    }
}

這段會更新發送訊號的計時,如果超過一秒就呼叫目前 instance 的 emit_signal(),傳入訊號名稱和新位置。

到這裡我們的 C GDExtension 已經完成了。請再編譯一次,然後在 Godot 編輯器裡重開專案。

你可以在 GDExample 的說明文件頁看到剛綁上的新訊號:

../../../_images/gdextension_c_signal_doc.webp

要確認訊號有作用,可以在自訂節點的父節點(場景根節點)加一個小腳本,每次收到訊號時就把位置印出來:

extends Node2D

func _ready():
    $GDExample.position_changed.connect(on_position_changed)

func on_position_changed(new_position):
    prints("New position:", new_position)

執行專案後,你可以在編輯器的 Output 側邊欄看到印出的數值:

../../../_images/gdextension_c_signal_print.webp

結論

這份教學展示了一個具備自訂方法、屬性與訊號的基本擴充元件。雖然寫起來樣板程式碼不少,但只要把瑣碎的事都包裝成輔助函式,整體架構就很容易擴充。

這可以作為理解 GDExtension API 的良好基礎,也很適合用來實作自己的繫結產生器。實際上你也可以用這類產生器來寫出更像這個範例 gdexample.c 那樣直覺、簡潔的 C 語言繫結。

如果你想要開發真正的擴充元件,建議還是用 C++ 繫結,這樣就不需要自己處理一堆樣板程式碼。參考 GDExtension C++ 範例,看看該怎麼做會更有效率。