FEATURED · 精选文章

Unity Raycast射线检测:点击拾取、LayerMask与防穿墙实战

发布时间 / 2026/9/18 4:52:04
来源 / 创域科博编辑部
栏目 / 资讯中心
Unity Raycast射线检测:点击拾取、LayerMask与防穿墙实战 1. 把射线这件事先想明白它解决的到底是什么问题做Unity交互的人早晚都会撞上射线检测。鼠标点击拾取物体、角色贴合地面、相机防穿墙、VR手柄指向目标、数字孪生里点选一栋楼背后跑的都是同一套东西。Raycast射线检测本质上是给物理世界做一次提问从某个点朝某个方向发一条无限细的线问引擎这条线上第一个被挡住的碰撞体是谁在什么位置法线朝哪。它不产生任何渲染、不产生任何物理力只是一次纯粹的几何查询所以开销可控、结果即时非常适合放在Update里做高频交互判断。但很多人第一次用它都会卡住明明屏幕上点中了那栋楼日志里却是空的明明射线画出来了就是碰不到Trigger代码在编辑器里跑得好好的打包到移动端触摸又失效了。这些问题几乎都不是API写错了而是对射线打到什么这件事的边界没弄清楚——LayerMask排掉了、起点在碰撞体内部、2D和3D物理是两套系统、UI把点击吃了。这篇就把Unity里射线检测这一整条链路拆开讲从概念、API参数、可复用模块到排查表和一个完整落地场景尽量让你看完就能直接抄进项目里用。1.1 从一次鼠标点击说起射线的几何直觉屏幕是二维的场景是三维的这中间缺一个映射关系射线就是这座桥。当你调用Camera.ScreenPointToRayUnity做的其实是反向投影把屏幕上那个像素点沿着相机的投影矩阵倒推还原成世界空间里一条从近裁剪面出发、指向远方的线。摄像机越远同一条屏幕射线覆盖的世界范围越宽所以同一个点击在近处和远处命中的东西完全不同——这也是为什么透视相机下点击拾取的精度会随着距离迅速下降。理解了这一点后面很多问题就顺了。比如为什么屏幕边缘的拾取容易偏因为透视投影在边缘是非线性的为什么正交相机做拾取特别准因为它的射线互相平行没有透视收缩。我一般会先在脑子里画这条线起点、方向、能走多远、路上会被哪些层拦住。这四个参数想清楚了Debug打不出来结果的情况基本能自己定位。1.2 Ray、RaycastHit、LayerMask 三个角色的职责边界初学者容易把这些概念揉成一团实际它们分工很清楚。Ray是输入只描述从哪出发、朝哪走一个结构体包含origin和direction两个向量RaycastHit是输出只有命中了才有意义里面装着point世界空间命中点、normal命中面法线、distance沿射线方向的距离、collider、rigidbody、transform、textureCoord这些信息LayerMask是过滤器是一个32位的整型掩码决定这次查询只问哪些层。RaycastHit里最被低估的是normal和textureCoord。normal决定了物体表面朝向做贴地、做贴花、做弹孔朝向全靠它textureCoord返回命中点在主纹理上的UV坐标做地形喷涂、地面血迹、程序化贴花时不用它就得自己算投影麻烦得多。这两个字段很多人用了几年射线都没碰过其实在白送的。LayerMask则是最该重视的性能开关。Unity的物理查询会在内部遍历候选碰撞体掩码收得越窄参与测试的物体越少。默认的DefaultRaycastLayers会命中除Ignore Raycast以外的所有层看起来很省事实际在几百个物体的场景里就是浪费。养成习惯每写一次Raycast先问自己我到底想打到什么然后给它一个专属层。1.3 为什么不用 OnMouseDown 和 Trigger三种交互方案怎么选Unity至少提供了三条做点击交互的路子。OnMouseDown/OnMouseEnter最简单挂个脚本就能响应但它依赖主相机对碰撞体的屏幕投影计算且无法控制能打中什么、多远算数物体一多就容易误触发另外它在移动端触摸上的表现也和预期不一致。Trigger是让物体会穿过一个区域来触发回调适合踩到陷阱进入房间这类体感判断但它检测的是体积重叠不是视线做不了我指着远处的东西。射线检测补的正是这两者的短板精确控制方向、距离、层返回命中细节还能只做查询不改变物理状态。所以我的判断标准很直接——需要从某个视角看向某个点的交互用射线需要物体之间是否重叠的判断用Trigger只是在做临时的原型验证OnMouseDown可以用但别带进正式版本。注意OnMouseDown在移动端需要触摸事件配合且它对UI元素没有优先级概念容易被UI下面的物体抢到。正式项目里点击拾取一律走射线加层过滤。2. Physics.Raycast 家族API逐参数拆解2.1 origin、direction、maxDistance三个最容易写错的参数最常用的重载长这样Physics.Raycast(Vector3 origin, Vector3 direction, out RaycastHit hitInfo, float maxDistance, int layerMask, QueryTriggerInteraction queryTriggerInteraction)origin的坑在于坐标系。transform.position是世界坐标可以直接用transform.localPosition是相对父物体的拿它当起点物体只要不在原点附近就会打空。带缩放和旋转的父节点下坐标混用是新手最常见的翻车点。direction不需要手动归一化Unity内部会处理但maxDistance的解释依赖方向向量的语义。习惯上直接给归一化方向加一个米数比如Vector3.down配10f表示向下10米。如果写transform.forward * 5f当方向、又给maxDistance 5f实际会打两倍距离这是很隐蔽的重复计算。我的做法是方向永远归一化距离单独给。maxDistance不传默认是Mathf.Infinity也就是无限远。这听起来方便实际是个性能陷阱一条无限长的射线会穿透整个场景直到命中如果你的掩码没设好它可能一路打到远处的背景碰撞体上。凡是能确定合理距离的场景都给它一个具体值比如地面检测给2到3米点击拾取给200米。2.2 LayerMask性能与逻辑的第一道闸门LayerMask的设计思路是用一次位运算代替一次遍历判断。32位int位为1代表该层参与本次查询。写起来有三种常见姿势// 方式一Inspector里配置最推荐策划也能改 [SerializeField] private LayerMask groundMask; // 方式二代码里按名字拼灵活但有字符串开销 int mask LayerMask.GetMask(Ground, Obstacle); // 方式三直接位运算性能最好可读性最差 int mask (1 8) | (1 9);我不建议在Update里调用LayerMask.GetMask或LayerMask.NameToLayer它们内部涉及字符串比较每帧调用是纯浪费。要么在Awake里缓存成int要么直接在Inspector暴露LayerMask字段——后者还有个额外好处策划自己就能调整哪些东西可被点击不用找程序改代码。有个反直觉的细节传0给layerMask并不会命中所有层而是什么都不命中。想要全部要传~0或者Physics.AllLayers。另外Ignore Raycast这个内置层Layer 2默认被DefaultRaycastLayers排除很多我的物体加了Collider却打不中的问题最后都发现是物体的层被误设成了Ignore Raycast。2.3 QueryTriggerInteraction 与 queriesHitTriggers 的全局开关2017版之后Unity加入了QueryTriggerInteraction枚举用来控制这次查询要不要命中Trigger。三个取值分别是UseGlobal跟随项目设置、Collide命中、Ignore忽略。以前没有这个参数时行为完全由Edit Project Settings Physics Queries Hit Triggers这个全局开关决定改一次影响全项目非常难排查。我的建议是永远显式传值不要用UseGlobal。理由很实在这个全局设置在多人协作时极容易被改而且一改就是全项目生效等到有人发现触发器突然能被射线打中了再回头找成本很高。显式写Ignore或Collide语义清楚也不会被别人的设置影响。做拾取、做视线遮挡检测一般用Ignore做点击触发区域这类需求用Collide。2.4 2D 物理与 UI 射线的两条平行线这里要划一条清晰的分界线Physics.Raycast3D物理和Physics2D.Raycast2D物理是完全独立的两套系统用的碰撞体类型、返回类型、层掩码体系都不通用。3D射线打不中Sprite的BoxCollider2D2D射线也打不中BoxCollider这是硬性隔离。混用2D和3D物理的场景比如2D角色配3D特效里这一点必须时刻记住。UI是第三条线。UGUI的点击不走物理系统走的是EventSystem加GraphicRaycaster这套事件系统。它同样基于射线但射线是在Canvas自己的空间里算的。所以在代码里做屏幕点击拾取必须先问一句EventSystem.current.IsPointerOverGameObject()——如果指针正压在UI上就不要再往场景里发物理射线了否则点个按钮会同时触发按钮和按钮后面的东西这类UI穿透的Bug在移动端特别常见。// UI优先判断当前指针是否落在UI上移动端记得传fingerId if (EventSystem.current ! null EventSystem.current.IsPointerOverGameObject()) { return; // 被UI吃掉不往下走 }3. 从零搭一套可复用的射线检测模块3.1 屏幕点击拾取ScreenPointToRay 的正确写法与包围盒扩展拾取的标准流程就四步判断是否是有效点击、排除UI、生成射线、执行查询。写成组件大概是这样using UnityEngine; using UnityEngine.EventSystems; public class ClickPicker : MonoBehaviour { [SerializeField] private Camera cam; [SerializeField] private LayerMask pickableMask; [SerializeField] private float maxDistance 200f; private void Update() { if (!Input.GetMouseButtonDown(0)) return; if (EventSystem.current ! null EventSystem.current.IsPointerOverGameObject()) return; Ray ray cam.ScreenPointToRay(Input.mousePosition); if (Physics.Raycast(ray, out RaycastHit hit, maxDistance, pickableMask, QueryTriggerInteraction.Ignore)) { Debug.Log($命中 {hit.collider.name} / 距离 {hit.distance:F2} / 法线 {hit.normal}); Debug.DrawRay(ray.origin, ray.direction * hit.distance, Color.green, 1f); } else { Debug.DrawRay(ray.origin, ray.direction * 20f, Color.red, 1f); } } }这里有个提升点击手感的技巧。细长的物体电线杆、栏杆、远处的小图标用真实Collider做拾取会非常难打中玩家要瞄得很准。解决办法是给这些物体额外挂一个尺寸放大几倍的BoxCollider放在专门的Pickable层上只用于射线检测不参与实际碰撞。这样视觉上物体没变点击容差却大了一圈。还有一种做法是用Renderer.bounds动态生成一个临时包围盒适合程序化生成、Collider不方便预挂的场景。提示拾取用的碰撞体一定要和物理碰撞体分开。如果共用一个Collider并把尺寸放大角色会被看不见的空气墙挡住这类问题排查起来很折磨人。3.2 悬停高亮把命中结果缓存下来别每帧新建数组悬停高亮看起来简单但很多人写出来的版本每帧都在分配内存。如果你用Physics.RaycastAll它每次调用都会new一个数组出来几十个物体同时悬停GC压力就上来了。正确姿势是用Physics.RaycastNonAlloc配合一个预分配的缓冲区private readonly RaycastHit[] buffer new RaycastHit[16]; private readonly RaycastHitComparer comparer new RaycastHitComparer(); private class RaycastHitComparer : System.Collections.Generic.IComparerRaycastHit { public int Compare(RaycastHit a, RaycastHit b) a.distance.CompareTo(b.distance); } private void HoverCheck() { Ray ray cam.ScreenPointToRay(Input.mousePosition); int count Physics.RaycastNonAlloc(ray, buffer, maxDistance, pickableMask, QueryTriggerInteraction.Ignore); if (count 0) { ClearHighlight(); return; } // NonAlloc 和 RaycastAll 一样返回顺序不保证需要自己按距离排序 System.Array.Sort(buffer, 0, count, comparer); SetHighlight(buffer[0].collider.gameObject); }RaycastNonAlloc的返回顺序在文档里是未定义的别指望它是按距离排好的。全穿透查询之后手动排一次比踩偶尔高亮错物体这种偶发Bug省事得多。缓冲区大小我一般给16如果命中数常常逼近这个值说明场景里可拾取的物体太密了要么收窄掩码要么改用一次性收集候选再距离筛选。3.3 角色贴地与斜坡对齐法线与距离参数的推导贴地检测是射线的另一个高频用法难点在于防止抖动和穿透。思路是从角色腰部向上、或从头顶向下发一条短射线拿到地面命中点和法线然后把角色位置和朝向对齐到地面。参数怎么定假设角色胶囊体高度1.8米、半径0.3米往下打的射线起点放在transform.position Vector3.up * 1.0f长度2.0f这样能覆盖从站立到小台阶落差的整个范围又不会误打到下方另一层地板。Vector3 origin transform.position Vector3.up * 1.0f; if (Physics.Raycast(origin, Vector3.down, out RaycastHit hit, 2.0f, groundMask, QueryTriggerInteraction.Ignore)) { // 抬高一点避免和地面碰撞体产生细微穿插导致抖动 transform.position hit.point Vector3.up * 0.05f; // 用地面法线算倾斜再叠加角色自身的偏航角 Quaternion slopeAlign Quaternion.FromToRotation(Vector3.up, hit.normal); Quaternion targetRot slopeAlign * Quaternion.Euler(0f, currentYaw, 0f); transform.rotation Quaternion.Slerp(transform.rotation, targetRot, Time.deltaTime * 10f); }Quaternion.FromToRotation(Vector3.up, hit.normal)这一句是核心它把世界上方向旋到地面法线方向得到的就是贴合斜坡所需的倾斜。再乘上角色自己的偏航旋转顺序不能反——先倾斜后偏航角色才会沿着坡面走而不是斜着一头扎进地面。平滑用Slerp而不是直接赋值否则在台阶边缘法线突变时角色会瞬间抽搐。3.4 第三人称相机防穿墙从目标点反向发射第三人称跟随最烦的就是相机穿到墙里面去。做法是先算出相机想要到达的理想位置然后从角色头部往这个理想位置发一条射线或者SphereCast效果更稳如果中间有障碍就把相机拉到障碍前面。Vector3 pivot target.position Vector3.up * 1.5f; Vector3 dir (desiredPos - pivot); float dist dir.magnitude; if (Physics.SphereCast(pivot, 0.2f, dir.normalized, out RaycastHit hit, dist, obstacleMask, QueryTriggerInteraction.Ignore)) { // 留一点余量避免相机贴在墙面上被近裁剪面裁掉 float safe Mathf.Max(hit.distance - 0.05f, 0.3f); desiredPos pivot dir.normalized * safe; } cam.position Vector3.Lerp(cam.position, desiredPos, Time.deltaTime * 12f);为什么用SphereCast而不是Raycast因为相机的近裁剪面是有体积的一条零宽度的射线可能穿过墙角的缝隙但相机的视锥体已经露出去了。把半径设成0.2米左右相当于给相机包了一层球形体积能有效减少半个画面卡进墙里的情况。插值用Lerp做平滑但要注意拉近的时候要快拉远的时候要慢否则贴墙转身时相机会频繁闪烁。生产上常见做法是给拉近用更大的插值系数。3.5 调试射线Debug.DrawRay 与 Gizmos 的配合射线代码出问题最高效的手段是把它画出来。Debug.DrawRay(origin, direction, color, duration)是最常用的duration给0表示只画一帧给1表示持续一秒。配合Debug.DrawLine画命中点能直观看到射线到底走到哪、是在哪被挡住的。Debug.DrawRay(ray.origin, ray.direction * maxDistance, Color.yellow, 0f); if (hit.collider ! null) { Debug.DrawRay(hit.point, hit.normal * 0.5f, Color.cyan, 1f); // 法线可视化 Debug.DrawLine(ray.origin, hit.point, Color.green, 0f); }一个小经验把所有射线绘制代码包在#if UNITY_EDITOR或[Conditional(UNITY_EDITOR)]的方法里。Debug.DrawRay在真机构建下会被剥离但如果你顺手写了字符串拼接或者对象查找那部分不会被自动去掉白白拖慢性能。4. 踩坑实录与排查速查表4.1 射线明明对着物体却打不中九个高频原因这张表是我这些年积累下来的按出现频率排序遇到打不中的情况从上往下核对基本能定位排查项典型现象处理方式层不在LayerMask里部分物体能打中部分完全不行检查Inspector上的mask字段确认目标层已勾选物体层是Ignore Raycast只这一个物体始终打不中换成Default或专属层没有Collider物体有MeshFilter但没碰撞体加Collider或给MeshCollider赋MeshCollider被禁用代码里SetActive/Enabled关掉了检查enabled与父物体激活状态Trigger被忽略只有Trigger打不中传QueryTriggerInteraction.Collide起点在碰撞体内部从物体内部向外打打不中自身挪起点或开启Physics.queriesHitBackfaces距离不够远处物体偶尔打不中打印hit.distance对比maxDistance2D/3D混用2D精灵怎么都打不中改用Physics2D.Raycast缩放为负极少数情况射线方向反向检查父节点scale是否有负值排序靠前的几项覆盖了八成以上的实际故障。我的习惯是在射线代码里临时加一句Debug.Log把origin、direction、maxDistance、mask四个值全打出来很多时候看到mask 0就知道问题在哪了。4.2 射线起点在碰撞体内部背面检测的隐藏规则这一条值得单独说因为它反直觉且难查。Unity的物理查询默认只检测碰撞体的正面如果射线起点落在某个碰撞体内部向外发射时不会命中这个碰撞体本身。场景是这样的角色身上挂着一个用于受击范围的Collider你想从角色中心往前打一条射线做视线检测结果永远打不到自己身上的这个Collider——听着像是好事但如果你的逻辑依赖射线能命中自身碰撞体比如做子弹从枪口发射、从载具内部的舱口向外检测就会一头雾水。解决办法有两个。一是把起点挪到碰撞体外面宁可多走一点几何计算。二是打开全局的Physics.queriesHitBackfaces true让查询检测背面。要注意这是全局设置开了之后所有射线都能打到背面可能引入新的误命中用的时候心里要有数。// 需要时开启记得在合适的时机关掉 Physics.queriesHitBackfaces true; // ... 执行需要背面检测的查询 Physics.queriesHitBackfaces false;4.3 性能账每帧几十次 Raycast 到底贵不贵先说结论单次Raycast的开销很小几十次每帧完全无感几百次就开始有压力上千次必须重构。真正让性能崩掉的往往不是射线本身而是三个附带成本一是掩码没收窄导致物理引擎遍历大量候选碰撞体二是用了RaycastAll之类的分配型API触发频繁GC三是在FixedUpdate里做视觉相关的检测浪费了物理步长的预算。优化优先级很明确先收掩码再换NonAlloc然后考虑降频。降频是个很实用的手段——悬停高亮不需要每帧检测改成每三帧一次肉眼几乎看不出差别开销直接降到三分之一。视线检测这种不那么敏感的也可以分帧轮询把一批敌人拆成几帧处理完。另外提一句射线查询是即时求值不受FixedUpdate的时间步长限制放在Update里做视觉交互检测是合理的但如果检测结果要驱动物理运动比如施加力、驱动刚体那就应该放进FixedUpdate跟物理步长对齐否则会出现受力抖动。实操心得在Profiler里看物理开销重点看Physics.Processing和Physics.Simulate两项。如果射线相关的帧耗时异常先确认是不是有代码在循环里对每个物体都发了一条射线——这种每个物体各自检测的写法改成一条射线打全部再分发结果通常能带来十倍级的改善。5. 把射线用进真实项目几个典型场景5.1 数字孪生与地图类项目地表拾取与坐标换算数字孪生、三维地图这类项目里射线最核心的用途是屏幕点到地面反算出世界坐标和对应的业务数据。流程是屏幕坐标经ScreenPointToRay得到射线射线打到地表网格通常是Terrain或一块巨大平面的Collider上拿到hit.point再把这个世界坐标转换成经纬度或平面图坐标去查对应的业务数据、POI信息或者触发区域高亮。这里的坑主要在精度和性能上。地表网格如果很密命中点的计算没问题但碰撞体本身的开销不小常见做法是给地表单独做一层低精度碰撞体只负责拾取视觉网格照常精细渲染。另外hit.textureCoord可以直接拿到UV如果地表纹理是按瓦片拼的用UV就能反推是哪个瓦片比用世界坐标做除法更准。如果地表是分块加载的射线要打在所有已加载块的层上掩码得动态维护——新块加载进来就把它的层加进去卸载时记得移除否则会出现新区域点不中的情况。5.2 VR/MR 手柄交互射线XR项目里的射线和鼠标拾取是一套逻辑只是驱动源从鼠标变成了手柄。手柄的位置和朝向决定射线起点和方向通常还要在场景里画一条可见的射线LineRenderer命中时前端变个色、手柄震一下交互反馈才完整。用XR Interaction Toolkit的话XRRayInteractor已经把射线、UI交互、传送、抓取这些搭好了直接配置Interaction Layer Mask就行不用从零写。真要自己写的话注意两点。一是手柄射线的maxDistance通常要设得比鼠标拾取短三到十米比较合适太长了在VR里指不准。二是手柄天然抖动方向向量建议做一点低通滤波或者直接以手柄的forward为准不要用两个采样点算差值。还有UI交互的问题VR里UI不在屏幕上UGUI的GraphicRaycaster需要挂到世界空间的Canvas上同时输入模块要换成XR专用的否则手柄射线点不动按钮。5.3 UI 点击范围扩大与射线穿透处理热词里经常出现如何扩大按钮的点击范围这本质上也是射线思维。UGUI按钮的可点击区域由Graphic的raycastTarget和图形本身的矩形决定。想扩大热区又不改视觉有三个办法在按钮下面垫一个透明的Image禁用raycastTarget的视觉部分只留热区尺寸调到想要的点击范围给按钮的Image换一张比视觉图大一圈的透明Sprite或者干脆自己实现IPointerClickHandler用代码里的矩形判断替代UGUI的默认命中。方向性的穿透问题则更常见场景物体和UI叠在一起时点击被谁吃掉。要记住UGUI的判断顺序是先UI后场景前提是你用了EventSystem所以只要在场景射线前加那句IsPointerOverGameObject检查就能避免点按钮时背后的模型被误选。反过来如果希望某些UI不阻挡场景点击把它的raycastTarget关掉即可。// 世界空间UI 手柄射线的常见做法先用GraphicRaycaster收集结果 var results new System.Collections.Generic.ListRaycastResult(); var pointer new PointerEventData(EventSystem.current) { position screenPos }; EventSystem.current.RaycastAll(pointer, results); if (results.Count 0) { // results 已按显示层级排序results[0] 就是最上层的UI Debug.Log($命中UI{results[0].gameObject.name}); }RaycastAll返回的列表是按渲染层级从高到低排好的这一点比物理射线的RaycastAll好用不用自己排序。但要复用列表对象别每次点击都new一个。最后分享一个我自己踩过的坑跟射线本身关系不大但很致命在移动端触摸拾取时Input.GetMouseButtonDown和Input.GetTouch在部分设备上会同时触发导致一次触摸执行两次检测、物体状态被切两遍。稳妥的做法是统一用触摸输入或者在检测入口加一个时间戳去重间隔小于0.1秒的重复触发直接丢弃。这类问题在编辑器里永远复现不出来只有真机上才会冒出来所以射线相关的代码务必在目标设备上过一遍再提测。
RELATED — 相关阅读

相关资讯

LATEST — 最新资讯

最新发布

TODAY — 本日精选

新闻

WEEKLY — 本周精选

新闻

MONTHLY — 本月精选

新闻