游戏开发 · 文章

+ 有更新

游戏中的数学,向量与矩阵,点积、叉积在游戏里的用途

有更新更新于 2026年11月6日

这篇文章在发布后更新过正文内容。

目标在前面还是后面?在左边还是右边?在不在视野里?这些问题都不需要三角函数,一次点积或叉积就能回答。

问题:守卫看见你了吗?

做一个潜行游戏,守卫站在走廊里,朝某个方向张望。每一帧都要回答:

  • 玩家在守卫前面还是后面?(背刺判定)

  • 在守卫的左边还是右边?(守卫该往哪边转头)

  • 在不在守卫 90° 的视野锥里?

新手的第一反应往往是 atan2 算出两个角度再相减,然后处理 359° 和 1° 之间的环绕问题。其实这些问题都有更简单、更快的答案:点积和叉积。


坐标系约定

本系列所有代码都使用屏幕坐标:x 向右,y 向下。这会影响叉积“左右”的结论,后面会专门说明。

一个二维向量只需要两个 float,配上常用运算符:

struct Vec2 {
    float x = 0.0f;
    float y = 0.0f;

    constexpr Vec2() = default;
    constexpr Vec2(float x_, float y_) : x(x_), y(y_) {}

    constexpr Vec2 operator+(Vec2 o) const { return {x + o.x, y + o.y}; }
    constexpr Vec2 operator-(Vec2 o) const { return {x - o.x, y - o.y}; }
    constexpr Vec2 operator*(float s) const { return {x * s, y * s}; }
    constexpr Vec2 operator/(float s) const { return {x / s, y / s}; }
    constexpr Vec2 operator-() const { return {-x, -y}; }
    constexpr Vec2& operator+=(Vec2 o) { x += o.x; y += o.y; return *this; }
    constexpr Vec2& operator-=(Vec2 o) { x -= o.x; y -= o.y; return *this; }
    constexpr Vec2& operator*=(float s) { x *= s; y *= s; return *this; }
    friend constexpr Vec2 operator*(float s, Vec2 v) { return v * s; }
    friend constexpr bool operator==(Vec2 a, Vec2 b) = default;
};

点积:前后与投影

// 点积:|a||b|cosθ。正 = 大致同向,0 = 垂直,负 = 大致反向
constexpr float dot(Vec2 a, Vec2 b) { return a.x * b.x + a.y * b.y; }

// 二维叉积(也叫 perp-dot):|a||b|sinθ。符号告诉你 b 在 a 的哪一侧
constexpr float cross(Vec2 a, Vec2 b) { return a.x * b.y - a.y * b.x; }

点积的几何意义是 dot(a, b) = |a|·|b|·cosθ,θ 是两个向量的夹角。我们不关心具体数值时,只看符号就够了:

DOT(A, B)

夹角 Θ

含义

大于 0

小于 90°

大致同向:目标在前方

等于 0

等于 90°

垂直:目标在正侧面

小于 0

大于 90°

大致反向:目标在后方

// 点积 > 0:目标与朝向的夹角小于 90°,在"前方"
inline bool isInFront(Vec2 facing, Vec2 toTarget) { return dot(facing, toTarget) > 0.0f; }

如果 a 是单位向量,dot(a, b) 还恰好等于 b 在 a 方向上的投影长度,也就是“沿着我面朝的方向,目标有多远”。演示里那条赭黄色的线段,长度就是这个点积。


叉积:左还是右

二维叉积 cross(a, b) = |a|·|b|·sinθ,它是一个有符号的数,符号告诉你 b 在 a 的哪一侧:

enum class Side { Left, Right, Straight };

// 叉积的符号判断左右。注意:屏幕坐标 y 朝下,结论与数学课本(y 朝上)相反
inline Side sideOf(Vec2 facing, Vec2 toTarget, float epsilon = 1e-4f) {
    float c = cross(facing, toTarget);
    if (c > epsilon) return Side::Right;
    if (c < -epsilon) return Side::Left;
    return Side::Straight;
}

Y 轴朝下时,左右是反的

