射线检测底层实现:那些相交算法到底怎么算 前面聊命中判定时射线检测这四个字被反复提到。但射线到底是怎么检测到东西的这一篇彻底钻到底层讲清楚射线和各种几何体求交的数学与代码。不用怕数学我会把每个公式的来龙去脉讲明白看完你自己就能手写一套。一、射线是什么先定义清楚。一条射线由两部分组成起点Origin 方向Direction数学表达是一个参数方程P(t) O t * D (t 0)O是起点D是方向必须是单位向量长度为1t是距离参数t越大离起点越远t 0这个约束很关键——它保证我们只看射线前方不看背后。如果 t 允许为负那就变成了一条无限长的直线会把身后的东西也算进来。structRay{Vector3 origin;Vector3 direction;// 记得归一化};Vector3Ray::PointAt(floatt)const{returnorigindirection*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 0a 1 b 2(m·D) c m·m - r²判别式决定一切初中数学判别式Δ b² - 4acΔ 0无解射线没碰到球Δ 0一个解射线正好擦过球相切Δ 0两个解射线穿过球进入点和穿出点代码实现boolRaySphere(constRayray,Vector3 center,floatradius,float*tOut){Vector3 mray.origin-center;floatbDot(m,ray.direction);// 这里用简化形式见下方说明floatcDot(m,m)-radius*radius;// 优化如果起点在球外(c0)且射线背离球(b0)直接不可能命中if(c0.0fb0.0f)returnfalse;floatdiscriminantb*b-c;if(discriminant0.0f)returnfalse;// 判别式0没命中floatt-b-sqrt(discriminant);// 取较小的t先撞到的那个点if(t0.0f)t0.0f;// 起点在球内部从0开始*tOutt;returntrue;}这里做了个化简把方程两边同时处理成b m·D去掉了系数2求根公式相应简化成t -b ± √(b² - c)。数学上等价但少几次乘法。这是图形学里的常见写法。为什么取-b - √Δ两个解里较小的 t 是射线先撞到的那个面进入点。做命中判定我们要的就是最近的撞击点。三、射线 vs 轴对齐盒子AABBAABBAxis-Aligned Bounding Box就是边和坐标轴平行的盒子粗筛阶段用得极多因为它算得飞快。核心思想Slab方法把盒子看成三对平行平面“板子”的交集X方向一对x xmin和x xmaxY方向一对Z方向一对射线穿过盒子等价于——射线进入这三对板子的区间有公共重叠部分。对每个轴算出射线进入和离开这对板子的 t 值t1 (slabMin - origin) / direction t2 (slabMax - origin) / direction然后取所有轴里最晚进入的时间和最早离开的时间tEnter max(所有轴的进入时间) tExit min(所有轴的离开时间)判定规则如果tEnter tExit射线命中盒子。反之说明射线在某个轴上还没进在另一个轴上已经出去了——没碰到。代码实现boolRayAABB(constRayray,Vector3 boxMin,Vector3 boxMax,float*tOut){floattEnter0.0f;floattExitFLT_MAX;// 对 x, y, z 三个轴分别处理for(inti0;i3;i){floatoriginray.origin[i];floatdirray.direction[i];floatminBboxMin[i];floatmaxBboxMax[i];if(fabs(dir)1e-6f){// 射线在这个轴上几乎平行于板子// 如果起点不在板子区间内永远打不到if(originminB||originmaxB)returnfalse;}else{floatinvD1.0f/dir;floatt1(minB-origin)*invD;floatt2(maxB-origin)*invD;// 保证 t1 是进入t2 是离开if(t1t2)Swap(t1,t2);tEnterMax(tEnter,t1);tExitMin(tExit,t2);if(tEntertExit)returnfalse;// 提前退出}}*tOuttEnter;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(constRayray,Vector3 A,Vector3 B,floatradius,float*tOut){// A, B 是胶囊中心线段的两个端点// 方法把胶囊拆成【圆柱侧面】【两端半球】分别测取最近的floatbestTFLT_MAX;boolhitfalse;floatt;// 1. 测两端的球if(RaySphere(ray,A,radius,t)tbestT){bestTt;hittrue;}if(RaySphere(ray,B,radius,t)tbestT){bestTt;hittrue;}// 2. 测中间的圆柱侧面下面详细讲if(RayCylinderSide(ray,A,B,radius,t)tbestT){bestTt;hittrue;}if(hit)*tOutbestT;returnhit;}圆柱侧面部分圆柱侧面求交的核心是把问题投影到垂直于圆柱轴的平面上。设圆柱轴方向为axis normalize(B - A)boolRayCylinderSide(constRayray,Vector3 A,Vector3 B,floatradius,float*tOut){Vector3 axisNormalize(B-A);Vector3 AOray.origin-A;// 把射线方向和AO分解为沿轴和垂直轴两部分floatdDotAxisDot(ray.direction,axis);floataoDotAxisDot(AO,axis);// 垂直于轴的分量这才是决定是否碰到圆柱面的部分Vector3 dPerpray.direction-axis*dDotAxis;Vector3 aoPerpAO-axis*aoDotAxis;// 又是一个一元二次方程 at²btc0floataDot(dPerp,dPerp);floatb2.0f*Dot(dPerp,aoPerp);floatcDot(aoPerp,aoPerp)-radius*radius;if(fabs(a)1e-6f)returnfalse;// 射线平行于轴侧面打不到floatdiscb*b-4*a*c;if(disc0)returnfalse;floatt(-b-sqrt(disc))/(2*a);if(t0)returnfalse;// 检查命中点是否落在圆柱的高度范围内不是在两个半球盖的范围Vector3 hitPointray.PointAt(t);floatprojectionDot(hitPoint-A,axis);if(projection0||projectionLength(B-A))returnfalse;// 超出圆柱段交给半球去处理*tOutt;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(constRayray,Vector3 v0,Vector3 v1,Vector3 v2,float*tOut,float*uOut,float*vOut){constfloatEPSILON1e-7f;Vector3 edge1v1-v0;Vector3 edge2v2-v0;Vector3 hCross(ray.direction,edge2);floataDot(edge1,h);// a接近0射线平行于三角形打不到if(fabs(a)EPSILON)returnfalse;floatf1.0f/a;Vector3 sray.origin-v0;floatuf*Dot(s,h);if(u0.0f||u1.0f)returnfalse;// u越界不在三角形内Vector3 qCross(s,edge1);floatvf*Dot(ray.direction,q);if(v0.0f||uv1.0f)returnfalse;// v越界floattf*Dot(edge2,q);if(tEPSILON){// t0 命中射线前方*tOutt;*uOutu;*vOutv;returntrue;}returnfalse;// 交点在射线背后}为什么FPS里很少直接用三角形判定角色一个角色模型上万个三角形逐个测太慢。所以实战中角色判定用胶囊/盒子只有静态场景墙、地面或者需要极致精度的地方才会退化到三角形级别。六、性能优化的几层套路底层算法讲完了实战中怎么让它跑得快核心是尽量少做精确计算。层次1空间划分不要每次射线都遍历全场所有物体。用空间数据结构提前把场景切块BVH包围盒层次树物体多且动态最常用八叉树Octree适合3D场景静态物体网格Uniform Grid分布均匀时简单高效射线进来先问数据结构我这条线可能碰到哪些块只测那几块里的东西。层次2由粗到精射线来了 → 先测大AABB超快排除绝大多数 → 通过的再测胶囊/球 → 需要极致精度的才测三角形每一层都在过滤越往后越精确也越慢但要测的对象越来越少。层次3算法内提前退出前面代码里其实已经埋了很多if(c0.0fb0.0f)returnfalse;// 球体一眼排除if(tEntertExit)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(t0) t0就是干这个的。3. 浮点精度导致的边界抖动物体正好在射线擦边的位置时浮点误差可能让判定在中和不中之间反复横跳。表现就是玩家贴着边缘时命中时有时无。通常靠给Hitbox留一点余量来缓解。4. 别忘了 t 的上限武器有射程。就算射线数学上命中了如果t 武器最大射程也应该算未命中。检测时把 maxDistance 传进去卡住。写在最后射线检测的底层说穿了就是射线方程和各种几何体方程联立求解。球体和圆柱最后都归结到一元二次方程AABB用区间重叠三角形用重心坐标——数学都不算难难的是把边界情况处理干净、把性能优化到位。我的建议先手写一遍球体和AABB的求交把每一步的几何意义搞懂。这两个吃透了胶囊、圆柱、OBB有向盒子都是变体触类旁通。真到了引擎里这些底层其实 PhysX、Bullet 这些物理库都帮你写好了。但理解底层你才知道什么时候该用哪种形状、为什么某个判定会出bug、性能瓶颈在哪。这才是自己写一遍的真正价值。