☰
Unity VR眼动选物实战:从注视射线到停留时长与避坑指南
2026/10/1 4:56:20 网站建设 项目流程

简介:这份资源面向Unity VR开发者与交互设计学习者,聚焦用眼睛注视来选择物体的实现方案,适合已掌握Unity基础操作、希望入门VR眼动交互的中级开发者。工程基于Unity 2019.4.9f1与VS2019搭建,附带Test场景可直接运行验证。压缩包共283个文件,约4.7MB,其中41个C#脚本承载注视检测、高亮渲染与事件注册等核心逻辑,19个unity场景与10个prefab提供可复用示例,另有shader、材质、动画、控制器及工程配置文件,结构完整便于二次开发。已有214人学习关注。资源核心价值在于给出了一套可落地的注视交互框架:在MainCamera挂载WatchController与HightlightingRenderer,为待注视物体添加WatchEvent并像UGUI Button那样注册事件,即可实现视线选中与高亮反馈,帮助读者快速理解VR眼动选择的实现思路并迁移到自己的项目中。

1. 眼动选物:从「盯着看就能选中」到 Unity VR 里真正跑通

在 Unity VR 项目里做「用眼睛注视选择物体」,第一反应往往是拿手柄射线凑合,但真正戴上头显之后你会发现,眼睛的注视点比手柄射线快得多,也自然得多。问题在于,这件事不是调一个 API 就能收工的:你得先让头显把眼动数据吐出来,再把它换算成场景里的一条射线,然后处理「看多久算选中」「看哪里算命中」「误触怎么过滤」这一连串工程问题。我见过太多团队卡在第一步——设备 SDK 接进来了,但注视射线和手柄射线打架,或者注视点飘得根本没法用。这篇笔记就按我实际落地的顺序,把 Unity VR 眼动选物从原理到代码、从参数到踩坑讲清楚,适合正在做 PICO4 或同类一体机 Unity 开发、想给交互加一层「眼神」的从业者。

2. 眼动数据怎么进 Unity:从设备 SDK 到注视射线

2.1 先搞清楚你拿到的到底是什么数据

眼动追踪在 VR 里通常输出两类东西:一是注视点方向(gaze direction),一个从眼睛出发的归一化向量;二是注视原点(gaze origin),一般是双眼中心或某个参考点。不同设备 SDK 给的坐标系不一样,有的直接给世界空间,有的给的是相对头显的局部空间。我一般会先写一个最小脚本,把原始数据打印出来,确认它到底是局部还是世界、单位是不是米、左右眼是否分开。这一步不做,后面射线方向错了你都不知道错在哪。

常见做法是:设备 SDK 提供一个GazeOrigin和GazeDirection,你把它转成 Unity 的Ray,然后Physics.Raycast去检测。但注意,眼动数据频率通常比渲染帧率高,直接每帧读最新值就行,不需要自己做插值,插值反而会引入延迟。

2.2 把眼动数据转成一条可用的注视射线

下面这段代码是我在 PICO4 Unity 项目里常用的最小实现,核心就是把 SDK 的注视姿态转成世界空间射线,并做一次命中检测。不同 SDK 的类名可能不同,但逻辑一致。

