UI系统 [Unity_Learn_RPG_1]
讲解一个VR项目来学习。
一.VRTK
1.简介
其实就是 VRTK 帮我们做了硬件适配,当作一个中间层,让我们只需要调用它提供的方法即可。
为什么学VRTK?
即使我们不使用VRTK来开发VR项目,它的很多功能和实现是非常值得我们学习的。
2.如何获得?
Unity Store就有
steamVR 和 VRTK
这两个东西还有对应版本的,而且挺乱的。要注意= =。
3.配置
1).VRTK 与 unity 版本
使用2020打开项目,一直报错,并且查找原因后,大部分的答案无法解决。目前仅知道的问题是,VRTK似乎只支持到了unity2018.
以下是视频UP主提供的下载链接
使用VRTK 必须steamVR的最高版本1.2.3它使用带有按钮ID的旧式输入,而不是OpenXR的操作和操作集
- SreamVR1.2.3版本下载地址
https://github.com/ValveSoftware/steamvr_unity_plugin/releases - vrtk3.3.0版本下载地址
https://github.com/ExtendRealityLtd/VRTK/tree/3.3.0
使用官方提供的长期支持的2019版unity即可
注意,不要随便移动加载进来的包的位置,除非你知道怎么操作。我移动完之后,manifest.json就报错了,找不到某个包。
unity历史版本下载
https://unity3d.com/get-unity/download/archive
总结
最后,本来是想用最新版本的 VRTK 和 SteamVR 的,但是 SteamVR 会一直报错,所以改成了 1.2.3 版本的。
虽然还是会报一些错误,但是已经可以接受了。
2).VRTK 使用结构
a.结构
[SDKManager] (VRTK_Manager 组件)
--> SteamVR (VRTK_SET_UP 组件)
-->[CameraRig] (SteamVR里的自身,左右手和头部)
--> 其他VR设备配置
[VRTK_Script]
LeftController
RightController
Head(VRTK_SDKObjectAlias 组件,sdk object 参数为 headSet)
Body(VRTK_SDKObjectAlias 组件,sdk object 参数为 Boundary)
LeftController 和 RightController 只是 gameObject ,需要手动拉到 VRTK_SDK_Manager中。
Head 和 Body 只需要添加额外的VRTK组件。
b.为何是这种结构
运行程序时候,它的结构是这样的。
[VRTKScript] 里的 GameObject,全部移到 SteamVR 的 [CameraRig]里的Controller,除了 body 在 [CameraRig]里。
- Controller(left) -> LeftController
- Controller(right) -> RightController
- Camera(eye) -> Head
- Body
这两个 Controller(left/right) 都挂着 Steam VR_tracked Object ,它负责检测VR设备里你的左右手在unity里的世界坐标。
所以它的流程是这样的
- VRTK检测当前是什么设备
- 选择 [SDKManager] 下对应的设备,比如 SteamVR
- 将 [VRTKScript] 下的 GameObject 放入 SteamVR
- 通过这些 GameObject,挂上自己编写的脚本,获取、操作在 Unity 世界里对应的VR设备实体了。
所以我们只需要针对VRTKScript下的做开发即可,不需要管 [SDKManager],以及用了哪种VR设备。它会把 VRTKScript 下的对应 GameObject 放到当前使用的 VR 结构里([CameraRig])。
PS:
[SDKManager] 代表的就是玩家在 Unity 世界里的位置,移动玩家位置就是移动 [SDKManger] 的位置。
下图是它案例里的结构:
c.无设备使用
VRTK 给我们提供了一个模拟设备。结构如图:
用鼠标键盘模拟VR设备输入。
d.PS:
[SDKManager] 识别所有的子的 GameObject 挂载的 VRTK_SDKSetup组件。
Setups 下的 Auto Load 从上到下识别,只要遇到哪个 设备是可以使用的,就会停止往下寻找。
4.SDK 和 插件 区别
都是一大段代码。
SDK(软件开发工具包)
直接看定义,来自百度百科:
- 软件开发工具包是一些被软件工程师用于为特定的软件包、软件框架、硬件平台、操作系统等创建应用软件的开发工具的集合,一般而言SDK即开发Windows平台下的应用程序所使用的SDK。它可以简单的为某个程序设计语言提供应用程序接口API的一些文件,但也可能包括能与某种嵌入式系统通讯的复杂的硬件。一般的工具包括用于调试和其他用途的实用工具。SDK还经常包括示例代码、支持性的技术注解或者其他的为基本参考资料澄清疑点的支持文档。
比如Android SDK,我们没有它,就无法开发android程序。
插件
- 面向功能的。如果没有某个插件,那我们还是可以自己开发相对应的功能。
比如Unity 里的 UGUI ,我们没有它,也还是可以自己写 UI 逻辑。
二.VRTK 事件检测
老师的版本和我的好像还不一样
参数请自行去文档查看。
VRTK_ControllerEvents
public class VRTK_ControllerEvents : MonoBehaviour
可以看到它是继承于 Mono 的,接下来看它的部分生命周期
1.Awake
protected virtual void Awake()
{
// 向 VRTK_SDKManager 注册自己
VRTK_SDKManager.AttemptAddBehaviourToToggleOnLoadedSetupChange(this);
}
2.OnEnable
protected virtual void OnEnable()
{
// 找到当前设备
GameObject actualController = VRTK_DeviceFinder.GetActualController(gameObject);
if (actualController != null)
{
// 找到当前设备上的 VRTK_TrackedController 组件
trackedController = actualController.GetComponentInParent<VRTK_TrackedController>();
if (trackedController != null)
{
// 向 VRTK_TrackedController 注册事件
trackedController.ControllerEnabled += TrackedControllerEnabled;
trackedController.ControllerDisabled += TrackedControllerDisabled;
trackedController.ControllerIndexChanged += TrackedControllerIndexChanged;
trackedController.ControllerModelAvailable += TrackedControllerModelAvailable;
}
}
}
3.update
每帧调用的逻辑,可以看出来是分有这些部分的。
protected virtual void Update()
{
VRTK_ControllerReference controllerReference = VRTK_ControllerReference.GetControllerReference(gameObject);
//Only continue if the controller reference is valid
if (!VRTK_ControllerReference.IsValid(controllerReference))
{
return;
}
// 每帧逻辑分块
CheckTriggerEvents(controllerReference);
CheckGripEvents(controllerReference);
CheckTouchpadEvents(controllerReference);
CheckTouchpadTwoEvents(controllerReference);
CheckButtonOneEvents(controllerReference);
CheckButtonTwoEvents(controllerReference);
CheckStartMenuEvents(controllerReference);
CheckExtraFingerEvents(controllerReference);
}
protected virtual void CheckTriggerEvents(VRTK_ControllerReference controllerReference)
{
...
//Trigger Touched
if (VRTK_SDK_Bridge.GetControllerButtonState(SDK_BaseController.ButtonTypes.Trigger, SDK_BaseController.ButtonPressTypes.TouchDown, controllerReference))
{
OnTriggerTouchStart(SetControllerEvent(ref triggerTouched, true, currentTriggerAxis.x));
}
这个SDK是怎么获取到实际硬件设备的输入呢???我们F12下面这个函数
// VRTK_SDK_Bridge.GetControllerButtonState
// F12 跳转
public static bool GetControllerButtonState(SDK_BaseController.ButtonTypes buttonType, SDK_BaseController.ButtonPressTypes pressType, VRTK_ControllerReference controllerReference)
{
return GetControllerSDK().GetControllerButtonState(buttonType, pressType, controllerReference);
}
// GetControllerButtonState 是如何实现的?
// F12 跳转
public abstract bool GetControllerButtonState(ButtonTypes buttonType, ButtonPressTypes pressType, VRTK_ControllerReference controllerReference);
可以看到,最后 GetControllerButtonState 方法是个抽象函数。它的类是 SDK_BaseController ,继承于
SDK_Base。
public abstract class SDK_BaseController : SDK_Base
GetControllerButtonState 检测了按钮是否被按下。也就是说,SDK_BaseController 应该被不同平台设备的代码所继承,然后实现。
我们用SteamVR来当作例子。看它的 GetControllerButtonState 是如何实现的
public override bool GetControllerButtonState(ButtonTypes buttonType, ButtonPressTypes pressType, VRTK_ControllerReference controllerReference)
{
uint index = VRTK_ControllerReference.GetRealIndex(controllerReference);
if (index >= OpenVR.k_unTrackedDeviceIndexInvalid)
{
return false;
}
switch (buttonType)
{
case ButtonTypes.Trigger:
return IsButtonPressed(index, pressType, SteamVR_Controller.ButtonMask.Trigger);
...
}
return false;
}
我们主要看Trigger,再找到 IsButtonPressed 方法
protected virtual bool IsButtonPressed(uint index, ButtonPressTypes type, ulong button)
{
if (index >= OpenVR.k_unTrackedDeviceIndexInvalid)
{
return false;
}
SteamVR_Controller.Device device = SteamVR_Controller.Input((int)index);
switch (type)
{
case ButtonPressTypes.Press:
return device.GetPress(button);
...
}
return false;
}
我们传入一个 index(物理设备上的按钮索引),通过 SteamVR_Controller.Input() 就可以得到一个设备。
SteamVR_Controller.Device device = SteamVR_Controller.Input((int)index);
老师到这里,就停了。说这里就是根,但我们继续往下查找。
//Device 就是一个最普通的类,什么都没继承
public class Device
最后进入到了这里,openvr_api.cs 里,再往后就是 openvr 的内容了,我们到此为止。
三.VRTK 光标指针
1.VR中的移动方式
VR设备三种玩家移动方式:
- 就是图里这种;
- 看图里红线位置,物理设备上会有个触摸板。
- 上下晃动VR设备,模拟双脚移动(我想到类似与switch的马里奥奥德赛的操作)
2.VRTK 提供的光标指针
注意要把 渲染组件(xxxRender) 拖到 Pointer 里。这里是 射线 还是 贝塞尔曲线 由 Render 决定,Pointer 负责检测用户的输入。
2.移动玩家
VRTK有给我们提供一些封装好的组件,来改变玩家位置。BasicTeleport 是基础的改变玩家位置组件。
例子是 heightAdjustTeleport 组件,它可以改变玩家的高度(一般只能平面移动)
3.传送遇到的问题
这里,老师把它遇到的问题,用练习来让我们了解。
1).禁止传送到指定区域
如图所示。老师分析之后,禁止传送的逻辑,应该放在传送组件上才对。所以这个组件上有个 Target List Policy 参数,要求传入一个 VRTK_PolicyList。说明这个参数(规则)是可以动态改变的。
我们看这个 Policy 组件,它的Check Types参数,其实就是判断物体身上的,层,tag,组件而已,很常规的做法。
2).传送时与墙壁保持间距
VRTK也有提供了一个这种组件。在Render下新增一个 VRTK_PlayAreaCursor 组件,并拖动到 Renderer 下的 playarea Cursor 属性下。
勾选如图属性。当玩家指定可移动区域时,如下图,是蓝色。不可移动区域,则是红色。
这种做法就是区域碰撞检测,用矩形来概括玩家的平面面积,只是做平面的碰撞检测,遇到台阶就会有问题。也是一种常规的做法。
3).允许进入触发器内
两个问题
- 平面检测不让进去
这里发光的地方,是一个触发器,然而因为触发器也是会触发VRTK这里的碰撞检测,所以无法移动到里面。 - 解决方法
- 新增参数 Renderer 上的 CustomRaycast ,添加 VRTK 提供的 VRTK_CustomRaycast 组件。也是判断层(Layer To Ingore 属性)。这样也只是让曲线可以移动到触发器的内部空间。
- 移动到了触发器的上方
触发器的区域是一个立方体,但是目前平面无法进入,但是顶部又可以判定为可移动。会导致移动到顶部的高度去。 - 解决方法
- 在 区域判断 里的 Target List Policy 属性,在物体上挂载一个新的VRTK_PolicyList组件。这样区域判断就不会禁止了。原理也是判断层。
- Renderer 里的 Custom Raycast 是为了让光标不停留在触发器之上,不与触发器的层发生检测。
- Area 里的 Policy 是为了让 Area 不与触发器的层发生检测。
最后解决的方法,大部分都是 层/tag/script 的碰撞/检测判断。
4.拾取
1).碰撞器
一般都用 box collider,这里老师说,就算是复杂的形状一般也用多个 box collider 来拼凑成大致的形状,这样效率更高。
2).抓取
这两个是一定都要的,Touch负责检测设备输入,Grab只负责其他的,比如哪个按键按下是抓取。
3).Interaction
VRTK 的 Interaction 有这些脚本。在学习其他的项目时,也有看到部分功能(系统)被命名为 Interaction。
不仅仅是拾取,还有战斗处决,开启宝箱之类的。也就是说游戏内部的交互,都由此系统来实现?
4).抓取物体的连接
抓取之后,其实就靠关节连接住了。这个只是默认的关节。
我们可以使用这些VRTK提供的Attach脚本,来放入这个参数,这样就会产生相应的物理连接器。我们这里使用 ChildOfControllerGrabAtttach 脚本,来链接物体和手。
如图,这脚本会把物体放入图中的层级,而不是仍在最外层。
5).把物体放入指定层级
如图,在controller上的 VRTK_InteractGrab 上有提供给我们配置,并且需要的是带刚体的GameObject。
6).抓取物体的定位
比如武器,每个武器有不同的体积,手的位置是固定的,武器的位置就不固定了,所以我们需要一个定位点。
如图,我们在 武器下增加一个 Snap,然后把他拖到 VRTK_xxxAttach,Right/Left 分别代表左右手。这样武器就会跟随定位点的设置了。
PS:精准抓取
如图,红框上面的 Precision Grab,勾选这个后就不会使用 定位点 了。
所谓精准抓取,就是手部设备与其他东西交互的点在哪里,那就在哪里抓起来,可以抓取物体的任一位置。
7).抓取时的控制器设置
不过目前看起来弃用了。
8).延时拾取
public class SingleGunControl : MonoBehaviour {
private VRTK_ControllerEvents controller;
/// <summary>
/// 枪的控制
/// </summary>
private void OnEnable() {
controller = GetComponentInParent<VRTK_ControllerEvents>();
if(null == controller) {
this.enabled = false;
return;
}
controller.TriggerPressed += OnTriggerPressed;
}
private void OnTriggerPressed(object sender, ControllerInteractionEventArgs e) {
//e.touchpadAngle 触摸角度
Debug.LogFormat("{0} -- {1} ", sender, e.touchpadAxis);
}
private void OnDisable() {
if(null != controller) {
controller.TriggerPressed -= OnTriggerPressed;
}
}
}
SingleGunControl 是负责检测设备按下后,让枪械开火的脚本。平时它是 disable 的,只有在枪械装到控制器上时才会 enable.
public class GrabGun : MonoBehaviour{
private void Start() {
GetComponent<VRTK.VRTK_InteractableObject>().InteractableObjectGrabbed += OnGrabbed;
}
private void OnGrabbed(object sender, InteractableObjectEventArgs e) {
StartCoroutine(SetGunControlState());
}
private IEnumerator SetGunControlState() {
// 延迟一帧
yield return null;
// 立即执行 OnEnable
GetComponent<SingleGunControl>().enabled = true;
}
}
GrabGun,拾枪逻辑,它注册了**InteractableObjectGrabbed(拾取完成)**事件,用来开启 SingleGunControl 脚本。
正确的顺序是
- 拾取物品
- 物品层级改变
- 触发InteractableObjectGrabbed事件
- 开启SingleGunControl脚本
但是实际上,2是在3前面的,所以我们使用协程,延迟了一帧时间来开启 SingleGunControl 脚本。
private IEnumerator SetGunControlState() {
// 延迟一帧
yield return null;
// 立即执行 OnEnable
GetComponent<SingleGunControl>().enabled = true;
}
其实就是使用协程延迟,来使一些逻辑顺序变成我们需要的样子。
四.传送源码分析
通过之前的学我们可以知道。传送是通过 VRTK_HeightAdjustTeleport 组件来实现的。 它的功能是:
- 接受渲染器选定的位置信息
- 改变Player所在位置
我们可以看到它可以放在 VRTK_Script 下的任一位置,那它是怎么实现功能的呢?
public class VRTK_HeightAdjustTeleport : VRTK_BasicTeleport
public class VRTK_BasicTeleport : MonoBehaviour
可以看到它的继承关系,最后还是继承了 Mono。先来看他的周期方法。
protected virtual void Awake() {
// 就是向 VRTK_SDKManager 注册一下自己
VRTK_SDKManager.AttemptAddBehaviourToToggleOnLoadedSetupChange(this);
}
protected virtual void OnEnable() {
// 把自己当前的 GameObject 设为 VRTK_PlayerObject.ObjectTypes.CameraRig
VRTK_PlayerObject.SetPlayerObject(gameObject, VRTK_PlayerObject.ObjectTypes.CameraRig);
// 传送的时候,相机会先一黑,再出现。
headset = VRTK_SharedMethods.AddCameraFade();
// 玩家位置变化。获取代表玩家的 GameObject 的 Transform 组件。
// 所以无论这个组件在哪里,它都会找的到真正代表玩家的 GameObject
playArea = VRTK_DeviceFinder.PlayAreaTransform();
adjustYForTerrain = false;
enableTeleport = true;
// 这里使用了协程延迟逻辑
initaliseListeners = StartCoroutine(InitListenersAtEndOfFrame());
VRTK_ObjectCache.registeredTeleporters.Add(this);
}
protected virtual void OnDisable()
{
if (initaliseListeners != null)
{
StopCoroutine(initaliseListeners);
}
InitDestinationMarkerListeners(false);
VRTK_ObjectCache.registeredTeleporters.Remove(this);
}
protected virtual void OnDestroy()
{
VRTK_SDKManager.AttemptRemoveBehaviourToToggleOnLoadedSetupChange(this);
}
这里使用协程延迟,因为OnEnable的执行顺序不满足使用顺序。
protected virtual IEnumerator InitListenersAtEndOfFrame() {
// 延迟到当前帧结束
yield return new WaitForEndOfFrame();
if (enabled)
{
InitDestinationMarkerListeners(true);
}
}
protected virtual void InitDestinationMarkerListeners(bool state) {
GameObject leftHand = VRTK_DeviceFinder.GetControllerLeftHand();
GameObject rightHand = VRTK_DeviceFinder.GetControllerRightHand();
InitDestinationSetListener(leftHand, state);
InitDestinationSetListener(rightHand, state);
for (int i = 0; i < VRTK_ObjectCache.registeredDestinationMarkers.Count; i++)
{
VRTK_DestinationMarker destinationMarker = VRTK_ObjectCache.registeredDestinationMarkers[i];
if (destinationMarker.gameObject != leftHand && destinationMarker.gameObject != rightHand)
{
InitDestinationSetListener(destinationMarker.gameObject, state);
}
}
}
我们可以看到,InitDestinationMarkerListeners 里基本都调用了 InitDestinationSetListener 方法
public virtual void InitDestinationSetListener(GameObject markerMaker, bool register) {
if (markerMaker != null) {
// 找到当前物体身上的所有 VRTK_DestinationMarker 组件
VRTK_DestinationMarker[] worldMarkers = markerMaker.GetComponentsInChildren<VRTK_DestinationMarker>();
// 遍历所有的 VRTK_DestinationMarker
for (int i = 0; i < worldMarkers.Length; i++) {
VRTK_DestinationMarker worldMarker = worldMarkers[i];
if (register) {
// 添加 DoTeleport 时间监听,DoTeleport 就是真正执行传送逻辑的方法
worldMarker.DestinationMarkerSet += new DestinationMarkerEventHandler(DoTeleport);
if (worldMarker.targetListPolicy == null) {
worldMarker.targetListPolicy = targetListPolicy;
}
worldMarker.SetNavMeshData(navMeshData);
worldMarker.SetHeadsetPositionCompensation(headsetPositionCompensation);
}
else {
worldMarker.DestinationMarkerSet -= new DestinationMarkerEventHandler(DoTeleport);
}
}
}
}
接下来看 VRTK_DestinationMarker 这个组件。可以看到它是继承于mono的抽象类。
这段逻辑会遍历传入 GameObject 的是所有子节点,找到 VRTK_DestinationMarker 的子类。然后注册 DestinationMarkerSet 事件,以及事件的处理方法DoTeleport(具体的移动逻辑)。
public abstract class VRTK_DestinationMarker : MonoBehaviour
它是抽象类,那么它的功能就应该由它的子类实现。
这是我搜索到它的部分子类。可以看到 VRTK_Pointer 这个指针基类也是继承于它的。
这样,通过事件消息监听的逻辑,就把光标指针的操作和实际的移动联系了起来。
五.UI
- 2D UI
UI的屏幕反馈。比如,摇杆,小地图等不随3D视角改变而改变。 - 3D UI
UI在世界里,会随着3D视角改变而改变。 - 模型 UI
1.Canvas
如图,前两种就是所谓的2D UI,最后一个是3D UI。Screen Space - Overlay 这种,老师说在头盔里看不到的,只有 Screen Space - Camera 才行。
区别是:
- Screen Space - Overlay 需要我们自己去写渲染。UGUI是自己做。
- Screen Space - Camera Camera指的就是有一个专门的摄像机来渲染。NGUI里是自动做了。
2.UICamera
我们使用Canvas 的 Screen Space - Camera 来渲染UI。那么我们肯定需要一个 UICamera 的。
Unity Camera (Clear Flags, Culling Mask, Viewport Rect 做些好玩的事) 以下内容来自链接。
3.2/3D UI
Unity种 1P = 1m,1像素等于1米。
1).2D UI
- Depth,深度。要比我们想覆盖在上层的所有相机大。比如主相机的depth默认是 -1 ,那UI肯定要渲染在它上面,那它的 Depth 就要比 -1 大。
- Culling Mask,渲染层。哪些层是需要我们渲染的。我们是单独用了一个Camera来渲染UI,所以只需要UI层即可。同样主相机就不需要渲染UI了,记得去掉。
拓展:既然UI有分这么多种,那我们可以再细分为 3D UI,2D UI 等Layer。这样也会有多个相机负责渲染不同类型的UI。
2).3D UI
如图,3DUI Canvas的基本设置。注意,因为1像素等于1米,而一般一张图的大小都是几到几千的像素,导致Canvas太大,我们应该修改 Canvas 的 Scale 来使它变成我们需要的大小,而不是修改Canvas下其他UI的尺寸和大小。
3.3D UI 交互
老师告诉我们的是,不论 NGUI 和 UGUI 都不支持VR设备的手柄进行交互的。因为 UGUI 和 NGUI 的建立时间比VR早。但是一般都使用 UGUI,因为是Unity原生的。所以这些VR厂商就出了很多基于UGUI的UI框架。
如图,VRTK里的3D UI交互步骤。
只需要用于发射的 GameObject 上增加 VRTK_UIPoint 组件,以及3DCanvas上增加 VRTK_Canvas 组件,即可成功交互。
VRTK_Pointer 负责发射光标指针,VRTK_StraightPointerRenderer 则负责渲染发射的射线。
1).改变发射点的位置
如图,在右手下面新建一个UIPointer,用来放 VRTK_UIPointer 和 VRTK_StraightPinterRenderer
注意 VRTK_UIPointer 是需要 VRTK_ControllerEvents 在同级的,如果不在同级则把拖动所在GameObject 到这个参数里。
六.UI交互原理
1.UGUI事件处理流程
图里的流程是老师自己总结的,省略了很多细节。
1).EventSystem 每帧调用 BaseInput
可以看到,EventSystem 以及BaseInputModule子类 StandaloneInputModule(标准输入模块) 都出一个一个 GameObject 上
- StandaloneInputModule(标准输入模块) 每帧处理处理鼠标,键盘等输入。
- TouchInputModule(触摸输入模块) 移动端使用的,每帧处理屏幕输入。
我们可以看到,这里是没有支持VR设备的输入的,所以VRTK自己实现了输入。
2).计算光标接触的物体(Graphic)
a.UGUI的底层就是在检测Graphic
为什么这么说呢?
我们可以发现,Image和Text都继承于MaskableGraphic,而MaskableGraphic继承于Graphic。所以我们知道。Image和Text是被射线检测的Grahpic。我们举个例子来看button。
所以我们知道,button能被检测到,是因为它的Image组件,而不是button本身。
Raycast Target 是否能被检测到的开关。一般来说也代表着这个组件是UGUI里能被检测到的Graphic。
b.Base Raycaster 的 Raycaster 获取所有 Grahpic 来检测
可以看到Canvas上挂着 Graphic Raycaster 组件,这表示它和它的子节点,都可以被纳入到 EventSystem 的检测中。如果UGUI被移动到画布外,那它将不会被检测到。
Graphic Raycaster 虽然叫 Raycaster,但是因为2DUI 只需要判断平面即可。所以只需要判断点的坐标是否在二维物体内即可。
PhysicsRaycaster 物理射线检测,用于3D物体
PhysicsRaycaster2D 用于Unity2d框架检测使用的
c.Graphic 通过它的 IsRaycastLocationValid 方法
Graphic 提供了可重写的 IsRaycastLocationValid 方法,IsRaycastLocationValid 用来判断 Graphic 是否被选中。
老师讲解了一个例子,如果我们想自己自定义一个形状,不按照图片的大小来表示点击区域,那该如何呢?
这里给GameObject添加了一个 PolygonColliser2D(2D不规则形状碰撞体),PolygonColliser2D 的 OverlapPoint 方法,允许我们传入一个屏幕点坐标来判断这个点是否在 PolygonColliser2D 的形状内。
这里例子只是告诉我们,发挥我们的想象,可以自己处理点击区域,不必按照它显示的样子。
3).通过ExecuteEvents引发物体的相关事件
如图。老师说了,Unity是接口编程。UGUI提供的Button类,实现了 IPointerClickHandler 接口,看名字就可以看出来,它是负责接受 Click 事件的接口。同样的,还有 Touch / Down 等接口。
2.VRTK UI事件处理流程
通过上面UGUI的例子,我们接下来看VRTK自己实现的VRTK UI 处理流程。
1).VRTK_EventSystem 创建 VRTK_VRInputModule 对象并每帧调用Process方法
UGUI是 EventSystem 每帧调用 BaseInputModule 的子类。因为原来 BaseInputModule 的两个子类,只有键盘鼠标和触摸输入,所以VRTK增加了一个监听VR设备输入的子类 VRTK_VRInputModule。
2).计算光标接触的物体(Graphic)
流程还是一样,不一样的是:
- 实现了一个 VRTK_UIGraphiocRaycaster 类。用它来检测获取所有的Graphic。
- 实现了一个 VRTK_UIPointer。负责定位发射射线的位置。
3).VRTK_UIPointer 的作用
我们从 VRTK_UIPointer 的代码开始看,OnEnable()
protected virtual void OnEnable(){
...
ConfigureEventSystem();
...
}
调用 ConfigureEventSystem 方法
protected virtual void ConfigureEventSystem()
{
if (cachedEventSystem == null)
{
// 查找唯一的 EventSystem
cachedEventSystem = FindObjectOfType<EventSystem>();
}
if (cachedVRInputModule == null)
{
// 把 EventSystem 传给 SetEventSystem 方法
cachedVRInputModule = SetEventSystem(cachedEventSystem);
}
if (cachedEventSystem != null && cachedVRInputModule != null)
{
if (pointerEventData == null)
{
pointerEventData = new PointerEventData(cachedEventSystem);
}
if (!cachedVRInputModule.pointers.Contains(this))
{
cachedVRInputModule.pointers.Add(this);
}
}
}
调用 SetEventSystem方法
public virtual VRTK_VRInputModule SetEventSystem(EventSystem eventSystem)
{
if (eventSystem == null)
{
VRTK_Logger.Error(VRTK_Logger.GetCommonMessage(VRTK_Logger.CommonMessageKeys.REQUIRED_COMPONENT_MISSING_FROM_SCENE, "VRTK_UIPointer", "EventSystem"));
return null;
}
if (!(eventSystem is VRTK_EventSystem))
{
// 给 eventSystem 的 GameObject 上增加 VRTK_EventSystem
// VRTK_EventSystem 里会增加 VRTK_VRInputModule 组件,所以下面的才能获取到 VRTK_VRInputModule
eventSystem = eventSystem.gameObject.AddComponent<VRTK_EventSystem>();
}
// 返回 VRTK_VRInputModule (VR设备输入模块)
return eventSystem.GetComponent<VRTK_VRInputModule>();
}
七.VRUI框架
1.需求
1).UI窗口(Canvas)的统一管理 (记录、提供显隐功能)
2).UI事件管理
Unity提供的还不够,我们自己得增加一些逻辑。
2.UI结构
— 跟物体 (UIManager)
—— 窗口 (xxxWindow:UIWindow。唯一的)
——— 交互元素 (UIEventListener。讲解了一个问题,如过有多个弹窗在一起,那么它们算交互元素而不算window)
Q1:如果一个Window,在运行时,会生成多个怎么办?比如提示框,它的样式都一样,但是很容易出现多个提示框层叠。
1). UIWindow
代码
///
/// UI窗口基类:定义所以有窗口共有成员(显隐)
///
public class UIWindow : MonoBehaviour
{
private CanvasGroup canvasGroup;
private VRTK.VRTK_UICanvas UICanvas;
private Dictionary<string, UIEventListener> UIEventDic;
private void Awake()
{
canvasGroup = GetComponent<CanvasGroup>();
UICanvas = GetComponent<VRTK.VRTK_UICanvas>();
}
///
/// 设置窗口可见性
///
public void SetVisible(bool state, float delay = 0)
{
// delay 默认值是0,虽然是0,但因为是协程,所以不会立即执行,而是会在下一帧执行
// 如果对 delay 为0 时,下一帧执行不满足需求,那我们再改成当 delay 为0时,不走协程
StartCoroutine(SetVisibleDelay(state, delay));
}
private IEnumerator SetVisibleDelay(bool state, float delay)
{
yield return new WaitForSeconds(delay);
// Canvas Group
canvasGroup.alpha = state ? 1 : 0;
// VRTK Canvas
UICanvas.enabled = state;
}
/// <summary>
/// 根据子物体名称获取UI监听器
/// </summary>
/// <param name="name"></param>
/// <returns></returns>
public UIEventListener GetUIEventListener(string name)
{
if (!UIEventDic.ContainsKey(name) ){
// 这段代码确保了一定会获取到 UIEventListener 组件,没有也会给你加上
// 问题是如果没有这个子物体,则一定会报错
UIEventDic.Add(name, UIEventListener.GetListener( transform.FindChildByName(name) ) );
}
return UIEventDic[name];
}
}
}
a.UI显隐
不建议使用物体的隐藏和显示,就是如图的打勾选项。
建议我们修改 alhpa 通道,也就是透明度。而且它的性能相对上面的方法更好。但是我们知道透明度这个东西,只在一些渲染组件上有,而且一个画布下,有很多的UI,怎么样才能把它们全部的alpha都改变呢?
Unity提供了Canvas Group组件,可以同改变它的 Alpha 属性,来改变它和它子UI的alpha。
但是还有一个问题,我们虽然改变了显隐,但是碰撞体是还在的。VR设备的射线检测还是能检测到。
之前有看过 VRTK_UI_Canvas 的源码,可以知道这个碰撞体是 VRTK 自己加的,放便它自己坐碰撞检测。所以我们只需要 禁用 VRTK_UI_Canvas 组件,它的 Ondisable 逻辑自然会把这些组件去掉。
b.GetUIEventListener
public UIEventListener GetUIEventListener(string name)
它会获取传入名称的子物体的 UIEventListener 组件(没有会加上),并且做缓存处理。
2).UIMainWindow
代码
public class UIMainView : UIWindow
{
// 1.在开始时,注册需要交互的UI元素事件
// Start is called before the first frame update
void Start()
{
// 问题1:Find只能查找子节点,无法递归寻找,如果寻找的物体在层层级下,该怎么办?
// 解决:未知层级,查找后代元素。
// Find 用法1
//transform.Find("ButtonGameStart").GetComponent<Button>().onClick.AddListener(OnGameStartButtonClick);
// Find 用法2
//transform.Find(".../ButtonGameStart").GetComponent<Button>().onClick.AddListener(OnGameStart);
// TransformHelper 拓展方法
//transform.FindChildByName("ButtonGameStart");
transform.FindChildByName("ButtonGameStart").GetComponent<UIEventListener>().PointerClick += OnGameStartButtonClick;
// 问题2:Button只具有单击事件,而其他大多数事件(光标按下、抬起、拖拽。。。)都不具备
// Button具有的单击事件没有事件参数类。没参数!无法分别哪个是哪个按钮
// 结论:UGUI提供的Button是给基础人员使用的,我们应该自己搞
// 解决:定义事件监听类,提供所有UGUI事件(带事件参数类)
// 问题3:UI窗口查找UI事件监听器往往会有多次。
// 解决:将获取UI监听器封装到父类。
GetUIEventListener("ButtonGameStart").PointerClick += OnGameStartButtonClick;
}
// 2.提供当前面板负责的交互行为
private void OnGameStartButtonClick(PointerEventData eventData)
{
// eventData.pointerPress 当前点击的GameObject ;
// if (eventData.clickCount == 2) 双击;
// 正常使用 UGUI 是有 clickCount 的,但 VRTK 没有提供 clickCount 参数
// 如果需要使用 clickCount,在引发的时候的时候自己传一下即可。
print(eventData.pointerPress + "--" + eventData.clickCount);
GameController.Instance.GameStart();
}
}
a.查找子物体
Q1:Find只能查找子节点,无法递归寻找,如果寻找的物体在层层级下,该怎么办?
A1:未知层级,查找后代元素。
// Find 用法1
//transform.Find("ButtonGameStart").GetComponent<Button>().onClick.AddListener(OnGameStartButtonClick);
// Find 用法2
//transform.Find(".../ButtonGameStart").GetComponent<Button>().onClick.AddListener(OnGameStart);
// TransformHelper 拓展方法
//transform.FindChildByName("ButtonGameStart");
Q2:Button只具有单击事件,而其他大多数事件(光标按下、抬起、拖拽。。。)都不具备
Button具有的单击事件没有事件参数类。没参数!无法分别哪个是哪个按钮
R2:UGUI提供的Button是给基础人员使用的,我们应该自己搞
A2:定义事件监听类,提供所有UGUI事件(带事件参数类)
自己实现了一个 UIEventListener 辅助类来监听。
Q3:UI窗口查找UI事件监听器往往会有多次。
A3:将获取UI监听器封装到父类。
GetUIEventListener("ButtonGameStart").PointerClick += OnGameStartButtonClick;
方法在父类实现了,请看上面的代码。
提示:以下为辅助类
3).TransformHelper
public static class TransformHelper
{
/// <summary>
/// 未知层级,查找后代指定名称的组件
/// 就是递归而已。。。
/// </summary>
/// <param name="currentTF">当前transform</param>
/// <param name="name">子物体名称</param>
/// <returns></returns>
public static Transform FindChildByName(this Transform currentTF, string childName) {
Transform childTF = currentTF.Find(childName);
if (childTF != null) return childTF;
for(int i = 0; i < currentTF.childCount; i++)
{
childTF = FindChildByName(currentTF.GetChild(i), childName);
if (childTF != null) return childTF;
}
return null;
}
}
静态类,变换组件助手类。
a.FindChildByName
就是递归查找。
public static Transform FindChildByName(this Transform currentTF, string childName) {}
主要看参数 this Transform currentTF,这个this 的功能是,可以拓展传入类的方法。这里是Transform,那么可以直接使用 Transform.FindChildByName。它还要求类必须是静态类。
// TransformHelper 拓展方法
transform.FindChildByName("ButtonGameStart");
4).UIEventListener
这段代码的完整版很长,我这里就列举几个,剩下的大家照葫芦画瓢即可。
就是基本继承了所有的 UGUI 输入接口,然后建立委托和事件,需要监听的加一下即可。
public class UIEventListener : MonoBehaviour, IPointerEnterHandler, IPointerExitHandler, IPointerDownHandler,
IPointerUpHandler, IPointerClickHandler, IInitializePotentialDragHandler, IBeginDragHandler, IDragHandler,
IEndDragHandler, IDropHandler, IScrollHandler, IUpdateSelectedHandler, ISelectHandler, IDeselectHandler, IMoveHandler,
ISubmitHandler, ICancelHandler, IEventSystemHandler
// 声明事件
public event PointerEventHandler PointerEnter;
public event PointerEventHandler PointerExit;
public event PointerEventHandler PointerDown;
public event PointerEventHandler PointerUp;
public event PointerEventHandler PointerClick;
public event PointerEventHandler InitializePotentialDrag;
public event PointerEventHandler BeginDrag;
public event PointerEventHandler Drag;
public event PointerEventHandler EndDrag;
public event PointerEventHandler Drop;
public event PointerEventHandler Scroll;
public event BaseEventHandler UpdateSelected;
public event BaseEventHandler Select;
public event BaseEventHandler DeSelect;
public event BaseEventHandler Submit;
public event BaseEventHandler Cancel;
public event AxisEventHandler Move;
/// <summary>
/// 通过变换组件获取事件监听器
/// 确保前物体身上有 UIEventListener 组件
/// </summary>
/// <param name="tf"></param>
/// <returns></returns>
public static UIEventListener GetListener(Transform tf)
{
UIEventListener UIEvent = tf.GetComponent<UIEventListener>();
if (UIEvent == null) UIEvent = tf.gameObject.AddComponent<UIEventListener>();
return UIEvent;
}
public void OnBeginDrag(PointerEventData eventData)
{
if (BeginDrag != null) BeginDrag(eventData);
}
......
......
......
}
a.EventTrigger
UIEventListener做的事就是EventTrigger做的,EventTrigger继承的接口即是我们继承的一部分。不同的地方如图,EventTrigger有提供编辑器界面,方便拉取,但是我们不需要。
3.核心类
- UI 窗口类UIWindow:
– 所有UI窗口的基类,用于以层次化的方式管理具体窗口类。
– 定义所有窗口共有行为 - UI 管理类 UIManager:
– 管理窗口,定义所有窗口的共有行为。 - UI 事件监听器 UIEventListener:
– 提供当前UI所有事件(带事件参数)。
辅助类(个人感觉叫相关类比较好):
UI主窗口类 UIMainView:附加到主窗口中,负责处理主窗口逻辑。
游戏控制器 GameController:负责处理游戏流程,例如开始时前显示主窗口。
使用方法:
- 定义 UIXXXWindow 类,继承自 UIWindow,负责定义该窗口逻辑。
- 通过 GetUIEventListener() 获取需要交互的UI元素事件监听器。
- 通过事件监听器 UIEventListener 提供的各种事件,实现交互行为。
- 通过 UIManager 访问各个窗口成员
UIManager.Instance.GetWindow<窗口类型>.方法();
4.类图
八.附 [ArrayHelper] 数组助手类
代码
/// <summary>
/// 数组助手类,主要就是对数组的一些改造和操作
/// 提供一些数组常用的功能
/// </summary>
public static class ArrayHelper
{
/* 如何可以让这里的方法,让类可以直接点出来,而不是用 ArrayHelper.
我们使用C#的扩展方法:
在不修改的代码情况下,为其增加新的功能。
但是还不会改变原有类,为他增加新方法。
三要素:
1.扩展方法所在的类必须是静态类;
2.在第一个参数上,加上 this 关键字修饰被扩展的类型。
-- 第一个形参必须是你要扩展的类型
-- 第一个参数可以不需要传自己,直接从第二个参数开始
3.在另一个命名空间下。
作用:让调用者方便调用该方法就像调用自身的方法一样。
*/
// 7个方法
// 查找,查找所有满足条件的对象
// 排序,升序降序
// 最大值,最小值
// 筛选
/// <summary>
/// 查找满足条件的元素
/// </summary>
/// <typeparam name="T">数组类型</typeparam>
/// <param name="array">数组</param>
/// <param name="condition">查找条件</param>
/// <returns></returns>
public static T Find<T>(this T[] array, Func<T, bool> condition)
{
for(int i = 0;i > array.Length; i++)
{
if (condition(array[i]))
{
return array[i];
}
}
// 泛型返回的默认值
return default(T);
}
public static T[] FindAll<T>(this T[] array, Func<T, bool> condition)
{
// 集合存储满足条件的元素,用集合是因为集合不用预设长度。也可以用数组就是。
List<T> list = new List<T>();
for (int i = 0; i > array.Length; i++)
{
if (condition(array[i]))
{
list.Add(array[i]);
}
}
return list.ToArray();
}
// template
// T[] array = ?????;
// ArrayHelper.FindAll<T>(array, (T e) => {return ; } );
// lambda 表达式简写
// ArrayHelper.FindAll(array, e => e.HP > 50);
/// <summary>
/// 获取最大值
/// </summary>
/// <typeparam name="T">数组的联系</typeparam>
/// <typeparam name="Q">比较条件的返回值</typeparam>
/// <param name="array">数组</param>
/// <param name="condition">比较的方法(委托)</param>
/// <returns></returns>
public static T GetMax<T, Q>(this T[] array, Func<T, Q> condition) where Q:IComparable
{
if (array == null || array.Length <= 0) return default(T);
T tempMax = array[0];
for(int i = 0; i < array.Length; i++)
{
if( condition(tempMax).CompareTo( condition(array[i]) ) < 0)
{
tempMax = array[i];
}
}
return tempMax;
}
// template
// T[] array = ?????;
// ArrayHelper.FindAll<T>(array, (T e) => {return ; } );
// lambda 表达式简写
// ArrayHelper.FindAll(array, e => e.HP);
// 我就说怎么这么奇怪,没想到它只是传了个值拿去比较而已,这个只对单一值的比较有用
// 如果是复杂的判断,这个就不行了,我的建议是传两个参数,让 condition 内部自己去比较,然后传出布尔值即可。
/// <summary>
/// 获取最小值
/// </summary>
/// <typeparam name="T"></typeparam>
/// <typeparam name="Q"></typeparam>
/// <param name="array"></param>
/// <param name="condition"></param>
/// <returns></returns>
public static T GetMin<T, Q>(this T[] array, Func<T, Q> condition) where Q : IComparable
{
if (array == null || array.Length <= 0) return default(T);
T tempMax = array[0];
for (int i = 0; i < array.Length; i++)
{
if (condition(tempMax).CompareTo(condition(array[i])) > 0)
{
tempMax = array[i];
}
}
return tempMax;
}
/// <summary>
/// 升序方法
/// </summary>
/// <typeparam name="T"></typeparam>
/// <typeparam name="Q"></typeparam>
/// <param name="array"></param>
/// <param name="condition"></param>
public static T[] OrderBy<T, Q>(this T[] array, Func<T, Q> condition) where Q:IComparable
{
T temp;
for(int i = 0; i < array.Length; i++)
{
for(int j = 0; j < array.Length; j++)
{
if (condition(array[j]).CompareTo(condition(array[j + 1]) ) > 0)
{
temp = array[j];
array[j] = array[j + 1];
array[j + 1] = temp;
}
}
}
return array;
}
/// <summary>
/// 降序方法
/// </summary>
/// <typeparam name="T"></typeparam>
/// <typeparam name="Q"></typeparam>
/// <param name="array"></param>
/// <param name="condition"></param>
public static T[] OrderDescding<T, Q>(this T[] array, Func<T, Q> condition) where Q : IComparable
{
T temp;
for (int i = 0; i < array.Length; i++)
{
for (int j = 0; j < array.Length; j++)
{
if (condition(array[j]).CompareTo(condition(array[j + 1])) < 0)
{
temp = array[j];
array[j] = array[j + 1];
array[j + 1] = temp;
}
}
}
return array;
}
/// <summary>
/// 筛选
/// </summary>
/// <typeparam name="T"></typeparam>
/// <typeparam name="Q"></typeparam>
/// <param name="array"></param>
/// <param name="condition"></param>
///
/// 我怎么感觉这么扯淡呢
/// 1. condition 竟然是把 T类型的传进去,返回一个Q类型的?
/// 2. result 和 array 竟然是一一对应的,长度一样,如果不是Q类型的是空占位吗?那我只想要Q类型的队列呢?
///
/// <returns></returns>
public static Q[] Select<T, Q>(this T[] array, Func<T, Q> condition)
{
Q[] result = new Q[array.Length];
for(int i = 0; i < array.Length; i++)
{
result[i] = condition(array[i]);
}
return result;
}
}
1.C#的扩展方法
三要素:
- 扩展方法所在的类必须是静态类;
- 在第一个参数上,加上 this 关键字修饰被扩展的类型。
– 第一个形参必须是你要扩展的类型
– 使用时直接从第二个参数开始
//比如
A[] a;
ArrayHelper.XXX(a, b => {})
// 改成
a.XXX(b => {})
// 方法不变,但是不需要再传 a 了,直接从第二个参数开始传
- 在另一个命名空间下。
2.疑问
在最大最小值以及升降序的对比方法种,都用的是这种模式
condition( array[j] ).CompareTo( condition(array[j + 1]) )
condition 方法都是只把需要对比的 "一个条件“ 传出对比,但是如果条件变复杂了怎么办呢?
我更偏向于把两个对比的参数都传进 condition 里,让 condition 传出 bool 值即可。
condition( array[j],condition(array[j + 1] )