游戏中的数学 12:射线如何找到第一个命中
命中是参数
射线不是一条没有方向的直线,而是 p(t)=o+t·d, t≥0。所以“第一个命中”必须返回最小非负 t;只返回 bool 会迫使拾取、视线和移动前探测各自重算位置与法线。
// $WORK_DIR/.tmp/game-math-lab/Program.cs
// GameMathLab.RaySphere
static bool RaySphere(
Vector3 o, Vector3 d, Vector3 c,
double radius, out Hit hit)
{
var oc = o - c;
var b = Vector3.Dot(oc, d);
var cc = Vector3.Dot(oc, oc) - (float)(radius * radius);
var disc = b * b - cc;
if (disc < 0) { hit = default; return false; }
var s = Math.Sqrt(disc);
var near = -b - s;
var far = -b + s;
var t = near >= 0 ? near : far;
if (t < 0) { hit = default; return false; }
var point = o + d * (float)t;
hit = new(t, point, Vector3.Normalize(point - c));
return true;
}
这里假定 d 已归一化,因此 t 同时是米制距离。方向归一化属于调用边界;若允许任意长度方向,二次方程系数和 t 的单位都会改变,接口就不能与后续 TOI 直接拼接。
实验接口直接返回 Hit(t,point,normal);命中点和单位法线由同一个 t 回代产生,不再分别调用第二套最近点函数。排序只比较非负 t,胜出的命中再查询材质或对象信息;宽阶段只交付候选形状,不拥有最终答案。
先近后远
球面方程回代后得到两个根。正常路径选择 near;射线起点在球内时 near<0,改选 far;两者都为负才表示球在射线背后。这个分支次序保证返回第一个正向表面,而不是“代数上较小的根”。
判别式小于零代表无实根,等于零是切线。当前实现让切线进入命中分支,符合拾取“接触即命中”的合同。若玩法需要半开边界,应在调用层用明确厚度改变形状,而不是把判别式阈值散落到每种求交函数。
// Program.cs · Chapter12 固定种子样本
var random = new Random(1212);
int hit = 0, miss = 0;
double maxResidual = 0;
for (var i = 0; i < 10_000; i++)
{
var origin = new Vector3(
random.NextSingle() * 8 - 4,
random.NextSingle() * 8 - 4,
-8);
var target = new Vector3(
random.NextSingle() * 8 - 4,
random.NextSingle() * 8 - 4,
0);
var dir = Vector3.Normalize(target - origin);
if (RaySphere(origin, dir, Vector3.Zero, 2, out var sphereHit))
hit++;
else
miss++;
}

回代而非相信
解析式算出 t 仍不能直接宣告正确。实验把命中点 o+d·t 回代球面,检查它到圆心的距离是否等于半径。目标点扩展到 [-4,4]² 后,10,000 条固定种子射线同时覆盖 2,316 次命中与 7,684 次未命中;未命中是合法分类,不应被改写成零距离碰撞。
// Program.cs · Chapter12 命中点回代
if (RaySphere(origin, dir, Vector3.Zero, 2, out var sphereHit))
{
hit++;
var residual = Math.Abs(
Vector3.Distance(sphereHit.Point, Vector3.Zero) - 2);
maxResidual = Math.Max(maxResidual, residual);
residualByAngle.Add(new(
Math.Acos(Math.Clamp(
Vector3.Dot(dir, Vector3.UnitZ), -1, 1))
* 180 / Math.PI,
residual));
}
Check(maxResidual < 1e-4,
"ray-sphere hit satisfies surface equation");

Slab 与平行
射线与 AABB 对每个轴分别求进入、离开参数,再取 enter=max(tMin)、exit=min(tMax)。若 exit<max(enter,0) 就没有公共区间;起点在盒内时返回最早正向离开面。固定样本 o=(-3,0,0), d=(1,0,0), box=[-1,1]³ 返回 t=2m、点 (-1,0,0)、法线 (-1,0,0);盒内射线返回 t=1m。
// Program.cs · RayAabb 的单轴核心
if (Math.Abs(direction) < 1e-8)
{
if (origin < min || origin > max)
return false; // 平行且位于 slab 外
continue; // 平行且位于 slab 内,不做除法
}
var t1 = (min - origin) / direction;
var t2 = (max - origin) / direction;
if (t1 > t2) (t1, t2) = (t2, t1);
enter = Math.Max(enter, t1);
exit = Math.Min(exit, t2);
平面求交仍先检测 abs(dot(d,n))<1e-8。失败时序因此是:检测近零分母或平行于盒轴且位于 slab 外 → 返回无命中 → 上层继续比较其他形状。没有 NaN 进入命中排序,也不会让一次平行测试污染整帧拾取结果。
平行与共面在这个最小接口中都返回无命中,因为共面射线没有唯一“第一个进入点”。编辑器拖拽若需要共面滑动,应进入单独的平面约束工具,而不是让通用射线函数返回任意零距离命中。
固定运行
同一实验进程生成 JSON 和两张数学图。退出码为 0;2,316 次球面命中的最大回代残差是 6.198883×10⁻⁶m,另有 1 个专门构造的平行平面分支。
散点图只抽取前 1200 个命中保持图面可读,最大残差统计仍覆盖全部 10,000 条射线。横轴是方向与正前方的夹角,纵轴是球面方程残差;它检查数值稳定性,不承担吞吐结论。
cd "$WORK_DIR"
/usr/bin/time -p dotnet run \
--project .tmp/game-math-lab/GameMathLab.csproj \
-- --out .tmp/game-math-lab/out
CH12 PASS rays=10000 hit=2316 miss=7684 parallel=1 aabb_t=2.000 inside_exit=1.000 max_residual=6.199E-006m
TOTAL pass=15 fail=0 skip=0 chapters=15 elapsed_ms=112.8
exit_code=0
{
"rays": 10000,
"hit": 2316,
"miss": 7684,
"parallel_cases": 1,
"aabb_entry": {
"t": 2,
"point": {"x": -1, "y": 0, "z": 0},
"normal": {"x": -1, "y": 0, "z": 0}
},
"aabb_inside_exit": {
"t": 1,
"point": {"x": 0, "y": 1, "z": 0},
"normal": {"x": 0, "y": 1, "z": 0}
},
"max_surface_residual_m": 6.198883056640625E-06
}
统一命中
解析相交的优势是结果精确、分支少;代价是每种形状都要推导,并维护平行、切线、内部起点等边界语义。这里没有用迭代步进替代解析式,因为当前球和平面都能直接求根;复杂形状需要时再引入层级包围盒,而不是预先加抽象。
下一章把 t 改读为时间:移动圆对墙的扫掠将返回首次接触时刻 TOI。统一结果仍由距离、点、法线和非负参数组成,只是参数单位从米切换为秒,接口必须明确标注,不能混用。