本系列是基于Unity和HTC Vive开发虚拟现实应用的学习笔记。Unity环境下Vive开发必用的插件是SteamVR Plugin,除此之外,计划参考一些开源项目和免费的Asset Store插件,暂时涉及的包括:VRTK和Hover UI。
VRTK包括基于SteamVR Plugin实现的各种组件,除了提供一个Vive开发的辅助工具,更重要的是,它演示了如何使用SteamVR Plugin在Unity环境下开发Vive应用,具有很高的学习价值。我们的学习过程首先从VRTK的Demo开始。
该Demo提供了一个与Vive Controller(以下简称:手柄)交互的基础脚本VRTK_ControllerEvents,以及一个演示脚本(VRTK_ControllerEvents_ListenerExample)。演示了如何编写与控制器交互的脚本程序。
该脚本基于.NET事件模式,对于手柄的几乎全部交互操作,封装了一个事件发布者类脚本,并挂载在[CameraRig]对象下使用。首先,我通过该脚本再一次系统地学习了如何开发.NET事件模式程序。概念上,事件等同于一个多播委托,代码上则包含一个固定的模式,我将它归纳为:
(1)事件消息类型定义:事件发布者需要进行更改并通知所有订阅者的数据,即数据源类型。通常要继承EventArgs,但新的.NET框架可以不必(VRTK的代码属于后者)。
(2)事件委托定义:起到了定义该事件委托格式的作用,几个约束包括,返回值一定是void、第一个参数一定为object类型、第二个参数为(1)步骤中定义的消息类型。事实上,这步代码真正的作用就是给该事件绑定(1)中定义的用户消息类型。
(3)定义事件:通过event关键字,给(2)中的事件委托起一个名称,实质上这个步骤起到的是命名作用。
(4)发布者方法:发布者最后需要决定的,即是在什么时候发布消息、以及如何发布消息。对于该案例脚本,在MonoBehaviour的Update函数中进行更新是合理的策略,该脚本提供了一个EmitAlias统一各种手柄交互事件的发布形式。
(5)订阅者:订阅者不需要知道事件的实现细节,它们只需要对该事件类的事件成员(通常设为public)进行操作即可,包括添加和删除自己定义的委托方法。
其次,VRTK是基于SteamVR Plugin实现的,必然要用到其中的接口。该脚本实现的核心在于SteamVR_Controller.Device(以下简称Device类型)类型。该类型代表了一个Vive设备,目前Vive还只包括两个手柄和一个头盔,从SteamVR的代码上看,Vive是有增加追踪设备的计划的。这里,VRTK_ControllerEvents脚本被挂载到[CameraRig]下的一个[Controller](left/right)子物体下,那么Device类型成员就被赋值为该手柄。Device类的接口为我们提供了通过手柄来交互的方式。手柄都包括哪些交互元素呢?在SteamVR Plugin中又提供了哪些接口呢?
通过阅读Device类的代码,我们可以将手柄交互元素和接口总结为以下几个方面:
(1)位置和速度:手柄运动的线速度和角速度,接口:velocity和angularVelocity。位置,接口:transform。
(2)按键操作:各种GetPressXX/GetTouchXX
(3)按键内容:对于触摸板(TouchPad),可以获取Axis、Angle等信息;对于扳机,可以获取勾动扳机的幅度,比如可以用来判断误操作。接口:GetAxis等。
这个是订阅者脚本,这里给事件绑定的响应是一系列的控制台打印操作。在后续的开发中,我们可以从这个Demo的执行效果来观察,Vive手柄各种交互操作的输出结果是什么,并以此来设计具体应用中的交互逻辑。
这个案例中,按Grip键应该能弹出一个射线,碰到物体会出现一个指针。但貌似几个脚本都没有检测Grip按键,因此效果看不出来。通过仔细阅读VRTK_ControllerEvents脚本,我发现错误在于下面这行代码:
if (device.GetTouchDown(SteamVR_Controller.ButtonMask.Grip))
Device接口有两种,GetPressXX或者GetTouchXX,从语义上也比较好理解Press即是按下而Touch是触摸。后者仅仅对支持触摸方式的交互元素有效(触摸板和扳机),而对于Grip按键,显然只支持Press操作,因此这行代码无法检测Grip按键(事实上,ApplicationMenu按键的处理也存在这个问题。Trigger按键的Press操作并没有处理,可以根据后续需求按照VRTK_ControllerEvents脚本的实现方式,添加对应的事件实现交互操作。)为了解决这个问题,我又仔细看了一下VRTK_ControllerEvents这个脚本,它在VRTK中的地位十分重要,是后面所有基本功能的基础。因此我给代码加了一些必要注释,方便后面回顾。
namespace VRTK
{
using UnityEngine;
using System.Collections;
//定义事件消息格式
public struct ControllerInteractionEventArgs
{
public uint controllerIndex; //控制器索引
public float buttonPressure; //按键幅度
public Vector2 touchpadAxis; //触摸板Axis
public float touchpadAngle; //触摸板Angle
}
//定义事件委托
public delegate void ControllerInteractionEventHandler(object sender, ControllerInteractionEventArgs e);
public class VRTK_ControllerEvents : MonoBehaviour
{
public enum ButtonAlias //给按钮起一些别名
{
Trigger,
Grip,
Touchpad_Touch,
Touchpad_Press,
Application_Menu
}
public ButtonAlias pointerToggleButton = ButtonAlias.Grip;
public ButtonAlias grabToggleButton = ButtonAlias.Trigger;
public ButtonAlias useToggleButton = ButtonAlias.Trigger;
public ButtonAlias menuToggleButton = ButtonAlias.Application_Menu;
public int axisFidelity = 1;
//公共按键状态标志位:方便其它脚本进行查询
public bool triggerPressed = false;
public bool triggerAxisChanged = false;
public bool applicationMenuPressed = false;
public bool touchpadPressed = false;
public bool touchpadTouched = false;
public bool touchpadAxisChanged = false;
public bool gripPressed = false;
public bool pointerPressed = false;
public bool grabPressed = false;
public bool usePressed = false;
public bool menuPressed = false;
//定义各种事件
public event ControllerInteractionEventHandler TriggerPressed;
public event ControllerInteractionEventHandler TriggerReleased;
public event ControllerInteractionEventHandler TriggerAxisChanged;
public event ControllerInteractionEventHandler ApplicationMenuPressed;
public event ControllerInteractionEventHandler ApplicationMenuReleased;
public event ControllerInteractionEventHandler GripPressed;
public event ControllerInteractionEventHandler GripReleased;
public event ControllerInteractionEventHandler TouchpadPressed;
public event ControllerInteractionEventHandler TouchpadReleased;
public event ControllerInteractionEventHandler TouchpadTouchStart;
public event ControllerInteractionEventHandler TouchpadTouchEnd;
public event ControllerInteractionEventHandler TouchpadAxisChanged;
public event ControllerInteractionEventHandler AliasPointerOn;
public event ControllerInteractionEventHandler AliasPointerOff;
public event ControllerInteractionEventHandler AliasGrabOn;
public event ControllerInteractionEventHandler AliasGrabOff;
public event ControllerInteractionEventHandler AliasUseOn;
public event ControllerInteractionEventHandler AliasUseOff;
public event ControllerInteractionEventHandler AliasMenuOn;
public event ControllerInteractionEventHandler AliasMenuOff;
//设备相关
private uint controllerIndex; //设备索引:获取具体设备
private SteamVR_TrackedObject trackedController; //跟踪:获取controllerIndex
private SteamVR_Controller.Device device; //设备对象:Device类提供了一系列获取设备信息的接口
private Vector2 touchpadAxis = Vector2.zero;
private Vector2 triggerAxis = Vector2.zero;
public virtual void OnTriggerPressed(ControllerInteractionEventArgs e)
{
if (TriggerPressed != null)
TriggerPressed(this, e);
}
public virtual void OnTriggerReleased(ControllerInteractionEventArgs e)
{
if (TriggerReleased != null)
TriggerReleased(this, e);
}
public virtual void OnTriggerAxisChanged(ControllerInteractionEventArgs e)
{
if (TriggerAxisChanged != null)
TriggerAxisChanged(this, e);
}
public virtual void OnApplicationMenuPressed(ControllerInteractionEventArgs e)
{
if (ApplicationMenuPressed != null)
ApplicationMenuPressed(this, e);
}
public virtual void OnApplicationMenuReleased(ControllerInteractionEventArgs e)
{
if (ApplicationMenuReleased != null)
ApplicationMenuReleased(this, e);
}
public virtual void OnGripPressed(ControllerInteractionEventArgs e)
{
if (GripPressed != null)
GripPressed(this, e);
}
public virtual void OnGripReleased(ControllerInteractionEventArgs e)
{
if (GripReleased != null)
GripReleased(this, e);
}
public virtual void OnTouchpadPressed(ControllerInteractionEventArgs e)
{
if (TouchpadPressed != null)
TouchpadPressed(this, e);
}
public virtual void OnTouchpadReleased(ControllerInteractionEventArgs e)
{
if (TouchpadReleased != null)
TouchpadReleased(this, e);
}
public virtual void OnTouchpadTouchStart(ControllerInteractionEventArgs e)
{
if (TouchpadTouchStart != null)
TouchpadTouchStart(this, e);
}
public virtual void OnTouchpadTouchEnd(ControllerInteractionEventArgs e)
{
if (TouchpadTouchEnd != null)
TouchpadTouchEnd(this, e);
}
public virtual void OnTouchpadAxisChanged(ControllerInteractionEventArgs e)
{
if (TouchpadAxisChanged != null)
TouchpadAxisChanged(this, e);
}
public virtual void OnAliasPointerOn(ControllerInteractionEventArgs e)
{
if (AliasPointerOn != null)
AliasPointerOn(this, e);
}
public virtual void OnAliasPointerOff(ControllerInteractionEventArgs e)
{
if (AliasPointerOff != null)
AliasPointerOff(this, e);
}
public virtual void OnAliasGrabOn(ControllerInteractionEventArgs e)
{
if (AliasGrabOn != null)
AliasGrabOn(this, e);
}
public virtual void OnAliasGrabOff(ControllerInteractionEventArgs e)
{
if (AliasGrabOff != null)
AliasGrabOff(this, e);
}
public virtual void OnAliasUseOn(ControllerInteractionEventArgs e)
{
if (AliasUseOn != null)
AliasUseOn(this, e);
}
public virtual void OnAliasUseOff(ControllerInteractionEventArgs e)
{
if (AliasUseOff != null)
AliasUseOff(this, e);
}
public virtual void OnAliasMenuOn(ControllerInteractionEventArgs e)
{
if (AliasMenuOn != null)
AliasMenuOn(this, e);
}
public virtual void OnAliasMenuOff(ControllerInteractionEventArgs e)
{
if (AliasMenuOff != null)
AliasMenuOff(this, e);
}
//创建事件消息
ControllerInteractionEventArgs SetButtonEvent(ref bool buttonBool, bool value, float buttonPressure)
{
buttonBool = value;
ControllerInteractionEventArgs e;
e.controllerIndex = controllerIndex;
e.buttonPressure = buttonPressure;
e.touchpadAxis = device.GetAxis();
float angle = Mathf.Atan2(e.touchpadAxis.y, e.touchpadAxis.x) * Mathf.Rad2Deg;
angle = 90.0f - angle;
if (angle < 0)
angle += 360.0f;
e.touchpadAngle = angle;
return e;
}
void Awake()
{
trackedController = GetComponent();
}
void Start()
{
controllerIndex = (uint)trackedController.index;
device = SteamVR_Controller.Input((int)controllerIndex);
}
//语义:type指定了按键操作类型,touchDown指明了事件是On/Off
//buttonPressure指明了按键幅度,buttonBool表示更改的标志位(因此是ref类型)
void EmitAlias(ButtonAlias type, bool touchDown, float buttonPressure, ref bool buttonBool) //发布消息
{
if (pointerToggleButton == type)
{
if (touchDown)
{
pointerPressed = true;
OnAliasPointerOn(SetButtonEvent(ref buttonBool, true, buttonPressure));
}
else
{
pointerPressed = false;
OnAliasPointerOff(SetButtonEvent(ref buttonBool, false, buttonPressure));
}
}
if (grabToggleButton == type)
{
if (touchDown)
{
grabPressed = true;
OnAliasGrabOn(SetButtonEvent(ref buttonBool, true, buttonPressure));
}
else
{
grabPressed = false;
OnAliasGrabOff(SetButtonEvent(ref buttonBool, false, buttonPressure));
}
}
if (useToggleButton == type)
{
if (touchDown)
{
usePressed = true;
OnAliasUseOn(SetButtonEvent(ref buttonBool, true, buttonPressure));
}
else
{
usePressed = false;
OnAliasUseOff(SetButtonEvent(ref buttonBool, false, buttonPressure));
}
}
if (menuToggleButton == type)
{
if (touchDown)
{
menuPressed = true;
OnAliasMenuOn(SetButtonEvent(ref buttonBool, true, buttonPressure));
}
else
{
menuPressed = false;
OnAliasMenuOff(SetButtonEvent(ref buttonBool, false, buttonPressure));
}
}
}
bool Vector2ShallowEquals(Vector2 vectorA, Vector2 vectorB)
{
return (vectorA.x.ToString("F" + axisFidelity) == vectorB.x.ToString("F" + axisFidelity) &&
vectorA.y.ToString("F" + axisFidelity) == vectorB.y.ToString("F" + axisFidelity));
}
void Update()
{
controllerIndex = (uint)trackedController.index;
device = SteamVR_Controller.Input((int)controllerIndex);
Vector2 currentTriggerAxis = device.GetAxis(Valve.VR.EVRButtonId.k_EButton_SteamVR_Trigger);
Vector2 currentTouchpadAxis = device.GetAxis();
if (Vector2ShallowEquals(triggerAxis, currentTriggerAxis))
{
triggerAxisChanged = false;
}
else
{
OnTriggerAxisChanged(SetButtonEvent(ref triggerAxisChanged, true, currentTriggerAxis.x));
}
if (Vector2ShallowEquals(touchpadAxis, currentTouchpadAxis))
{
touchpadAxisChanged = false;
}
else
{
OnTouchpadAxisChanged(SetButtonEvent(ref touchpadTouched, true, 1f));
touchpadAxisChanged = true;
}
touchpadAxis = new Vector2(currentTouchpadAxis.x, currentTouchpadAxis.y);
triggerAxis = new Vector2(currentTriggerAxis.x, currentTriggerAxis.y);
//Trigger
if (device.GetTouchDown(SteamVR_Controller.ButtonMask.Trigger)) //对于每一个按键,都有两类事件:
{
OnTriggerPressed(SetButtonEvent(ref triggerPressed, true, currentTriggerAxis.x)); //首先,是单纯的按键事件
EmitAlias(ButtonAlias.Trigger, true, currentTriggerAxis.x, ref triggerPressed); //其次,是按键别名事件
}
else if (device.GetTouchUp(SteamVR_Controller.ButtonMask.Trigger))
{
OnTriggerReleased(SetButtonEvent(ref triggerPressed, false, 0f));
EmitAlias(ButtonAlias.Trigger, false, 0f, ref triggerPressed);
}
//ApplicationMenu
if (device.GetPressDown(SteamVR_Controller.ButtonMask.ApplicationMenu))
{
OnApplicationMenuPressed(SetButtonEvent(ref applicationMenuPressed, true, 1f));
EmitAlias(ButtonAlias.Application_Menu, true, 1f, ref applicationMenuPressed);
}
else if (device.GetPressUp(SteamVR_Controller.ButtonMask.ApplicationMenu))
{
OnApplicationMenuReleased(SetButtonEvent(ref applicationMenuPressed, false, 0f));
EmitAlias(ButtonAlias.Application_Menu, false, 0f, ref applicationMenuPressed);
}
//Grip
//if (device.GetTouchDown(SteamVR_Controller.ButtonMask.Grip)) //怀疑是这一行有问题,API是否发生了改变?
if (device.GetPressDown(SteamVR_Controller.ButtonMask.Grip)) //显然对于Grip不存在touch这种操作,应该改成Press
{
OnGripPressed(SetButtonEvent(ref gripPressed, true, 1f));
EmitAlias(ButtonAlias.Grip, true, 1f, ref gripPressed);
}
else if (device.GetPressUp(SteamVR_Controller.ButtonMask.Grip))
{
OnGripReleased(SetButtonEvent(ref gripPressed, false, 0f));
EmitAlias(ButtonAlias.Grip, false, 0f, ref gripPressed);
}
//Touchpad Pressed
if (device.GetPressDown(SteamVR_Controller.ButtonMask.Touchpad))
{
OnTouchpadPressed(SetButtonEvent(ref touchpadPressed, true, 1f));
EmitAlias(ButtonAlias.Touchpad_Press, true, 1f, ref touchpadPressed);
}
else if (device.GetPressUp(SteamVR_Controller.ButtonMask.Touchpad))
{
OnTouchpadReleased(SetButtonEvent(ref touchpadPressed, false, 0f));
EmitAlias(ButtonAlias.Touchpad_Press, false, 0f, ref touchpadPressed);
}
//Touchpad Touched
if (device.GetTouchDown(SteamVR_Controller.ButtonMask.Touchpad))
{
OnTouchpadTouchStart(SetButtonEvent(ref touchpadTouched, true, 1f));
EmitAlias(ButtonAlias.Touchpad_Touch, true, 1f, ref touchpadTouched);
}
else if (device.GetTouchUp(SteamVR_Controller.ButtonMask.Touchpad))
{
OnTouchpadTouchEnd(SetButtonEvent(ref touchpadTouched, false, 0f));
EmitAlias(ButtonAlias.Touchpad_Touch, false, 0f, ref touchpadTouched);
}
}
}
}
这里有一个点我想提一下,该脚本的事件总体包括两大类,一个是代表按键操作本身的事件,另一个是该脚本实现的抽象事件类型。对于后者,可以在Inspector中根据现有的属性进行更改,比如我们可以将发射射线的操作由Grip轻松改成TouchPad_Press!也可以自行扩展,实现我们想要的交互功能。
关于如何处理手柄交互操作的VRTK_ControllerEvents脚本我们已经基本搞清楚了,下一节我们将会详细探究003案例射线的实现原理和交互逻辑。
参考文献:
1. Github:https://github.com/giveaphuk/SteamVR_Unity_Toolkit
2. 《Htc Vive VR游戏开发实战》
3. Vive教程:https://blog.csdn.net/fcauto2012/article/details/78398171