Gebruik van elke willekeurige C++-bibliotheek in Godot

Veel projecten hebben echter vroeg of laat iets nodig dat al bestaat als een C- of C++-bibliotheek: een simulatiebibliotheek, een database, een netwerkprotocol of een machine learning runtime. GDScript kan geen native code aanroepen, maar Godot kan dit wel laden via GDExtension. Met godot-cpp, de officiële C++ bindings, kun je die code ontsluiten als reguliere engine-klassen. Het schrijven van de C++-code is het eenvoudige gedeelte; het lastige is het buildproces. godot-cpp moet overeenkomen met jouw Godot-versie, en elke bibliotheek die je toevoegt moet worden gecompileerd voor elk platform waarop je het product uitbrengt.

In dit artikel geven we een korte tour door Godot, leggen we uit hoe C++-extensies werken en laten we zien hoe je C++-bibliotheken in een Godot-game brengt met Conan en godot-cpp 10, dat nu beschikbaar is in ConanCenter. Als voorbeeld gebruiken we flecs, een Entity Component System (ECS) bibliotheek, om 100.000 deeltjes in een Godot-scène te simuleren.

Een korte introductie tot Godot

Godot is een general-purpose engine voor 2D- en 3D-games. Alles in een Godot-project is opgebouwd uit twee concepten:

  • Nodes zijn de basisbouwstenen. Elke node heeft een type (Sprite2D, Camera3D, AudioStreamPlayer, Timer...), een set eigenschappen die je in de Inspector kunt bewerken, en callbacks zoals ready() of process() die de engine aanroept tijdens de game loop.
  • Scenes zijn bomen van nodes die als .tscn-bestanden op schijf worden opgeslagen. Een scène kan een personage, een menu of een volledig level zijn, en scènes kunnen worden geïnstantieerd binnen andere scènes.

Gedrag wordt meestal toegevoegd door een script aan een node te koppelen. GDScript is een Python-achtige taal die speciaal voor de engine is ontworpen en ideaal is voor gameplay-logica, omdat wijzigingen onmiddellijk zichtbaar zijn zonder dat er een compilatiestap nodig is.

Wat Godot interessant maakt voor C++-ontwikkelaars, is dat de engine zelf in C++ is geschreven en extensies in C++ kan laden zonder dat de engine opnieuw gecompileerd hoeft te worden. Een klasse die voortkomt uit een van deze extensies wordt een reguliere engine-klasse: deze verschijnt in de editor naast de ingebouwde nodes, met eigenschappen in de Inspector, en GDScript kan deze gebruiken als elke andere node.

Godot uitbreiden met C++

Er zijn twee manieren om C++-code aan Godot toe te voegen:

  1. Engine modules worden gecompileerd in de engine zelf. Ze hebben volledige toegang tot de internals, maar je moet je eigen kopie van Godot bouwen en distribueren, inclusief de editor en export-templates voor elk platform.
  2. GDExtension laadt een gedeelde bibliotheek (.dll, .so, .dylib, of .wasm op het web) tijdens runtime in een officiële, ongewijzigde Godot-build. De engine communiceert met de bibliotheek via een stabiele C-interface.

GDExtension is de aanbevolen aanpak voor de meeste projecten en is de manier waarop veel populaire plugins tegenwoordig worden gedistribueerd. Omdat de C-interface omslachtig is om direct te gebruiken, onderhoudt het Godot-team godot-cpp, een C++-bibliotheek die dit omhult met een API die zeer dicht bij die van de engine ligt. Het biedt een C++-klasse voor elke engine-klasse, zoals Node2D, Sprite2D of Input.

Je eigen klassen zijn reguliere C++-code die afgeleid zijn van die klassen. Een node geschreven met godot-cpp ziet er als volgt uit:

#include <godot_cpp/classes/node2d.hpp>

namespace godot {
class MyNode : public Node2D {
    GDCLASS(MyNode, Node2D)

protected:
    static void _bind_methods() {}

public:
    void _process(double p_delta) override {
        // wordt elke frame uitgevoerd
    }
};
} // namespace godot

