简介面向Unity与VR开发者的注视点交互示例工程基于Unity 2019.4.9f1和Visual Studio 2019开发专门解决VR设备中通过眼睛视线瞄准并选择物体的交互问题。工程自带可运行的Test场景场景内已布置基础测试物体开发者导入后即可直接验证效果。核心代码拆分为WatchController、WatchEvent和HightlightingRenderer三个脚本事件注册方式类似UGUI点击按钮只需给目标物体挂上脚本并绑定事件就能完成注视选中、高亮反馈等逻辑适合学习射线检测、凝视交互与UI事件机制的初中级开发者。压缩包共包含283个文件其中C#脚本41个、Unity场景19个、预制体10个、材质17个、着色器6个另有若干动画片段和FBX模型作为演示素材整体压缩后仅4.7MB结构紧凑便于阅读和移植。目前已有214人学习下载包内附有详细使用说明和开箱即用的测试场景可快速上手也能帮助用户在自有VR项目中实现类似的注视选择功能。1. Unity VR 用眼睛注视选择物体Gaze 交互从射线到事件的完整闭环Unity VR 开发里用眼睛注视选择物体行业内默认叫 Gaze 交互是几乎所有依赖头显的交互方案的底座。手柄会抖、会丢追踪但头显的朝向和位置始终稳定可靠所以很多 3D 电影播放器、展厅漫游、医疗培训项目里最终都选择了盯住某个物体来完成确认这条路径。这份资源给的是一个可直接运行的 Unity 2019.4.9f1 工程核心是 WatchController.cs 发射线、HightinglightingRenderer.cs 给高亮反馈、WatchEvent.cs 暴露事件回调整套逻辑等价于把 UGUI 的 Button 搬到了 3D 物体上。它解决的是从摄像机朝哪看到业务层要响应谁的完整链路适合刚转 VR 的开发者也适合正在为交互选型纠结的从业者。2. 核心脚本拆解WatchController、HightinglightingRenderer、WatchEvent 的分工与数据流2.1 三条脚本的职责边界先说结论这个工程把注视选择拆成了三件事——谁来发线、谁来给视觉反馈、谁来响应业务。三者各干各的不互相 new、不互相持有对方实例这在 Unity 项目里是一套很清爽的架构。WatchController.cs 挂在 MainCamera 上本质是一个持续发 Physics.Raycast 的引擎。每一帧它从相机前方向发一条射线出去打中目标后检查目标物体上有没有 WatchEvent.cs有就把当前注视对象切换过去没有就清空。它不关心场景里哪些物体可以被选择也不关心选中后要执行什么逻辑只负责当前盯着谁这件事。HightinglightingRenderer.cs 也挂在 MainCamera 上负责视觉反馈。它会让当前注视的物体产生高亮或描边效果让用户明确感知到自己正盯着哪个物体。这个脚本和 WatchController 在功能上是并行关系一个管逻辑判定一个管表现渲染。如果你不喜欢它自带的渲染方式可以单独替换掉不影响其他两个脚本。WatchEvent.cs 则挂在每个可交互的 3D 物体上提供事件回调。它暴露了类似 OnLookAt、OnLookAway、OnSelect 这样的事件字段用法和 UGUI Button 的 onClick 如出一辙。你在 Inspector 里把某个 MonoBehaviour 的方法拖进去事件就绑定了不需要写任何胶水代码。2.2 从相机射线到事件回调的完整链路我把这个工程的调用链路复原一下方便你理解三个脚本是怎么协同工作的。WatchController 在 Update 里发射线命中结果优先找 WatchEvent 组件找到就做当前对象的切换切换时会把上一个被注视对象的 OnLookAway 事件触发掉再触发新对象的 OnLookAt当前对象一直处于被注视状态时每一帧还会触发 OnSelect 用于 Dwell 计时或持续选中逻辑。HightinglightingRenderer 单独监听 WatchController 的当前注视对象变化只在高亮对象切换时才会重新计算渲染目标。工程里 WatchController.cs 的核心逻辑大概是这个结构我按关键字复述一下// WatchController.cs 核心逻辑简化复述 public class WatchController : MonoBehaviour { public float maxDistance 20f; // 最远注视距离超出忽略 public LayerMask watchLayerMask ~0; // 只检测指定层默认全检测 private WatchEvent _current; void Update() { Ray ray new Ray(Camera.main.transform.position, Camera.main.transform.forward); if (Physics.Raycast(ray, out RaycastHit hit, maxDistance, watchLayerMask)) { WatchEvent watchEvent hit.collider.GetComponentWatchEvent(); if (watchEvent ! null) { if (_current ! watchEvent) { _current?.OnLookAway(); _current watchEvent; _current.OnLookAt(); } _current.OnSelect(); } } else { _current?.OnLookAway(); _current null; } } }这段代码里最值得注意的参数是 maxDistance 和 watchLayerMask。maxDistance 建议设成 10 到 20 之间如果场景里物体经常放在 30 米开外那默认 20 会直接漏掉watchLayerMask 则建议单独分一个层只让可交互物体参与检测不然射线扫到地面、墙面也会触发多余的 GetComponent 查询。另外Camera.main 在主相机被禁用时会返回 null工程里默认你在 MainCamera 挂着脚本所以没做 null 保护但你自己加一层容错会更稳。2.3 为什么不直接拿手柄射线改很多第一次接触这个资源的人会问VIVE 或 Quest 手柄也有射线为什么不直接用手柄指向去选原因有两个。第一手柄射线依赖 SixDof 手柄的持续追踪用户手臂疲劳后射线会严重抖动命中稳定性远不如头部朝向第二在一体机观影、PC 端播片这类场景里用户手里可能没有手柄或者双手揣在兜里唯一稳定的输入源就是头显的旋转数据。所以 Gaze 交互在这些场景里是默认方案手柄射线反而成了锦上添花的可选项。工程里 WatchController 直接用 Camera.main.transform.forward 作为射线方向没有额外引入 XR 的追踪节点本质上是拿眼睛当摇杆。如果你后面要接 Pico 或 Quest这套逻辑依然成立因为 Unity XR 架构下主相机的旋转已经被头显实时驱动你不需要改任何一行它天然就是眼睛注视。3. 把注视系统接进自己的场景导入、挂载、注册事件的完整操作过程3.1 导入工程并创建测试场景这份资源本身是一个完整 Unity 工程不是零星几个脚本。所以第一步先把整个文件夹用 Unity 2019.4.9f1 打开因为工程里 ProjectSettings.asset、InputManager.asset 这些配置文件都是 2019 LTS 版本的格式用 2021 及以上的版本打开会自动升级虽然多数情况下没毛病但 HightinglightingRenderer 涉及到的 GraphicsSettings.asset 和 QualitySettings.asset 在升级时有概率丢 Shader 变体导致高亮失效。我的建议是严格用 2019.4.9f1 跑通一遍再决定要不要升版本。工程本身带一个 Test 场景里面的物体已经挂好了 WatchEvent 并注册了事件。你想从零搭也可以在工程里新建一个场景然后往场景里随便丢几个 Cube、Sphere、Capsule别改它们默认的碰撞体。这里有个细节Collider 一定要保留因为 Physics.Raycast 只打 Collider不打 MeshRenderer。3.2 挂载脚本并拖引用挂脚本的顺序很关键我习惯先挂 HightinglightingRenderer再挂 WatchController最后给目标物体挂 WatchEvent。这样在 WatchController 的 Inspector 里拖引用时目标物体上已经存在 WatchEvent不容易漏。// 在 MainCamera 上挂好两个脚本后你需要确认引用 // WatchController.maxDistance 20 // WatchController.watchLayerMask Everything // HightinglightingRenderer 不需要手动拖引用它内部会找到 WatchController这里补充一下HightinglightingRenderer 内部是通过 FindObjectOfType 去拿 WatchController 的还是 Inspector 手动拖引用取决于工程里那个版本的写法。如果是 FindObjectOfType那么在 Awake 阶段必须保证 WatchController 已经存在否则会拿空引用。我遇到的版本是手拖引用版本所以你在 Inspector 里能看到一个标着Watch Controller的拖槽把 MainCamera 上挂的 WatchController 拖进去即可拖完再跑场景。然后是给物体挂 WatchEvent。挂在 Cube 上之后Inspector 面板会露出三个事件列表OnLookAt视线进入、OnLookAway视线离开、OnSelect注视中持续触发。UnityEvent 的绑定操作和 Button 完全一致点加一行把带方法的 GameObject 拖到 Object 槽里在右侧下拉框选择方法。3.3 WatchEvent 注册事件与 UGUI Button 一致的心智模型注册事件这一步是这份资源里做得最顺手的地方。只要你的目标物体上挂了 WatchEvent其他脚本里写一个公开方法然后在 Inspector 里拖进去绑定事情就结束了。// 示例挂到 Cube 上的测试行为脚本 public class TestCubeBehaviour : MonoBehaviour { public void OnFocused() { GetComponentRenderer().material.color Color.green; } public void OnLostFocus() { GetComponentRenderer().material.color Color.white; } public void OnConfirmed() { Debug.Log(确认选中 gameObject.name); } }这段代码里 OnFocused 对应 OnLookAtOnLostFocus 对应 OnLookAwayOnConfirmed 对应 OnSelect。注意 OnSelect 是持续触发的所以如果你把它绑到 Debug.Log 上就会每帧刷屏。正确做法是绑一个带冷却时间的函数或者把 Dwell Time 的逻辑放到 WatchController 里去改。有一点跟 UGUI Button 不太一样Button 的 onClick 是鼠标或触摸板触发一次但这里的 OnSelect 是每帧触发的持续事件。所以你在绑定事件时脑子里的模型要换成这不是 Click这是 Stay不然很容易写出每帧执行的逻辑。3.4 关键参数调优跑通之后你需要关注的参数就三个我列在下面这个表里对应不同项目场景的推荐值也是我自己实际用过之后的结果。参数作用推荐值适用场景maxDistance射线最大检测距离10小型桌面展台、近距离操作maxDistance射线最大检测距离20通用漫游场景展厅中岛watchLayerMask可交互物体所在的 Layer单独自定义层避免地面、墙面干扰HightinglightingRenderer 高亮强度视觉反馈强度0.5 ~ 0.8太低了看不清太高了眼睛累maxDistance 我一般设 20这是多数 VR 漫游场景的安全值如果你做的是大空间建筑漫游物体经常出现在 30 米外那就得改到 50。watchLayerMask 是典型的不设置后面会翻车的参数——默认是 Everything意味着射线会打在所有带 Collider 的物体上。如果场景地面很大你每次低头都会先选中地板再切回来视觉上高亮会疯狂闪烁。我建议在 Tags and Layers 里建一个 Interactable 层把需要注视图标的物体都放进去然后 WatchController 的 watchLayerMask 只勾这一层。4. 避坑与常见问题从高亮不显示到事件不触发的五条踩坑记录4.1 高亮不显示Material 与 Shader 的兼容性问题现象场景能跑射线检测出来的物体也能触发 Debug.Log但 HightinglightingRenderer 没有任何视觉反馈物体不变色也没有描边。原因这是这份资源里最大的一个坑。HightinglightingRenderer 的实现依赖渲染管线对 Shader 变体的支持如果你把项目的 Graphics Settings 改成过 URP 或 HDRPStandard Shader 的变体会被裁剪掉高亮材质球就变成紫红色或者完全透明。另一个常见原因是你给物体手动替换了自定义 Shader而高亮材质是在运行时通过 MaterialPropertyBlock 修改 _Color 属性实现的自定义 Shader 没有声明这个属性自然什么效果都看不到。解决跑通之前先检查 GraphicsSettings.asset 里的管线设置确认还是内置渲染管线。其次给目标物体用一个 Standard Shader 的标准材质别用 URP 的 Lit。如果你必须在 URP 下用那就得改 HightinglightingRenderer 的实现把渲染方式从材质替换改成后处理膨胀描边或者在 Shader 里手动加 _Color 属性入口。4.2 事件不触发Collider 与 LayerMask 的双重门槛现象WatchEvent 已经挂上去了事件也注册了C# 方法都是 public但射线就像没看到这个物体一样怎么盯都没反应。原因我见过最多的翻车现场是两种。一是物体没有 Collider只有 MeshRenderer二是物体有 Collider但所在 Layer 被 WatchController 的 watchLayerMask 过滤掉了。Physics.Raycast 的检测对象是 Collider不是 GameObject没有 Collider 就不可能被打中。Layer 被过滤的问题则隐蔽得多因为 Unity 默认 Everything 层掩码不会过滤任何东西但只要有人动过 watchLayerMask就很容易出现代码没问题但就是打不中的玄学情况。解决先给物体补一个 Box Collider 或 Mesh Collider再确认物体 Layer 在 watchLayerMask 勾选范围内。调试时可以在 WatchController 的 Update 里加一句 Debug.DrawRay(ray.origin, ray.direction * maxDistance, Color.red)看到红色射线穿过了物体但没反应那 90% 是 Collider 或层的问题剩下 10% 是代码逻辑问题。4.3 误选与脱靶缺少 Dwell Time 引发的交互漂移现象用户本来想盯着门把手然后确认开门结果刚把视线停在门把手上不到 0.2 秒门就开了。或者说用户想选左边按钮但转头扫过右边按钮过程中把右边也触发了。原因这套工程里 OnSelect 是注视即触发的没有内置 Dwell Time 做防抖。在真实头显里人的头部即使在盯住状态下也有微小晃动射线命中点会一直在物体表面上抖动。如果 OnSelect 绑定了确认逻辑这些次像素抖动会在短时间内反复触发多次事件造成连点、误触。根本原因是工程没做确定注视与短暂扫过的区分。解决我建议给 WatchController 加一个 dwellTime 字段默认 0.5 秒只有当射线持续命中同一个物体超过 0.5 秒后才触发 OnSelect中间只要有脱离或切换就清零计时。实现逻辑是把 OnSelect 调用从每帧触发改成计时完成后触发一次。如果你不想改源码也可以直接在事件回调函数里做一个协程延时但那样多个物体同时存在时会很乱。4.4 导入后脚本引用丢失Unity 版本差异与 .meta 文件残留现象你下载的资源包导入后场景里所有挂好的脚本引用全部是 Missing脚本名还在但组件状态显示为红色错误特别是 Test 场景更明显。原因Unity 的脚本 GUID 存在 .meta 文件里如果作者打包时隐藏了 .meta 文件或者你直接拷贝的是用 SVN 同步的文件那么项目打开后 Unity 会为每个脚本重新生成 GUID原本场景文件里记录的脚本引用就失效了。另一个常见情况是你用太高版本的 Unity 打开编译错误导致脚本组件全部失效。解决先从工程文件夹里确认有没有 .meta 文件没有的话就放弃直接升级改用 2019.4.9f1 打开一次生成正确的 meta 文件后再升级。场景里已经丢失的引用只能手动重新挂脚本没有捷径。我的习惯是拿到任何 Unity 工程资源后第一件事看 Assets 目录下有没有 .meta没有就先让 Unity 自动生成再动场景文件。4.5 VR 运行时相机视口闪烁高亮渲染与单眼渲染的冲突现象在 PC 上跑 Game 视图一切正常但只要进入 VR 模式画面高亮区域会闪甚至两只眼睛的渲染结果不一致一只眼睛看到高亮另一只看不到。原因HightinglightingRenderer 的实现里如果采用的是在当前相机上叠加渲染层的方案那么在 XR 单眼渲染Stereo Rendering模式下叠加渲染只被应用到了主相机的主目标左右眼的 RenderTexture 没有同时被处理。这是单目渲染逻辑迁移到立体渲染时最常见的后遗症。解决在工程里找到 HighightingRenderer 里处理 OnRenderImage 或 CommandBuffer 的代码确认它是回调到 Camera.onPreRender 还是 OnRenderImage。如果是 OnRenderImage就要把 XR 的 stereo 处理加上或者换一种做法不做后处理直接通过材质属性控制目标物体的自发光来实现高亮。后者在 VR 模式下兼容性最好性能消耗也最低。5. 进阶技巧注视阻尼、Dwell Time 与另一套调试习惯5.1 用 SmoothDamp 让注视点不再抖头显设备哪怕放在桌子上不动IMU 的数据也有微小的噪声和漂移。你盯着一个物体时射线命中点在物体表面其实是不断跳动的。这种高频抖动对视觉效果和事件稳定性都是灾难。我一般会在 WatchController 和 WatchEvent 中间加一个平滑层不对 Camera.main.transform.forward 直接做 Raycast而是先用 Vector3.SmoothDamp 把射线方向平滑到一个缓冲方向再拿缓冲方向去发射。这样命中点会变得粘滞不会因为设备抖动产生误切换。// 平滑注视方向的核心逻辑 private Vector3 _smoothForward; private Vector3 _velocity; void Update() { Vector3 rawForward Camera.main.transform.forward; _smoothForward Vector3.SmoothDamp( _smoothForward, rawForward, ref _velocity, 0.05f ); Ray ray new Ray(Camera.main.transform.position, _smoothForward); // 后续 Raycast 逻辑不变 }这里的 smoothTime 参数是关键。0.05 秒是几乎没有延迟又足够稳定的甜点值超过 0.1 秒会感觉到明显的追尾感用户转头时高亮会慢半拍。0.02 秒以下基本起不到滤波作用。别小看这 50 毫秒的缓冲实际项目里它能消除 70% 以上的边缘误触。5.2 Dwell Time把看着它升级为确认它真正成熟的项目里OnLookAt 只是让物体进入候选状态真正执行选择动作还需要一个停留时长来确认。这套工程没内置这个机制你需要自己加。实现也非常简单在 WatchController 里加一个计时器字段当 OnLookAt 被触发后开始累加持续注视超过 dwellTime 再派发 OnSelected。用户转头移开则清零。这一套逻辑在很多 VR 播放器里就是看 2 秒自动播放在工业软件里就是盯住红色按钮 1.5 秒触发急停成本极低收益极大。5.3 调试时强制开启 Debug 可视化我自己的习惯是用 Debug.DrawRay 画射线路径再在 OnLookAt 触发时用 Debug.DrawLine 画一条从相机的命中点到目标物体中心的连线颜色换成高亮的绿色。这在编辑器里能非常直观地看出当前系统以为你在看谁。另外我会把 WatchController 的当前命中物体名称打印到屏幕角落的 OnGUI 上不用打开 Console 就能实时看到。这套系统在测试场景里验证通过后我做的第一件事永远是检查 Dwell Time 和 LayerMask 这两个参数。从那以后我每次接手别人的 Gaze 交互工程都强制先改这两个地方再谈其他这套检查顺序确实帮我少踩了很多坑。希望这套思路对你也有用。本文还有配套的精品资源点击获取
阅读完成 · 觉得有帮助?