Godot 4.4 把 Jolt 物理引擎收编进官方仓库(modules/jolt_physics/),标着 experimental,而在这之前它以 GDExtension 插件 Godot Jolt 的身份在社区里活了两年多,口碑好到反过来推动了官方收编。我翻 4.4 源码时最感兴趣的问题是:一个第三方物理引擎要改成什么样,才能塞进 Godot 的服务器架构里? 答案分三层:注册点、适配层、行为差异。看完源码后我认为这次收编也是一次压力测试——它验证了 Godot 的"服务器抽象"到底是不是真抽象。
为什么物理引擎可以整个换掉:服务器模式的回报
Godot 从 3.x 到 4.x 还的最大的架构债,就是把"引擎核心"和"具体实现"拆成节点层与服务器层。节点层的 RigidBody3D、CollisionShape3D 不做任何物理计算,它们只是 PhysicsServer3D 里 RID 句柄的持有者:节点创建时向服务器 body_create() 拿一个 RID,之后所有操作都是"拿 RID 调服务器接口"。
这个设计的直接后果是:场景树和物理内核之间没有任何共享数据结构,只有接口调用。接口是窄的——几十个虚函数,参数全是 RID 和值类型;语义是显式的——body_set_state、space_set_param,每个调用自包含。窄接口意味着替换实现时不用改任何调用方,这正是 Jolt 能进来的前提。反例是 3.x 时代物理代码直接摸场景对象,换物理引擎等于重写半个引擎。
代价当然也真实存在:每次 body_set_state 都是一次跨抽象层的调用加 RID 解引用,十万次级别的逐 body 操作会比直接摸内存慢。引擎的回应是把高频批量操作收进服务器内部(broad phase、求解全在服务器一侧跑),节点侧只剩低频状态读写。抽象的税按调用次数收,所以设计接口时第一件事就是把调用次数压下去。
注册点:PhysicsServer3DManager
物理引擎在 Godot 里是可插拔的"服务器"。启动时 main/main.cpp 的 initialize_physics() 从 ProjectSettings 读配置,向 manager 要一个实例(4.2 源码,4.4 同样):
// main/main.cpp
void initialize_physics() {
physics_server_3d = PhysicsServer3DManager::get_singleton()->new_server(
GLOBAL_GET(PhysicsServer3DManager::setting_property_name)); // "physics/3d/physics_engine"
if (!physics_server_3d) {
physics_server_3d = PhysicsServer3DManager::get_singleton()->new_default_server();
}
physics_server_3d->init();
}
Jolt 模块的注册代码在 modules/jolt_physics/register_types.cpp(4.4,原文节选):
PhysicsServer3D *create_jolt_physics_server() {
#ifdef THREADS_ENABLED
bool run_on_separate_thread = GLOBAL_GET("physics/3d/run_on_separate_thread");
#else
bool run_on_separate_thread = false;
#endif
JoltPhysicsServer3D *physics_server = memnew(JoltPhysicsServer3D(run_on_separate_thread));
return memnew(PhysicsServer3DWrapMT(physics_server, run_on_separate_thread));
}
void initialize_jolt_physics_module(ModuleInitializationLevel p_level) {
switch (p_level) {
case MODULE_INITIALIZATION_LEVEL_SERVERS: {
jolt_initialize();
PhysicsServer3DManager::get_singleton()->register_server(
"Jolt Physics", callable_mp_static(&create_jolt_physics_server));
JoltProjectSettings::register_settings();
} break;
// ...
}
}
register_server 注册一个名字加工厂函数,ProjectSettings 里 physics/3d/physics_engine 选 "Jolt Physics" 后,new_server 就调到这个工厂。注意工厂返回的不是裸的 JoltPhysicsServer3D,而是再包一层 PhysicsServer3DWrapMT——多线程包装器是复用的,物理服务器跑独立线程这件事 Jolt 不需要自己实现。这就是抽象设计得好带来的接入便利:插件作者只用关心物理本身。
整个套娃结构值得画一张图:
适配层:JoltSpace3D 怎么映射 Space
Godot 物理的顶层概念是 Space(空间),节点侧的 World3D 持有它。Jolt 侧对应物是 JPH::PhysicsSystem。JoltSpace3D 的构造函数就是两个世界观的翻译表(jolt_space_3d.cpp,4.4,节选):
JoltSpace3D::JoltSpace3D(JPH::JobSystem *p_job_system) :
job_system(p_job_system),
layers(new JoltLayers()),
contact_listener(new JoltContactListener3D(this)),
physics_system(new JPH::PhysicsSystem()) {
physics_system->Init((JPH::uint)JoltProjectSettings::get_max_bodies(), 0,
(JPH::uint)JoltProjectSettings::get_max_pairs(),
(JPH::uint)JoltProjectSettings::get_max_contact_constraints(),
*layers, *layers, *layers);
physics_system->SetGravity(JPH::Vec3::sZero()); // 注意:重力清零了
physics_system->SetContactListener(contact_listener);
physics_system->SetCombineFriction([](...) {
return ABS(MIN(p_body1.GetFriction(), p_body2.GetFriction()));
});
physics_system->SetCombineRestitution([](...) {
return CLAMP(p_body1.GetRestitution() + p_body2.GetRestitution(), 0.0f, 1.0f);
});
}
三个细节值得记下来:
- 重力设成零。Godot 的语义是 Area3D 可以定义局部重力、重力方向可以逐 body 定制,所以重力和阻尼由 Godot 侧自己积分,Jolt 只负责碰撞和约束。后面
dump_debug_snapshot里专门有一段把mGravityFactor回填回去的代码,注释写着 "Since we do our own integration of gravity and damping, while leaving Jolt's own values at zero",印证了这个分工。 - 摩擦合并取最小值、弹性合并相加再截断——这是 Godot Physics 的语义,Jolt 默认值不是这样,所以用回调显式改写。换物理引擎后手感一致,靠的就是这种细节的逐条对齐。
max_bodies、max_pairs、max_contact_constraints全是预分配上限(Jolt 的内存模型是启动时分配好),所以 4.4 多了一组physics/jolt_3d/开头的项目设置;默认上限打满时会在step里打 WARN_PRINT_ONCE 提示你调大。
这里"逐条对齐语义"是适配层真正的工作量所在。物理引擎之间没有标准语义:摩擦系数怎么合并(取小、取均值、相乘)、弹性怎么合成、休眠阈值、接触回收距离,每家都不一样,而玩家手感对这些全部敏感。适配层与其说是"翻译接口",不如说是"把 Jolt 的默认值逐条掰回 Godot 的行为"。这也是换物理引擎永远不可能零成本的根本原因——接口可以对齐,物理语义只能逼近。
step 流程:上帝视角的一帧物理
JoltPhysicsServer3D::step() 本身极简(jolt_physics_server_3d.cpp,4.4):
void JoltPhysicsServer3D::step(real_t p_step) {
if (!active) {
return;
}
for (JoltSpace3D *active_space : active_spaces) {
job_system->pre_step();
active_space->step((float)p_step);
job_system->post_step();
}
}
JoltSpace3D::step() 里才是真正的活(节选):
void JoltSpace3D::step(float p_step) {
stepping = true;
_pre_step(p_step); // 提交形状变更、把 Godot 侧状态同步进活跃 body
const JPH::EPhysicsUpdateError update_error =
physics_system->Update(p_step, 1, temp_allocator, job_system);
// ManifoldCacheFull / BodyPairCacheFull / ContactConstraintsFull
// 三种容量溢出各自打一次性警告
_post_step(p_step);
stepping = false;
}
_pre_step() 里有个循环容易被忽略:遍历 Jolt 的活跃刚体列表,回调每个 JoltObject3D::pre_step()——同步方向是 Godot → Jolt。反方向(Jolt 算完的位置写回 Godot)不在 step 里,而是 body 状态变化时进 call_queries 队列,由 flush_queries() 统一兑现,这和 SceneTree 每物理帧开头 PhysicsServer3D::flush_queries() 的时机正好接上(第一篇主循环讲过这个 flush)。
双向同步拆成"输入即时、输出排队"是有道理的:Godot → Jolt 方向的修改(脚本挪了一个 body)必须在下一次 Update 前生效,否则这帧就用了旧状态;Jolt → Godot 方向的结果则没有时效压力——场景树在物理帧内不该读物理结果(读了也是上一个可用快照),所以攒起来在 flush_queries() 一次性写回,既避免逐 body 加锁,又让"物理帧内读到的变换"有明确语义。两个方向的时效需求不对称,同步机制也就不对称。
physics_system->Update 内部是 Jolt 真正快的地方,值得说清楚快在哪。Jolt 把互不接触的刚体划分成 island(隔离岛),每个岛的约束求解是独立的子问题——$n$ 个刚体分成 $k$ 个岛后,求解成本从"一个 $n$ 体问题"变成 $k$ 个可并行的小问题,JobSystem 把岛分发到工作线程。Godot Physics 的求解器是单线程整体推进,同样的一千个盒子,Jolt 能把 islands 铺满所有核。再加上 Jolt 全程 SIMD 友好的内存布局(body 数据连续存储、按 16 字节对齐),单核算力本身也高一截。后面实测里"四成耗时"就是这么来的:多核并行吃一笔,数据局部性再吃一笔。
那个关于四叉树的诚实注释
try_optimize() 里的注释是我整份源码里最喜欢的一段,因为它罕见地承认了对第三方库内部实现的依赖(原文引用):
void JoltSpace3D::try_optimize() {
// This makes assumptions about the underlying acceleration structure of Jolt's broad-phase,
// which currently uses a quadtree, and which gets walked with a fixed-size node stack of 128.
// This means that when the quadtree is completely unbalanced, as is the case if we add bodies
// one-by-one without ever stepping the simulation, like in the editor viewport, we would
// exceed this stack size (resulting in an incomplete search) as soon as we perform a physics
// query after having added somewhere in the order of 128 * 3 bodies. We leave a hefty margin
// just in case.
if (likely(bodies_added_since_optimizing < 128)) {
return;
}
physics_system->OptimizeBroadPhase();
bodies_added_since_optimizing = 0;
}
翻译一下这个坑:Jolt 的 broad phase 是四叉树,查询时用固定 128 深的节点栈。如果一口气加几百个 body 但从不 step(编辑器视口摆场景就是这个模式),树会极度不平衡,查询栈不够用,结果是不完整查询——射线莫名其妙打不中。Godot 侧的对策是每加 128 个 body 主动调一次 OptimizeBroadPhase() 重建平衡。不读这段注释,你排查"编辑器里射线检测漏检"能查到怀疑人生。
这段注释还示范了集成第三方库时一种必要的诚实:适配层不只是转发调用,还要对被适配方的内部实现假设负责。四叉树和 128 深栈都是 Jolt 的实现细节,随时可能在升级中改变,但 Godot 的正确性此刻依赖它们。注释把假设写下来,就是给未来升级的人留的排雷图。
行为差异:切换到 Jolt 前要知道的
set_param() 是另一个诚实的角落:Godot Physics 支持按 space 调接触回收半径、穿透容差、求解迭代数等八个参数,Jolt 版对其中每一个都回一句 "Space-specific ... is not supported when using Jolt Physics. Any such value will be ignored."——这些参数在 Jolt 里变成了全局项目设置。这意味着从 Godot Physics 迁到 Jolt 时,逐 space 调过参的工程会静默失效,不会报错,只有警告。
实测层面(4.4,同场景一千个盒子自由落体堆叠):Jolt 的 JobSystem 把 islands 分发到多线程后,物理耗时大约降到 Godot Physics 的四成;但单线程小场景(几十个 body)两者差距不大,Jolt 甚至因为固定开销略慢一点。大规模堆叠、大量关节的绳子/布娃娃才是 Jolt 的主场。
这个结果和前面的架构分析对得上:island 并行的收益随刚体数超线性增长(岛多了才有东西可并行),而 Jolt 的固定开销——预分配内存、JobSystem 调度、双向同步队列——在小场景里摊不薄。几十 body 的场景换 Jolt 是负优化,这个结论不看源码只看 benchmark 平均值是很容易搞反的。
小结
这次收编对引擎架构是个很好的背书:PhysicsServer3D 抽象层和服务器注册机制设计得足够正,一个外部物理引擎可以在不改场景侧一行代码的前提下换进来。对用户的启示同样直接——物理引擎是个 ProjectSettings 下拉框,但下拉框背后是一整套参数语义的切换,迁移前把 jolt_3d 的设置页和上面那些 WARN_PRINT_ONCE 过一遍,比事后排查手感变化省时间。
本文代码引自 Godot 4.4-stable 的 modules/jolt_physics/register_types.cpp、jolt_physics_server_3d.cpp、spaces/jolt_space_3d.cpp,均有删减。