早上来公司调试一个交互项目,同事逮住我就问:为什么从手柄发射出去的那条射线,打在左侧按钮上一打一个准,打到右边UI上却像穿过了空气?我扫了一眼代码,用的都是同一个函数,射线起点、方向也看不出毛病。这种“时而命中、时而miss”的现象,在“发射射线,判断是否存在交点”这类几何计算里特别常见。简单说,射线求交就是从某个起点出发,沿一个方向找有没有物体挡在前进路径上;它是三维交互、物理碰撞、工业测量里最基础也最容易被低估的一类计算。
这篇文章我会从那次排错说起,把射线求交的数学原理、工程实现里的坑、性能优化思路,以及同一套逻辑在不同行业里的变体串起来聊一遍。适合刚接触游戏物理、在做手势或手柄交互、以及写工业视觉和CAD辅助功能的朋友参考。后续你会看到:有些问题看起来像玄学,最后都是坐标系、浮点精度或“你拿错了几何对象”在捣乱。
1. 一次“射线没打中”的经典排查现场
1.1 现象还原:为什么左边命中、右边漏掉
同事的项目是虚拟现实手柄射线点选UI。手柄发射射线,射到面板按钮上,就触发点击事件。左侧按钮一切正常,右侧按钮却偶尔失灵。更奇怪的是,失灵并不固定,稍微转一下头又好了。由于左右按钮用的是同一套逻辑,第一反应是怀疑按钮的碰撞体挂错了位置。
我让他做了一件事:把射线的可视化打开,同时在按钮的Box Collider上画一个线框。结果发现,射线明明穿过了按钮的可视范围,却没有命中Collider。再仔细一看,问题根本不在射线计算,而是按钮面板被手柄拖拽或旋转后,碰撞体的世界坐标更新滞后了一帧。Physics.Raycast读的是物理引擎当前帧的数据,而UI的World坐标在LateUpdate里才同步,于是射线打中了“旧位置”的空气。
这个案例给了我一个很重要的提醒:射线求交本身是纯数学判断,不会错;错的是你喂给它的数据。
1.2 我画了一条调试辅助线,问题立刻缩小了一半
排查这类问题,我有个习惯,先用一条可视化的线把射线画出来,再和实际场景对照。在Unity里写这几行就够了:
csharp复制Vector3 origin = handTransform.position;
Vector3 direction = handTransform.forward;
Debug.DrawLine(origin, origin + direction * 5f, Color.red, 2f);
如果用的是鼠标点击屏幕后发射的摄像机射线,还需要确认屏幕坐标到世界射线这一步有没有换算错。例如Canvas的Render Mode不是Overlay时,屏幕上的点击点不能直接当作UI坐标,要先转成世界平面上的点。射线“看起来穿过去了,实际上没碰到”的最常见原因,就是这里的二维三维转换出了问题。
画线只是帮我们把“想象中的射线”和“真实的射线”对齐。一旦对齐,很多问题就藏不住了。比如射线被某个不需要的物体挡住,或者射线长度不够,根本到不了目标位置。
1.3 最小复现:把几何测试从引擎里剥离
那次排查的最后,我们确认不是射线函数的锅。但为了还原整个过程,我还做了一件更彻底的事:把检测逻辑从工程里抽出来,写一个只输入起点、方向、目标点三个参数的纯函数,用它单独算一遍。
csharp复制public static bool RayHitsSphere(
Vector3 origin, Vector3 dir, Vector3 sphereCenter, float radius)
{
Vector3 oc = origin - sphereCenter;
float b = Vector3.Dot(dir, oc);
float c = Vector3.Dot(oc, oc) - radius * radius;
float discriminant = b * b - c;
return discriminant >= 0f;
}
这段代码无论有没有引擎都能跑,所有输入都清清楚楚。跑完的结果和Unity的Physics.Raycast对比,就能区分“是我的数据不对”还是“引擎计算有问题”。绝大多数时候,答案都是前者。这个“最小复现”的思路,后来成了我排查几何相关Bug的第一步。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 交点检测背后的数学,其实只需要这一套参数化模型
2.1 从“射线”到“线段”:先认清你在和什么做相交
射线最简单的写法是参数方程:
P(t) = O + D * t
O是起点,D是方向向量,t是一个非负实数。当t为0时,P就在起点;t越大,点沿方向越远。如果进一步限制t的取值范围,就能得到不同类型:
- 射线:t >= 0
- 线段:0 <= t <= 1
- 直线:t没有任何限制
工程里大家经常嘴上说“射线检测”,实际用的却可能是一条有限长度的线段。比如枪械射击里子弹飞行距离有限,那射线的最大长度就是子弹射程。把射线建模成线段后,即使目标在方向上,但距离超过上限,也不能算命中。
这个差异在视觉上有时候看不出来,因为相机视角里远处的物体很小,你很难判断是否超长。所以写代码前先定义清楚:射线是无限延伸的,还是只在一定范围内有效?Unity里Raycast如果不传maxDistance,就会用无限远;UE5里如果忘了配Trace Length,可能默认只有一小段距离。这个习惯能帮你省掉后面一大半困惑。
2.2 与球体求交时,判别式在说什么
判断射线是否穿过一个球,是我们用的第一个数学例子。设球心为C,半径为r,并把射线上任意一点代入到球的方程中。球面上任意一点都满足距离等于半径,因此方程是:
|O + D * t - C|^2 = r^2
把D归一化以后,可以简化成一个一元二次方程。解这个方程会得到最多两个根,分别对应射线进入球体和离开球体的位置。如果判别式小于0,说明射线和球没有交点;等于0说明刚好擦到边缘;大于0则说明有两个交点,按t从小到大取第一个就是最近的命中点。
我在真实项目里经常看到有人用“距离判断”代替球体求交:把射线原点和球心连一条线,看距离是否小于半径。这种方法不严谨,因为它没有考虑方向,会误判位于射线“身后”的球。正确做法一定是用带方向的参数化射线去求方程的根。
2.3 平面、三角形与AABB:三种主流几何体怎么判断
实际场景里不可能全是球。游戏场景的网格模型由三角形组成,UI面板通常是平面矩形,碰撞体和物理引擎则大量使用AABB(轴对齐包围盒)。这三种几何体各有各的求交方式。
平面求交最简单。平面可以写成 n · P + d = 0,把射线方程代进去,得到:
t = -(n · O + d) / (n · D)
如果分母n·D等于0,说明射线方向和平面平行,此时要么完全不相交,要么完全在平面上。得到t后,把它代回射线方程,就是交点坐标。判断一个点是否在矩形范围内,还需要把交点转回局部坐标再比较长宽。
三角形求交的经典算法是Möller–Trumbore。它的核心思想是用两条边向量把三角形内部点参数化,通过解一个三元的线性方程组,一次性得到距离t和重心坐标u、v。当u和v都大于等于0,且u+v<=1时,交点才在三角形内部。这个算法比“先求平面交点,再判断点是否在三角形内”更快,因为它把两步合并了。
AABB求交常用slab方法:
python复制def ray_hit_aabb(origin, inv_dir, bbox_min, bbox_max):
tmin = (bbox_min[0] - origin[0]) * inv_dir[0]
tmax = (bbox_max[0] - origin[0]) * inv_dir[0]
if tmin > tmax:
tmin, tmax = tmax, tmin
for axis in range(1, 3):
t1 = (bbox_min[axis] - origin[axis]) * inv_dir[axis]
t2 = (bbox_max[axis] - origin[axis]) * inv_dir[axis]
if t1 > t2:
t1, t2 = t2, t1
tmin = max(tmin, t1)
tmax = min(tmax, t2)
return tmin <= tmax
这里对每个轴都算出进入和离开包围盒的t,再取交集。如果最终tmin小于tmax,说明射线穿过了这个盒子。用方向分量的倒数是为了把除法变成乘法,这是老派优化手法,在大量检测时能省不少时间。
2.4 一个容易忽略的细节:方向向量归一化的连锁影响
很多求交算法的输入方向向量并不要求归一化,因为t本身可以携带“长度”信息。但如果把方向向量归一化,t的值就直接代表世界距离。这对排序、射线长度限制、以及可视化调试都更方便。
有一次我写Ray-Sphere求交时,忘记归一化direction,结果射线的t值一会儿是实际距离,一会儿又是实际距离的1.5倍。引擎自带的Raycast不会有这个问题,但自己写的几何检测函数基本都默认方向是归一化的,于是在参数传递时出现了不一致。后来我在每个需要方向向量的函数入口处显式调用normalize,或者干脆在文档里用注释写清楚前提条件。
这类“前提条件”最坑人。写的时候觉得理所当然,三个月后回来看,根本想不起来当初为什么没归一化。
3. 工程里真正让命中失效的,往往不是数学
3.1 坐标系不一致是最常见的问题
射线的起点是世界坐标,物体的顶点可能是模型本地坐标。如果直接把模型顶点拿来和世界射线做比较,结果必然离谱。正确做法有两种:要么把顶点通过模型矩阵变换到世界空间,要么把射线原点和方向通过逆矩阵变换到模型空间。
我自己更喜欢第二种,因为模型顶点数量通常很大,逐个变换到世界空间开销很高;而射线只有两个向量,变换一次很划算。但要注意:如果模型矩阵带有非均匀缩放,法线不能直接使用同一个矩阵变换,必须用逆转置矩阵,否则法线方向会被扭曲。这个问题在带Scale的UI元素上特别常见。
排查坐标系问题有一个很土但有效的方法:在射线命中点处画一个小的球体,然后切换Scene视图的坐标空间查看它落在哪里。如果落在了物体的包围盒外,明显就不是“精度问题”,而是“坐标系根本没对齐”。
3.2 浮点误差:刚好在边界上那一帧发生了什么
几何计算最怕的其实是“几乎命中”的状态。浮点数在判断等于0、等于1这类边界条件时并不可靠。比如一个点本来在平面上,经过几轮坐标变换后,得到的距离可能是1e-8,也可能正好是-1e-8。一旦判断方向写反,命中和不命中就在这毫厘之间反复横跳。
处理办法是给比较操作加一个容忍度epsilon。不要直接写t <= 0,而是写t <= 1e-6;不要判断距离等于半径,而是判断距离和半径的差是否在容差范围内。
还有个更麻烦的情况:射线从一个平面表面发出,按理说交点就在起点,但由于浮点误差,射线会和该平面刚离开一点点距离,导致第一帧命中自己。做轮廓描边、环境光遮蔽这类自相交检测时尤其明显。解决方法是把射线起点沿法线方向“推开”一个微小偏移,很多引擎里叫bias,比如0.0001到0.001。数值太小没效果,太大又会漏掉细薄物体,需要实测调整。
3.3 背面剔除和单面材质会删掉你想要的交点
有些引擎的射线检测默认不检测背面。比如Unity里用Physics.Raycast去点一个Cube,如果射线从背面穿过,默认是能检测到的,因为Cube的碰撞体是封闭的;但如果是对着Mesh Collider,射线方向刚好穿出背面三角面片,结果可能取决于mesh的正面朝向。UE5的Line Trace也有Trace Complex选项,和只做简单碰撞的检测结果不一样。
在UI上出问题的场景通常是:面板是平面Mesh,材质设成了单面显示,从背面看啥都没有,但碰撞体依然存在。射线从背面穿过来时,用户看不到面板,代码却可能命中或者漏命,完全取决于碰撞体怎么配。这种“视觉和物理不一致”的情况,排查起来最费时间。
所以画辅助线时别只看场景里的模型,还要把碰撞体、NavMesh、物理形状也一并显示出来。你会发现很多“幽灵交点”其实都在你看不见的碰撞体上。
3.4 小物体和快速运动:交点在两帧之间溜走了
一个高速移动的小物体,比如一颗子弹,如果帧率是60fps,一帧大约16毫秒。子弹一帧能飞过很长的距离,可能上一帧还在球体左边,下一帧已经到了球体右边。这种情况下按帧采样射线,很可能永远不会命中。
游戏引擎的Continuous Collision Detection(CCD)解决的正是这个问题。它不再只检测物体在某一瞬间的位置,而是检测物体从上一帧位置到当前帧位置扫过的路径。模拟方法之一是把移动物体建模成一根短线段或胶囊体,再做扫掠求交。
如果手写这类检测,最简单的方法是沿运动路径分成多个小步长,每步做一次射线检测。步长越小越精确,但代价越高。工业上做传送带上的工件检测也类似,物体高速移动、相机或传感器按固定频率触发,如果不能在积分时间内覆盖整个运动路径,就会出现“穿过但看不到”的漏检。
3.5 实战参数表:不同引擎射线相关API的默认差异
这几类引擎的射线API默认行为非常容易混淆。我把实际用到的差异整理成表:
| 环境/API | 默认最大距离 | 背面处理 | 常用适用场景 |
|---|---|---|---|
| Unity Physics.Raycast | 无限远(不传距离时) | 标准Collider通常双面 | 鼠标点选、武器命中 |
| Unity Graphic.Raycast | 仅UI层 | 与Graphic Raycaster的Blocking设置相关 | UI点击 |
| UE4/UE5 LineTraceByChannel | 受Trace Length参数控制 | 与碰撞预设相关 | 摄像机阻挡、射击检测 |
| PICO/UISystem射线点击 | 默认手柄有效距离 | EventSystem管理 | XR手柄UI |
| VisionMaster直线交点 | 两条直线的数学解 | 不适用 | 工业视觉定位 |
表格里最值得记的一点是:不同API的“默认最大距离”并不一样。很多人换引擎后沿用旧习惯,结果出现远处的物体点不中,调半天才发现是距离上限太小。遇到这类情况,先把距离参数设大两三个数量级,能立刻判断是不是这个原因。
4. 当场景里有一万根射线时,如何不把性能拖垮
4.1 先从“粗筛”开始,绝大多数物体不值得细算
射线求交本身不复杂,但一帧里有几百个物体、每个物体几千个三角形,逐个去做精细计算就麻烦了。比较好的做法是两阶段检测:先粗筛,后细算。
粗筛一般用包围盒。每个物体都预先算一个简化的AABB或球体,射线先和这些简单体做快速求交。如果连包围盒都碰不到,那就不需要继续看它内部的三角形了。这层筛选能杀掉绝大多数候选物体。
有的初学者会想:我直接对所有三角形做一遍Möller–Trumbore不就行了?可以,但没必要。一个十万三角形的Mesh要全部检查一遍,即使每个三角形计算只要五纳秒,加起来也要半毫秒。做完粗筛后,可能只有十几个三角形进入精细阶段,开销几乎可以忽略。
4.2 BVH/八叉树/网格索引:选型先看清查询模式
当射线数量也很多,比如光线追踪里一帧就有上百万条,光靠“遍历物体列表求包围盒”还是扛不住。这时候需要更高效的空间索引结构,常见的是BVH、八叉树和均匀网格。
BVH是一种层次包围盒树。每个物体先有一个包围盒,邻近的物体再合成一个更大的包围盒,不断向上归并,最终形成一棵树。检测射线时,从根节点开始往下走,如果一个节点的包围盒没被命中,整个子树都可以跳过。这种结构的优点是构建灵活,适用于静态和动态物体混合的场景。
八叉树则是把空间递归均匀切分为八个子空间,适合物体分布相对离散的场景。均匀网格适合物体密集且分布比较均匀的场合。选型没有绝对最优,关键是看你的查询模式:模型多不多、场景动不动物体、射线是随机方向还是固定方向。这些问题决定了哪种结构命中率高、重建成本能接受。
4.3 减少重复计算和分支发散:写给性能和热路径的优化
手写几何库时,除了选索引结构,还要注意代码层面的优化。第一,很多求交结果可以在粗筛阶段缓存一部分数据,比如光线方向的倒数;第二,要尽量避免在热路径里使用三角函数、除法、动态分配内存这类重操作。
这里的“热路径”可能是一帧里执行几十万次的更新循环。就拿方向向量归一化来说,如果一万根射线都从同一个起点出发、方向各不相同,每根都要做一次sqrt,CPU的压力会立刻体现出来。有些引擎会提供快速近似开方函数,在精度要求不高的场景可以试试,但最好先做性能剖析。
分支发散也是GPU和SIMD代码要关注的问题。不同的射线可能命中不同的物体,如果写成大量if-else,并行的线程会被迫执行所有分支。对这种大规模射线检测,可以改成“先收集数据,再统一处理”的形式,减小分支带来的性能惩罚。
4.4 一个可落地的两阶段检测伪代码
把两阶段检测逻辑整理成伪代码就是下面这样:
text复制hitCandidates = []
for obj in sceneObjects:
if rayHitsAABB(ray, obj.bounds):
hitCandidates.add(obj)
closestHit = None
closestT = INFINITY
for obj in hitCandidates:
hit = rayHitsMeshDetailed(ray, obj.mesh)
if hit && hit.t < closestT:
closestHit = obj
closestT = hit.t
return closestHit
注意第二阶段仍要比较t值,因为粗筛阶段通过AABB的物体不一定都真会被Mesh命中,而且射线终点应该取最近的那个,不能看遍历顺序。很多人在这一步偷懒,直接返回第一个检测到的结果,结果在物体重叠时出现“点A却选中后面的B”的怪异现象。
5. 同一个“射线交点”,在不同行业里换了多少张脸
5.1 游戏交互:Unity与Pico里射线点击UI的本质
手柄射线点击UI,最终的判断都可以抽象为“射线与UI所在的平面求交点,再判断交点是否落在UI元素的矩形范围内”。在Unity的UI系统里,这个方法被封装成了RectTransformUtility.ScreenPointToLocalPointInRectangle。
我早期接过PICO项目,按网上的教程用EventSystem和Physics Raycaster,但手柄的激光笔明明指在UI上,点击却没反应。后来发现PICO的手柄射线默认是从手柄模型前方向前打的,如果UI Canvas的Render Mode是World Space,需要把手柄的指针方向和Canvas平面求交,不能直接用Screen Space的表现坐标。
射线点击UI的关键点不是数学,而是坐标转换链路。屏幕坐标、世界坐标、UI本地坐标,环环相扣。任何一个环节的单位不统一,最后就会得到错误结果。
5.2 UE5射线障碍检测的通道过滤到底在过滤什么
在UE5里做“LineTrace”,看上去是发出一条射线看有没有挡路,但里面涉及通道和对象类型的过滤机制。实际追踪时,射线会和哪些Actor产生碰撞,取决于Actor的碰撞预设,比如Block、Overlap、Ignore。不是所有可见物体都会挡住射线,很多美术物体就应该设置为仅可见但不阻挡射线。
有几个坑比较典型。第一,TraceComplex设为false时,引擎用一个简化碰撞体去做检测,速度很快但对细碎物体不精确;设为true时用三角形的精确网格,准确但更慢。第二,多玩家联机时,射线检测可能需要在服务器和客户端分别执行,参数会决定行为是否一致。
UE5自带的蓝图节点即使不放任何代码,只要设好长度和碰撞通道,也能完成障碍检测。但它背后的原理依旧是射线与图元的求交,只是引擎帮你做了空间划分和过滤。
5.3 视觉软件里求两条直线的交点:共线和近似平行怎么处理
很多工业视觉软件,比如VisionMaster,都有“测量两条直线交点”的功能。它处理的是二维直线求交,但需要考虑的边界情况和射线求交非常像。
两条直线求交可以用参数法或行列式法。假设直线一经过点P1方向为d1,直线二经过点P2方向为d2,交点存在的条件是d1和d2不平行。判定平行常用叉积的模是否小于阈值,而不仅仅是等于0;两个方向如果夹角为0.001度,硬件计算和像素误差都会放大,算出来的交点可能抖得厉害。
工业项目中常见的误检是直线几乎平行,但算法强行求出了交点。这种交点即使返回给人眼也看着不自然。所以更稳妥的做法是,在求交前先计算两线夹角,如果小于预设角度,比如0.1度,就直接判定为失败,不再继续解方程。这就是把“几何可行性”判断放到“精确计算”之前的一个典型例子。
5.4 当“射线”变成X射线:工业检测设备里的几何设计
全反射X射线荧光分析仪里也有“射线”这个词,但它和计算机图形学的射线求交不完全是一回事。X射线以极小角度掠射到样品表面,发生全反射,只激发样品浅表层信息,而不进入深层。这里“入射角是否达到全反射临界角”决定分析能否成立,本质上还是在考虑一条射线到达界面后的行为。
设备设计时,X射线光源、样品台和探测器三者的位置关系,同样可以建模成一组几何关系。光源焦点可以看成射线起点,样品表面是一个平面,探测器窗口是一个目标区域,找出X射线光路是否与样品表面在有效区域产生交集,是硬件标定的一部分。把这种工业仪器的问题抽象成几何问题来思考,很多软件思路也能迁移过去,只是单位从米变成了纳米,精度要求从毫米级变成了原子级。
5.5 电气设计图纸里放射状出线,和射线求交有什么关系
天正电气里常说的“出线很多射线”,指的是从配电箱引出的一路上的导线像射线一样放射出去。在图纸上这些线经常会和墙体、设备图块交叉,CAD软件要自动处理避让和打断。这背后同样是二维平面上的线段相交检测,只不过它是通过大量图元和折线做几何求交来判断是否冲突。
CAD平台的开发里经常提到“碰撞检查”和“净距分析”,它们用到的线段与矩形相交、多段线与圆相交等算法,本质上也是射线和几何体求交的变体。开闭区间、容差、忽略端点接触这些细节,和游戏里的射线检测都是一模一样的。你在这里积累的求交经验,完全可以平移到另一个行业。
最后说一句排查经验
老实说,我见过太多同事遇到“射线打不中”就怀疑引擎Bug,或者不停加日志。我自己总结出的可靠流程是:先把射线起点、方向、目标表示清楚,自己画出来;然后用一个纯函数做最小复现;最后才去看引擎层API的默认行为。如果能把两个信息搞明白——你的射线是有向线段还是无限延伸、目标物体用的是哪种碰撞几何体——大部分问题在开写代码前就已经解决了。
还有一个小技巧分享给你们:调试时不要只盯着“有没有交点”,要在命中点打一个断点,或者画一个十字叉,把交点坐标、t值、命中的图元ID直接打印出来。很多时候问题出现在一排按钮上,但真实原因是所有按钮都命中了同一个不正确的UI平面。交点本身能说话,比猜管用得多。
