☰
碰撞检测与接触力学贯通:刚体仿真稳定性实践
2026/10/29 15:32:10 网站建设 项目流程

接触力学基础和碰撞检测,这两件事在很多项目里是被拆给两个人做的:做几何的只管求交,做动力学的只管往方程里塞力。平时各写各的没事,一到联调就全露馅了——球明明躺在地上却在微微发抖,物体速度一快就直接穿墙,箱子码到第五层开始自己散架,角色贴着墙走会被莫名其妙弹出去。这些问题看着像三个独立的 bug,根子上往往是同一条链路上某个环节没对齐。第014篇想干的事,就是把“几何求交”和“力学生成”这两段重新接起来讲,说清楚中间到底传了什么数据、哪些量必须由上游算准、哪些坑是下游无论怎么补都补不回来的。

不管你是刚接触刚体仿真的同学,还是已经能把一个物理解算器跑通、但总觉得手感不对的工程同学,这篇应该都能拿到东西。我会从包围体、SAT 和 GJK 这些几何判定讲起,一路走到赫兹接触、罚函数、库仑摩擦锥、顺序冲量和接触流形维护,中间穿插参数的计算过程和实测区间。不会只堆公式,重点是讲清楚每个参数为什么是这个量级、调大调小分别会发生什么。

1. 两条链路为什么要合在一起看

1.1 碰撞检测只管“有没有碰”,接触力学只管“碰了之后怎么办”

这两句话看着像废话,但实际工程里最常见的架构错误就是把职责搞混。碰撞检测的正常输出只应该有三样东西:是否相交、相交的几何特征(点、边、面)、以及穿透深度和法线。它不该关心质量、速度、恢复系数,更不该直接去改位置。接触力学那一层拿到的输入,就是这个“接触流形”,然后结合刚体的质量、惯量、速度,算出该施加多大的冲量。

分开写的好处是可测试性。几何层的单元测试可以完全脱离物理世界:给两个 OBB,断言法线方向和穿透深度;给一个三角形和一个球,断言接触点坐标落在面上。力学层的测试则可以在给定流形的前提下,断言冲量的符号和量级。混在一起写,你根本没法判断到底是求交算错了还是力算错了,只能盯着屏幕猜。

但分开不等于解耦。有一个量是两边共享的,就是穿透深度。几何层算出来的深度符号约定如果和力学层相反,症状会非常诡异:物体不是被推开,而是被一点一点吸进去,越陷越深。我踩过最典型的一次是法线方向搞反,物体在接触瞬间获得一个指向内部的冲量,然后以极快的速度飞出去——日志里看到的是速度瞬间到了 1e6 量级,那其实是数值发散之前的最后一次爆发,真正的原因只是那一行normal = -normal。

1.2 联调时最容易断掉的三个接口

第一条接口是穿透深度的符号约定。业界比较常见的约定是:法线从 A 指向 B,深度为正表示已经重叠。但也有引擎用“距离”表示,负值才是重叠。这两套混用,就是前面说的吸入现象。

第二条接口是接触点的数量与分布。窄相算法给出一个特征对(顶点—面、边—边)之后,直接拿这一个点去做求解,物体就会像顶在一根针上一样摇晃。所以中间必须有一个流形生成步骤,把单个特征扩展成最多四个接触点,形成一个稳定的支撑多边形。这一步做不做,手感的差距比换求解器还大。

第三条接口是坐标系的时效性。碰撞检测通常在世界空间做,但求解器迭代时物体位置每轮都在变。如果你拿第 n 帧的接触点去解第 n+1 帧的约束,物体走得快时接触点会“漂”到几何体外面,表现为摩擦方向莫名其妙地偏转。稳妥的做法是接触点用局部坐标存储,求解时再变换到世界空间。

1.3 精度、稳定、实时:方案选型的三角约束

任何一个刚体系统,你只能在这三个角里挑两个,剩下那个必须让步。想要纳秒级的接触精度又要实时跑,就得上约束求解加子步,CPU 预算立刻见底;想要绝对稳定又想要低开销,那就得接受位置修正带来的软绵绵手感。

