1. 为什么要在 Godot 里用 Rust 写扩展
1.1 从一次性能瓶颈说起
去年我接手一个 2D 沙盒类项目,核心玩法是“地形可破坏 + 实时寻路 + 大量实体状态同步”。原型阶段用 GDScript 写得飞快,两周就把玩法跑通了。但到了压力测试阶段,问题来了:当地图上有 800 个以上活跃实体、每帧都要做邻域查询和路径重算时,GDScript 的帧率直接从 60 掉到 22,Profiler 里_physics_process和自定义的网格重建函数吃掉了将近 70% 的时间。
我试过几种常规优化:把循环拆细、用PackedVector2Array替代普通数组、把能缓存的都缓存。帧率回到 38 左右,但离稳定 60 还差得远。这时候摆在面前的路其实就三条:一是换引擎,二是把核心算法改成 C++ 模块重新编译引擎,三是用 GDExtension 挂一个原生扩展进去。
换引擎成本太高,玩法逻辑全要重写;改引擎源码意味着每次升级 Godot 都要重新合并代码,维护噩梦。最后我选了第三条路——用godot-rust(也就是 gdext)写一个 Rust 扩展,把寻路和网格重建这两块热逻辑挪出去。改完之后同样的场景,帧率稳定在 60,_physics_process的耗时从 18ms 降到 4ms 左右。
这就是我想聊这个话题的原因:godot-rust 不是让你“用 Rust 重写整个游戏”,而是让你在保留 GDScript 开发效率的同时,把真正吃性能的那 10% 代码用 Rust 接管。它适合已经有一定 Godot 使用经验、又遇到性能天花板、同时愿意折腾原生工具链的开发者。如果你连 Godot 的节点树和信号机制都还没摸熟,那先把 GDScript 写顺了再来,不然会同时被两套东西折磨。
1.2 godot-rust 到底是什么,和 GDExtension 什么关系
先把概念理清楚,不然很容易被一堆名词绕晕。
GDExtension是 Godot 4 引入的官方原生扩展机制。它定义了一套 C 语言层面的接口(gdextension_interface.h),任何能编译成动态库(Windows 下.dll、Linux 下.so、macOS 下.dylib)的语言,只要按这套接口实现,就能把自己的类、方法、属性注册进 Godot,像内置节点一样在场景里使用。它取代了 Godot 3 时代的 GDNative,最大的改进是不再需要为每个版本重新编译——ABI 稳定了。
godot-rust则是社区维护的 Rust 绑定库,官方名字叫gdext(GitHub 上仓库名是godot-rust/gdext)。它把 GDExtension 那套 C 接口用 Rust 安全地包了一层,让你能写这样的代码:
#[derive(GodotClass)] #[class(base=Node2D)] struct PathAgent { speed: f32, base: Base<Node2D>, } #[godot_api] impl INode2D for PathAgent { fn ready(&mut self) { godot_print!("agent ready, speed = {}", self.speed); } }编译出来就是一个.dll,Godot 通过一个.gdextension配置文件加载它,然后你就能在编辑器里像用普通节点一样add_child这个PathAgent。
这里有个关键点要强调:godot-rust 和 GDExtension 不是竞争关系,而是“实现”与“规范”的关系。你完全可以用 C++、C、甚至 Zig 去实现 GDExtension,godot-rust 只是其中对 Rust 开发者最友好的那条路。选它的理由很实际——Rust 有 cargo 这套顺手的构建工具、有所有权模型帮你避开原生扩展最常见的内存崩溃、还有serde、rayon这些成熟库可以直接用。
1.3 什么场景值得上 Rust,什么场景别折腾
不是所有项目都值得引入原生扩展。我自己的判断标准是这样的:
| 场景特征 | 建议方案 | 理由 |
|---|---|---|
| 纯 UI、回合制、文字类 | 纯 GDScript | 性能根本不是瓶颈,引入 Rust 纯属自找麻烦 |
| 大量数学计算、寻路、物理模拟 | Rust 扩展 | 这类逻辑没有引擎 API 调用,纯 CPU 密集,Rust 收益最大 |
| 需要调用第三方 C/Rust 库 | Rust 扩展 | 比如图像处理、压缩、加密,直接复用生态 |
| 频繁和节点树交互的逻辑 | 谨慎 | 跨语言调用有开销,每帧几千次call反而更慢 |
| 需要热重载、快速迭代的玩法 | GDScript 为主 | Rust 编译一次几十秒,改玩法逻辑不划算 |
我踩过的一个坑:一开始我把“实体状态机”也挪到了 Rust,结果发现状态机里大量调用get_node、emit_signal,跨语言边界来回跳,性能反而比 GDScript 还差。后来把状态机挪回 GDScript,只把“纯计算”的邻域查询留在 Rust,才达到理想效果。记住一句话:Rust 扩展擅长算,不擅长频繁和引擎对象打交道。
2. 环境搭建与项目骨架
2.1 工具链准备:别在版本上栽跟头
godot-rust 对版本比较敏感,我第一次搭环境就卡了半天,原因是 Rust 版本太旧。下面是我实测稳定的一套组合(截至我写这篇时的主流版本,你装的时候尽量对齐):
- Godot 4.2 或 4.3:4.0/4.1 也能用,但 gdext 的新版本逐渐不再保证兼容,建议直接上 4.2+。
- Rust 1.75 以上:用
rustup安装,别用系统包管理器里的老版本。 - cargo:随 rustup 一起装好。
- 平台构建工具:Windows 需要 MSVC 工具链(装 Visual Studio Build Tools,勾选 C++ 生成工具);Linux 需要
build-essential;macOS 需要 Xcode Command Line Tools。
安装 Rust 的命令很标准:
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | shWindows 用户直接去官网下rustup-init.exe更省事。装完执行rustc --version和cargo --version确认一下。
注意:如果你在 Windows 上用的是 GNU 工具链而不是 MSVC,编译出来的 dll 有可能加载失败。稳妥起见,
rustup default stable-msvc切到 MSVC。
2.2 用官方模板起项目,别从零手搓
godot-rust 官方提供了一个项目模板godot-rust/gdext-template,我强烈建议用它起步,因为.gdextension配置、Cargo.toml的crate-type、目录结构这些容易出错的细节它都帮你配好了。
最省事的做法是装cargo-generate:
cargo install cargo-generate cargo generate --git https://github.com/godot-rust/gdext-template它会问你项目名,然后生成一个包含src/lib.rs、Cargo.toml、project.godot、demo.gdextension的完整骨架。生成后目录大概长这样:
my-extension/ ├── Cargo.toml ├── src/ │ └── lib.rs ├── godot/ │ ├── project.godot │ └── demo.gdextension └── .gitignore如果你不想用模板,手动配也行,但Cargo.toml里这两行必须写对:
[lib] crate-type = ["cdylib"] [dependencies] godot = { git = "https://github.com/godot-rust/gdext", branch = "master" }cdylib是关键,它告诉 cargo 生成动态库而不是 Rust 默认的 rlib。我第一次忘了改这个,编译出来一堆.rlib,Godot 根本加载不了,排查了半小时才反应过来。
2.3 .gdextension 配置文件的门道
.gdextension是 Godot 识别扩展的入口,模板里生成的demo.gdextension内容大致如下:
[configuration] entry_symbol = "gdext_rust_init" compatibility_minimum = 4.2 [libraries] windows.debug.x86_64 = "res://../target/debug/my_extension.dll" windows.release.x86_64 = "res://../target/release/my_extension.dll" linux.debug.x86_64 = "res://../target/debug/libmy_extension.so" linux.release.x86_64 = "res://../target/release/libmy_extension.so" macos.debug = "res://../target/debug/libmy_extension.dylib" macos.release = "res://../target/release/libmy_extension.dylib"几个容易踩的点:
entry_symbol必须和 Rust 侧#[gdextension] unsafe impl ExtensionLibrary for ...生成的符号一致,模板默认是gdext_rust_init,别乱改。- 路径里的
res://../target/...是相对 Godot 项目目录的。如果你的 Godot 项目不在godot/子目录,路径要相应调整。 compatibility_minimum写你实际用的 Godot 版本,写高了低版本加载会报错。
实操心得:调试阶段用 debug 库,方便看 panic 信息;发布前一定切 release,Rust 的 release 优化能把计算性能再拉高一大截,我那个寻路模块 debug 和 release 差了将近 3 倍。
3. 核心机制:类注册、属性暴露与跨语言调用
3.1 用宏把 Rust 结构体变成 Godot 节点
godot-rust 最核心的体验就是那几个过程宏。一个能被 Godot 使用的类,基本结构是这样的:
use godot::prelude::*; #[derive(GodotClass)] #[class(base=Node2D, init)] struct GridBuilder { cell_size: f32, grid: Vec<Vector2i>, base: Base<Node2D>, } #[godot_api] impl GridBuilder { #[func] fn rebuild(&mut self, width: i32, height: i32) { self.grid.clear(); for x in 0..width { for y in 0..height { self.grid.push(Vector2i::new(x, y)); } } } #[func] fn cell_count(&self) -> i32 { self.grid.len() as i32 } } #[godot_api] impl INode2D for GridBuilder { fn ready(&mut self) { godot_print!("GridBuilder ready"); } }拆开看几个关键点:
#[derive(GodotClass)]负责生成 Godot 需要的虚表、类型信息。#[class(base=Node2D)]指定它继承自哪个 Godot 类。你可以继承Node、Node2D、Resource、RefCounted等等。base: Base<Node2D>这个字段是必须的,它持有父类实例的句柄,你调用父类方法时要用它。#[godot_api]标记的impl块里,#[func]方法会被暴露给 GDScript 调用。impl INode2D for ...是生命周期回调,ready、process、physics_process都能在这里实现。
编译之后,在 Godot 编辑器里点“创建新节点”,搜索GridBuilder,就能像内置节点一样添加。GDScript 里也能直接GridBuilder.new()。
3.2 属性暴露:让编辑器里能调参数
光有方法还不够,很多时候你希望策划能在编辑器里调参数,而不是改代码重编译。godot-rust 用#[export]实现这个:
#[derive(GodotClass)] #[class(base=Node2D, init)] struct PathAgent { #[export] speed: f32, #[export(range = (0.0, 100.0, 1.0))] turn_rate: f32, #[export] agent_name: GString, base: Base<Node2D>, }#[export]支持的类型包括基本数值、GString、Vector2/3、Color、NodePath,以及你自己注册的其他 Godot 类。range参数能生成滑动条,(最小值, 最大值, 步长)。
这里有个细节值得说:Rust 的f32和 Godot 的float是对应的,但如果你用f64,导出会失败。我一开始习惯性写f64,编辑器里死活看不到属性,后来才意识到 Godot 内部浮点是 32 位。同理,整数用i32或i64都行,但要注意 Godot 的int是 64 位。
3.3 跨语言调用的开销,心里要有数
这是很多人忽略的一点。Rust 和 GDScript 之间每次调用都要经过 GDExtension 的 C 接口,有固定的开销。我做过一个粗略的基准测试(同一台机器,release 构建):
| 调用方式 | 单次耗时(纳秒级) |
|---|---|
| GDScript 内部函数调用 | 约 50-100 |
GDScript 调 Rust#[func] | 约 300-600 |
| Rust 调 GDScript 方法 | 约 400-800 |
| Rust 内部纯计算 | 约 1-5 |
数字只是量级参考,但结论很明确:跨语言边界比语言内部调用贵一个数量级。所以正确的用法是“批量传数据、批量算、批量返回”,而不是“每帧调用几千次小函数”。
我那个寻路模块的改法就是典型:GDScript 每帧只调用一次compute_paths(agents_array, obstacles_array),把整批数据打包传进去,Rust 内部用rayon并行算完,一次性返回结果数组。这样跨语言调用从每帧几千次降到一次,开销可以忽略。
注意:传数组时优先用
PackedInt32Array、PackedVector2Array这类紧凑类型,别传Array(里面装的是 Variant,装箱拆箱很贵)。
4. 实战:把寻路热逻辑迁到 Rust
4.1 需求拆解与接口设计
回到开头那个沙盒项目。我要迁出去的是两块:
- 邻域查询:给定一个实体位置和半径,找出附近所有实体。
- 网格重建:地形被破坏后,重新计算受影响区域的可行走网格。
设计接口时我遵循一个原则:Rust 侧只接收纯数据,不持有节点引用。因为一旦 Rust 结构体里存了Gd<Node>,生命周期管理会变得很麻烦,而且容易造成循环引用。
所以接口设计成这样:
#[godot_api] impl SpatialIndex { #[func] fn build_index(&mut self, positions: PackedVector2Array, cell_size: f32) { // 用均匀网格重建空间索引 } #[func] fn query_radius(&self, center: Vector2, radius: f32) -> PackedInt32Array { // 返回命中实体的索引数组 } }GDScript 侧每帧把实体位置打包成PackedVector2Array传进来,拿到索引数组后再自己映射回节点。这样 Rust 完全不碰节点树,职责清晰。
4.2 均匀网格索引的实现
邻域查询用均匀网格(uniform grid)比四叉树更适合这个场景,因为实体分布相对均匀,网格实现简单、缓存友好。核心思路是把空间切成固定大小的格子,每个实体按坐标落进对应格子,查询时只检查中心格子周围一圈。
struct SpatialIndex { cell_size: f32, cells: HashMap<Vector2i, Vec<i32>>, positions: Vec<Vector2>, } impl SpatialIndex { fn rebuild(&mut self, positions: Vec<Vector2>) { self.cells.clear(); self.positions = positions; for (idx, pos) in self.positions.iter().enumerate() { let cell = self.cell_of(*pos); self.cells.entry(cell).or_default().push(idx as i32); } } fn cell_of(&self, pos: Vector2) -> Vector2i { Vector2i::new( (pos.x / self.cell_size).floor() as i32, (pos.y / self.cell_size).floor() as i32, ) } fn query(&self, center: Vector2, radius: f32) -> Vec<i32> { let mut result = Vec::new(); let r2 = radius * radius; let min_cell = self.cell_of(center - Vector2::new(radius, radius)); let max_cell = self.cell_of(center + Vector2::new(radius, radius)); for cx in min_cell.x..=max_cell.x { for cy in min_cell.y..=max_cell.y { if let Some(bucket) = self.cells.get(&Vector2i::new(cx, cy)) { for &idx in bucket { let d = self.positions[idx as usize] - center; if d.length_squared() <= r2 { result.push(idx); } } } } } result } }cell_size的选择有讲究:太小格子数量爆炸,内存和遍历成本上升;太大每个格子实体太多,退化成暴力遍历。经验值是取“平均查询半径的 1 到 2 倍”。我那个项目查询半径普遍在 120 像素左右,所以cell_size设成 128,实测效果最好。
4.3 用 rayon 并行加速批量查询
单个查询其实已经很快了,但每帧要处理几百个实体的查询,串行还是有点吃力。这时候 Rust 生态的优势就体现出来了——直接上rayon:
[dependencies] rayon = "1.8"use rayon::prelude::*; fn batch_query(&self, centers: &[Vector2], radius: f32) -> Vec<Vec<i32>> { centers .par_iter() .map(|c| self.query(*c, radius)) .collect() }par_iter会自动把工作分到多个线程。我实测在 8 核机器上,800 个实体的批量查询从 6ms 降到 1.2ms 左右。这里要注意:rayon 用的是自己的线程池,和 Godot 的主线程是分开的。只要你的 Rust 函数不碰 Godot 对象(不调get_node、不发信号),就是线程安全的。这也是我前面强调“Rust 侧只处理纯数据”的另一个原因。
实操心得:第一次用 rayon 时我在函数里调了
godot_print!,结果偶发崩溃。原因是 Godot 的打印接口不是线程安全的。后来把所有日志都改成先收集到Vec<String>,回到主线程再统一打印,问题消失。
4.4 GDScript 侧的对接写法
Rust 编译好、.gdextension配好之后,GDScript 侧的使用非常自然:
extends Node2D var spatial_index: SpatialIndex func _ready(): spatial_index = SpatialIndex.new() add_child(spatial_index) func _physics_process(delta): var positions := PackedVector2Array() for agent in agents: positions.append(agent.global_position) spatial_index.build_index(positions, 128.0) var centers := PackedVector2Array() for agent in agents: centers.append(agent.global_position) var neighbors := spatial_index.query_radius(centers, 120.0) # neighbors 是 PackedInt32Array,按顺序对应每个 agent 的邻居索引注意SpatialIndex.new()能直接用,是因为 Rust 侧注册了这个类。add_child之后它才进入场景树,ready回调才会触发。
5. 常见问题与排查实录
5.1 加载失败:先看这三个地方
扩展加载失败是最常见的问题,Godot 控制台一般会给提示,但信息不一定直白。我整理了一个排查顺序:
| 现象 | 可能原因 | 排查方法 |
|---|---|---|
| 编辑器里搜不到自定义类 | .gdextension路径错 / dll 没编译 | 检查路径指向的文件是否存在,先cargo build |
报entry_symbol not found | 符号名不匹配 | 确认 Rust 侧ExtensionLibrary实现和配置一致 |
| 加载后崩溃 | 版本不兼容 / 线程问题 | 对齐 Godot 和 gdext 版本,检查是否在子线程碰了 Godot 对象 |
| 类能搜到但属性不显示 | 类型不支持导出 | 检查是否用了f64等 Godot 不支持的类型 |
我遇到最多的是第一种。有次改项目目录结构,忘了同步.gdextension里的相对路径,折腾了二十分钟。后来养成习惯:每次改目录结构,第一件事就是打开.gdextension核对路径。
5.2 panic 了怎么办:让错误信息可见
Rust 的 panic 默认会打到 stderr,但 Godot 编辑器不一定能捕获到。调试阶段我建议在Cargo.toml里加上:
[profile.dev] panic = "abort"同时在入口处设置 panic hook,把信息转成 Godot 能显示的日志。更简单的办法是:调试时用 debug 构建,然后在终端里直接运行 Godot,这样 stderr 会打到终端,panic 的堆栈一目了然。
另一个坑是unwrap()。Rust 里图省事写vec.get(i).unwrap(),一旦索引越界就 panic,整个游戏崩掉。在扩展代码里,我现在的习惯是能用if let就用if let,实在要用unwrap的地方加注释说明为什么这里不可能失败。
5.3 性能不升反降:多半是调用粒度太细
前面提过,跨语言调用有固定开销。如果你发现用了 Rust 反而更慢,八成是把调用切得太碎。判断方法很简单:在 Godot 的 Profiler 里看 Rust 函数的调用次数。如果每帧调用上千次,那基本就是这个问题。
解决办法就是“批量化”。把 N 次小调用合并成 1 次大调用,把循环从 GDScript 挪到 Rust 内部。我那个项目最初是每个实体单独调一次query_radius,改成批量接口后,调用次数从每帧 800 次降到 1 次,性能立刻正常了。
5.4 热重载与迭代效率
Rust 编译慢是公认的,debug 构建一个中等项目也要十几秒。我的应对策略是:
- 逻辑分层:容易变的玩法逻辑留在 GDScript,Rust 只放稳定的计算核心。这样改玩法不用重编译。
- 增量编译:cargo 默认就支持,但要注意别频繁改
Cargo.toml的依赖,改依赖会触发全量重编译。 cargo check先行:改完代码先cargo check看有没有编译错误,比直接cargo build快很多,确认没问题再 build 出 dll。
最后分享一个小技巧:Godot 4.2 之后支持在编辑器里重新加载 GDExtension(不用重启编辑器),但前提是 dll 文件没被占用。Windows 上有时 dll 被锁住,重新 build 会失败,这时候关掉 Godot 再 build 就行。
6. 一些关于选型和长期维护的体会
用了一段时间 godot-rust,我对它的定位越来越清晰:它是 Godot 性能工具箱里的一把好刀,但不是万能钥匙。刀要用在刀刃上——纯计算、批量处理、需要复用 Rust 生态的场景,它无可替代;而频繁和节点树交互、需要快速迭代的玩法逻辑,还是 GDScript 更合适。
从维护角度看,godot-rust 社区挺活跃,gdext 的 API 也在持续完善,但毕竟不是官方项目,版本升级时偶尔会有 breaking change。我的做法是锁定一个稳定版本,不盲目追新,等新版本稳定一两个小版本之后再升级。升级前先在分支上跑一遍完整测试,确认没问题再合并。
另外,如果你的团队里只有你一个人懂 Rust,那引入它要慎重。原生扩展一旦出问题,排查门槛比 GDScript 高不少,别人接手也困难。我现在的做法是把 Rust 侧代码写得尽量“薄”——只做纯函数式的计算,接口简单到看名字就知道干什么,这样即使别人不懂 Rust,也能从 GDScript 侧理解整个数据流。
这个内容后续还能往几个方向扩展:比如把物理碰撞检测也挪到 Rust 用空间哈希加速,或者用 Rust 写自定义的Resource类型做数据序列化。等我把这些实践跑通,再来补一篇。