ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

Unity VR眼睛注视交互实战:Gaze选中、射线检测与焦点确认机制

Unity VR眼睛注视交互实战:Gaze选中、射线检测与焦点确认机制 简介面向Unity VR开发者的注视交互示例工程基于Unity 2019.4.9f1与VS2019构建针对虚拟现实项目中“眼睛注视选中物体”这一典型交互需求提供了从视线检测、高亮反馈到事件触发的完整实现思路。工程内建可运行的Test测试场景导入后即可随意添加物体进行试验使用门槛低。核心机制由WatchController、HightlightingRenderer、WatchEvent等脚本配合完成事件注册方式与UGUI的Button按钮相近便于开发者快速迁移到自己的VR项目里。资源包共283个文件以C#脚本、场景、预制体、材质和着色器为主另配有FBX模型、动画剪辑、动画控制器以及配套配置资产和PDF说明压缩后仅4.7MB结构清晰方便按需查阅和二次开发。目前已有214人学习下载适合正在学习VR交互设计、计划快速搭建眼部注视选物功能的初中级Unity开发者也可作为课堂教学或项目原型的参考资料。1. 用眼睛注视选物体Unity VR里最自然的交互也最容易做飘在VR里选东西手柄扳机是最常见的方式但总有腾不开手的时候。只要你的视线落在物体上物体就会被选中这就是眼睛注视交互英文对应Gaze Interaction。它不只服务于眼动追踪硬件头部旋转同样能模拟视线在Unity里实现一套稳定的Gaze选择系统是给VR展馆、培训、装配指导类项目降低操作门槛的关键一步。很多团队做到一半发现准星飘、误触发、UI点不中往往不是算法问题而是选型、坐标系和确认机制没想清楚。这篇就按我自己的落地方案来讲。2. 视线从哪来眼动追踪设备选型与XRRig里的坐标系坑2.1 用真眼动还是头动模拟先想清楚你的硬件底线项目立项时第一个要拍板的事是视线数据到底取自哪里。常见做法是三种头显内置眼动追踪、外接眼动模块、纯头动模拟。内置方案的代表是Pico系列的企业版和部分旗舰型号还有基于OpenXR Eye Gaze扩展的设备。外接模块像Tobii、Pupil LabsSDK集成成本高一些一般用于科研或特殊定制设备。如果你的目标平台是普通Pico Neo 3、Quest 2这类不带眼动的机器那只能走头动模拟即用头显位置和头部朝向代替视线方向。选型上我的建议是产品原型阶段直接上头动模拟把交互逻辑跑通不要一上来就等眼动硬件。因为Gaze系统的核心难点在前端命中、焦点管理和确认交互视线来源只是输入源不同。等到核心逻辑稳了再切换成真正眼动数据这时你只改一行数据来源的代码其他全都不动。反过来如果项目目标是给带眼动的设备做旗舰体验那就必须真眼动头动模拟做不了瞳孔级的注视点精度特别是近处小物体差了可能有好几厘米偏差。视线来源精度集成成本适用场景头显内置眼动高瞳孔级中SDK 校准旗舰体验、注视点渲染、眼动菜单外接眼动模块很高但有额外标定高科研、医疗、专用设备头动模拟低头部朝向低原型验证、无眼动设备的消费级头显从Unity层面看不管哪个方案最终都要落到XRRig里的眼睛锚点。内置眼动设备通常会提供一个Eye节点或对应的XRNodeOpenXR的设备会在InputDevice列表里给你一个RightEye/LeftEye的节点它的Position和Rotation就是瞳孔位置和注视方向。使用Unity XR Interaction Toolkit的话你可以在TrackedPoseDriver上挂一个眼睛节点也可以自己用InputDevices去拉数据。2.2 坐标系的坑拿到注视方向后别直接用把视线数据接到Unity后第一个容易翻车的点是坐标系变换。眼动设备返回的往往是头显本机的局部坐标或者是Session空间的会话原点坐标。你在XRRig里拿到的眼睛节点的localPosition和localRotation是相对于它的父节点一般是Camera Offset的。如果你的射线起点直接取眼睛节点的世界坐标、方向直接取forward在头显没有校准时方向会偏移好几度选小物体时指东打西。我一般会在XRRig下专门挂一个空的Gaze Origin节点通过InputDevice的TryGetFeatureValue把眼睛的位移和旋转读出来再赋给这个节点。代码里这样写using UnityEngine.XR; public class EyeGazeSource : MonoBehaviour { public XRNode eyeNode XRNode.RightEye; private InputDevice eyeDevice; private Vector3 eyePosition; private Quaternion eyeRotation; void Update() { if (!eyeDevice.isValid) { eyeDevice InputDevices.GetDeviceAtXRNode(eyeNode); return; } if (eyeDevice.TryGetFeatureValue(CommonUsages.devicePosition, out eyePosition) eyeDevice.TryGetFeatureValue(CommonUsages.deviceRotation, out eyeRotation)) { transform.localPosition eyePosition; transform.localRotation eyeRotation; } } }这段代码的逻辑是先按XRNode.RightEye这个类型去取设备取到之后每帧把眼睛的位置和旋转同步到当前挂载的GameObject上。注意这里赋的是localPosition和localRotation也就是说这个GameObject必须挂在正确的父级下父级应该带有从XRRig到头显的完整变换。参数上有两个点要留意。第一eyeNode选RightEye还是LeftEye取决于你最常用的那只眼睛有的设备两只眼睛角度不一致建议做成可配置项在Inspector里能切。第二如果设备不支持某个FeatureTryGetFeatureValue会返回false这时要保留上一帧的数据否则眨眼那一瞬你的注视方向会被归零射线直接拍到地上。补一行缓存就行if (eyeDevice.TryGetFeatureValue(CommonUsages.devicePosition, out Vector3 newPos)) eyePosition newPos; if (eyeDevice.TryGetFeatureValue(CommonUsages.deviceRotation, out Quaternion newRot)) eyeRotation newRot; transform.localPosition eyePosition; transform.localRotation eyeRotation;这算是个血泪经验之前我见过有人直接取TryGetFeatureValue的返回值结果眼睛一眨目标选择被清零测试员一脸懵。3. 从眼睛位置射出那条命中射线Raycast与LineRenderer最小实现3.1 用Physics.Raycast做命中检测起点、方向、距离三个参数怎么填不管视线来源是眼动还是头动最终命中检测都是用Unity物理射线那一套。常见做法是在Gaze Origin节点上放一个脚本在Update里构建Ray然后用Physics.Raycast打出去。public class GazeRaycaster : MonoBehaviour { public float maxDistance 30f; public LayerMask targetMask -1; public QueryTriggerInteraction triggerInteraction QueryTriggerInteraction.Ignore; private RaycastHit hitInfo; public RaycastHit CurrentHit hitInfo; public bool HasHit { get; private set; } void Update() { Ray ray new Ray(transform.position, transform.forward); HasHit Physics.Raycast(ray, out hitInfo, maxDistance, targetMask, triggerInteraction); } }这段代码里的几个参数直接决定手感。maxDistance取30米适合大多数室内展馆和培训场景如果做户外可视化或大空间这个值要加大到50100米但要注意Unity物理引擎对超远距离射线的精度仍然没问题问题在于小物体在远距离上屏占比太小用户自己都看不清命中也就没有意义。targetMask建议单独建一层叫GazeTarget把可交互物体都放到这一层而不是用-1匹配所有层否则视线穿过墙壁会选中墙后的物体这在VR里非常违和。triggerInteraction设Ignore是为了避免带Collider的Trigger区域比如UI的触发区、隐形的传送区干扰选择。命中之后下一步是把射线可视化。VR调试时你看不到自己的注视方向没有反馈就等于瞎按。用LineRenderer是最简单的public LineRenderer line; public Transform lineEnd; void Update() { if (line ! null) { Vector3 endPoint HasHit ? transform.position transform.forward * hitInfo.distance : transform.position transform.forward * maxDistance; line.SetPosition(0, transform.position); line.SetPosition(1, endPoint); if (lineEnd ! null) lineEnd.position endPoint; } }这段逻辑是有命中时射线终点取实际命中点的距离没命中时打到最大距离避免LineRenderer拖出无限长的一条线。好的做法是给这个LineRenderer设一个半透明的材质宽度调到0.005米左右这样既能看到视线方向又不会挡住目标物体。3.2 为什么要用左右眼中心而不是单眼很多人图省事直接取左眼或右眼的节点当射线起点这在看近距离物体时会出问题。人眼有瞳距单眼看到的物体位置和双眼汇聚中心看到的并不完全一致距离越近偏差越大。比如一个物体离你0.5米右眼方向打过去可能偏了左侧。正确做法是取左右眼位置的平均值作为射线起点。public class StereoGazeRaycaster : MonoBehaviour { public XRNode leftEye XRNode.LeftEye; public XRNode rightEye XRNode.RightEye; public float maxDistance 30f; public LayerMask targetMask -1; private Vector3 rayOrigin; private RaycastHit hitInfo; public RaycastHit CurrentHit hitInfo; public bool HasHit { get; private set; } void Update() { InputDevice leftDevice InputDevices.GetDeviceAtXRNode(leftEye); InputDevice rightDevice InputDevices.GetDeviceAtXRNode(rightEye); if (leftDevice.isValid rightDevice.isValid) { leftDevice.TryGetFeatureValue(CommonUsages.devicePosition, out Vector3 lPos); rightDevice.TryGetFeatureValue(CommonUsages.devicePosition, out Vector3 rPos); rayOrigin (lPos rPos) * 0.5f; } else { rayOrigin transform.position; } Ray ray new Ray(rayOrigin, transform.forward); HasHit Physics.Raycast(ray, out hitInfo, maxDistance, targetMask, QueryTriggerInteraction.Ignore); } }参数说明rayOrigin用左右眼世界坐标的平均值等价于双眼汇聚中心。实际项目中如果眼动设备只提供一只眼睛的数据那仍用单眼同时把maxDistance调小来降低误差感知。另一种折中是让眼睛节点位置局部偏移几厘米模拟瞳距均值。但要注意这里的射线方向仍取自头显forward不包含真正的眼球转角也就是说你是用头部朝向做Gaze精度上限就是头部朝向精度——想要真正的眼睛注视还得回到第二章的眼动数据接口去取direction。在移动端VR一体机上Physics.Raycast的性能一般不用太担心因为场景中可交互物体通常只占一小部分。但有个坑如果场景里所有墙体也加了Collider并且层没滤干净你这条射线会先打中墙永远够不到后面的按钮。调试时怀疑这个问题就先用Debug.DrawLine把射线画出来配合Unity编辑器里的Layer可视化一眼就能看出是哪个Collider挡了路。4. 从“看”到“选中”焦点切换、确认延迟与操作手感调参4.1 瞬选、悬停确认、长按确认三种确认方式怎么选射线命中只是第一步真正的选择动作需要一个“确认”环节。这里有三个主流做法我帮你拆开比较。瞬选是视线一落到物体上就立刻触发选择适合启动类交互比如看向一个开关它就打开。瞬选的缺点是容易误触发用户只是转头扫一眼东西就被选中了日常使用很容易翻车。悬停确认是视线落在物体上并保持一段时间后触发这是最成熟、最不累的交互C端VR应用里大量使用通常设置0.8到1.5秒。长按确认则需要用户按住手柄按钮同时注视一段时间防止误触效果好适配那些“选错了代价高”的场景比如删除文件、提交答案。性能上看悬停确认多了一个计时状态需要在Update里做时间累加和清零没有额外渲染消耗。手感上我自己的经验是主交互用悬停确认危险操作用长按确认纯开关类短促操作才考虑瞬选。确认延迟这个参数新手期项目先全调1秒后续让真人测试来微调先别管舒服不舒服先把逻辑跑通。4.2 实现焦点状态机与计时确认核心代码与必调参数这个脚本是一个最小可用的GazeInteractor。它负责维护当前焦点目标、计算注视停留时间、触发选中事件。using UnityEngine; using UnityEngine.Events; public class GazeInteractor : MonoBehaviour { [Header(Targeting)] public float maxDistance 30f; public LayerMask targetMask -1; public float confirmDelay 0.8f; public bool requireButtonPress false; public KeyCode confirmButton KeyCode.JoystickButton0; [Header(Events)] public UnityEventGameObject onFocusEnter; public UnityEventGameObject onFocusExit; public UnityEventGameObject onSelected; private GameObject currentTarget; private GameObject pendingTarget; private float focusTime; void Update() { Ray ray new Ray(transform.position, transform.forward); if (Physics.Raycast(ray, out RaycastHit hit, maxDistance, targetMask, QueryTriggerInteraction.Ignore)) { pendingTarget hit.collider.gameObject; } else { pendingTarget null; } if (pendingTarget ! currentTarget) { if (currentTarget ! null) onFocusExit?.Invoke(currentTarget); currentTarget pendingTarget; focusTime 0f; if (currentTarget ! null) onFocusEnter?.Invoke(currentTarget); } if (currentTarget null) return; focusTime Time.deltaTime; bool confirmed confirmDelay 0f || focusTime confirmDelay; if (requireButtonPress) confirmed confirmed Input.GetKey(confirmButton); if (confirmed) { onSelected?.Invoke(currentTarget); ResetFocus(); } } private void ResetFocus() { onFocusExit?.Invoke(currentTarget); currentTarget null; pendingTarget null; focusTime 0f; } }逻辑说明每帧用Physics.Raycast拿到当前视线命中的物体放进pendingTarget。如果pendingTarget和currentTarget不一致说明用户视线从A挪到了B这时触发A的onFocusExit重置计时再触发B的onFocusEnter。只有当前目标不为空时才累加focusTime等累计时间达到confirmDelay就触发onSelected然后立刻重置焦点让用户下一次注视可以再次触发。参数说明confirmDelay设为0表示瞬选即射线命中那一帧直接选中设为0.8秒适合展馆里浏览类场景设为1.5秒适合高风险的确认操作。requireButtonPress为true时悬停计时完成后还需要按住确认键才触发同时眼睛移开时不要立即释放按键否则这次操作会被打断。我一般会在onFocusExit里直接调用ResetFocus保持状态干净。如果你的物体上有多个Collider比如一个按钮同时有BoxCollider和MeshCollider用hit.collider.gameObject拿到的是最外层那个要确保你的逻辑是对GameObject生效而不是对Collider生效否则两个Collider会触发两次选中。悬停确认还有个细节要不要做“失焦惩罚”。意思是如果用户眼睛已经盯了0.7秒还差0.1秒就触发结果视线一晃到别处回来后还得从0开始。这个惩罚逻辑在测试中非常明显用户会觉得很笨。解决方案是给每个可交互目标记一个已累计时间视线离开后在0.5秒内回来可以接着上次继续。这块按项目需要取舍如果你的目标物体很密保留重置更安全。实践里还有一个参数容易忽略失焦容忍距离。Physics在每帧里都是瞬时结果如果用户头抖一下射线从物体边缘划过去这一帧的pendingTarget变成null焦点状态就被重置了。你要给射线检测加一个“震荡窗口”做法是记录过去3帧的命中结果只要有1帧没有命中且下一帧连续3帧都没命中才真正把一个目标踢掉。代码实现时用一个数组做环形缓冲即可这部分因项目差异很大保证你的GazeInteractor不要因为单帧抖动就反复触发onFocusEnter和onFocusExit就行。5. Gaze交互的5个常见翻车点和排查顺序5.1 注视点漂移、射线穿墙、UI点不到现象与原因这套交互看着简单实际运行时问题都藏在细节里。我把自己常碰到的五个问题写出来按现象、原因、解决三项列清楚你可以拿来当排查清单用。一、注视点慢慢飘走选中的物体越来越偏现象刚校准完还好玩五分钟之后视线中心总落在目标物体偏右上方几个像素的位置。原因眼动追踪设备在校准之后头显会有微小的滑动瞳距也会变化如果是头动模拟方案则可能是头显定位漂移。解决检查设备是否支持定期重新校准或在应用里做一个隐藏的“五秒长按进入校准”功能。另外运行环境里如果摄像头反光或照明变化剧烈追踪质量会下降尽量让测试环境光照稳定。二、射线穿透了正前方的物体选中了后面的按钮现象视线明显落在面前的桌台上但触发的是桌台后面的屏幕。原因桌台的Collider层不在targetMask里Physics.Raycast直接忽略了它。解决确认所有可交互表面都放在GazeTarget层或是把targetMask改成能包含桌台的层。还有一个常见原因是物体只有MeshCollider但没有网格或者Collider被禁用物理检测直接落空这种情况先用编辑器里选中物体看Inspector里Collider的状态。三、UI按钮既不亮也不触发但3D物体选择正常现象Gaze能选中场景里的3D物体但Canvas上的Button点了没反应。原因UGUI的事件系统不走Physics.Raycast它依赖GraphicRaycaster和EventSystem.current。Gaze射线没有接入UI事件通道所以UI不响应。解决在EventSystem上挂XR UI模块或者写一个简单的Caster把Gaze的Ray传给GraphicRaycaster。用Unity XR Interaction Toolkit的话直接把Gaze Interactor和XRUIInputModule搭配使用。四、眨眼或快速转头时焦点疯狂切换物体被误选现象用户眨了一下眼睛或者是快速回头明明没有看目标目标却被选中了。原因眨眼时瞳孔数据出现空白快速转头时视角变化导致某一帧的射线打在别处。解决在EyeGazeSource里对无效帧做Hold不要用无效数据覆盖上一帧的有效数据。给GazeInteractor加一个最小稳定时间参数比如0.15秒内连续命中才切焦点能滤掉单帧抖动。五、同一个物体被选中两次状态被重复触发现象按钮第一次选中后用户眼睛还盯在上面又触发了一次。原因确认触发后没有把当前焦点复位下一帧相同目标又被判定为“新目标”。解决在onSelected触发后立即调ResetFocus且确认一个事件里不要挂多个监听器特别是如果事件还挂在父物体上会连带触发父物体的逻辑。5.2 排查顺序先看数据层再看逻辑层遇到Gaze交互失灵我的排查顺序一般固定是三层第一层数据可视化。先在场景里加一个来自眼睛锚点的小球看看它是不是稳定跟随你的注视移动。小球乱跳问题就在眼动数据获取和坐标变换不用往下查。如果小球数据正常再看LineRenderer画出的射线方向与实际注视是否一致不一致就说明射线起点或方向取错了点。第二层命中检测。找一个你确定在视线正前方的物体临时把targetMask改为-1让射线全打到所有层再加上Debug.DrawLine持续画线看射线是否与目标Collider相交。如果画线正常但onFocusEnter没触发检查脚本里的事件绑定是不是被序列化丢失了。Unity里UnityEvent在Inspector上绑定的回调有时会因为GameObject改名或脚本重载而断掉这属于隐性问题光是看代码发现不了。第三层交互状态机。确认命中没问题后在GazeInteractor里加几个Debug.Log把currentTarget、focusTime打出来。焦点切换正常但延时触发不工作重点查confirmDelay的数值是不是被配成了0或者ResetFocus被意外调用。这套流程走下来90%的Gaze问题都能定位到具体那一层。比较玄学的是个别设备型号的眼动数据刷新率偏低只有30HzUnity跑在72Hz或90Hz就会看到射线一卡一卡地跳这种属于硬件限制只能做插值平滑不要在代码里找原因。5.3 别忘了眨眼和HUD对焦的冲突最后一条容易忽视的是眨眼本身。真眼动方案里眨眼那一瞬间眼睛位置会丢失或突变如果此时射线还在计算大概率会选中你本来看着的物体——这倒不算误触但眨眼频繁时确认计时会被反复重置。解决的办法是在EyeGazeSource里加一个简单的失效窗口检测到眼睛数据失帧后把射线暂停0.1秒。数据恢复后有0.1秒的保护时间这样快速连续眨眼不会中断你的操作。这个参数放到配置里因为每个人眨眼频率不一样0.1秒对部分用户可能太短0.2秒又会显得不跟手。另一个高发问题是在看远距离大物体时确认延迟被拉得很长。因为人眼看到一个大物体视线可能停在不同部位焦点在子物体之间跳来跳去。你要么在确认逻辑里对同一个根物体做聚合要么给子物体都挂同一个可交互脚本让lecture命中后向上找到根物体。至少要做一层去重否则大物体的交互会非常难受。6. 把注视点画出来调试可视化与一套长期可用的验证习惯Gaze系统这玩意儿没有可视化就完全是黑匣子。我习惯在最早期就做一个调试用的注视点指示器一个小圆点或者十字线画在命中物体表面。做法是拿到RaycastHit的point把它作为一个小Sprite的坐标。这个指示器不仅帮你确认射线方向对没对还能暴露很多隐藏问题比如指示器在物体表面上跳动说明眼动数据不稳指示器总停在物体边缘说明单眼起点偏了。下面这个脚本用于在任何需要的时候把注视方向和命中点画进编辑器using UnityEngine; public class GazeDebugDrawer : MonoBehaviour { public GazeRaycaster raycaster; public float lineDistance 5f; void OnDrawGizmos() { if (raycaster null) return; Ray ray new Ray(transform.position, transform.forward); Gizmos.color Color.yellow; Gizmos.DrawLine(ray.origin, ray.origin ray.direction * lineDistance); if (raycaster.HasHit) { Gizmos.color Color.red; Gizmos.DrawSphere(raycaster.CurrentHit.point, 0.01f); Gizmos.DrawLine(raycaster.CurrentHit.point, raycaster.CurrentHit.point raycaster.CurrentHit.normal * 0.02f); } } }这里的Gizmos线条在Scene视图里可见运行时也能看到不额外消耗性能。做测试的时候把Scene窗口分个预览窗放在旁边数据一清二楚。还有个好用的小技巧让被试坐在椅子上头不动只转动眼球观察指示器是否能在目标物体之间移动。如果指示器纹丝不动你的输入源很可能还停留在头动模拟阶段没有切到真正的眼动模式。这个快速验证方法能帮你判断设备接入状态一次性能省下半小时的无效测试。验证手感时我的习惯是准备一张白板写上两行字分别对应“确认延迟”和“失焦容忍距离”,每轮测试改一个参数而不是同时改多个。让测试人员执行三个固定动作看左前方的按钮、看右后方的按钮、快速左右扫视。每轮记录“误触次数”和“完成时间”数据对比就能找到最优区间。这套方法虽然土但比拍脑袋调参数靠谱得多。最后落脚到一个习惯给交互物体做高亮反馈。在现代VR项目里物体被注视反馈是刚需等确认快触发时把高亮颜色从半透明变成实色让用户心里有个“快了”的预期。这个反馈让误操作率进一步下降不少而且实现成本很低。希望帮到你。本文还有配套的精品资源点击获取
返回列表