实际项目里我的取舍顺序是:先保稳定,再保实时,最后才是精度。原因是前两个出问题,用户立刻能看见;精度不足,在 60 帧、物体尺寸 0.1 到 10 米这个尺度下,肉眼基本分辨不出来。所以你会看到大量工程实现里,接触刚度被人为压到真实值的百分之一甚至千分之一,这在物理上完全不合理,但在交互上是对的。

2. 碰撞检测的宽相与窄相怎么拆

2.1 宽相:包围体层级、扫描剪枝与空间网格的取舍

宽相的唯一任务,是把 O(n²) 的候选对削减到接近 O(n)。它允许误报,绝不允许漏报,这条线一旦破了,后面再精密的窄相也救不回来。

常见方案就三种。第一种是动态 AABB 树,也就是 BVH,把每个物体的包围盒插进一棵可自平衡的树里,查询时做剪枝。它的优势是适应任意分布,稀疏场景和密集场景表现都不差,物体移动时只需要更新对应叶子节点的路径,摊销成本很低。缺点是插入删除有开销,物体尺寸差异极大时树会退化。

第二种是扫描剪枝,所有物体在某一根轴上排序,用一个“活动列表”沿轴扫过去,重叠区间才可能是候选。它对沿某个方向分布的物体极其高效,实现也就几十行,缺点是轴的选择需要动态调整,物体均匀散布时收益不明显。

第三种是空间网格或空间哈希,把世界切成格子,物体按覆盖的格子登记。它天然适合大量尺寸相近、分布均匀的物体,比如粒子、碎片、人群。缺点是尺寸差异大时一个巨型物体要占几百个格子,内存和遍历成本都会爆炸。

实测下来,混合方案最省心:静态几何(地形、建筑)走 BVH,动态小物体走网格,两者在查询时合并结果。不要追求一套方案打天下。

2.2 窄相:SAT 的轴向清单与 GJK 的单纯形演进

窄相要做两件事:判定是否相交,以及给出穿透深度和法线。这两个输出是绑定的,很多人只做了前者,后面用“随便找个方向”凑合,手感必然崩。

分离轴定理的思路很朴素:两个凸体如果不相交,一定存在一根轴,让它们在轴上的投影区间不重叠。对两个 OBB 来说,候选轴一共 15 根:A 的三个面法线、B 的三个面法线,以及两边棱方向的所有叉积组合(3×3=9,去掉退化后通常还有 9 根)。逐一测试,只要有任意一根轴上区间分离,立刻返回不相交;如果全部重叠,取重叠量最小的那根轴作为最小穿透轴。

def sat_obb(a, b): axes = list(a.axes) + list(b.axes) for u in a.axes: for v in b.axes: c = u.cross(v) if c.length() > 1e-6: axes.append(c.normalized()) best_depth, best_axis = float('inf'), None for ax in axes: ra = a.project_radius(ax) rb = b.project_radius(ax) dist = abs((b.pos - a.pos).dot(ax)) overlap = ra + rb - dist if overlap <= 0.0: return None # 找到分离轴,直接退出 if overlap < best_depth: best_depth = overlap best_axis = ax if (b.pos - a.pos).dot(ax) > 0 else -ax return best_axis, best_depth

GJK 走的是另一条路,用支撑函数和 Minkowski 差。核心思想是:两个凸体相交,当且仅当它们的 Minkowski 差包含原点。算法维护一个最多四点的单纯形,每轮朝原点方向取一个新的支撑点,如果新点的投影还不如当前单纯形上的点靠近原点,说明原点在外面,不相交。GJK 的优点是通用性强,凸包、胶囊、球都一样处理,而且只依赖支撑函数,不需要预先枚举面法线;缺点是它只回答“是/否”,穿透深度还得交给 EPA 去补。

