技术博客

PhysicsServer3D 抽象:为什么物理和场景树是解耦的

第 60 / 149 篇 · 2025-12-30 · godot · 物理引擎架构源码分析

物理这个分类第一篇,先把地基讲清楚:RigidBody3D 这类节点不算任何物理。它们只是服务器对象的遥控器——持有一个 RID,把属性读写转发给 PhysicsServer3D。真正的 broad phase、接触求解全在服务器侧。这个设计贯穿 Godot 4.x 全部子系统(渲染、导航、音频同构),但物理域最能看出它的必要性,因为物理和场景树之间隔着三道真实的矛盾。

接口形状:五族对象加一个查询出口

servers/physics_server_3d.h 定义的接口按对象族组织:space(世界)、shape(碰撞形状)、body、area、joint,外加单例级的状态开关。全部是纯虚函数,参数只用 RID 和值类型:

// servers/physics_server_3d.h(节选,4.x)
virtual RID body_create() = 0;
virtual void body_set_space(RID p_body, RID p_space) = 0;
virtual void body_set_state(RID p_body, BodyState p_state, const Variant &p_value) = 0;
virtual void body_set_shape(RID p_body, int p_shape_idx, RID p_shape) = 0;
// joint/area/shape 同构,每个对象一套 CRUD

节点侧的对应物薄得可怜:RigidBody3D::set_mass() 就是 PhysicsServer3D::get_singleton()->body_set_param(body, BODY_PARAM_MASS, p_mass) 一行。整个节点类的存在价值是"把服务器对象挂进场景树的生命周期"——_enter_tree 时创建 RID,_exit_tree 时销毁,其余全是转发。

这个接口为什么这样窄:所有跨层的共享都发生在参数值里,不共享指针、不共享容器。替换实现时调用方一行不用改——Jolt 能整体换进来(分类第 3 篇讲过),靠的就是这个。

三道矛盾:节奏、线程、可替换

节奏不同。 物理要固定步长(默认 60Hz,即 physics ticks per second),渲染帧率随便(无上限、掉帧、暂停都有)。绑在一起的话,渲染一卡物理就跟着卡,或者反过来物理在可变步长下数值漂移。解耦后各跑各的:SceneTree 用累加器凑够一个固定步长就调一次 PhysicsServer3D::step(),多出来的渲染帧用 physics interpolation 平滑。

线程不同。 物理默认跑在独立线程(physics/3d/run_on_separate_thread)。场景节点随时可能调物理 API——脚本在 _process 里给刚体加个力——但求解器正在另一个核上迭代。Godot 的解法是 PhysicsServer3DWrapMT:跨线程包装器把每个 API 调用序列化成命令入队,物理线程 step 前统一 flush 兑现。节点侧完全无感,"立即生效"的语义实际 deferred 最多一个物理帧。

实现可替换 + 无头可跑。 物理引擎之间没有标准行为(上一篇 Jolt 的摩擦合并规则就是例子),把语义收进接口后面,换引擎才成为可能。顺带的收益:服务器环境 --headless 也能创建 space、step、做烘焙——导航网格烘焙、程序化关卡生成这些纯计算场景不需要一个像素。

查询的纪律:direct state 只在窗口期可用

解耦带来一条硬规矩:PhysicsDirectSpaceState3D(intersect_ray 等查询的入口)只能在物理回调窗口内使用——_physics_process、信号回调、flush_queries 之后。脚本里换个时机调用:

ERROR: PhysicsServer3D: Flushing queries ... (or:space is locked)

原因值得讲透:step 期间,求解器就地改写 body 的位置和速度数组,broad phase 的树也在半更新状态。此刻做射线查询,读到的是迭代中途的撕裂快照——返回的命中点既不对应上一帧也不对应下一帧,拿去用就是隐性 bug。引擎的应对是加锁:step 期间空间上锁,查询直接报错。所以纪律不是风格问题,是数据一致性问题:物理状态在任何时刻只有一个合法读者(求解器)和一个合法观察窗口(flush 后)。

代价与缓解

抽象有税,物理域收两处:

  1. RID 映射:每次 API 调用多一次句柄查表。缓解方式在接口设计里——批量。body_set_state 一次传完整状态而不是逐属性调用,调用次数按 O(对象数) 而不是 O(属性数×对象数) 收税;
  2. 命令队列延迟:独立线程模式下,节点侧的修改最多一个物理帧后才生效。对"这帧改下帧生效"敏感的逻辑(子弹出膛当帧就要碰撞),要么接受延迟,要么关掉独立线程(小项目实测收益不大,牺牲值得掂量)。

这两笔税是恒定的,而物理计算的规模随场景膨胀——所以抽象的相对成本随项目变大反而下降。这也是为什么引擎敢在物理这种性能敏感域坚持服务器模式:税按调用次数收,而好的接口设计把调用次数压到了最低。

物理服务器分层(左)与双线程时序(右) 场景节点层 RigidBody3D 等只持有 RID,属性读写全转发 PhysicsServer3DWrapMT 跨线程命令队列(run_on_separate_thread 开启时) PhysicsServer3D 实现 GodotPhysics 或 JoltPhysics,同一份虚接口 Space 世界 broadphase + 求解器 + 查询,step 期间上锁 主线程 节点调用 → 命令入队 物理线程 flush → step(固定 dt) flush_queries 回主线程 解耦动机:节奏不同(固定步长)/ 线程隔离 / 实现可替换 + 无头可跑 代价:RID 查表 + 命令队列延迟最多一个物理帧;缓解靠批量 API 与低调用次数设计

本文基于 Godot 4.x 源码 servers/physics_server_3d.h(接口族)、servers/physics_server_3d_wrap_mt.cpp(命令队列包装)与 main/main.cpp 的 initialize_physics()。