数学课本里 y 轴朝上,cross > 0 表示 b 在 a 的左边(逆时针方向)。屏幕坐标 y 轴朝下,整个画面上下翻转,cross > 0 就变成了右边。换引擎、换坐标系时,这是最容易出错的地方。


亲手试一试

拖动红点(玩家)、黑点(守卫)或守卫前方的箭头。播放时守卫会左右扫视。左下角实时显示点积、叉积和局部坐标。

几个值得做的小实验:

  1. 把玩家拖到守卫正侧面,dot 接近 0;拖到身后,dot 变成负数,赭黄线段指向背后。

  2. 让玩家沿着守卫的朝向线来回穿过,观察 cross 的符号翻转,以及 left / right 的切换。

  3. 调小 fov,再把玩家放在视野边缘附近,看 can see 何时变化。

  4. 对比 local 的两个分量和 dot、cross,你会发现它们是同一组数字(下文解释)。


不用acos的方式,视野锥

判断“夹角是否小于半视角”,直觉写法是 acos(dot(f, normalize(to))) < halfAngle。但 acos 慢,还要先归一化。两边同时取余弦,再乘以 |to|,就能变成一次乘法比较:

struct VisionCone {
    Vec2 position;
    Vec2 facing;      // 单位向量
    float halfAngle;  // 视野半角(弧度)
    float range;
};

// 不需要 acos,也不需要先把 toTarget 归一化:
//   dot(f, to) >= |to| · cos(halfAngle)  ⇔  夹角 <= halfAngle
inline bool canSee(const VisionCone& cone, Vec2 target) {
    Vec2 to = target - cone.position;
    float d2 = lengthSq(to);
    if (d2 > cone.range * cone.range) return false;  // 先做便宜的距离判断
    if (d2 < 1e-8f) return true;
    return dot(cone.facing, to) >= std::cos(cone.halfAngle) * std::sqrt(d2);
}

实际项目中,cos(halfAngle) 可以在视野参数变化时预先算好;距离比较也用平方长度,省掉开方:

constexpr float lengthSq(Vec2 v) { return dot(v, v); }  // 比较距离时用它,省掉开方
inline float length(Vec2 v) { return std::sqrt(dot(v, v)); }
inline float distance(Vec2 a, Vec2 b) { return length(b - a); }

inline Vec2 normalized(Vec2 v) {
    float len = length(v);
    return len > 1e-6f ? v / len : Vec2{};  // 零向量没有方向,返回零向量而不是 NaN
}

矩阵:换一个视角看世界

旋转一个向量可以直接用三角函数:

// 垂直向量:在 y 朝下的屏幕坐标里,这是"顺时针转 90°",也就是朝向 v 时的右手方向
constexpr Vec2 perp(Vec2 v) { return {-v.y, v.x}; }

inline Vec2 fromAngle(float radians) { return {std::cos(radians), std::sin(radians)}; }
inline float angleOf(Vec2 v) { return std::atan2(v.y, v.x); }

inline Vec2 rotate(Vec2 v, float radians) {
    float c = std::cos(radians), s = std::sin(radians);
    return {v.x * c - v.y * s, v.x * s + v.y * c};
}

// 从 a 转到 b 的有符号角度,范围 (-π, π]
inline float signedAngle(Vec2 a, Vec2 b) { return std::atan2(cross(a, b), dot(a, b)); }

但当我们需要在“世界坐标”和“某个物体的局部坐标”之间来回转换时,矩阵是更好的工具。理解 2×2 旋转矩阵只需要一句话:矩阵的两列,就是旋转之后的 x 轴和 y 轴。

// 用两列表示:col0 是变换后的 x 轴,col1 是变换后的 y 轴
struct Mat2 {
    Vec2 col0{1, 0};
    Vec2 col1{0, 1};

    static Mat2 rotation(float radians) {
        float c = std::cos(radians), s = std::sin(radians);
        return {{c, s}, {-s, c}};
    }

    // 矩阵 × 向量 = 两列按分量加权求和
    Vec2 operator*(Vec2 v) const { return col0 * v.x + col1 * v.y; }

    Mat2 operator*(const Mat2& o) const { return {(*this) * o.col0, (*this) * o.col1}; }

