在VR中如何用凝视输入替代点击
解读
国内主流 VR 一体机(Pico、奇遇、Quest 国行版)均把“凝视+确认”作为无手柄场景下的核心交互。面试时,考官想确认两点:
- 你能否在 Unity 端零延迟地捕获用户头部凝视射线;
- 你能否用状态机把“看-停-确认”全过程做得既防误触又兼顾性能,而非简单 Time.deltaTime 累加。
回答必须体现工程化落地经验:适配单眼渲染、动态事件分发、与 UI 系统的无缝嫁接,并给出性能预算(如 <0.2 ms/帧)。
知识点
- Unity XR SDK 眼向射线:XRNode.Head 或 InputDevice 的 devicePosition/rotation,优先用 XRInputSubsystem 获取中心眼数据,避免 Camera.main 额外开销。
- Physics.RaycastNonAlloc 与 PhysicsRaycaster 差异:前者可复用 RaycastHit[] 数组,后者与 EventSystem 耦合,需权衡 GC 与可扩展性。
- GazePointerEventData:继承自 PointerEventData,把 gazeDistance、hitPosition 写进去,才能让 GraphicRaycaster 正确响应 uGUI。
- Fill-Button 状态机:Idle→Hover→Dwell→Trigger→Cooldown,用 unscaledTime 计时,防止 Time.timeScale=0 时卡死。
- 视觉反馈三件套:Reticle 缩放、Progress Ring、物体 Outline,全部在 Shader 的顶点阶段做动画,避免每帧 SetFloat。
- 性能陷阱:
– 每帧 new Ray 会触发 GC,提前缓存;
– 层剔除用 LayerMask.GetMask 写死,防止字符串比对;
– 远距离(>15 m)物体用 LOD Group 剔除,减少 Raycast 数量。 - 热更兼容:若项目用 ILRuntime,GazeModule 必须写成行为脚本而非 DLL,否则反射注册会失败。
答案
-
搭建 GazeInputModule
继承 BaseInputModule,重写 Process():- 通过 XRInputSubsystem.TryGetNodeStates 获取头部姿态,拼出射线;
- 用 PhysicsRaycastNonAlloc 检测 3D,用 GraphicRaycaster.Raycast 检测 UI,合并结果取最近命中;
- 把结果写入自定义 GazePointerEventData,派发 PointerEnter、PointerExit、PointerClick 事件,实现与 uGUI 的无缝兼容。
-
实现 Dwell-Confirm 逻辑
- 状态机:Idle→Hover(进入瞬间)→Dwell(停留)→Trigger(确认)→Cooldown(防连发)。
- 计时器:Hover 进入时记录 Time.unscaledTime,当持续时长 ≥ dwellTime(默认 0.8 s,策划可配)即触发。
- 防误触:头部角速度 > 0.3 rad/s 时强制退出 Dwell 状态。
-
视觉反馈
- Reticle 贴花:使用 World-Space Canvas,位置 = hitPoint + hitNormal * 0.01 m,朝向用 Quaternion.LookRotation(-hitNormal)。
- 进度环:在 Shader 里用
_Progress属性做 360° 填充,CPU 端只 SetFloat 一次,GPU 插值。 - 选中高亮:用 CommandBuffer 在 AfterForwardOpaque 绘制 Outline,避免额外相机。
-
性能与适配
- 射线检测频率:XR 设置 72 Hz,实际每两帧检测一次,降频到 36 Hz 节省 0.15 ms。
- 层剔除:仅检测 Interactable 与 UI 两层,LayerMask 写死为 1<<12|1<<5。
- 单眼渲染优化:若开启 Single-Pass Instanced,Reticle 的 Shader 加
multi_compile_instancing,防止右眼闪烁。
-
代码骨架(面试可直接口述)
public class GazeInputModule : BaseInputModule {
[SerializeField] float dwellTime = 0.8f;
RaycastHit[] hits = new RaycastHit[16];
GazePointerEventData gazeData;
float enterTime;
GameObject currentOver;
public override void Process() {
var head = InputDevices.GetDeviceAtXRNode(XRNode.Head);
if (!head.TryGetFeatureValue(CommonUsages.centerEyeRotation, out var rot)) return;
Ray ray = new Ray(Camera.main.transform.position, rot * Vector3.forward);
int cnt = PhysicsRaycastNonAlloc(ray, hits, 20f, interactableMask);
bool hit = cnt > 0;
var newOver = hit ? hits[0].collider.gameObject : null;
if (newOver != currentOver) {
if (currentOver) ExecuteEvents.Execute(currentOver, gazeData, ExecuteEvents.pointerExitHandler);
currentOver = newOver;
enterTime = Time.unscaledTime;
if (currentOver) ExecuteEvents.Execute(currentOver, gazeData, ExecuteEvents.pointerEnterHandler);
}
if (currentOver && Time.unscaledTime - enterTime >= dwellTime) {
ExecuteEvents.Execute(currentOver, gazeData, ExecuteEvents.pointerClickHandler);
enterTime = float.MaxValue; // 进入 Cooldown
}
}
}
- 一键切换
在 PlayerSettings/XR Plug-in Management 里同时勾选 Pico 与 OpenXR,运行时根据 XRSettings.loadedDeviceName 自动选择射线来源,零脚本改动即可上架国内商店。
拓展思考
- 眼动追踪加持:Pico 4 Enterprise 内置眼动追踪,可用 EyeTrackingSubsystem 获取真实注视点,误差 <0.5°,可把 dwellTime 缩短到 0.3 s,但需处理眨眼丢失(用 100 ms 的 LastValidData 缓存)。
- 语音二次确认:在 Trigger 前加 VoiceConfirmation 状态,用户说“确定”才执行,避免误触;接入国内 科大讯飞离线 SDK,延迟 <80 ms。
- 手势融合:当手进入视野且 Pinch 手势被识别,可立即中断凝视计时,实现“你看我点”的混合交互,需用 Hand Tracking Subsystem 与凝视模块共享状态机。
- UGUI 与 TMP 的汉字凝视:对长文本 TextMeshPro,把 TextMeshProUGUI.textInfo 的字符边界拆成多盒 Collider,实现逐字凝视选择,适合做 VR 输入法。