def support(shape, d): local_d = shape.rot.transpose() @ d p = shape.farthest_point(local_d) return shape.rot @ p + shape.pos def gjk(a, b, max_iter=32): d = b.pos - a.pos simplex = [] for _ in range(max_iter): w = support(a, -d) - support(b, d) if w.dot(d) < 0.0: return False, None # 原点在差集之外 simplex.append(w) contains, d, simplex = do_simplex(simplex) if contains: return True, simplex return False, None

选型上我的经验是:形状种类固定、以盒体为主,用 SAT 更直接,代码短、调试方便;形状五花八门、需要大量凸包,用 GJK/EPA 更划算,因为增加一种新形状只需要实现一个支撑函数。

2.3 连续碰撞检测:扫掠体、TOI 与保守推进

离散检测的根本问题是它只在一个时间点上采样。物体从 A 点跳到 B 点,中间穿过了一堵墙,但两个采样位置都在墙的同一侧,检测结果就是“没碰”。这在高帧率、慢速物体上无所谓,在子弹、投掷物、快速挥动的武器上就是灾难。

连续碰撞检测的核心是求“首次接触时间”,也就是 TOI。做法有几档。最直接的是扫掠体:把物体在时间步内的运动扫成一个凸体(球体扫掠就是胶囊,盒子扫掠就是凸包),用这个扩展体去做一次静态检测。好处是实现简单、能复用现有窄相;坏处是法线不准,因为扫掠体的表面并不对应真实接触面。

进阶一点的是保守推进:把时间区间 [0,1] 从起点开始,先用当前位姿做一次距离查询,得到最近距离 d,再根据相对速度算出“最多还能走多久才会靠到 d 以内”,得到 t1,把时间推进到 t1,再重新查询。反复迭代直到距离小于阈值,就得到了一个保守的 TOI 下界。它不会漏报,最多多迭代几次,工程上很好用。

def conservative_advance(a, b, v_rel, max_iter=8): t = 0.0 for _ in range(max_iter): dist, n = distance_query(a, b) # 最近距离与方向 closing = -v_rel.dot(n) if closing <= 1e-9: return None # 正在远离,永远碰不上 dt = (dist - margin) / closing if dt <= 1e-6: return t # 收敛到接触时刻 t += dt a.pos += v_rel * dt # 推进到新位置 if t >= 1.0: return None return t

第三档是推测接触(speculative contact),思路反过来:不去求 TOI,而是把“这一步内会接触到的物体”也塞进约束求解器,用一个额外的距离约束把穿透提前拦住。它的好处是全程走同一套求解器,不需要单独的运动逻辑,实现最统一;代价是求解器要多处理一批“还差一点才碰上”的约束,迭代次数会上去。

2.4 几何精度上的几个硬性约束

世界尺寸别乱定。很多新手把单位设成像素,一个角色半径 500,坐标系跨度几万,然后单精度浮点只有 7 位有效数字,精度直接掉到 0.01 以下,物体在静止时都会因为位置误差乱跳。稳妥的做法是把主要物体尺寸控制在 0.1 到 10 这个区间,整体世界跨度不超过一万个单位。

包围盒要留出微小外扩。因为浮点误差,理论上刚好贴着的两个物体,AABB 可能判定为不重叠,导致接触对漏生成,物体就沉下去了。常见做法是在 AABB 上外扩 1e-3 到 1e-2 的相对尺寸,宁可多生成几对候选,也不要漏。

对最小特征尺寸要有下限。三角形边长小于 1e-4,法线计算就会因为叉积太小而失去方向精度,表现为物体在薄壁上随机弹开。遇到这种输入,直接在导入阶段做一次网格清理,把过小的三角形合并或剔除,比在运行时打补丁靠谱得多。

3. 接触力学的模型层:从赫兹理论到非穿透约束

3.1 赫兹接触:接触半径、压入深度与刚度的推导

真实的两个弹性体压在一起,接触面不是点,而是一个小圆面,接触面积随载荷增大。赫兹在十九世纪给出了这个问题的解析解,对两个球体:

接触半径 $a = \left(\dfrac{3FR}{4E^*}\right)^{1/3}$,压入深度 $\delta = \dfrac{a^2}{R}$,最大压应力 $p_0 = \dfrac{3F}{2\pi a^2}$。

