本文属于系列「游戏中的数学:从坐标到可同步运动」第 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");

入射角与球面回代残差散点

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 必须保持单位长度。求交前同时缩放 normaloffset,才能在不改变平面的前提下统一结果;零法线没有几何意义,直接拒绝。

// 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 表示接触后消耗切向余量的最终位置,接口不独立返回接触点或法线。