    Mat2 transposed() const { return {{col0.x, col1.x}, {col0.y, col1.y}}; }
};

物体的变换 = 旋转 + 平移。把局部坐标转到世界坐标,就是先旋转、再平移;反过来则先平移回去、再反向旋转。旋转矩阵的各列互相垂直且长度为 1,所以它的逆矩阵就是转置:

// 位置 + 朝向:游戏物体最常见的变换(这里省略了缩放)
struct Transform2D {
    Vec2 position;
    float rotation = 0.0f;

    Vec2 forward() const { return fromAngle(rotation); }
    Vec2 right() const { return perp(forward()); }

    // 局部 -> 世界:先旋转,再平移
    Vec2 toWorld(Vec2 local) const { return position + Mat2::rotation(rotation) * local; }

    // 世界 -> 局部:先平移回去,再反向旋转。旋转矩阵的逆就是它的转置
    Vec2 toLocal(Vec2 world) const { return Mat2::rotation(rotation).transposed() * (world - position); }
};

把转置矩阵乘以向量展开来看,其实就是在“前方”和“右方”两个轴上各做一次点积。局部坐标的 x 分量就是 dot(to, forward),y 分量就是 dot(to, right)。点积和矩阵其实是同一件事:

// 等价写法:局部坐标就是在"前方"和"右方"两个轴上各做一次点积
inline Vec2 toLocalByDot(const Transform2D& t, Vec2 world) {
    Vec2 d = world - t.position;
    return {dot(d, t.forward()), dot(d, t.right())};
}

投影与反射

另外两个高频操作:把一个向量投影到某个方向上(例如把速度分解为沿墙面和垂直墙面的部分),以及沿法线反射(子弹弹墙、台球撞库):

// v 在 onto 方向上的投影(onto 不必是单位向量)
inline Vec2 project(Vec2 v, Vec2 onto) {
    float d = lengthSq(onto);
    return d > 1e-12f ? onto * (dot(v, onto) / d) : Vec2{};
}

// 以单位法线 n 反射 v:子弹弹墙、光线反射都是它
constexpr Vec2 reflect(Vec2 v, Vec2 n) { return v - n * (2.0f * dot(v, n)); }

常见陷阱

问题

原因

解决

结果出现 NaN,物体瞬移消失

归一化了零向量(除以 0)

normalized 对极短向量返回零向量,调用方再做判断

视野判断时灵时不灵

朝向向量没有归一化,点积被长度放大

存储朝向时就保证是单位向量

左右判断恰好相反

坐标系 y 轴方向和公式假设不一致

写单元测试,用一个确定的例子验证

角度插值、比较出错

混用了角度和弧度,或没处理 ±180° 环绕

内部一律用弧度;能用向量就别用角度

距离比较拖慢性能

大量调用 sqrt

比较距离时用 lengthSq


在游戏中的应用

  • 背刺判定:攻击者的朝向与受害者的朝向点积大于某个阈值(大致同向),说明是从背后出手。

  • 转向决策:AI 需要转向目标时,用叉积的符号决定往左转还是往右转,不需要算角度。

  • 光照:漫反射亮度正比于 dot(法线, 光线方向),这是几乎所有着色器的第一行代码。

  • 点在直线的哪一侧:cross(b - a, p - a) 的符号。三角形内部判定、多边形凸性检查、碰撞检测都会用到(见第二卷)。

  • 三维中:点积的含义完全一样;叉积则得到一个同时垂直于两个向量的新向量,是计算法线和构建坐标系的基础。


延伸阅读

  • 3Blue1Brown,线性代数的本质:用动画建立矩阵和点积的直觉

  • Fletcher Dunn、Ian Parberry,《3D 数学基础:图形和游戏开发》

版权声明

本文内容版权归作者或相关权利人所有。转载、引用或其他使用请遵循相应授权条款,并保留本文链接。

XuYi,《游戏中的数学,向量与矩阵,点积、叉积在游戏里的用途》,https://xuyi.dev/2026-11-06-bgcegv

Comments

评论

正在加载评论…