其中等效曲率和等效模量为 $\dfrac{1}{R} = \dfrac{1}{R_1} + \dfrac{1}{R_2}$,$\dfrac{1}{E^*} = \dfrac{1-\nu_1^2}{E_1} + \dfrac{1-\nu_2^2}{E_2}$。

把它写成力与位移的关系:$F = \dfrac{4}{3}E^\sqrt{R},\delta^{3/2}$。对 δ 求导得到切线刚度 $k = 2E^a$。注意这个 k 不是常数,它随压入深度增长,所以赫兹接触本质上是硬弹簧,越压越硬。

拿一个具体数字感受一下。半径 0.1 米的钢球(E=200 GPa,ν=0.3)静止在地面上,重力 9.81 N。算等效模量:$E^* = 200\times10^9/(1-0.09) \approx 2.2\times10^{11}$ Pa。接触半径 $a = (3 \times 9.81 \times 0.1 / (4\times2.2\times10^{11}))^{1/3} \approx 1.5\times10^{-4}$ 米,也就是 0.15 毫米。压入深度 $\delta = a^2/R \approx 2.2\times10^{-7}$ 米,0.22 微米。切线刚度 $k = 2 \times 2.2\times10^{11} \times 1.5\times10^{-4} \approx 6.6\times10^{7}$ N/m。

这个数字非常关键。真实的钢对钢接触刚度在 1e7 到 1e8 N/m 量级,而绝大多数交互项目里用的接触刚度是 1e4 到 1e5。差了三个数量级,这就是“物理正确”和“工程可用”之间的鸿沟,下一节解释为什么必须妥协。

3.2 三种接触处理范式的对比:罚函数、约束求解、LCP

罚函数最简单:允许穿透,穿透多少就产生多大的反向力,$F_n = k\delta - c\dot{\delta}$,$\delta$ 是穿透深度,$c$ 是阻尼。实现就几行,接在力的累加器上即可。它的致命弱点是刚度和步长强耦合:显式积分下,稳定条件大致要求 $k < 4m/\Delta t^2$。1 kg 的物体、1/60 秒的步长,$k$ 的上限是 14400 N/m。这就是为什么工程里只用 1e4 量级——不是不想用真的,是步长不允许。

约束求解把接触当成一个不等式约束:非穿透条件 $C = \delta \ge 0$,速度层面要求沿法线的相对速度在接触后不小于零,力层面要求法向冲量非负。整个问题就变成了一个带互补条件的优化,求解器通过迭代(比如投影高斯—赛德尔、顺序冲量)逼近解。它的优势是稳定,穿透只作为误差存在,并且可以通过位置修正(Baumgarte 稳定化)慢慢抹掉,不依赖弹簧刚度。

LCP(线性互补问题)是数学上最严谨的写法,把接触和摩擦统一成 $w = Ax + b$,$w \ge 0$,$x \ge 0$,$w^Tx = 0$。$x$ 是冲量,$w$ 是速度残差。它的好处是有明确的存在性和唯一性讨论,坏处是求解成本高、大规模场景下很难实时,而且摩擦锥线性化之后本身就不唯一。

方案实现复杂度稳定性穿透控制典型适用
罚函数低差,受刚度步长约束依赖刚度,必有穿透粒子、软体、快速原型
顺序冲量中好靠偏置项修正刚体、角色、道具
LCP高好精确小规模高精度场景
推测接触中高好提前拦截,几乎无穿透高速物体混杂场景

我自己的选择基本是:刚体主链路走顺序冲量,软体、布料、绳索走罚函数或基于位置的动力学,只有在对接触精度有明确数值要求的离线场景下才考虑 LCP。

3.3 摩擦锥与切向约束:库仑模型的工程化裁剪

库仑摩擦的完整形式是一个圆锥:切向力的模不能超过法向力乘以摩擦系数,$|f_t| \le \mu f_n$。这个约束在三维里是非线性的,直接塞进求解器会让迭代失去收敛性保证。

