游戏中的数学 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
取舍与接口
点积阈值省去每个目标一次反三角函数,适合批量感知;代价是调用前必须归一化。叉积保留共线状态,减少中心线抖动,但上层需要明确第三分支。投影公式保持最小,不为任意轴长度增加隐式除法,因为单位轴已经是共享不变量。
交给下一章的是正交基、朝向分类和投影结果。矩阵会把这些局部基向量与位置一起搬到世界空间,但点与方向的变换不能混写,组合顺序也必须由运行时约定锁定。