技术博客

Chaos 求解器主流程:FPBDRigidsSolver 一帧内做了什么

第 43 / 100 篇 · 2026-01-26 · ue5 · Chaos物理引擎源码分析

UE5 默认物理引擎是 Chaos,代码在 Engine/Source/Runtime/Experimental/Chaos(注意 5.3 了还在 Experimental 目录下,别被路径误导,它已经是正式默认)。物理一帧的核心在 FPBDRigidsSolver(Chaos/PBDRigidsSolver.h)和它持有的 FPBDRigidsEvolutionGBF。下面按 AdvanceOneTimeStep 的顺序拆。

线程模型先讲清

UE5 里物理默认跑在独立的物理线程(Physics Thread,PT)上,和游戏线程(GT)解耦。GT 每帧把"本帧想干的物理操作"(加力、设置速度、spawn 约束)通过命令队列丢给 PT,PT 按固定步长(Fixed Timestep,项目设置里默认 60Hz 左右)跑子步,结果再异步回写 GT。

为什么要固定步长而不是跟着渲染帧率走?两个原因。其一是数值稳定性:约束求解的收敛行为依赖 dt,变步长会让同一堆箱子在 30fps 和 144fps 的机器上表现不同,物理手感随帧率漂移是跨平台游戏的灾难。其二是确定性:回放系统、锁步网络同步都要求同样输入产生同样结果,固定步长是前提。代价是物理状态天然滞后渲染一帧左右,以及帧率不是步长整数倍时要插值(RigidBodyInterpolation),这些延迟和插值毛刺就是固定步长方案的账单。

解耦带来的新手必踩坑:GT 上直接读写物理体状态是不同步的。你 GT 里 SetPhysicsLinearVelocity 完立刻 GetPhysicsLinearVelocity,拿到的可能是 PT 还没处理的旧值。5.1 起引擎加了 FDirtyPropertiesManager(PhysicsProxy 上的脏属性机制)专门管这个:GT 写的属性先进 dirty set,PT 在下个 tick 开头统一应用。读源码时注意 Chaos::FPhysicsObject 这层封装,组件层看到的 FBodyInstance 只是个句柄。

一帧的管线

FPBDRigidsEvolutionGBF::AdvanceOneTimeStep(PBDRigidsEvolutionGBF.cpp)的主干:

1. ApplyInputs / ApplyPendingData    ← 应用 GT 队列里的命令、dirty 属性
2. Integrate                          ← 半隐式欧拉:v += F/m·dt,x += v·dt
3. BroadPhase                         ← 空间加速结构粗筛碰撞对
4. NarrowPhase                        ← 逐对生成 contact manifold
5. CreateConstraints                  ← 接触 + 关节全部转成约束图
6. Solve(迭代若干次)                 ← PBD 位置投影 + 速度求解
7. UpdateBodies / Sleep               ← 更新状态、岛休眠判定
8. 回写 GT(PostPhysicsCallback)

GT / PT 双线程模型与固定步长子步 GameThread(渲染帧率,可变) PhysicsThread(固定步长 60Hz) 帧 n:SetVelocity / AddForce / Spawn 帧 n+1:应用回写,派发 Hit 事件 坑:写完立刻读拿到旧值 DirtyProperties 下 tick 才生效 子步 1:Integrate → 碰撞 → Solve 子步 2:同管线再跑一遍 PostPhysicsCallback 打包变换 命令队列 + dirty set 结果回写(滞后一帧内) 帧率高于物理频率时靠插值补间(RigidBodyInterpolation) 确定性(回放/锁步同步)与数值稳定是固定步长的存在理由

BroadPhase:AABB 树加格子

粗筛在 FBroadPhase(Chaos/BroadPhase.cpp)里,底层是动态 AABB 树(FAABBTree)+ 空间哈希格子的混合结构。动态体放树里方便增量更新,静态大批量走格子。混合的原因是两种结构的成本曲线互补:树的查询是 $O(\log n)$ 但插入删除要维护平衡,移动物体每帧 reinsert 不便宜;格子查询摊还 $O(1)$、更新就是换桶,但内存随场景尺寸线性涨且对尺度悬殊的场景退化。动态上树、静态上格,是把"高频更新"和"海量静态"分别交给各自占优的结构。实测一个场景里几千个静态 mesh 反复 SetMobility 会让粗筛时间翻几倍——每次迁移都在两个结构之间搬数据。

NarrowPhase:隐式几何 + GJK/EPA

Chaos 的碰撞形状不是三角网格直接算,而是一套隐式对象体系(FImplicitObject,Chaos/ImplicitObject.h):球、胶囊、盒子是解析形状,凸包是 FConvex,三角网格是 FTriangleMeshImplicitObject(BVH 包着三角形)。窄相位的核心算法:

  • 凸对凸:GJK 求最近点,穿透时切 EPA 求穿透深度和法线(Chaos/GJK.h、Chaos/EPA.h);
  • 凸对三角网格:BVH 遍历到候选三角形后逐个做 GJK;
  • 输出是 contact manifold(一组 contact point,带法线、穿透深度)。