工程上的标准做法是把它线性化成金字塔,用两个正交切向方向分别约束:$|f_{t1}| \le \mu f_n$,$|f_{t2}| \le \mu f_n$。这在 45 度方向上会略微高估可用的摩擦力,但带来的好处是两个方向可以独立求解,迭代收敛得干净利落。四个方向的金字塔能把这个误差压得更小,代价是计算量翻倍。

顺序冲量里的摩擦求解要注意一个细节:切向冲量的上下界必须用“当前累积的法向冲量”来算,而不是本轮新算出来的法向增量。否则摩擦会在法向还没解稳的时候就被砍到零,表现为物体在斜面上缓慢下滑,看起来像阻尼不足,实际是摩擦钳位用错了变量。

# 每个接触点维护累积冲量 accum_n 与 accum_t for it in range(iterations): for c in contacts: # 法向 vn = c.rel_velocity().dot(c.n) d_l = -(vn + c.bias) * c.eff_mass_n old = c.accum_n c.accum_n = max(0.0, old + d_l) c.apply_normal(c.accum_n - old) # 切向(两个正交方向) limit = c.friction * c.accum_n for axis in (c.t1, c.t2): vt = c.rel_velocity().dot(axis) d_l = -vt * c.eff_mass_t old = c.accum_t[axis] new = clamp(old + d_l, -limit, limit) c.accum_t[axis] = new c.apply_tangent(axis * (new - old))

另外,静摩擦和动摩擦如果不分开,物体会在斜面上“蠕行”。原因是静摩擦需要一个速度阈值来判断,速度低于阈值时用较大的系数,高于阈值时降到动摩擦系数。这个切换必须是连续的,否则会在临界速度附近产生抖动。Stribeck 曲线就是干这个的:摩擦系数从静摩擦值随速度平滑下降到动摩擦值,中间有个负斜率段,这一段正是自激振动(比如刹车尖叫)的来源,交互应用里通常直接跳过负斜率,用单调下降就够了。

3.4 时间积分与接触稳定性

步长是接触稳定性的第一决定因素,比任何参数都重要。固定步长几乎是必须的,变步长会让同一套参数在不同帧之间表现不一致,物体会在掉帧时突然穿模。常见做法是逻辑层跑固定 1/60 秒,物理内部再拆 2 到 8 个子步,子步只做积分和约束求解,不触发逻辑回调。

积分器的选择上,半隐式欧拉(先更新速度,再用新速度更新位置)是刚体仿真的默认选项,它的能量行为比显式欧拉好得多,实现也简单。隐式积分更稳,但需要解一个线性系统,对接触这种强非线性约束,收益不一定抵得上复杂度。

恢复系数的处理有个容易忽略的阈值。如果不管速度大小一律乘上恢复系数,那些以极小速度接触的物体会一直轻微弹跳,永远静不下来,因为每一次接触都被注入了能量。工程上的通用做法是设一个速度阈值,比如 1 m/s,低于这个速度时恢复系数按比例衰减到零。

def restitution_scale(vn, threshold=1.0): if vn > -threshold: return 0.0 # 低速接触完全非弹性 return min(1.0, -vn / threshold - 1.0)

两个物体的恢复系数怎么合并,各引擎的做法不一样,常见的有取最大值、取最小值、取几何平均、按质量加权。物理上都不严格,取最大值会让“弹球撞泥巴”变成弹球,取最小值会让弹球撞木地板变得不弹。我自己习惯用几何平均,实测手感最符合直觉。

4. 从几何对到力的完整实现链路

4.1 接触流形的生成与增量维护

窄相给出一个特征对之后,第一步是把它扩展成流形。以盒体落在地面为例,最深的那个角点只是单点接触,如果只解这一个点,盒子会绕着它转,下一帧又换成另一个角,永远在晃。正确做法是用裁剪:把盒子的底面多边形按地面的平面做一次多边形裁剪(Sutherland–Hodgman 就是干这个的),保留所有低于平面的顶点,得到最多四个接触点,组成一个支撑面。