Sinds versie 10.0 werkt een enkele godot-cpp-release met elke Godot-versie vanaf 4.3. Je kiest er een met de api_version build-optie, waarna godot-cpp zijn C++-klassen genereert op basis van de API van die versie. Een extensie gebouwd voor Godot 4.3 werkt ook in nieuwere versies, maar niet in oudere; daarom kies je meestal de oudste Godot-versie die je wilt ondersteunen.

Build targets en feature tags

Er is nog één concept dat belangrijk is voordat je begint met bouwen. godot-cpp wordt gecompileerd voor een van de drie targets, vernoemd naar de Godot-builds die de bibliotheek laden:

  • templatedebug: de standaard. Schakelt debug-checks in via de DEBUGENABLED definitie. Deze bibliotheek wordt geladen door de editor en door debug-exports.
  • template_release: voor release-exports, waarbij de debug-checks zijn verwijderd.
  • editor: voor bibliotheken die alleen door de editor worden geladen.

Welke bibliotheek Godot laadt, wordt tijdens runtime bepaald door een klein .gdextension-bestand. Dit bestand koppelt feature tags aan bibliotheekpaden. De debug tag komt overeen met de editor en debug-exports, en de release tag komt overeen met release-exports:

[configuration]
entry_symbol = "gdexample_library_init"
compatibility_minimum = "4.7"

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

De gebruikelijke workflow

De documentatie van Godot adviseert om godot-cpp aan je repository toe te voegen als een git-submodule en deze samen met je bibliotheek te bouwen met SCons. Dat werkt goed voor een eerste extensie, maar uiteindelijk compileert elk project zijn eigen godot-cpp voor elk target, platform en architectuur. Bovendien moet elke externe bibliotheek die je wrap (zoals een physics-engine of machine learning runtime) als vendor worden toegevoegd en worden gebouwd met overeenkomstige vlaggen voor elk platform waarop Godot exporteert.

Dit zijn precies de problemen die Conan is ontworpen om op te lossen.

Afhankelijkheden beheren met Conan

Met het godot-cpp recept in ConanCenter wordt godot-cpp een regulier pakket. De twee eerder besproken parameters zijn Conan-opties:

  • api_version: de Godot API-versie waarop de bindings gericht zijn, van 4.3 tot 4.7 (standaard).
  • target: templatedebug (standaard), templaterelease of editor.

Elke combinatie wordt één keer gebouwd en vervolgens hergebruikt door elk project dat het nodig heeft, in plaats van dat het binnen elke extensie opnieuw wordt gecompileerd.

Je GDExtension wordt zo gewoon een C++-project met afhankelijkheden. Elke bibliotheek uit de meer dan 1.900 bibliotheken in ConanCenter, of een bibliotheek die je zelf package met een Conan-recept, kan naast godot-cpp worden toegevoegd. Conan bouwt ze vervolgens consistent voor elk platform dat je target.

Een praktisch voorbeeld: Een zwerm van 100.000 deeltjes

Om te laten zien hoe dit in de praktijk werkt, schrijven we een GDExtension die een nieuwe Swarm node registreert. Deze simuleert 100.000 deeltjes die vluchten voor de muisaanwijzer en terugkaatsen tegen de randen van het venster, en tekent ze allemaal in een Godot-scène.

De simulatie draait op flecs, een Entity Component System (ECS) bibliotheek voor C en C++. In een ECS zijn entiteiten eenvoudige ID's, componenten zijn eenvoudige datastructuren die aan hen zijn gekoppeld, en systemen zijn functies die draaien over elke entiteit die een bepaalde set componenten heeft. Componenten van hetzelfde type worden samen in het geheugen opgeslagen, wat het itereren over grote aantallen entiteiten zeer snel maakt. Daarom is ECS een populaire keuze voor simulaties, menigten of bullet-hell games. Het is ook het soort werk waarbij native code echt loont, aangezien het bijwerken van zoveel entiteiten per frame veel sneller gaat in C++ dan in GDScript.

Je kunt het volledige voorbeeld vinden in de Conan examples2 repository:

$ git clone https://github.com/conan-io/examples2.git
$ cd examples2/examples/libraries/godot-cpp/gdextension

De src map bevat de extensiecode, en demo is een regulier Godot-project dat deze laadt.

De afhankelijkheden declareren

Het conanfile.py vereist godot-cpp en flecs van ConanCenter:

from conan import ConanFile
from conan.tools.cmake import CMake, CMakeToolchain, cmake_layout

