前言

  看引擎源码应该是引擎岗的基操了,且了解我的可能知道我主物理模拟。之前在项目组实习时改过Unity的源码,返校后就只能接触些开源引擎。UE的Chaos有些过重了:Init时Scene依次创建Scene、Solver、Evolution,Object依次RegisteEvent,CreateState,InitBody;Simu时先将游戏线程的AOS数据Push到SOA的物理线程中,经过一系列复杂的计算(毕竟个人比较在意移动端的表现)后将改动的脏数据Pull回去。而Godot的源码轻量,结构清晰,干脆就着拆解下3D物理部分。

  看Godot最新版本4.5的文档其实还挺有趣的,如Godot的内存管理:对小对象采取预留空闲内存的堆分配,对大对象采取动态内存池 (用PooledList记录list和freelist)。与操作系统的分页选择截然不同,算是特化与通用的差异?以及Godot在21年发布的解释Godot为什么不基于ECS的架构和抽象层次的选择 (提供了一种新的视角,但个人其实不大认同,不过既然开源了有需要自己搭框架就好)。还有Godot的移动端渲染管线无法读取相邻像素 (不是哥们你这就BAN了我的片元偏导,计算着色器也基本不支持,印度东南亚手机都没这么不抗造吧?)。

架构概览

  整个Godot的3D物理部分都在modules/godot_physics_3d中,十分甚至九分的方便查找

  • 物理服务器(godot_physics_server_3d)创建和管理空间、形状、区域和碰撞对象。
  • 物理空间(godot_space_3d)包含物理世界中的所有物体,负责物理模拟。
  • 物理步进器(godot_step_3d)被空间用来逐步执行物理模拟。
  • 碰撞检测(godot_broad_phase_3d)快速找出可能发生碰撞的物体对
  • 碰撞求解(godot_collision_solver_3d)处理具体的形状之间的碰撞检测。
  • 形状(godot_shape_3d)定义了各种几何形状,用于碰撞检测。
  • 碰撞对象(godot_collision_object_3d)是区域、刚体和软体的基类,都可以有形状。
  • 区域(godot_area_3d)用于检测物体进入和退出,并可以修改重力等属性。
  • 刚体(godot_body_3d)硬质物理物体,受力和碰撞的影响。
  • 软体(godot_soft_body_3d)可变形物体,使用粒子系统模拟。
  • 约束和关节(godot_constraint_3d和godot_joint_3d)用于限制物体之间的运动。

  然后模拟流程大致为:

img

参数准备

  首先还是从准备开始讲起,虽说是要处理各种属性设置,主要的处理还是落在更新物体的质量属性。以最复杂的刚体为例,其先计算总面积(累加每个形状的面积),再计算质心(按面积分配质量),最后计算惯性张量(平行轴定理)。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
void GodotBody3D::update_mass_properties() {
// Select the calculation path based on the mode type
switch (mode) {
case PhysicsServer3D::BODY_MODE_RIGID: {
// Rigid body - Complete calculation of mass properties
// Area - Mass Center - Inertia
} break;
case PhysicsServer3D::BODY_MODE_KINEMATIC:
case PhysicsServer3D::BODY_MODE_STATIC: {
// Kinematics/Static Body - No Mass Attribute
} break;
case PhysicsServer3D::BODY_MODE_RIGID_LINEAR: {
// Linear rigid body - Mass only, no rotational inertia
} break;
}

// Update the quality attributes of the dependency transformation
_update_transform_dependent();
}

  稍微补充下惯性张量的计算: $I_{total} = \displaystyle\sum_{i=1}^{n}{(R I_{local}R^T + w(r \cdot r \cdot I_{identity} - r \otimes r))} $。以加号为界,左侧为当前形状转动惯量,右侧为加权合成。光是这样可能十分抽象,那就以点云的惯性张量举例吧。

  假设我们有一个由 n 个点组成的滔博陀螺,这些点的坐标是 pi ,其中点 i 的质量为 mi,我们希望计算其初始的旋转惯量。 则有公式:$I_{ref}=\displaystyle\sum_{i=1}^{n}(m_i(r_i\cdot r_i\cdot I_{identity}− r_i\otimes r_i))$,其中偏移向量$r_i=p_i-p_{center}$。这就是引擎所用公式的右侧部分,内积描述质量分布,外积描述惯性贡献。之后陀螺开始自转,旋转矩阵为 $R$ ,则有$ I=RI_{ref}R^T $。这就是公式的左侧部分,合并下就是引擎所使用的方案(这里引擎的方法考虑了形状在整体惯性张量中的位置偏移,个人认为没这必要)。

