HTC Vive 是一個虛擬現實頭盔,由 HTC 和 Valve 公司制造。它提供一種在虛擬世界中的浸入式體驗,而不是屏幕頭像。
如果你是一個 Unity 開發者,在虛擬現實游戲中使用 HTC Vive 非常簡單——你可以認為 HTC Vive 和 Unity 是天生一對。
在這篇 HTC Vive 教程中,你會學習如何在 Unity 游戲中集成 HTC Vive。包括:
- 下載和配置 StreamVR
- 處理控制器輸入
- 在 VR 中和物理對象交互
- 創建一支激光筆
- 瞬移
在本文最后,你將對未來體驗有一個粗略的了解。讓我們開始吧!
注:每個人在戴着頭戴式顯示器都會對運動和旋轉產生不同的反應。如果你是第一此穿戴此類設備,當感覺不適時請放松並深呼吸。大部分人很快就會適應 VR。開頭幾次如果你不適應請不要着急——它很快就會過去。
開始
在正式開始學習之前,你必須擁有下列條件:
- 一台支持 VR 的 Windows PC。
- 在機器上安裝有 Unity 5.5 或更高版本。
- 一套完整的 HTC Vive 硬件,並配置和升級好軟件。
- 安裝 Steam 和 SteamVR。請參考 https://help.steampowered.com/zh-cn/,它將指引你完成硬件安裝,並提供 Steam 和 SteamVR 的下載鏈接。
- 熟悉基本的 Unity 使用:[Introduction to Unity: Getting Started](Introduction to Unity: Getting Started)。
- 熟悉基本的腳本:https://www.raywenderlich.com/980-introduction-to-unity-scripting。
確認 HTC Vive 已經打開並連接!下載:https://koenig-media.raywenderlich.com/uploads/2016/12/Introduction-To-HTC-Vive-Starter.zip
解壓縮到任意目錄並用 Unity 打開。在項目窗口中看一眼文件夾:

每個文件夾都和對應的資源一一對應:
- Materials: 場景所用到的材質,包括藍色小球。
Models: 所有的模型。
Physics Materials: 彈性小球的物理材質。
Prefabs: 預制件。
Scenes: 游戲場景。
Scripts: 全部腳本。
Textures: 場景中所有對象共有的單一紋理。
看一看場景視圖,按 play 按鈕運行游戲:

這里不會有太多內容,因為場景中還沒有加入 VR 控制。你需要將 SteamVR 添加到項目中,以便將 Vive 連接到 Unity。
設置 StreamVR
SteamVR SDK 是一個由 Valve 提供的官方庫,以簡化 Vive 開發。當前在 Asset 商店中是免費的,它同時支持 Oculus Rift 和 HTC Vive。
打開 Asset 商店,在頂部工具欄中選擇 Window > Asset Store:

等商店頁面加載完,在搜索欄中輸入 StreamVR 並回車。上下滾動瀏覽搜索結果,點擊 StreamVR Plugin,會打開它的商店頁面:

點擊 Download 按鈕,然后靜靜等待。等下載完成,你看到導入包對話框。
點擊右下角的 Import,導入包:

等導入完成,你會看到下列提示:

點擊 I Made a Backup 按鈕,讓編輯器對腳本進行預編。幾秒后會看到這個窗口:

這是 SteamVR 插件的界面。它會列出一些編輯器設置,這些設置能夠提升性能和兼容性。
當你打開一個新項目並導入 SteamVR 時,你會在這里看到幾個選項。因為開始項目已經優化過,這里我們只需要禁用解析度對話框(resolution dialog)即可。點擊 Accept All 按鈕,執行所有推薦的修改。關閉 Asset 商店回到場景視圖。在項目窗口中,我們現在多了一個新文件夾 SteamVR:

打開這個文件夾,看一眼內容。我們會從 Prefabs 文件中添加一個 VR GameObjects 到場景中。

同時選中 [CameraRig] 和 [SteamVR] ,將它們拖到結構窗口:

