局部射线求交:归一化方向为何会选错最近对象
鼠标指向两个前后排列的对象,拾取器却选中了后面的那个。两个局部求交都算对了交点,最近命中的排序仍然可能出错:前方对象返回 5,后方拉伸对象返回 4。问题在于,这两个数已经不在同一把尺子上。
设世界射线从原点沿 +Z 发出,方向长度为 1,世界长度单位为米。对象 B 是中心位于 z=6、半径为 1 的球;对象 A 的局部形状同样是单位球,先沿 Z 放大两倍,再平移到 z=10。射线先在 z=5 碰到 B,再在 z=8 碰到 A。本文的 C# 几何实现把这组输入固定下来,用它检查一次容易被隐藏在辅助函数里的归一化。

图中展示世界形状与局部形状的关系,尺寸以接下来的数据图为准。这里讨论的是对象几何的精确求交,不是用包围球近似一个任意 Mesh,也不涉及鼠标坐标如何生成世界射线。
两个正确的局部交点,为什么排出了错误顺序
把射线送入 A 的局部空间,原点变成 (0,0,-5),方向变成 (0,0,0.5)。到单位球前表面 z=-1,需要原参数走 8。若顺手把方向归一化为 (0,0,1),到同一点只需新参数走 4。B 没有缩放,因此它的参数仍是 5。比较 4 与 5,正好颠倒了世界空间中的先后关系。
仿射逆变换允许把一条参数直线拆开:世界原点单独变换为局部原点,世界方向只乘逆矩阵的线性部分。于是对同一个 t,局部点 oLocal + t*dLocal 回到世界后仍是 oWorld + t*dWorld。平移不能进入方向变换;而世界方向为单位向量,才让 t 同时表示世界距离。
归一化本身可以使用,但必须承认参数已更名。令 L=length(dLocal),单位局部方向上的交点参数为 s,则 s=t*L,返回世界距离要计算 t=s/L。A 的 L 是 0.5,所以 4/0.5=8。非均匀缩放下,L 还依赖本次射线方向,不能只缓存对象的某一个缩放分量来代替。