积分外力

  在更新完物理属性后就是刚体的积分外力,软体的形状预测和BVH碰撞检测。

刚体的积分外力

  首先是刚体的积分外力,跳过前面那堆遍历区域确认阻尼模式的代码,直接进入最核心的部分。对于运动学物体,其由外部驱动,再根据位置和旋转变化更新线速度和角速度。而对于刚体,其先计算合力和合力矩,再应用阻尼更新速度。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
void GodotBody3D::integrate_forces(real_t p_step) {
if (mode == PhysicsServer3D::BODY_MODE_STATIC) return;

// Get current gravity and damping

if (mode == PhysicsServer3D::BODY_MODE_KINEMATIC) {
//compute velocities from prev transfor
} else {
//compute force and torque
//apply damp and update velocity
}
//If CCD is used, calculate the motion for this step.
}

//If motion is calculated, update the shape for ray cast.
}

软体的形状预测

  然后是软体的形状预测,同样跳过前面那堆遍历区域确认重力的代码,在计算完风力后积分部分采用的显式欧拉加位移钳制,最后更新边界和加速结构(AABB包围盒树)。(有些想吐槽软体连个半隐欧拉都不上,但其实后续软体的迭代修正应该也足够了)

1
2
3
4
5
void GodotSoftBody3D::predict_motion(real_t p_delta) {
// Apply gravity and wind forces.
// Explicit euler + displacement clamping
// Update AABB bounds tree(vertices and triangular faces).
}

BVH碰撞检测

  最后是BVH碰撞检测,先更新BVH树,再检测碰撞。其中更新BVH树有增量优化和动态扩张。而碰撞检测阶段则是先扩展AABB,再填充裁剪参数,找出不再碰撞的项,查询重叠的项,处理新的碰撞项。

1
2
3
4
void GodotBroadPhase3DBVH::update() {
// Update BVH tree
// Check collisions
}

  先从Godot基于模板库的动态BVH树实现开始,节点和叶子定义如下。

1
2
3
4
5
6
7
8
9
10
11
12
13
struct TNode {
BVHABB_CLASS aabb;
union { int32_t num_children, neg_leaf_id; };
uint32_t parent_id;
uint16_t children[MAX_CHILDREN];
int32_t height;
};

struct TLeaf {
uint16_t num_items, dirty;
uint32_t item_ref_ids[MAX_ITEMS]; // Array of item reference IDs
BVHABB_CLASS aabbs[MAX_ITEMS]; // Array of item AABB
};

  在增量优化中则先就着原来结构自从底而上的更新AABB包围盒,再分帧抽出一个节点更新后重新插入,由此可解决AABB膨胀和次优位置。

1
2
3
4
5
6
7
8
void incremental_optimize() {
// Traverse downwards to the leaf nodes.
// Check for dirty marks, if necessary do upward fit.
refit_branch(root);
// Remove and get AABB
// Reinsert and balanced the path
_logic_item_remove_and_reinsert(node)
}

约束分组

构造约束

  在积分完外力之后,就是构造约束关系供后续并行化了。Godot分了区域,刚体和软体三处不同逻辑,简单来说区域部分就是逮着一个就扔进去(毕竟不进行计算),刚体是检测与其关联的刚体软体,软体只检测与其关联的刚体(软体之间应该是暂不支持)。三处构造约束的逻辑大同小异,这里放个刚体的做简单说明。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