第二步是增量维护。每一帧重新生成流形,接触点的索引会跳变,上一帧累积的冲量就没法复用了,相当于每次都从零开始迭代,收敛慢、抖动大。解决办法是给每个接触点分配一个基于特征 ID 的稳定键(比如“顶点 3 对 面 1”),新一帧的流形生成后,用这个键去匹配旧的接触点,把累积冲量继承过来,这就是所谓的 warm starting。加上这一步,同样的迭代次数下,堆叠的稳定性能提升一个档次。

第三步是流形裁剪。流形里的点不能无限多,一般限制在 4 个。超过之后按面积贡献排序,保留能撑起最大支撑多边形的四个点。原因很实在:每个接触点都是一组约束,约束越多迭代越慢,而且冗余约束会让求解器在多个等价解之间来回跳。

4.2 一个球撞地面的完整参数计算实例

假设一个半径 0.1 米、质量 1 kg 的球,从 1 米高处自由下落,撞向静止地面,恢复系数 0.5,摩擦系数 0.6,步长 1/60 秒,子步 4 个(有效步长 1/240 秒)。

先算落地速度。$v = \sqrt{2gh} = \sqrt{2 \times 9.81 \times 1} \approx 4.43$ m/s。这个速度高于 1 m/s 的恢复阈值,所以恢复系数完整生效,反弹速度约 2.21 m/s,反弹高度约 0.25 米。

再看单个子步内球走过的距离:$4.43 / 240 \approx 0.018$ 米。球的半径是 0.1 米,所以穿透深度最多也就是 0.018 米,相对半径 18%。这个量级离散检测还扛得住,但如果球半径缩小到 0.01 米,单步位移就是半径的 1.8 倍,隧穿风险陡增,必须开连续检测或把子步加到的 20 以上。

然后是接触刚度。走罚函数的话,1 kg 质量、1/240 步长的稳定上限是 $k < 4 \times 1 / (1/240)^2 \approx 230400$ N/m。为了留余量,取 5e4 N/m。求解器的迭代次数取 10,位置修正系数(Baumgarte)取 0.2,允许穿透容差取 0.005 米。

把这些带进求解流程:球在某个子步结束时穿透了 0.012 米,超过容差 0.005,那么偏置速度就是 $0.2 \times (0.012 - 0.005) / (1/240) \approx 0.34$ m/s,这个速度会被加到法向约束上,推动球向上修正。压制速度的冲量由有效质量算得,球对静态地面时有效质量就是球的质量 1 kg,法向冲量约 $1 \times (4.43 + 0.34) \approx 4.77$ N·s。

摩擦部分,切向可用冲量上限是 $\mu \times$ 法向累积冲量 $= 0.6 \times 4.77 \approx 2.86$ N·s。如果球带有水平速度 1 m/s,需要的切向冲量是 1 N·s,小于上限,说明是静摩擦情形,球会在地面滚动而不是滑动。

这套数字不是精确物理,但它自洽、可复现,参数之间的关系也都说得清楚。你换一个质量或者换一个步长,按同样的逻辑重新推一遍就行。

4.3 顺序冲量求解的实现骨架

整个求解器的结构很清晰,外部看就是三层循环:子步、迭代、接触点。

def step(world, dt, substeps=4, iterations=10): h = dt / substeps for _ in range(substeps): # 1. 施加外力,更新速度 for body in world.dynamic_bodies: body.velocity += (body.force * body.inv_mass + world.gravity) * h body.angular_velocity += body.inv_inertia @ body.torque * h # 2. 碰撞检测,生成流形(warm start 继承冲量) manifolds = world.detect_contacts() # 3. 预计算有效质量与偏置 for m in manifolds: for c in m.points: c.eff_mass_n = 1.0 / (m.a.inv_mass + m.b.inv_mass + m.a.rotated_inv_inertia_term(c.ra, c.n) + m.b.rotated_inv_inertia_term(c.rb, c.n)) c.bias = -(0.2 / h) * max(0.0, c.depth - 0.005) # 4. 顺序冲量迭代 for _ in range(iterations): for m in manifolds: for c in m.points: solve_normal(c) solve_friction(c) # 5. 位置积分 for body in world.dynamic_bodies: body.position += body.velocity * h body.rotation = integrate(body.rotation, body.angular_velocity, h)

