Hacker News 中文摘要

RSS订阅

在 Godot 中调用任意 C++ 库 -- Using any C++ library in Godot

戈多(Godot)已成为近年最热门游戏引擎之一,免费基于 MIT 许可开源,下载小巧。多数戈多游戏用戈多脚本(GDScript)编写。不少项目需要现成 C 或 C++ 库,但戈多脚本无法调用原生代码,戈多可通过 GD扩展(GDExtension)加载,官方 C++ 绑定戈多CPP(godot-cpp)能将代码暴露为引擎类。写 C++ 容易,构建难:godot-cpp 须匹配戈多版本,每加一库都要按发布平台编译。

正文

Godot 近几年成了最流行的游戏引擎之一:免费、MIT 开源,体积小到几分钟就能下载上手。多数 Godot 游戏用引擎自带的脚本语言 GDScript 编写。

但项目迟早会需要已有的 C 或 C++ 库:仿真库、数据库、网络协议、机器学习运行时。GDScript 无法调用原生代码,不过 Godot 可以通过 GDExtension 加载,官方的 godot-cpp C++ 绑定能把代码暴露成普通的引擎类。写 C++ 代码是简单的部分,难的是构建:godot-cpp 必须匹配你的 Godot 版本,每加一个库都要为每个发布平台编译。

本文简单介绍 Godot、解释 C++ 扩展的工作方式,并演示如何用 Conan 和 godot-cpp 10(现已进入 ConanCenter)把 C++ 库带进 Godot 游戏。示例使用 Entity Component System 库 flecs,在 Godot 场景中模拟 10 万个粒子。

Video 3

在 Godot 场景中用 flecs 模拟的 10 万个粒子,躲避鼠标光标

Godot 简介

Godot 是面向 2D 和 3D 游戏的通用引擎,项目中的一切都由两个概念构成:

  • 节点(Node) 是基本构件。每个节点有类型(Sprite2D、Camera3D、AudioStreamPlayer、Timer 等)、可在 Inspector 中编辑的属性,以及引擎在游戏循环中调用的回调,如 _ready() 或 _process()。
  • 场景(Scene) 是以 .tscn 文件保存到磁盘的节点树。场景可以是角色、菜单或整个关卡,场景可以实例化到其他场景中。

行为通常通过把脚本挂到节点上来添加。GDScript 是类似 Python 的引擎专用语言,非常适合玩法逻辑,因为改动无需编译就立即生效。

对 C++ 开发者来说,Godot 的有趣之处在于引擎本身用 C++ 写成,并且能在不重新编译的情况下加载 C++ 扩展。来自扩展的类会成为普通引擎类:在编辑器中与内置节点并列,属性出现在 Inspector 里,GDScript 可以像用其他节点一样使用它。

用 C++ 扩展 Godot

给 Godot 添加 C++ 代码有两种方式:

  • 引擎模块编译进引擎本身,能完全访问内部实现,但需要构建并分发自己的 Godot 副本,包括编辑器和各平台的导出模板。
  • GDExtension 在运行时把共享库(.dll、.so、.dylib,Web 上是 .wasm)加载进官方未修改的 Godot 构建中。引擎通过稳定的 C 接口与库通信。

GDExtension 是多数项目推荐的方式,也是如今许多流行插件的分发形式。直接使用 C 接口很繁琐,因此 Godot 团队维护了 godot-cpp,用非常接近引擎内部 API 的接口包装它,为每个引擎类(如 Node2D、Sprite2D、Input)提供对应的 C++ 类。

你自己的类就是继承这些类的普通 C++ 代码。用 godot-cpp 写的节点长这样:

```

include

namespace godot {

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

protected: static void bindmethods() {}

public: void process(double pdelta) override { // runs every frame } };

} // namespace godot ```

从 10.0 版起,单个 godot-cpp 发行版可配合 4.3 之后的任何 Godot 版本。通过 api_version 构建选项选择版本,godot-cpp 会根据该版本的 API 生成 C++ 类。为 Godot 4.3 构建的扩展也能在更新的版本中工作,但反过来不行,所以通常选你想支持的最老 Godot 版本。

构建目标与特性标签

godot-cpp 按三种目标之一编译,名称来自加载该库的 Godot 构建:

  • template_debug:默认。通过 DEBUG_ENABLED 定义启用调试检查。编辑器与调试导出会加载它。
  • template_release:用于发布导出,去掉调试检查。
  • editor:只被编辑器加载的库。

Godot 加载哪个库由一个小 .gdextension 文件在运行时决定,它把特性标签映射到库路径。debug 标签匹配编辑器和调试导出,release 匹配发布导出:

``` [configuration] entrysymbol = "gdexamplelibraryinit" compatibilityminimum = "4.7"

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

常规工作流

Godot 文档建议把 godot-cpp 作为 git submodule 加入仓库,并用 SCons 与你的库一起构建。这对第一个扩展来说没问题,但每个项目最终都要为每个目标、平台和架构编译自己的 godot-cpp,而且你包装的任何第三方库(物理引擎、机器学习运行时)都得为每个导出平台以匹配的编译参数引入并构建。

这正是 Conan 要解决的问题。

用 Conan 管理依赖

有了 ConanCenter 里的 godot-cpp recipe,godot-cpp 就成了普通包。上面讨论的两个参数是 Conan 选项:

  • api_version:绑定针对的 Godot API 版本,从 4.3 到 4.7(默认)。
  • target:template_debug(默认)、template_release 或 editor。

每种组合只构建一次,之后被所有需要的项目复用,而不是在每个扩展里各自编译。

你的 GDExtension 就只是另一个带依赖的 C++ 项目。ConanCenter 中 1900 多个库中的任何一个,或用 Conan recipe 自己打包的库,都可以和 godot-cpp 并列添加,Conan 会为你面向的每个平台一致地构建它们。

实例:10 万粒子的蜂群

我们写一个 GDExtension,注册新的 Swarm 节点,模拟 10 万个躲避鼠标光标、碰到窗口边缘会反弹的粒子,并把它们全部绘制在 Godot 场景中。

模拟运行在 flecs 上,这是一个面向 C 和 C++ 的 Entity Component System(ECS)库。在 ECS 中,实体就是普通 id,组件是附加到实体上的纯数据结构,系统是作用于拥有某组组件的每个实体的函数。同一类型的组件在内存中连续存放,遍历大量实体非常快,因此 ECS 很适合仿真、人群或弹幕游戏。这类工作也最能体现原生代码的价值:每帧更新这么多实体,C++ 比 GDScript 快得多。

完整示例在 Conan examples2 仓库:

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

src 目录是扩展代码,demo 是加载它的普通 Godot 项目。

声明依赖

conanfile.py 需要 ConanCenter 中的 godot-cpp 和 flecs:

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

class GDExtensionExample(ConanFile): packagetype = "shared-library" settings = "os", "compiler", "buildtype", "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 picks the library to load by its build "target", so we name
    # the output after the target godot-cpp was built with
    tc.cache_variables["GODOTCPP_TARGET"] = str(self.dependencies["godot-cpp"].options.target)
    tc.generate()

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

```

唯一 Godot 特有的细节在 generate():读取 godot-cpp 依赖的 target 选项并传给 CMake,使库名始终与所链接的 godot-cpp 二进制匹配。

CMakeLists.txt

``` cmakeminimumrequired(VERSION 3.15) project(gdexample LANGUAGES CXX)

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

addlibrary(gdexample SHARED src/registertypes.cpp src/swarm.cpp ) targetlinklibraries(gdexample PRIVATE godot-cpp flecs::flecs_static)

Output as demo/bin/libgdexample.., the path the

demo/bin/gdexample.gdextension file points Godot to. The generator

expression prevents multi-config generators from adding a Release/ subfolder

settargetproperties(gdexample PROPERTIES OUTPUTNAME "gdexample.${GODOTCPPTARGET}" PREFIX "lib" LIBRARYOUTPUTDIRECTORY "$<1:${CMAKESOURCEDIR}/demo/bin>" RUNTIMEOUTPUTDIRECTORY "$<1:${CMAKESOURCEDIR}/demo/bin>" ) ```

这是完全标准的 CMake 项目。扩展是共享库,静态链接 godot-cpp 和 flecs,所以只需发布一个库文件。输出直接写进 demo/bin,Godot 无需额外复制步骤就能找到它。

编写节点

Swarm 类继承自 Node2D,持有 flecs world。每个粒子的组件是纯结构体。GDCLASS 宏添加 Godot 类系统所需的样板代码,_bind_methods() 声明 Godot 能看到什么,这里是 count 和 flee_radius 属性。类注册后,它们出现在 Inspector 中,也能从 GDScript 使用。下面是该类的简化版本:

``` 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 bindmethods() { ClassDB::bindmethod(DMETHOD("setcount", "count"), &Swarm::setcount); ClassDB::bindmethod(DMETHOD("getcount"), &Swarm::getcount); ADDPROPERTY(PropertyInfo(Variant::INT, "count"), "setcount", "getcount"); // ... and the same for fleeradius } ... }; ```

节点的其余部分连接两个世界。_ready() 为每个粒子创建一个 flecs 实体,以及更新它们的 flecs 系统;还设置一个 MultiMesh,用一次绘制调用画出同一网格的众多实例,因为给 10 万个粒子各配一个 Godot 节点太重了。

每帧 _process() 把鼠标位置交给 flecs,用 world.progress() 运行系统,再把结果位置拷回 MultiMesh。这同样是简化版,完整代码在仓库中:

``` void Swarm::_ready() { // One entity per particle, with a Position and a Velocity component for (int i = 0; i < count; i++) { world.entity().set({ ... }).set({ ... }); }

// A system that runs for every entity with both components
world.system<Position, Velocity>("Move").each([this](flecs::iter &it, size_t, Position &p, Velocity &v) {
    // flee from the mouse, move, and bounce off the window edges
});

// A MultiMeshInstance2D child node that draws all the particles
...

}

void Swarm::process(double pdelta) { mouse = getlocalmouseposition(); world.progress(staticcast(p_delta));

// Copy the position of every entity into the MultiMesh buffer
render_query.each([&](const Position &p, const Velocity &v) { ... });
multimesh->set_buffer(buffer);

} ```

