技术博客

Jolt 接入 Godot 4.4 始末:PhysicsServer3D 是怎么被替换的

第 62 / 149 篇 · 2026-02-09 · godot · 物理引擎源码分析Jolt

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 不需要自己实现。这就是抽象设计得好带来的接入便利:插件作者只用关心物理本身。

整个套娃结构值得画一张图:

Jolt 接入后的调用栈分层(Godot 4.4) 场景节点层:RigidBody3D / CollisionShape3D —— 只持有 RID,不算物理 PhysicsServer3DWrapMT —— 跨线程命令队列(复用组件,Jolt 白拿) 调用先入队,物理线程统一兑现;替换物理引擎时这层不动 JoltPhysicsServer3D 适配层:RID ↔ JPH::BodyID GodotPhysicsServer3D 默认实现,同一接口的另一份 JPH::PhysicsSystem(Jolt 内核) broad phase 四叉树 / 求解器 / JobSystem ProjectSettings 的 physics/3d/physics_engine 决定虚线框左右哪一份被实例化

适配层: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,均有删减。