资讯动态

物理引擎中的接触力学与碰撞检测:从穿透检测到力求解

发布时间:2026/9/14 4:22:04 来源:尧图企业网站定制
1. 先搞清楚接触力学和碰撞检测到底是两件事还是一件事1.1 它们的分工一个负责发现碰撞一个负责算力做物理仿真做了这么多年我见过太多人把接触力学和碰撞检测混为一谈结果调试的时候无从下手。其实这俩是流水线上的两个环节碰撞检测回答的是什么东西、在什么时间、什么位置碰到了接触力学回答的是碰到了之后它们之间该产生多大的力、物体该怎么动。拿一个最简单的球掉地上的场景来说碰撞检测要做的是在某个时间步发现球的底部与地面发生了重叠然后计算出接触点坐标和接触法线方向。而接触力学要做的是根据穿透深度、相对速度、材料刚度这些信息算出地面那个向上的支撑力再把这个力加进动力学方程里让球弹起来或者稳住。在游戏引擎和机器人仿真里这两个环节的性能占比、稳定性风险完全不同。碰撞检测的难点在快帧率一掉体验直接崩接触力求解的难点在稳你要是直接把一个很大的接触力灌进积分器整个场景当场炸飞。这也是为什么我会建议所有做仿真的人都先把这条链路在脑子里拆清楚再动手写代码。1.2 一个球的落地背后到底发生了什么我经常用这个例子给团队里的新人讲。假设一个半径0.1米、质量1千克的球从1米高度自由落体。重力让它加速到大约4.4米每秒的速度撞向地面。从物理角度看接下来发生的事分成三个阶段第一阶段碰撞检测器在某个时刻检测到球面与地面平面相交得到穿透深度比如0.002米同时得到法线方向也就是竖直向上的单位向量。第二阶段接触力学模块根据穿透深度和接触点的相对速度计算法向接触力。如果用最基础的罚函数法这个力大概是刚度系数乘以穿透深度再叠加一个阻尼项。刚度和阻尼定得好球会快速减速、轻微下压、然后反弹定得不好球要么像撞在钢板上一样疯狂抖动要么直接穿透地面掉下去。第三阶段把接触力喂给积分器更新球的速度和位置。如果还有摩擦力还要处理切向方向的力让球滚动或者滑动。你会发现每一帧都在重复这个流程。而实际工程里的难点就在于接触力的计算和积分器的更新是耦合的力的计算依赖当前状态状态又反过来被力改变。这一步处理不好所有物理仿真都会翻车。接下来我把这两个环节逐个拆开讲。2. 接触力学的核心法向力、摩擦与赫兹理论的边界2.1 赫兹接触教科书模型和它的三个前提接触力学的理论根基绕不开赫兹Hertz在1882年提出的经典接触理论。它的核心结论是两个弹性球体在压力F下接触接触面的半径a与压力之间存在关系a (3FR / 4E*)^(1/3)其中R是等效曲率半径1/R 1/R1 1/R2E是等效弹性模量1/E (1-ν1²)/E1 (1-ν2²)/E2ν是泊松比。进一步推导可以得到著名的力-压入量关系F (4/3) × E* × R^(1/2) × δ^(3/2)这个公式在机械设计、轴承接触、牙齿咬合分析里都非常经典。但做仿真的人必须清楚赫兹理论有三个严格的前提小变形、弹性材料、无摩擦表面。真实世界的接触基本都不满足这些前提尤其是塑性变形和摩擦赫兹理论根本没法处理。所以在碰撞检测系统里我们几乎不会直接拿赫兹公式去算每一帧的接触力。一方面是因为它要求精确的几何和材料参数另一方面是因为它假设的是准静态过程而碰撞本质上是瞬态强冲击。我的经验是赫兹理论适合做离线验证和参数标定不适合做实时仿真。比如你想确定一个合理的接触刚度范围可以用赫兹公式估算一下同等载荷下的接触面积和压入量这个参考价值很大。2.2 罚函数法 vs 约束法两种主力方案的取舍实时仿真的接触力求解目前主流就两条路线罚函数法和基于约束的方法拉格朗日乘子法。罚函数法的思路特别直白把穿透想象成被压缩的弹簧穿透越深力越大。力的大小是 F_n k×δ c×(-v_n)其中δ是穿透深度v_n是接触点的法向相对速度k是刚度c是阻尼。这个公式你一看就懂实现起来也是真的简单两三行代码就能写出来所以很多轻量级物理引擎、自研小项目都在用。但罚函数法有个很烦人的问题刚度k调小了物体软绵绵的穿透很深看起来像果冻k调大了接触力在极短时间内猛冲猛拉数值积分器会震荡甚至发散。这就是所谓的stiffness dilemma。一般做法是让k和c匹配振动方程使得系统处于临界阻尼附近同时限制最大穿透深度超过阈值就强制修正位置。约束法则完全换了个思路。它把接触建模成一个不可穿透的约束然后通过求解一个线性互补问题LCP或直接使用拉格朗日乘子算出恰好能阻止穿透的接触力。这种方法稳定、不依赖调参Bullet、PhysX、Box2D这些引擎在高精度模式里用的都是它。代价是实现复杂要处理矩阵求解、迭代收敛、摩擦锥近似等等。实战中怎么选我个人建议如果你在写Demo、验证算法、做2D小游戏罚函数法绰绰有余如果你要做机器人仿真、高精度物理模拟直接上约束法省得后面反复改架构。2.3 摩擦模型从库仑到工程可用的摩擦圆锥摩擦力是接触力学里最容易被人忽视、又最容易出问题的一环。教科书上的库仑摩擦模型很简单摩擦力≤μ×法向力静摩擦和动摩擦分别用μ_s和μ_k描述。但这个模型在数值仿真里直接实现会翻车原因在于它突然在静止和滑动之间切换导致力不连续。我自己的实现里工程上常用的做法是把摩擦模型改造成带摩擦圆锥的形式。法向力确定后能施加的最大静摩擦力就是一个圆锥的底面半径。求出来的切向方向需要的力如果在这个圆锥内就认为物体保持相对静止施加一个与相对速度方向相反的粘滞力把物体拉住如果超过了圆锥边界就按滑动摩擦处理力的大小锁定在μ×F_n。关键细节是静摩擦阶段不能只靠速度来判定而要靠累积的切向位移来模拟否则物体会出现低频震荡——你从侧面推一个箱子箱子会像粘了果酱一样一抖一抖地动。用数学语言说就是需要一个切向弹簧来咬住接触点这个弹簧的刚度不能太高也不能太低太高会震荡太低箱子会漂移。3. 碰撞检测从哪个碰哪个到在哪碰3.1 Broad Phase先用粗筛子筛一遍碰撞检测如果一上来就两两精确求交N个物体的场景就是N²次检测1000个物体就是100万次直接爆炸。所以工业级实现都分两阶段Broad Phase粗检测和Narrow Phase精检测。Broad Phase的目标是快速排除绝对不可能相交的物体对。最常见的做法是给每个物体包一个AABB轴对齐包围盒或包围球然后通过空间数据结构批量筛选。我自己用的场景里选择标准大致是这样的物体数量不多、移动规律接近的场景用Sweep and Prune扫描排序法把所有AABB按一个轴向排序然后滑动扫描性能非常稳定。物体分布均匀、数量在几千量级的用空间哈希Spatial Hash每帧把AABB投影到均匀网格里同一个格子里的才做细节检测。物体分布极不均匀、场景纵深差异大的用BVH包围体层次或者四叉树/八叉树树的构建和更新逻辑稍复杂但查询效率最高。我开发机器人运动规划模块那阵子环境里有大量静态障碍物、少量动态障碍物最终选的方案是静态障碍物烘焙成BVH 动态物体用空间哈希两套结构叠着用。这个组合让每帧碰撞检测耗时降了一个数量级效果非常明显。3.2 Narrow Phase精确求交的三种主流算法Broad Phase筛完剩下的候选对要进入Narrow Phase做精确判定算出接触点、法线和穿透深度。凸体之间的求交主流算法是这三种分离轴定理SAT思路最直观两个凸体不相交当且仅当存在一个轴让它们在这个轴上的投影互不重叠。把两个凸体的所有面法线都作为候选轴依次测试找到最小重叠量的那个方向就是碰撞法线方向。SAT在2D场景里很好用实现简单、数值稳定3D里多边形面数一多就比较吃力。GJK算法Gilbert-Johnson-Keerthi走的是另一条路它利用闵可夫斯基差的概念两个凸体的闵可夫斯基差包含原点就说明它们相交。GJK迭代地在支撑点上构建单纯形逐步逼近原点。相比SATGJK不依赖面法线适合处理任意凸体包括凸包、球、胶囊体而且能直接给出两物体之间的最小距离。代价是理解门槛高算法细节多新手容易在退化情况上踩坑。MPRMinkowski Portal Refinement相对小众一些实现起来比GJK简单性能也接近但在某些凹形边界情况下会给出错误结果。我的建议是2D项目用SAT3D项目用封装好的GJK库不要自己从零写除非你想彻底吃透底层原理。至于凹体思路就一句话把凹体拆成多个凸体或者用凹体包围盒快速拒绝、再对每个凸子部分做精确检测。3.3 CCD快速物体穿透隧道的唯一解药有一个特别常见的bug子弹高速飞行上帧还在墙的左边下帧就到了墙的右边直接穿透过去。这就是著名的隧道效应Tunneling。离散碰撞检测DCD每个时间步只检测一次时间步内物体的运动路径被忽略了速度一快就出事。解决办法是连续碰撞检测CCD。它的核心思路是把物体在一个时间步内的扫掠体Swept Volume作为检测对象或者采用保守推进法Conservative Advancement先计算物体在当前时刻到碰撞的最短时间然后只推进这个时间保证不会错过碰撞。在子弹、机械臂快速运动这类场景里CCD是必须上的。代价是计算量通常是DCD的5到10倍所以工业引擎的默认策略是只对高速物体开启CCD静态场景和低速物体一律用DCD。4. 实操搭一个最小可跑的接触力加碰撞检测系统4.1 数据结构和主循环理论讲再多不如跑起来。我提供一个2D的最小实现思路用伪代码形式写你拿Python或者C都能快速还原。class RigidBody: def __init__(self, mass, moment_of_inertia, position, rotation): self.mass mass self.inv_mass 1.0 / mass self.position position # (x, y) self.velocity (0, 0) self.force (0, 0) self.rotation rotation self.angular_velocity 0.0 class Contact: def __init__(self, body_a, body_b, point, normal, penetration): self.body_a body_a self.body_b body_b self.point point # 接触点世界坐标 self.normal normal # 从A指向B的单位法线 self.penetration penetration # 穿透深度主循环就是典型的固定时间步结构输入处理、碰撞检测、接触力求解、积分、渲染。我强烈建议用固定时间步长比如1/60秒外加一个累加器来追赶真实时间不要直接用帧间隔做步长。原因后面讲帧间隔不稳定会导致物理表现时快时慢而且接触力的数值稳定性对时间步极其敏感。4.2 法向接触力的数值实现刚度、阻尼怎么定罚函数法的法向接触力实现核心就这几行# 法向相对速度接触点在法线方向上相互靠近的速度 v_rel body_b.velocity - body_a.velocity v_n dot(v_rel, contact.normal) # 罚函数力刚度项 阻尼项 # 注意只有当穿透深度0才施加力阻尼只在相互接近时起作用防止吸附 if contact.penetration 0.0: f_spring k_contact * contact.penetration f_damp c_contact * min(v_n, 0.0) # 只抵抗靠近不抵抗分离 f_normal max(0.0, f_spring f_damp) else: f_normal 0.0关键在于刚度k和阻尼c怎么定。这里有个从振动理论借来的经验公式接触力系统可以看成一个弹簧振子其固有圆频率 ω √(k/m)。要保证数值稳定每个时间步内能解析的振动周期必须大于若干倍步长一般要求 ω·Δt 0.2~0.5。换句话说k m × (0.2 / Δt)²举个例子质量1千克的物体Δt取1/60秒算出来k大约等于14400牛顿每米。这个值作为初值是合理的。阻尼c按临界阻尼来取c 2√(k×m)。这样设置之后接触过程的超调量小、反弹自然不会来回抖。我说一个容易踩的坑如果场景里物体质量差异很大比如一颗1克的沙粒压在一个100千克的钢板上用全局统一的k值轻物体会被弹飞重物体会陷进地面。这时候最好把k做成与等效质量相关接触点处的等效质量按 m_eff 1/(inv_mass_a inv_mass_b) 计算再去确定k和c。这样不管是轻碰重还是重碰轻行为都一致。4.3 摩擦力实现让箱子咬住不滑摩擦力的实现比法向力隐蔽得多。工程上我推荐的方式是先计算出理想情况下接触点应该锁死的切向速度然后算出让物体达到这个状态需要的切向冲量再把这个冲量限制在摩擦锥内。# 接触点的切向相对速度 v_t v_rel - v_n * contact.normal tangent normalize(v_t) # 理想目标是消除切向速度锁死接触点 # 计算切向冲量 j_t -mass_eff * v_t j_t -mass_eff * dot(v_t, tangent) # 库仑摩擦锥切向冲量不能超过 mu * 法向冲量 max_j_t mu * f_normal * dt if abs(j_t) max_j_t: j_t max_j_t * sign(j_t) else: # 静摩擦阶段用一个切向弹簧力来模拟咬住 j_t -k_tangent * tangent_displacement这个切向弹簧的概念我第一次看到时也不太理解直到调一个推箱子的场景发现箱子要么一直滑停不下来要么抖动得像筛糠才明白纯库仑模型算出来的静摩擦在数值上无法维持静止。加上切向弹簧记录接触点从建立到现在的累积切向位移乘以一个较小的刚度之后箱子被实实在在咬住了推一下动一下手感完全变了。摩擦还会引入一个能量问题法向力做功把物体弹回去摩擦力做功把能量耗散掉如果耗散不够物体反弹后会缓慢漂移尤其是堆叠场景一摞箱子会慢慢融化。这种问题在后期很难通过调单个参数解决通常要整体调整摩擦系数和恢复系数。4.4 时间步长、积分器与稳定性接触力求解不管多漂亮最后都要交给时间积分器去更新位置和速度。最基础的是显式欧拉速度快但接触这种硬约束会让显式欧拉疯狂注入能量表现就是物体越弹越高。我实际项目中至少会用半隐式欧拉# 半隐式欧拉先用力更新速度再用新速度更新位置 body.velocity body.inv_mass * body.force * dt body.position body.velocity * dt这比显式欧拉稳定得多因为它保证了速度场和位置场的一致性。如果再往上走就是Verlet积分或者RK4但绝大多数物理引擎在接触力场景下都用半隐式欧拉加多次迭代没必要上高阶积分器。时间步长的选择我建议遵循两条规则第一固定时间步长渲染帧率变化的波动丢给插值去处理第二每个时间步内所有物体移动距离不要超过其最小包围盒尺寸的一半一旦接近这个阈值就该考虑CCD或缩小时间步。半物理引擎还有一个标准技巧把一个大时间步拆成多个子步substep每子步内做一次接触力求解这样既保证了稳定性又不用全局缩小步长导致性能下降。5. 踩坑实录穿透、抖动、炸飞与调参经验5.1 穿透与隧道效应的排查先看问题类型。如果是慢速物体缓慢穿透地面多半是接触力刚度不够或者求解迭代次数不足。处理方法检查穿透深度是否超过你设定的最大阈值比如0.01米如果超过用位置修正position correction把物体沿着法线推回去一部分。位置修正的比例一般取0.2到0.8取太大会注入能量取太小等于没修正。如果是快速物体直接穿过薄墙那就是隧道效应与刚度无关。排查方式很简单打印物体前后两帧的位置如果前一帧在墙前、后一帧在墙后且中间没有任何接触记录那就实锤了。处理手段只有开CCD或拆小步长。我自己遇到过一个很诡异的穿透物体明明速度不快但每隔几十帧就穿透一次而且穿透点很随机。后来排查发现是Broad Phase里的AABB更新时机不对——物体位置已经更新了但AABB还停留在上一帧的位置导致精密检测那一环压根没跑。这个问题在很多自研引擎里都会出现建议每次物体位置更新后立刻刷新包围盒并调用Broad Phase不要等到渲染阶段再统一刷新。5.2 接触抖动与能量注入接触抖动是最让人头疼的现象一个箱子放在地面上明明静止了却每帧都在小幅振动声音大一点就是嗡嗡嗡。抖动的来源通常有两个一是罚函数刚度太大进入过线性区域接触力开始震荡二是接触点判定不稳定这一帧在离散点A下一帧在离散点B法线方向突变导致受力方向反复横跳。处理抖动的思路一是加接触缓存contact persistence把上一帧的接触点保留如果本帧检测到的新接触点距离很近就延续旧接触点的法线和位置避免频繁跳变。二是对接触力做低通滤波把当前帧接触力与上一帧做一个加权平均平滑掉高频毛刺。三是重新审视阻尼的设置确保阻尼项接近临界阻尼。还有一个被低估的细节恢复系数restitution也就是反弹系数如果设置成0.8甚至更高物体在碰撞中会不断反弹配合罚函数就极易震荡。做堆叠场景时我建议把恢复系数压到0.1以下。5.3 调试三板斧可视化、日志、单步接触力和碰撞检测的调试比拼写算法难得多。我总结了三个必熟的工具第一可视化接触点。在渲染层画一个红色小球标出每个接触点画一根蓝色线段表示法线方向画一根黄色线段表示摩擦力方向。只要一眼扫过去就能发现法线方向是否合理、接触点是否漂浮在物体表面之外、有没有出现不正常的接触对。第二日志记录关键数值。每个时间步记录最大穿透深度、接触力峰值、抖动频率。一旦场景炸飞回看日志对比炸飞前倒数几帧的数值变化通常都能定位到是哪个接触对出了问题。第三单步调试stepping。把仿真暂停每按一次键执行一个时间步逐步观察穿透深度和接触力的演化。这个方法在排查稳定性和抖动问题上几乎百试百灵。我在调一个机械臂抓取场景时就是用单步发现第37帧出现了一个错误的接触法线导致机械臂瞬间获得了一个不存在的力矩把整个场景搅了。另外补充一个经验每次只改一个参数改完跑一遍用曲线图对比结果。同时调刚度、阻尼、摩擦、恢复系数四个参数你会完全失去对系统的感觉最后所有参数面目全非也不知道到底是哪个改动起了作用。用参数控制台把每个参数暴露出来绑到键盘上实时调整调试效率会高得多。6. 一些个人体会接触力学和碰撞检测这摊事做久了你会发现真正的难点从来不是某个算法多高深而是整个系统的耦合稳定性。碰撞检测的微小误差会被接触力学放大接触力的微小抖动又会在积分器中积累成爆炸。所以我的习惯是先保证每个环节的输出都是合理且平滑的再追求效率和精度——因为平滑的近似解永远比精确的毛刺解更接近真实世界的表现。每次把一堆箱子稳稳堆起来、或者一个机械臂稳稳抓住零件的那一刻我都觉得前面调参的苦没白吃。这个领域没有银弹但方法论是通用的拆环节、控变量、可视化、勤记录。希望这篇从两件事拆到一个球落地的梳理能帮你少走一些弯路。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价