4、动态碰撞检测:连续碰撞检测(CCD)概念、扫掠体法、保守推进法
4.1 为什么需要连续碰撞检测?
先聊个实际问题。你玩过那些高速运动的游戏吧?子弹、赛车、或者角色快速穿过薄墙。有没有遇到过「穿模」?明明看着撞上了,结果物体直接穿过去了。
这就是离散碰撞检测的硬伤。传统做法是每帧采样一次位置,然后判断是否重叠。但物体速度太快,一帧之内可能从墙的左边飞到右边。采样点完美错过了碰撞。
我早年做一款赛车游戏时就栽过这个坑。轮胎高速旋转,每次检测时轮胎已经「瞬移」到了地面另一侧。物理表现就像在冰面上滑行,毫无抓地力。后来我才意识到——不是物理参数错了,是检测方式错了。
连续碰撞检测(CCD)就是来解决这个问题的。它的核心思想很简单:不只看「当前是否重叠」,而是看「从上一帧到这一帧之间,是否发生过碰撞」。
核心区别一句话总结:
- 离散检测:问「现在撞了吗?」
- 连续检测:问「刚才那段时间里撞过吗?」
4.2 CCD 的基本原理
说白了,CCD 就是把时间轴也纳入检测范围。我们不再把物体当成一个静态的快照,而是把它在一帧内的运动轨迹看作一条连续的路径。
数学上,我们引入一个时间参数 t,范围是 [0, 1]。t=0 代表上一帧的位置,t=1 代表当前帧的位置。然后我们求解:在 t 取什么值时,两个物体刚好接触?
这个「刚好接触」的时刻,我们称为 TOI(Time of Impact)。找到 TOI 之后,物理引擎就把物体回退到那个时刻,然后处理碰撞响应。
嗯,这里要注意:TOI 的精度直接影响物理表现。精度太高,计算量爆炸;精度太低,又会出现微小的穿透。我个人习惯是允许一个极小的容差(比如 1e-5),只要穿透量在这个范围内,就认为没有发生碰撞。
4.3 扫掠体法(Swept Volume)
扫掠体法是最直观的 CCD 实现方式。你想想看,一个物体从 A 点运动到 B 点,它扫过的空间是什么形状?
对于球体来说,扫过的体积是一个「胶囊体」——两个半球加一个圆柱。对于 AABB(轴对齐包围盒),扫过的是一个拉伸后的六面体。对于凸多面体,扫过的是一个更复杂的凸体。
然后我们检测这个扫掠体是否与其他物体相交。如果相交,就说明运动过程中发生了碰撞。
实战经验: 我曾经用扫掠体法做过一个弹幕游戏的碰撞系统。子弹速度极快,用离散检测时经常出现「子弹穿过敌人但没判定」的 bug。换成扫掠体后,问题立刻解决。但代价是——每颗子弹都要生成一个胶囊体,CPU 开销涨了 3 倍。
扫掠体法的核心步骤:
- 计算物体在 [0, 1] 时间内的运动轨迹
- 根据物体形状和轨迹生成扫掠体
- 用扫掠体与场景中的静态/动态物体做相交测试
- 如果相交,计算 TOI 并回退位置
代码实现上,最简单的球体扫掠体检测大概长这样:
// 球体扫掠体 vs 平面的碰撞检测
bool SweptSphereVsPlane(Vector3 start, Vector3 end, float radius,
Plane plane, float& outTOI) {
Vector3 velocity = end - start;
float distToPlane = Dot(plane.normal, start) - plane.distance;
// 球心到平面的有符号距离
float signedDist = distToPlane - radius;
// 如果已经穿透,直接返回
if (signedDist < 0.0f) {
outTOI = 0.0f;
return true;
}
// 计算速度在法线方向的分量
float vDotN = Dot(velocity, plane.normal);
// 如果远离平面,不会碰撞
if (vDotN >= 0.0f) return false;
// 计算 TOI
outTOI = signedDist / (-vDotN);
return (outTOI >= 0.0f && outTOI <= 1.0f);
}
这个代码我实际项目里改过很多版。最初版本没考虑「已经穿透」的情况,结果物体卡在墙里时,TOI 算出来是负数,物理直接崩了。后来加上了 signedDist < 0 的判断,才算稳定。
4.4 保守推进法(Conservative Advancement)
扫掠体法虽然直观,但有个大问题——生成扫掠体的计算量不小,尤其是对于复杂形状。而且如果物体旋转了,扫掠体就更难算了。
保守推进法(简称 CA)是另一种思路。它不生成扫掠体,而是用迭代的方式「一步步往前推」。
具体做法是这样的:
- 从 t=0 开始
- 计算当前时刻两个物体之间的最近距离
- 根据这个距离,计算一个「安全步长」——在这个步长内移动,保证不会发生碰撞
- 把时间推进这个步长
- 重复,直到 t 到达 1,或者距离小于阈值(发生碰撞)
为什么叫「保守」?因为每一步都走得非常谨慎,确保不会「跳过」碰撞。说白了,就是用多次迭代换取安全性。
注意: 保守推进法有可能陷入死循环!如果两个物体几乎平行地贴在一起滑动,每次推进的步长会越来越小,最终趋近于零。我遇到过这种情况,debug 了一整天才发现是浮点精度问题导致的步长退化。解决方案是设置一个最小步长阈值,如果步长小于这个值,就强制认为发生了碰撞。
保守推进法的伪代码:
float ConservativeAdvancement(Body a, Body b, float maxTime) {
float t = 0.0f;
float minStep = 1e-6f;
while (t < maxTime) {
// 计算当前时刻两个物体的最近距离
float dist = ComputeDistance(a, b, t);
// 如果距离小于阈值,认为发生碰撞
if (dist < COLLISION_EPSILON) {
return t; // 返回 TOI
}
// 计算安全步长
// 这里需要用到物体的速度、角速度等信息
float step = ComputeSafeStep(a, b, dist);
// 防止步长退化
if (step < minStep) {
return t; // 强制返回
}
// 限制步长不超过剩余时间
step = Min(step, maxTime - t);
t += step;
}
return maxTime; // 没有碰撞
}
我个人更偏爱保守推进法。虽然它比扫掠体法慢一些(迭代次数多),但胜在通用性强。不管物体是什么形状,只要你能算出两个形状之间的最近距离,就能用 CA。我在做 Ragdoll 物理时,骨骼之间的碰撞检测就是用 CA 实现的,效果很稳。
4.5 两种方法的对比
| 维度 | 扫掠体法 | 保守推进法 |
|---|---|---|
| 核心思路 | 生成运动轨迹的包络体 | 迭代推进,逐步逼近碰撞点 |
| 计算开销 | 一次扫掠体生成 + 一次相交测试 | 多次距离计算(迭代) |
| 适用形状 | 球体、AABB、凸多面体 | 任意凸形状(只要能算距离) |
| 旋转支持 | 困难(扫掠体形状复杂) | 容易(只需更新变换矩阵) |
| 精度 | 精确(数学上无误差) | 迭代精度可控 |
| 主要风险 | 扫掠体生成失败 | 步长退化、死循环 |
4.6 我的选择建议
如果你问我实际项目中怎么选,我的建议是:
- 子弹、小体积高速物体:用扫掠体法。这类物体形状简单(通常是球体或胶囊体),扫掠体生成快,一次检测搞定,性能好。
- 角色、复杂形状物体:用保守推进法。角色可能涉及多个碰撞体,而且有旋转,CA 更灵活。
- 静态场景 vs 动态物体:可以考虑混合方案。场景用 BVH 加速结构做粗检测,动态物体之间用 CA 做细检测。
我曾经在一个项目中两种方法都实现了,然后根据物体的「速度阈值」自动切换。速度低于某个值就用离散检测(省性能),高于阈值才启用 CCD。这样既保证了高速时的准确性,又不会让低速物体白白浪费计算资源。
一个小技巧: 不是所有物体都需要 CCD。静态物体、低速物体、或者体积很大的物体,用离散检测完全够用。我一般只对「速度 × 半径 > 某个阈值」的物体开启 CCD。这个阈值需要根据你的游戏世界尺度来调,没有通用值。
4.7 知识体系总览
下面这张图总结了本章的核心逻辑,我画成了流程图,方便你理解 CCD 的整体脉络:
这张图把 CCD 的两种主流方法放在一起对比。你可以看到,无论选哪条路,最终目标都是算出 TOI,然后回退位置、处理响应。区别只在于「怎么算」。
好了,关于 CCD 的概念和两种核心方法,我们就聊到这里。下一节我们会深入具体的碰撞响应机制,包括冲量法、位置修正和摩擦处理。到时候见。