游戏开发 · 文章
+ 有更新游戏中的数学,向量与矩阵,点积、叉积在游戏里的用途
这篇文章在发布后更新过正文内容。
目标在前面还是后面?在左边还是右边?在不在视野里?这些问题都不需要三角函数,一次点积或叉积就能回答。
问题:守卫看见你了吗?
做一个潜行游戏,守卫站在走廊里,朝某个方向张望。每一帧都要回答:
玩家在守卫前面还是后面?(背刺判定)
在守卫的左边还是右边?(守卫该往哪边转头)
在不在守卫 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就变成了右边。换引擎、换坐标系时,这是最容易出错的地方。
亲手试一试
拖动红点(玩家)、黑点(守卫)或守卫前方的箭头。播放时守卫会左右扫视。左下角实时显示点积、叉积和局部坐标。
几个值得做的小实验:
把玩家拖到守卫正侧面,
dot接近 0;拖到身后,dot变成负数,赭黄线段指向背后。让玩家沿着守卫的朝向线来回穿过,观察
cross的符号翻转,以及left/right的切换。调小
fov,再把玩家放在视野边缘附近,看can see何时变化。对比
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) |
|
视野判断时灵时不灵 | 朝向向量没有归一化,点积被长度放大 | 存储朝向时就保证是单位向量 |
左右判断恰好相反 | 坐标系 y 轴方向和公式假设不一致 | 写单元测试,用一个确定的例子验证 |
角度插值、比较出错 | 混用了角度和弧度,或没处理 ±180° 环绕 | 内部一律用弧度;能用向量就别用角度 |
距离比较拖慢性能 | 大量调用 | 比较距离时用 |
在游戏中的应用
背刺判定:攻击者的朝向与受害者的朝向点积大于某个阈值(大致同向),说明是从背后出手。
转向决策:AI 需要转向目标时,用叉积的符号决定往左转还是往右转,不需要算角度。
光照:漫反射亮度正比于
dot(法线, 光线方向),这是几乎所有着色器的第一行代码。点在直线的哪一侧:
cross(b - a, p - a)的符号。三角形内部判定、多边形凸性检查、碰撞检测都会用到(见第二卷)。三维中:点积的含义完全一样;叉积则得到一个同时垂直于两个向量的新向量,是计算法线和构建坐标系的基础。
延伸阅读
3Blue1Brown,线性代数的本质:用动画建立矩阵和点积的直觉
Fletcher Dunn、Ian Parberry,《3D 数学基础:图形和游戏开发》
Newsletter
订阅更新
新文章发布时收到邮件通知,随时可退订。
版权声明
本文内容版权归作者或相关权利人所有。转载、引用或其他使用请遵循相应授权条款,并保留本文链接。
XuYi,《游戏中的数学,向量与矩阵,点积、叉积在游戏里的用途》,https://xuyi.dev/2026-11-06-bgcegv
Comments
评论
正在加载评论…