· 5 分钟阅读 · 1990 字
射线贴着盒面却未命中:平行轴不能交给零乘无穷
射线沿着包围盒的一个侧面前进,几何上有一整段与盒子重合,查询却返回未命中。把起点往盒内挪一点,结果恢复正常。这个边界足以让编辑器拾取漏掉贴边物体,也可能让 BVH 遍历跳过仍有候选三角形的节点。问题可以缩成单位盒:起点 O=(0,−1,0.5),方向 D=(0,1,0),有效参数 t≥0。正确交集是 [1,2]。

常见的 slab 实现先预计算方向倒数,再把三个轴的进入、离开参数取交集。对上述 X 轴,倒数是正无穷,下边界偏移却恰好为零;相乘得到 NaN。几何里的“这一轴始终在盒内”,被转换成一个不参与正常大小比较的数值。这里需要修正的是平行轴的语义,给包围盒随意加一点厚度只会移动出问题的位置。
平行轴交付的应该是约束,而不是两个除法结果
设射线为 P(t)=O+tD。每个轴要求 min≤O+tD≤max。当 D 不为零时,可以算出两个边界参数并按大小排序;当 D 为零时,坐标恒定,只有两种结果:O 在闭区间内,该轴不收缩已有参数区间;O 在区间外,整条射线都不可能进入盒子。
固定输入中,X 轴允许所有 t,Y 轴给出 [1,2],Z 轴也允许所有 t。再与查询范围 [0,+∞) 相交,仍为 [1,2]。若把起点 X 改成 2,X 轴立即拒绝,不必读取另外两个轴。允许所有参数和没有可用参数是两个明确的集合,不能都用无穷乘法间接表达。
错误路径保留在 slab.mjs 的 reciprocalVersion 中。它对普通内部射线可以工作,贴面样本则返回 null:
const inv = 1 / direction[axis];
const a = (min[axis] - origin[axis]) * inv;
const b = (max[axis] - origin[axis]) * inv;
enter = Math.max(enter, Math.min(a, b));
exit = Math.min(exit, Math.max(a, b));
// 贴面平行轴:a=NaN,enter/exit 被污染;enter<=exit 为 false。
这个反例针对 JavaScript 的 Math.min/Math.max 语义。迁移到着色器或 SIMD 时,应重新验证目标指令对 NaN 的选择行为;某条最小值指令偶然留下另一个操作数,并不能证明进入、离开区间的语义正确。特别是换掉比较次序后,边界结果也可能变化。
把零方向作为一条完整分支
探针以 JavaScript 实现独立几何内核,适合接到 Web 游戏拾取或 BVH 节点测试之前。intersectBox 完整实现如下;输入为三个分量的数组,输出是参数区间或 null,不依赖渲染框架。
export function intersectBox(origin, direction, min, max, near = 0, far = Infinity) {
for (const vector of [origin, direction, min, max]) {
if (vector.length !== 3 || !vector.every(Number.isFinite))
throw new RangeError('Expected finite 3-vector');
}
if (!Number.isFinite(near) || near < 0 ||
!(Number.isFinite(far) || far === Infinity) || far < near)
throw new RangeError('Invalid parameter range');
if (min.some((v, i) => v > max[i]) || direction.every(v => v === 0))
throw new RangeError('Invalid box or stationary ray');
let enter = near, exit = far;
for (let axis = 0; axis < 3; axis++) {
const o = origin[axis], d = direction[axis];
if (d === 0) {
if (o < min[axis] || o > max[axis]) return null;
continue;
}
let a = (min[axis] - o) / d;
let b = (max[axis] - o) / d;
if (!Number.isFinite(a) || !Number.isFinite(b))
throw new RangeError('Slab quotient overflow');
if (a > b) [a, b] = [b, a];
enter = Math.max(enter, a);
exit = Math.min(exit, b);
if (enter > exit) return null;
}
return { enter, exit };
}
方向分量比较使用精确的 d===0,同时覆盖正零和负零。这里不采用任意 epsilon:一个很小但非零的方向仍可能在足够大的 t 后进入盒子,把它改判为平行,会悄悄改变最大射程内的候选集合。极小方向导致商溢出时,当前内核明确抛错;生产系统可在坐标缩放、有限查询范围或更宽数值表示之间选择,不能把溢出伪装成几何未命中。
实现保留闭区间,只有 enter>exit 才拒绝。单点相交和零厚度盒因此可以命中,这适合作为保守候选测试。若拾取产品规则要求排除擦边,应在后续精确测试中显式规定,而不是让 NaN 随机承担边界归属。