class GDExtensionExample(ConanFile):
    package_type = "shared-library"
    settings = "os", "compiler", "build_type", "arch"
    generators = "CMakeDeps"

    def requirements(self):
        self.requires("godot-cpp/10.0.0")
        self.requires("flecs/4.1.6")

    def layout(self):
        cmake_layout(self)

    def generate(self):
        tc = CMakeToolchain(self)
        # Godot kiest de bibliotheek om te laden op basis van het build "target",
        # dus we noemen de output naar het target waarop godot-cpp is gebouwd
        tc.cache_variables["GODOTCPP_TARGET"] = str(self.dependencies["godot-cpp"].options.target)
        tc.generate()

    def build(self):
        cmake = CMake(self)
        cmake.configure()
        cmake.build()

Het enige Godot-specifieke detail bevindt zich in generate(). We lezen de target optie van de godot-cpp afhankelijkheid en geven deze door aan CMake, zodat de naam van de bibliotheek altijd overeenkomt met het godot-cpp binary waarmee deze is gekoppeld.

Het CMakeLists.txt

cmake_minimum_required(VERSION 3.15)
project(gdexample LANGUAGES CXX)

find_package(godot-cpp REQUIRED CONFIG)
find_package(flecs REQUIRED CONFIG)

add_library(gdexample SHARED
    src/register_types.cpp
    src/swarm.cpp
)

target_link_libraries(gdexample PRIVATE godot-cpp flecs::flecs_static)

# Output als demo/bin/libgdexample.<target>.<ext>, het pad waar
# het demo/bin/gdexample.gdextension bestand Godot naar verwijst.
# De generator expressie voorkomt dat multi-config generators een Release/ submap toevoegen.
set_target_properties(gdexample PROPERTIES
    OUTPUT_NAME "gdexample.${GODOTCPP_TARGET}"
    PREFIX "lib"
    LIBRARY_OUTPUT_DIRECTORY "$<1:${CMAKE_SOURCE_DIR}/demo/bin>"
    RUNTIME_OUTPUT_DIRECTORY "$<1:${CMAKE_SOURCE_DIR}/demo/bin>"
)

Dit is een volledig standaard CMake-project. De extensie is een gedeelde bibliotheek die godot-cpp en flecs statisch linkt, zodat er slechts één bibliotheekbestand gedistribueerd hoeft te worden. We schrijven dit direct naar demo/bin, zodat Godot het vindt zonder extra kopieerstap.

De node schrijven

De Swarm klasse is afgeleid van Node2D en bezit de flecs world. De componenten van elk deeltje zijn eenvoudige structs. De GDCLASS macro voegt de boilerplate toe die het klassensysteem van Godot nodig heeft, en bindmethods() declareert wat Godot kan zien; in dit geval de count en flee_radius eigenschappen.

Zodra de klasse is geregistreerd, verschijnen deze in de Inspector en kunnen ze worden gebruikt vanuit GDScript. Dit is een vereenvoudigde weergave van de klasse:

struct Position { float x, y; };
struct Velocity { float x, y; };

class Swarm : public Node2D {
    GDCLASS(Swarm, Node2D)
    int count = 100000;
    double flee_radius = 150.0;
    flecs::world world;
    ...

protected:
    static void _bind_methods() {
        ClassDB::bind_method(D_METHOD("set_count", "count"), &Swarm::set_count);
        ClassDB::bind_method(D_METHOD("get_count"), &Swarm::get_count);
        ADD_PROPERTY(PropertyInfo(Variant::INT, "count"), "set_count", "get_count");
        // ... en hetzelfde voor flee_radius
    }
    ...
};

De rest van de node verbindt beide werelden. _ready() maakt één flecs-entiteit per deeltje en een flecs-systeem dat deze bijwerkt. Het stelt ook een MultiMesh in, die veel instanties van dezelfde mesh in één draw call tekent, omdat één Godot-node per deeltje veel te zwaar zou zijn voor 100.000 stuks.

Elke frame geeft _process() de muispositie door aan flecs, voert de systemen uit met world.progress() en kopieert de resulterende posities terug naar de MultiMesh. Ook dit is een vereenvoudigde weergave; de volledige code staat in de repository:

