当前位置: 首页 > news >正文

射线检测底层实现:那些相交算法到底怎么算

前面聊命中判定时,"射线检测"这四个字被反复提到。但射线到底是怎么"检测"到东西的?这一篇彻底钻到底层,讲清楚射线和各种几何体求交的数学与代码

不用怕数学,我会把每个公式的来龙去脉讲明白,看完你自己就能手写一套。


一、射线是什么

先定义清楚。一条射线由两部分组成:

起点(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 = xminx = 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-6f1e-7f这种小量,用来判断"接近0"。选太大会漏判,选太小浮点误差又会误判。这个值需要根据你游戏的单位尺度(用米还是厘米)来调,没有万能值。

2. 射线起点在物体内部

比如角色枪口正好卡在墙里开枪,射线起点在墙体内。这时候有的算法会返回负的 t 或者行为异常。要专门处理"起点在内部"的情况(前面球体代码里if(t<0) t=0就是干这个的)。

3. 浮点精度导致的边界抖动

物体正好在射线擦边的位置时,浮点误差可能让判定在"中"和"不中"之间反复横跳。表现就是玩家贴着边缘时命中时有时无。通常靠给Hitbox留一点余量来缓解。

4. 别忘了 t 的上限

武器有射程。就算射线数学上命中了,如果t > 武器最大射程,也应该算未命中。检测时把 maxDistance 传进去卡住。


写在最后

射线检测的底层,说穿了就是射线方程和各种几何体方程联立求解。球体和圆柱最后都归结到一元二次方程,AABB用区间重叠,三角形用重心坐标——数学都不算难,难的是把边界情况处理干净、把性能优化到位。

我的建议:先手写一遍球体和AABB的求交,把每一步的几何意义搞懂。这两个吃透了,胶囊、圆柱、OBB(有向盒子)都是变体,触类旁通。

真到了引擎里,这些底层其实 PhysX、Bullet 这些物理库都帮你写好了。但理解底层,你才知道什么时候该用哪种形状、为什么某个判定会出bug、性能瓶颈在哪。这才是自己写一遍的真正价值。


http://www.cnnetsun.cn/news/4137282.html

相关文章:

  • tiktok-uploader 进阶技巧:自定义封面、私密发布与商品链接一键添加
  • 物联网技术目录
  • DeepSeek Harness 零基础上手:10分钟让智能体框架跑起来并挂载你的第一个插件
  • Easy-Es性能优化指南:提升Elasticsearch查询效率的10个技巧
  • 为什么Vespene停止开发?Ansible作者Michael DeHaan的CI/CD项目兴衰启示
  • JupyterLab Desktop 快速上手:3 个真实场景玩转 Python 环境管理
  • 团队协作必备:nypm + corepack 锁定包管理器版本的完整指南
  • AI论文写作工具最全盘点:语法+润色+降AI率,一篇全搞定
  • 一张随手拍,凭什么挂上墙?试试这个会“做减法“的照片转抽象艺术工具
  • Vespene自动扩缩容实战:按需伸缩Worker集群降低云端成本
  • 从零到上手:免费引导工具让老Mac流畅跑新系统
  • build2 模块体系全解析:config、test、install、version 核心模块指南
  • 2026残酷现实:别盲目跟风MCP!CLI悄悄崛起,码农真正的危机来了
  • Python如何进入帮助模式
  • 测试驱动开发:用 Docker 搭建 deno-postgres 三种认证模式测试环境
  • rust-ctrlc 快速入门:5 分钟让 Rust CLI 程序优雅响应 Ctrl-C
  • Whoosh核心原理:倒排索引的构建、存储与查询全解析
  • 个人微信API二次开发:消息撤回两分钟窗口
  • mybatis-generator-gui-extension 代码合并机制解密:重新生成代码为何不再丢失手写逻辑
  • 动画生成异常时怎样保留可用体验
  • 如何消除Shotlooter误报?高熵字符串与信用卡检测的3个调优技巧
  • AI生成人物如何摆脱僵硬感?Seedance2提示词撰写实战指南
  • 告别静态建模!镜像视界带你迈入实时三维重构的“时空计算”新纪元
  • 手写数字识别:线性判别分析与逻辑回归的模型对比与实践
  • 【storage】
  • 网络同步的奥秘:Jazz² Resurrection 在线多人架构深度解析(ENet/WebSocket/状态插值)
  • build2 构建语言进阶:函数、变量展开、条件与循环完全教程
  • 框架降级的实现路径
  • 043、RT-X开源跨机器人数据集:数据多样性与策略泛化能力
  • svelte-motion useSpring 弹性动画教程:为 Svelte UI 注入自然物理手感