VR中的UI凝视交互
发表于2016-11-02
示例项目下载地址:https://static.oculus.com/downloads/OVR_UI_Demo_5_2.zip
Unity GUI系统
Unity的UI系统由下列几个关键部分组成
· EventSystem
· InputModules
· RayCasters
· GraphicComponents: Button, Toggle, Slider 等.
EventSystem是处理所有UI事件的核心。它与其他几个部分紧密联系,例如InputModule,是它主要的事件处理来源。在Unity UI系统中,一个场景中只能有一个InputModule活跃。默认情况下,EventSystem绑定了一个鼠标输入模块(StandaloneInputModule)和一个触碰输入模块(TouchInputModule),他们分别用于处理PC端鼠标的输入以及移动设备触摸的输入监测。真正意义上的UI交互监测是在raycaster类中实现,例如GraphicRaycaster。
raycaster负责处理可交互对象的一些设置。当一个InputModule提交了鼠标或触摸点的信息,它会轮询所有可用的raycaster,每个raycaster都会监测自己的组件是否有触发交互事件。有两种Unity内置的raycaster,GraphicRaycaster (用于Canvas)以及PhysicsRaycaster (用于非UI对象)。
在用户点击场景时,raycaster会发出很多用于检测的射线,返回来的结果是一个对象的集合,但是用户肯定不希望一次点击同时响应不同的UI元素,这会混淆操作,而
InputModule的职责就是去找到场景中最近的射线检测结果。
为什么不能直接在VR下使用Unity UI
答案很简单,因为VR中没有屏幕,意味着没有鼠标可以移动的界面。
在VR下实现GUI操控,可以在世界空间下创建一个虚拟屏幕,然后仍然使用鼠标来移动,头显的移动并不能控制光标的移动,可以看到市面上的一些从PC移植的VR游戏就是采用这样的形式,但是这样显然很不“VR”。
另一种使用广泛的交互方式是使用VR凝视,始终在视野的正前方显示一个凝视点(十字准星,光圈等),它会随着头显的移动而移动。凝视功能同样通过raycast实现,但是射线发出的位置不是Unity UI系统中的origin of camera,而是两眼之间的点。也可以设置射线发出的点为可追踪手柄输入设备。
与鼠标及触摸屏点击不同,这些射线是世界坐标下的射线,而Unity UI系统只处理屏幕坐标。
改进思路
幸运地是,我们可以对UI系统进行简单的修改,使其可以处理世界坐标下发出的射线。也许未来Unity的UI系统也会支持各种形式的射线,但是目前我们只能将世界坐标下的射线转换为屏幕坐标,以适配当前的UI系统。
打开demo项目,可以看到几个继承自Unity UI内置类的脚本。
我们会对这些类进行分析理解,在此之前,先打开示例项目。
运行示例项目
此项目是Unity 5.2下构建的,所以建议使用此版本的Unity来打开项目。但是,项目中的代码也可以在Unity4.6或者5.1中使用。
此外还需要最新版本的Oculus Unity Utilities,可以在Oculus官网下载。下载后import进当前项目。
接下来让我们看看如何使用OVRInputModule, OVRRaycaster, OVRPhysicsRaycaster (以及一些辅助类)来实现非VR版本场景到VR场景的转换。
实现步骤
打开”Pointers”场景点击运行。可以看到,这是一个非VR的应用,直接可以用传统的鼠标操控来与UI交互,滑动滑动条或者点击复选框等。
接下来让我们看看如何把这个场景转换成VR中的场景。
Step 1: 用OVRCameraRig替换MainCamera
删除场景中的Camera,将其替换为OVR/Prefabs目录下的OVRCameraRig预置体。(如果在项目中找不到,请确保已经导入了Oculus插件包)。可以将其放在MainCamera之前所在的位置,也可以自己改变其位置使其适应场景中的UI界面。
如果使用Unity 5.1或更新的版本,确保在ProjectSettings->Player设置里勾选了“Virtual Reality Supported”。
Step 2: 改变InputModule
选择Hierarchy中的EventSystem对象。在Inspector中,可以看到它绑定了一个StandaloneInputModule组件。这是常规鼠标输入处理的输入模块。移除该组件,添加Assets/Scripts目录下的OVRInputModule。OVRInputModule用于处理射线指示,需要我们指定一个RayTransform属性。把OVRCameraRig下的CentreEyeAnchor拖到这个属性上就好,这样射线始终从中间眼位置发出。
In order to enable gamepad support you should also add the OVRGamepadController component to the EventSystem object.
Step 3: 添加GazePointer
由于不再使用鼠标,所以我们需要一个代替物来指示当前凝视的位置。在Assets/Prefabs目录下找到GazePointerRing预置体,拖放到场景中。预置体上除了GazePointer.cs还有一个与粒子特效有关的脚本,如不需要可以删除。
将OVRCameraRig对象拖放到脚本上的CameraRig位置上,这样OVRGazePointer就能获取到所需的CameraRig。
Step 4: 设置Canvas
任何world-space模式的Canvas对象都可以在经过一些改动之后在VR场景中操作。场景中有三个Canvas,所以只需要为每个Canvas重复以下步骤即可。首先,找到并选择Computer对象下的JointsCanvas对象。
4a: 可以注意到Canvas下的EventCamera会显示“Missing”,因为我们删除了场景中的默认camera,这里我们可以把OVRCameraRig下的Camera作为Canvas的EventCamera。在Unity 4.6中,可以选择LeftEyeAnchor或者RightEyeAnchor。5.1以上只能选择CenterEyeAnchor。
4b: 在Inspector中,可以看到Canvas绑定了GraphicRaycaster组件。它用于监测鼠标与GUI元素的交互。移除它,替换为OVRRaycaster脚本,它用于处理射线而非鼠标。
4c: 在OVRRaycaster对象下,改变Blocking Objects下拉列表为All。这可以确保凝视行为可以锁定场景中的其他可交互对象(如操作杆等)。
完成上述步骤后,运行场景,凝视功能就已经可以实现。可以在EventSystem的Inspector中修改OVRInputModule中的gaze click按键,默认是空格键。凝视某个UI元素,使用空格键模拟鼠标的按下松开等,可以模拟UI交互。(如果只修改了JointsCanvas,那么只能与这个Canvas进行凝视交互)。
Step 5: 与非UI对象交互
在OVRCameraRig上添加OVRPhysicsRaycaster组件。这个组件很像Unity内置的PhysicsRaycaster。在Inspector中有一个EventMask数学。这个过滤器定义了场景中OVRPhysicsRaycaster会监测的对象。将其设置为“Gazable” layer。场景中已经将所有可交互的对象置于“Gazable” layer了。再次运行场景并且尝试与场景中央的操作杆交互。
Step 6: 添加世界坐标下的鼠标
在场景中添加一个鼠标。这个鼠标和传统UI下的鼠标类似,只不过它是在世界坐标下的,只有当使用者凝视某个Canvas时,这个Canvas上的鼠标才可用。它为VR场景提供了与凝视一起存在的鼠标操作UI的功能。
以下步骤可以用于任何希望添加虚拟鼠标的Canvas。暂时只为JointsCanvas添加。
6a: 找到CanvasPointer预置体,将其作为Canvas对象的子对象。这个预置体上没有绑定任何形式的脚本,只是一个鼠标的显示。可以把它换成任何2D或者3D的鼠标指针。
6b: 把新添加的Pointer放在OVRRaycaster下的Pointer属性下。
6c: 在Canvas对象下添加OVRMousePointer脚本。它负责处理虚拟鼠标在Canvas上的移动。
现在重新运行场景,可以发现,除了可以继续使用凝视来进行UI交互,同时可以使用鼠标,当前凝视的Canvas上的虚拟鼠标也可以移动并且与UI元素交互。
工作原理
OVRInputModule
Unity中的StandaloneInputModule有许多与射线交互以及GUI元素响应有关的代码,如果能够继承它是最好的,但是大部分的核心功能都是private的,为了解决这个问题,我们有三个选项:
1、对Unity的UI系统做一个分支,实现我们自己版本的UI。在一个private的项目里,这可能是最好的选择,但是不需要如此小题大作,为了一个简单的凝视功能还要去安装一个新的UI DLL,所以这个方法不行。
2、要求Unity把这些核心函数改成protected。这个过程很费时,但是有人已经在跟进此事,在不久的将来,也许可以实现。
3、直接从StandaloneInputModule的基类PointerInputModule继承,然后把StandaloneInputModule里面我门需要的代码直接拷贝到我们的自定义类中。这种办法目前是最合适的。
GUI源码下载地址:https://bitbucket.org/Unity-Technologies/ui/downloads
梳理
阅读OVRInputModule.cs中的代码,可以看到它继承自PointerInputModule,并且有两处地方放置了StandaloneInputModule.cs的代码:
region StandaloneInputModule code
region Modified StandaloneInputModule methods
第一处是直接从StandaloneInputModule整个逐行复制过来的代码,但是去掉了三个方法
1、ProcessMouseEvent(int id)
2、Process
3、UseMouse
第二处正是我们本应继承的方法(如果它不是private的话),第一处复制的时候这三个方法没有复制进来,在这里做一些修改,就当作重写了
1、ProcessMouseEvent(MouseState mouseData)
2、Process()
3、UseMouse(bool pressed, bool released, PointerEventData pointerData)。
总之,OVRInputModule中的改变仅仅是StandaloneInputModule的拓展。以下是几个关键的改动处:
1、处理Gaze(凝视)和虚拟鼠标
在ProcessMouseEvent()方法内部,需要获取MouseState,StandaloneInputModule中的做法是传入参数int id,然后通过GetMousePointerEventData(id)来获取,而OVRInputModule是直接传入参数MouseState mouseData,而这个参数来源是GetGazePointerData()和GetCanvasPointerData()。两个新定义的方法GetGazePointerData()和GetCanvasPointerData()实现GetMousePointerEventData()和类似的功能,但是它处理的是一种新的pointer。 这些都是我们对射线输入处理的一些扩展,用按键来代替GUI中鼠标的点击,指定ray transform来设定射线方向等。这些方法也调用了 OVRRaycaster/OVRPhysicsRaycaster来寻找GUI/Physics交互对象。我们要把射线结果转换为屏幕坐标,以适配UI系统的要求。
2、使用Rays指示线
一个重要的改动是,我们继承PointerEventData,定义了OVRRayPointerEventData。这个类只比基类多了一个成员变量public Ray worldSpaceRay;,由于它是PointerEventData的一个子类,所以Unity的UI系统会把它当作一个特殊的PointerEventData处理。自定义的OVRRaycaster/OVRPhysicsRaycaster将会使用这个成员来进行正确的射线交互处理。
3、使用World-Space Pointers指示线(可选功能)
OVRInputModule 有一个公有变量:
1 | public OVRRaycaster activeGraphicRaycaster; |
这个变量用来跟踪记录”活跃”的raycaster。至于哪个raycaster算活跃可以有多种方案,本例中,当绑定了OVRRaycaster的对象检测到有pointer进入自己时,就把自己设为OVRInputModule中的活跃raycaster。这个变量的用途主要是,当World-space mouse pointers可用时,让系统知道哪个canvas上的鼠标是可以使用的。
4、将世界坐标下的射线检测结果转换为屏幕坐标
OVRInputModule最重要的工作,就是向GUI系统中的buttons、toggles、sliders、scroll bars等,隐瞒当前正在使用VR pointers的事实。这些UI元素的逻辑很多都依赖于PointerEventData的屏幕坐标(Vector2 二维坐标)。我们的pointers是基于世界坐标的,但是我们可以通过camera来转换它为屏幕坐标,生成正确的PointerEvent。
OVRInputModule.cs
1 2 | Vector2 position = ovrRaycaster.GetScreenPosition(raycast); leftData.position = position; |
OVRRaycaster.cs
1 2 3 4 5 | public Vector2 GetScreenPosition(RaycastResult raycastResult) { // In future versions of Uinty RaycastResult will contain screenPosition so this will not be necessary return eventCamera.WorldToScreenPoint(raycastResult.worldPosition); } |
经过这样的转换,又由于我们的OVRRayPointerEventData类是PointerEventData的子类,那么不管它来自于鼠标还是凝视的pointer,剩下的与UI元素交互的工作,Unity UI系统都会为我们完成。
OVRGazePointer
上述改动已经可以实现VR环境下的UI凝视功能。但是,我们希望给这个凝视提供一个视觉效果(不然使用者不知道自己当前凝视的是哪一点)。OVRGazePointer(单例)会完成这些工作,而OVRInputModule会保证它以正确的位置和方向显示。
OVRInputModule.cs
1 2 | OVRGazePointer.instance.RequestShow(); OVRGazePointer.instance.SetPosition(raycast.worldPosition, raycast.worldNormal); |
OVRGazePointer.cs
1 2 | Quaternion newRot = transform.rotation; newRot.SetLookRotation(normal, (lastPosition - transform.position).normalized); |
凝视圈的位置很简单,就是射线检测到的对象的位置。而方向可能复杂一点,当没有凝视到可交互对象时它始终面对使用者,如下图:
当凝视到UI元素或者其他可交互的对象时,OVRInputModule会获取该对象的世界法线(垂直于它的向量),然后设置凝视圈的y轴方向与这个法线方向平行(即x-z平面与其垂直),使得凝视圈与交互对象平行,如下图:
总结
借助几个继承Unity UI系统的自定义类,就可以使UI在VR中100%地实现。这里展示的例子只是一个开始,还有许多可以实现VR中UI操作的办法。gaze pointer也可以替换为tracked controller;虚拟鼠标可以替换为gamepad thumbstick;虚拟鼠标甚至可以被可追踪的手柄代替,等等。
随着Unity兼容更多的VR特性,此例中的许多脚本可能会变得有些多余,但是目前可以使用它们来基本实现VR中的凝视功能。
腾讯GAD游戏程序交流群:484290331