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 檔案,內容如下:
#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.
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);
之後我們會在這個檔案裡加入更多輔助功能。目前只定義了一個能用 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_func 和 free_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,然後執行。也可以順便設為主場景,方便測試。
完成!我們已經讓自訂節點在 Godot 執行起來了。不過現在它還沒有任何行為,與一般 Sprite2D 節點沒什麼差別。接下來我們會加入自訂方法與屬性,讓它能做些事情。
自訂方法
在擴充元件中很常見的事情就是為自訂類別建立方法,並將這些方法公開給 Godot API。我們會先建立一組 getter 和 setter,這樣之後才能把屬性綁定到這些方法。
首先,請在 struct 裡加上新的欄位來保存 amplitude 和 speed 的值,我們之後要讓節點有行為時會用到。請修改 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:
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.c 的 load_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 類別 介紹方法與屬性綁定的頁面。
完整版本內容會多一點。首先會分別建立 String 和 StringName,配置記憶體並呼叫建構子。然後建立 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_func 和 ptrcall_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,確認它們確實有被註冊到說明頁面裡。
自訂屬性
現在我們已經有 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 側邊欄裡也會出現對應的屬性。
綁定虛擬方法
我們的自訂節點現在有了可調整行為的屬性,但還沒實際做什麼事情。這一節我們要綁定虛擬方法 _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,這個物件代表一個已綁定的方法。我們要取得的是 Node2D 的 set_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 屬性更新累積時間,再用這個值算出 x 和 y,同時根據 amplitude 屬性調整,這樣就能產生移動路徑的效果。這裡會用到 sin() 和 cos(),所以要引入 math.h 標頭檔。
接著會先設一個參數陣列用來建立 Vector2,呼叫建構子產生位置向量。然後再設一個參數陣列,把這個向量傳進 set_position(),透過先前取得的 bind 呼叫。
這裡沒有配置任何記憶體,所以不用額外釋放。
現在重新編譯擴充元件並重開 Godot,你會在編輯器裡看到自訂精靈一直在動。
你可以試著調整 Speed 和 Amplitude 屬性,看看精靈的移動會有什麼變化。
註冊與發送訊號
為了讓這份教學完整,我們來看看如何註冊自訂訊號並在適當時機發送它。如你所想,這會需要 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_name 和 method_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 的說明文件頁看到剛綁上的新訊號:
要確認訊號有作用,可以在自訂節點的父節點(場景根節點)加一個小腳本,每次收到訊號時就把位置印出來:
extends Node2D
func _ready():
$GDExample.position_changed.connect(on_position_changed)
func on_position_changed(new_position):
prints("New position:", new_position)
執行專案後,你可以在編輯器的 Output 側邊欄看到印出的數值:
結論
這份教學展示了一個具備自訂方法、屬性與訊號的基本擴充元件。雖然寫起來樣板程式碼不少,但只要把瑣碎的事都包裝成輔助函式,整體架構就很容易擴充。
這可以作為理解 GDExtension API 的良好基礎,也很適合用來實作自己的繫結產生器。實際上你也可以用這類產生器來寫出更像這個範例 gdexample.c 那樣直覺、簡潔的 C 語言繫結。
如果你想製作真正的擴充,建議改用 C++ 綁定,這能省去大量樣板程式碼。請參考 godot-cpp 文件 了解如何進行。