void Swarm::_ready() {
    // Eén entiteit per deeltje, met een Position en een Velocity component
    for (int i = 0; i < count; i++) {
        world.entity().set<Position>({ ... }).set<Velocity>({ ... });
    }

    // Een systeem dat draait voor elke entiteit met beide componenten
    world.system<Position, Velocity>("Move").each([this](flecs::iter &it, size_t, Position &p, Velocity &v) {
        // vlucht voor de muis, beweeg, en kaats terug van de vensterranden
    });

    // Een MultiMeshInstance2D child node die alle deeltjes tekent
    ...
}

void Swarm::_process(double p_delta) {
    mouse = get_local_mouse_position();
    world.progress(static_cast<float>(p_delta));

    // Kopieer de positie van elke entiteit naar de MultiMesh buffer
    render_query.each([&](const Position &p, const Velocity &v) { ... });
    multimesh->set_buffer(buffer);
}

De extensie registreren

Ten slotte registreert register_types.cpp de klasse wanneer Godot de bibliotheek laadt:

void initialize_gdexample_module(ModuleInitializationLevel p_level) {
    if (p_level != MODULE_INITIALIZATION_LEVEL_SCENE) {
        return;
    }
    GDREGISTER_RUNTIME_CLASS(Swarm);
}

We registreren Swarm met GDREGISTERRUNTIMECLASS. Standaard wordt de code van een GDExtension-klasse ook in de editor uitgevoerd, waardoor ready() en process() de simulatie zouden starten terwijl je de scène bewerkt. Een runtime-klasse is in de editor slechts een placeholder: je kunt hem aan een scène toevoegen en eigenschappen instellen, maar de code draait pas wanneer het spel echt wordt uitgevoerd.

Hetzelfde bestand definieert gdexamplelibraryinit(), het entry point dat in het .gdextension bestand wordt genoemd. Dit is een paar regels boilerplate die in elke extensie hetzelfde zijn.

Bouwen en uitvoeren

Als alles klaarstaat, is het bouwen van de extensie een enkel commando:

$ conan build . --build=missing
...
[100%] Linking CXX shared library .../demo/bin/libgdexample.template_debug.dylib
[100%] Built target gdexample

Conan lost godot-cpp en flecs op, downloadt vooraf gecompileerde binaries van ConanCenter wanneer deze bestaan voor jouw configuratie, bouwt de rest vanuit broncode, genereert de CMake-integratie en bouwt tot slot de extensie.

Let op: godot-cpp vereist C++17. Als je standaardprofiel een oudere standaard gebruikt (zoals bij MSVC), voeg dan -s compiler.cppstd=17 toe aan het commando.

Start nu Godot 4.7, klik op "Import" in de Project Manager en selecteer demo/project.godot. Wanneer het project opent, leest Godot bin/gdexample.gdextension, laadt de bibliotheek, en wordt Swarm beschikbaar als elke ingebouwde node. Je kunt deze vinden in de "Create New Node" dialoog onder Node2D.

De hoofdscène van de demo bevat al een Swarm node. Door deze te selecteren, zie je count en fleeradius in de Inspector, de twee eigenschappen die we in bind_methods() hebben gekoppeld.

Druk op Play om de scène te runnen en beweeg de muis over het venster om de deeltjes weg te duwen. Je kunt de simulatie stopzetten, count of flee_radius in de Inspector wijzigen en opnieuw Play drukken om te zien hoe de zwerm zich gedraagt met meer deeltjes of een grotere vluchtstraal.

Conclusie

GDExtension en godot-cpp stellen je in staat om engine-klassen in C++ te schrijven, en Conan neemt het bouwen van godot-cpp en andere benodigde C++-bibliotheken voor je over. Dit is ook een groot voordeel bij het distribueren van de extensie: het bouwen voor elk platform waar je naartoe verscheept, vereist enkel het wijzigen van de build-instellingen.

Opmerking voor Linux: Standaard linkt de extensie libstdc++ dynamisch, dus het doelsysteem moet een versie hebben die minstens zo nieuw is als die waarmee de extensie is gebouwd. Voor brede compatibiliteit kun je de extensie en afhankelijkheden bouwen tegen een toolchain en systeembibliotheken die compatibel zijn met de oudste distributie die je wilt ondersteunen. Alternatief kun je libstdc++ statisch linken en de symbolen verbergen met een linker version script.