几个实现细节值得单独说。第一,有效质量里的惯量项必须用世界空间的惯量张量,写成本地空间再变换,否则斜着撞的时候法向冲量会算偏。第二,接触点的相对位置向量要在求解开始时算一次并缓存,迭代中不能重算,因为物体位置在迭代期间并不更新(求解器只更新速度)。第三,摩擦的两个切向方向要正交且都在切平面上,构造方法是用法线和任意一个辅助向量叉乘,辅助向量选世界坐标轴里和法线夹角最大的那个,避免退化。

4.4 手感调参的经验区间

调参这件事,我先给区间,再说怎么在这个区间里找。

参数建议区间调大的后果调小的后果
固定步长1/120 ~ 1/30 s抖动、穿透性能吃紧
子步数2 ~ 8性能线性下降高速物体隧穿
求解迭代8 ~ 20收敛好但发热堆叠软塌
穿透容差0.002 ~ 0.02 m可见嵌入接触抖动
位置修正系数0.1 ~ 0.3强力弹开、能量增加修正太慢、缓慢下沉
恢复速度阈值0.5 ~ 2 m/s小物体永不静止球落地即停,失去弹性
摩擦系数0.4 ~ 0.9斜坡上卡住不动物体滑动不止

真正难的不是选参数,是判断问题出在哪。有个很实用的技巧:把所有接触点的穿透深度按帧打出来,看它的时间序列。如果深度是锯齿状的(大—小—大—小),说明位置修正力度过头,把 β 降下来;如果深度缓慢单调增长然后突然跳变,说明有接触对被漏掉了,去查宽相和包围盒外扩。

还有一个反直觉的经验:先调阻尼,再调刚度。很多“软绵绵”的手感其实不是刚度不够,而是阻尼给多了,物体在接触瞬间被吸住了动量,恢复的时候没力气。把接触阻尼降到 0 附近试一次,往往立刻就能判断出问题在哪一层。

5. 常见问题与排查实录

5.1 抖动、穿透与弹飞的三类症状对照

这三种症状看着完全不同,成因却经常交叉。我把实际遇到过的整理成了一张对照表,遇到现象先按这张表缩小范围。

现象高概率成因验证方式处理
静止物体高频抖动恢复系数在微小速度下仍生效打速度时间序列,看是否在 ±0.1 之间震荡加恢复速度阈值
静止物体缓慢下沉位置修正系数太小或容差太大打穿透深度,看是否单调增长提高 β 或减小容差
接触瞬间被弹飞法线方向反了,或有效质量算成了负打法线向量与两点连线夹角检查法线朝向约定
堆叠整体缓慢滑移摩擦钳位用了本轮增量而非累积值打累积切向冲量与上限之比改用累积法向冲量作上限
高速物体穿墙单步位移超过最小特征尺寸算 速度 × 步长 与物体尺寸之比开连续检测或加子步
物体在斜面上蠕行静摩擦与动摩擦未分离观察速度是否收敛到很小的常值加静摩擦阈值

5.2 高速物体隧穿的定位流程

隧穿的判断很简单:单步位移大于自身最小特征尺寸的一半,就有风险。比如半径 0.02 米的球、速度 30 m/s、步长 1/60,单步位移 0.5 米,是半径的 25 倍,必穿。

定位的时候先别急着上连续检测,按成本从低到高试。第一档是减步长,把 1/60 改成 1/240,位移变成 0.125 米,还是 6 倍半径,不够,说明光靠子步解决不了,速度太高了。第二档是加粗检测,除了静态位置,再用一条从上一帧位置到当前帧位置的线段去做扫掠查询,这个改动很小,能拦住大部分中速情况。第三档才是真正的连续检测。