using UnityEngine; public class EyeGazeSelector : MonoBehaviour { // 设备 SDK 提供的注视数据接口,不同设备替换成对应类 public Transform gazeOriginTransform; // 通常挂在相机或眼动节点上 public float maxDistance = 10f; // 注视射线最大检测距离 public LayerMask selectableLayer; // 只检测可选中物体,避免打到 UI 或环境 private Ray gazeRay; private RaycastHit hitInfo; void Update() { // 1. 构造注视射线:原点用 SDK 给的注视原点,方向用注视方向 // 这里假设 gazeOriginTransform.forward 就是注视方向 gazeRay = new Ray(gazeOriginTransform.position, gazeOriginTransform.forward); // 2. 射线检测,只打 selectableLayer if (Physics.Raycast(gazeRay, out hitInfo, maxDistance, selectableLayer)) { // 3. 命中后可以高亮,具体逻辑交给后续章节 Debug.DrawLine(gazeRay.origin, hitInfo.point, Color.green); } else { Debug.DrawLine(gazeRay.origin, gazeRay.origin + gazeRay.direction * maxDistance, Color.red); } } }

逻辑说明:gazeOriginTransform的位置和朝向决定了射线。如果你的 SDK 给的是局部数据,需要先TransformPoint和TransformDirection转到世界空间。maxDistance我一般设 10 米,太远没意义,太近又容易漏掉远处物体。selectableLayer是关键,眼动射线非常容易打到地板、墙壁或者 UI 上,用 LayerMask 过滤是必须的。

参数说明:maxDistance根据场景大小调,室内场景 5 到 15 米都合理;selectableLayer建议单独建一个「GazeInteractable」层,不要和手柄射线共用,否则后面做区分交互会很麻烦。

2.3 注视点稳定性和滤波:为什么你的射线在抖

眼动数据天生有噪声,尤其是快速扫视时。如果你直接把原始数据拿来做射线,会发现准星一直在小范围抖动,用户根本没法稳定「盯住」一个物体。常见做法是加一个低通滤波或者滑动平均,但要注意延迟。我一般用指数平滑,系数取 0.2 到 0.4 之间,既能压抖动又不至于让注视点明显滞后。

// 指数平滑示例:对注视方向做平滑 private Vector3 smoothedDirection; public float smoothFactor = 0.3f; // 越小越平滑,但延迟越大 void Update() { Vector3 rawDir = gazeOriginTransform.forward; smoothedDirection = Vector3.Slerp(smoothedDirection, rawDir, smoothFactor); gazeRay = new Ray(gazeOriginTransform.position, smoothedDirection); // 后续检测同上 }

注意:平滑只对方向做,不要对原点做,原点抖动一般不大,平滑反而会让射线起点偏移。另外,如果设备本身提供了「注视点稳定」选项,优先用设备端的,比在 Unity 里做更省事。

3. 注视选物的交互逻辑:停留时长、命中判定与视觉反馈

3.1 停留时长怎么定:200ms 还是 800ms

眼动选物最核心的参数就是停留时长(dwell time)。太短容易误触,太长用户觉得累。我实测下来,600ms 到 800ms是比较舒服的区间,具体看场景:如果是菜单选择,600ms 够用;如果是场景里选物体,800ms 更稳。低于 400ms 基本没法用,误触率极高。

实现上,你需要一个计时器,当注视射线持续命中同一个物体时累加时间,达到阈值就触发选中。一旦射线离开物体,计时器清零。

private float dwellTimer = 0f; public float dwellThreshold = 0.7f; // 700ms private GameObject currentTarget; void Update() { // 假设已经拿到 hitInfo if (hitInfo.collider != null) { GameObject hitObj = hitInfo.collider.gameObject; if (hitObj == currentTarget) { dwellTimer += Time.deltaTime; if (dwellTimer >= dwellThreshold) { SelectObject(hitObj); dwellTimer = 0f; // 防止重复触发 } } else { currentTarget = hitObj; dwellTimer = 0f; } } else { currentTarget = null; dwellTimer = 0f; } }

参数说明:dwellThreshold根据用户测试调,建议做成可配置。另外,触发后最好加一个短冷却,比如 0.3 秒内不再检测,否则用户眨眼或者微动会连续触发。

3.2 命中判定:为什么你盯的是 A 却选中了 B

眼动射线的命中判定比手柄射线更容易出问题,因为眼睛的注视点本身就有误差,加上头显的校准偏差,实际注视位置可能和你以为的差好几度。常见做法是不要用单点射线,而是用一个小球体做球形检测,或者用Physics.SphereCast代替Raycast,给一个小的半径(比如 0.05 到 0.1 米),这样能容忍一定的注视误差。

// 用 SphereCast 代替 Raycast,半径 0.08 米 float castRadius = 0.08f; if (Physics.SphereCast(gazeRay, castRadius, out hitInfo, maxDistance, selectableLayer)) { // 命中逻辑同上 }

注意:SphereCast在物体边缘可能会命中到后面的物体,如果场景里物体排列密集,需要结合hitInfo.distance做二次判断,或者用RaycastAll取最近的有效目标。

3.3 视觉反馈:让用户知道「快选中了」

没有反馈的眼动选物是灾难,用户不知道自己在看哪里、还要看多久。我一般会做三层反馈:注视点光标(一个小圆点,跟随注视射线)、目标高亮(命中物体变色或描边)、进度环(在光标或物体上显示停留进度)。进度环用Image.fillAmount或者Material的_Progress属性都行,关键是让用户感知到「再坚持一下就好了」。

// 进度环更新示例 public Image progressRing; // UI 上的进度环 void Update() { if (currentTarget != null) { progressRing.fillAmount = dwellTimer / dwellThreshold; } else { progressRing.fillAmount = 0f; } }

参数说明:进度环的填充速度要和dwellThreshold一致,不要用独立的动画时间,否则用户会觉得反馈和实际触发对不上。

4. 避坑与排查:眼动选物最容易翻车的 5 个地方

4.1 注视射线打到了 UI 上,物体选不中

现象:明明盯着物体,但射线总是命中 Canvas 上的按钮或者背景图。原因:UI 的 Layer 和可选中物体在同一层,或者 UI 的Raycast Target没关。解决:把 UI 单独放一个 Layer,眼动射线的selectableLayer不包含 UI 层;或者把不需要交互的 UI 的Raycast Target取消勾选。

4.2 停留计时器不重置,导致「看一眼就选中」

现象:用户只是扫了一眼物体,结果直接触发了选中。原因:计时器在射线离开物体时没有清零,或者currentTarget判断用的是hitInfo.collider而不是gameObject,导致同一物体的不同碰撞体被当成不同目标。解决:确保离开时dwellTimer = 0,并且用hitInfo.collider.gameObject做比较,而不是collider本身。

4.3 眼动数据延迟大,注视点跟不上头部转动

现象:转头之后,注视点要过一会儿才跟过来。原因:设备 SDK 的眼动数据更新频率低,或者你在 Unity 里做了过度平滑。解决:优先用设备端的最新数据,减少平滑系数;如果设备支持,开启「注视点预测」或「延迟补偿」。我一般会把平滑系数调到 0.5 以上,牺牲一点平滑换低延迟。

4.4 不同用户眼动校准差异大,同一套参数有人用不了

现象:A 用户觉得 700ms 刚好,B 用户觉得太短总是误触。原因:每个人的眼动特征和校准精度不同,固定阈值不通用。解决:在应用启动时加一个简单的校准流程,让用户盯着几个点看,根据命中情况动态调整dwellThreshold和castRadius。至少提供设置选项让用户自己调。

4.5 注视选物和手柄射线冲突,两个准星同时出现

现象:用户既能看到手柄射线,又能看到注视光标,不知道该用哪个。原因:两套交互系统没有做互斥。解决:根据输入源切换,比如检测到手柄有输入时隐藏注视光标,或者用「最近使用优先」策略。我一般会做一个InputModeManager,统一管理当前激活的交互方式。

5. 进阶:用注视点做「意图预测」和动态阈值

5.1 根据注视速度动态调整停留时长

固定 700ms 是能用,但不够聪明。如果用户快速扫视,说明他在搜索,这时候可以适当延长阈值避免误触;如果用户已经稳定注视某个物体,说明意图明确,可以缩短阈值加快选中。实现上,你可以计算注视方向的变化率,变化率大时dwellThreshold乘以 1.5,变化率小时乘以 0.8。

// 动态阈值示例 private Vector3 lastDir; private float dirChangeRate; void Update() { Vector3 currentDir = gazeOriginTransform.forward; dirChangeRate = Vector3.Angle(lastDir, currentDir) / Time.deltaTime; lastDir = currentDir; float dynamicThreshold = dwellThreshold; if (dirChangeRate > 30f) // 快速扫视 dynamicThreshold *= 1.5f; else if (dirChangeRate < 5f) // 稳定注视 dynamicThreshold *= 0.8f; // 用 dynamicThreshold 替代固定阈值 }

参数说明:dirChangeRate的单位是度/秒,30 和 5 这两个阈值是我在 PICO4 上实测的经验值,不同设备可能需要微调。

5.2 用注视点做「预高亮」,减少用户等待感

在停留计时器还没满的时候,就可以让物体有一个轻微的高亮或者放大,告诉用户「我注意到你在看它了」。这个反馈不需要等计时器满,命中即触发,能显著提升交互的流畅感。我一般会在currentTarget变化时立刻给物体加一个描边,计时器满了再换成选中态。

5.3 验证方法:用日志和回放确认注视轨迹

眼动交互很难靠肉眼调试,我习惯在开发阶段把注视射线的原点、方向、命中物体、计时器状态全部打到日志里,然后用一个简单的回放工具在 Scene 视图里画出轨迹。这样能直观看到用户到底在看哪里、为什么没选中。Unity 的Debug.DrawLine配合Gizmos就够用,不需要额外插件。

最后说一个我自己的习惯:每次调完眼动参数,我都会戴上头显连续测试 20 次选中操作,记录误触和漏触的次数。参数不是调一次就完事,不同场景、不同光照、不同用户都得重新验证。眼动选物这件事,玄学成分有,但更多是耐心。希望帮到你。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询