[SteamVR] 負責幾件事情。它在玩家打開系統菜單並將物理刷新率和繪圖系統進行同步時讓游戲自動暫停。它還負責處理“房間規模 VR 動作”的平滑。在檢視器面板中查看屬性:

[CameraRig] 更有趣,因為它控制着 Vive 頭盔和控制器。選擇 [CameraRig] ,在檢視器面板中設置它的位置為 (X:0, Y:0, Z:-1.1),將攝像機放到桌子后面。

從結構視圖中刪除主攝像,因為這會干擾 [CameraRig] 和它的相機。
打開手柄,查看屏幕。拿起手柄,四處移動。你會看到在場景視圖中看到虛擬手柄也會隨之移動:

當 SteamVR 插件檢測到手柄,它會創建出虛擬手柄。虛擬手柄被映射為 [CameraRig] 的子節點:

現在——繼續在場景視圖中——從結構視圖中選擇 Camera(eye),小心地拿起你的頭盔顯示器的頂部皮帶,移動並微微旋轉,同時觀察場景視圖:

攝像機和頭盔顯示器是連接在一起的,它會准確地捕獲頭盔的移動。
現在將頭盔顯示器戴到頭上,拿起手柄,在房間里四處走動感受一下。
如果你想和物體進行交互,那么你會大失所望——什么也不會發生。要添加運動跟蹤之外的功能,需要編寫一點腳本。
處理輸入
拿起一只手柄,仔細觀察。每個控制器上有這些按鈕:

Touchpad 既是可以做模擬搖桿也可以當做按鈕。當移動或旋轉手柄時,手柄會有速度和旋轉速度感應,當和物體交互時這會非常有用。
讓我們來編寫一些代碼!在 Scripts 文件夾中創建一個新的 C# 腳本,取名為 ViveControllerInputTest 然后用任意代碼編輯器打開它。
刪除 Start() 方法,在 Update() 方法之上添加下列代碼:
// 1 private SteamVR_TrackedObject trackedObj; // 2 private SteamVR_Controller.Device Controller { get { return SteamVR_Controller.Input((int)trackedObj.index); } }
在這里進行了如下操作:
- 對正在被跟蹤的對象進行一個引用。在這里,也就是一只手柄。
- Device 屬性能夠很方便地訪問到這個手柄。通過所跟蹤的對象的索引來訪問控制器的 input,並返回這個 input。
頭盔和手柄都是被跟蹤的對象——他們在真實事件中的移動和旋轉都會被 HTC Vive 跟蹤到並傳遞到虛擬世界。
在 Update() 方法上方添加方法:
void Awake() { trackedObj = GetComponent<SteamVR_TrackedObject>(); }
當腳本加載時,trackedObj 會被賦值為 SteamVR_TrackedObject 對象,這個對象和手柄是關聯的:

現在你已經能夠訪問手柄了,你可以讀取到它的輸入。在 Update() 方法中添加:
// 1 if (Controller.GetAxis() != Vector2.zero) { Debug.Log(gameObject.name + Controller.GetAxis()); } // 2 if (Controller.GetHairTriggerDown()) { Debug.Log(gameObject.name + " Trigger Press"); } // 3 if (Controller.GetHairTriggerUp()) { Debug.Log(gameObject.name + " Trigger Release"); } // 4 if (Controller.GetPressDown(SteamVR_Controller.ButtonMask.Grip)) { Debug.Log(gameObject.name + " Grip Press"); } // 5 if (Controller.GetPressUp(SteamVR_Controller.ButtonMask.Grip)) { Debug.Log(gameObject.name + " Grip Release"); }
上述代碼包含了所有當玩家在 VR 中時你夠訪問到大部分方法。它將 GameObject 的名字輸出到控制台,以便區分左右手柄。代碼的解釋如下:
- 獲取手指在 touchpad 上的位置並輸出到控制台。
- 當你按下扳機時,這會打印到控制台。扳機有一個專門的方法用於判斷它是否被按下:GetHairTrigger(), GetHairTriggerDown() 和 GetHairTriggerUp()。
- 如果松開扳機,這會打印到控制台。
- 如果按下抓取(grip)鍵,這會打印到控制台。GetPressDown 方法是用於判斷某個按鈕已經被按下的標准方法。
- 如果釋放抓取鍵,這會打印到控制台。GetPressUp 方法是用於判斷某個按鈕是否已經被釋放的標准方法。
來測試一下腳本。保存腳本,返回 Unity 編輯器。
在結構視圖中選中兩個手柄,拖動剛才創建的腳本到檢視器中,為它們添加 ViveControllerInputTest 組件

