在2D游戏和可视化交互中,轴对齐矩形通常用左上角坐标和宽高来表示。两个矩形是否重叠,实质上是在X轴和Y轴两个方向上的投影是否同时发生重叠。只要有一个轴向上不存在重叠,矩形就不会碰撞。这个判定逻辑本身很简单,但为什么实际运行时经常出现穿透?原因在于大多数项目采用离散检测:每一帧根据当前位置计算一次重叠,如果物体在两帧之间移动的距离过大,它可能直接越过对方,导致两帧都没有检测到重叠。

除了漏检,已经检测到重叠后的处理也容易引入穿透。有些实现会在检测到碰撞后直接把物体位置恢复到上一帧坐标,或者简单地把坐标反向移动一个固定值。这种做法没有考虑本帧实际重叠深度和速度方向,轻则让物体出现明显抖动,重则把物体推到另一侧,下一帧再次穿透。要彻底解决这个问题,需要把碰撞检测与碰撞响应拆开,分别计算最小穿透向量和合理的速度分离量。
一、从重叠判定到穿透成因
轴对齐矩形的碰撞检测其实是在做两次一维区间重叠判断。设矩形A的横向范围是 [a.x, a.x + a.w],矩形B的横向范围是 [b.x, b.x + b.w]。如果两个区间存在交集,说明它们在X轴方向上重叠;Y轴同理。只有两个轴同时重叠,矩形才发生碰撞。这个条件的代码实现非常直接:
function isColliding(a, b) {
return a.x < b.x + b.w &&
a.x + a.w > b.x &&
a.y < b.y + b.h &&
a.y + a.h > b.y;
}
上面的判断只回答“是否碰撞”,不提供任何关于穿透深度或推出方向的信息。离散检测带来的核心问题是,物体的位置更新是按帧进行的。假设一个宽度为20像素的矩形以每帧30像素的速度向右移动,另一侧是一个宽度为10像素的静态矩形。在某一帧中,移动矩形可能还完全在左侧,下一帧已经跳到右侧,两个矩形从未在某个离散时刻上同时满足重叠条件。检测函数返回false,碰撞被漏掉,于是出现高速穿透。
即便检测到重叠,如果把响应逻辑写成“直接回退到上一帧位置”,也可能产生新的穿透。比如物体斜向撞向墙壁,回退会让它同时失去X轴和Y轴上的合法位移,显得像被吸住;如果回退距离不足,物体仍然嵌在墙里,下一帧继续触发检测并重复修正,表现为抖动甚至穿透。可见,只依赖isColliding的布尔结果不足以解决穿透,需要获得穿透深度和方向。
二、使用最小穿透向量计算重叠深度
对于轴对齐矩形,分离轴定理可以简化为计算两个轴上的重叠深度。先求X轴重叠量:用两个矩形右边界的最小值减去两个矩形左边界的最大值。Y轴同理。如果任一重叠量小于等于0,说明没有碰撞;如果两个重叠量都大于0,取较小的那个作为最小穿透向量MTV。该向量表示将其中一个矩形推出重叠所需的最短距离,可以避免修正后物体出现在错误的一侧。
function getMTV(a, b) {
const overlapX = Math.min(a.x + a.w, b.x + b.w) - Math.max(a.x, b.x);
const overlapY = Math.min(a.y + a.h, b.y + b.h) - Math.max(a.y, b.y);
if (overlapX <= 0 || overlapY <= 0) {
return null;
}
if (overlapX < overlapY) {
return { x: overlapX, y: 0, axis: 'x' };
}
return { x: 0, y: overlapY, axis: 'y' };
}
这段代码与isColliding不同,它直接给出重叠深度。比如移动矩形右侧越过静态矩形左侧5像素,同时Y轴重叠了30像素,那么MTV会选择X轴,因为5像素的推出距离更短。沿X轴把移动矩形向左推5像素,就能以最小代价解除重叠。使用MTV还能避免简单回退带来的副作用,因为修正方向是由穿透深度决定的,而不是由上一帧位置猜测的。
不过需要注意浮点数计算带来的误差。当两个矩形刚好贴边时,overlapX或overlapY可能得到一个极小的正数或负数。如果项目里频繁出现贴边抖动,可以引入一个极小阈值,例如小于0.001的重叠量直接视为无碰撞,或者将位置保留到固定小数位,减少误差累积。
三、位置修正与速度分离的完整响应
拿到MTV后,第一件事是修正位置。如果碰撞双方都是可移动物体,常见的做法是按质量比例分配MTV。质量越大,承担的位移修正越小;静态物体质量设为无穷大,可以将修正全部施加给动态物体。具体实施时,为每个矩形维护mass、vx和vy属性,位置修正按照对方质量占总质量的比例进行。这样两个物体都能被推开,且运动看起来更自然。
仅仅修正位置还不够。如果物体仍然具有指向碰撞面的速度,下一帧它会再次嵌入。速度分离就是在碰撞轴上消除相对速度的法向分量。以X轴碰撞为例,判断两个物体在X轴上的相对速度方向是否与MTV方向一致,如果一致说明它们正在靠近,需要施加一个反向冲量。可以根据需求设置恢复系数,0表示完全非弹性碰撞,1表示完全弹性碰撞。
function resolveCollision(a, b, mtv) {
const totalMass = a.mass + b.mass;
const ratioA = b.mass / totalMass;
const ratioB = a.mass / totalMass;
a.x -= mtv.x * ratioA;
a.y -= mtv.y * ratioA;
b.x += mtv.x * ratioB;
b.y += mtv.y * ratioB;
const restitution = 0.8;
if (mtv.axis === 'x') {
const relativeVX = a.vx - b.vx;
if (relativeVX * mtv.x > 0) {
const impulse = (1 + restitution) * relativeVX / (1 / a.mass + 1 / b.mass);
a.vx -= impulse / a.mass;
b.vx += impulse / b.mass;
}
} else {
const relativeVY = a.vy - b.vy;
if (relativeVY * mtv.y > 0) {
const impulse = (1 + restitution) * relativeVY / (1 / a.mass + 1 / b.mass);
a.vy -= impulse / a.mass;
b.vy += impulse / b.mass;
}
}
}
上面的冲量计算使用了经典碰撞公式的简化形式。对于平台游戏中的地面和墙壁,静态物体的质量可以设为一个非常大的数,而不是真正的无穷大,这样比例计算不会出现除零。实际项目里还可以进一步拆分轴向:地面碰撞只修正Y轴并清除Y轴速度,而保留X轴移动;侧墙碰撞只处理X轴,让角色可以贴墙滑动。这种按轴独立的处理方式能显著减少斜向碰撞时的阻滞感。
四、高速运动下的穿透抑制策略
即使有了MTV修正和速度分离,如果单帧位移过大,物体仍可能在检测前就越过对方。解决这个问题通常有两种思路:连续碰撞检测和子步进。连续碰撞检测需要从上一帧位置向当前位置做扫掠,计算碰撞时间点,实现复杂度较高。子步进则更简单:把一帧的位移拆分成多个小步,每一步的移动距离不超过矩形最小边长的二分之一,再逐步执行检测和响应。这样能将高速穿透概率降到很低。
function moveWithSubsteps(entity, dt) {
const dx = entity.vx * dt;
const dy = entity.vy * dt;
const minSize = Math.min(entity.w, entity.h);
const maxStep = minSize / 2;
const steps = Math.max(1, Math.ceil(Math.max(Math.abs(dx), Math.abs(dy)) / maxStep));
for (let i = 0; i < steps; i++) {
entity.x += dx / steps;
entity.y += dy / steps;
// 在这里对该实体执行碰撞检测与响应
}
}
子步进的优点是实现简单、与现有碰撞逻辑兼容,缺点是性能开销随步数增加。通常每帧步数控制在8到16次以内,对于大多数2D游戏已经足够。另一种优化方式是膨胀碰撞体:根据速度方向把矩形在当前帧的运动范围内扩展为一个更大的扫掠矩形,先做一次扫掠检测,再决定是否启用子步进,这样可以在物体远离其他物体时避免额外计算。
如果项目使用固定时间步长,穿透问题的调试会容易很多。固定步长下每次更新距离基本一致,可以针对最大速度设置合理的子步进数量。而如果使用可变时间步长,需要先限制单帧最大时间,再根据实际经过时间计算子步数,避免卡顿后物体直接穿过整个场景。最终方案通常是:最小穿透向量负责精准推出,速度分离负责消除持续嵌入,子步进或扫掠负责抑制高速漏检,三者结合才能稳定解决矩形穿透。
JavaScript碰撞检测矩形穿透碰撞响应修改时间:2026-09-18 08:32:37