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

游戏中的数学 03:点积、叉积与投影怎样回答方向问题

一个目标方向

第二章已经保证方向向量要么归一化成功,要么显式失败。本章让观察者朝向 +X,把目标方向从 -180° 扫到 179°。每个样本只回答三件事:是否在前方 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 个整数角,因此结果不是直觉中的 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 个点、阈值线和计数均由本章循环写出。全书一次运行通过 15 章,本章正交残差为零,没有失败或跳过。

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=112.8
EXIT code=0

取舍与接口

点积阈值省去每个目标一次反三角函数,适合批量感知;代价是调用前必须归一化。叉积保留共线状态,减少中心线抖动,但上层需要明确第三分支。投影公式保持最小,不为任意轴长度增加隐式除法,因为单位轴已经是共享不变量。

交给下一章的是正交基、朝向分类和投影结果。矩阵会把这些局部基向量与位置一起搬到世界空间,但点与方向的变换不能混写,组合顺序也必须由运行时约定锁定。