跳转到内容

序 · 基础工具§ 0.2

向量与矩阵:点积、叉积在游戏里的用途

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

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

  • 玩家在守卫前面还是后面?(背刺判定)
  • 在守卫的左边还是右边?(守卫该往哪边转头)
  • 在不在守卫 90° 的视野锥里?

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

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

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

cpp/common/vec2.h
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;
};
cpp/common/vec2.h
// 点积:|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° 大致反向:目标在后方
cpp/foundations/vectors/vision.h
// 点积 > 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 的哪一侧:

cpp/foundations/vectors/vision.h
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;
}
图 0.2拖动红点(玩家)、黑点(守卫)或守卫前方的箭头。播放时守卫会左右扫视。左下角实时显示点积、叉积和局部坐标。单独打开 ↗

几个值得做的小实验:

  1. 把玩家拖到守卫正侧面,dot 接近 0;拖到身后,dot 变成负数,赭黄线段指向背后。
  2. 让玩家沿着守卫的朝向线来回穿过,观察 cross 的符号翻转,以及 left / right 的切换。
  3. 调小 fov,再把玩家放在视野边缘附近,看 can see 何时变化。
  4. 对比 local 的两个分量和 dot、cross,你会发现它们是同一组数字(下文解释)。

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

cpp/foundations/vectors/vision.h
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) 可以在视野参数变化时预先算好;距离比较也用平方长度,省掉开方:

cpp/common/vec2.h
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
}

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

cpp/common/vec2.h
// 垂直向量:在 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 轴。

cpp/foundations/vectors/transform.h
// 用两列表示: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,所以它的逆矩阵就是转置:

cpp/foundations/vectors/transform.h
// 位置 + 朝向:游戏物体最常见的变换(这里省略了缩放)
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)。点积和矩阵其实是同一件事:

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

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

cpp/common/vec2.h
// 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) 的符号。三角形内部判定、多边形凸性检查、碰撞检测都会用到(见第二卷)。
  • 三维中:点积的含义完全一样;叉积则得到一个同时垂直于两个向量的新向量,是计算法线和构建坐标系的基础。
  1. 实现 bool isBehind(Vec2 victimFacing, Vec2 attackerFacing, float toleranceDeg),用于背刺判定。
  2. 给视野锥加上“近处 360° 感知”:玩家贴得足够近时,即使在背后也会被发现。
  3. 用叉积判断三个点 a、b、c 是顺时针还是逆时针排列。在 y 朝下的屏幕坐标里,结论又是什么?
  • 3Blue1Brown,线性代数的本质:用动画建立矩阵和点积的直觉
  • Fletcher Dunn、Ian Parberry,《3D 数学基础:图形和游戏开发》

© 2026 XuYi · 原文 https://algo.xuyi.dev/foundations/vectors/
文章采用 CC BY-NC-SA 4.0 许可,转载请署名并附原文链接;代码以 MIT 协议开源。