交互输入是引擎功能层中十分重要的一个功能,它允许用户使用设备,触摸或手势来与应用程序进行人机交互,在 0.6 里程碑中,我们初步搭建了 Galacean Engine 的交互系统,目前已经支持了点击与键盘,而本文将与大家分享开发过程中的思路与不足。
输入设备,触摸,XR 设备等都属于交互系统的输入,我们将输入的所有逻辑收拢在输入管理器(InputManager)中,根据各种不同类型的输入再细分出触控管理器(PointerManager)与键盘管理器(KeyBoardManager)等特定输入。输入管理器分管所有特定输入管理器,在交互的帧处理中,只需要处理各个管理器内特定输入的逻辑即可。
如下是帧处理的生命周期:
InputManager 的内部生命周期如下:
脚本组件
(Script)内回调接口的触发条件添加适宜的逻辑。
| 接口 | 触发时机与频率 |
| --- | --- |
| onPointerEnter | 当触控点进入 Entity 的碰撞体范围时触发一次 |
| onPointerExit | 当触控点离开 Entity 的碰撞体范围时触发一次 |
| onPointerDown | 当触控点在 Entity 的碰撞体范围内按下时触发一次 |
| onPointerUp | 当触控点在 Entity 的碰撞体范围内松开时触发一次 |
| onPointerClick | 当触控点在 Entity 的碰撞体范围内按下并松开,在松开时触发一次 |
| onPointerDrag | 当触控点在 Entity 的碰撞体范围内按下时持续触发,直至触控点解除按下状态 |直接调用交互管理器(InputManager)提供的方法判断按键状态。
方法名称 | 方法释义 |
---|---|
isKeyHeldDown | 返回这个按键是否被持续按住 |
isKeyDown | 返回当前帧是否按下过此按键 |
isKeyUp | 返回当前帧是否抬起过此按键 |
PointerEvent 是浏览器内鼠标与触控交互后续发展的势头,Pointer 是输入设备的硬件层抽象,开发者不需要关心数据来源是鼠标,触控板或是触摸屏,但是它也有一定兼容性问题,可以看到在 canIUse 中,PointerEvent 的设备覆盖率为:92.82 % ,需要通过导入 Polyfill 来解决。
在脚本组件中增加响应 Pointer 的钩子函数,对于在三维空间中有碰撞体积的实体,可以让开发者通过补充对应钩子函数内的逻辑方便地实现点击,拖动,选中等交互操作。
钩子函数 | 触发时机与频率 |
---|---|
onPointerEnter | 当触控点进入 Entity 的碰撞体范围时触发一次 |
onPointerExit | 当触控点离开 Entity 的碰撞体范围时触发一次 |
onPointerDown | 当触控点在 Entity 的碰撞体范围内按下时触发一次 |
onPointerUp | 当触控点在 Entity 的碰撞体范围内松开时触发一次 |
onPointerClick | 当触控点在 Entity 的碰撞体范围内按下并松开,在松开时触发一次 |
onPointerDrag | 当触控点在 Entity 的碰撞体范围内按下时持续触发,直至触控点解除按下状态 |
和 MouseEvent ,TouchEvent 一样,PointerEvent 也可以通过监听捕获。
canvas.addEventListener('pointerXXX', callBack);
MouseEvent | TouchEvent | PointerEvent | |
---|---|---|---|
按下 | mousedown | touchstart | pointerdown |
抬起 | mouseup | touchend | pointerup |
移动 | mousemove | touchmove | pointermove |
离开 | mouseout | mouseleave | touchend | touchcancel | pointerout | pointercancel | pointerleave |
可以归纳出 Pointer 处理的大致流程,其中绿框代表原生事件。
在 Pointer 中要解决的最大问题是如何根据原生事件中的位置信息在三维空间中做射线检测,因为这部分内容不仅仅包含空间转换的基本知识,还包含了物理系统的基础使用。
在我们捕获了 PointerEvent 后,需要
我们期望拿到指针相对于目标元素的位置,但是原生事件中关于坐标的属性有很多,因此需要甄别哪个坐标信息是有效的。
原生事件坐标属性 | 属性释义 |
---|---|
clientX & clientY | 相对于触发事件的应用区域的坐标(可视区域坐标) |
offsetX & offsetY | 相对于目标元素的坐标 |
pageX & pageY | 相对于整个 Document 的坐标(包含滚动区域) |
screenX & screenY | 相对于主显示屏左上角的坐标(基本不会使用) |
x & y | 同 clientX & clientY |
他们有以下的转换关系(假设原生事件为 event
,点击的目标元素为 canvas
):
可以得到的结论是:大多坐标属性都可以得到期望的坐标信息,其中 offset 最直接方便。
简化射线检测,根据从获取到屏幕上点击的坐标得到三维空间中的一条射线,然后与三维空间中碰撞体进行碰撞检测。
以透视相机为例,当获取到屏幕上点击的坐标后,只需要完成以下步骤便可得到射线:
有图形引擎基础的同学比较熟悉我们在渲染时经过了如下变换:
似乎只需要得到屏幕空间的坐标,然后再经过几个空间变换的逆变换即可。
需要对 像素(pixel), **设备独立像素(dips) **与 **设备像素比(divicePixelRatio) **有一个大致的了解,从点击事件中的属性 offset 获取的坐标信息携带的单位是 **设备独立像素 **,因此在求解屏幕空间坐标的时候需要注意分子与分母的单位一致。
裁剪空间是 XYZ 范围皆在 -1 到 1 的左手坐标系(裁剪空间可以形象地理解为当渲染范围超出这个区间就会被裁减),此处转换时需注意:
公示推导中矩阵为列为主序。
以透视相机为例,世界空间经过 View 变换和 Project 变换即可转换到裁剪空间,那么从裁剪空间转换到世界空间只需要经历这些变换的逆即可。
上式中代入近平面深度与远平面深度依次求得触摸点在世界坐标空间下近平面与远平面的投影点,连接这两个点即可得到检测射线。
碰撞体由规则几何体组成(长方体,球体等)可以查阅相关射线与几何体相交算法。
当物理引擎返回命中的碰撞体后,可以认为它的 Entity
这就是当前帧的所有onPointerXXX
回调的当事人
了,在这个环节只需要根据收集的原生事件进行脚本回调即可。
正如开篇提到的兼容性问题,如果你的项目可能运行在低系统版本的机器中,可以导入我们定制的 PointerPolyFill 。 https://github.com/galacean/polyfill-pointer-event
KeyBoardEvent 可以通过监听捕获。
canvas.addEventListener('keyXXX', callBack);
事件 | 触发时机 |
---|---|
keypress | 字符键按下时触发 |
keydown | 任意键按下时触发 |
keyup | 任意键抬起时触发 |
可以归纳出键盘处理的大致流程,其中绿框代表原生事件。
无论是在不同的大小写状态或不同键盘的布局下,按键都是一个可枚举的值,如果可以键值以枚举的形式存储,无论对性能还是使用都将带来极大的便利,因此需要确定适宜作为枚举值的属性。
以下为 KeyEvent 内可作为枚举值的属性:
属性 | 属性释义 | 简单示例 | 兼容性 |
---|---|---|---|
code | 触发事件的物理按键,与布局无关 | 无论大小写或布局,当你按下 Y 键时,返回都是物理键“KeyY” | 兼容 |
key | 触发事件的键值 | 当小写时为“y”,大写时为“Y” | 兼容 |
charCode | 已弃用 | ||
keyCode | 已弃用 | ||
char | 已弃用 |
可以发现,最适用的属性是 **code **,参考 https://w3c.github.io/uievents-code/ 。
每帧按键的交互逻辑较为简单,维护按下,松开与按住的三个数组即可满足所有需求,重点是如何降低帧级别增,删,查操作的性能损耗。
无序数组在绝大多数情况下减少了增加与删除元素时性能损耗,下图表示无序数组组成:
下图表示无序数组如何降低性能损耗:
若仅使用三个无序数组,当需要查特定按键的状态依旧需要遍历数组,在极端情况下带来的性能损耗也不可小觑,如果将此现成的按键枚举作为 Key ,当前帧的是否按下过记录成 Value 就可以避免遍历。
虽然这样实现可以让查询变得更快,却额外增加了维护成本 —— 每帧开始需要重置映射表的状态,但如果保存的是帧序号,就可以完美避免这个消耗,只需要在每帧开始的时候更新帧序号即可。
依葫芦画瓢,在记录 HeldDown 的按键时也增加了一个表来映射按键在无序数组中的索引。
按键状态 | isKeyHeldDown | isKeyDown | isKeyUp |
---|---|---|---|
该键从上帧开始就一直按着 | true | false | false |
该键当前帧按下后就没有松开 | true | true | false |
该键在当前帧松开后又按下 | true | true | true |
该键在当前帧按下后又松开 | false | true | true |
该键在当前帧被抬起 | false | false | true |
该键没按下且没交互 | false | false | false |
不会出现这种情况 | true | false | true |
不会出现这种情况 | false | true | false |