射线检测底层实现:那些相交算法到底怎么算
前面聊命中判定时,"射线检测"这四个字被反复提到。但射线到底是怎么"检测"到东西的?这一篇彻底钻到底层,讲清楚射线和各种几何体求交的数学与代码。
不用怕数学,我会把每个公式的来龙去脉讲明白,看完你自己就能手写一套。
一、射线是什么
先定义清楚。一条射线由两部分组成:
起点(Origin) + 方向(Direction)数学表达是一个参数方程:
P(t) = O + t * D (t >= 0)O是起点D是方向(必须是单位向量,长度为1)t是距离参数,t越大离起点越远
t >= 0这个约束很关键——它保证我们只看射线前方,不看背后。如果 t 允许为负,那就变成了一条无限长的直线,会把身后的东西也算进来。
structRay{Vector3 origin;Vector3 direction;// 记得归一化!};Vector3Ray::PointAt(floatt)const{returnorigin+direction*t;}第一个坑:方向没归一化
如果D不是单位向量,那么t就不再代表真实距离,后面所有的距离比较(“谁更近”)全部失效。写之前先normalize。
二、射线 vs 球体
从最简单的球体开始,因为它是理解其他所有算法的基础。
几何思路
球体上的点满足:“到球心的距离等于半径”。射线上的点是O + tD。我们要找的,就是射线上那个到球心距离正好等于半径的点。
球心C,半径r。射线上某点到球心的距离平方:
|P(t) - C|² = r²把P(t) = O + tD代进去,展开:
|O + tD - C|² = r²令m = O - C(从球心指向射线起点的向量),展开这个平方:
(m + tD)·(m + tD) = r²点乘展开:
t²(D·D) + 2t(m·D) + (m·m) - r² = 0因为 D 是单位向量,D·D = 1,于是变成一个标准的一元二次方程:
t² + 2(m·D)t + (m·m - r²) = 0对照at² + bt + c = 0:
a = 1 b = 2(m·D) c = m·m - r²判别式决定一切
初中数学:判别式Δ = b² - 4ac
Δ < 0:无解,射线没碰到球Δ = 0:一个解,射线正好擦过球(相切)Δ > 0:两个解,射线穿过球(进入点和穿出点)
代码实现
boolRaySphere(constRay&ray,Vector3 center,floatradius,float*tOut){Vector3 m=ray.origin-center;floatb=Dot(m,ray.direction);// 这里用简化形式,见下方说明floatc=Dot(m,m)-radius*radius;// 优化:如果起点在球外(c>0)且射线背离球(b>0),直接不可能命中if(c>0.0f&&b>0.0f)returnfalse;floatdiscriminant=b*b-c;if(discriminant<0.0f)returnfalse;// 判别式<0,没命中floatt=-b-sqrt(discriminant);// 取较小的t(先撞到的那个点)if(t<0.0f)t=0.0f;// 起点在球内部,从0开始*tOut=t;returntrue;}这里做了个化简:把方程两边同时处理成b = m·D(去掉了系数2),求根公式相应简化成t = -b ± √(b² - c)。数学上等价,但少几次乘法。这是图形学里的常见写法。
为什么取-b - √Δ:两个解里,较小的 t 是射线先撞到的那个面(进入点)。做命中判定我们要的就是最近的撞击点。
三、射线 vs 轴对齐盒子(AABB)
AABB(Axis-Aligned Bounding Box)就是边和坐标轴平行的盒子,粗筛阶段用得极多,因为它算得飞快。
核心思想:Slab方法
把盒子看成三对平行平面(“板子”)的交集:
- X方向一对:
x = xmin和x = xmax - Y方向一对
- Z方向一对
射线穿过盒子,等价于——射线进入这三对板子的区间有公共重叠部分。
对每个轴,算出射线进入和离开这对板子的 t 值:
t1 = (slabMin - origin) / direction t2 = (slabMax - origin) / direction然后取所有轴里"最晚进入"的时间和"最早离开"的时间:
tEnter = max(所有轴的进入时间) tExit = min(所有轴的离开时间)判定规则:如果tEnter <= tExit,射线命中盒子。反之,说明射线在某个轴上还没进,在另一个轴上已经出去了——没碰到。
代码实现
boolRayAABB(constRay&ray,Vector3 boxMin,Vector3 boxMax,float*tOut){floattEnter=0.0f;floattExit=FLT_MAX;// 对 x, y, z 三个轴分别处理for(inti=0;i<3;i++){floatorigin=ray.origin[i];floatdir=ray.direction[i];floatminB=boxMin[i];floatmaxB=boxMax[i];if(fabs(dir)<1e-6f){// 射线在这个轴上几乎平行于板子// 如果起点不在板子区间内,永远打不到if(origin<minB||origin>maxB)returnfalse;}else{floatinvD=1.0f/dir;floatt1=(minB-origin)*invD;floatt2=(maxB-origin)*invD;// 保证 t1 是进入,t2 是离开if(t1>t2)Swap(t1,t2);tEnter=Max(tEnter,t1);tExit=Min(tExit,t2);if(tEnter>tExit)returnfalse;// 提前退出}}*tOut=tEnter;returntrue;}关键坑:方向分量为0的处理
如果射线在某个轴上方向是0(比如水平射线的Y分量),那(minB-origin)/dir会除以0,得到无穷大或NaN,程序直接崩或者判定错乱。
上面代码里fabs(dir) < 1e-6f那段就是专门处理这个的:方向为0时,只要检查起点是否落在这个轴的区间内就行——落在区间内不影响判定,落在区间外直接返回false。
优化:1.0f / dir预计算
除法比乘法慢。如果同一条射线要测很多个盒子(游戏里常见),可以把1/direction提前算好存起来,循环里全用乘法。
四、射线 vs 胶囊体(Capsule)
胶囊体是FPS角色Hitbox最常用的形状——一个圆柱两端各接半个球。射线和它求交是这几个里最复杂的。
拆解思路
胶囊体 = 一条线段+ 一个半径。本质上,胶囊体是"到某条线段距离等于r的所有点"。
所以射线和胶囊求交,可以转化为:求射线上的点到胶囊中心线段的最近距离,看它是否 <= 半径。
严格解法需要解一个二次方程(射线和无限圆柱求交),再单独处理两端的半球盖,逻辑比较绕。这里给出工程上更常用的分段近似思路,容易理解也够用:
boolRayCapsule(constRay&ray,Vector3 A,Vector3 B,floatradius,float*tOut){// A, B 是胶囊中心线段的两个端点// 方法:把胶囊拆成【圆柱侧面】+【两端半球】分别测,取最近的floatbestT=FLT_MAX;boolhit=false;floatt;// 1. 测两端的球if(RaySphere(ray,A,radius,&t)&&t<bestT){bestT=t;hit=true;}if(RaySphere(ray,B,radius,&t)&&t<bestT){bestT=t;hit=true;}// 2. 测中间的圆柱侧面(下面详细讲)if(RayCylinderSide(ray,A,B,radius,&t)&&t<bestT){bestT=t;hit=true;}if(hit)*tOut=bestT;returnhit;}圆柱侧面部分
圆柱侧面求交的核心,是把问题投影到垂直于圆柱轴的平面上。设圆柱轴方向为axis = normalize(B - A):
boolRayCylinderSide(constRay&ray,Vector3 A,Vector3 B,floatradius,float*tOut){Vector3 axis=Normalize(B-A);Vector3 AO=ray.origin-A;// 把射线方向和AO分解为"沿轴"和"垂直轴"两部分floatdDotAxis=Dot(ray.direction,axis);floataoDotAxis=Dot(AO,axis);// 垂直于轴的分量(这才是决定是否碰到圆柱面的部分)Vector3 dPerp=ray.direction-axis*dDotAxis;Vector3 aoPerp=AO-axis*aoDotAxis;// 又是一个一元二次方程 at²+bt+c=0floata=Dot(dPerp,dPerp);floatb=2.0f*Dot(dPerp,aoPerp);floatc=Dot(aoPerp,aoPerp)-radius*radius;if(fabs(a)<1e-6f)returnfalse;// 射线平行于轴,侧面打不到floatdisc=b*b-4*a*c;if(disc<0)returnfalse;floatt=(-b-sqrt(disc))/(2*a);if(t<0)returnfalse;// 检查命中点是否落在圆柱的高度范围内(不是在两个半球盖的范围)Vector3 hitPoint=ray.PointAt(t);floatprojection=Dot(hitPoint-A,axis);if(projection<0||projection>Length(B-A))returnfalse;// 超出圆柱段,交给半球去处理*tOut=t;returntrue;}这个思路的巧妙之处:把三维圆柱问题,通过"去掉沿轴分量"降维成了二维圆的问题,又回到了熟悉的一元二次方程。
注意projection那个检查:圆柱侧面命中的点,必须落在 A 到 B 之间的高度范围内。如果超出去了,说明真正命中的应该是端部的半球,这部分已经由前面的 RaySphere 处理了。
五、射线 vs 三角形(精确到模型面)
如果你要做超精确的判定(比如打中枪械模型的某个部件),就得直接测三角形。经典算法是Möller–Trumbore,图形学必学。
核心思想
用重心坐标表示三角形内的点。三角形三个顶点 V0、V1、V2,内部任意点可以写成:
P = V0 + u*(V1-V0) + v*(V2-V0)其中u >= 0,v >= 0,u + v <= 1时,点在三角形内部。
让它等于射线方程O + tD,得到三个未知数t, u, v,三个方程,用克拉默法则解出来。
代码实现
boolRayTriangle(constRay&ray,Vector3 v0,Vector3 v1,Vector3 v2,float*tOut,float*uOut,float*vOut){constfloatEPSILON=1e-7f;Vector3 edge1=v1-v0;Vector3 edge2=v2-v0;Vector3 h=Cross(ray.direction,edge2);floata=Dot(edge1,h);// a接近0:射线平行于三角形,打不到if(fabs(a)<EPSILON)returnfalse;floatf=1.0f/a;Vector3 s=ray.origin-v0;floatu=f*Dot(s,h);if(u<0.0f||u>1.0f)returnfalse;// u越界,不在三角形内Vector3 q=Cross(s,edge1);floatv=f*Dot(ray.direction,q);if(v<0.0f||u+v>1.0f)returnfalse;// v越界floatt=f*Dot(edge2,q);if(t>EPSILON){// t>0 命中射线前方*tOut=t;*uOut=u;*vOut=v;returntrue;}returnfalse;// 交点在射线背后}为什么FPS里很少直接用三角形判定角色:一个角色模型上万个三角形,逐个测太慢。所以实战中角色判定用胶囊/盒子,只有静态场景(墙、地面)或者需要极致精度的地方才会退化到三角形级别。
六、性能优化的几层套路
底层算法讲完了,实战中怎么让它跑得快?核心是尽量少做精确计算。
层次1:空间划分
不要每次射线都遍历全场所有物体。用空间数据结构提前把场景切块:
- BVH(包围盒层次树):物体多且动态,最常用
- 八叉树(Octree):适合3D场景静态物体
- 网格(Uniform Grid):分布均匀时简单高效
射线进来,先问数据结构"我这条线可能碰到哪些块",只测那几块里的东西。
层次2:由粗到精
射线来了 → 先测大AABB(超快,排除绝大多数) → 通过的再测胶囊/球 → 需要极致精度的才测三角形每一层都在过滤,越往后越精确也越慢,但要测的对象越来越少。
层次3:算法内提前退出
前面代码里其实已经埋了很多:
if(c>0.0f&&b>0.0f)returnfalse;// 球体:一眼排除if(tEnter>tExit)returnfalse;// AABB:中途发现不可能就退在能确定结果的那一刻立刻 return,别做完所有计算。
层次4:减少除法和开方
- 除法预计算成乘法(
1/dir) - 能比较平方就别开方(比距离用
dist²比,避免sqrt)
// 差:if (Length(v) < radius)// 好:if (LengthSquared(v) < radius * radius) 省一个sqrt七、几个容易翻车的细节
1. EPSILON 的选择
代码里到处是1e-6f、1e-7f这种小量,用来判断"接近0"。选太大会漏判,选太小浮点误差又会误判。这个值需要根据你游戏的单位尺度(用米还是厘米)来调,没有万能值。
2. 射线起点在物体内部
比如角色枪口正好卡在墙里开枪,射线起点在墙体内。这时候有的算法会返回负的 t 或者行为异常。要专门处理"起点在内部"的情况(前面球体代码里if(t<0) t=0就是干这个的)。
3. 浮点精度导致的边界抖动
物体正好在射线擦边的位置时,浮点误差可能让判定在"中"和"不中"之间反复横跳。表现就是玩家贴着边缘时命中时有时无。通常靠给Hitbox留一点余量来缓解。
4. 别忘了 t 的上限
武器有射程。就算射线数学上命中了,如果t > 武器最大射程,也应该算未命中。检测时把 maxDistance 传进去卡住。
写在最后
射线检测的底层,说穿了就是射线方程和各种几何体方程联立求解。球体和圆柱最后都归结到一元二次方程,AABB用区间重叠,三角形用重心坐标——数学都不算难,难的是把边界情况处理干净、把性能优化到位。
我的建议:先手写一遍球体和AABB的求交,把每一步的几何意义搞懂。这两个吃透了,胶囊、圆柱、OBB(有向盒子)都是变体,触类旁通。
真到了引擎里,这些底层其实 PhysX、Bullet 这些物理库都帮你写好了。但理解底层,你才知道什么时候该用哪种形状、为什么某个判定会出bug、性能瓶颈在哪。这才是自己写一遍的真正价值。
