7.1 AABB、圆形与 SAT 碰撞检测
前六章把 mini2d 的渲染、时间、输入、组件模型都搭起来了。但从第 1 章的方块跑起来到现在,玩家和世界之间其实还没有任何关系:他能穿过平台,也能穿过金币。《方块冲刺》要成立,就必须回答同一个问题——两个物体「碰到了吗」。这就是碰撞检测,也是本章三节的地基。
与站内
game/顶层概念综述、graphics/理论文的分工:那些文章讲「碰撞检测是什么、有哪些算法」;本节给的是能在 Node 与 vitest 里真跑的可执行代码,以及一份能直接搬进项目的mini2d/src/physics/模块。零cc依赖,零第三方库。
7.1.1 先定形状:AABB、Circle、OBB
检测的第一步不是写算法,而是选形状。形状决定了数据结构,数据结构决定了检测代价。mini2d 只提供三种,够《方块冲刺》用一辈子。
// mini2d/src/physics/shapes.ts
import type { Vec2 } from '../math/vec2';
/** 轴对齐包围盒:中心 + 半宽半高。MTV 推导时这个表示最省事。 */
export interface AABB {
readonly center: Vec2;
readonly half: Vec2;
}
/** 圆:2D 平台游戏里最便宜的碰撞体,判定只需要一次平方比较。 */
export interface Circle {
readonly center: Vec2;
readonly radius: number;
}
/** 有向包围盒:中心 + 半宽半高 + 旋转弧度(逆时针为正)。 */
export interface OBB {
readonly center: Vec2;
readonly half: Vec2;
readonly angle: number;
}
三个设计决策值得先讲清楚,它们会在后面每一节反复影响你。
第一,用中心 + 半宽半高,而不是 min/max。两种表示等价,但中心表示让「求最小穿透向量」(7.3 节)只需要两个减法;min/max 表示则要先还原中心再算。平台游戏里 90% 的检测都在求穿透,这个选择每天省下的加法以百万计。
第二,不缓存四个角点。角点是 center ± half 的派生量,缓存它意味着每次移动都要同步更新四个 Vec2,还要小心旋转。OBB 的角点更贵:要乘旋转矩阵。派生量一律现算,除非性能剖析(第 15 章)证明它是热点。
第三,readonly 只是给编译器的建议。AABB 描述的是某一帧的几何快照,不是「会自己动的对象」。位置属于刚体,形状只负责「在这个位置上长什么样」。
7.1.2 AABB vs AABB:区间重叠
两个轴对齐盒相交,当且仅当它们在 x 轴和 y 轴上的投影都重叠。写成代码就是两行:
// mini2d/src/physics/shapes.ts(续)
export function intersectAABB(a: AABB, b: AABB): boolean {
return Math.abs(a.center.x - b.center.x) <= a.half.x + b.half.x
&& Math.abs(a.center.y - b.center.y) <= a.half.y + b.half.y;
}
代价是 4 次 abs、2 次加法、2 次比较,没有除法也没有开方。这是所有检测里最快的一种,所以 mini2d 的默认碰撞体就是 AABB:平台、砖块、尖刺、移动的机关,全部先用 AABB 兜住。
注意这里用的是 <= 而不是 <:相切算相交。这个边界选择看起来无所谓,但它决定了「玩家脚尖刚好踩到平台顶面」是站上去还是掉下去。约定必须固定并写进测试,否则两个开发者会在两个地方写出不同的比较符,产生极难复现的「有时能站有时不能站」。
7.1.3 圆 vs 圆、圆 vs AABB
圆对圆的判定核心是「圆心距离 ≤ 半径之和」。但不要开方——比较平方值即可,因为两数非负时 a ≤ b 与 a² ≤ b² 等价:
export function intersectCircleCircle(a: Circle, b: Circle): boolean {
const dx = a.center.x - b.center.x;
const dy = a.center.y - b.center.y;
const r = a.radius + b.radius;
return dx * dx + dy * dy <= r * r;
}
Math.sqrt 在 V8 里大约是几次浮点乘的耗时,放在每帧上万次的检测里就是实打实的开销。金币、子弹、爆炸范围这类「圆形更自然」的物体用它。
圆与 AABB 的判定稍微绕一点,标准解法是最近点法:先把圆心夹取(clamp)到盒子的范围内,得到盒子上离圆心最近的点,再比较这个点到圆心的距离与半径:
function clamp(v: number, lo: number, hi: number): number {
return v < lo ? lo : v > hi ? hi : v;
}
export function intersectCircleAABB(c: Circle, b: AABB): boolean {
const nx = clamp(c.center.x, b.center.x - b.half.x, b.center.x + b.half.x);
const ny = clamp(c.center.y, b.center.y - b.half.y, b.center.y + b.half.y);
const dx = c.center.x - nx;
const dy = c.center.y - ny;
return dx * dx + dy * dy <= c.radius * c.radius;
}
这个实现有个漂亮的边界:当圆心落在盒内时,clamp 的结果就是圆心自己,dx = dy = 0,平方和为 0 必然小于半径平方,返回 true——完全正确,不需要额外分支。很多手写版本在这里加一句「若圆心在盒内直接返回 true」,纯属多余。
7.1.4 SAT:分离轴定理
AABB 与圆都是「无旋转」或「各向同性」的形状。一旦物体旋转(比如《方块冲刺》里被撞歪的旋转平台),就需要 OBB。判断两个 OBB 相交的标准武器是分离轴定理(Separating Axis Theorem,SAT):
两个凸多边形不相交,当且仅当存在一条轴,使它们在轴上的投影不重叠。
对两个 OBB,候选轴只有 4 条:每个盒子的两条局部轴。因为盒子是矩形,边的法线只有两个方向,其余方向上的投影一定不会先分离。
/** 把 OBB 投影到单位轴 ax 上,返回投影区间的中心与半长。 */
function projectOBB(o: OBB, ax: Vec2): { center: number; radius: number } {
const cos = Math.cos(o.angle);
const sin = Math.sin(o.angle);
// 局部轴 u = (cos, sin),v = (-sin, cos)
const center = o.center.x * ax.x + o.center.y * ax.y;
const radius =
Math.abs(o.half.x * (cos * ax.x + sin * ax.y)) +
Math.abs(o.half.y * (-sin * ax.x + cos * ax.y));
return { center, radius };
}
export function intersectOBB(a: OBB, b: OBB): boolean {
const axes: Vec2[] = [
{ x: Math.cos(a.angle), y: Math.sin(a.angle) },
{ x: -Math.sin(a.angle), y: Math.cos(a.angle) },
{ x: Math.cos(b.angle), y: Math.sin(b.angle) },
{ x: -Math.sin(b.angle), y: Math.cos(b.angle) },
];
for (const ax of axes) {
const pa = projectOBB(a, ax);
const pb = projectOBB(b, ax);
if (Math.abs(pa.center - pb.center) > pa.radius + pb.radius) {
return false; // 找到分离轴,立刻退出
}
}
return true; // 四条轴都重叠,判定相交
}
radius 的公式来自「把半宽投影到轴上的绝对值之和」:|half.x·(u·ax)| + |half.y·(v·ax)|。这是 OBB 投影半径的解析解,不必真的构造四个角点再求 min/max,省掉一半的乘法。
关于圆:SAT 对圆同样成立,但圆的「轴」是无穷多条(每条边的法线方向都不同),实践里要额外加入「两圆心连线」这条轴。既然圆有自己的解析式(7.1.3),就没必要为了统一而把圆塞进 SAT。
7.1.5 该用哪个:代价与场景对照
| 形状对 | 主要运算 | 相对代价 | 《方块冲刺》里的典型用途 |
|---|---|---|---|
| AABB–AABB | 4 abs + 2 加法 + 2 比较 | 1× | 平台、砖块、地形格、尖刺 |
| 圆–圆 | 2 减法 + 2 乘法 + 1 比较 | ~1× | 金币、子弹、爆炸范围 |
| 圆–AABB | 4 次 clamp + 2 乘法 | ~2× | 玩家胶囊近似 vs 平台 |
| OBB–OBB | 4 条轴 × 2 次投影 | ~15× | 旋转平台、被撞歪的箱子 |
结论很直接:默认 AABB,圆形更自然的物体用圆,只有真正旋转的物体才上 OBB。OBB 的代价是 AABB 的十几倍,如果用它去检测一堆本来就轴对齐的砖块,等于白白扔掉一个数量级的性能。
还要记住一句工程原则:形状的选择优先于算法的优化。把 200 个砖块从 AABB 换成 OBB,再快的 SAT 也救不回来;反过来,保持 AABB 再用 7.2 节的空间划分做粗筛,才是正路。
7.1.6 四个反复踩到的陷阱
碰撞代码短,但坑密度极高。下面四个是手写检测时最常出错的,值得在落笔前先记住。
第一,浮点相等几乎永远为假。 不要写 if (dist === radius),因为 0.1 + 0.2 !== 0.3 在几何计算里是常态。相切这种边界要用 <= 把「相等」纳入相交一侧,让浮点误差落在安全的那边;需要判「是否精确相等」时,用一个容差 Math.abs(x - y) < 1e-6。
第二,半宽与全宽混用。 7.1.1 选了「中心 + 半宽」,但很多人写 half 字段时手滑填了整宽,结果碰撞体凭空大了一倍。防御办法是给构造函数而不是裸对象,让单位在类型层面被约束:
export function aabbFromRect(cx: number, cy: number, w: number, h: number): AABB {
// 入参是「全宽全高」,返回的 half 是它们的一半,避免调用方自己除
return { center: { x: cx, y: cy }, half: { x: w / 2, y: h / 2 } };
}
第三,坐标系不统一。 检测必须在同一个坐标系里做。如果 AABB 用的是世界坐标、而某个物体的 center 还是父节点下的局部坐标,结果会随关卡层级不同而随机出错。mini2d 的约定是:physics 模块只认世界坐标,局部转世界由场景图(第 3 章)在传进来之前完成。
第四,忘了相切是刻意的。 有人为了「避免误判」把 <= 改成 <,结果玩家站在平台边缘时不断在两个状态间抖动。相切算相交是为了稳定,不是将就;把这条约定写进测试,别让后来者「顺手修正」。
7.1.7 用 vitest 把约定固定下来
碰撞检测是最适合单测的代码:纯函数、无副作用、边界明确。把「相切算相交」这类约定写成断言,比写在注释里可靠得多。
// mini2d/test/physics/shapes.test.ts
import { describe, it, expect } from 'vitest';
import {
intersectAABB,
intersectCircleCircle,
intersectCircleAABB,
intersectOBB,
} from '../../src/physics/shapes';
describe('shapes', () => {
it('AABB 相切也算相交', () => {
const a = { center: { x: 0, y: 0 }, half: { x: 1, y: 1 } };
const b = { center: { x: 2, y: 0 }, half: { x: 1, y: 1 } };
expect(intersectAABB(a, b)).toBe(true);
});
it('圆与圆相切算相交,分开一点就 false', () => {
const a = { center: { x: 0, y: 0 }, radius: 1 };
expect(intersectCircleCircle(a, { center: { x: 2, y: 0 }, radius: 1 })).toBe(true);
expect(intersectCircleCircle(a, { center: { x: 2.001, y: 0 }, radius: 1 })).toBe(false);
});
it('圆心在盒内时圆与 AABB 必然相交', () => {
const box = { center: { x: 0, y: 0 }, half: { x: 5, y: 5 } };
expect(intersectCircleAABB({ center: { x: 1, y: 1 }, radius: 0.1 }, box)).toBe(true);
});
it('旋转 45 度的 OBB 与原 OBB 仍相交', () => {
const a = { center: { x: 0, y: 0 }, half: { x: 1, y: 1 }, angle: 0 };
const b = { center: { x: 0, y: 0 }, half: { x: 1, y: 1 }, angle: Math.PI / 4 };
expect(intersectOBB(a, b)).toBe(true);
});
});
跑 npx vitest run 时,这四个用例把三条关键约定钉死了:相切算相交、圆用平方比较、SAT 在四轴全重叠时才返回 true。第 16 章还会把这套测试接进确定性回放。
7.1.8 与 Cocos Creator 3.8 对照
Cocos 3.8 的 2D 碰撞体是一个 Collider2D 家族:BoxCollider2D(盒)、CircleCollider2D(圆)、PolygonCollider2D(多边形)。没有独立的 OBB 类型——盒碰撞体挂在节点上,会跟随节点的旋转,所以在行为上等价于我们的 OBB,只是旋转由 transform 承担而非形状自带。Collider2D.worldAABB 暴露世界坐标下的包围盒,可以拿来做自己写的调试绘制。
import { _decorator, Component, Collider2D, BoxCollider2D, CircleCollider2D, Size } from 'cc';
const { ccclass, property } = _decorator;
@ccclass('HitboxSetup')
export class HitboxSetup extends Component {
@property(BoxCollider2D)
body: BoxCollider2D | null = null;
@property(CircleCollider2D)
pickup: CircleCollider2D | null = null;
onLoad(): void {
if (this.body) {
this.body.size = new Size(32, 48); // 对应 mini2d 的 half = (16, 24)
}
if (this.pickup) {
this.pickup.radius = 12;
}
const any = this.getComponent(Collider2D);
if (any) {
// worldAABB 是只读的 math.Rect,宽度是完整宽而非半宽
console.log(any.worldAABB.width, any.worldAABB.height);
}
}
}
两个口径差异要留意:mini2d 用半宽半高(half),Cocos 的 size 与 radius 都是全量(Size.width 是整宽,radius 是半径本身);mini2d 的检测是你能调用的纯函数,Cocos 的检测跑在引擎物理步里,你拿到的是回调而非返回值——这正是下一节与 7.3 要展开的内容。
下一节先把「两两检测」从 O(n²) 里救出来:当场景里有一千个砖块时,逐对比较会拖垮帧率,我们需要空间划分来做粗筛。
阅读导航:上一节:目录 · 下一节:7.2 空间划分与宽相 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。