输入和输入源
一个完整的 WebXR 体验不仅仅是向用户展示一个完全虚拟的场景,或者通过添加或改变周围的世界来增强现实。为了创造一个令人满意和引人入胜的体验,用户需要能够与它互动。为此,WebXR 提供了对各种输入设备的支持。
在本指南中,我们将探讨如何使用 WebXR 的输入设备管理功能来确定有哪些输入源可用,以及如何监测这些源的输入,以便处理用户与您的虚拟或增强环境的交互。
WebXR 中的输入
从根本上说,WebXR 中的输入分为两个基本类别:目标定位和动作。目标定位是用户输入对空间中一个点的指定。这可能涉及用户点击屏幕上的一个点,跟踪他们的眼睛,或者使用操纵杆或运动感应控制器来移动光标。
动作包括选择动作(例如点击按钮)和挤压动作(例如拉动扳机或戴着触觉手套时握紧)。
通过将这两种类型的输入与通过头戴设备或其他机制改变视角位置和/或方向相结合,您可以创建一个交互式的模拟环境。
输入设备类型
WebXR 支持各种不同类型的设备来处理目标定位和动作输入。这些设备包括但不限于
- 屏幕点击(尤其但不仅限于手机或平板电脑)可同时用于执行目标定位和选择。
- 运动感应控制器,使用加速度计、磁力计和其他传感器进行运动跟踪和目标定位,并且可能额外包含任意数量的按钮、操纵杆、拇指垫、触摸板、力传感器等,以提供用于目标定位和选择的额外输入源。
- 可挤压的扳机或手套握把垫以提供挤压动作。
- 使用语音识别的语音命令。
- 空间跟踪的关节手,例如有线手套可以提供目标定位和挤压动作,如果配备了按钮或其他选择动作来源,还可以提供选择动作。
- 单按钮点击设备。
- 凝视跟踪(跟随眼睛的移动来选择目标)。
输入源
每个 WebXR 输入数据源都由一个 XRInputSource 对象表示,该对象描述输入源及其当前状态。每个输入源的信息包括它握在哪只手(如果适用),它使用的目标定位方法,可用于绘制目标射线和查找目标对象或位置以及在用户手中绘制对象的 XRSpace,以及指定在用户视野中表示控制器以及输入如何操作的首选方式的配置文件字符串。
输入源的基本功能是
- 目标定位
- 
监测方向控制(例如,运动感应指针或操纵杆或轨迹板)以朝某个方向瞄准,可能指向某个目标,尽管目标定位由您自行实现。有关详细信息,请参阅朝向和目标定位。 
- 选择
- 
使用主“选择”按钮或控制器上的其他输入来选择目标方向(或其指向的对象),或以其他方式执行动作。有关主要动作的详细信息,请参阅主要动作。 
- 挤压
- 
挤压控制器或控制器上的机制以执行辅助动作。有关详细信息,请参阅主要挤压动作一节。 
WebXR 控制器可能拥有的任何附加功能都通过输入源的 gamepad 对象访问。此对象提供对控制器可能包含的所有按钮、轴、轨迹板等的访问。请参阅使用游戏手柄对象的高级控制器以了解如何使用这些控制器。
输入源的实例属性
每个独立的 XRInputSource 都有一组属性,描述了输入的可用轴和按钮、用户握在哪只手以及输入源如何用于处理 3D 空间内的目标定位。
惯用手
惯用手,由 XRInputSource 属性 handedness 表示,是一个字符串,指示控制器在查看者的哪只手:left 或 right。如果控制器不是手持的,或者不知道控制器在哪只手,也可以将其设置为 none。
惯用手可以用于各种事物,包括选择合适的网格来表示视图中的控制器,如果显示器上绘制了手,则有助于在正确的手中呈现它。如果您的应用程序使用“主手”和“副手”的概念来确定控制器的功能,它也可能很有用;例如,在游戏中,主手控制器可能是玩家的武器,而副手控制器可能用于控制盾牌的定位。
目标射线模式
目标射线模式是一个字符串,位于 targetRayMode 属性中。它描述了用于确定目标射线以及如果视觉呈现时应如何向用户显示的技术。
当目标射线模式为 gaze 时,射线的原点在观察者处,并指向用户所面对的方向。这种凝视输入方法相当简单,不需要任何特殊控制,因为它将基于头戴设备或用于确定观察者面部指向哪个方向的任何设备报告的朝向。目标射线应从眼睛之间向外延伸,方向垂直于观察者的面部。
更灵活的是 tracked-pointer 模式,其中射线的原点位于手持控制器或手部跟踪系统的原点,并向控制器指向的方向延伸。如果已定义,射线将沿任何平台和控制器定义的方向向外延伸;否则,如果用户当前伸出食指,射线将沿用户指向的方向延伸。
第三种也是最后一种目标射线模式最常见于智能手机和平板电脑等移动设备。screen 模式表示目标射线是根据用户以某种方式与屏幕交互来确定 WebXR 上下文的——最可能是通过观察者点击屏幕或用手指拖动目标射线。
目标射线空间
用于描述目标射线位置和方向的 XRSpace 位于 targetRaySpace 属性中。此空间的本机原点位于目标射线发出的点(例如,控制器的前端,或者如果控制器被渲染为枪,则为枪管的末端),并且空间的方向向量沿目标射线的路径向外延伸。
您可以使用 XRFrame 的 getPose() 方法,在给定帧的绘制处理程序中轻松获取与 targetRaySpace 对应的目标射线。返回的 XRPose 的 transform 是与目标射线对应的变换。因此,对于输入控制器 primaryInput
let targetRayPose = frame.getPose(primaryInput.targetRaySpace, viewerRefSpace);
let targetRayOrigin = targetRayPose.transform.position;
let targetRayVector = targetRayPose.transform.orientation;
有了这个,您现在就有了目标射线发出的点(targetRayOrigin)和它指向的方向(targetRayVector),以观察者的参考空间(viewerRefSpace)给出。这是您绘制目标射线、确定指向什么、进行命中测试等所需的一切。
握持空间
输入源的 gripSpace 属性是一个 XRSpace,您可以使用它来渲染对象,使它们看起来像握在观察者的手中。
图:左手握持空间的坐标系。  图:右手握持空间的坐标系。 
握持空间的本源原点位于玩家拳头中心附近,在输入源的局部坐标系中为 (0, 0, 0),而 gripSpace 指定的 XRSpace 可以随时用于将坐标或向量从输入源空间转换为世界坐标(反之亦然)。
这意味着,如果您使用 3D 模型来表示您的控制器、玩家头像的手或任何其他代表控制器在空间中位置的事物,则 gripSpace 可以用作正确定位和定向对象模型进行渲染的变换矩阵。为此,需要使用变换将握持空间转换为 WebGL 用于渲染目的的世界坐标系。
图:将握持空间映射到世界坐标系。距离 x、y 和 z 共同构成了与握持空间 G 的原点对应的世界坐标 (x, y, z)。 
在上图中,我们看到握持空间,其原点位于 G,位于用户握住控制器的中点,控制器直接远离用户,平行于 z 轴。相对于世界空间的原点 W,握持空间的原点位于右侧 x 单位,上方 y 单位,更远 z 单位。给定轴的方向性,握持空间的坐标可以表示为世界坐标 (x, y, -z);z 为负,因为握持空间沿 z 轴更远,因此在负方向。
如果控制器位于世界空间原点的左侧且比世界空间原点更靠近用户(或者可能位于用户后面,如果用户位于原点,尽管这是一种不舒服的握持控制器方式),则坐标的 x 值将为负,但 z 值为正。y 的值仍然为正,除非控制器移动到世界空间原点下方。
这在下图中显示,其中控制器位于世界空间原点的下方和左侧,控制器也比世界原点更靠近我们。因此,x 和 y 的值都为负,而 z 为正。
图:当控制器位于世界原点下方和左侧,且比世界原点更靠近我们时,将握持空间映射到世界原点。 
游戏手柄记录
每个输入源都有一个 gamepad 属性,如果不是 NULL,则是一个 Gamepad 对象,描述控制器上可用的各种控件和小部件。如果输入设备只有主要的运动传感器、挤压控制和一个按钮,它可能没有 Gamepad 记录。但是,如果 gamepad 存在,您可以使用它来识别和轮询控制器上可用的按钮和轴。
虽然 Gamepad 记录由 Gamepad API 规范定义,但它实际上不由 Gamepad API 管理,并且功能不完全相同。有关更详细的信息,请参阅使用游戏手柄对象的高级控制器。
配置文件字符串
每个输入源可以有零个或多个输入配置文件名称字符串,位于数组 profiles 中,每个字符串描述输入源在 3D 世界中的首选视觉表示以及输入源的功能。这些配置文件的使用在下面的输入配置文件中简要描述。
瞬时输入源
某些设备可能会创建瞬时输入源,以便与并非真正来自该设备的动作一起使用,但却被呈现为来自该设备。例如,如果 XR 设备提供一种模式,其中鼠标用于模拟设备上的事件,则可能会创建一个新的 XRInputSource 对象来表示模拟输入源,持续处理动作。
这是必要的,因为标准输入设备和 XR 输入源之间保持了分离。在每个瞬时动作的持续时间内,使用人工源来表示外部源。
管理输入源
当有多个输入源可用时,您需要能够获取每个输入源的信息,包括其位置和方向,其目标射线(如果适用于您的需求),以及可以帮助您决定如何以视觉方式呈现输入源(如果需要)的详细信息。您还需要能够确定将哪个输入源用于哪些活动;例如,如果用户有两个控制器,哪个将被跟踪以操作 UI 元素,或者两者都将被跟踪?
因此,要管理输入源,您需要能够枚举输入源,检查每个输入源的配置文件信息,并决定如何使用每个输入控制器。
枚举输入源
由 XRSession 对象表示的 WebXR 会话具有一个 inputSources 属性,该属性是当前连接到 XR 系统的 WebXR 输入设备的实时列表。
let inputSourceList = xrSession.inputSources;
由于列表中表示每个输入源的 XRInputSource 对象的内容是只读的,因此 WebXR 系统通过删除源的记录并添加新记录来替换它来更改这些输入。每当一个或多个输入源发生更改,或者当输入源被添加到列表或从列表中移除时,都会向您的 XRSession 发送一个 inputsourceschange 事件。
例如,如果您需要跟踪玩家每只手中握着哪个控制器,您可以这样做
let inputSourceList = NULL;
let leftHandSource = NULL;
let rightHandSource = NULL;
xrSession.addEventListener("inputsourceschange", (event) => {
  inputSourceList = event.session.inputSources;
  inputSourceList.forEach((source) => {
    switch (source.handedness) {
      case "left":
        leftHandSource = source;
        break;
      case "right":
        rightHandSource = source;
        break;
    }
  });
});
inputsourceschange 事件在会话创建回调首次完成执行时也会触发一次,因此您可以在启动时立即可用时使用它来获取输入源列表。该事件作为 XRInputSourcesChangeEvent 传递,其中包括三个感兴趣的属性
- 会话
- 
输入源已更改的 XRSession。
- 已添加
- 
一个包含零个或多个 XRInputSource对象的数组,指示已新添加到 XR 系统的输入源。
- 已移除
- 
一个包含零个或多个 XRInputSource对象的数组,指示已从 XR 系统中移除的任何输入源。
识别输入的配置文件
每个输入源都有一个 profiles 属性,其中包含适用于输入源的 WebXR 输入配置文件的实时列表,按从最具体到最不具体的顺序排列。
为了进行除基本功能识别之外的任何有意义的配置文件扫描,您可能需要从 WebXR 输入配置文件注册表导入 JSON 配置文件数据库。
有关使用输入配置文件的更具体详细信息,请参阅输入配置文件。
选择主控制器
为了避免由于多个控制器试图无意中同时操作 UI 而引入问题,您的应用程序可能需要一个“主”控制器。这个控制器不仅要承担点击应用程序用户界面的责任,而且还会被认为是“主手”,而其他控制器则将是副手或附加控制器。
注意:这并不意味着您的应用程序需要决定一个主控制器。但如果确实需要,这些策略可能会有所帮助。
有几种方法可以决定主控制器。我们将探讨三种。
惯用手
决定哪个控制器是主控制器的最直接方法是设置一个用户可定义的“惯用手”偏好,用户通过它来指示哪只手是惯用手。然后,您将查看每个输入源并找到与之匹配的一个(如果可用),如果该手中没有控制器,则回退到另一个控制器。
const primaryInputSource =
  xrSession.inputSources.find((src) => src.handedness === user.handedness) ??
  xrSession.inputSources[0];