GJK 的原理值得一句:两个凸体的距离问题被转化为它们的 Minkowski 差 $A \ominus B = \{a - b\}$ 到原点的距离——原点不在差集内则无碰撞。算法不显式构造差集,只用 support 函数 $s(d) = \arg\max_{x \in A \ominus B} (x \cdot d)$ 迭代挑选点、在差集里逐步围出一个包含原点的单纯形,通常几次迭代就收敛,实践复杂度接近线性。穿透发生时 GJK 失效(原点已在单纯形内部),换 EPA:从 GJK 留下的单纯形出发,沿法向反复扩展成一个包住原点的多面体,最近的那个面给出最小穿透向量——深度和法线一次到手。

隐式对象还有个巧妙设计:TImplicitObjectScaled / TImplicitObjectTransformed 这类模板装饰器,缩放和变换不用改底层几何,命中测试时把光线/查询变换到本地空间就行。所以一个 box 资产可以被任意缩放复用而不用生成新形状。

约束求解:PBD 为什么叫 PBD

Chaos 用的是位置基动力学(Position Based Dynamics)。先把数学骨架立起来:一个约束写成位置函数 $C(x_1, x_2) = 0$(比如接触约束要求两穿透点沿法向的距离非负),PBD 每步求一个满足约束的最小位置修正,对粒子/刚体按逆质量加权:

$$\Delta x_i = -\frac{w_i\, C(x)}{\sum_j w_j\, \lVert \nabla_{x_j} C \rVert^2}\, \nabla_{x_i} C, \qquad w_i = \frac{1}{m_i}$$

所有约束逐个投影、循环若干遍,等价于在约束流形上做 Gauss-Seidel 迭代。Chaos 里约束(接触、关节)求解分两阶段,在 FConstraintRule 派生类里(接触是 FCollisionConstraintRule):

  1. 速度级:按冲量方式解一次,算目标速度;
  2. 位置级(投影):直接把穿透的物体沿法线"推出去",按上面的公式加权分配位移。

为什么位置投影"无条件稳定":纯冲量法是靠速度间接修正位置,穿透能否消除依赖积分精度,dt 一大或堆叠一高能量就注入系统,箱子塔会炸;位置投影每一步都把状态几何上拉回约束流形,误差以投影方式消除而不是靠动力学演化,dt 再大也不会爆炸——只是约束表现得越来越软(刚度随迭代次数收敛,不随 dt)。代价同样明确:堆叠底部的压力分布会失真(Gauss-Seidel 误差从底部累积),大质量比场景收敛慢,所以 Chaos 保留了速度级冲量求解做混合,还要靠迭代次数(NumIterations)和 substepping 补收敛。实测大质量比场景(重物压轻物)把 iteration 从默认调高一两档,比换 solver 参数里的 pushout 系数有效。

Island 与休眠

约束图建好后按连通性分岛(FPBDIslandManager),岛是并行求解的粒度——不同岛之间无线程依赖,可以并行跑 solver。岛也是休眠的粒度:岛内所有 body 速度低于阈值一段时间,整岛 sleep,之后 broadphase 之外的阶段直接跳过。这就是为什么"睡着的物体不耗 CPU",以及为什么吵醒一个箱子会连坐一整堆——唤醒是岛级的。

Substepping 与 CCD 的实际边界

固定步长内还可以再切子步(p.Chaos.Solver.NumSubsteps 系 cvar 或项目设置),快速小物体穿透问题先调这里而不是全局提高物理帧率——子步只重跑积分和求解,broadphase 的 AABB 更新不重复,性价比高。CCD(连续碰撞检测)在 Chaos 里只对线性扫掠有效,并且默认只对开了 CCD 的 body 与静态世界的交互启用;两个高速动态体互穿,CCD 救不了,要么上子步要么把速度限制住。调参时 p.Chaos.CCD 调试绘制能直接看到扫掠体。

结果回写与调试

PT 算完后通过 FSolverCallback 把 body 变换打包,GT 在下一个帧边界应用回 FBodyInstance 再到组件。物理材质、hit 事件也是在这个回写阶段派发的——所以 OnComponentHit 回调里拿到的 hit 信息是"上一个物理子步"的,时序上天然延迟一帧以内。

排物理问题的工具按使用频率排序:

  • p.Chaos.Solver.DebugDraw 1 系列:看接触点、约束、岛染色;
  • Stat Chaos / Stat ChaosPhysics:看各阶段耗时,窄相位和求解通常是大头;
  • 录帧后用 chaos.Solver 的 trace 通道在 Unreal Insights 里看 island 数和迭代分布。

一个常见误解要纠正:Chaos 不是"UE 自研从零写的",它的刚体求解继承了原 PhysX 集成的不少接口形态(FPhysScene、body instance 这套 API 层原样保留),替换的是底层。所以老项目迁移时上层组件代码基本不动,出问题多在行为差异:PBD 的手感(更"粘")、sleep 阈值、以及 CCD 的实现差异(Chaos 的 CCD 只对线性扫掠,角速度大的细长物体该穿还是穿)。