从这一节起我开始介绍一些vive的交互实现方式,比如手柄发出的射线,凝视,瞬移等等。SteamVR插件内都有这三种交互的辅助类。
Extras文件夹里面的SteamVR_GazeTracker是凝视的工具类,SteamVR_LaserPointer是射线的工具类,SteamVR_Teleporter是瞬移的工具类,下面我们来分析这三种交互是如何实现的。
SteamVR_GazeTracker(凝视)
凝视是一种在没有手柄等输入设备的情况下,可以通过眼睛盯着某个物体看来与物体进行交互的体验。
我们只需要将个辅组类添加到我们想要凝视的物体上,比如菜单等,就可以实现凝视的功能。现在我们来看看凝视的实现原理。
void Update ()
{
if (hmdTrackedObject == null)
{
/*查找全部的SteamVR_TrackedObject组件,我们知道这个组件是用来跟踪设备位置的,手柄,头盔上都有这个组件*/
SteamVR_TrackedObject[] trackedObjects = FindObjectsOfType<SteamVR_TrackedObject>();
/*循环遍历trackedObject,找到头盔的trackedObject*/
foreach (SteamVR_TrackedObject tracked in trackedObjects)
{
if (tracked.index == SteamVR_TrackedObject.EIndex.Hmd)
{
/*获取头显的transform*/
hmdTrackedObject = tracked.transform;
break;
}
}
}
if (hmdTrackedObject)
{
/*从头显发出一条向前的射线*/
Ray r = new Ray(hmdTrackedObject.position, hmdTrackedObject.forward);
Plane p = new Plane(hmdTrackedObject.forward, transform.position);
float enter = 0.0f;
if (p.Raycast(r, out enter))
{
Vector3 intersect = hmdTrackedObject.position + hmdTrackedObject.forward * enter;
float dist = Vector3.Distance(intersect, transform.position);
/*如果凝视的点与凝视目标在gazeIncutoff的范围内,则目标为凝视状态,并调用OnGazeOn()回调方法*/
if (dist < gazeInCutoff && !isInGaze)
{
isInGaze = true;
GazeEventArgs e;
e.distance = dist;
OnGazeOn(e);
}
/*如果凝视的点与凝视目标大于gazeIncutoff这个范围,则目标为非凝视状态,并调用OnGazeOff()回调方法*/
else if (dist >= gazeOutCutoff && isInGaze)
{
isInGaze = false;
GazeEventArgs e;
e.distance = dist;
OnGazeOff(e);
}
}
}
}
通过上面的代码我们知道了凝视的原理实际上是从头盔的位置发出一条射线判断是否与物体相交来做选中或者交互的。而且因为凝视的精确度不高,所以没有做直接与物体相交,而是在物体的位置创建了一个平面,通过射线与平面相交的交点的位置与物体的距离来大概判断的。这个距离值是可以调的,缺省是0.15到0.4米之间就算选中了。
我们现在知道了凝视的交互是如何实现的,实现的方式其实还是挺简单的,下面我们在来看看射线这种交互方式。
SteamVR_LaserPointer(激光束)
SteamVR_LaserPointer的作用是从指定位置(通常是手柄)发出一条射线,它会将这条射线显示出来,然后也是判断这条视线与场景中的物体是否相交。与凝视不一样的是,它可以精确操作,所以不需要一个辅助平面。用法和凝视也不太一样,需要将这个组件添加发出射线的物体上,比如手柄。
我们来分析一下这个类的代码
/*射线事件触发的回调参数,凝视也是类似的用法*/
public struct PointerEventArgs
{
/*手柄的索引*/
public uint controllerIndex;
/*暂时无用的参数*/
public uint flags;
/*射线源到目标的距离*/
public float distance;
/*射线射中的transform对象*/
public Transform target;
}
/*定义命中事件委托函数*/
public delegate void PointerEventHandler(object sender, PointerEventArgs e);
public class SteamVR_LaserPointer : MonoBehaviour
{
/*光线颜色*/
public Color color;
/*光线厚度*/
public float thickness = 0.002f;
/*空的GameObject,用来存放极光的gameobject*/
public GameObject holder;
public GameObject pointer;
bool isActive = false;
/*是否给激光束添加刚体*/
public bool addRigidBody = false;
/*激光束命中和离开的委托事件*/
public event PointerEventHandler PointerIn;
public event PointerEventHandler PointerOut;
Transform previousContact = null;
void Start ()
{
/*一些初始化操
1,创建激光束父GameObject(holder)
*/
holder = new GameObject();
/*2,将holder的transform的parent设为当前脚本所在的物体(手柄)上面*/
holder.transform.parent = this.transform;
/*3,将holder本地坐标初始为0*/
holder.transform.localPosition = Vector3.zero;
/*4,创建激光束,用长方体模拟(这一点其实不太合理,用圆柱模拟会更好一点)*/
pointer = GameObject.CreatePrimitive(PrimitiveType.Cube);
/*5,将激光束父亲设为holder*/
pointer.transform.parent = holder.transform;
/*6,设置激光束locale为(0.002,0.002,100),使它看起来像一条很长的线*/
pointer.transform.localScale = new Vector3(thickness, thickness, 100f);
pointer.transform.localPosition = new Vector3(0f, 0f, 50f);
/*7,是否添加刚体*/
BoxCollider collider = pointer.GetComponent<BoxCollider>();
if (addRigidBody)
{
if (collider)
{
collider.isTrigger = true;
}
Rigidbody rigidBody = pointer.AddComponent<Rigidbody>();
rigidBody.isKinematic = true;
}
else
{
if(collider)
{
Object.Destroy(collider);
}
}
/*8,设置激光束的材质*/
Material newMaterial = new Material(Shader.Find("Unlit/Color"));
newMaterial.SetColor("_Color", color);
pointer.GetComponent<MeshRenderer>().material = newMaterial;
}
public virtual void OnPointerIn(PointerEventArgs e)
{
if (PointerIn != null)
PointerIn(this, e);
}
public virtual void OnPointerOut(PointerEventArgs e)
{
if (PointerOut != null)
PointerOut(this, e);
}
// Update is called once per frame
void Update ()
{
/*第一次调用时将holder设为active*/
if (!isActive)
{
isActive = true;
this.transform.GetChild(0).gameObject.SetActive(true);
}
/*将激光束的最远距离设为100米*/
float dist = 100f;
/*获取当前物体(手柄)上的SteamVR_TrackedController脚本*/
SteamVR_TrackedController controller = GetComponent<SteamVR_TrackedController>();
/*构造一条射线*/
Ray raycast = new Ray(transform.position, transform.forward);
RaycastHit hit;
bool bHit = Physics.Raycast(raycast, out hit);
/*射线命中物体后移出,说明物体不在命中,调用OnPointerOut的通知*/
if(previousContact && previousContact != hit.transform)
{
PointerEventArgs args = new PointerEventArgs();
if (controller != null)
{
args.controllerIndex = controller.controllerIndex;
}
args.distance = 0f;
args.flags = 0;
args.target = previousContact;
OnPointerOut(args);
previousContact = null;
}
/*射线命中物体,调用OnPointerIn的通知*/
if(bHit && previousContact != hit.transform)
{
PointerEventArgs argsIn = new PointerEventArgs();
if (controller != null)
{
argsIn.controllerIndex = controller.controllerIndex;
}
argsIn.distance = hit.distance;
argsIn.flags = 0;
argsIn.target = hit.transform;
OnPointerIn(argsIn);
previousContact = hit.transform;
}
if(!bHit)
{
previousContact = null;
}
/*如果命中物体距离大于100,则无效,否则有效*/
if (bHit && hit.distance < 100f)
{
dist = hit.distance;
}
if (controller != null && controller.triggerPressed)
{
/*当按下扳机键时,将光束的粗细增大5倍,长度会设为dist,通过这种方法让光线不会穿透物体*/
pointer.transform.localScale = new Vector3(thickness * 5f, thickness * 5f, dist);
}
else
{
/*没按下扳机或者当前控制器没有添加SteamVR_TrackedController时,显示原始粗细的光束*/
pointer.transform.localScale = new Vector3(thickness, thickness, dist);
}
/*将光束的位置设在光束长度的一半的位置,使得光束看起来是从手柄发出来的*/
pointer.transform.localPosition = new Vector3(0f, 0f, dist/2f);
}
}
看完了SteamVR_LaserPointer的代码,我们就知道了激光束实现的原理,其实激光束实现起来还是蛮简单的,但是在VR的交互中,使用起来非常的方便。
好了,我们接下来再看看最后一种交互方式,瞬移。
SteamVR_Teleporter(瞬移)
我们只需要将这个脚本添加到手柄上就能使用瞬移功能,这个类的面板如下图
可以看到,它只有两个可控制的参数
- Teleport On Click:表示是否激活按扳机键瞬移功能
- Teleport Type:瞬移类型,有三种
- Teleport Type Use Terrain:表示在地形上做瞬移,地形有高低的区别
- Teleport Type Use Collider:表示与场景中的任何碰撞体做相交瞬移
- eleport Type Use Zero Y:表示在Y方向0坐标的平面上做瞬移,当地面为平面时可以使用
同样,我们再来分析瞬移的源码,为了精简,一些不太核心的源码我直接省去了
public class SteamVR_Teleporter : MonoBehaviour
{
……
Transform reference
{
get
{
/*获取CameraRig的Transform,SteamVR_Render.Top实际就是头显的预制体*/
var top = SteamVR_Render.Top();
return (top != null) ? top.origin : null;
}
}
void Start ()
{
/*获取SteamVR_TrackedController脚本,这个脚本是用来相应输入的触发回调的,比如手柄上的按键等*/
var trackedController = GetComponent<SteamVR_TrackedController>();
if (trackedController == null)
{
trackedController = gameObject.AddComponent<SteamVR_TrackedController>();
}
/*Trigger键的回调,实际上是通过按下Trigger来实现瞬移*/
trackedController.TriggerClicked += new ClickedEventHandler(DoClick);
if (teleportType == TeleportType.TeleportTypeUseTerrain)
{
/*这里的reference就是我们在上面获取的摄像机的位置
这这里,会将头显的位置设置为地形地图上的采样高度,这么做是为了避免瞬移时钻入地里面*/
var t = reference;
if (t != null)
t.position = new Vector3(t.position.x, Terrain.activeTerrain.SampleHeight(t.position), t.position.z);
}
}
/*Trigler的回调实现*/
void DoClick(object sender, ClickedEventArgs e)
{
if (teleportOnClick)
{
var t = reference;
if (t == null)
return;
float refY = t.position.y;
Plane plane = new Plane(Vector3.up, -refY);
/*发出一条射线,用来寻找瞬移的目的地*/
Ray ray = new Ray(this.transform.position, transform.forward);
bool hasGroundTarget = false;
float dist = 0f;
/*这里是对三种不同地形的处理*/
if (teleportType == TeleportType.TeleportTypeUseTerrain)
{
RaycastHit hitInfo;
TerrainCollider tc = Terrain.activeTerrain.GetComponent<TerrainCollider>();
hasGroundTarget = tc.Raycast(ray, out hitInfo, 1000f);
dist = hitInfo.distance;
}
else if (teleportType == TeleportType.TeleportTypeUseCollider)
{
RaycastHit hitInfo;
Physics.Raycast(ray, out hitInfo);
dist = hitInfo.distance;
}
else
{
hasGroundTarget = plane.Raycast(ray, out dist);
}
if (hasGroundTarget)
{
/*将头显的位置设置到移动的目的地*/
Vector3 headPosOnGround = new Vector3(SteamVR_Render.Top().head.localPosition.x, 0.0f, SteamVR_Render.Top().head.localPosition.z);
t.position = ray.origin + ray.direction * dist - new Vector3(t.GetChild(0).localPosition.x, 0f, t.GetChild(0).localPosition.z) - headPosOnGround;
}
}
}
}
我们可以看到,瞬移的核心不是怎么移过去,而是如何确定瞬移的目标位置,确定了移动的目标位置后再将Camera的position设置成目标位置就行了,瞬移的难点在于对不同地形的处理。
现在我们已经知道这三种交互方式的用法和原理了,在VIVE的开发中,这三种交互是很常见的。同样,我们也可以根据这几种交互的实现原理,设计出我们自己想要的交互。