VR 中的抓取功能除了近距离地与物体接触将其抓起(我把它称为 “直接抓取”)之外,还有一种通过射线抓取的方式。当射线射到物体上时,按下抓取键就能将其抓起。本篇教程,我将介绍如何用 XR Interaction Toolkit 实现射线抓取。
使用的 Unity 版本: 2020.3.36
使用的 VR 头显: Oculus Quest 2
前期的配置:环境配置参考教程一,手部模型参考教程二。本篇教程的场景基于上一篇教程搭建的场景进行延伸。
项目源码(持续更新):https://github.com/YY-nb/Unity_XRInteractionToolkit_Tutorial2022/tree/master
最终实现的效果:
回顾上一篇直接抓取的教程,VR 中的交互一般需要两个对象:一个是可交互的对象(Interactable),一个是发起交互的对象(Interactor,一般是玩家自己)。直接抓取和射线抓取所抓取的对象并没有区别,因此 Interactable 需要的组件可以参考上一篇教程:Unity VR开发教程 OpenXR+XR Interaction Toolkit 2.1.1 (六)手与物品交互(触摸、抓取)
而它们的区别正是 Interactor 的不同。直接抓取使用的是 XR Direct Interactor;射线抓取使用的是射线,如果你看过此系列教程的前几篇,应该对一个叫做 “XR Ray Interactor” 的组件有点印象,我们在传送和 UI 交互功能中有用到这个组件。它的功能是通过射线检测实现与物体的远距离交互,因此这个组件就是远距离抓取需要的 Interactor。
还是沿用上一篇教程中搭建好的场景。
我们在 XR Origin 下的 Ray Controller 物体创建两个子物体,可以鼠标右键点击 Ray Controller,选择 XR -> Ray Interactor(Action-based):
观察这两个物体的 Inspector 面板,可以发现其中具备了 XR Interaction Toolkit 射线操作中需要用到的 XR Controller,XR Ray Interactor,Line Renderer 和 XR Interactor Line Visual。
我们先找到 XR Controller(Action-based),点击下图中用红框标出的按钮,选择左手或右手的 Preset:
选择后 XR Controller 下就会有对应的 Reference。左右手的 XR Controller 都设置完毕后,为了更好地区分左右手,我们可以修改刚刚创建的两个子物体的名字,分别为 LeftHand DistanceGrabController 和 RightHand DistanceGrabController :
以上操作是教大家如何从零创建一个能够发射射线的 Interactor 物体。为了能有更好的效果,我们还可以设置 XR Ray Interactor 上的射线发射起始点 Ray Origin Transform 和 XR Interactor Line Visual 的颜色效果,这一部分可以参考 UI 交互的教程(Unity VR开发教程 OpenXR+XR Interaction Toolkit 2.1.1 (五) UI),我这边就直接模仿 LeftHand UIController 和 RightHand UIController 的设置,让射线起始点位于食指处,射线无效是显示为透明(Invalid Color Gradient)。
现在我们试着运行一下程序,当我们手部发出的射线射到可抓取的物体上时,按下手柄的抓取键就能将物体抓到手上。但是射线射到地面上时居然激活了传送。
这个问题曾经在 UI 交互的 Demo 中出现过,当时我们是修改 XR Ray Interactor 的 Raycast Mask 来过滤能被 UI 射线射到的目标。这种方式需要更改物体的 Physics Layer。
然而,我们还有一种解决方法,这里就要用到上一篇教程中提到的 Interaction Layer Mask,它依靠 Interactor 和 Interactable 中的 Interaction Layer 决定了 Interactor 能与哪些 Interactable 发生交互;而 Raycast Mask 是 XR Ray Interactor 特有的,它依靠物体的 Physics Layer(Inspector 面板最上方的那个 Layer)来决定射线能射到哪些物体的碰撞体上。
因为 XR Ray Interactor 的 Interaction Layer Mask 默认是 Everything,地面的 Interaction Layer Mask 是 Teleport(我们在之前的教程中修改过),Teleport 包含在 Everthing 中,所以射线抓取的射线能与传送地面交互。而可交互物体的 Interaction Layer Mask 默认是 Default,所以只要把 XR Ray Interactor 的 Interaction Layer Mask 改为 Default,就能解决这个问题。
当然,你也可以为直接抓取和射线抓取分别设立一个 Interaction Layer,来区分哪些物体能被直接抓取,哪些能被射线抓取,或者哪些既能被直接抓取,又能被射线抓取(Interaction Layer Mask 可以多选)
效果图:
但是此时还有一个 BUG,如上图所示,当我们用射线抓取了一个物体后,即使按住手柄 Grip 键不放,有时候也会不小心将另一个物体 “吸” 过来。
实际上,这是 XR Direct Interactor 对 XR Ray Interactor 产生了干扰。我这个场景中的物体既能被射线抓取,又能被直接抓取。当我用射线抓取将物体吸到手上时,XR Direct Interactor 会认为它抓到了物体,因为手柄的 Grip 键处于按下的状态,而且物体位于 XR Direct Interactor 的触发区域,所以触发了它的功能,而且此时这个被抓取的物体就不是由 XR Ray Interactor 管了,而是归 XR Direct Interactor 管。
与此同时,XR Ray Interactor 会继续发挥它的作用,通过射线检测判断是否与可交互的物体发生交互,所以它仍然会从起始点开始发射射线。我们在场景中看不到射线是因为 XR Interactor Line Visual 脚本只能渲染从起始点到第一个有效物体的碰撞体的那一部分射线,因为射线射到的第一个有效的物体是刚刚抓取的第一个物体,所以我们看不到渲染的射线,但是没被渲染的那部分射线仍然发挥着检测作用。所以当射线检测到另一个可交互的物体时,XR Ray Interactor 仍会发挥它的抓取作用。
解决思路也很简单,就是当 XR Direct Interactor 发挥作用时,将 XR Ray Interactor 隐藏掉。我们可以写一个脚本进行控制:
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
using UnityEngine.XR.Interaction.Toolkit;
public class GrabRayController : MonoBehaviour
{
public XRRayInteractor leftGrabRay;
public XRRayInteractor rightGrabRay;
public XRDirectInteractor leftDirectGrab;
public XRDirectInteractor rightDirectGrab;
void Update()
{
leftGrabRay.enabled = leftDirectGrab.interactablesSelected.Count == 0;
rightGrabRay.enabled = rightDirectGrab.interactablesSelected.Count == 0;
}
}
然后把这个脚本随便挂在一个物体上,我这里挂在在 RayController上。然后在 Inspector 面板中对变量进行赋值,那么这个 BUG 就被解决了。
刚刚的 Demo 中,当射线射到可交互的物体后按下手柄的 Grip 键,物体会被立刻吸到手上。如果我想要抓取的时候让物体和手保持一定距离,营造一种远程操纵物体的感觉要怎么办呢?
我们找到 XR Ray Interactor 脚本,将 Force Grab 取消勾选。
如果想要让射线射到物体上的点作为实际的抓取点,而不是默认的抓取点,可以将 Dynamic Attach 勾选(上一篇直接抓取的教程中有介绍)
最终效果:
当你在测试射线抓取的场景时,也许会发现在使用取消 Force Grab 的射线抓取方式的前提下,如果按下手柄的 Grip 键抓取物体再推动摇杆,物体的位移和旋转角度为发生变化。
这是由 XR Controller(Action-based)和 XR Ray Interactor 组件的设置引起的。
首先看 XR Controller(Action-based)上的 Rotate Anchor Action 和 Translate Anchor Action:
它们引用了 Input System 中的 Action,我们找到默认的输入配置文件:
找到 Rotate Anchor 和 Translate Anchor:
可以看到这两个动作和摇杆在平面坐标轴上的位置有关,我们点击 Primary2DAxis,观察右侧的面板。
Rotate Anchor:
Translate Anchor:
关注其中的 Scale Vector 2,Rotate Anchor 的 X 是 1,说明在 X 轴上(左右)推动摇杆会触发这个动作;Translate Anchor 的 Y 是 1,说明在 Y轴上(前后)推动摇杆会触发这个动作。
然后我们再看 XR Ray Interactor 上的 Anchor Control:
正是因为这些设置,当我们远程抓取物体时,前后推动摇杆能控制物体与手的距离,左右推动摇杆能控制物体的旋转角度。
实际效果:
但是可以发现,触发 Rotate Anchor 或 Translate Anchor 的时候也会触发其他与摇杆控制有关的动作,比如传送,持续移动。因此我们希望在对远距离抓取物体进行操作时,不会触发该手柄其他和摇杆控制绑定的动作。
我们可以修改刚刚写的 Grab Ray Controller 脚本:
using System;
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
using UnityEngine.InputSystem;
using UnityEngine.XR.Interaction.Toolkit;
public class GrabRayController : MonoBehaviour
{
public XRRayInteractor grabRayInteractor;
public XRDirectInteractor grabDirectInteractor;
public InputActionReference rotateAnchorReference;
public InputActionReference translateAnchorReference;
public InputActionReference teleportActivateReference;
public InputActionReference moveReference;
public InputActionReference turnReference;
private void Start()
{
grabRayInteractor.selectEntered.AddListener(OnEnterGrab);
grabRayInteractor.selectExited.AddListener(OnExitGrab);
}
private void OnDestroy()
{
grabRayInteractor.selectEntered.RemoveListener(OnEnterGrab);
grabRayInteractor.selectExited.RemoveListener(OnExitGrab);
}
private void OnEnterGrab(SelectEnterEventArgs arg)
{
DisableAction(teleportActivateReference);
DisableAction(moveReference);
DisableAction(turnReference);
EnableAction(rotateAnchorReference);
EnableAction(translateAnchorReference);
}
private void OnExitGrab(SelectExitEventArgs arg)
{
EnableAction(teleportActivateReference);
EnableAction(moveReference);
EnableAction(turnReference);
DisableAction(rotateAnchorReference);
DisableAction(translateAnchorReference);
}
void Update()
{
grabRayInteractor.enabled = grabDirectInteractor.interactablesSelected.Count == 0;
}
private void EnableAction(InputActionReference actionReference)
{
var action = GetInputAction(actionReference);
if (action != null && !action.enabled)
action.Enable();
}
private void DisableAction(InputActionReference actionReference)
{
var action = GetInputAction(actionReference);
if (action != null && action.enabled)
action.Disable();
}
private InputAction GetInputAction(InputActionReference actionReference)
{
return actionReference != null ? actionReference.action : null;
}
}
核心思想是在触发抓取(SelectEnter 事件触发)和取消抓取(SelectExit 事件)的时候去控制相关的 InputAction 是否能开启。
因为我把这个脚本改成了左右手通用的模式,所以我们要把刚刚挂载到 RayController 物体上的 GrabRayController 脚本移除,改成在 LeftHand Controller 和 RightHand Controller 物体上分别挂载这个脚本,并且手动赋值。
最终效果: