游戏中的数学 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");

AABB 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);
平面方程允许输入任意长度的法线,但 Hit.Normal 必须保持单位长度。求交前同时缩放 normal 与 offset,才能在不改变平面的前提下统一结果;零法线没有几何意义,直接拒绝。
// Program.cs · RayPlane
var length = normal.Length();
if (!float.IsFinite(length) || length < 1e-8 ||
!double.IsFinite(offset))
{
hit = default;
return false;
}
var unitNormal = normal / length;
var unitOffset = offset / length;
var denom = Vector3.Dot(d, unitNormal);
if (Math.Abs(denom) < 1e-8)
{
hit = default;
return false;
}
var t = (unitOffset - Vector3.Dot(o, unitNormal)) / denom;
if (!double.IsFinite(t) || t < 0)
{
hit = default;
return false;
}
hit = new(t, o + d * (float)t,
denom < 0 ? unitNormal : -unitNormal);
return true;
固定回归用 normal=(0,2,0), offset=4 表示 y=2 平面,命中点的 y 必须等于 2m,返回法线长度必须等于一。近零分母,或射线平行于盒轴且位于 slab 外,都直接返回无命中;上层随后比较其他形状。
平行与共面在这个最小接口中都返回无命中,因为共面射线没有唯一“第一个进入点”。编辑器拖拽若需要共面滑动,应进入单独的平面约束工具,而不是让通用射线函数返回任意零距离命中。
命中统计
10,000 条射线中有 2,316 次球面命中、7,684 次未命中,最大表面回代残差为 6.198883×10⁻⁶m。除此之外,回归还覆盖平行平面、两条 AABB slab 分支、非单位平面法线与零法线拒绝。
散点图只抽取前 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 plane_t=2.000 aabb_t=2.000 inside_exit=1.000 max_residual=6.199E-006m
TOTAL pass=15 fail=0 skip=0 chapters=15 elapsed_ms=148.7
exit_code=0
{
"rays": 10000,
"hit": 2316,
"miss": 7684,
"parallel_cases": 1,
"scaled_plane": {
"t": 2,
"point": {"x": 0, "y": 2, "z": 0},
"normal": {"x": 0, "y": -1, "z": 0}
},
"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
}
统一命中接口
窄阶段统一暴露 Hit(t, point, unitNormal)。解析相交的优势是结果精确、分支少;代价是每种形状都要维护平行、切线、内部起点和退化输入。当前球、平面与 AABB 都能直接求根,不需要增加迭代步进抽象。
第 13 章把参数从距离改为时间:移动圆对墙的扫掠返回 MotionResult(Position, Hit, Toi, RemainingSeconds)。Toi 是以秒计的非负首次接触时刻;当前 Position 表示接触后消耗切向余量的最终位置,接口不独立返回接触点或法线。