此代码片段首先假定第一个输入源是主源,然后查找其 handedness 与 user 对象中指定的匹配的输入源。如果匹配,则选择该输入源作为主源。
首次使用
另一个选择是使用用户触发选择动作的第一个输入。下面的代码首先假设第一个输入源是主源,然后为 select 事件建立一个处理程序,该处理程序将事件的源记录为主输入源。然后将 select 事件处理程序替换为函数 realSelectHandler(),该函数将用于处理所有未来的 select 事件。然后我们将事件传递给 realSelectHandler(),以允许事件正常处理。
let primaryInputSource = xrSession.inputSources[0];
xrSession.onselect = (event) => {
  primaryInputSource = event.inputSource;
  xrSession.onselect = realSelectHandler;
  return realSelectHandler(event);
};
其效果是,无论 select 事件来自哪个输入源,我们都在第一次收到该事件时设置主输入源,并从那时起正常处理事件,不再担心哪个输入源是主输入源。
用户选择
确定主输入源最复杂的方法是高度灵活的,但可能需要大量的工作来实现。在这种情况下,您遍历输入源列表及其配置文件以收集每个输入源的信息,然后呈现一个描述每个输入的 UI,允许用户为它们分配用途。做好这项工作可能是一项艰巨的任务,但对于可能涉及多个用户输入的复杂应用程序可能很有用。
实现此功能所需的大部分信息可以在下面的输入配置文件部分找到。但是,详细信息超出了本文的范围。
输入配置文件
如上所述,每个输入源都有一个输入配置文件名称列表,对应于一组描述该输入源及其如何使用的信息。这些名称位于输入源的 profiles 属性中,这些配置文件字符串的官方注册表维护在 GitHub 上的 WebXR 输入配置文件注册表中。
例如,generic-trigger-squeeze-touchpad 配置文件名称可用于通过查找具有值 generic-trigger-squeeze-touchpad 的 profileId 字段来定位以下 JSON 配置文件数据。
{
  "profileId": "generic-trigger-squeeze-touchpad",
  "fallbackProfileIds": [],
  "layouts": {
    "left-right-none": {
      "selectComponentId": "xr-standard-trigger",
      "components": {
        "xr-standard-trigger": { "type": "trigger" },
        "xr-standard-squeeze": { "type": "squeeze" },
        "xr-standard-touchpad": { "type": "touchpad" }
      },
      "gamepad": {
        "mapping": "xr-standard",
        "buttons": [
          "xr-standard-trigger",
          "xr-standard-squeeze",
          "xr-standard-touchpad"
        ],
        "axes": [
          { "componentId": "xr-standard-touchpad", "axis": "x-axis" },
          { "componentId": "xr-standard-touchpad", "axis": "y-axis" }
        ]
      }
    }
  }
}
这是一个控制器,无论它在哪只手(即使它目前没有与特定的手关联),它都有三个组件:一个标准扳机、一个标准挤压输入和一个触控板。根据 selectComponentId 属性,xr-standard-trigger 组件是用于执行主要动作的组件。
此外,gamepad 对象将这些输入映射到游戏手柄,将扳机、挤压和触控板点击分配给输入源的按钮列表,并将触控板的“轴”分配给轴列表。
profiles 中的列表按反向特异性排序;也就是说,最精确的描述在前,最不精确的描述在后。列表中的第一个条目通常表示控制器的精确型号,或控制器兼容的型号。
例如,Oculus Touch 控制器在 profiles 中的条目 0 是 oculus-touch。下一个条目是 generic-trigger-squeeze-thumbstick,表示具有扳机、挤压控制和拇指杆的通用设备。虽然 Oculus Touch 控制器实际上是拇指垫而不是拇指杆,但总体描述“足够接近”,配置文件中与名称匹配的详细信息将使控制器能够被有效地解释。
动作
在 WebXR 中,动作是一种特殊类型的事件,由用户激活控制器上的特殊按钮触发。任何额外的按钮(以及诸如轴控制器——例如操纵杆——之类的东西)都完全通过 XRInputSource 属性 gamepad 进行管理。有关支持这些额外控件和按钮的更多详细信息,请参阅下面的使用游戏手柄对象的高级控制器。
主要动作是当用户启动具有特殊用途的主控制元素时触发的动作。目前有两种主要动作类型
- 主要动作是当用户激活控制器上的主要或“选择”输入时激活的动作。此输入可以是按钮、扳机、轨迹板轻触或点击、语音命令或特殊手势,也可能是其他形式的输入。例如,在带有可点击轨迹板、扳机控制以及后退和“菜单”按钮的手部控制器上,点击轨迹板很可能是主要动作。某些控制器可能有一个标记为“选择”的按钮。在游戏手柄式控制器上,“A”按钮很可能是主要动作。
- 主要挤压动作是当用户挤压控制器时执行的动作。这种“挤压”可以通过字面意义上使用控制器中的压力传感器来检测,或者可以使用扳机、手势或其他机制来模拟。例如,如果输入控制器是触觉手套,当用户握紧拳头时,它可能会报告发生了主要挤压动作。
虽然给定的输入源只能有一个主要动作和一个主要挤压动作,但输入设备上可以配置多个控件来触发每个主要动作。例如,用户可能将控制器设置成轻触和点击触控板都会产生一个主要动作。
这些类型的输入动作将在下面更详细地描述。
主要动作
每个输入源都应该定义一个主要动作。主要动作(有时会简称为“选择动作”)是一种特定于平台的动作,它通过按顺序传递 selectstart、select 和 selectend 事件来响应用户的操作。这些事件都属于 XRInputSourceEvent 类型。
注意:如果输入源没有主要动作,则该输入源被视为辅助输入源。
当用户在您的 3D 空间中沿着目标射线指向设备,然后触发选择动作时,以下事件将发送到活动的 XRSession
- 一个 selectstart事件,指示用户已执行开始主要动作的活动。这可能是一个手势、按下按钮等。
- 如果主要动作成功结束(例如,由于用户释放按钮或扳机),而不是由于错误,则发送 select事件。
- 在 select事件发送之后,或者如果执行动作的控制器断开连接或变得不可用,则发送selectend事件。
一般来说,selectstart 和 selectend 事件告诉您何时可能需要向用户显示一些东西,以指示主要操作正在进行。这可能是用新颜色绘制激活按钮的控制器,或者显示目标对象被抓住并移动,从 selectstart 到达时开始,到 selectend 收到时停止。
另一方面,select 事件是告诉您的代码用户已完成他们想要完成的动作的事件。这可能就像在游戏中抛掷物体或扣动枪的扳机一样简单,也可能像将他们在世界中拖动的物体放置到新位置一样复杂。
如果您的主要动作是一个简单的触发动作,并且您不需要在触发器接合时进行任何动画,则可以忽略 selectstart 和 selectend 事件,并对 select 事件采取行动。
xrSession.addEventListener("select", (event) => {
  let inputSource = event.inputSource;
  let frame = event.frame;
  /* handle the event */
});
某些动作可能会很快地发送这些事件,一个接一个。这些事件之间经过的时间取决于导致动作发生的硬件设备以及解释硬件动作并将其转换为一系列事件的软件驱动程序。不要假定这些事件之间会有任何特定的时间间隔。
例如,如果导致主要动作发生的硬件是一个按钮,当用户按下按钮时,您将收到 selectstart,然后在用户释放按钮时收到 select 和 selectend。
在整个文档中都有许多示例说明如何处理 select 事件,例如本文中目标定位和目标射线一节。
主要挤压动作
主要挤压动作是一种平台特定的动作,它会向 XRSession 发送 squeezestart、squeezeend 和 squeeze 事件。这通常是由用户挤压控制器、做出模仿抓住东西的手势或使用(挤压)扳机产生的。
事件序列与主要动作发送的事件序列相同,只是每个事件的名称不同
- 一个 squeezestart事件被发送到XRSession,表明用户已开始挤压动作。
- 如果主要挤压动作成功结束,则会话会收到一个 squeeze事件。
- 然后,发送一个 squeezeend事件,表示挤压动作不再进行中。无论挤压动作是否成功,都会发送此事件。
主要挤压动作的两个常见用途是在 3D 世界中抓取和/或拾取物体,以及在游戏或模拟中挤压扳机以发射武器。
示例
此示例代码显示了一组挤压事件处理程序,它们实现这些事件以管理从场景中拾取和握持对象。该代码假定存在一个代表角色的 avatar 对象(如本页上的其他几个示例中所用),以及 pickUpObject() 和 dropObject() 函数,它们分别处理将对象从世界转移到特定手和从手中释放对象并将其放回世界。
拾取对象:处理 squeezestart 事件
xrSession.addEventListener("squeezestart", (event) => {
  const targetRaySpace = event.inputSource.targetRaySpace;
  const hand = event.inputSource.handedness;
  let targetRayPose = event.frame.getPose(targetRaySpace, viewerRefSpace);
  if (!targetRayPose) {
    return;
  }
  let targetRayTransform = targetRayPose.transform;
  let targetObject = findTargetObject(targetRayTransform);
  if (targetObject) {
    if (avatar.heldObject[hand]) {
      dropObject(hand);
    }
    pickUpObject(targetObject, hand);
  }
});
处理 squeezestart 事件的方法是照常获取姿势和变换,并将输入源的 handedness 放入局部常量 hand 中。我们将使用它将手映射到该手中持有的对象。
然后,代码识别目标对象,如果沿着目标射线找到对象,则将其拾起。拾起对象涉及首先查看手(由 avatar.heldObject[hand] 表示)中是否已持有任何对象,如果已持有,则通过调用 dropObject() 函数将其放下。
然后调用 pickUpObject(),将目标对象指定为要从场景中移除并放入指定 hand 的对象。pickUpObject() 还记录对象的原始位置,以便在挤压取消或中止时可以将其返回到该位置。
放下物体:挤压事件处理程序
当用户通过松开握力来结束挤压动作时,会收到 squeeze 事件。在此示例中,我们将其解释为释放当前持有的物体,将其放置在目标位置的场景中。
此代码假设存在附加函数 findTargetPosition(),该函数会沿着目标射线跟踪直到与某物碰撞,然后返回碰撞发生的坐标,以及 putObject(),该函数将指定 hand 中持有的物体放置在给定位置,并将其从手中移除。
xrSession.addEventListener("squeeze", (event) => {
  const targetRaySpace = event.inputSource.targetRaySpace;
  const hand = event.inputSource.handedness;
  let targetRayPose = event.frame.getPose(targetRaySpace, viewerRefSpace);
  if (!targetRayPose) {
    return;
  }
  let targetRayTransform = targetRayPose.transform;
  let targetPosition = findTargetPosition(targetRayTransform);
  if (targetPosition) {
    if (avatar.heldObject[hand]) {
      putObject(hand, targetPosition);
      avatar.heldObject[hand] = null;
    }
  }
});
与 squeezestart 处理程序一样,这首先收集有关事件所需的信息,包括放下对象的的手和目标射线的变换。目标射线变换被传递到假定的 findTargetPosition() 函数,以获取放置放下对象的坐标。
有了位置信息,我们就可以通过调用 putObject() 函数来放下对象,该函数将 hand 和目标位置作为输入。此函数的作用是将对象从指定手中移除并将其重新添加到场景中,并将其位置设置为放置在 findTargetPosition() 返回的坐标之上。
在 squeezeend 处理程序中取消挤压
即使挤压失败,也会在挤压完成后收到 squeezeend 事件。我们通过将当前持有的对象返回到拾取时的位置来处理它。
xrSession.addEventListener("squeezeend", (event) => {
  const targetRaySpace = event.inputSource.targetRaySpace;
  const hand = event.inputSource.handedness;
  if (avatar.heldObject[hand]) {
    returnObject(hand);
    avatar.heldObject[hand] = null;
  }
});
这里,returnObject() 函数被假定为一个知道如何将指定 hand 中持有的对象返回到其初始位置的函数,正如它在 squeezestart 事件处理程序中由 pickUpObject() 记录的那样。
这里,returnObject() 函数被假定为一个知道如何将指定 hand 中持有的对象返回到其初始位置的函数,正如它在 squeezestart 事件处理程序中由 pickUpObject() 记录的那样。
瞬时动作
如果 XR 设备在 inline 模式下使用鼠标模拟控制器,则大致发生以下序列
- 用户在显示 WebXR 场景的 <canvas>内部按下鼠标按钮。
- 鼠标事件由 XR 设备的驱动程序捕获。
- 设备创建一个新的 XRInputSource来表示模拟的 XR 输入源。将targetRayMode设置为screen,并根据需要填写其他信息。这个新的输入源被临时添加到XRSession属性inputSources返回的列表中。
- 浏览器发送与动作对应的 pointerdown事件。
- 生成主要动作,并以 selectstart事件的形式发送给应用程序,其源设置为新的XRInputSource。或者,如果鼠标用作副手或辅助控制器,则发送辅助动作。
- 当用户释放鼠标按钮时,select事件将发送到XRSession,然后 DOM 收到一个click事件。然后会话收到selectend事件,指示动作完成。
- 当动作完成时,浏览器会删除瞬时输入源,并发送任何适当的 pointerup事件。
因此,瞬时输入源确实是瞬时的——它仅在处理输入的持续时间内存在,因此不会在输入源列表中列出。
朝向和目标定位
朝向是观察者正在看的方向。这不是通过输入源提供的。相反,它是使用从当前动画帧的 XRFrame.getViewerPose() 方法获得的 XRPose 获得的。观察者姿势变换矩阵的旋转分量是观察者的朝向方向。
您可以在文章视点和观察者中了解更多关于如何使用观察者姿势来确定朝向方向。
目标定位是用户使用输入源指向特定方向的行为。输入源的 targetRaySpace 是一个 XRSpace(实际上可能是 XRReferenceSpace),可用于确定目标射线相对于观察者朝向方向的方向。
这可能涉及也可能不涉及实际指向 3D 世界中的特定对象;您必须使用命中测试自行确定——也就是说,检查目标射线是否与场景中的任何对象相交。
目标定位和目标射线
目标射线,其原点位于目标射线空间的原点,并指向用户指向控制器设备的方向。目标射线使用一个 XRSpace 定义,其原点位于目标射线的来源(通常是控制器向外的一端或其在 3D 世界中的表示),其方向具有 -Z 沿与 XRInputSource 的 gripSpace 相同的方向从控制器向外延伸。
这个空间可以在输入源的 targetRaySpace 属性中找到。它可用于确定控制器指向的方向以及确定目标射线的原点和方向。这可以通过执行以下示例来完成,该示例实现了一个需要此信息的 select 事件处理程序。像往常一样,此代码假定使用 glMatrix 来执行矩阵和向量数学
xrSession.addEventListener("select", (event) => {
  const targetRaySpace = event.inputSource.targetRaySpace;
  let targetRayPose = event.frame.getPose(targetRaySpace, viewerRefSpace);
  if (!targetRayPose) {
    return;
  }
  let targetRayTransform = targetRayPose.transform;
  let targetObject = findTargetObject(targetRayTransform);
  if (targetObject) {
    /* do stuff with the targeted object */
  }
});
这在向量 targetSourcePoint 中获得了目标射线的原点,并在四元数 targetDirection 中获得了射线的方向。两者都
这首先将目标射线的空间获取到局部常量 targetRaySpace 中。然后,在调用 XRFrame 方法 getPose() 时使用它,以创建表示目标射线在观察者参考空间 viewerRefSpace 中的位置和方向的 XRPose 对象。如果此值为 null,则事件处理程序将返回,不再执行任何操作。
目标射线的变换从姿势的 transform 属性获取并存储在局部变量 targetRayTransform 中。然后,它(在本例中通过名为 findTargetObject() 的函数)用于查找射线相交的第一个对象。如果目标射线确实与场景中的对象相交,我们就可以对它做任何需要做的事情。
如果您需要剥离目标射线原点的实际位置和射线的方向性,您可以这样做
const targetRayOrigin = vec3.create();
const targetRayDirection = quat.create();
mat4.getTranslation(targetRayOrigin, viewerRefSpace);
mat4.getRotation(targetRayDirection, viewerRefSpace);
为了确定目标对象,沿目标射线跟踪直到它与对象相交。这个过程被称为命中测试或碰撞检测。您采用的命中测试方法很大程度上取决于您应用程序的具体需求。第一个问题是:您正在检测与虚拟对象或地形、真实世界对象或地形的碰撞,还是两者兼而有之?
无论如何,要识别目标对象,您需要确定由 XRInputSource 属性 targetRaySpace 指定的射线是否与场景中的任何对象相交,无论是虚拟的还是真实世界的。
有关更详细的介绍,请参阅目标定位和命中检测。
呈现手持物体
输入源的 gripSpace 属性标识一个 XRSpace,它描述了渲染对象时使用的原点和方向,以使其看起来像握在与输入源相同的手中。此空间旨在用于绘制由 XRInputSource 对象表示的手持 WebXR 输入控制器的模型,但同样可以用于绘制任何对象,例如球、工具或武器。我们上面已经介绍了握持空间,但让我们看看它如何用于绘制代表手或手中持有的对象。
由于握持空间的原点位于手掌的中心,您可以将其作为渲染对象的起点。应用任何所需的偏移变换以将原点移动到渲染对象的起点,同时应用任何所需的旋转以使您的模型与握持空间的方向正确对齐。
使用游戏手柄对象的高级控制器
一个 XRInputSource 有一个 gamepad 属性,如果该值不为 null,则它是一个 Gamepad 对象,提供对游戏手柄式按钮、轴控制器(例如操纵杆或拇指垫)等的访问。这可能包括触发标准 XRInputSource 动作的相同按钮,但可能包括任意数量的附加按钮和控制。
注意:虽然 Gamepad 由 Gamepad API 定义,但它不受 Gamepad API 管理,因此您不得尝试使用任何 Gamepad API 方法。对象类型被重用是为了方便。
如果 gamepad 的值为 null,则输入源不使用 Gamepad 记录定义任何控件,原因可能是它不支持它,或者因为它上面没有添加任何控件。
此 gamepad 对象不仅用于获取对特殊按钮、轨迹板等的访问,还提供了一种更直接地访问和监测作为主要选择和挤压输入的控件的方法,因为这些控件包含在其 buttons 列表中。
由于这种 Gamepad 接口的使用是一种便利,而不是 Gamepad API 的真实应用,因此它在 WebXR 中的使用方式与在 Gamepad API 应用程序中的使用方式存在一些差异。最显著(但不是唯一)的差异是 WebXR 添加了 xr-standard 游戏手柄映射,有关其他差异,请参阅 XRInputSource.gamepad 属性。此游戏手柄映射定义了典型单手手持 VR 控制器上的控件如何映射到游戏手柄控件。
整合非 WebXR 来源的输入
有时,您需要一种方法让用户使用 WebXR 外部的控制器提供输入。最常见的是,这些输入来自键盘和鼠标,但您也可以使用非 XR 游戏手柄设备、网络输入或其他数据源来模拟用户控制。虽然 WebXR 不支持将这些输入设备直接与 XR 场景连接,但您可以自己收集输入数据并自行应用。
假设输入用于控制模拟中的一个化身,这是最常见的用例,WebXR 输入使用从非 XR 输入设备收集的数据,以以下方式影响化身
- 位置
- 
化身的位置通过对先前已知位置应用 delta 来改变,然后用一个新的参考空间替换化身的参考空间,该参考空间的变换反映了新的位置。 
- 方向
- 
通过对其围绕三个轴的旋转应用增量来改变化身的方向或朝向方向,更新其方向向量,然后重新计算其参考空间。 
- 动作
- 
化身执行一个动作,例如使用物体或武器、跳跃,或任何其他与基本移动和旋转无关的活动。 
有些输入用于控制应用程序而不是化身。例如,一个按钮可能会打开一个用于配置应用程序的选项菜单。当该菜单打开时,原本会控制化身的输入可能会被用于控制菜单的界面。
使用键盘和鼠标事件
从键盘和鼠标捕获输入就像在任何 Web 应用程序中一样。设置您需要处理的事件的处理程序,以获取您想要的输入。有趣的是您如何处理这些输入。
想象一个 avatar 对象,我们将用它来跟踪化身及其世界观的信息。我们希望玩家能够使用 W、A、S 和 D 键向前、向左、向后和向右移动。由于我们除了 XR 硬件可能正在做的事情之外,还在管理由键盘和鼠标定义的化身位置,因此我们需要单独维护这些信息,并在渲染化身(或从化身视角渲染世界)之前将其作为变换应用。
为了实现这一点,我们在 avatar 对象中包含一个 posDelta 属性,类型为 DOMPoint,其中包含要应用于所有三个轴的偏移量,以调整化身位置(观察者姿势的参考空间原点),使其包含来自键盘和鼠标的移动和旋转。
键盘输入的相应代码可能如下所示
document.addEventListener("keydown", (event) => {
  switch (event.key) {
    case "a":
    case "A":
      avatar.posDelta.x -= ACCEL_X;
      break;
    case "d":
    case "D":
      avatar.posDelta.x += ACCEL_X;
      break;
    case "w":
    case "W":
      avatar.posDelta.y += ACCEL_Y;
      break;
    case "s":
    case "S":
      avatar.posDelta.y -= ACCEL_Y;
      break;
    default:
      break;
  }
});
这是一个简单的例子,加速度是恒定的,并不特别真实。您可以大大增强这一点,通过应用一些物理知识,使加速度随着时间的推移而变化,这取决于按键的持续时间和其他因素。
将输入应用于场景
现在我们有了需要应用于位置和方向的增量——在我们的示例中,在 avatar 对象的 posDelta 和 orientDelta 属性中——我们可以编写代码来应用这些更改。由于我们已经按计划渲染场景,因此我们可以将应用这些更改的代码添加到那里,以及准备和绘制场景。
function drawFrame(time, frame) {
  applyExternalInputs(avatar);
  let pose = frame.getViewerPose(avatar.referenceSpace);
  animationFrameRequest = session.requestAnimationFrame(drawFrame);
  /* draw the frame here */
}
这里显示的 drawFrame() 函数是调用 XRSession 方法 requestAnimationFrame() 时,在需要绘制帧时调用的回调。它调用一个我们即将定义的函数 applyExternalInputs();它接收 avatar 对象并使用其信息更新化身的参考帧。
之后,一切照常进行,从更新的参考帧获取观察者姿势,通过 requestAnimationFrame() 请求下一个帧回调,然后继续设置 WebGL 并绘制场景。绘图和其他相关代码可以在示例移动、方向和运动中找到。
applyExternalInputs() 方法获取 avatar 对象,并将其 referenceSpace 属性替换为一个新的参考空间,该空间包含了更新的增量。
function applyExternalInputs(avatar) {
  if (!avatar.posDelta.x && !avatar.posDelta.y && !avatar.posDelta.z) {
    return; // Player hasn't moved with keyboard
  }
  let newTransform = new XRRigidTransform({
    x: avatar.posDelta.x,
    y: avatar.posDelta.y,
    z: avatar.posDelta.z,
  });
  avatar.referenceSpace =
    avatar.referenceSpace.getOffsetReferenceSpace(newTransform);
}