简体中文
简体中文
InputManager 设计与实现

InputManager 设计与实现

technical

引言

交互输入是引擎功能层中十分重要的一个功能,它允许用户使用设备,触摸或手势来与应用程序进行人机交互,在 0.6 里程碑中,我们初步搭建了 Galacean Engine 的交互系统,目前已经支持了点击键盘,而本文将与大家分享开发过程中的思路与不足。

整体设计

主要架构

输入设备,触摸,XR 设备等都属于交互系统的输入,我们将输入的所有逻辑收拢在输入管理器(InputManager)中,根据各种不同类型的输入再细分出触控管理器(PointerManager)与键盘管理器(KeyBoardManager)等特定输入。输入管理器分管所有特定输入管理器,在交互的帧处理中,只需要处理各个管理器内特定输入的逻辑即可。

API 设计

image.png

帧内生命周期

如下是帧处理的生命周期:

image.png

InputManager 的内部生命周期如下:

image.png

如何使用

Pointer

  1. 为三维空间内有碰撞体积的物体增加碰撞体。
  2. 参考脚本组件(Script)内回调接口的触发条件添加适宜的逻辑。 | 接口 | 触发时机与频率 | | --- | --- | | onPointerEnter | 当触控点进入 Entity 的碰撞体范围时触发一次 | | onPointerExit | 当触控点离开 Entity 的碰撞体范围时触发一次 | | onPointerDown | 当触控点在 Entity 的碰撞体范围内按下时触发一次 | | onPointerUp | 当触控点在 Entity 的碰撞体范围内松开时触发一次 | | onPointerClick | 当触控点在 Entity 的碰撞体范围内按下并松开,在松开时触发一次 | | onPointerDrag | 当触控点在 Entity 的碰撞体范围内按下时持续触发,直至触控点解除按下状态 |

KeyBoard

直接调用交互管理器(InputManager)提供的方法判断按键状态。

方法名称方法释义
isKeyHeldDown返回这个按键是否被持续按住
isKeyDown返回当前帧是否按下过此按键
isKeyUp返回当前帧是否抬起过此按键

鼠标与触控

背景

PointerEvent 是浏览器内鼠标与触控交互后续发展的势头,Pointer 是输入设备的硬件层抽象,开发者不需要关心数据来源是鼠标,触控板或是触摸屏,但是它也有一定兼容性问题,可以看到在 canIUse 中,PointerEvent 的设备覆盖率为:92.82 % ,需要通过导入 Polyfill 来解决。

image.png

需求调研

在脚本组件中增加响应 Pointer 的钩子函数,对于在三维空间中有碰撞体积的实体,可以让开发者通过补充对应钩子函数内的逻辑方便地实现点击,拖动,选中等交互操作。

钩子函数触发时机与频率
onPointerEnter当触控点进入 Entity 的碰撞体范围时触发一次
onPointerExit当触控点离开 Entity 的碰撞体范围时触发一次
onPointerDown当触控点在 Entity 的碰撞体范围内按下时触发一次
onPointerUp当触控点在 Entity 的碰撞体范围内松开时触发一次
onPointerClick当触控点在 Entity 的碰撞体范围内按下并松开,在松开时触发一次
onPointerDrag当触控点在 Entity 的碰撞体范围内按下时持续触发,直至触控点解除按下状态

原生事件

和 MouseEvent ,TouchEvent 一样,PointerEvent 也可以通过监听捕获。 canvas.addEventListener('pointerXXX', callBack);

MouseEventTouchEventPointerEvent
按下mousedowntouchstartpointerdown
抬起mouseuptouchendpointerup
移动mousemovetouchmovepointermove
离开mouseout | mouseleavetouchend | touchcancelpointerout | pointercancel | pointerleave

流程图

可以归纳出 Pointer 处理的大致流程,其中绿框代表原生事件。

未命名文件 (1).png

射线检测

