尧图网站设计 尧图网站设计YAOTU DESIGN
ARTICLE DETAIL

资讯详情

深耕网站设计与一线实操的经验洞察。

Unity VR 注视交互:三个脚本实现视线停留选择与高亮反馈

Unity VR 注视交互:三个脚本实现视线停留选择与高亮反馈 简介面向Unity VR开发者的眼球注视选择交互方案解决在虚拟现实场景中通过视线停留或注视点指向来选中物体的问题适用于VR头显的射线检测与UI交互开发。工程基于Unity 2019.4.9f1与VS2019附带可运行的Test测试场景便于直接验证效果。包体共283个文件包含C#脚本、Unity场景、材质、预制体、着色器、动画及fbx模型等其中C#脚本为核心交互逻辑Prefab与Shader可快速复用包体仅4.7MB轻量易部署。目前已有213人学习下载适合刚接触VR交互的开发者参考。资源提供了WatchController与WatchEvent脚本采用类似UGUI Button的事件注册方式只需在需要注视的物体上挂载脚本并注册回调即可。同时包含HighlightingRenderer高亮渲染脚本让被注视物体有明确视觉反馈还配有材质、动画和PDF说明可帮助理解实现原理并快速接入自己的项目。1. VR 眼镜注视选物体到底需要几个脚本在 VR 里做“眼睛注视选择”第一反应是拿相机中心射线打出去命中就触发。可实际放到 Pico 4 或 Meta Quest 上试一次就会发现问题视线会有高频率抖动用户想盯哪就立刻触发不行要停留物体重叠时射线可能一会儿在这边一会儿在那边选中后没有任何反馈用户根本不知道系统在“读”他的意图。这个工程把整套交互收敛成了三个脚本WatchController.cs 负责“看哪里”和“看了多久”WatchEvent.cs 让场景里任何带 Collider 的物体变成能注册事件的对象HightlightingRenderer.cs 负责把当前目标高亮出来。工程基于 Unity 2019.4.9f1 和 VS2019自带 Test 场景导入就能跑适合刚开始做 Unity VR 交互、或者准备在 MR/VR 数字孪生项目里加入视线点选的开发者。下面按这三个脚本的职责逐个拆开。2. WatchController 核心拆解从相机射线到注视计时确认WatchController 是挂在 MainCamera 上的指挥者。它每帧发射一条视线射线得到候选物体再靠计时器决定“是否确认点击”。如果只做到这一步跟普通 Raycast 没什么区别真正的关键是候选切换逻辑和防抖参数。2.1 射线起点为什么绑在主相机上工程里最常见的做法是直接用Camera.main.transform作为眼睛锚点下面这段代码是按脚本职责还原的示意实现// WatchController 核心帧逻辑挂在 MainCamera 上 public Transform eyeAnchor; public float maxDistance 30f; public LayerMask raycastMask ~0; private WatchEvent _current; void LateUpdate() { Vector3 origin eyeAnchor.position; Vector3 forward eyeAnchor.forward; if (Physics.Raycast(origin, forward, out RaycastHit hit, maxDistance, raycastMask)) { WatchEvent evt hit.collider.GetComponentInParentWatchEvent(); HandleCandidate(evt); } else { ClearCurrent(); } }射线起点直接取相机坐标就是“目光所向”的近似。注意这里用LateUpdate而不是Update因为 VR 相机的位置会由追踪系统在Update阶段更新LateUpdate里拿到的是这一帧稳定后的姿态。如果项目里还有别的后处理移动相机的逻辑LateUpdate也能少一半的抖动问题。raycastMask建议单独指定一层。把可交互物体放到 “WatchUI” 层射线就只检测这一层。原因是场景里地面、墙、门把手这些不需要响应视线漏掉它们能减少不必要的GetComponentInParent。如果不想维护 Layer 表也可以清空 mask 用~0但后面你会发现误触严重还是分层最省心。2.2 注视计时不是 raycast 到了就算选中假设用户想让视线在某个按钮上停留 1.2 秒后确认最稳妥的实现是public float triggerTime 1.2f; public float exitDelay 0.3f; private float _timer; private WatchEvent _candidate; void HandleCandidate(WatchEvent evt) { if (evt ! _candidate) { ExitCandidate(); _candidate evt; _timer 0f; evt.OnPointerEnter(); return; } _timer Time.unscaledDeltaTime; if (_timer triggerTime) { _candidate.OnPointerConfirm(); ExitCandidate(); } else { _candidate.OnGazeProgress(_timer / triggerTime); } }这里值得注意的有两点。第一计时用Time.unscaledDeltaTime不用deltaTime。VR 应用里常为了眩晕处理把Time.timeScale拉到 0 做 UI 暂停此时还要让视线选择继续工作所以不受缩放影响。第二OnPointerEnter和OnGazeProgress是两步Enter 只负责“被看到了”Progress 负责把进度反馈给 UI 或高亮脚本。触发后的行为要从业务上想清楚确认后立刻ExitCandidate还是保持选中状态工程做法是确认后马上清空避免用户视线一直停在按钮上导致反复触发。如果你要做“盯住旋钮拖动”需要另外加一个锁定状态这部分在后面说。2.3 头晃不换目标角度阈值比“是不是同一个物体”更可靠实际 VR 里有个让新手头疼的细节人的头部一直在做几度的微小转动即使你觉得在盯固定点射线也会在几个重叠物体之间跳来跳去。如果对evt ! _candidate做纯引用比较那么相邻物体之间的频繁切换会让计时永远走不到triggerTime。我一般会在工程里加一个“目标锚定点”判断记录进入目标时的碰撞点每帧检查当前射线方向与这个锚点的夹角。只要夹角小于阈值就仍然认为在盯同一个目标private Vector3 _anchorPoint; public float angleThreshold 3f; // 单位度 bool StillOnSameTarget(Vector3 hitPoint) { Vector3 cameraPos eyeAnchor.position; Vector3 toHit (hitPoint - cameraPos).normalized; Vector3 toAnchor (_anchorPoint - cameraPos).normalized; float angle Vector3.Angle(toHit, toAnchor); return angle angleThreshold; }这样即使碰撞点从物体边缘滑到另一个 Collider 上只要不超过 3 度计时不中断。angleThreshold的取值看物体大小和交互距离放在 UI 面板上的按钮建议 2 到 3 度场景里选一个墙面大目标可以放宽到 5 度否则用户稍一偏头就取消。下面是这个工程里常用的几个参数初始值可以直接写在 Inspector 里参数推荐值作用maxDistance20m 到 30m超远目标没必要响应也减少射线误触及triggerTime0.8s 到 1.5s停留确认时间越大越防误触越小越灵活angleThreshold2 到 5 度抵消头部微抖超过则切换目标exitDelay0.3s目标短暂离开后是否马上重置0.3s 防闪烁跟着默认的 1.2 秒做测试你会感受到正常转动头部浏览场景时基本不会触发只有刻意盯着一个物体 1 秒以上才会确认这就是凝视交互该有的手感。工程里还有 WatchPoint.anim 和 Cool.anim 两个动画文件配合注视点做缩放反馈说明确认后可以再补一个啵点动画。3. WatchEvent把普通物体变成 VR 按钮如果用一句话概括 WatchEvent它就是“UGUI 的 Button 放进 3D 世界”。一个普通 Cube 上挂了 Collider 和 WatchEvent就能像 UI 一样注册 Enter、Exit、Confirm 事件。这个设计最大的好处是业务逻辑不用写进 Controller每个物体自己决定被选中后做什么。3.1 UnityEvent 三段式回调设计组件的核心接口一般长这样using UnityEngine; using UnityEngine.Events; [RequireComponent(typeof(Collider))] public class WatchEvent : MonoBehaviour { // 暴露到 Inspector策划也能直接配置 public UnityEvent onEnter; public UnityEvent onExit; public UnityEvent onConfirm; public void OnPointerEnter() { onEnter?.Invoke(); } public void OnPointerExit() { onExit?.Invoke(); } public void OnPointerConfirm() { onConfirm?.Invoke(); } public void OnGazeProgress(float progress) { // 一般交给外部脚本更新进度条/动画 } }onEnter在射线首次进入物体时触发onExit在离开时触发onConfirm在计时到达后触发。OnGazeProgress是一般方法没有暴露成UnityEventfloat。如果有多个 UI 元素需要订阅进度变化可以加一个UnityEventfloat onProgress但在 Unity 2019 的 Inspector 里对泛型 UnityEvent 可视化支持有限直接用代码拉一条线性进度更稳。事件拆成三段而非一个“点击”事件原因是 VR 视线交互需要给用户“正在看、准备触发、已经触发”三种连续状态。否则很难做环形进度条也不知道用户什么时候移开了视线。3.2 注册事件的方式与 UGUI Button 对照用过 UGUI 的人会对这套操作很熟。下面是对照需求场景UGUI ButtonWatchEventInspector 可视化配置在 onClick 列表拖入方法在 onConfirm 列表拖入方法代码动态注册Button.onClick.AddListenerWatchEvent.onConfirm.AddListener长按确认需要自行计时内置注视计时进入/离开反馈无标准事件onEnter / onExit代码动态注册也完全类似WatchEvent target gameObject.GetComponentWatchEvent(); target.onConfirm.AddListener(() { Debug.Log($确认选择 {target.name}); transform.GetChild(0).gameObject.SetActive(true); }); target.onEnter.AddListener(() { target.transform.localScale * 1.05f; }); target.onExit.AddListener(() { target.transform.localScale / 1.05f; });把事件绑在 Inspector 上是可行的把想要响应视线的物体拖进场景挂上 WatchEvent然后在 onConfirm 的列表里拖入目标物体并选择方法跟 UGUI Button 完全同一个操作路径。好处是策划可以直接配置而不是改代码。3.3 让“看不见”的 World UI 也能被点击实际工程中VR 的 World UI 经常出现“被手柄模型挡住”的问题手一抬起来就遮住了视线。而且Physics.Raycast不会穿过RectTransform除非 UI 上有 Collider。所以我常用的处理方式在 Canvas 上挂一个 BoxCollider用脚本来缩放大小适配 RectTransform。把可交互物体放到 WatchUI 层让 WatchController 只检测该层。WatchEvent 已经带了RequireComponent(typeof(Collider))所以每个按钮都要有 Collider。BoxCollider 默认尺寸是 1 米必须通过RectTransform.rect同步尺寸[RequireComponent(typeof(BoxCollider))] public class UIGazeCollider : MonoBehaviour { private RectTransform _rect; private BoxCollider _col; void Awake() { _rect GetComponentRectTransform(); _col GetComponentBoxCollider(); SyncCollider(); } void SyncCollider() { _col.size new Vector3(_rect.rect.width, _rect.rect.height, 0.02f); } }这里厚度设成 0.02 米防止摄像机稍微倾斜时射线穿模。想扩大点击范围时把 size 再乘个 1.2f手感会比目测大一圈就不需要去动 triggerTime 了。一个容易踩的坑如果 Canvas 是 Screen Space - Overlay它不参与 3D 射线检测。VR 的 World UI 要把 Canvas 的 Render Mode 改成 World Space只有这种模式下 RectTransform 才会被 Collider 命中。所以 WatchEvent 本质上是把交互语义从“物理点击”提升到了“注视命中 时间确认”外延可以继续扩把 onEnter 替换成高亮、声音、缩放把 onConfirm 接到场景切换把 onExit 用于清除弹窗。4. 高亮反馈HighlightingRenderer 的作用不止于“发光”用户看向一个物体时如果没有视觉反馈视线选择基本不可用。这组资源里的 HightlightingRenderer.cs 就是干这件事的。注意拼写工程里是“HightlightingRenderer”比 Highlighting 多了个 t如果你要从零复制文件命名保持一致。4.1 挂到主相机而不是物体上的原因HighlightingRenderer 的定位是“后处理渲染器”它需要知道注视物体的边缘然后覆盖一层高亮颜色。把它挂在 MainCamera 上意味着它可以在渲染完不透明物体后把当前选中物体轮廓画在屏幕上。这样做可以不破坏被选中物体的材质也不会影响其它会被动态合批的物体。最朴素的高亮做法是在选中时替换材质在取消时换回来。但替换材质会把renderer.material变成实例导致 draw call 增加而且如果物体本来共用着一个材质球换回来时可能丢 Shader 属性。所以工程里用独立的高亮渲染器而不是每帧改材质。4.2 材质替换实现简单但要注意合批如果你只是临时验证手感可以先写一版最简单的替换注意这里用的是内置渲染管线的 Standard Shaderpublic class SimpleHighlight : MonoBehaviour { public Color highlightColor Color.cyan; private Material _highlightMat; private Renderer _r; private Material[] _originalMats; void Start() { _highlightMat new Material(Shader.Find(Standard)); _highlightMat.EnableKeyword(_EMISSION); _highlightMat.SetColor(_EmissionColor, highlightColor); } public void EnableHighlight(Renderer renderer) { if (_r renderer) return; DisableHighlight(); _r renderer; _originalMats renderer.sharedMaterials; var mats new Material[_originalMats.Length]; for (int i 0; i mats.Length; i) mats[i] _highlightMat; renderer.materials mats; } public void DisableHighlight() { if (_r null) return; _r.materials _originalMats; _r null; } }这个写法每次切换亮显都会触发materials的 setter在 Unity 里会产生新的材质实例批量切换时 GC 波动不小。作为演示够用真机 60 帧场景不建议长期挂着。4.3 CommandBuffer 描边不污染场景材质在 Unity 2019.4 的内置渲染管线里更可控的路径是用 CommandBuffer 把选中物体的 Renderer 画进一个 RT再做边缘检测。HighlightingRenderer 从命名上就属于这一类渲染器正常结构会包含中间 RT 和合成材质private void OnRenderImage(RenderTexture source, RenderTexture destination) { if (_targetRenderer null) { Graphics.Blit(source, destination); return; } // 1. 把选中物体用纯色画到 _outlineRT // 2. 对 _outlineRT 做边缘模糊 // 3. 将模糊结果与 source 叠加到 destination Graphics.Blit(source, destination, _compositeMaterial); }很多开源实现会在此基础上再做颜色插值和边缘发光。但如果你只是要一个“正在注视”的反馈用 Standard 材质调_EmissionColor已经够用把自发光调亮比后处理简单移动端性能也更友好。4.4 关于阴影和渲染顺序的提醒如果被注视物体开着实时阴影高亮后描边会把阴影也算进去边缘会出现一块块异常发亮。我在工程里的处理方式是临时关闭选中物体的阴影投射public void SetShadowOff(Renderer renderer) { renderer.shadowCastingMode UnityEngine.Rendering.ShadowCastingMode.Off; } public void SetShadowOn(Renderer renderer) { renderer.shadowCastingMode UnityEngine.Rendering.ShadowCastingMode.On; }这样能解决 Unity 阴影问题里很典型的“边缘脏块”。还有一点World Space Canvas 上的 Image 如果开了阴影高亮时也会冒出一片黑影建议把 UI 的阴影禁用。另外如果 WatchController 已经通过 Raycast 拿到了目标 Renderer高亮模块应该通过事件监听而不是每帧查找。例如在WatchEvent.onEnter里把自己传给高亮系统而不是在 HighlightingRenderer 里再执行一次 Raycast原因是同一个渲染物体下可能挂了多个 Collider两次射线结果可能不一致。5. 从 Test 场景迁移到自己的 VR 工程参数真机坑工程自带 Test 场景但测试场景和可交付的 VR 工程之间还隔着不少设备问题。下面是有效的迁移顺序和真机调参。5.1 在你自己场景里搭一套最小验证先在空场景里建一个 World Space Canvas放三个按钮分别给按钮挂上BoxCollider和WatchEvent。然后打开 Test 场景里 MainCamera 上的 WatchController 配置把eyeAnchor指定为主相机raycastMask设为 WatchUI 层再拖入 HightlightingRenderer。点击 Play用鼠标控制旋转并盯着按钮 1.2 秒确认三个事件都能触发这个过程不要超过十分钟。这里有两条实用规则原工程里的 InputManager.asset、TagManager.asset 自带层定义合并到自己工程时如果冲突优先保留原工程的 Layer 和 Tag否则 WatchUI 层的索引会漂。ProjectSettings.asset 里的 Player Settings 建议用你目标设备的默认模板不要直接覆盖到现有工程仅把层定义和输入轴合并过去。5.2 真机上的三个坑与对策第一个坑计时被 SteamVR 或 Pico 的交互脚本暂停。如果用Time.deltaTime设备偶尔掉帧时会累计额外时长出现“没盯住就触发了”。建议严格用Time.unscaledDeltaTime并把Application.targetFrameRate 90设上Pico 4 这类设备才能保证注视停留的时间感一致。第二个坑Pico 4 和 Meta Quest 系列连不上无线调试时日志很久刷不到。可以在场景里放一个 Debug 面板把_timer、当前目标名、角度阈值实时显示在 World UI 上这比每次看 Logcat 快得多DebugText.text ${_candidate?.name ?? none} | {_timer:0.00}s;第三个坑同一帧内既触发 onEnter 又触发 onConfirm导致事件状态错乱。原因通常是 triggerTime 设得太小比如 0.2 秒头部晃动把 Enter 重置后立刻又进入。我建议 triggerTime 不要低于 0.8 秒angleThreshold 不要低于 2 度。想扩大点击范围优先调 UIGazeCollider 的 size不要调 triggerTime否则真机会变得极度灵敏。经过一轮这样调下来用眼睛注视选择物体的交互就能真正在 Unity VR 工程里给人用了。整个方案没有引入额外 SDK依赖 Unity 2019.4.9f1 自带的物理和渲染管线你要是需要兼容 Meta VR 和 Pico4只需要在 WatchController 里把射线源换成设备追踪返回的 Gaze 向量。本文还有配套的精品资源点击获取
返回列表