使用 VR 控制器與 WebVR
許多 WebVR 硬體設定都配有與頭戴裝置配套的控制器。這些控制器可以透過 Gamepad API,特別是透過添加了用於訪問控制器姿態、觸覺執行器等功能的 Gamepad Extensions API 在 WebVR 應用中使用。本文解釋了其基本原理。
注意: WebVR API 已被 WebXR API 取代。WebVR 從未被批准為標準,在少數瀏覽器中預設實現和啟用,並支援少量裝置。
WebVR API
WebVR API 是一個新生但非常有趣的 Web 平臺新特性,它允許開發人員建立基於 Web 的虛擬現實體驗。它透過提供對連線到計算機的 VR 頭戴裝置的訪問(作為 VRDisplay 物件),從而實現這一點。這些物件可以被操縱以啟動和停止向顯示器呈現內容,查詢運動資料(例如,方向和位置)以在動畫迴圈的每一幀更新顯示器,等等。
在閱讀本文之前,您應該已經熟悉 WebVR API 的基礎知識——如果您還沒有閱讀過,請先閱讀使用 WebVR API,其中還詳細介紹了瀏覽器支援和所需的硬體設定。
Gamepad API
Gamepad API 是一個支援相當完善的 API,它允許開發人員訪問連線到計算機的遊戲手柄/控制器,並使用它們來控制 Web 應用程式。基本的 Gamepad API 將連線的控制器作為 Gamepad 物件提供訪問,然後可以查詢這些物件以瞭解在任何時間點正在按下的按鈕和正在移動的搖桿(軸)等。
您可以在 使用 Gamepad API 和 使用 Gamepad API 實現控制器 中找到有關基本 Gamepad API 用法的更多資訊。
然而,在本文中,我們將主要關注由 Gamepad Extensions API 提供的一些新功能,該 API 允許訪問高階控制器資訊,例如位置和方向資料、觸覺執行器(例如,振動硬體)的控制等。這個 API 非常新,目前只在 Firefox 55+ Beta/Nightly 通道中預設支援和啟用。
控制器型別
使用 VR 硬體時,您會遇到兩種型別的控制器
- 6DoF(六自由度)控制器提供位置和方向資料的訪問——它們可以透過移動和旋轉來操縱 VR 場景及其包含的物件。HTC VIVE 控制器就是一個很好的例子。
- 3DoF(三自由度)控制器提供方向資料,但不提供位置資料。Google Daydream 控制器就是一個很好的例子,它可以像雷射指示器一樣旋轉以指向 3D 空間中的不同物體,但不能在 3D 場景中移動。
基本控制器訪問
現在來看一些程式碼。我們首先看看如何使用 Gamepad API 訪問 VR 控制器的基礎知識。這裡有一些奇怪的細微差別需要注意,所以值得一看。
我們編寫了一個示例來演示——請參閱我們的 vr-controller-basic-info 原始碼(也請在此處檢視其執行情況)。此演示輸出有關連線到您計算機的 VR 顯示器和遊戲手柄的資訊。
獲取顯示資訊
第一個值得注意的程式碼如下
let initialRun = true;
if (navigator.getVRDisplays && navigator.getGamepads) {
info.textContent = "WebVR API and Gamepad API supported.";
reportDisplays();
} else {
info.textContent =
"WebVR API and/or Gamepad API not supported by this browser.";
}
這裡我們首先使用一個跟蹤變數 initialRun 來記錄這是我們第一次載入頁面。稍後您會了解更多。接下來,我們透過檢查 Navigator.getVRDisplays() 和 Navigator.getGamepads() 方法是否存在來檢測 WebVR 和 Gamepad API 是否受支援。如果是,我們執行自定義函式 reportDisplays() 來啟動該過程。此函式如下所示
function reportDisplays() {
navigator.getVRDisplays().then((displays) => {
console.log(`${displays.length} displays`);
displays.forEach((display, i) => {
const cap = display.capabilities;
// cap is a VRDisplayCapabilities object
const listItem = document.createElement("li");
listItem.innerText = `
VR Display ID: ${display.displayId}
VR Display Name: ${display.displayName}
Display can present content: ${cap.canPresent}
Display is separate from the computer's main display: ${cap.hasExternalDisplay}
Display can return position info: ${cap.hasPosition}
Display can return orientation info: ${cap.hasOrientation}
Display max layers: ${cap.maxLayers}`;
listItem.insertBefore(
document.createElement("strong"),
listItem.firstChild,
).textContent = `Display ${i + 1}`;
list.appendChild(listItem);
});
setTimeout(reportGamepads, 1000);
// For VR, controllers will only be active after their corresponding headset is active
});
}
此函式首先使用基於 Promise 的 Navigator.getVRDisplays() 方法,該方法解析為包含表示連線顯示器的 VRDisplay 物件的陣列。接下來,它打印出每個顯示器的 VRDisplay.displayId 和 VRDisplay.displayName 值,以及顯示器關聯的 VRDisplayCapabilities 物件中包含的一些有用值。其中最有用的是 hasOrientation 和 hasPosition,它們允許您檢測裝置是否可以返回方向和位置資料並相應地設定您的應用程式。
此函式中包含的最後一行是一個 setTimeout() 呼叫,它在 1 秒延遲後執行 reportGamepads() 函式。我們為什麼需要這樣做?首先,VR 控制器只有在其關聯的 VR 頭戴裝置啟用後才能準備就緒,因此我們需要在呼叫 getVRDisplays() 並返回顯示資訊之後呼叫它。其次,Gamepad API 比 WebVR API 舊得多,並且不是基於 Promise 的。正如您稍後將看到的,getGamepads() 方法是同步的,並且只是立即返回 Gamepad 物件——它不等待控制器準備好報告資訊。除非您等待一小段時間,否則返回的資訊可能不準確(至少,這是我們在測試中發現的)。
獲取 Gamepad 資訊
reportGamepads() 函式如下所示
function reportGamepads() {
const gamepads = navigator.getGamepads();
console.log(`${gamepads.length} controllers`);
for (const gp of gamepads) {
const listItem = document.createElement("li");
listItem.classList = "gamepad";
listItem.innerText = `
Associated with VR Display ID: ${gp.displayId}
Gamepad associated with which hand: ${gp.hand}
Available haptic actuators: ${gp.hapticActuators.length}
Gamepad can return position info: ${gp.pose.hasPosition}
Gamepad can return orientation info: ${gp.pose.hasOrientation}`;
listItem.insertBefore(
document.createElement("strong"),
listItem.firstChild,
).textContent = `Gamepad ${gp.index}`;
list.appendChild(listItem);
}
initialRun = false;
}
這與 reportDisplays() 的工作方式類似——我們使用非 Promise 的 getGamepads() 方法獲取 Gamepad 物件陣列,然後迴圈遍歷每個物件並打印出每個物件的資訊。
Gamepad.displayId屬性與控制器關聯的頭戴裝置的displayId相同,因此對於將控制器和頭戴裝置資訊關聯起來非常有用。Gamepad.index屬性是唯一的數字索引,用於標識每個連線的控制器。Gamepad.hand返回控制器預期握持在哪隻手中。Gamepad.hapticActuators返回控制器中可用的觸覺執行器陣列。這裡我們返回其長度,以便我們可以看到每個控制器有多少可用。- 最後,我們返回
GamepadPose.hasPosition和GamepadPose.hasOrientation以顯示控制器是否可以返回位置和方向資料。這與顯示器的工作方式相同,只是在遊戲手柄的情況下,這些值在姿態物件上可用,而不是在能力物件上。
請注意,我們還為包含控制器資訊的每個列表項指定了 gamepad 類名。我們稍後會解釋這是為了什麼。
這裡要做的最後一件事是將 initialRun 變數設定為 false,因為初始執行現在已經結束。
遊戲手柄事件
為了結束本節,我們將介紹與遊戲手柄相關的事件。我們需要關注兩個——gamepadconnected 和 gamepaddisconnected——它們的功能相當明顯。
在我們的示例的末尾,我們首先包含 removeGamepads() 函式
function removeGamepads() {
const gpLi = document.querySelectorAll(".gamepad");
for (const li of gpLi) {
list.removeChild(li);
}
reportGamepads();
}
此函式獲取所有類名為 gamepad 的列表項的引用,並將它們從 DOM 中移除。然後它重新執行 reportGamepads() 以使用更新的連線控制器列表填充列表。
每次連線或斷開遊戲手柄時,都會透過以下事件處理程式執行 removeGamepads()
window.addEventListener("gamepadconnected", (e) => {
info.textContent = `Gamepad ${e.gamepad.index} connected.`;
if (!initialRun) {
setTimeout(removeGamepads, 1000);
}
});
window.addEventListener("gamepaddisconnected", (e) => {
info.textContent = `Gamepad ${e.gamepad.index} disconnected.`;
setTimeout(removeGamepads, 1000);
});
我們這裡設定了 setTimeout() 呼叫——就像我們在指令碼頂部的初始化程式碼中所做的那樣——以確保在每種情況下呼叫 reportGamepads() 時,遊戲手柄都已準備好報告其資訊。
但還有一點需要注意——您會看到在 gamepadconnected 處理程式內部,只有當 initialRun 為 false 時才會執行超時。這是因為如果您的遊戲手柄在文件首次載入時已連線,則每個遊戲手柄都會觸發一次 gamepadconnected,因此 removeGamepads()/reportGamepads() 將執行多次。這可能會導致不準確的結果,因此我們只希望在初始執行之後,而不是在初始執行期間,在 gamepadconnected 處理程式內部執行 removeGamepads()。這就是 initialRun 的作用。
介紹一個真實演示
現在我們來看一個真實 WebVR 演示中使用的 Gamepad API。您可以在 raw-webgl-controller-example (也請在此處檢視其執行情況) 找到此演示。
與我們的 raw-webgl-example (詳情請參閱 使用 WebVR API) 完全相同,此演示渲染一個旋轉的 3D 立方體,您可以選擇在 VR 顯示器中呈現。唯一的區別是,在 VR 呈現模式下,此演示允許您透過移動 VR 控制器來移動立方體(原始演示在您移動 VR 頭戴裝置時移動立方體)。
我們將在下面探討此版本中的程式碼差異——請參閱 webgl-demo.js。
訪問遊戲手柄資料
在 drawVRScene() 函式中,您會找到這段程式碼
const gamepads = navigator.getGamepads();
const gp = gamepads[0];
if (gp) {
const gpPose = gp.pose;
const curPos = gpPose.position;
const curOrient = gpPose.orientation;
if (poseStatsDisplayed) {
displayPoseStats(gpPose);
}
}
這裡我們使用 Navigator.getGamepads 獲取連線的遊戲手柄,然後將檢測到的第一個遊戲手柄儲存在 gp 變數中。由於我們只需要一個遊戲手柄用於此演示,因此我們將忽略其他遊戲手柄。
接下來我們做的是獲取儲存在 gpPose 中的控制器的 GamepadPose 物件(透過查詢 Gamepad.pose),並存儲當前遊戲手柄的此幀的位置和方向到變數中,以便以後方便訪問。我們還使用 displayPoseStats() 函式在 DOM 中顯示此幀的姿勢統計資訊。所有這些操作只有在 gp 確實有值(如果連線了遊戲手柄)時才執行,這可以防止在未連線遊戲手柄時演示出錯。
稍後在程式碼中,您可以找到此程式碼塊
if (gp && gpPose.hasPosition) {
mvTranslate([
0.0 + curPos[0] * 15 - curOrient[1] * 15,
0.0 + curPos[1] * 15 + curOrient[0] * 15,
-15.0 + curPos[2] * 25,
]);
} else if (gp) {
mvTranslate([0.0 + curOrient[1] * 15, 0.0 + curOrient[0] * 15, -15.0]);
} else {
mvTranslate([0.0, 0.0, -15.0]);
}
這裡我們根據從連線的控制器接收到的 position 和 orientation 資料來改變螢幕上立方體的位置。這些值(儲存在 curPos 和 curOrient 中)是包含 X、Y 和 Z 值的 Float32Array(這裡我們只使用 [0] 即 X,和 [1] 即 Y)。
如果 gp 變數中有一個 Gamepad 物件並且它可以返回位置值(gpPose.hasPosition),表明它是一個 6DoF 控制器,我們就會使用位置和方向值修改立方體位置。如果只有前者為真,表明它是一個 3DoF 控制器,我們就會只使用方向值修改立方體位置。如果沒有連線遊戲手柄,我們根本不修改立方體位置。
顯示遊戲手柄姿態資料
在 displayPoseStats() 函式中,我們從傳遞給它的 GamepadPose 物件中獲取所有要顯示的資料,然後將它們列印到演示中用於顯示此類資料的 UI 面板中。
function displayPoseStats(pose) {
const pos = pose.position;
const formatCoords = ([x, y, z]) =>
`x ${x.toFixed(3)}, y ${y.toFixed(3)}, z ${z.toFixed(3)}`;
posStats.textContent = pose.hasPosition
? `Position: ${formatCoords(pose.position)}`
: "Position not reported";
orientStats.textContent = pose.hasOrientation
? `Orientation: ${formatCoords(pose.orientation)}`
: "Orientation not reported";
linVelStats.textContent = `Linear velocity: ${formatCoords(
pose.linearVelocity,
)}`;
angVelStats.textContent = `Angular velocity: ${formatCoords(
pose.angularVelocity,
)}`;
linAccStats.textContent = pose.linearAcceleration
? `Linear acceleration: ${formatCoords(pose.linearAcceleration)}`
: "Linear acceleration not reported";
angAccStats.textContent = pose.angularAcceleration
? `Angular acceleration: ${formatCoords(pose.angularAcceleration)}`
: "Angular acceleration not reported";
}
總結
本文向您介紹瞭如何在 WebVR 應用程式中使用 Gamepad Extensions 來使用 VR 控制器。在真實的應用程式中,您可能會有一個更復雜的控制系統,將控制分配給 VR 控制器上的按鈕,並且顯示器會同時受到顯示姿態和控制器姿態的影響。然而,在這裡,我們只是想隔離 Gamepad Extensions 的純粹部分。