游戏中的数学 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 平面,再由矩阵映射到世界空间。点与方向使用不同的齐次语义,组合顺序由运行时约定锁定。