void _populate_island(GodotBody3D *p_body, ...) {
// 1. Iterate through all the constraints of the rigid body
for (const KeyValue<GodotConstraint3D *, int> &E : p_body->get_constraint_map()) {

// 2. Search for the connected rigid body
for (int i = 0; i < constraint->get_body_count(); i++) {
GodotBody3D *other_body = constraint->get_body_ptr()[i];
if (i == E.value) continue; // Skip self
_populate_island(other_body, ...); // Recursive detection
}

// 3. Search for the connected soft body
for (int i = 0; i < constraint->get_soft_body_count(); i++) {
GodotSoftBody3D *soft_body = constraint->get_soft_body_ptr(i);
_populate_island_soft_body(soft_body, ...); // Turn to softbody
}
}
}

求解约束

  构造完约束的话下一步自然是求解:先并行设置所有约束,再单线程预求解(过滤无效约束),最后并行求解(使用优先级迭代)。具体约束由area,body,joint这些继承类实现,这里选取较为通用的body分析(area中没有求解步骤,joint变体过多)。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
bool GodotBodyPair3D::setup(real_t p_step) {
// 1. Check exception layers and collision layers
// 2. Calculate offset
// 3. Detect collisions
// 4. Mark whether CCD is required
}

bool GodotBodyPair3D::pre_solve(real_t p_step) {
// 1. If CCD is required, call _test_ccd()
// 2. Calculate the quality, offset, and impulse of the contact point
// 3. Report the contact point
// 4. Calculate the rebound coefficient
}

void GodotBodyPair3D::solve(real_t p_step) {
// 1. Solving for the offset impulse (for penetration resolution)
// 2. Solving for the normal impulse (for collision resolution)
// 3. Solving for the tangential impulse (for friction resolution)
// 4. Accumulating and limiting the impulses
}

  比较值得一提的是其3D碰撞检测是Bullet物理引擎中GJK-EPA算法的移植,算是游戏引擎的标配了。不过挺好奇为什么没有引擎上SAT,这种小常数的O(n)大概在100以内应该是优于大常数的O(log n)的,处理OBB那种简单形状应该较优。原因可能是Godot需要EPA求深度用于穿透冲量,但个人也不喜欢穿透冲量这一并不物理的概念,只能说有空自己写个物理引擎去玩玩。

积分速度

积分速度

  在解约束后,就是积分速度。对于刚体而言本质上就是各种注册回调,处理物体属性,锁定轴向的位置更新。

1
2
3
4
5
void GodotBody3D::integrate_velocities(real_t p_step) {
// 1. Callback registration
// 2. Kinematic objects, axis locking processing
// 3. Transformation update
}

约束求解

  如果是软体的话最终输出阶段还是要稍微复杂些,涉及到迭代求解约束(本质上还是基于 PBD 的距离约束,但引入了类 XPBD的刚度缩放与质量权重)。

1
2
3
4
5
6
void GodotSoftBody3D::solve_constraints(real_t p_delta) {
// 1. Pre-compute link parameters
// 2. Predict position (using current speed)
// 3. Iteratively solve constraints (position correction)
// 4. Update physical properties
}

  还是稍微展开下约束求解吧,它先计算了柔度$c_0=(invMass0+invMass1)/stiffness$ ,之后以 $k=(L²−|x|²)/(c0∗(L²+|x|²)) $求解。前一步结合了质量和刚度,但未除以 $Δt²$ 解耦材料属性与模拟时间步长;后一步在分母中使用 $(L²+|x|²)$ 稳定数值,但也没有存储和累计拉格朗日乘子 λ ,所以只能算是魔改的PBD。

  但话又说回来,游戏中步长和迭代次数恒定,只要刚度能调就行。先不提XPBD所带来的5%-10%性能消耗,XPBD还被 $Δt²$ 的大常数拖累导致参数调整不直观,反馈到美术那边就是开到极大极小值才有些微表现差异。这种情况下XPBD反而是技术陷阱(毕竟个人程策美三开)。

后记

  Godot的3D物理引擎是一个轻量级、模块化的实时物理模拟系统。通过约束划分和并行求解,也能在多核CPU上高效运行。虽然看源码本来就是一件麻烦事,但确实远比不上UE的心智负担,还是希望这个分析能给读者一些帮助吧。