使用 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 显示器和游戏手柄的信息。

获取显示信息

第一个值得注意的代码如下

js
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() 来启动该过程。此函数如下所示

js
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.displayIdVRDisplay.displayName 值,以及显示器关联的 VRDisplayCapabilities 对象中包含的一些有用值。其中最有用的是 hasOrientationhasPosition,它们允许您检测设备是否可以返回方向和位置数据并相应地设置您的应用程序。

此函数中包含的最后一行是一个 setTimeout() 调用,它在 1 秒延迟后运行 reportGamepads() 函数。我们为什么需要这样做?首先,VR 控制器只有在其关联的 VR 头戴设备激活后才能准备就绪,因此我们需要在调用 getVRDisplays() 并返回显示信息之后调用它。其次,Gamepad API 比 WebVR API 旧得多,并且不是基于 Promise 的。正如您稍后将看到的,getGamepads() 方法是同步的,并且只是立即返回 Gamepad 对象——它不等待控制器准备好报告信息。除非您等待一小段时间,否则返回的信息可能不准确(至少,这是我们在测试中发现的)。

获取 Gamepad 信息

reportGamepads() 函数如下所示

js
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.hasPositionGamepadPose.hasOrientation 以显示控制器是否可以返回位置和方向数据。这与显示器的工作方式相同,只是在游戏手柄的情况下,这些值在姿态对象上可用,而不是在能力对象上。

请注意,我们还为包含控制器信息的每个列表项指定了 gamepad 类名。我们稍后会解释这是为了什么。

这里要做的最后一件事是将 initialRun 变量设置为 false,因为初始运行现在已经结束。

游戏手柄事件

为了结束本节,我们将介绍与游戏手柄相关的事件。我们需要关注两个——gamepadconnectedgamepaddisconnected——它们的功能相当明显。

在我们的示例的末尾,我们首先包含 removeGamepads() 函数

js
function removeGamepads() {
  const gpLi = document.querySelectorAll(".gamepad");
  for (const li of gpLi) {
    list.removeChild(li);
  }
  reportGamepads();
}

此函数获取所有类名为 gamepad 的列表项的引用,并将它们从 DOM 中移除。然后它重新运行 reportGamepads() 以使用更新的连接控制器列表填充列表。

每次连接或断开游戏手柄时,都会通过以下事件处理程序运行 removeGamepads()

js
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 处理程序内部,只有当 initialRunfalse 时才会运行超时。这是因为如果您的游戏手柄在文档首次加载时已连接,则每个游戏手柄都会触发一次 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() 函数中,您会找到这段代码

js
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 确实有值(如果连接了游戏手柄)时才执行,这可以防止在未连接游戏手柄时演示出错。

稍后在代码中,您可以找到此代码块

js
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]);
}

这里我们根据从连接的控制器接收到的 positionorientation 数据来改变屏幕上立方体的位置。这些值(存储在 curPoscurOrient 中)是包含 X、Y 和 Z 值的 Float32Array(这里我们只使用 [0] 即 X,和 [1] 即 Y)。

如果 gp 变量中有一个 Gamepad 对象并且它可以返回位置值(gpPose.hasPosition),表明它是一个 6DoF 控制器,我们就会使用位置和方向值修改立方体位置。如果只有前者为真,表明它是一个 3DoF 控制器,我们就会只使用方向值修改立方体位置。如果没有连接游戏手柄,我们根本不修改立方体位置。

显示游戏手柄姿态数据

displayPoseStats() 函数中,我们从传递给它的 GamepadPose 对象中获取所有要显示的数据,然后将它们打印到演示中用于显示此类数据的 UI 面板中。

js
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 的纯粹部分。

另见