2、基础数学知识(上):向量基础与坐标系变换

说实话,很多刚入行做碰撞检测的朋友,上来就怼代码、调参数,结果遇到穿透、抖动、漏检,完全摸不着头脑。我当年也一样,踩了不少坑才明白——碰撞检测的根基,其实是数学。尤其是向量和坐标系变换,你理解得越深,写出来的碰撞逻辑就越稳。

这一章,咱们就把这些基础啃透。别怕,我会用最直白的方式讲,配合代码和图示,保证你学完就能用。

向量基础与坐标系变换知识体系 向量基础与坐标系变换知识体系 向量运算 坐标系与变换 碰撞检测应用 加法 / 减法 点积(内积) 叉积(外积) 世界坐标系 局部坐标系 平移 / 旋转 / 缩放 分离轴定理(SAT) 最近点计算 穿透深度 / 法线 💡 核心思路:向量是碰撞检测的“语言”,坐标系变换是“翻译器” 掌握这两样,你就能读懂任何碰撞形状之间的“对话”

2.1 向量:碰撞检测的“通用语言”

向量是什么?说白了,就是一个有方向、有大小的箭头。在游戏里,物体的位置、速度、力、法线……几乎一切都能用向量表示。

我个人习惯把向量看作“位移指令”:从点 A 到点 B,怎么走、走多远。碰撞检测里,我们经常用向量来描述两个物体之间的相对位置和运动趋势。

2.1.1 向量加法与减法

加法和减法是最直观的。你想想看:

  • 加法:把两个向量首尾相接,结果就是从起点到终点的向量。比如,物体当前位置 + 速度向量 = 下一帧位置。
  • 减法:从 B 减 A,得到从 A 指向 B 的向量。这在碰撞检测里太常用了——计算两个物体之间的相对位置。

实战经验:我在做格斗游戏时,判断“拳头是否打中对方”,其实就是用减法算出拳套到对手身体的距离向量,再跟拳套的半径做比较。减法用得好,碰撞检测就成功了一半。

// 向量加法与减法示例(伪代码)
struct Vector2 {
    float x, y;
};

Vector2 add(Vector2 a, Vector2 b) {
    return {a.x + b.x, a.y + b.y};
}

Vector2 subtract(Vector2 a, Vector2 b) {
    return {a.x - b.x, a.y - b.y};
}

// 使用场景:计算从物体A到物体B的方向
Vector2 direction = subtract(positionB, positionA);

2.1.2 点积(内积)

点积是个好东西。它的公式很简单:a · b = |a| * |b| * cos(θ)。但更重要的是它的几何意义——衡量两个向量的“对齐程度”。

  • 点积 > 0:两个向量方向大致相同(夹角小于 90°)
  • 点积 = 0:两个向量垂直
  • 点积 < 0:两个向量方向相反(夹角大于 90°)

碰撞检测里,点积最常见的用途是判断物体在某个方向上的投影。比如,分离轴定理(SAT)就是靠点积把多边形投影到各个轴上,再检查投影是否重叠。

避坑指南:我曾经在实现 SAT 时,忘记对点积结果取值,导致投影重叠判断出错。记住:投影长度是标量,要用值。

// 点积实现
float dot(Vector2 a, Vector2 b) {
    return a.x * b.x + a.y * b.y;
}

// 判断两个单位向量是否同向
bool isSameDirection(Vector2 a, Vector2 b) {
    return dot(a, b) > 0.0f;
}

2.1.3 叉积(外积)

叉积在 2D 里返回一个标量(实际上是有方向的“面积”),在 3D 里返回一个垂直于两个输入向量的新向量。它的核心用途:判断方向、计算法线、求面积。

2D 叉积公式:a × b = a.x * b.y - a.y * b.x

  • 结果 > 0:b 在 a 的左侧(逆时针方向)
  • 结果 < 0:b 在 a 的右侧(顺时针方向)
  • 结果 = 0:a 和 b 平行

