☰
Unity VR 注视交互完整指南:从射线检测到事件闭环
2026/10/5 5:52:05 网站建设 项目流程

简介:面向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.GetComponent<WatchEvent>(); 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() { GetComponent<Renderer>().material.color = Color.green; } public void OnLostFocus() { GetComponent<Renderer>().material.color = Color.white; } public void OnConfirmed() { Debug.Log("确认选中:" + gameObject.name); } }

这段代码里 OnFocused 对应 OnLookAt,OnLostFocus 对应 OnLookAway,OnConfirmed 对应 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 或 HDRP,Standard 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 交互工程,都强制先改这两个地方再谈其他,这套检查顺序确实帮我少踩了很多坑。希望这套思路对你也有用。

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

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

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

立即咨询