Attention: Here be dragons

This is the latest (unstable) version of this documentation, which may document features not available in or compatible with released stable versions of Godot.

GDExtension C 範例

前言

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

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

設定專案

你需要先準備以下幾項:

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

  • C 編譯器,

  • SCons 作為建置工具。

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

檔案結構

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

gdextension_c_example/
|
+--project/                  # 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 = "project/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 檔案,內容如下:

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

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

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

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

這裡也一併包含了一些標準標頭檔,讓後續操作更方便。只要引入 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 檔案,內容如下:

#pragma once

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

這裡要注意的是 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 檔案:

#pragma once

/*
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.

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;
} api;

void load_api(GDExtensionInterfaceGetProcAddress p_get_proc_address);

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

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

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

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

#include "api.h"

GDExtensionClassLibraryPtr class_library = NULL;

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

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 = (GDExtensionInterfaceClassdbRegisterExtensionClass2)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);
}

這裡最重要的是 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;

如註解所述,這些型態的大小可以從我們先前生成的 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,加上這些新函式:

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

然後在 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 = (GDExtensionInterfaceObjectSetInstance)p_get_proc_address("object_set_instance");
    api.object_set_instance_binding = (GDExtensionInterfaceObjectSetInstanceBinding)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 物件本身不需我們解構,這會由引擎自動處理。

範例專案

Now that we can create and free our custom object, we should be able to try it out in an actual project. For this, you need to open Godot and create a new project in the project folder. The project manager may warn you the folder isn't empty if you have compiled the extension before, you can safely ignore this warning this time.

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

Then, create a file called gdexample.gdextension inside the project folder. This is a Godot resource that describes the extension, allowing the engine to properly load it. Put the following content in this file:

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

...

gdexample.c 檔案中,我們會在建構子初始化這些值,並加入那些新函式的實作,內容都相當簡單:

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:

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

extern 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 函式來真正綁定我們的自訂方法。

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

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

extern 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 = (GDExtensionInterfaceClassdbRegisterExtensionClassMethod)p_get_proc_address("classdb_register_extension_class_method");

    // Constructors.
    ...
    constructors.string_new_with_utf8_chars = (GDExtensionInterfaceStringNewWithUtf8Chars)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);
}

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

If you compile the code and reopen the Godot project, nothing will be different at first, since we only added two new methods. To ensure those are registered properly, you can search for GDExample in the editor help and verify they are present in the documentation page.

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

自訂屬性

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

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

extern 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 = (GDExtensionInterfaceClassdbRegisterExtensionClassProperty)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,用來保存自訂運算子的函式指標:

extern struct Operators
{
    GDExtensionPtrOperatorEvaluator string_name_equal;
} operators;

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

struct Operators operators;

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

    ...
}

This is enough to bind the virtual method. If you build the extension and run the Godot project again, the _process() function will be called. You just won't be able to tell since the function itself does nothing visible. We will solve this now by making the custom node move following a pattern.

要讓節點開始有動作,我們需要呼叫 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 來保存要呼叫的引擎方法。

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

...

extern struct Methods
{
    GDExtensionMethodBindPtr node2d_set_position;
} methods;

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

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

struct Methods methods;

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 = (GDExtensionInterfaceObjectMethodBindPtrcall)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 函式,另一個則是包裝訊號綁定的輔助函式。

extern 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 = (GDExtensionInterfaceClassdbRegisterExtensionClassSignal)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 用的輔助函式:

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

extern struct Destructors
{
    ..
    GDExtensionInterfaceVariantDestroy variant_destroy;
} destructors;

...

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

extern 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 = (GDExtensionInterfaceObjectMethodBindCall)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 = (GDExtensionInterfaceVariantDestroy)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.
    destructors.variant_destroy(&arg1);
    destructors.variant_destroy(&arg2);
    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(),傳入訊號名稱和新位置。

Now we're done with our C GDExtension. Build it once more and reopen the Godot project in the editor.

你可以在 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++ 綁定,這能省去大量樣板程式碼。請參考 godot-cpp 文件 了解如何進行。