本文属于系列「游戏中的数学:从坐标到可同步运动」第 3 / 15 篇

游戏中的数学 03:点积、叉积与向量投影

方向分类

第二章已经保证方向向量要么归一化成功,要么显式失败。沿用第一章的右手坐标约定,实验将观察方向固定为 +X,并在 XY 平面内把目标方向从 -180° 扫描到 179°;二维叉积的正值对应 +Z,即观察者左侧。每个样本输出三项结果:是否位于前方 90° 视野内、位于左侧或右侧、在指定轴上的分量。

// $WORK_DIR/.tmp/game-math-lab/Program.cs · Chapter03
var forward = Vector2.UnitX;
var threshold = Math.Cos(DegToRad(45));

int inside = 0;
int left = 0;
int right = 0;
var curve = new List<P>();

向量分解为投影与拒绝分量

状态所有者仍是玩法系统:感知系统拥有观察方向与视野半角,数学层只返回标量和向量。渲染层可以画扇形,但不能成为“敌人是否可见”的事实来源;否则分辨率、帧率或裁剪变化会反过来改变战斗判定。

点积判定

单位向量点积等于夹角余弦。半视野角为 45° 时,阈值是 cos(45°);使用 >= 表示边界点也算视野内。扫描包含 -45°45° 共 91 个整数角,因此固定计数为 91,而非 90。

// Program.cs · Chapter03 angular scan
for (var degree = -180; degree < 180; degree++)
{
    var direction = new Vector2(
        (float)Math.Cos(DegToRad(degree)),
        (float)Math.Sin(DegToRad(degree)));

    var dot = Vector2.Dot(forward, direction);
    if (dot >= threshold - 1e-7)
        inside++;

    curve.Add(new(degree, dot));
}

减去 1e-7 并非扩大视野,而是为 float 构造方向后的边界误差保留预算。该预算属于采样实现;生产实现若使用定点数、查表角度或协议量化,需要配置对应的边界断言。

叉积判定

二维叉积返回有向面积:正值在左,负值在右,接近零表示共线。扫描中左、右各 179 个样本;正前与正后两个共线方向不应被强行归到任一侧。

// Program.cs · Cross and side classification
static double Cross(Vector2 a, Vector2 b) =>
    a.X * b.Y - a.Y * b.X;

var cross = Cross(forward, direction);
if (cross > 1e-7)
    left++;
else if (cross < -1e-7)
    right++;

退化条件为叉积接近零。若写成 cross >= 0 ? left : right,正前方也会被标成左侧,自动瞄准会在中心线持续抖动。保留“共线”第三种状态后,上层状态机可以沿用上一次转向或停止角速度。

正交分解

坡面运动、镜头约束和击退分解都需要同一操作:先求向量在单位轴上的投影,再用原向量减投影得到拒绝分量。两者相加必须还原原向量,拒绝分量与轴的点积必须为零。

// Program.cs · Chapter03 projection
var value = new Vector2(3, 2);
var axis = Vector2.Normalize(new Vector2(1, 1));

var projection = axis * Vector2.Dot(value, axis);
var rejection = value - projection;
var orthogonalResidual = Vector2.Dot(rejection, axis);

Check(
    Math.Abs(orthogonalResidual) < 1e-6,
    "projection residual is orthogonal");

固定结果是投影 (2.5,2.5)、拒绝 (0.5,-0.5)、正交残差零。若把未归一化的轴送进这个公式,轴长度会被乘进结果两次;这个 API 合同因此直接继承第二章的单位向量不变量。

计数不变量

视野和左右侧使用同一批 360 个输入,避免两个系统各自定义角度边界。总数断言同时锁定采样区间、阈值包含关系与共线分类。

// Program.cs · Chapter03 deterministic checks
Check(
    inside == 91 && left == 179 && right == 179,
    "angular classification count is deterministic");

WriteData(3, new {
    samples = 360,
    fov_half_angle_deg = 45,
    inside,
    left,
    right,
    projection = V(projection),
    rejection = V(rejection),
    orthogonal_residual = orthogonalResidual
});
{
  "samples": 360,
  "fov_half_angle_deg": 45,
  "inside": 91,
  "left": 179,
  "right": 179,
  "projection": {
    "x": 2.5,
    "y": 2.5
  },
  "rejection": {
    "x": 0.5,
    "y": -0.5
  },
  "orthogonal_residual": 0
}

角度、点积曲线与视野阈值

实验结果

图中的 360 个点、阈值线和三组计数均由同一循环写出。正交残差为零,视野与左右分类命中固定计数;完整验证程序没有失败或跳过。

cd "$WORK_DIR"
dotnet run --project .tmp/game-math-lab/GameMathLab.csproj \
  -- --out .tmp/game-math-lab/out
CH03 PASS samples=360 inside=91 left=179 right=179
  residual=0.000E+000
TOTAL pass=15 fail=0 skip=0 chapters=15 elapsed_ms=148.7
EXIT code=0

系统边界

点积阈值避免对每个目标执行一次反三角函数,代价是输入必须预先归一化。叉积保留共线状态以减少中心线抖动,但上层需要处理第三分支。投影公式不为任意轴长度增加隐式除法,因为单位轴已经是第二章建立的共享不变量。

第四章将二维点、方向和正交分解嵌入右手三维坐标系的 XY 平面,再由矩阵映射到世界空间。点与方向使用不同的齐次语义,组合顺序由运行时约定锁定。