
简介面向Unity开发者的VR眼动交互示例资源主要解决虚拟现实场景中通过眼睛注视选择物体的交互问题。工程基于Unity 2019.4.9f1与VS2019构建自带Test场景可直接运行可在场景中放置物体体验注视选中反馈适合正在学习VR交互或需要快速搭建视线选择机制的中级开发者。资源包共283个文件包含41个C#脚本、17个材质、10个预制体、6个Shader以及Unity场景、资产、动画等类型压缩包仅4.7MB轻量易用。脚本与预制体结构清晰便于理解注视检测、事件注册仿UGUI Button和高亮渲染等逻辑并附有PDF说明文档与FBX模型可参考。目前已有214人学习下载。资源提供WatchController、WatchEvent、HightlightingRenderer等完整脚本及配套控制器与动画能帮助读者掌握Unity VR中基于视线焦点进行物体选中的设计思路并将这套机制迁移到自己的VR项目中。1. 眼睛注视选择VR 交互里最像「直觉」的那一种在 VR 里伸手点一个 2 米外的按钮手一抖就要重新瞄头部转动去选中脖子转了半天目标还不在中心。眼睛注视选择Gaze Selection解决的就是这个场景眼睛看哪儿射线就打哪儿停在目标上约一秒目标就被选中。它不需要手柄、不需要抬手、不需要大幅度转头是 VR 里最接近「我用眼睛看它就响」的交互方式。这篇文章要讲清楚两件事Unity 里怎么拿到眼动数据并生成可靠的注视射线以及从「能命中」到「选得准、不误触」要跨过的那些硬件与算法细节。适合正在做 VR 应用交互层、被手柄操作搞到烦躁、又不想引入完整眼动 SDK 的 Unity 开发者。2. 眼动数据从哪来XR Input 与 OpenXR 两条路线怎么选2.1 眼动设备在 Unity 里的两个来源通用 XR API 与设备私有 SDK眼动数据在 Unity 里有两个获取层次。第一个是 Unity XR 模块提供的公共 API通过InputDevices找到带EyeTracking特征的设备再用TryGetFeatureValue拿眼动数据第二个是设备厂商自己的 SDK比如 PICO 的交互 SDK、Quest 的眼动 SDK它们封装了更多原始数据瞳孔直径、注视置信度、双眼坐标分开给。我一般先走通用 XR API。理由很实际换设备不用改代码OpenXR 在 2021 LTS 之后的 Unity 版本里已经能统一暴露眼动特征大部分消费级眼动头显PICO 4 Pro、Quest Pro 这类都通过 OpenXR 层把数据吐给 XR 模块。厂商 SDK 适合你要拿注视置信度、双眼瞳孔数据做高级算法时才切入否则只会增加耦合度。2.2 配置 OpenXR少了这一步数据永远是 false用通用 API 的前提是 Project Settings 里正确启用了 OpenXR 的 Eye Gaze Interaction 功能。具体路径Project Settings - XR Plug-in Management - OpenXR - Interaction Type里勾选 Eye Gaze Interaction部分设备还需要在头显系统的隐私设置里开启「允许应用访问眼动数据」否则 Unity 端拿到的是一个空设备。这一步是最大翻车点。很多开发者写完代码发现TryGetFeatureValue永远返回 false问题不在脚本而在 OpenXR 交互配置或头显系统开关。排查顺序是先看设备端权限再回 Unity 看 Plug-in Provider 是否启用最后看 Player Settings 里的 Active Input Handling 是否切到了 Input System Package。2.3 拿到的是世界坐标射线不是屏幕坐标点眼动数据通过CommonUsages.eyeTrackingData返回里面包含左眼和右眼各自的射线Ray射线带有 origin 和 direction且通常是世界坐标。少数设备厂商的 SDK 会给局部空间的旋转量拿到后需要转成世界坐标再用于射线检测。这里有一个容易踩的坑不要直接用两只眼睛中某一侧的射线去 Raycast。单眼射线在近距离1 米以内会有明显偏斜因为双眼汇聚点在近处和射线方向不在一条线上。我一般取左右眼射线做平均得到一个「双眼中心射线」近距离命中表现会好很多。下一章的实现脚本里就是这样处理的。2.4 为什么不建议直接用手柄激光射线代替眼动手柄激光Hand Ray和注视射线在实现上几乎一样都是Physics.Raycast但使用体验完全不同手柄需要抬手、瞄准、稳定在 VR 里保持手臂静止 0.5 秒本身就是体力活眼睛注视的优点是零物理动作缺点是微颤、容易误触。所以眼动选择不能简单地拿手柄选中的逻辑复用需要额外做「稳定化处理」——我们对射线做角度过滤、对命中物体做连续帧计数、对确认动作加延时。这是下一章的三个核心手段。3. 用 Gaze Ray Dwell 实现注视选物体核心脚本与三段式反馈3.1 第一步写一个 Gaze 射线控制器把眼动数据变成稳定的命中我先把最底层的射线生成和命中检测写成一个组件GazeRaycaster。它的职责只有两件拿到眼动数据、做一次物理检测。具体 Dwell注视停留逻辑不放在这里保持职责单一方便在别的项目里复用。using System.Collections.Generic; using UnityEngine; using UnityEngine.XR; public class GazeRaycaster : MonoBehaviour { [Header(射线参数)] [SerializeField] private float maxDistance 8f; // 最远可选中距离 [SerializeField] private LayerMask hitLayer ~0; // 检测层级默认全部 [SerializeField] private float angleFilter 2f; // 角度过滤抑制微颤 private InputDevice eyeDevice; private bool eyeDeviceFound; private readonly ListInputDevice eyeDevices new(); private EyeTrackingData eyeData new(); // 对外暴露的当前状态 public GameObject CurrentHit { get; private set; } public Vector3 GazeOrigin { get; private set; } public Vector3 GazeDirection { get; private set; } public Vector3 HitPoint { get; private set; } private Vector3 lastValidDirection; private void Start() { // 通过特征查找眼动设备而不是用 device 名字匹配 InputDevices.GetDevicesWithCharacteristics( InputDeviceCharacteristics.EyeTracking, eyeDevices); eyeDeviceFound eyeDevices.Count 0; if (!eyeDeviceFound) Debug.LogWarning(未找到眼动设备GazeRaycaster 将不生效); } private void Update() { if (!eyeDeviceFound) return; // 每次重新获取设备防止设备热插拔后引用失效 if (!eyeDevice.isValid) { eyeDevices.Clear(); InputDevices.GetDevicesWithCharacteristics( InputDeviceCharacteristics.EyeTracking, eyeDevices); if (eyeDevices.Count 0) return; eyeDevice eyeDevices[0]; } if (!eyeDevice.TryGetFeatureValue(CommonUsages.eyeTrackingData, out eyeData)) return; Vector3 rawOrigin Vector3.Lerp(eyeData.leftEyeRay.origin, eyeData.rightEyeRay.origin, 0.5f); Vector3 rawDirection Vector3.Lerp(eyeData.leftEyeRay.direction, eyeData.rightEyeRay.direction, 0.5f); // 角度过滤小于 angleFilter 的变化都忽略防止目标来回抖 if (Vector3.Angle(rawDirection, lastValidDirection) angleFilter lastValidDirection ! Vector3.zero) { GazeOrigin rawOrigin; GazeDirection lastValidDirection; } else { GazeOrigin rawOrigin; GazeDirection rawDirection; lastValidDirection rawDirection; } // 物理检测 if (Physics.Raycast(GazeOrigin, GazeDirection, out RaycastHit hit, maxDistance, hitLayer)) { CurrentHit hit.collider.gameObject; HitPoint hit.point; } else { CurrentHit null; HitPoint GazeOrigin GazeDirection * maxDistance; } } }这段脚本里有两个参数决定手感一个是maxDistance另一个是angleFilter。maxDistance设太远会让远处无关物体也被命中设太近影响可用范围我一般按场景交互目标最远距离取 5 到 10 米。angleFilter的意义是「眼睛转动小于这个角度时认为你在看原来的目标」默认 2 度配合近距离交互可以放宽到 3 度不然视线稍微一动 1 度目标就从按钮滑到背景上了。TryGetFeatureValue返回 false 时直接 return不做兜底射线。有些教程会建议「拿不到眼动时退回头部射线」我的做法是保留这个降级方案但放在单独的开关里触发时明确提示用户「眼动未开启」避免用户以为交互坏了是玄学问题。3.2 第二步Dwell 选择器——看一会儿才算选中还要有进度反馈有了命中结果接下来是选择判定。纯「看到就选中」体验太灵敏用户只是扫一眼就把界面触发所以要用 Dwell 机制视线停留在目标上超过设定时间才执行选中。Dwell 最核心的体验设计是进度反馈选中前给一个圆环从 0 到 100% 填充用户看到进度就知道「我再盯一会儿就会触发」。没有反馈的 Dwell 是 VR 交互里的黑洞用户不知道自己是没看准还是已经选中了那是真的翻车现场。using System; using UnityEngine; public class DwellSelector : MonoBehaviour { [Header(Dwell 参数)] [SerializeField] private float dwellTime 1.0f; // 从注视到触发的时间 [SerializeField] private float progressStart 0.25f; // 超过该时间才开始显示进度 [Header(依赖)] [SerializeField] private GazeRaycaster raycaster; [SerializeField] private GazeProgressRing progressRing; private GameObject lastTarget; private string lastTargetId; private float dwellTimer; private bool isDwelling; private bool triggered; public event ActionGameObject OnSelected; private void Update() { GameObject target raycaster.CurrentHit; if (target null) { ResetDwell(); return; } // 切换了目标就重置计时 string targetId target.GetInstanceID().ToString(); if (lastTargetId ! targetId) { ResetDwell(); lastTarget target; lastTargetId targetId; return; } // 连续帧命中同一目标才累计时间 if (!isDwelling) { dwellTimer Time.deltaTime; if (dwellTimer progressStart) { isDwelling true; progressRing?.Show(); } return; } progressRing?.SetProgress(dwellTimer / dwellTime); dwellTimer Time.deltaTime; if (dwellTimer dwellTime !triggered) { triggered true; OnSelected?.Invoke(target); progressRing?.Complete(); } } private void ResetDwell() { lastTarget null; lastTargetId null; dwellTimer 0f; isDwelling false; triggered false; progressRing?.Hide(); } }dwellTime是核心手感参数。太短0.5 秒容易误触用户刚扫过按钮就激活太长1.5 秒以上让每次选择都像在等电梯耐心会被消耗频繁选择的操作会让人烦躁。静态界面按钮我给 0.8 到 1.0 秒移动中的物体或高风险操作如删除确认给 1.5 秒。progressStart是进度的「预热」时间定为 0.25 秒避免用户刚把视线移过去就跳出进度环视线一晃又消失造成闪烁干扰。注意这段脚本把「选中目标」和「触发方式」解耦了OnSelected事件里传的是GameObject由上层决定这个物体被选中后做什么。这样同一个 Dwell 逻辑可以复用到按钮、3D 物体、菜单项等多种类型的交互上。3.3 第三步三重界面统一接入——3D 物体、世界空间 UI、手柄按钮共存实际项目里很少只有 3D 物体需要被凝视选中界面按钮和手柄并存是常态。我的处理方式是再包一层GazeSelectionManager它监听DwellSelector的事件把不同类型的物体统一分发。using UnityEngine; using UnityEngine.Events; public class GazeSelectionManager : MonoBehaviour { [SerializeField] private DwellSelector dwellSelector; public UnityEventGameObject onObjectSelected; private void OnEnable() { dwellSelector.OnSelected HandleSelected; } private void OnDisable() { dwellSelector.OnSelected - HandleSelected; } private void HandleSelected(GameObject target) { // 同一套入口分发到不同交互逻辑 if (target null) return; onObjectSelected?.Invoke(target); // 如果目标挂了 ISelectable直接调接口 var selectable target.GetComponentISelectable(); selectable?.OnGazeSelected(); } } public interface ISelectable { void OnGazeSelected(); }这层封装的价值在于手柄选择、眼动选择、将来的手势选择都汇聚到同一个HandleSelected换交互设备不需要重写业务逻辑。世界空间 UI 按钮的接入不需要单独写一套我只需要在 UI 按钮的根节点上挂一个实现ISelectable的脚本隐式实现里调button.onClick.Invoke()即可。3.4 进度环的实现用 UGUI 的 fillAmount 做一个轻量反馈上面代码里有个GazeProgressRing组件它承担进度可视化。我直接在 Canvas 上建一个圆形 Image把 Image Type 设为 Filled用fillAmount推进进度。using UnityEngine; using UnityEngine.UI; public class GazeProgressRing : MonoBehaviour { [SerializeField] private Image ringImage; [SerializeField] private CanvasGroup group; [SerializeField] private float hideSpeed 8f; private void Awake() { if (ringImage null) ringImage GetComponentImage(); if (group null) group GetComponentCanvasGroup(); ringImage.fillAmount 0f; group.alpha 0f; } public void Show() { ringImage.fillAmount 0f; group.alpha 1f; } public void SetProgress(float p) { ringImage.fillAmount Mathf.Clamp01(p); } public void Complete() { ringImage.fillAmount 1f; group.alpha 0f; } public void Hide() { ringImage.fillAmount 0f; group.alpha 0f; } }这里的进度环挂在目标的CanvasGroup上跟随 UI 元素显示——但注意如果它是世界空间 Canvas 下的组件Canvas 的渲染方式要设成 World Space并且要保证 Canvas 在视线方向上是正面朝向用户。如果进度环固定在屏幕角落用户视线离开物体时还得用余光确认进度交互会断掉我建议把进度环直接做在目标体上UI 或 WorldSpace 即可本身不需要 Raycast 命中因为当前命中的目标已经由射线决定了。3.5 参数速查表参数推荐值影响maxDistance5~10m过远易误选背景过近限制交互范围angleFilter2°~3°越小越灵敏但越抖越大越稳但反应迟缓dwellTime0.8~1.2s核心确认手感高风险操作 1.5sprogressStart0.2~0.3s避免进度环闪烁双眼射线必须平均单眼在近距离偏斜 3~5°LayerMask只放可选中层防止视线扫过任意物体都触发4. 眼动选中的避坑清单从硬件不返回数据到射线发飘的 5 个实操问题4.1 eyeTrackingData 一直返回 false先查系统开关再查 OpenXR 配置这是遇到频率最高的现象代码照着抄下来了TryGetFeatureValue却不给脸。原因有三层。第一层是头显系统里没有开「允许应用记录眼部活动」的权限眼动模块根本没在采集第二层是工程里 OpenXR 的 Interaction Type 没勾选 Eye Gaze Interaction第三层是设备没有眼动硬件这个只能放弃改用手柄。解决顺序也是这个顺序先到设备设置里开权限再到 Unity 里检查 XR Plug-in Management 配置最后看 Player Settings 的 Active Input Handling如果是 Both 或 Input System 模式脚本里用UnityEngine.XR.InputDevice的写法还是能工作的但如果 OpenXR 用的 Input System 而代码里同时初始化了旧输入可能出现绑定冲突。用 Debug 输出设备名能直接确认硬件是否被系统识别。4.2 射线在近距离偏了 3~5 度别用单眼射线现象是站在 1 米以内看按钮射线总打在按钮边缘Dwell 半天都触发不了稍微退后到 1.5 米又变准了。原因是双眼汇聚的几何问题。左右眼各自看向一个近处的点两束射线形成夹角单眼射线指向的不是你想看的点。而眼球追踪返回的射线如果只取左眼视线方向会带一个固定偏移这个偏移在 0.5 米时尤其明显。解决按我 3.1 节脚本里的写法对左右眼射线取Vector3.Lerp做平均。注意不要把 position 和 direction 分开平均后直接拼应该分别Lerporigin 和 direction这是两个独立的量。加一版调试视图把左右眼射线和平均射线同时画出来偏移量就一目了然了。4.3 视线停在目标上但选不中计时被微颤重置了现象明明盯着按钮进度环走到 30% 突然消失又从头开始。用户开始烦躁你开始怀疑 Dwell 实现有 bug。原因眼睛在自然状态下并不是绝对静止的微颤通常在 0.5 到 1 度之间。如果Raycast命中结果在按钮和背景之间交替DwellSelector检测到目标切换把计时重置了。解决别急着重置。在GazeRaycaster里我加了角度过滤——每帧射线方向变化小于angleFilter时沿用上一帧合法方向这已经过滤了大部分微颤。还有一个更稳的做法DwellSelector里记录lastTargetId时允许目标在极短时间内丢失比如不超过 0.1 秒不算切换。我把这个叫「命中宽容窗口」代码里用一个lostTimer计数超过阈值才真正重置效果显著。4.4 UI 按钮射线命中了但 Point Enter 不触发UGUI 和 XR 射线是两套机制现象用Physics.Raycast能打中 World Space Canvas 上的 ButtonCurrentHit有值但IPointerEnterHandler这些 UGUI 事件一概不响应。原因UGUI 的StandaloneInputModule默认只认鼠标和触摸不认 XR 射线。Physics.Raycast命中的是碰撞体跟 UGUI 的 EventSystem 是各自独立的两套命中体系。解决我一般直接用GazeRaycaster.CurrentHit的结果自己命中自己触发不走 EventSystem——这也是上面 3.2/3.3 节的做法。如果你的项目里大量使用了 UGUI 且不想改现有结构那就引入 XR UI Input Module让眼睛命中可以被自动转换成 Pointer 事件。但我不推荐因为 XR UI Input Module 会接管原本鼠标模拟的逻辑纯编辑器调试时反而不方便。4.5 眼动校准弹窗反复出现每次启动都让用户重新校准现象应用每次启动都弹出眼动校准界面用户测一次烦一次心想这破眼动能退吗。原因有些头显把校准数据绑定在系统账号下并不随应用启动而自动加载如果应用没有正确请求眼动权限系统认为「首次启动」就要求重新校准。解决在应用初始化时先检查眼动权限状态如果未授权主动弹一个引导界面让用户去系统设置开启而不是让系统弹窗打断启动流程。同时把校准完成状态缓存到本地下次启动不再强制校准直到用户自己进入设置页重新校准。在团队内部测试时这个弹窗几乎每天都要点一次我后来直接把「启动后首次检测到眼动有效」作为校准完成的标志减少测试时的等待时间。5. 进阶把注视选择调「顺手」的两个技巧和一套调试视图5.1 用角度阈值过滤微颤而不是无限拉长 dwellTime很多团队遇到误触第一反应是把dwellTime调到 1.5 秒。这能解决误触但代价是每次选择都变成 1.5 秒的等待。更优雅的做法是给射线加一个「方向惯性」。我在 3.1 节里已经用angleFilter做了实现当新方向与上一帧方向夹角小于阈值时沿用上一帧方向作为射线方向。它本质上是把「眼睛本身的微颤」和「用户有意的视向移动」做了区分——2 度以内的变化是噪声不更新超过 2 度的变化才有意义。要注意一个边界angleFilter设到 3 度以上时用户快速扫过一排按钮射线会「拖影」在旧方向上命中延后一两个帧但 Dwell 本身是累积计时的所以体感不明显。我还测试过更精确的算法——用最近 5 帧方向的加权平均做平滑效果类似但没有角度阈值直观调参成本更高。5.2 混合交互眼睛定位、手柄确认——最不容易误触的方案Gaze Selection 最让人担心的是误触尤其是高危操作。我现在的常用做法是混合眼睛负责把焦点移到目标上手柄按键负责确认。这样选错目标不会触发任何后果用户也保留了「反悔权」。实现上不需要改太多DwellSelector提供一个Confirm()方法外部把手柄按键事件接进来public void Confirm() { if (isDwelling !triggered) { triggered true; OnSelected?.Invoke(lastTarget); progressRing?.Complete(); } }这样组合之后dwellTime可以适当回调到 0.6 秒——用户只需要把视线稳定在目标上 0.6 秒再按一下确认整体操作比纯 Dwell 轻快又比纯手柄稳定。纯 Gaze 方案留给「只是看一下」的低风险交互比如界面入口、灯光开关混合方案留给会删除数据、提交订单这类操作。5.3 把注视点和命中物体画在 Scene 视图肉眼验证比日志快十倍调试眼动交互最难的是「你看不到用户在看什么」。在编辑器里运行头显上的注视点不进入编辑器视野光靠 Debug.Log 打坐标很难定位「为什么打不中」。我养成了一个习惯把注视射线和命中点用OnDrawGizmos画到 Scene 视图每次调参先看射线方向再有针对性地处理。private void OnDrawGizmos() { if (GazeDirection Vector3.zero) return; Gizmos.color Color.yellow; Gizmos.DrawRay(GazeOrigin, GazeDirection * maxDistance); if (CurrentHit ! null) { Gizmos.color Color.red; Gizmos.DrawWireSphere(HitPoint, 0.02f); } }在 Scene 视图里把 Gizmos 开启后我们能直接看到黄色射线是否准确穿过了目标物体红点是命中位置。配合上面提到的左右眼射线调试很多方向性偏移当场就能发现不用反复戴头显确认。5.4 调试视图里加一个「置信度」色彩维度如果你用的设备 SDK 能返回注视置信度confidencelenicate 0~1把它映射到射线颜色上置信度高就画成亮黄色置信度降低时渐变到橙色或红色。这能帮你区分「射线本身不正」和「当前眼睛没有稳定注视」两种截然不同的问题。这两个问题以前我靠猜现在低头看一眼 Gizmos 颜色立刻明白归因省掉大半扯皮时间。做眼动交互这两年最深的个人体会是眼动数据本身是「准」的但人的眼睛天然会抖交互设计必须容忍这种抖动而不是靠用户练成「定睛术」。我不会把 dwellTime 一调了之而是会优先检查角度过滤、命中宽容窗口、反馈节奏这三处。一个真正顺手的 Gaze Selection用户几乎感受不到「延迟」的代价只会觉得「我要它亮它就亮」。这套思路从硬件视角看也还算保守——不依赖任何厂商私有 SDK 的魔法接口只用了 OpenXR 和 Unity XR API 的基础能力换设备、换平台都能平移过去。你按 3.1 的脚本把射线打出来按 5.3 的方式调通 Gizmos 可视化剩下的手感就是反复调dwellTime和angleFilter之间取舍了。希望这次的经验能帮到你少走我那段把 dwellTime 调到 2 秒然后被测试用户骂的弯路。本文还有配套的精品资源点击获取