
简介这份资源面向Unity VR开发者与交互设计学习者聚焦用眼睛注视来选择物体的实现方案适合已掌握Unity基础、希望拓展VR交互技能的中级开发者。工程基于Unity2019.4.9f1与VS2019搭建附带Test场景可直接运行验证。压缩包共283个文件约4.7MB其中41个C#脚本承担注视检测、高亮渲染与事件注册等核心逻辑19个unity场景与10个prefab提供可复用示例另有shader、材质、动画、控制器及fbx模型等配套资源结构完整。已有214人学习关注。使用时在MainCamera上挂载WatchController与HightlightingRenderer脚本为待注视物体添加WatchEvent并注册事件即可像UGUI的Button一样响应注视交互便于快速理解VR眼动选择的实现思路并迁移到自己的项目中。1. 眼动选物为什么你的 VR 项目需要它以及它到底难在哪在 PICO 4 或 Quest 上做 Unity VR 开发手柄射线选物是最省事的方案但一旦用户双手被占用——比如在虚拟驾驶舱里握方向盘、在手术模拟里持器械——手柄就废了。眼动选物解决的正是这个场景用户看一眼物体配合一个确认动作 dwell 停留或手柄扳机就能完成选中。听起来简单但真正落地时你会发现三个硬骨头Unity 的 XR Interaction Toolkit 默认只认手柄射线眼动数据需要单独接入PICO 和 Quest 的眼动 SDK 接口完全不同切换平台意味着重写输入层最要命的是眼动信号本身抖动严重不做滤波和 dwell 状态机选中体验会像在颠簸的车上点鼠标。这篇笔记面向已经跑通 Unity VR 基础场景、想把手柄交互升级为眼动交互的开发者。我会从 XR Interaction Toolkit 的交互器扩展讲起拆到 PICO 4 眼动 SDK 的数据接入、dwell 状态机的参数调优再到跨平台抽象层的设计。中间会给出可直接复现的 C# 脚本和场景配置步骤也会把我在真机上踩过的坑——比如眼动坐标系和 Unity 世界坐标系对不上、dwell 计时器被 Update 帧率带偏——全部摊开讲。读完你应该能在一个下午内把眼动选物跑通并且知道哪些参数该动、哪些千万别碰。2. 从手柄射线到眼动射线XR Interaction Toolkit 的扩展点在哪2.1 为什么不能直接改 XRRayInteractor 的起点XR Interaction Toolkit 的交互逻辑围绕XRBaseInteractor和XRBaseInteractable展开。手柄射线用的是XRRayInteractor它的射线起点由attachTransform决定通常挂在手柄控制器上。一个直觉方案是每帧把attachTransform的位置和旋转改成眼动数据让射线从眼睛发出。这个方案在 Demo 里能跑但真机上会翻车——XRRayInteractor的attachTransform同时参与抓取姿态计算你改了它抓取时物体会瞬移到眼睛位置。血泪经验是眼动射线应该是一个独立的 Interactor不要复用XRRayInteractor的抓取逻辑。正确的扩展点是继承XRBaseInteractor自己实现射线检测和选中逻辑。这样眼动选物和手柄抓取可以共存互不干扰。下面是一个最小化的眼动 Interactor 骨架using UnityEngine; using UnityEngine.XR.Interaction.Toolkit; public class EyeGazeInteractor : XRBaseInteractor { [SerializeField] private float gazeRayLength 10f; [SerializeField] private LayerMask gazeLayerMask ~0; [SerializeField] private Transform gazeOrigin; // 眼动数据驱动的空物体 private RaycastHit currentHit; private bool hasHit; protected override void Awake() { base.Awake(); // 不参与抓取只做悬停和选中 allowSelect true; allowHover true; } private void Update() { if (gazeOrigin null) return; // 从眼睛位置沿朝向发射射线 hasHit Physics.Raycast( gazeOrigin.position, gazeOrigin.forward, out currentHit, gazeRayLength, gazeLayerMask, QueryTriggerInteraction.Ignore ); if (hasHit) { var interactable currentHit.collider.GetComponentXRBaseInteractable(); if (interactable ! null !IsHovering(interactable)) { // 触发悬停进入 OnHoverEntered(new HoverEnterEventArgs()); } } } public override void GetValidTargets(ListXRBaseInteractable targets) { targets.Clear(); if (hasHit) { var interactable currentHit.collider.GetComponentXRBaseInteractable(); if (interactable ! null) targets.Add(interactable); } } }这段代码的关键点gazeOrigin是一个空物体它的位置和旋转由眼动 SDK 每帧写入而不是直接改相机。这样做的好处是眼动数据和相机渲染解耦——用户眼球转动时注视点会偏移但头部朝向不变如果直接用相机 forward射线会跟着头动而不是跟着眼动。gazeLayerMask用来过滤掉 UI 层和不可交互层避免射线打到 Canvas 上。GetValidTargets是 XR Interaction Toolkit 新版交互管理的入口必须重写否则悬停事件不会触发。2.2 眼动数据接入PICO 4 和 Quest 的接口差异PICO 4 的眼动 SDK 是PICO Unity Integration SDK眼动数据通过PXR_EyeTracking类获取。Quest Pro 用的是Meta XR Core SDK的OVREyeGaze组件。两者的数据格式都是「注视点在世界坐标系中的位置 旋转」但坐标系定义不同PICO 的眼动原点在头显中心Quest 的眼动原点在双眼中心。如果你不做归一化切换平台时射线会偏移几厘米在近距离选物时就是「看左选右」。我一般会写一个IEyeTrackingProvider接口把两个 SDK 包一层public interface IEyeTrackingProvider { bool TryGetGazePose(out Vector3 position, out Quaternion rotation); } // PICO 实现 public class PicoEyeTrackingProvider : IEyeTrackingProvider { public bool TryGetGazePose(out Vector3 position, out Quaternion rotation) { position Vector3.zero; rotation Quaternion.identity; if (!PXR_EyeTracking.GetCombineEyeGazePoint(out var eyePoint)) return false; if (!PXR_EyeTracking.GetCombineEyeGazeVector(out var eyeVector)) return false; // PICO 返回的是头显局部坐标系需要转到世界坐标 var head Camera.main.transform; position head.TransformPoint(eyePoint); rotation head.rotation * Quaternion.LookRotation(eyeVector); return true; } } // Quest 实现 public class QuestEyeTrackingProvider : IEyeTrackingProvider { [SerializeField] private OVREyeGaze eyeGaze; public bool TryGetGazePose(out Vector3 position, out Quaternion rotation) { position Vector3.zero; rotation Quaternion.identity; if (eyeGaze null || !eyeGaze.EyeTrackingEnabled) return false; // OVREyeGaze 直接给出世界坐标下的注视姿态 position eyeGaze.transform.position; rotation eyeGaze.transform.rotation; return true; } }参数说明PICO 的GetCombineEyeGazePoint返回的是双眼融合后的注视点单位是米原点在头显中心GetCombineEyeGazeVector返回的是归一化方向向量。Quest 的OVREyeGaze组件挂在相机下它的transform已经被 SDK 更新为世界坐标下的注视姿态直接读即可。注意 Quest 需要先在OVRManager里开启Eye Tracking Support否则EyeTrackingEnabled永远是 false。2.3 场景配置从零搭一个眼动选物场景步骤一在 Unity 中新建一个 3D 场景导入 XR Interaction ToolkitPackage Manager 里搜com.unity.xr.interaction.toolkit。步骤二在场景里添加XR Origin删掉自带的两个手柄控制器只保留相机。步骤三在相机下新建一个空物体命名为GazeOrigin位置归零。步骤四给GazeOrigin挂上EyeGazeInteractor脚本把gazeOrigin字段指向自己。步骤五在场景里放几个 Cube每个 Cube 加XRSimpleInteractable组件和BoxCollider。步骤六给EyeGazeInteractor的gazeLayerMask设为DefaultgazeRayLength设为 10。配置完成后运行你会看到射线从眼睛发出但还没有选中反馈。下一步是加 dwell 状态机让「注视超过 1 秒」触发选中。3. Dwell 状态机把抖动的眼动信号变成可靠的选中事件3.1 为什么眼动信号不能直接用来触发选中眼动仪的输出频率通常是 90Hz 到 120Hz但原始信号里混着微颤microsaccade和噪声。如果你直接把「射线命中物体」当作选中条件用户扫视场景时会误触发一连串选中。更麻烦的是不同用户的注视稳定性差异很大——有人盯一个点能稳如磐石有人眼球一直在微动。我实测过不做滤波的情况下误触发率能到 30% 以上用户会明显感到「我还没想选呢它就选了」。Dwell 状态机的思路是射线命中物体后开始计时计时期间如果射线离开物体计时重置只有连续命中超过阈值时间才触发选中。这个阈值通常设在 800ms 到 1500ms 之间。但单纯计时还不够还要加一个「冷却期」——选中后 500ms 内不再响应新的悬停防止用户眨眼或快速扫视导致连续选中。3.2 Dwell 状态机的 C# 实现与参数调优下面是一个可复用的 Dwell 状态机挂在EyeGazeInteractor上using UnityEngine; using UnityEngine.XR.Interaction.Toolkit; public class DwellSelection : MonoBehaviour { [SerializeField] private EyeGazeInteractor gazeInteractor; [SerializeField] private float dwellTime 1.0f; // 触发选中所需停留时间 [SerializeField] private float cooldownTime 0.5f; // 选中后冷却时间 [SerializeField] private float gazeStabilityThreshold 0.05f; // 注视点移动阈值米 private XRBaseInteractable currentTarget; private float dwellTimer; private float cooldownTimer; private Vector3 lastGazePoint; private bool isDwelling; private void Update() { if (cooldownTimer 0f) { cooldownTimer - Time.deltaTime; return; } var target gazeInteractor.GetCurrentInteractable(); if (target null) { ResetDwell(); return; } // 检查注视点是否稳定 var gazePoint gazeInteractor.GetGazePoint(); if (Vector3.Distance(gazePoint, lastGazePoint) gazeStabilityThreshold) { ResetDwell(); lastGazePoint gazePoint; return; } if (target ! currentTarget) { currentTarget target; dwellTimer 0f; isDwelling true; } dwellTimer Time.deltaTime; if (dwellTimer dwellTime) { // 触发选中 target.SendMessage(OnSelectEntered, SendMessageOptions.DontRequireReceiver); cooldownTimer cooldownTime; ResetDwell(); } } private void ResetDwell() { dwellTimer 0f; isDwelling false; currentTarget null; } }参数说明dwellTime是核心参数新手建议从 1.2 秒开始调熟练用户可降到 0.8 秒。cooldownTime防止连续触发0.5 秒是经验值太短会误触太长会显得迟钝。gazeStabilityThreshold用来判断注视点是否稳定单位是米0.05 米大约对应 3 度视角的偏移超过这个值就重置计时。注意Time.deltaTime在 Update 里是帧间隔如果帧率波动大dwellTimer 的累加会不均匀建议在FixedUpdate里做计时或者用Time.unscaledDeltaTime避免时间缩放影响。3.3 视觉反馈让用户知道「快选中了」没有视觉反馈的 dwell 选物是灾难——用户不知道系统有没有在计时会反复盯同一个物体。我一般会加一个环形进度条挂在被注视物体的上方用Image.fillAmount驱动。进度条的填充速度就是dwellTimer / dwellTime。这个反馈必须跟手延迟超过 50ms 用户就会觉得「卡」。实现方式在XRSimpleInteractable的OnHoverEntered事件里实例化一个世界空间的 Canvas每帧更新fillAmount。注意 Canvas 要设成World Space渲染模式并且sortingOrder要高于场景物体否则会被遮挡。如果物体本身有 Shader 支持也可以直接改材质的高亮参数性能更好。4. 避坑与排查眼动选物在真机上的五个翻车现场4.1 射线打不到物体但手柄射线正常现象眼动射线在 Scene 视图里能看到但 Game 视图里没有命中任何物体。原因gazeLayerMask默认是Nothing或者gazeOrigin的 forward 方向反了。PICO 的眼动向量是「从眼睛指向注视点」但有些 SDK 返回的是「从注视点指向眼睛」方向反了射线就朝后打。解决在EyeGazeInteractor里加一行Debug.DrawRay在 Scene 视图里确认射线方向。如果是反的把rotation乘一个Quaternion.Euler(0, 180, 0)。4.2 选中事件触发两次现象注视一个物体dwell 计时结束后选中事件触发了两次。原因XRSimpleInteractable的OnSelectEntered在悬停和选中状态切换时都会调用而我的SendMessage没有做去重。解决在DwellSelection里加一个hasSelected标志位触发后置 true冷却结束后置 false。或者直接用IXRSelectInteractor接口的isSelectActive属性判断。4.3 眼动数据延迟导致「看左选右」现象用户注视左边的物体但射线命中的是右边。原因PICO 的眼动数据有 1 到 2 帧的延迟如果直接在Update里读射线位置会滞后。解决在LateUpdate里更新gazeOrigin的位置和旋转确保在相机渲染之后、下一帧逻辑之前写入。另外可以加一个简单的预测用上一帧的注视点速度外推当前帧的位置但外推系数不要超过 0.3否则会过冲。4.4 Dwell 计时器在低帧率下不准现象在 PICO 4 上跑 72Hz 时 dwell 正常降到 60Hz 时选中变慢。原因Time.deltaTime在帧率波动时累加不均匀而且Update的调用频率和眼动数据频率不一致。解决用Time.unscaledDeltaTime替代Time.deltaTime并且在FixedUpdate里做计时累加FixedUpdate的调用间隔是固定的 0.02 秒不受帧率影响。注意FixedUpdate里不能直接读眼动数据需要先在Update里缓存。4.5 切换平台后眼动完全失效现象在 PICO 上跑得好好的打包到 Quest 后眼动没反应。原因Quest 需要在OVRManager里手动开启Eye Tracking Support而且OVREyeGaze组件必须挂在OVRCameraRig的CenterEyeAnchor下。另外 Quest 的眼动权限需要在AndroidManifest.xml里声明com.oculus.permission.EYE_TRACKING。解决写一个EyeTrackingInitializer脚本在Start里检查平台如果是 Quest 就动态添加OVREyeGaze组件并请求权限。5. 跨平台抽象与性能调优让同一套代码跑在 PICO 和 Quest 上跨平台的第一原则是把平台相关的代码全部塞进IEyeTrackingProvider的实现类里业务逻辑只依赖接口。我一般会在EyeGazeInteractor的Awake里用#if宏定义选择 Providerprivate IEyeTrackingProvider provider; private void Awake() { #if PICO_PLATFORM provider new PicoEyeTrackingProvider(); #elif QUEST_PLATFORM provider new QuestEyeTrackingProvider(); #else provider new EditorSimulatedEyeTrackingProvider(); // 编辑器里用鼠标模拟 #endif }编辑器模拟 Provider 很重要——你不可能每次调参数都打包到真机。我一般用鼠标位置映射到屏幕坐标再从相机发射一条射线模拟眼动注视点。这样在 Editor 里就能调 dwell 时间和视觉反馈效率提升至少三倍。性能方面眼动选物的开销主要在射线检测和 UI 更新。射线检测每帧一次Physics.Raycast开销可以忽略。UI 更新如果每帧改fillAmount在移动端会有 Canvas 重建的开销。优化方法是只在fillAmount变化超过 0.01 时才调用SetVerticesDirty或者直接用MeshRenderer加一个简单的扇形 Shader避开 Canvas 系统。最后一个技巧dwell 时间不要写死根据物体距离动态调整。远处的物体注视精度低dwell 时间应该短一些比如 0.8 秒近处的物体容易盯准dwell 时间可以长一些比如 1.5 秒防止误触。这个映射关系用AnimationCurve配在 Inspector 里就能调。我自己的习惯是每次换新场景先花十分钟用编辑器模拟把曲线调到位再打包真机验证。眼动交互的玄学在于参数差 200ms体验就是天壤之别。希望帮到你。本文还有配套的精品资源点击获取