恒美微站
首页
关于我们
建站服务
主题模板
案例展示
资讯中心
联系我们
PhysX约束求解器深度拆解:从PGS到TGS的刚体堆叠稳定性实践
首页
资讯中心
/
PhysX约束求解器深度拆解:从PGS到TGS的刚体堆叠稳定性实践
PhysX约束求解器深度拆解:从PGS到TGS的刚体堆叠稳定性实践
发布时间:2026/9/11 21:28:35
最近在调一个基于PhysX的物理仿真场景堆了一百多块箱子做落体堆积测试结果发现底部几层箱子一直轻微抖动怎么调都压不实。后来追到约束求解器那一层才算把问题看清楚。你别说PhysX里这套约束求解逻辑确实像是一个法官每个物理帧里成千上万的接触点、关节、碰撞约束堆到它面前它得在有限时间内判定每个物体该受到多大的修正力还得保证整个场景不炸、不抖、不穿模。这种“戴着镣铐跳舞”的活儿看着不起眼其实整套物理引擎的稳定性、性能上限全压在这里。这篇文章就围绕PhysX源码里的约束求解器展开从一个工程实现者的视角把它的数学模型、核心数据结构、迭代求解流程以及调试经验一条条拆开讲。如果你正准备阅读PhysX源码或者在做物理引擎相关开发、想理解游戏物理背后机制这篇应该能省下不少翻代码的时间。1. PhysX引擎里的约束系统它到底在解什么不少人拿到PhysX源码后第一个困惑是PhysX功能这么多刚体、柔体、布料、粒子、车辆、角色控制器各个模块之间到底是怎么协作的我要看“约束求解器”应该从哪里入手先建立整体坐标系。PhysX 3.x/4.x的架构大体分三层最上层是面向用户的APIRigidActor、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_extM是质量矩阵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用的就是经典中的经典PGSProjected 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-Seidel4.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 * penetrationbeta是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“时间上预测位置再求解”的妙处TGSTemporal 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且开启TGS40层依然很稳定。你可以拿这个当阈值去比较不同求解器改动的收益。7. 给想要深入源码的开发者一点建议PhysX源码不是那种照着一行行注释就能读懂的项目工程实现里塞满了为性能和兼容性服务的细节。第一次读约束求解器不要指望一次吞完。我的建议是先跑通总体流程找一次简单的刚体接触从NarrowPhase输出的ContactPair入手追踪它如何变成SolverConstraint如何被填充进求解器缓冲区如何在迭代循环里被处理。把这一条线走通比读十个旁支功能都有效。再简化代码把PhysX源码里的SIMD和模板部分剥掉只看“数值逻辑”。你可以在心里把float4换成float把四元数乘法换成矩阵旋转甚至用纸笔把单个约束的一次迭代完整手算一遍。做到这一步你对整个求解器的理解会扎实很多。然后研究变体差异想办法在同一场景下切换PGS和TGS观察求解结果的差异再对照源码找出差异对应的代码分支。这种对比学习的方式比单独抄源码有效率得多。最后别忘了调试工具PhysX有PvdPhysX Visual Debugger和大量的debug渲染接口可以直观看到接触点位置、法线方向、累积冲量大小。反复看数据能帮你快速积累“哪些参数调了会有什么结果”的工程直觉。我在读源码过程中最大的感悟是约束求解器不是高深的数学花瓶而是一台在精度、稳定性和性能之间反复走钢丝的工程机器。读源码的价值不在于每一个符号的意义而在于理解设计者在无数权衡中做出的取舍。当你自己动手改过一次迭代逻辑、亲手把一个抖得离谱的堆叠场景调稳之后就会明白“物理世界的法官”这个称号它确实担得起。