再次運行游戲,拿起兩只手柄,觀察控制台中的輸出:

按下按鈕,扳機並在 touchpad 上滑動,你會看到控制台會輸出每個我們注冊的動作:

這僅僅是最基本的輸入。現在我們可以將虛擬世界操縱在我的手心了——差不多這個意思啦!
在物理對象上應用手柄
VR 提供了許多我們在真實世界中不可能實現的能力,比如撿起一個物體,查看它們並扔到地上,不需要你負責清理。
通過使用觸發器碰撞機和編寫少量腳本,HTC Vive 能夠創建后顧無憂的虛擬體驗。
在結構視圖中選中兩個手柄,為它們添加剛性體。(Add Component > Physics > Rigidbody)
勾上 Is Kinematic,反選 Use Gravity:

為兩個手柄添加一個盒子碰撞體 (Add Component > Physics > Box Collider) 並勾上 Is Trigger。
默認的碰撞體有點大,我們需要重新指定大小和位置。設置中心為 (X:0, Y:-0.04, Z:0.02),大小為 (X:0.14, Y:0.07, Z:0.05)。這里需要將值精確到兩位數,否則都會影響到手柄的最終效果。

運行游戲,從結構視圖中選擇一只手柄,並拿起真正的手柄。觀察場景視圖,然后將焦點置於你正在拿着的那只手柄上(按F)。將碰撞體正好放在手柄的頂端部分,這個部分是你用於抓握物體的地方。

