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

游戏中的数学 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++;
}

射线球、AABB 与平面的统一命中推导

回代而非相信

解析式算出 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。统一结果仍由距离、点、法线和非负参数组成,只是参数单位从米切换为秒,接口必须明确标注,不能混用。