还有一个隐蔽的坑:即使开了连续检测,如果两个物体都在高速运动,TOI 是在“另一个物体的位置冻结”的假设下算的,仍然会漏。这种情况需要做相对运动的扫掠,或者干脆在检测前对两个物体的相对速度做一次收敛判断。我见过一个投掷物打飞行靶的场景,两边都是 20 m/s 以上,纯粹靠 TOI 会有 5% 左右的命中丢失,最后是靠把靶子的碰撞体加厚 3 倍解决的——工程上比起改算法,加厚碰撞体便宜太多了。

5.3 堆叠失稳的成因拆解

一堆箱子码起来然后自己散架,是接触链路里最综合的问题,几乎每一层出问题都会导致这个现象。

第一层是流形。如果每个箱子之间只有一个接触点,那它就是一个球而不是一个面,稍微有一点扰动就会转。检查方法很简单:把接触点数量打出来,如果是 1,问题就找到了。

第二层是 warm starting。没有冲量继承,每次迭代都从零开始,8 次迭代根本不够收敛,箱子之间会周期性地下沉一点、被顶回来一点。开启 warm starting 之后,稳定所需的迭代次数通常能减半。

第三层是迭代顺序。顺序冲量对接触的遍历顺序敏感,同样的场景,按物体索引顺序遍历和按空间顺序遍历,收敛速度能差一倍。一个常用技巧是每帧随机打乱接触的遍历顺序,或者在多线程里让不同线程处理不同的接触块,避免系统性的偏置。

第四层是位置修正的能量注入。Baumgarte 修正本质上是往系统里塞速度,箱子越多、堆得越高,注入的能量越大,最后会表现为整摞箱子轻微上浮然后下沉的呼吸感。缓解办法是用分离式的位置修正(只在位置层面做投射,不改速度),或者把 β 降到 0.1 以下并配合更多迭代。

实测下来,一个 10 层、每层 5 个箱子的塔,8 次迭代加 warm starting 能稳住大约 30 秒,之后开始缓慢漂移;20 次迭代能稳到一分钟以上。再往上加迭代收益递减,这时候该考虑的是减小子步步长,让每次求解面对的位移更小。

5.4 联调清单与几个不写在文档里的经验

上线前我会过一遍这份清单,基本上能拦掉八成回归问题。

  • 法线方向约定,从 A 指向 B,深度为正,全链路统一,写进接口注释。
  • 接触点用局部坐标存,求解时变换,跨帧复用前先校验物体是否还在同一个接触对。
  • 所有涉及除法的量(有效质量、曲率、距离)都要判零,浮点下“几乎为零”比“完全为零”更常见。
  • 恢复系数、摩擦系数、刚度这些参数,全部走同一套合并规则,不要这里取最大那里取平均。
  • 每个接触点的累积冲量要有上限,防止数值爆炸;同时要有下限保护,防止出现负的法向冲量。
  • 加一个调试渲染开关,能把接触点、法线、穿透深度画出来,这个功能的投入产出比高得离谱。

注意:调试渲染里的法线长度按穿透深度缩放会形成误导,深穿透的物体法线看起来特别长,容易让人以为是法线计算错了。法线固定画 0.1 米长,穿透深度用单独的数字或颜色标注。

最后分享一个我个人用得多的小技巧。当怀疑是接触环节的问题但又定位不到时,把所有物体的恢复系数临时设为 0、摩擦系数设为 0,让系统变成纯非弹性无摩擦。如果问题消失了,那八成在恢复或摩擦的参数处理上;如果问题还在,那就是流形生成、法线方向或者求解器迭代的问题。这个二分法比逐个参数试要快得多,尤其是面对一个别人写的、参数有几十个的工程时,能省掉大半天。

接触力学和碰撞检测这两块,真正难的部分从来不是公式推导,而是那条从几何特征到冲量、再到位置的完整数据链路上,每一个环节的约定是否一致。我在实际项目里的体会是,把接口约定写清楚、把调试可视化做出来,比多读十篇论文管用。几何层多算一个点、力学层少改一次位置,这些看似微小的取舍,最后都会以“手感”这种说不清道不明的方式,直接反馈到用户手上。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询