嗯,这里要注意:叉积在碰撞检测里常用于计算多边形法线和判断点是否在多边形内部。我做过一个赛车游戏,用叉积判断车辆是否越过了赛道边界——每次计算轮胎位置与赛道边缘向量的叉积,符号一变就说明出界了。

// 2D 叉积
float cross2D(Vector2 a, Vector2 b) {
    return a.x * b.y - a.y * b.x;
}

// 判断点P是否在三角形ABC内部(顺时针/逆时针方向一致)
bool isPointInTriangle(Vector2 p, Vector2 a, Vector2 b, Vector2 c) {
    Vector2 ab = subtract(b, a);
    Vector2 ap = subtract(p, a);
    Vector2 bc = subtract(c, b);
    Vector2 bp = subtract(p, b);
    Vector2 ca = subtract(a, c);
    Vector2 cp = subtract(p, c);
    
    float c1 = cross2D(ab, ap);
    float c2 = cross2D(bc, bp);
    float c3 = cross2D(ca, cp);
    
    return (c1 >= 0 && c2 >= 0 && c3 >= 0) || 
           (c1 <= 0 && c2 <= 0 && c3 <= 0);
}

2.2 坐标系与变换:让物体“各就各位”

有了向量,我们还得知道这些向量是在哪个“参考系”里说的。这就是坐标系的作用。

2.2.1 世界坐标系 vs 局部坐标系

坐标系 描述 碰撞检测中的角色
世界坐标系 全局固定的坐标系,所有物体共享 用于最终碰撞判断、空间划分(如四叉树)
局部坐标系 每个物体自己的坐标系,原点在物体中心 用于定义物体形状(顶点、半径等),方便计算

举个例子:一个旋转的矩形,它的四个顶点在局部坐标系里是固定的。但到了世界坐标系里,这些顶点会随着矩形的旋转和平移而变化。碰撞检测时,我们通常先把所有顶点变换到世界坐标系,再做比较。

我曾经踩过的坑:在实现碰撞响应时,我直接在局部坐标系里计算了穿透深度,然后应用到世界坐标的物体上。结果物体飞到了莫名其妙的位置。记住:计算要在同一坐标系下进行,要么全转世界,要么全转局部。

2.2.2 平移、旋转与缩放

坐标系变换说白了就是三个操作:

  1. 平移:把物体从一个位置挪到另一个位置。用向量加法搞定。
  2. 旋转:让物体绕某个轴转起来。2D 里用旋转矩阵,3D 里用四元数或欧拉角。
  3. 缩放:把物体变大或变小。用标量乘法。

在碰撞检测里,最常用的是平移和旋转的组合。比如,一个移动的圆形,它的位置每帧都在变,但形状不变。我们只需要更新它的圆心坐标(平移),半径保持不变。

// 2D 旋转矩阵应用
Vector2 rotate(Vector2 v, float angle) {
    float cosA = cos(angle);
    float sinA = sin(angle);
    return {
        v.x * cosA - v.y * sinA,
        v.x * sinA + v.y * cosA
    };
}

// 将局部坐标变换到世界坐标
Vector2 localToWorld(Vector2 localPos, Vector2 worldPos, float worldAngle) {
    Vector2 rotated = rotate(localPos, worldAngle);
    return add(rotated, worldPos);
}

2.3 本章小结:向量与坐标系是碰撞检测的“左膀右臂”

这一章我们聊了:

  • 向量加法/减法:描述位移和相对位置
  • 点积:衡量方向对齐,用于投影和角度判断
  • 叉积:判断左右、计算法线和面积
  • 坐标系与变换:让物体在统一参考系下“对话”

这些概念看起来简单,但它们是所有碰撞检测算法的基石。我做了这么多年物理引擎,每次遇到奇怪的碰撞 bug,追根溯源,十有八九是向量算错了或者坐标系搞混了。

给你的建议:别急着写复杂的碰撞检测代码。先花一周时间,把向量运算练熟,把坐标系变换写对。你会发现,后面的 SAT、GJK、EPA 学起来轻松一大截。