简介:这是一份面向Unity VR开发者的注视交互资源包,解决在VR场景中通过视线凝视选择、触发物体的核心需求,适合想快速实现Gaze交互的初中级开发者,也可用于毕业设计或课程项目。工程基于Unity 2019.4.9f1与VS2019编写,自带Test场景可直接运行验证;主逻辑由WatchController.cs与WatchEvent.cs两个脚本协作完成,事件注册方式类似UGUI Button,另配有HighLightingRenderer高亮脚本,让注视目标可视化呈现。压缩包共283个文件,涵盖C#脚本、Unity场景、材质、动画、着色器、FBX模型及预制体等类型,总体积仅4.7MB,内容紧凑且便于二次开发。目前已有213人学习下载,希望快速掌握VR视线选择机制的开发者,可从中获得一套结构清晰、直接复用改写的完整参考。
1. 用眼睛注视代替手柄点击:把 VR 里的“看”变成“选”
在 VR 里选中一个物体,第一反应是抬手柄、扣扳机;但选项一多,手柄的移动成本比眼神高出太多,尤其菜单密集的场景里,手还得先稳住再瞄准。这个工程的做法是把“注视”本身变成一个输入信号:MainCamera 每一帧向前发射一条射线,命中物体后立刻高亮,并且像 UGUI 的 Button 一样触发事件回调。工程基于 Unity 2019.4.9f1 开发,核心代码就是 WatchController.cs、HighlightingRenderer.cs、WatchEvent.cs 三个文件,自带 Test 场景可以跑起来看效果。适合做过 VR 交互、想把手柄点击替换成视线选择的开发者,也适合刚接触 VR 交互想搭一个最小可用视线选择框架的人,照着导入就能看到眼神选中物体的完整链路。
2. 拆解三层架构:射线检测、高亮反馈与事件分工
2.1 视线射线的来源:摄像机方向还是瞳距偏移
先解决一个认知问题:工程里说的“眼睛注视”,不是硬件级眼球追踪,而是“视线凝视”方案。用头显朝向代表用户视线方向,MainCamera 的 transform.forward 就是那条虚拟视线。对不支持眼动追踪的设备(大部分 PCVR 头显和初代一体机)来说,这是最通用、也最不会出错的取法。
WatchController.cs 挂在 MainCamera 上,核心就一件事:每帧从相机位置构造一条射线,丢给 Physics.Raycast 检测命中。常见实现里射线起点直接用 transform.position,方向用 transform.forward,最大检测距离放在一个公开字段里,默认给 50m 到 100m。值得注意的细节是,射线起点不应该放在相机朝前方向的绝对中心,而是略微下移约 0.1m 到 0.15m,模拟人眼水平的平均高度。很多新手直接拿相机位置当起点,结果发现看地面物体时命中点偏上,这就是没做视线偏移补偿。
检测逻辑里还有一层容易被忽略:同一帧可能命中多个物体,WatchController 需要按距离排序后只取最近的那个。射线检测的结果只告诉你有物体被命中,不会自动区分“我想选”和“只是路过”。所以工程在 WatchController 内部维持了一个“当前注视对象”的状态,只有命中对象发生变化时才通知上层,避免每帧重复触发相同的进入事件。
void Update() { Ray ray = new Ray(transform.position + Vector3.down * eyeHeightOffset, transform.forward); RaycastHit hit; if (Physics.Raycast(ray, out hit, maxDistance, watchMask)) { WatchEvent evt = hit.collider.GetComponent<WatchEvent>(); if (evt != null) { SwitchWatchTarget(evt); } else { SwitchWatchTarget(null); } } else { SwitchWatchTarget(null); } }这段代码里 eyeHeightOffset 是视线高度补偿,建议设 0.1f 左右;watchMask 是 LayerMask,用来过滤掉地面、墙壁这类不想交互的物体。SwitchWatchTarget 是内部状态切换方法,只有传进来的 target 和上一次不同时才执行真正的逻辑,这就是防止事件每帧重复触发的关键。值得一提,Physics.Raycast 默认只检测带 Collider 的物体,所以测试场景里所有可交互物体必须有碰撞体,这一点后面实操章节会再强调。
2.2 高亮反馈:HighlightingRenderer 为什么比改材质颜色靠谱
选中的物体要有视觉反馈,很多新手第一反应是动态替换材质颜色,但这套工程没有走这条路,而是用 HighlightingRenderer 做后处理高亮。原因很实际:换材质容易,还原材质才是灾难。一个物体挂多个 Material,或者有多层 MeshRenderer 时,换回去经常漏掉某一个,而且高亮材质和原材质之间的过渡闪烁很难看。
HighlightingRenderer.cs 挂到 MainCamera 上之后,通过 OnRenderImage 在渲染管线末端做后处理。原理大致是:先把深度图采样出来,找出物体轮廓边缘,再做一次边缘发光。这样做的好处是完全不动物体的原始材质,命中时只影响画面最终输出,移开视线后画面自动恢复正常,不需要任何还原逻辑。
不过代价也很直接:全屏后处理会带来额外的 GPU 消耗。尤其是移动端 VR(比如一体机),每帧多一次全屏采样,帧率会明显掉一截。工程里比较折中的做法是只在射线命中时才开启高亮效果,没有命中时直接输出原图,避免无意义的全屏处理。
2.3 WatchEvent 的回调设计:三个生命周期事件
WatchEvent.cs 是整个工程里和业务最贴近的一层,注释里说得很直白——“就像 UGUI 的 Button 一样使用”。Button 靠 onClick 暴露一个 UnityEvent,WatchEvent 则把注视行为拆成了三个阶段:进入、停留、离开,外加一个完成事件。这样设计的好处是,业务代码不需要自己维护射线检测状态,只需要在 Inspector 里把回调函数拖到对应事件上。
进入事件通常配合高亮反馈出现,比如切换物体颜色或播放提示音;停留事件用来驱动一些持续性行为,比如显示进度条;离开事件负责清理状态,把进度条归零。最后一组是“注视完成”事件,等视线在物体上停留超过设定阈值后触发,这个事件才对应最终的“选中”动作,类似手柄的扳机按下。
public class WatchEvent : MonoBehaviour { public UnityEvent onWatchEnter; public UnityEvent onWatchStay; public UnityEvent onWatchExit; public UnityEvent onWatchComplete; public float completeTime = 2.0f; public void OnWatchEnter() { onWatchEnter.Invoke(); } public void OnWatchStay(float deltaTime) { onWatchStay.Invoke(); } public void OnWatchExit() { onWatchExit.Invoke(); } public void OnWatchComplete() { onWatchComplete.Invoke(); } }注意 onWatchStay 上挂的回调是每帧执行,不要在里头放高频对象生成之类的逻辑。completeTime 是用作注视确认的秒数,类似“盯着看两秒才确认选中”,防止路过物体时误触发,这个参数值得在项目里调成可配置项,不同场景需要不同的松紧度。
2.4 工程文件里的 WatchPoint.anim、Cool.anim 是什么
工程正文里列着一堆文件,除了脚本以外,WatchPoint.anim 和 Cool.anim 这两个动画资源值得解释。WatchPoint 是注视光标,也就是跟随射线命中点的小圆点或准星,工程里用动画控制它的缩放和透明度——当视线刚命中一个可交互物体时,光标会从小变大,给出“你可以选我”的暗示。
Cool.anim 则是配合高亮效果使用的过渡动画,控制高亮强度或颜色的渐变。你可能注意到这俩都没有对应的代码逻辑,因为动画本身就是状态机,Unity 的 Animator 会自动驱动它们。导入工程后,如果发现注视光标不显示,大概率不是代码问题,而是 Animator Controller 没有正确分配到光标的组件上。
ProjectSettings.asset 那一批文件是 Unity 自动生成的项目配置,包括 InputManager、TagManager、GraphicsSettings 之类。这块要特别小心:整个文件夹导入别人的项目时,这些设置文件会覆盖你现有的同名配置。比如 TagManager.asset 里可能包含自定义 Layer,如果和你当前项目的 Layer 编号冲突,会出现物体碰撞分组错乱的情况。我一般只拷 Assets 目录下的脚本、预制体、场景和动画资源,ProjectSettings 让 Unity 自动重建,不保留旧文件。
3. 在 Unity 2019.4.9f1 里跑通注视选择:完整实操
3.1 项目导入与版本环境确认
工程锁定 Unity 2019.4.9f1,编辑器版本最好保持一致。打开 Unity Hub,找到 2019.4.9f1 的对应版本,没有就先在 Archive 页面下载安装。版本号不匹配时,打开工程 Unity 会自动升级 API,大多数情况没问题,但渲染管线相关的 GraphicsSettings 文件偶尔会丢失部分字段。
工程带一个 Test 场景,这是最快的验证入口。直接用 Unity 打开工程目录后,在 Project 窗口定位到 Test 场景双击加载,按 Play 就能测试。Test 场景里已经配置好了 MainCamera、WatchPoint 光标的引用关系和所有脚本绑定,不用做任何手动设置。如果你打开后出现渲染异常或脚本编译报错,先确认脚本编译平台设置——VS2019 写的 C# 脚本在 Unity 2019.4 里没有兼容问题,但若装了高版本 VS 生成的 .NET 版本太高,会出现 API 引用丢失,需要手动改回 .NET 4.x。
3.2 从头搭一个测试场景:碰撞体是关键
不满足于 Test 场景的话,自己新建场景也就几分钟。新建场景后在地面上放几个 Cube,位置分散开,高度放在人眼的平视范围内。每个 Cube 都必须带 Box Collider,这是 Physics.Raycast 命中的基础条件。很多人做完脚本、绑好事件,运行起来却没反应,八成就是忘了加 Collider。
为了后期好过滤,把所有可交互物体放到同一个 Layer,比如新建一个叫 Interactable 的 Layer,然后把立方体、按钮、可拾取物都归进去。WatchController 的 watchMask 里只勾选 Interactable,其他物体(地面、墙体、天空盒)全部忽略,省掉很多不必要的射线检测开销。
3.3 绑定 MainCamera 上的两个核心脚本
选中 MainCamera,在 Inspector 点击 Add Component,依次添加 WatchController.cs 和 HighlightingRenderer.cs。添加之后,WatchController 会出现几个关键字段需要赋值:Watch Point 是注视光标对象(把场景里的 WatchPoint 拖进去),Max Distance 填写 100,Watch Mask 选择刚才建好的 Interactable 层级。HighlightingRenderer 不需要额外赋值,它会自动挂载到相机的后期处理链上。
如果你严格按上面步骤操作,此时运行场景,移动鼠标让相机视角转动——桌面模式下用鼠标转视角就行——会看到射线命中物体时出现高亮描边。注意,如果没有 VR 头显,普通的 Game 视图里也能测试,因为摄像机的 forward 就是视线方向,鼠标控制视角旋转完全等价于头显转动视角。
3.4 给物体挂 WatchEvent 并注册回调
最后一步是给单个物体挂 WatchEvent 脚本,然后注册事件。先写一个调用测试脚本 CubeAction.cs,内容是一个简单的 Debug.Log 和颜色变化,然后把它挂到 Cube 上。再给 Cube 添加 WatchEvent 组件,在 Inspector 底部的事件列表里,把 onWatchEnter 拖入 CubeAction 实例,选择对应方法。同样把 onWatchComplete 也注册到同一个方法上,或者注册不同的方法验证生命周期。
public class CubeAction : MonoBehaviour { public void OnSelected() { GetComponent<Renderer>().material.color = Color.green; Debug.Log(gameObject.name + " selected"); } public void OnDeselected() { GetComponent<Renderer>().material.color = Color.white; Debug.Log(gameObject.name + " deselected"); } }注册完成后,运行测试:视线移入 Cube,onWatchEnter 触发,颜色变绿;持续注视到阈值时间后 onWatchComplete 触发,控制台打印 selected 消息;视线移开,onWatchExit 触发,颜色恢复白色。整个交互闭环就出来了。
3.5 Inspector 运行时状态观察与调试
调试时最实用的是把 WatchController 组件在 Play 模式下展开来看内部状态。工程实现里通常会有一个当前命中对象的引用字段,既可以看到当前锁定的是哪个物体,还可以确认状态切换是否正常。如果发现某次移开视线后状态没有复位,说明射线未命中分支没处理好,下一次进入时 SwitchWatchTarget 认为目标没变化,事件就不会重新触发。
4. 避坑清单:注视选择项目里最常见的五类问题
4.1 高亮闪烁不停
现象:物体被注视时高亮忽亮忽灭,有时还闪到旁边的物体上。
原因:WatchController 和 HighlightingRenderer 的执行顺序不稳定,射线检测的结果还没更新,高亮渲染已经采了上一帧的深度图;或者射线同时命中了两个物体,内部状态在两者之间反复切换。
解决:在 Project Settings 的 Script Execution Order 里,给 WatchController 设一个靠前的执行顺序(比 HighlightingRenderer 早执行),确保射线检测结果先更新,高亮渲染后读取。多个物体命中时,检查代码里是否做了距离排序,只保留最近的一个。
4.2 事件永远只触发一次,再看一遍没反应
现象:第一次视线进入物体时 onWatchEnter 正常触发,视线移开再进入,事件不再触发。
原因:WatchController 的当前命中对象在射线未命中时没有置空,内部状态一直停留在上一个物体上,再次进入时 SwitchWatchTarget 判定新目标跟上一次相同,直接跳过了触发逻辑。
解决:检查射线未命中分支是否调用了 SwitchWatchTarget(null),以及 onWatchExit 是否在状态切换时被调用。一个简单的复现方式是运行测试后把视线转向天空,再看控制台有没有打印 exit 日志,没有说明状态未复位。
4.3 视线穿过 UI 面板选中了背后的 3D 物体
现象:VR 界面里的按钮可以点击,但检测光标透过 UI 面板选中了后面的物体,事件被错误触发。
原因:Physics.Raycast 不会检测 UGUI 的 GraphicRaycaster,所有 UI 元素对射线检测来说是透明体,射线穿过面板直接碰到后面的物体。
解决:用事件系统的 GraphicRaycaster 先做一次 UI 命中检测,结果非空时直接跳过 WatchController 的射线分支。代码里在 Update 开头加一段 EventSystem 的判定,UI 优先,3D 其次。
if (EventSystem.current != null) { PointerEventData ped = new PointerEventData(EventSystem.current); ped.position = new Vector2(Screen.width / 2f, Screen.height / 2f); var results = new List<RaycastResult>(); EventSystem.current.RaycastAll(ped, results); if (results.Count > 0) return; }这段代码把屏幕中心点作为 UI 射线起点,命中任何 UI 元素就直接返回,不进入注视检测逻辑。注意 PointerEventData 每次分配会产生少量 GC,高频调用时建议缓存复用或只在 VR 设备的交互场景里启用。
4.4 戴上头显后命中点偏移严重
现象:桌面模式下测试一切正常,换成 VR 头显后,光标和目标物体之间总有偏差,越是边缘物体偏差越大。
原因:桌面模式和 VR 模式的相机投影不一致,VR 里眼球位置和摄像机位置有物理偏移,瞳孔间距没做校准。尤其是把 Ray 起点直接放在相机位置时,单目渲染的偏移会被放大。
解决:把射线起点改为 transform.position + transform.right * interpupillaryOffset,偏移量等于头显瞳距的一半,通常取值 0.03m 左右。另一个常见做法是直接用 Camera.main.ScreenPointToRay 从屏幕中心发射射线,让 Unity 自己处理投影变换,能消除大部分偏移。
4.5 移动端一体机上帧率掉到目标线以下
现象:PC 上流畅,打包到一体机后明显卡顿,尤其是高亮物体多的时候。
原因:HighlightingRenderer 的全屏后处理在移动端 GPU 上开销过大,每个被高亮物体还会增加额外的边缘检测计算量。
解决:限制同一帧最多只有一个高亮物体,毕竟注视选择场景里用户同时只可能盯着一个目标;同时把后处理降分辨率渲染,例如渲染到屏幕尺寸一半的 RenderTexture 再做上采样,画质损失肉眼几乎不可见但帧率提升显著。
5. 参数组合调优:从演示品进化为可交互产品
5.1 注视确认时间与反馈节奏的配合
completeTime 这个参数直接决定产品的松紧度。设 0.5s 会让人感觉灵敏但也容易误触,设 3s 则能有效防误触但对用户耐心是考验。工业上比较合理的做法是 1.2s 到 2s 之间,同时配一个进度条反馈,让用户知道自己已经盯了多久。进度条可以使用一个简单的 Image 组件做 fillAmount,放在视线命中点的位置,每帧根据注视时间计算填充比例。离开物体立即清零,避免下次进入时产生跳变。
高亮强度也要分两级表达:进入物体时高亮 30%,表示“我知道你在看我了”,注视过程超过阈值一半时间后高亮提到 80%,表示“马上选中”。两层反馈对避免误触非常有效,用户能清晰感知系统状态。
5.2 LayerMask、检测距离与碰撞体的边界范围
检测距离默认给到 100m 在室内 VR 场景里过远,建议根据应用类型设置为 2m 到 5m。过近会丢失远处大物体的交互,过远会增加无用射线命中结果。LayerMask 是性能和安全感的双重保障,地面、墙体一定要单独放一个 Layer 排除在 watchMask 之外。实际项目里我还遇到过碰撞体比视觉模型大一圈的情况,比如一个按钮的视觉尺寸是 0.5m,但碰撞体是 1m,用户还没看到按钮就被选中了。这个问题的排查方法是在场景视图里打开碰撞体显示,逐个检查可交互物体的 Collider 边界是否贴合视觉模型。
5.3 多平台设备的配置差异
Pico 和 Oculus 两套 SDK 在射线发射上有一个隐蔽差异:部分 Oculus 设备会自动校正用户瞳距,相机的 localPosition 会实时变化;Pico 则很少做这种调整。如果你的项目同时跑两种设备,不要假设相机位置恒定。另外,Pico 4 这类设备对后处理精度的容忍度比 PCVR 低得多,在保证帧率的前提下尽量降低后处理分辨率。工程里如果用了 XR Plugin Management,需要注意 OpenXR 和各自厂商 SDK 的切换逻辑,确保射线起点使用的相机数据是最新的 rather than 缓存的旧值。
6. 把“注视选择”升级成“注视拖拽”和“视线+手柄混合交互”
选物体只是眼神交互的第一层,接下来自然的方向是拖拽。在 WatchEvent 的基础上做拖拽,核心思路是:onWatchEnter 时记录物体的其实位置,onWatchStay 期间每帧把物体移动到射线命中点,onWatchExit 时停止移动。这套逻辑不需要改 WatchController 的代码,只是扩展 WatchEvent 的回调使用方式。
public class DragByWatch : MonoBehaviour { private bool isDragging = false; public void StartDrag() { isDragging = true; } public void UpdateDrag() { if (!isDragging) return; Vector3 targetPos = WatchController.Instance.RaycastPoint(); transform.position = Vector3.Lerp(transform.position, targetPos, 0.1f); } public void EndDrag() { isDragging = false; } }这段代码里 StartDrag 对应 onWatchEnter,UpdateDrag 对应 onWatchStay,EndDrag 对应 onWatchExit。RaycastPoint 需要 WatchController 暴露一个公共方法,返回当前射线命中点的世界坐标。拖拽的移动速度用 Lerp 做了平滑,0.1f 的系数接近临界阻尼,不会显得拖泥带水。
如果想把“注视选择”和手柄结合,可以看场景里的事件注册,把“确认选中”从视线停留时间改为手柄扳机键。这样视线负责瞄准,手柄负责确认,操作精度比纯注视更高,这是我在工业级 VR 项目里推荐最多的交互模式:眼球快速定位 + 手柄精确确认,既保留了一部分“眼神秒选”的快捷性,又避免了视线停留带来的误触。从那以后我接手类似的 VR 交互项目,无论需求方要求多花哨的交互方式,我都会先让团队把这一套视线选中 + 高亮反馈 + 事件回调的框架搭好,后续加手势、加语音都只是在这个框架上多挂一个输入源而已。希望帮到你。
本文还有配套的精品资源,点击获取