HTC VIVE開發教程(四)
從這一節起我開始介紹一些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的開發中,這三種互動是很常見的。同樣,我們也可以根據這幾種互動的實現原理,設計出我們自己想要的互動。