返回的 enter 也不是无条件的物体表面距离。射线从盒内出发,near 为零时,enter 返回零,exit 才是向前离开盒子的参数。调用者若要选取出射表面,需要识别起点状态,不能直接把 enter 当成面交点。有限 near 还可能把进入端裁到盒子内部;这个接口交付的是查询区间与盒内区间的交集。
D 不要求单位长度。同一起点改用 (0,2,0) 后,交集成为 [0.5,1],实际进入位置仍为 (0,0,0.5)。因此 near/far 必须与 D 使用同一参数尺度;它们只有在方向单位化时才可直接解释为世界距离。若上游保留了变换后的非单位方向,节点查询也应保持该参数合同。
用六个面交点检查逐轴求交
test-slab.mjs 的固定回归同时检查下侧面、上侧面、盒边、负零、盒外平行、反向、盒内起点、有限射程、单点交集和退化盒。非法数值、反向盒范围、零方向向量与商溢出会抛错。失败样本单独断言错误版本漏检,避免后续维护只留下正常路径。
const min = [0, 0, 0], max = [1, 1, 1];
intersectBox([0,-1,.5], [0,1,0], min, max); // {enter:1, exit:2}
intersectBox([2,-1,.5], [0,1,0], min, max); // null
intersectBox([0,-1,.5], [0,2,0], min, max); // {enter:.5, exit:1}
intersectBox([0,-1,.5], [0,1,0], min, max, 0, .9); // null
reciprocalVersion([0,-1,.5], [0,1,0], min, max); // null:失败回归
随机对照没有复制 slab 的区间收缩代码。参照器枚举六个盒面交点和 near/far 两个端点,以 BigInt 分数判断参数是否在查询范围、交点是否同时满足三个轴的盒内条件,再取全部合法候选中的首尾。直线与凸盒的交集只能是一段区间;非空区间的端点必在查询端点或盒面上,所以这些候选足以覆盖当前输入域。
2026-11-08 在 Node.js 的同一进程运行下面命令,执行位置为探针根目录 $LAB_ROOT:
$ node --check slab.mjs
$ node --check test-slab.mjs
$ node test-slab.mjs
tests=21 passed=21 failed=0 skipped=0 elapsedMs=155.475
randomCases=10000 disagreements=0 maxParameterError=0
fixed robust.enter=1 robust.exit=2 reciprocal=null outside=null
syntaxExitCode=0 runExitCode=0
随机种子为 11082000;起点各分量为 [−12,12] 整数,方向分量为 [−4,4] 整数并排除全零,盒下界为 [−6,0],各轴长度为 [0,8];near 为 [0,2],far 为 [3,12]。这组范围刻意保留零方向和零厚度盒。参照比较前始终使用精确分数,最后才转为 Number 与实现输出比较。10,000 组命中分类零分歧,参数最大绝对误差为零。耗时记录回归执行,不代表与倒数版本的性能比较。
这一修正决定的是宽阶段能否保留正确候选。它没有处理三角形拾取、表面法线或世界变换,也没有证明极端浮点坐标下不存在舍入漏检;对需要保守误差界的遍历器,还应单独建立区间外扩规则。当前可直接落地的决策是:先把零方向、闭区间和参数尺度固定为几何接口合同,再评估倒数缓存或向量化。优化后的路径必须继续通过贴面与盒外平行这两类样本,才能接替这条标量基线。