不編寫腳本,碰撞體僅僅是一個無用的方塊——在 Scripts 文件夾中創建一個新腳本,取名為 ControllerGrabObject 然后打開它。、
刪除 Start() 方法並在這里添加這段你已經熟悉的代碼:
private SteamVR_TrackedObject trackedObj; private SteamVR_Controller.Device Controller { get { return SteamVR_Controller.Input((int)trackedObj.index); } } void Awake() { trackedObj = GetComponent<SteamVR_TrackedObject>(); }
這段代碼和你在輸入測試中的代碼是一樣的。這里獲取了手柄,然后保存到一個變量中以備后用。
在 trackedObj 下面添加變量:
private GameObject collidingObject; // 2 private GameObject objectInHand;
這兩個變量的作用分別是:
- 一個 GameObject,用於保存當前與之碰撞的觸發器(trigger),這樣你才能抓住這個對象。
- 一個 GameObject,用於保存玩家當前抓住的對象。
在 Awake() 方法后添加:
private void SetCollidingObject(Collider col) { // 1 if (collidingObject || !col.GetComponent<Rigidbody>()) { return; } // 2 collidingObject = col.gameObject; }
這個方法接受一個碰撞體作為參數,並將它的 GameObject 保存到 collidingObject 變量,以便抓住和釋放這個對象。同時:
- 如果玩家已經抓着某些東西了,或者這個對象沒有一個剛性體,則不要將這個 GameObject 作為可以抓取目標。
- 將這個對象作為可以抓取的目標。
現在,添加觸發器方法:
// 1 public void OnTriggerEnter(Collider other) { SetCollidingObject(other); } // 2 public void OnTriggerStay(Collider other) { SetCollidingObject(other); } // 3 public void OnTriggerExit(Collider other) { if (!collidingObject) { return; } collidingObject = null; }、
當觸發器碰撞體進入、退出另一個碰撞體時,這些方法將被觸發。
- 當觸發器碰撞體進入另一個碰撞體時,將另一個碰撞體作為可以抓取的目標。
- 和第一段類似(第一段注釋 //1),但不同的是玩家已經將手柄放在一個對象上並持續一段時間。如果沒有這段代碼,碰撞會失敗或者會導致異常。
- 當碰撞體退出一個對象,放棄目標,這段代碼會將 collidingObject 設為 null 以刪除目標對象。
下面的代碼用於抓住一個對象:
private void GrabObject() { // 1 objectInHand = collidingObject; collidingObject = null; // 2 var joint = AddFixedJoint(); joint.connectedBody = objectInHand.GetComponent<Rigidbody>(); } // 3 private FixedJoint AddFixedJoint() { FixedJoint fx = gameObject.AddComponent<FixedJoint>(); fx.breakForce = 20000; fx.breakTorque = 20000; return fx; }
在這里,我們:
- 在玩家手中的 GameObject 轉移到 objectInHand 中,將 collidingObject 中保存的 GameObject 移除。
- 添加一個連接對象,調用下面的 FixedJoint 方法將手柄和 GameObject 連接起來。
- 創建一個固定連接並加到手柄中,並設置連接屬性,使它堅固,不那么容易斷裂。最后返回這個連接。
被抓住的東西也要能夠被放下。下面的代碼放下一個物體:
private void ReleaseObject() { // 1 if (GetComponent<FixedJoint>()) { // 2 GetComponent<FixedJoint>().connectedBody = null; Destroy(GetComponent<FixedJoint>()); // 3 objectInHand.GetComponent<Rigidbody>().velocity = Controller.velocity; objectInHand.GetComponent<Rigidbody>().angularVelocity = Controller.angularVelocity; } // 4 objectInHand = null; }
這段代碼將被抓對象的固定連接刪除,並在玩家扔出去時控制它的速度和角度。這里關鍵的是手柄的速度。如果沒有這個,扔出的東西會直直地往下掉,不管你用多大的力扔它。相信我,這絕對是錯誤的。
代碼解釋如下:
- 確定控制器上一定有一個固定連接。
- 刪除這個連接上所連的對象,然后銷毀這個連接。
- 將玩家放開物體時手柄的速度和角度賦給這個物體,這樣會形成了一個完美的拋物線。
- 將 objectInHand 變量置空。
最后,在 Update() 方法中添加代碼以處理手柄的輸入:
// 1 if (Controller.GetHairTriggerDown()) { if (collidingObject) { GrabObject(); } } // 2 if (Controller.GetHairTriggerUp()) { if (objectInHand) { ReleaseObject(); } }
- 當玩家按下扳機,同時手上有一個可以抓取的對象,則將對象抓住。
- 當玩家松開扳機,同時手柄上連接着一個物體,則放開這個物體。
相信你已經迫不及待地想試一把了吧?保存腳本,退出編輯器。
在結構視圖中選中手柄,將新腳本拖到檢視器中將它添加為一個組件。
開心的時候來了!打開你的手柄,運行游戲,戴上頭盔。按下扳機,抓起幾個方塊或者圓球,扔出去。你可能需要適應一下。

你不得不佩服你自己——你真的很棒!但我覺得你應該讓你的 VR 體驗變得更好!
制作一只激光筆
因為種種原因,激光筆在 VR 世界中非常有用。你可以用它們去戳破虛擬氣球,做瞄准具使用或者調戲虛擬貓咪。
創建激光筆非常簡單。只需要一個方塊和一個腳本。在結構視圖中創建一個方塊 (Create > 3D Object > Cube)。

為它取名 Laser,設置它的位置為 (X:0, Y:5, Z:0),縮放為 (X:0.005, Y:0.005, Z:0) ,並去掉 Box Collider 組件。
讓它居中,你會看到他漂浮在其他對象之上:

激光不可能有陰影,它們只會有一種顏色,因此我們可以用一個不反光材質實現這個效果。
在 Materials 文件夾下創建一個新材質,取名為 Laser,修改它的着色器為 Unlit/Color ,設置它的 Main Color 為大紅色:

通過將材質拖到場景視圖的 Laser 上即可分配新材質。當然,也可以將材質拖到結構視圖的 Laser 上。

最后,將 Laser 拖到 Prefabs 文件夾,然后從結構視圖中刪掉 Laser 對象。

現在,在 Scripts 文件夾下創建一個新腳本,名為 LaserPointer,並打開它。添加你早已熟悉的代碼:
private SteamVR_TrackedObject trackedObj; private SteamVR_Controller.Device Controller { get { return SteamVR_Controller.Input((int)trackedObj.index); } } void Awake() { trackedObj = GetComponent<SteamVR_TrackedObject>(); }
在 trackedObj 下面添加變量:
// 1 public GameObject laserPrefab; // 2 private GameObject laser; // 3 private Transform laserTransform; // 4 private Vector3 hitPoint;
- 這個變量用於引用 Laser 預制件。
- 這個變量用於引用一個 Laser 實例。
- 一個 Transform 組件,方便后面適用。
- 激光擊中的位置。
用這個方法顯示一束激光:
private void ShowLaser(RaycastHit hit) { // 1 laser.SetActive(true); // 2 laserTransform.position = Vector3.Lerp(trackedObj.transform.position, hitPoint, .5f); // 3 laserTransform.LookAt(hitPoint); // 4 laserTransform.localScale = new Vector3(laserTransform.localScale.x, laserTransform.localScale.y, hit.distance); }
這個方法使用一個 RaycastHit 作為參數,因為它會包含被擊中的位置和射擊的距離。
代碼解釋如下:
- 顯示激光。
- 激光位於手柄和投射點之間。我們可以用 Lerp 方法,這樣我們只需要給它兩個端點,以及一個距離百分比即可。如果我們設置這個百分比為 0.5,也就是 50%,這會返回一個中點的位置。
- 將激光照射到 hitPoint 的位置。
在 Update() 方法中添加下列代碼,獲得玩家的輸入:
// 1 if (Controller.GetPress(SteamVR_Controller.ButtonMask.Touchpad)) { RaycastHit hit; // 2 if (Physics.Raycast(trackedObj.transform.position, transform.forward, out hit, 100)) { hitPoint = hit.point; ShowLaser(hit); } } else // 3 { laser.SetActive(false); }
- 如果 touchpad 被按下…
- 從手柄發射激光。如果激光照射到某樣物體,保存射到的位置並顯示激光。
- 當玩家放開 touchpad,隱藏激光。
在空的 Start() 方法中添加代碼:
// 1 laser = Instantiate(laserPrefab); // 2 laserTransform = laser.transform;
- 制造出一束新的激光,然后保存一個它的引用。
- 保存激光的 transform 組件。
保存腳本,返回編輯器。在結構視圖中選中兩個手柄,將激光的腳本拖進檢視器中以添加一個組件。

現在從 Prefabs 文件夾中將 Laser 預制件拖到檢視器的 Laser 欄中:

保存項目,重新運行游戲。拿起手柄,戴上頭盔,按下 touchpad,激光出現了:

在繼續之前,右擊輸入測試組件,選擇 Remove Component,從手柄中刪除它們。
之所以要刪除輸入測試組件,因為會在繪制每一幀時向控制台中輸出字符串。這會影響性能,在 VR 中每毫秒都會受影響。為了方便測試我們可以這樣做,但在真正的游戲中這是不應該的。
接下來是通過激光在房間中進行瞬移!
移動
在 VR 中移動不像驅使玩家前進那么簡單,這樣做會極易引起玩家眩暈。更可行的辦法是使用瞬移。
從玩家的視覺感知來說,寧可接收位置的突然改變,而不是漸進式的改變。在 VR 設備中輕微的改變都會讓你的速度感和平衡感徹底失控,還不如直接讓你來到一個新的地方。
要顯示你最終位於什么地方,你你可以使用 Prefabs 文件夾中的大頭釘或標記。
標記是一個簡單的、不反光的圓環:

要使用標記,你需要修改 LaserPointer 腳本,打開這個腳本,在類聲明中添加變量:
// 1 public Transform cameraRigTransform; // 2 public GameObject teleportReticlePrefab; // 3 private GameObject reticle; // 4 private Transform teleportReticleTransform; // 5 public Transform headTransform; // 6 public Vector3 teleportReticleOffset; // 7 public LayerMask teleportMask; // 8 private bool shouldTeleport;
每個變量的用途如下:
- 這是 [CameraRig] 的 transform 組件。
- 一個對傳送標記預制件的引用。
- 一個傳送標記實例的引用。
- 一個傳送標記的 transform 的引用。
- 玩家的頭(攝像機)的引用。
- 標記距離地板的偏移,以防止和其他平面發生“z-緩沖沖突”。
- 一個層遮罩,用於過濾這個地方允許什么東西傳送。
- 如果為 true,表明找到一個有效的傳送點。
在 Update() 方法中,將這一句:
if (Physics.Raycast(trackedObj.transform.position, transform.forward, out hit, 100))
替換為這句,以便將 LayerMask 加入到判斷中:
if (Physics.Raycast(trackedObj.transform.position, transform.forward, out hit, 100, teleportMask))
這確保激光只能點到你能夠傳送過去的 GameObjects 上。
仍然在 Update() 方法中,在 ShowLaser() 一句后添加:
// 1 reticle.SetActive(true); // 2 teleportReticleTransform.position = hitPoint + teleportReticleOffset; // 3 shouldTeleport = true;
代碼解釋如下:
- 顯示傳送標記。
- 移動傳送標記到激光點到的地方,並添加一個偏移以免 z 緩沖沖突。
- 將 shouldTeleport 設為 true,表明找到了一個有效的瞬移位置。
仍然在 Update 方法,找到 laser.SetActive(false); 一句,在后面添加:
reticle.SetActive(false);
如果目標地點無效,隱藏傳送標記。
添加下列方法,進行傳送:
private void Teleport() { // 1 shouldTeleport = false; // 2 reticle.SetActive(false); // 3 Vector3 difference = cameraRigTransform.position - headTransform.position; // 4 difference.y = 0; // 5 cameraRigTransform.position = hitPoint + difference; }
真正的傳送只需要 5 行代碼嗎?讓我們解釋一下:
- 將 shouldTeleport 設為 false。表明傳送進行中。
- 隱藏傳送標記。
- 計算從玩家頭盔到攝像機中心的坐標偏移。
- 將這個差中的 y 坐標設置為0,因為我們不考慮玩家頭部有多高。
- 移動相機到照射點加上所算出來的坐標偏移。如果不加上這個偏移,玩家會傳送到一個錯誤的地方。看下面的例子:

看到了沒有,這個偏移起到了一個關鍵的作用,讓我們精確地定位攝像機的位置並將玩家放到他們想去的地方。
在 Update() 的檢查 touchpad 按鍵的 if else 語句之外添加代碼:
if (Controller.GetPressUp(SteamVR_Controller.ButtonMask.Touchpad) && shouldTeleport) { Teleport(); }
如果玩家松開 touchpad,同時傳送位置有效的話,對玩家進行傳送。
最后,在 Start() 方法中添加代碼:
// 1 reticle = Instantiate(teleportReticlePrefab); // 2 teleportReticleTransform = reticle.transform;
- 創建一個標記點,並將它保存到 reticle 變量。
- 保存 reticle 的 transform 組件。
保存腳本,返回 Unity。
在結構視圖中選中兩個手柄,會發現多了幾個新字段:

將 [CameraRig] 拖到 Camera Rig Transform 欄,將 TeleportReticle 從 Prefabs 文件夾拖到 Teleport Reticle Transform 欄,將 Camera (head) 拖到 Head Transform 欄。

將 Teleport Reticle Offset 設為 (X:0, Y:0.05, Z:0) ,Teleport Mask 設為 CanTeleport。CanTeleport 不是默認層— 它是專門為這個教程創建的。這個層里面只有 Floor 和 Table 對象。
現在運行游戲,用激光照射在地板上進行瞬移。