在 Pointer 中要解决的最大问题是如何根据原生事件中的位置信息在三维空间中做射线检测,因为这部分内容不仅仅包含空间转换的基本知识,还包含了物理系统的基础使用。

在我们捕获了 PointerEvent 后,需要

  1. 从原生事件中获取有效的屏幕位置信息。
  2. 将位置从屏幕空间转换到三维空间,并获取检测射线。
  3. 射线与碰撞体相交检测。
  4. 回调脚本。

屏幕位置信息

我们期望拿到指针相对于目标元素的位置,但是原生事件中关于坐标的属性有很多,因此需要甄别哪个坐标信息是有效的。

原生事件坐标属性属性释义
clientX & clientY相对于触发事件的应用区域的坐标(可视区域坐标)
offsetX & offsetY相对于目标元素的坐标
pageX & pageY相对于整个 Document 的坐标(包含滚动区域)
screenX & screenY相对于主显示屏左上角的坐标(基本不会使用)
x & y同 clientX & clientY

他们有以下的转换关系(假设原生事件为 event,点击的目标元素为 canvas ):

image.png

可以得到的结论是:大多坐标属性都可以得到期望的坐标信息,其中 offset 最直接方便。

空间转换

简化射线检测,根据从获取到屏幕上点击的坐标得到三维空间中的一条射线,然后与三维空间中碰撞体进行碰撞检测。

以透视相机为例,当获取到屏幕上点击的坐标后,只需要完成以下步骤便可得到射线:

  1. offset -> 屏幕空间
  2. 屏幕空间 -> 裁剪空间
  3. 裁剪空间 -> 世界空间

有图形引擎基础的同学比较熟悉我们在渲染时经过了如下变换:

  1. 模型空间 -> 世界空间
  2. 世界空间 -> 观察空间 -> 裁剪空间
  3. 裁剪空间 -> 屏幕空间

似乎只需要得到屏幕空间的坐标,然后再经过几个空间变换的逆变换即可。

offset -> 裁剪空间

需要对 像素(pixel), **设备独立像素(dips) **与 **设备像素比(divicePixelRatio) **有一个大致的了解,从点击事件中的属性 offset 获取的坐标信息携带的单位是 **设备独立像素 **,因此在求解屏幕空间坐标的时候需要注意分子与分母的单位一致。

裁剪空间是 XYZ 范围皆在 -1 到 1 的左手坐标系(裁剪空间可以形象地理解为当渲染范围超出这个区间就会被裁减),此处转换时需注意:

  1. 求解触摸点在屏幕空间的相对位置时要注意分子与父母应都为像素或都为设备独立像素。
  2. 裁剪空间 Y 轴方向向上,offset 参考坐标系 Y 轴方向向下,因此 Y 轴需翻转。
  3. 裁剪空间中 depth 离观察者越远值越大,简单来说近平面是 -1 远平面是 1 。
image.png

屏幕空间的点 -> 世界空间的射线

公示推导中矩阵为列为主序。

以透视相机为例,世界空间经过 View 变换和 Project 变换即可转换到裁剪空间,那么从裁剪空间转换到世界空间只需要经历这些变换的逆即可。

image.png
image.png

检测射线

上式中代入近平面深度与远平面深度依次求得触摸点在世界坐标空间下近平面与远平面的投影点,连接这两个点即可得到检测射线。

image.png

射线相交检测

碰撞体由规则几何体组成(长方体,球体等)可以查阅相关射线与几何体相交算法。

image.png

脚本回调

当物理引擎返回命中的碰撞体后,可以认为它的 Entity 这就是当前帧的所有onPointerXXX回调的当事人了,在这个环节只需要根据收集的原生事件进行脚本回调即可。

性能优化

  • **压流:**捕获 PointerEvent 后将原生事件压入数组,待执行到交互系统的 tick 时,再按序处理相应逻辑。
  • **Pointer 合并:**射线检测的性能损耗较大,所以在屏幕上有多个触控点时,我们会按照一定规则合并这几个触控点,因此在触控交互逻辑中每帧的射线检测至多只会执行一次。
  • **多相机场景:**当出现多相机时,会依次检查渲染范围包含了点击点的所有相机,并根据相机的渲染顺序进行排序(后渲染优先),如果当前比较的相机渲染场景内没有命中碰撞体且相机的背景透明,点击事件会继续传递至上一个渲染的相机,直至命中或遍历完所有相机。