注册扩展

最后,register_types.cpp 在 Godot 加载库时注册该类:

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

用 GDREGISTER_RUNTIME_CLASS 注册 Swarm。默认情况下,GDExtension 类的代码也会在编辑器里运行,所以编辑场景时 _ready() 和 _process() 就会启动模拟。运行时类在编辑器中只是占位符:可以把它加进场景、设置属性,但代码只在游戏运行时执行。

同一个文件定义 gdexample_library_init(),即 .gdextension 文件中指定的入口点,是每个扩展都差不多的几行样板代码。

构建与运行

一切就绪后,构建扩展只需一条命令:

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

Conan 解析 godot-cpp 和 flecs,对你的配置有预编译二进制时从 ConanCenter 下载,其余从源码构建,生成 CMake 集成,最后构建扩展。

注意: godot-cpp 需要 C++17。如果你的默认 profile 使用更老的标准(MSVC 就是如此),在命令中加上 -s compiler.cppstd=17。

现在启动 Godot 4.7,在项目管理器中点 “Import”,选择 demo/project.godot。项目打开后,Godot 读取 bin/gdextension,加载库,Swarm 就像任何内置节点一样可用。你可以在 “Create New Node” 对话框的 Node2D 下找到它:

Godot 的 Create New Node 对话框在 Node2D 下显示 Swarm 类

演示的主场景里已经有一个 Swarm 节点。选中它,Inspector 会显示 count 和 flee_radius,即我们在 _bind_methods() 中绑定的两个属性:

Godot 编辑器选中 Swarm 节点,Inspector 显示 Count 和 Flee Radius 属性

按 Play 运行场景,在窗口上移动鼠标推动粒子。然后停止,在 Inspector 中修改 count 或 flee_radius,再次运行,看看更多粒子或更大躲避半径下蜂群的表现。

结论

GDExtension 和 godot-cpp 让你用 C++ 编写引擎类,Conan 负责构建 godot-cpp 以及扩展需要的任何其他 C++ 库。分发扩展时这也是很大的优势:为每个目标平台构建只需改改构建设置。

Linux 注意: 默认情况下扩展动态链接 libstdc++,因此目标系统提供的版本至少要和构建时一样新。为了广泛兼容,应针对与你想支持的最老发行版兼容的工具链和系统库构建扩展及其依赖。另一种做法是静态链接 libstdc++,并用链接器版本脚本隐藏其符号。

试试完整示例,并查阅 godot-cpp 文档了解编写扩展的更多内容。如有反馈或遇到问题,请在 Conan GitHub 仓库告诉我们。

祝游戏开发愉快!

评论

社区多数人认为在Godot中引入C++原生扩展虽构建繁琐却对突破性能瓶颈必要,少数人推荐Rust绑定或C++反射简化流程;Linux下的ABI与链接兼容性是技术讨论主轴。

认为引入C++扩展繁琐但对突破性能瓶颈必要

  • 多位开发者反馈将重型逻辑从GDScript迁移到C++后规模与帧率显著提升,尽管需编写CMake、Python及特殊C++代码,过程冗长但结果说话。(较多人) > 把重活移入C++模拟虽繁琐,但结果不言自明(@voodooEntity)

强调Linux下须静态链接或隐藏符号以避免ABI冲突

  • 不少意见指出,发布到Linux时应静态链接libstdc++或使用链接器版本脚本隐藏实现,防止与发行版运行时发生符号冲突导致诡异错误,异常跨边界时尤需谨慎。(较多人) > 要么用版本脚本隐藏实现,要么用旧发行版匹配库版本,否则动态链接会出随机怪事(@fwsgonzo)

推荐Rust绑定或C++反射降低集成成本

  • 少数声音认为,借助Godot的Rust绑定(GDExtension)或C++26反射,可用极少样板安全调用原生库,避开传统构建系统的麻烦。(少数人) > Godot的Rust绑定很好,能在引擎里直接用任何Rust库甚至异步运行时(@valorzard)

关注采用C++前的性能剖析与分发选型

  • 部分用户疑问:在跳入C++麻烦前是否有GDScript或C#的剖析工具定位瓶颈;以及Steam分发应构建自带引擎模块还是运行时加载GDExtension。(少数人) > 为了性能跳进C++麻烦前,先得有找瓶颈的剖析支持(@MeteorMarc)

有价值的补充

  • 正确的Linux二进制应加上-static-libgcc -static-libstdc++并仅静态加载通用glibc、动态加载其余私有库,以最大化不同发行版长期兼容;对Steam上Godot4游戏的检查显示多数已如此处理。
  • 使用-fvisibility=hidden静态链接虽解决符号冲突,但一旦需要异常跨越语言边界便会踩坑。
  • C++与Godot内部的数据边界(如MultiMesh缓冲区复制)可能使零拷贝难以实现,需进一步确认。