让求交器接收方向的真实长度
当前实现选择保留 dLocal。对应源码为研究工程中的 RayQuery.cs,调用链是 Program.Hit → RayQuery.ToLocal → RayQuery.HitUnitSphere。它返回可空距离:没有非负且处于范围内的表面交点时返回 null,非法输入则拒绝。最近对象选择器只比较有效的世界距离,不需要了解对象缩放。
单位球方程展开后是 a*t*t + 2*b*t + c = 0,其中 a=dot(d,d)、b=dot(o,d)、c=dot(o,o)-1。方向不再是单位向量,a 就不能省略。代码用 q 形式计算两个根,减少直接相减带来的消去;q 为零的重根单独处理。以下是实际参与测试的完整核心文件。
using System.Numerics;
public readonly record struct LocalRay(Vector3 Origin, Vector3 Direction);
public static class RayQuery
{
static bool Finite(Vector3 v) => float.IsFinite(v.X) &&
float.IsFinite(v.Y) && float.IsFinite(v.Z);
public static LocalRay ToLocal(Vector3 origin, Vector3 direction, Matrix4x4 world)
{
if (!Finite(origin) || !Finite(direction) ||
Math.Abs(direction.LengthSquared() - 1) > 1e-5)
throw new ArgumentException("World direction must be unit length");
if (world.M14 != 0 || world.M24 != 0 || world.M34 != 0 || world.M44 != 1 ||
!Matrix4x4.Invert(world, out var inverse))
throw new ArgumentException("Invertible affine matrix required");
var ray = new LocalRay(Vector3.Transform(origin, inverse),
Vector3.TransformNormal(direction, inverse));
if (!Finite(ray.Origin) || !Finite(ray.Direction) || ray.Direction == Vector3.Zero)
throw new ArgumentException("Invalid transformed ray");
return ray;
}
static double Dot(Vector3 a, Vector3 b) =>
(double)a.X * b.X + (double)a.Y * b.Y + (double)a.Z * b.Z;
public static double? HitUnitSphere(LocalRay ray, double maxDistance)
{
if (!Finite(ray.Origin) || !Finite(ray.Direction) ||
ray.Direction == Vector3.Zero || !double.IsFinite(maxDistance) || maxDistance < 0)
throw new ArgumentException("Finite ray and nonnegative range required");
double a = Dot(ray.Direction, ray.Direction);
double b = Dot(ray.Origin, ray.Direction);
double c = Dot(ray.Origin, ray.Origin) - 1;
double discriminant = b * b - a * c;
if (discriminant < 0) return null;
double q = -b - Math.CopySign(Math.Sqrt(discriminant), b);
double t0 = q == 0 ? -b / a : q / a;
double t1 = q == 0 ? t0 : c / q;
if (t0 > t1) (t0, t1) = (t1, t0);
double t = t0 >= 0 ? t0 : t1;
return t >= 0 && t <= maxDistance ? t : null;
}
}
System.Numerics 的世界矩阵在本例中写成 Scale * Translation,表示先缩放再平移。TransformNormal 在这里仅执行方向的线性变换;函数名字不意味着正在处理表面法线,不能把它替换成法线所需的逆转置变换。
同一参数也决定了距离裁剪。世界查询上限为 6 时,A 的 t=8 必须被拒绝;若把归一化后的局部参数 4 直接交给这个上限,会让范围外对象进入候选集。若已有底层库强制单位方向,就应在调用前把上限换成 maxDistance*L,返回时再除以 L。保留方向长度与显式换算都能成立,混用两套参数才会破坏排序。
把命中点送回世界,再检查距离
测试入口 Program.Main 保留了错误实现作为回归:对 A 的局部方向归一化后得到 4,同时断言正确结果为 8,且 B 的 5 更近。range-reject 与 range-inclusive 分别检查上限 6 的拒绝和上限 8 的包含;球内起点取向前的离开表面,表面起点允许 t=0,背后交点与不相交射线返回 null。
2026-09-13 在 .NET 9.0.3 下运行 Release 版本,执行位置用 $PROBE_PROJECT 表示本篇研究工程。构建和运行均退出 0,构建为零警告、零错误。下面的耗时是这一批测试的计时结果,不是引擎拾取性能基准。
cd "$PROBE_PROJECT"
dotnet build RayLab.csproj -c Release
dotnet run --project RayLab.csproj -c Release --no-build
passed=19 failed=0 skipped=0
samples=648 elapsed_ms=10.1026
correctA=8 correctB=5 wrongA=4
winner=B wrongWinner=A localDirectionA=0.5
build_exit=0 run_exit=0
Program.Scan 组合六种 Z 缩放、十二个旋转角与九个偏移,包含负缩放。它从局部外点构造指向球心的世界单位射线,求交后检查局部点长度为 1,再把交点送回世界,同时检查到原点的距离等于 t、位置等于世界射线上的点;绝对容差为 0.00002。这组检查避开重复求根公式,但仍共享矩阵变换,因此不能充当另一套数值库的独立正确性证明。A、B 的轴向解析样本补上了直接可复算的校准。
奇异矩阵、投影矩阵、零方向、非有限原点与负上限都有拒绝测试。一般可逆仿射变换的参数等式并不局限于这 648 个样本,但当前浮点实现没有给出病态矩阵的误差保证:接近零的缩放、极大坐标和近切线会放大误差,判别式也没有使用人为容差把负数夹回零。编辑器若要容忍这些资产,需另行定义精度范围、逆矩阵条件检查及切线接触政策。
对于前后两个对象,修复已经落实到拾取接口:世界射线只在入口归一化一次,局部几何保留其参数尺度,排序和最大距离共用返回的 t。本实现未接入 Unity 物理查询或完整三角形加速结构;对需要自定义局部求交的工具,这份参数约定可以先固定下来,避免每种形状都在返回一个名称相同、单位却不同的“距离”。