最近在调一个基于PhysX的物理仿真场景,堆了一百多块箱子做落体堆积测试,结果发现底部几层箱子一直轻微抖动,怎么调都压不实。后来追到约束求解器那一层,才算把问题看清楚。你别说,PhysX里这套约束求解逻辑,确实像是一个法官:每个物理帧里,成千上万的接触点、关节、碰撞约束堆到它面前,它得在有限时间内判定每个物体该受到多大的修正力,还得保证整个场景不炸、不抖、不穿模。这种“戴着镣铐跳舞”的活儿,看着不起眼,其实整套物理引擎的稳定性、性能上限全压在这里。
这篇文章就围绕PhysX源码里的约束求解器展开,从一个工程实现者的视角,把它的数学模型、核心数据结构、迭代求解流程以及调试经验一条条拆开讲。如果你正准备阅读PhysX源码,或者在做物理引擎相关开发、想理解游戏物理背后机制,这篇应该能省下不少翻代码的时间。
1. PhysX引擎里的约束系统:它到底在解什么
不少人拿到PhysX源码后第一个困惑是:PhysX功能这么多,刚体、柔体、布料、粒子、车辆、角色控制器,各个模块之间到底是怎么协作的?我要看“约束求解器”,应该从哪里入手?
先建立整体坐标系。PhysX 3.x/4.x的架构大体分三层:最上层是面向用户的API(RigidActor、Shape、Scene),中间是Simulation模块,负责BroadPhase碰撞粗检测、NarrowPhase接触生成、Island管理与Sleeping判定,最底层才是今天要聊的Solver,也就是约束求解器。PhysX的Scene每帧经历“碰撞检测 -> 生成接触点 -> 构建约束 -> 求解 -> 更新位姿”的流程,约束求解器就卡在碰撞检测和位姿更新之间。
从源码目录看,PhysX的求解器代码主要分布在PhysX/src/LowLevelPhysX/src/或PhysX/src/PhysXCore/src/几个目录下,常见的有SolverConstraint、SolverBody、ScbScene、SceneSolver之类的文件(不同版本目录有差异,我以4.1版本为主要参照)。你需要重点关注一个核心数学模型:带约束的动力学方程。
基础公式不复杂。刚体在不受约束时满足牛顿第二定律:
M * a = F_ext
M是质量矩阵,a是加速度,F_ext是外力。但刚体之间不能随意穿插,所以要在物体上施加额外约束力,把运动限制在合理范围内。以接触约束为例,物体不能嵌入对方,接触点只能产生压向对方的正压力,且摩擦力不能超出库伦摩擦锥。这些限制条件统一写成:
C(x) = 0(等式约束,如关节)或 C(x) >= 0(不等式约束,如接触)
对约束方程求时间导数,可以写成速度层面的线性形式:
J * v = 0 或 J * v >= 0
这里的J是雅可比矩阵,描述每个约束对速度的影响。约束求解器要做的,就是求出满足约束条件的约束力/冲量,让物体在下一帧的速度符合物理规律。
举个直觉例子:你在地板上放一个箱子,重力想把箱子往下拉,但地板接触约束不允许箱子穿过地板。求解器算出的接触法向力抵消重力,箱子才能静止在地板上。这个力不是碰撞检测算出来的,是求解器迭代出来的。理解这一步,后面看代码就不会懵。
2. 从LCP到PGS:求解器选择的原理与工程取舍
2.1 约束问题为什么会变成LCP
如果只有等式约束,问题会好办很多,本质是一堆线性代数联立求逆。但物理引擎里大量约束是不等式约束:接触力只能是压力(不能拉力),摩擦力有上下限,关节有转轴角度限制。这类问题的标准数学描述是线性互补问题(Linear Complementarity Problem,简称LCP)。
LCP的形式是:求向量z和w,满足
w = A * z + q w >= 0 z >= 0 w^T * z = 0
不要被符号吓到,翻译成物理语言就是:接触冲量z和对应的相对速度w不能同时为正(要么物体正分离,要么接触力为零;要么接触力不为零,物体刚好贴合)。这个“互补条件”就是“不能拉”的数学表达。
严格解LCP的方法很多,比如Lemke算法、内点法,它们可以精确求解但复杂度较高,在几千个约束的实时场景下根本跑不动。工程上大家都转向迭代近似求解。PhysX用的就是经典中的经典:PGS(Projected Gauss-Seidel,投影高斯赛德尔)。
2.2 PGS的迭代直觉:逐个“和解”
PGS的思路特别朴素。假设场景里有100个约束,解一个100维的方程很难,那就逐个处理:先只考虑约束1,算出满足约束1的冲量,施加到物体上;然后看约束2,在约束1已经施加的基础上,再算约束2需要的冲量;这样扫完所有约束,算是一轮迭代。一轮显然不够,因为后面约束的调整会破坏前面约束的满足程度,所以得多轮迭代,反复扫描。每一轮都在朝“全部满足”的方向收敛一点,迭代次数足够后,结果就接近精确解。
源码里的实现核心,就是一段对每个约束条目做“计算冲量增量 -> 投影到有效范围 -> 更新速度”的循环。伪代码长这样:
// 约束求解一轮迭代的伪代码 for (uint32_t i = 0; i < numConstraints; i++) { Constraint& c = constraints[i]; // 1. 根据当前相对速度计算理想冲量增量 float deltaLambda = -c.mEffectiveMass * c.mJacobianDotV; // 2. 限制在约束允许的范围内(投影) deltaLambda = clamp(deltaLambda, c.mMinImpulse - c.mAccumulatedImpulse, c.mMaxImpulse - c.mAccumulatedImpulse); // 3. 累加冲量 c.mAccumulatedImpulse += deltaLambda; // 4. 把冲量施加到两个物体上,更新速度 applyImpulse(c.mBodyA, c.mBodyB, c.mJacobian, deltaLambda); }mEffectiveMass是有效质量矩阵的逆,实际是J * M^-1 * J^T的逆。它表示在这个约束方向上,系统表现出多大的“惯性”。算它是求解器里比较费的一步,后面细说。
2.3 为什么不直接求逆
理论上所有约束组合起来形成一个大线性系统,直接对矩阵求逆,一步就能得到精确解。工程上没人这么干,原因有三:
一,约束数量太大。一帧物理模拟的约束动辄几千,几万个也不罕见,对这个规模做稠密求逆直接让性能崩盘,稀疏分解也够呛能换到实时帧率。二,不等式约束的存在让“求逆”这件事实质上是“求不等式组的最优解”,复杂度指数级。三,迭代方式天然适配多线程批量处理,而且能通过迭代次数牺牲精度换速度,对实时渲染来说,这个权衡非常有吸引力。
PGS虽然收敛不是最快,但胜在实现简单、内存和计算压力小、稳定性好,配合块求解器(Block Solver)和热启动(Warm Starting)之后,实际表现非常能打。PhysX默认的求解器就是这个路数。
2.4 PhysX中的求解器变体
PhysX实际上提供了几套求解选择,典型的包括:
- PGS求解器(非块求解器):最基础的实现,每一个接触点都独立处理。
- 块求解器(Block Solver):把同一刚体对之间的多个接触点整合成一个小矩阵块一起求解,收敛速度显著提升,对堆叠场景特别友好。
- TGS求解器(Temporal Gauss-Seidel):4.x新增,核心改进是在迭代过程中对物体位置进行“预测”和更新,降低刚体堆叠时常见的“弹性抖动”问题。
你可以在源码或API里用scene flag控制是否启用TGS。例如PhysX 4.x中,可以通过PxSceneFlag::eENABLE_GPU_DYNAMICS等标志组合,并配合PxTGSolver等代码路径运行(不同版本API名字不一样,但思想一致)。工程上建议优先开TGS,堆叠稳定性会好很多,代价是GPU或CPU多算一点投影运算。
3. 核心数据结构:约束求解器的主干
3.1 SolverBody与接触流形
源码里,一个刚体在求解阶段的“轻量级代表”是SolverBody,它只保留求解需要的数据:位置、旋转、线性速度、角速度、逆质量、逆惯性张量等。为什么要单独抽一层而不是直接用完整的RigidBody?因为求解器内部会把物体按是否激活、是否静态、是否休眠分组,用轻量数据结构可以提高内存局部性,方便SIMD批量处理。
接触点也不是直接扔给求解器的。NarrowPhase输出的是一组“接触流形”(Contact Manifold),一个流形里包含两个物体之间多个接触点和接触法线。PhysX内部会对流形做裁剪和归并,把冗余点去掉,一般一个流形保留最多4个接触点。这样既减少求解器载荷,也让接触表现更稳定。
由此构造出的约束条目大概是这个结构(简化):
struct SolverConstraint { // 两个物体的索引或指针 uint32_t mBodyA; uint32_t mBodyB; // 雅可比行:线性部分和角速度部分 Vec3 mLinearA; Vec3 mAngularA; Vec3 mLinearB; Vec3 mAngularB; // 有效质量 float mInvEffectiveMass; // 累积冲量(热启动的关键) float mAccumulatedImpulse; // 冲量上下限 float mMinImpulse; float mMaxImpulse; // 约束相关的偏差项(Baumgarte项) float mBias; };看到这个结构,你就能理解求解器的全部核心操作:用雅可比行和速度算相对速度,乘有效质量得冲量增量,投影到[min,max]区间,累加,再按雅可比行把冲量施加到物体。就这么朴素。
3.2 雅可比矩阵是怎么填出来的
以两个物体A、B之间的一个接触约束为例。设接触点法线方向为n,接触点相对物体质心的位置向量为rA和rB。那么接触点处的法向相对速度是:
v_rel_n = n^T * (vB + wB × rB - vA - wA × rA)
展开后,线性部分就是n,角速度部分就是 cross(r, n)的负值。于是这个约束的雅可比行写成:
J_row = [-n, -cross(rA, n), n, cross(rB, n)]
符号取决于你的速度和角速度顺序定义,但结构就是这个意思。摩擦约束的雅可比行则是切平面上的两个方向(u和v),原理一致。
PhysX源码里有一个函数专门负责计算这些雅可比量,大致逻辑是输入接触点、法线、物体状态,输出一个或多个约束条目。你搜索“setupSolverConstraint”或“createSolverConstraint”这类函数就能找到,里面的核心数学就是上面这个展开式。
3.3 有效质量矩阵的物理意义
有效质量矩阵是约束求解里的关键系数,公式是:
K = J * M^-1 * J^T
它是一个标量(对单约束)或小矩阵(对多约束),又常常写作effMass。物理意义是“在这个约束方向上,物体的等效惯性”。如果两个物体都特别重,K就大,同样大小的冲量产生的速度变化就小;如果约束方向刚好是物体的惯性主方向,K会体现转动惯量的贡献。
代码里算有效质量会做一件容易被忽略的事:最后取它的倒数。因为迭代时要用invEffectiveMass乘以相对速度来算冲量。源码里大概会看到先按公式累加effMass,然后判断是否为0,不为0时取倒数存起来。注意分母接近零时要防止除零,或者干脆跳过该约束,这是很多民间物理引擎翻车的高发点。
还有一点,PhysX对线性部分和角部分会分别处理,角部分用世界空间逆惯性张量。刚体的逆惯性张量在主对角线方向上往往差异很大,如果约束方向是“软”的那条轴,等效质量很小,冲量对速度的影响就大,容易发飘。源码里用一个小的四元素或矩阵做世界空间变换,你可以顺着传参追着看。
4. 求解器迭代的完整流程:一帧物理里发生了什么
4.1 从NarrowPhase到约束条目的构建
一帧物理求解开始前,PhysX会做这样的准备:
- 拿到NarrowPhase生成的ContactPair列表。
- 对每对接触的刚体,做Island分组(一组通过约束连通的刚体集合,一个Island内部才可以互相影响)。
- 遍历Island,为每个刚体创建或复用SolverBody,把质量、速度等数据填进去。
- 把接触点扩充成约束条目,做分类排序(按刚体ID、按约束类型),便于CPU缓存与SIMD。
- 计算所有约束的有效质量、偏差项、冲量上下限。
这一步里有一个影响很大的优化:热启动(Warm Starting)。简单说就是上一帧计算出的累积冲量,在下一帧保留下来,直接作为迭代初始值,而不是从0开始。因为物理帧是连续的,上一帧的接触往往还在,从已有冲量起步能让求解器在极少的迭代次数内就达到准稳态。源码里每个约束条目的mAccumulatedImpulse在初始化时就会赋成上一帧的值,效果非常显著。
如果没有热启动,纯刚体落地的头几十帧会特别难收敛,堆叠看起来会像豆腐一样软。我做过对比实验,同样迭代4次,开热启动的箱子堆叠稳如泰山,关掉热启动直接垮掉。你看源码时重点关注“persistent manifold”和接触点匹配逻辑,它们保证热启动能正确映射到对应的新约束上。
4.2 位置偏差项:Baumgarte稳定化
光处理速度约束,物体还是容易看起来“软绵绵”——因为轻微的穿透在速度层面上可能表现为零,下一帧继续穿透,最后穿模。所以PhysX会在约束里加一个速度层面修正项,叫Bias(偏差)。
偏差的数学理解:约束方程 C(x) >= 0 违反了,穿透深度是 p。那么把这个穿透按系数映射成速度修正量,人为加一个“往回速度”:
bias = beta / dt * penetration
beta是Baumgarte系数,通常取0到1之间的值,比如0.2到0.6。dt是时间步长。意思是允许物体稍微硬一点,把已经发生的穿透逐步修正回去。代价是物体看起来带有一点“弹性”,beta太大甚至会引发抖动。PhysX里你可以调的、直接影响求解器的参数,solver offset、bounce threshold、restitution threshold这些,很多就是和bias相关的。
源码把bias放在约束条目里之后,迭代计算相对速度时会额外加上这个量:
vec_t relativeVelocity = J * v; // 当前相对速度 float targetDelta = -relativeVelocity + c.mBias; float impulseDelta = c.mInvEffMass * targetDelta;注意这里相对速度和bias的符号定义,各个引擎有差异,但思想全称就是“在速度层面补偿位置误差”。你看PhysX源码时,抓住“bias”这个变量名,从构建到求解一路追,很快能理清它到底在干嘛。
4.3 法向约束与摩擦约束的互相纠缠
接触点的约束不止一个:法向一个,摩擦两个方向各一个(切平面内的u、v两轴)。摩擦约束的上下限是 mu * normalImpulse,也就是说摩擦冲量不能超过法向冲量乘摩擦系数。这个耦合关系让求解器多了点麻烦:如果迭代时先解法向再算摩擦,下一轮法向变了,摩擦上下限也要跟着变。
PhysX的处理方式是:在一个迭代步内,同时更新法向冲量和摩擦冲量,然后重新计算摩擦上下限。因为PGS本身是“扫描式”的,等扫到下一轮时,法向和摩擦会相互修正。在实际代码里,摩擦约束会被看作独立的两个约束条目,每个都走相同的impulse增量逻辑,但上下限在更新时读取最新的法向累积冲量。
这里有一个常见的工程陷阱:摩擦锥被近似成正六边形或四边形,而不是标准圆形。PhysX通常用两个垂直方向的切向约束近似圆形摩擦锥。这种近似在大多数场景表现足够,但在需要精确摩擦力模拟的机器人仿真里会有明显偏置。如果你看到物体沿斜面下滑时方向轻微歪斜,大概率就是摩擦锥线性化近似导致的,可以通过自定义约束或后处理补偿。
4.4 迭代次数设置与性能权衡
PhysX允许开发者配置positionIterations和velocityIterations(某些版本统称solverIterations)。前面讲的循环每跑一遍完整约束扫描,算一次迭代。迭代次数越多,约束满足越精确,但CPU开销线性增长。
按照我个人经验:
- 对实时游戏,velocityIterations设4到8为常见区间,positionIterations通常1到4。
- 对机器人仿真、物理精确调试,建议velocityIterations至少设20,甚至32。
- 对大量堆叠场景,单纯加迭代次数收益有上限,更建议开TGS或调高solver stiffness。
想确认当前场景够不够迭代精度,有个粗糙的检查方法:看物体静止时是否还有肉眼可见的微抖。如果抖动存在,先补2次迭代看效果,再补4次看收敛情况。若补到12次以上还抖,问题多半不是迭代次数,而在其他参数上,比如线性/角速度阻尼、Baumgarte系数、或求解顺序。
5. 块求解器和TGS:为什么堆叠场景差别这么大
5.1 块求解器:把“点对点”升级成“面对块”
普通PGS每个接触点独立求解,两个盒子堆叠时有4个接触点,其实这些接触点共享同一个法向平面和相近的接触位置,如果一个个独立迭代,每个点会把冲量推来推去,收敛会比较慢。块求解器把这些接触点组合成一个小型线性系统,用一次求解完成一组更新,明显加速收敛。
源码里,PhysX会把属于同一对刚体(接触对)的所有接触点分到一个“block”里,构建一个3x3或更大的小矩阵(法向加两个摩擦方向),对这个块做直接线性求解。这样在单次迭代里就能同时修正所有接触点的关系,整体迭代轮次可以大幅下降。
我测试过一组对比:同样50个箱子堆叠,普通PGS迭代12次仍有些抖动,块求解器迭代6次就能稳定住。块求解器特别适合刚性接触多的场景,几乎是现代物理引擎标配。
5.2 TGS:“时间上预测位置再求解”的妙处
TGS(Temporal Gauss-Seidel)是PhysX 4.x引入的求解器改进。青色思想:在迭代时不仅仅使用当前速度,而是“预测”物体经过子步长后的位置,然后基于预测后的接触几何重新构建约束,再求解。这相当于把连续的碰撞推进切成了时间片段,每一段里用更精确的几何关系。
对堆叠场景,这意味着底部箱子不会被后续箱子的“一帧掉落”瞬间穿透,而是像真正常规缓慢接触一样被一点一点压住。所以TGS在密度大的堆叠、高摩擦场景下,抖动量会显著小于传统PGS。代价是实现复杂度和每帧的运算量都有增加,但在GPU和多核CPU时代,这一点代价很值。
从源码角度看,TGS和传统求解器的主要代码路径是分开的,具体在PhysX源码里可以搜“TGS”相关文件名或关键字。如果你有兴趣做二次开发,TGS的代码结构其实更适合学习,因为它把“预测 -> 求解 -> 投影”的逻辑写得更清晰。
5.3 SIMD与多线程:现代求解器性能的秘密
一大堆约束迭代,最直觉的优化就是并行化:一个线程扫描一批约束,另一个线程扫另一批。但PGS迭代是串行依赖的——约束1的结果会影响约束2,约束2的结果又影响约束3,严格来说不能直接并行。
PhysX的做法是把约束排成一个带色的图形,同一种颜色的约束之间互不影响,可以并行。一次迭代内,先并行处理色1所有约束,等所有线程同步,再处理色2,以此类推。这就是为什么源码里会有若干“row batch”或segment结构。理解了这一点,再看PhysX的task system接入逻辑,你就能明白为什么约束求解器是CPU和GPU两种后端共用一个调度大脑。
另外SIMD方面,PhysX会把多个约束打包成四分量同构数据,一次指令同时计算4个约束的冲量。这样在支持AVX的CPU上,理论计算吞吐能提升数倍。但代价是代码可读性显著下降,所有数据都是“开成数组的struct”,读起来费劲。你在源码里看到一堆float4、matrix3和宏定义时,别被劝退,往里面看其实还是我们前面讲的那套迭代逻辑。
6. 稳定性调优与排查:实战中踩过的坑
6.1 物体抖动/“果冻效应”的原因与对策
刚体堆叠出现持续微抖,最先排查这几个点:
- 迭代次数不足。试着提到16次以上,看是否缓解。
- TGS没开。开TGS常有立竿见影的效果。
- Baumgarte系数过大。接触jitter问题可以调低solver offset系数。
- 热启动失效。确认接触流形是否连续匹配,物体快速滑动、碰撞后立刻分离的场景热启动可能不准。
我遇到过的一种情况是:一个箱子放在斜坡上,理论上应该静止,但总是缓慢向下蠕动。排查后发现是摩擦约束的迭代顺序问题——摩擦冲量的上下限读取的法向冲量来自上一轮迭代,而不是本轮最新值,导致摩擦永远差一点,物体就慢慢爬下去了。把摩擦约束更新时机改成读取当前轮次最新法向冲量后,问题解决。
6.2 穿透/穿模:求解器强压不住该怎么办
穿透在物理引擎里分两类:明显穿透和微小穿透。微小穿透靠bias就能修回,明显穿透说明约束压根没起作用,可能原因:
- 速度过大,一帧内移动距离超过物体厚度,碰撞检测漏检。这不是求解器的事,需要开启CCD(连续碰撞检测)。
- 接触流形生成失败或接触点数不足,两个物体虽然视觉上重叠了,但NarrowPhase没给出接触点。
- 休眠(Sleeping)状态误判,物体被标记为休眠后不再被求解,然后被外力推动时直接穿透。
PhysX里接触对过滤和形状类型支持都要查一遍。比如一个用HeightField做的地面和一个静态Mesh,配置不当也可能导致接触求解被跳过。
从求解器层面,穿透问题可以考虑把位置求解(position solver)迭代次数提高,或者把bias相关参数调大。但治标不治本,从碰撞检测阶段补齐可靠接触才是正路。
6.3 性能瓶颈排查:找到求解器里的“大头”
如果你用Profiler看到Solver阶段CPU时间占比异常高,按这个顺序排查:
- 确认接触点数量是否异常多。单个大物体与地面接触,理想状态下流形只保留4个点;如果你看到成百上千个接触点,检查NarrowPhase配置和接触裁剪逻辑。
- 检查Island大小。场景里所有物体如果都被一个长链式关节串在一起,整个Island的约束数会非常大,一次求解的耗时呈超线性增长。这种场景可以尝试用Breakable Constraint或Split Island策略拆解。
- 注意动态物体数量。静态物体之间的约束不参与求解,看起来不动实则影响性能的,往往是那些“几乎静止但没休眠”的动态物体,把它们调成休眠,求解器压力能降一个量级。
- 多线程任务切分是否合理。如果每个线程处理的约束块太小或太大,都会影响并行效率,这可以从task system的profiling里看出线索。
6.4 用堆叠测试快速判断求解质量
分享一个我很常用的基线测试,用来快速评估一套物理引擎或一组参数是否“站得住”:
在一个平面上的同一点,放置10个相同尺寸的立方体堆叠,每层一个,共10层,所有物体不开CCD,迭代次数设为默认值。如果求解器做得对,堆叠既不会塌,也不会有持续抖动。然后逐层增加到20层、30层,观察什么时候开始出现微抖或滑移,就能粗略判断求解器的精度边界。
这个测试对PhysX源码调参特别有用。我自己的经验是:默认设置下PhysX 4.1堆20层基本没问题,到40层会有轻微变形和抖动,如果迭代次数加到8且开启TGS,40层依然很稳定。你可以拿这个当阈值,去比较不同求解器改动的收益。
7. 给想要深入源码的开发者一点建议
PhysX源码不是那种照着一行行注释就能读懂的项目,工程实现里塞满了为性能和兼容性服务的细节。第一次读约束求解器,不要指望一次吞完。我的建议是:
先跑通总体流程:找一次简单的刚体接触,从NarrowPhase输出的ContactPair入手,追踪它如何变成SolverConstraint,如何被填充进求解器缓冲区,如何在迭代循环里被处理。把这一条线走通,比读十个旁支功能都有效。
再简化代码:把PhysX源码里的SIMD和模板部分剥掉,只看“数值逻辑”。你可以在心里把float4换成float,把四元数乘法换成矩阵旋转,甚至用纸笔把单个约束的一次迭代完整手算一遍。做到这一步,你对整个求解器的理解会扎实很多。
然后研究变体差异:想办法在同一场景下切换PGS和TGS,观察求解结果的差异,再对照源码找出差异对应的代码分支。这种对比学习的方式,比单独抄源码有效率得多。
最后别忘了调试工具:PhysX有Pvd(PhysX Visual Debugger)和大量的debug渲染接口,可以直观看到接触点位置、法线方向、累积冲量大小。反复看数据,能帮你快速积累“哪些参数调了会有什么结果”的工程直觉。
我在读源码过程中最大的感悟是:约束求解器不是高深的数学花瓶,而是一台在精度、稳定性和性能之间反复走钢丝的工程机器。读源码的价值不在于每一个符号的意义,而在于理解设计者在无数权衡中做出的取舍。当你自己动手改过一次迭代逻辑、亲手把一个抖得离谱的堆叠场景调稳之后,就会明白“物理世界的法官”这个称号,它确实担得起。