image.png

注意事项

正如开篇提到的兼容性问题,如果你的项目可能运行在低系统版本的机器中,可以导入我们定制的 PointerPolyFill 。 https://github.com/galacean/polyfill-pointer-event

键盘输入

需求调研

  • 获取当前帧所有按下过的按键
  • 获取当前帧所有松开过的按键
  • 获取当前还按着的按键
  • 判断某个按键在当前帧是否按下过
  • 判断某个按键在当前帧是否松开过
  • 判断某个按键现在还按着

原生事件

KeyBoardEvent 可以通过监听捕获。 canvas.addEventListener('keyXXX', callBack);

事件触发时机
keypress字符键按下时触发
keydown任意键按下时触发
keyup任意键抬起时触发

流程图

可以归纳出键盘处理的大致流程,其中绿框代表原生事件。

任务系统.png

索引值的选定

无论是在不同的大小写状态或不同键盘的布局下,按键都是一个可枚举的值,如果可以键值以枚举的形式存储,无论对性能还是使用都将带来极大的便利,因此需要确定适宜作为枚举值的属性。

以下为 KeyEvent 内可作为枚举值的属性:

属性属性释义简单示例兼容性
code触发事件的物理按键,与布局无关无论大小写或布局,当你按下 Y 键时,返回都是物理键“KeyY”兼容
key触发事件的键值当小写时为“y”,大写时为“Y”兼容
charCode已弃用
keyCode已弃用
char已弃用

可以发现,最适用的属性是 **code **,参考 https://w3c.github.io/uievents-code/

性能优化

每帧按键的交互逻辑较为简单,维护按下,松开与按住的三个数组即可满足所有需求,重点是如何降低帧级别增,删,查操作的性能损耗。

  • 优化增加元素与重置数组长度,可以使用无序数组
  • 优化查询可以额外增加索引

无序数组

无序数组在绝大多数情况下减少了增加与删除元素时性能损耗,下图表示无序数组组成:

image.png

下图表示无序数组如何降低性能损耗:

image.png

存储与索引

若仅使用三个无序数组,当需要查特定按键的状态依旧需要遍历数组,在极端情况下带来的性能损耗也不可小觑,如果将此现成的按键枚举作为 Key ,当前帧的是否按下过记录成 Value 就可以避免遍历。

image.png

虽然这样实现可以让查询变得更快,却额外增加了维护成本 —— 每帧开始需要重置映射表的状态,但如果保存的是帧序号,就可以完美避免这个消耗,只需要在每帧开始的时候更新帧序号即可。

image.png

依葫芦画瓢,在记录 HeldDown 的按键时也增加了一个表来映射按键在无序数组中的索引。

快速上手

按键状态isKeyHeldDownisKeyDownisKeyUp
该键从上帧开始就一直按着truefalsefalse
该键当前帧按下后就没有松开truetruefalse
该键在当前帧松开后又按下truetruetrue
该键在当前帧按下后又松开falsetruetrue
该键在当前帧被抬起falsefalsetrue
该键没按下且没交互falsefalsefalse
不会出现这种情况truefalsetrue
不会出现这种情况falsetruefalse

注意事项

  • 当按住某个按键持续一段时间时,原生的 keydown 事件会不断触发, 我们已经考虑并过滤了此情形,所以开发者无需做任何额外处理。
  • 某些状态按键的原生事件表现可能比较怪异,甚至在 FireFox 和 Chrome 上触发事件的表现都不一致。(如 Caps Lock)
Galacean Logo
Make fantastic web apps with the prospective
technologies and tools.
Copyright © 2024 Galacean
All rights reserved.