序 · 基础工具§ 0.2
向量与矩阵:点积、叉积在游戏里的用途
目标在前面还是后面?在左边还是右边?在不在视野里?这些问题都不需要三角函数,一次点积或叉积就能回答。
问题:守卫看见你了吗?
Section titled “问题:守卫看见你了吗?”做一个潜行游戏,守卫站在走廊里,朝某个方向张望。每一帧都要回答:
- 玩家在守卫前面还是后面?(背刺判定)
- 在守卫的左边还是右边?(守卫该往哪边转头)
- 在不在守卫 90° 的视野锥里?
新手的第一反应往往是 atan2 算出两个角度再相减,然后处理 359° 和 1° 之间的环绕问题。其实这些问题都有更简单、更快的答案:点积和叉积。
先约定坐标系
Section titled “先约定坐标系”本系列所有代码都使用屏幕坐标: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;};点积:前后与投影
Section titled “点积:前后与投影”// 点积:|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 方向上的投影长度,也就是“沿着我面朝的方向,目标有多远”。演示里那条赭黄色的线段,长度就是这个点积。
叉积:左还是右
Section titled “叉积:左还是右”二维叉积 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;}几个值得做的小实验:
- 把玩家拖到守卫正侧面,
dot接近 0;拖到身后,dot变成负数,赭黄线段指向背后。 - 让玩家沿着守卫的朝向线来回穿过,观察
cross的符号翻转,以及left/right的切换。 - 调小
fov,再把玩家放在视野边缘附近,看can see何时变化。 - 对比
local的两个分量和dot、cross,你会发现它们是同一组数字(下文解释)。
视野锥:不用 acos
Section titled “视野锥:不用 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) ⇔ 夹角 <= halfAngleinline 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}矩阵:换一个视角看世界
Section titled “矩阵:换一个视角看世界”旋转一个向量可以直接用三角函数:
// 垂直向量:在 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 |
在游戏中的应用
Section titled “在游戏中的应用”- 背刺判定:攻击者的朝向与受害者的朝向点积大于某个阈值(大致同向),说明是从背后出手。
- 转向决策:AI 需要转向目标时,用叉积的符号决定往左转还是往右转,不需要算角度。
- 光照:漫反射亮度正比于
dot(法线, 光线方向),这是几乎所有着色器的第一行代码。 - 点在直线的哪一侧:
cross(b - a, p - a)的符号。三角形内部判定、多边形凸性检查、碰撞检测都会用到(见第二卷)。 - 三维中:点积的含义完全一样;叉积则得到一个同时垂直于两个向量的新向量,是计算法线和构建坐标系的基础。
- 实现
bool isBehind(Vec2 victimFacing, Vec2 attackerFacing, float toleranceDeg),用于背刺判定。 - 给视野锥加上“近处 360° 感知”:玩家贴得足够近时,即使在背后也会被发现。
- 用叉积判断三个点 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 协议开源。