在VR中如何用凝视输入替代点击

解读

国内主流 VR 一体机(Pico、奇遇、Quest 国行版)均把“凝视+确认”作为无手柄场景下的核心交互。面试时,考官想确认两点:

  1. 你能否在 Unity 端零延迟地捕获用户头部凝视射线;
  2. 你能否用状态机把“看-停-确认”全过程做得既防误触又兼顾性能,而非简单 Time.deltaTime 累加。
    回答必须体现工程化落地经验:适配单眼渲染、动态事件分发、与 UI 系统的无缝嫁接,并给出性能预算(如 <0.2 ms/帧)。

知识点

  • Unity XR SDK 眼向射线:XRNode.Head 或 InputDevice 的 devicePosition/rotation,优先用 XRInputSubsystem 获取中心眼数据,避免 Camera.main 额外开销。
  • Physics.RaycastNonAllocPhysicsRaycaster 差异:前者可复用 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,否则反射注册会失败。

答案

  1. 搭建 GazeInputModule
    继承 BaseInputModule,重写 Process():

    • 通过 XRInputSubsystem.TryGetNodeStates 获取头部姿态,拼出射线;
    • PhysicsRaycastNonAlloc 检测 3D,用 GraphicRaycaster.Raycast 检测 UI,合并结果取最近命中;
    • 把结果写入自定义 GazePointerEventData,派发 PointerEnter、PointerExit、PointerClick 事件,实现与 uGUI 的无缝兼容
  2. 实现 Dwell-Confirm 逻辑

    • 状态机:Idle→Hover(进入瞬间)→Dwell(停留)→Trigger(确认)→Cooldown(防连发)。
    • 计时器:Hover 进入时记录 Time.unscaledTime,当持续时长 ≥ dwellTime(默认 0.8 s,策划可配)即触发。
    • 防误触:头部角速度 > 0.3 rad/s 时强制退出 Dwell 状态。
  3. 视觉反馈

    • Reticle 贴花:使用 World-Space Canvas,位置 = hitPoint + hitNormal * 0.01 m,朝向用 Quaternion.LookRotation(-hitNormal)
    • 进度环:在 Shader 里用 _Progress 属性做 360° 填充,CPU 端只 SetFloat 一次,GPU 插值。
    • 选中高亮:用 CommandBuffer 在 AfterForwardOpaque 绘制 Outline,避免额外相机。
  4. 性能与适配

    • 射线检测频率:XR 设置 72 Hz,实际每两帧检测一次,降频到 36 Hz 节省 0.15 ms。
    • 层剔除:仅检测 InteractableUI 两层,LayerMask 写死为 1<<12|1<<5。
    • 单眼渲染优化:若开启 Single-Pass Instanced,Reticle 的 Shader 加 multi_compile_instancing,防止右眼闪烁。
  5. 代码骨架(面试可直接口述)

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
        }
    }
}
  1. 一键切换
    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 输入法。