上一篇讲了物理服务器为什么和场景树解耦,这篇进入默认实现(GodotPhysics,servers/physics_3d/)的内部,把 step() 这一个函数拆开看。整个流程一句话概括:flush 旧账、施力、找接触、解约束、写回。但每个词背后都是一个阶段,而阶段的先后顺序不是随便排的——它由数据依赖决定,看懂顺序就看懂了大半设计。
入口:服务器侧的四个顺序阶段
GodotPhysicsServer3D::step()(godot_physics_server_3d.cpp,4.x,有删减):
void GodotPhysicsServer3D::step(real_t p_step) {
if (!active) return;
delta = p_step;
for (GodotSpace3D *space : active_spaces) space->set_active(true);
// 1. 兑现上一帧排队的直接查询与 Area 信号
for (GodotSpace3D *space : active_spaces) space->call_queries();
// 2. 施加重力/阻尼、重算质量属性、更新形状 AABB
for (GodotSpace3D *space : active_spaces) space->setup(p_step);
// 3. broad phase:树重平衡 + 生成候选 body pair(此段空间上锁)
is_flushing_queries = true;
for (GodotSpace3D *space : active_spaces) space->get_broadphase()->update();
// 4. 窄相 + 求解 + 积分
for (GodotSpace3D *space : active_spaces) space->step(p_step);
is_flushing_queries = false;
}
两个顺序细节值得注意。call_queries 放在最前:上一帧 physics frame 里排队的查询(信号回调里调的 intersect_ray)必须在任何状态被改写之前兑现,否则会读到本帧的新状态——"排队查询"语义要求的是旧快照。setup 单独成阶段且在所有空间上跑完才进 broad phase:AABB 更新是纯几何操作,先全部做完,树的重平衡才能一次批处理完,而不是插一棵、平衡一棵。
阶段拆解
setup:遍历活跃 body,施加重力(或 Area 的局部重力)、阻尼,按速度更新每个形状的包围盒。这一步产出的是"broad phase 需要的最小变更集"。
broad phase(GodotOctree):八叉树重平衡后,对每个刚更新的 AABB 做树查询,AABB 有交的 body 组成候选 pair,过一遍 layer/mask 位过滤(分类第 7 篇展开),交叠的 pair 交给窄相。
step(GodotSpace3D::step → GodotStep3D),这是内核:
- 窄相:对每个候选 pair 跑
GodotCollisionSolver3D的求交,box/box 走 SAT,sphere/box 走最近点,凸包对凸包走 GJK/MPR——产出接触流形(3~4 个接触点及其法向、穿透深度); - 约束组装:接触转成求解器约束,关节(hinge、6DOF……)转成速度约束,连同接触图一起交给求解器;
- island 切分:以 body 为点、接触/关节为边做连通分量。关键性质:不同 island 之间没有约束耦合,可以独立求解——这是物理并行的基本单位,也是 Jolt 在大场景快的一个重要原因(第 4 篇细讲);
- 迭代求解:逐约束顺序应用冲量,经典 Gauss-Seidel。
求解的数学:一个冲量怎么算
单个接触约束,两个 body $A$、$B$,法向 $n$,相对速度 $v_{rel} = v_B - v_A$,恢复系数 $e$。要求冲量 $J$ 使法向相对速度从 $-(1+e)(v_{rel}\cdot n)$ 归零。对每个 body,冲量改变线速度也改变角速度,合并写成:
$$ J = -\frac{(1 + e)\,(v_{rel} \cdot n)}{n^T M^{-1} n}, \qquad v \gets v + M^{-1} J $$
其中 $M^{-1}$ 是"逆质量块矩阵"——线运动部分 $1/m$,角运动部分 $I^{-1}$(惯量张量,分类第 5 篇)。分母 $n^T M^{-1} n$ 是"沿法向推动这对 body 有多容易"的度量:质量大、惯量大的物体分母小,同样冲量下速度变化小。
Gauss-Seidel 的含义是:一次迭代里逐个约束算 $J$、立即写回速度,再算下一个约束。刚写完的 $J$ 马上影响后续约束的 $v_{rel}$——顺序敏感,但内存访问极佳(顺序扫数组,无矩阵组装),所以游戏物理几乎全用它。单约束精确、多约束靠迭代逼近,迭代数就是 physics/3d/solver/solver_iterations(默认 16 左右):堆叠的箱子每对箱子间的约束耦合深,迭代不够就"弹果冻"。穿透本身用 bias 项做位置修正:约束里额外加一段与穿透深度成正比的"伪速度",迭代结束一并积分掉——Baumgarte 稳定化的标准做法。
从流程看调参的落点
- 接触太多 → 嫌疑在 broad phase 之后的阶段:pair 数随物体密度平方涨,先查是不是全挤在 layer 1(第 7 篇);
- 堆叠抖动、果冻 → solver 迭代数或 contact bias 参数;
- 高速物体穿墙 → 离散积分的采样问题,不是求解器问题,走 CCD(第 6 篇)或提物理帧率;
_physics_process里读到的位置是上一个 step 写完的快照——上一篇讲过的窗口纪律在这里兑现。
本文基于 Godot 4.x 源码 servers/physics_3d/godot_physics_server_3d.cpp(step 入口)、godot_space_3d.cpp(space 阶段)、godot_step_3d.cpp / godot_solver_3d.cpp(窄相、island、迭代求解),代码片段有删减。