引言
交互输入是引擎功能层中十分重要的一个功能,它允许用户使用设备,触摸或手势来与应用程序进行人机交互,在 0.6 里程碑中,我们初步搭建了 Oasis 的交互系统,目前已经支持了点击与键盘,而本文将与大家分享开发过程中的思路与不足。
整体设计
主要架构
输入设备,触摸,XR 设备等都属于交互系统的输入,在 Oasis 中我们将输入的所有逻辑收拢在输入管理器(InputManager)中,根据各种不同类型的输入再细分出触控管理器(PointerManager)与键盘管理器(KeyBoardManager)等特定输入。输入管理器分管所有特定输入管理器,在交互的帧处理中,只需要处理各个管理器内特定输入的逻辑即可。
API 设计
帧内生命周期
如下是 Oasis 运行时帧处理的生命周期:
InputManager 的内部生命周期如下:
如何使用
Pointer
- 为三维空间内有碰撞体积的物体增加碰撞体。
- 参考
脚本组件(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 来解决。
需求调研
在脚本组件中增加响应 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 最直接方便。
空间转换
简化射线检测,根据从获取到屏幕上点击的坐标得到三维空间中的一条射线,然后与三维空间中碰撞体进行碰撞检测。
以透视相机为例,当获取到屏幕上点击的坐标后,只需要完成以下步骤便可得到射线:
- offset -> 屏幕空间
- 屏幕空间 -> 裁剪空间
- 裁剪空间 -> 世界空间
有图形引擎基础的同学比较熟悉我们在渲染时经过了如下变换:
- 模型空间 -> 世界空间
- 世界空间 -> 观察空间 -> 裁剪空间
- 裁剪空间 -> 屏幕空间
似乎只需要得到屏幕空间的坐标,然后再经过几个空间变换的逆变换即可。
offset -> 裁剪空间
需要对 像素(pixel), 设备独立像素(dips与 设备像素比(divicePixelRatio有一个大致的了解,从点击事件中的属性 offset 获取的坐标信息携带的单位是 设备独立像素,因此在求解屏幕空间坐标的时候需要注意分子与分母的单位一致。
裁剪空间是 XYZ 范围皆在 -1 到 1 的左手坐标系(裁剪空间可以形象地理解为当渲染范围超出这个区间就会被裁减),此处转换时需注意:
- 求解触摸点在屏幕空间的相对位置时要注意分子与父母应都为像素或都为设备独立像素。
- 裁剪空间 Y 轴方向向上,offset 参考坐标系 Y 轴方向向下,因此 Y 轴需翻转。
- 裁剪空间中 depth 离观察者越远值越大,简单来说近平面是 -1 远平面是 1 。
屏幕空间的点 -> 世界空间的射线
公示推导中矩阵为列为主序。
以透视相机为例,世界空间经过 View 变换和 Project 变换即可转换到裁剪空间,那么从裁剪空间转换到世界空间只需要经历这些变换的逆即可。
检测射线
上式中代入近平面深度与远平面深度依次求得触摸点在世界坐标空间下近平面与远平面的投影点,连接这两个点即可得到检测射线。
射线相交检测
碰撞体由规则几何体组成(长方体,球体等)可以查阅相关射线与几何体相交算法。
脚本回调
当物理引擎返回命中的碰撞体后,可以认为它的 Entity 这就是当前帧的所有onPointerXXX回调的当事人了,在这个环节只需要根据收集的原生事件进行脚本回调即可。
性能优化
- 压流: 捕获 PointerEvent 后将原生事件压入数组,待执行到交互系统的 tick 时,再按序处理相应逻辑。
- Pointer 合并: 射线检测的性能损耗较大,所以在屏幕上有多个触控点时,我们会按照一定规则合并这几个触控点,因此在触控交互逻辑中每帧的射线检测至多只会执行一次。
- 多相机场景: 当出现多相机时,会依次检查渲染范围包含了点击点的所有相机,并根据相机的渲染顺序进行排序(后渲染优先),如果当前比较的相机渲染场景内没有命中碰撞体且相机的背景透明,点击事件会继续传递至上一个渲染的相机,直至命中或遍历完所有相机。
注意事项
正如开篇提到的兼容性问题,如果你的 Oasis 项目可能运行在低系统版本的机器中,可以导入我们定制的 PointerPolyFill 。
github.com/oasis-engin…
键盘输入
需求调研
原生事件
KeyBoardEvent 可以通过监听捕获。
canvas.addEventListener('keyXXX', callBack);
| 事件 | 触发时机 |
|---|---|
| keypress | 字符键按下时触发 |
| keydown | 任意键按下时触发 |
| keyup | 任意键抬起时触发 |
流程图
可以归纳出键盘处理的大致流程,其中绿框代表原生事件。
索引值的选定
无论是在不同的大小写状态或不同键盘的布局下,按键都是一个可枚举的值,如果可以键值以枚举的形式存储,无论对性能还是使用都将带来极大的便利,因此需要确定适宜作为枚举值的属性。
以下为 KeyEvent 内可作为枚举值的属性:
| 属性 | 属性释义 | 简单示例 | 兼容性 |
|---|---|---|---|
| code | 触发事件的物理按键,与布局无关 | 无论大小写或布局,当你按下 Y 键时,返回都是物理键“KeyY” | 兼容 |
| key | 触发事件的键值 | 当小写时为“y”,大写时为“Y” | 兼容 |
| charCode | 已弃用 | ||
| keyCode | 已弃用 | ||
| char | 已弃用 |
可以发现,最适用的属性是 code,参考 w3c.github.io/uievents-co… 。
性能优化
每帧按键的交互逻辑较为简单,维护按下,松开与按住的三个数组即可满足所有需求,重点是如何降低帧级别增,删,查操作的性能损耗。
无序数组
无序数组在绝大多数情况下减少了增加与删除元素时性能损耗,下图表示无序数组组成:
下图表示无序数组如何降低性能损耗:
存储与索引
若仅使用三个无序数组,当需要查特定按键的状态依旧需要遍历数组,在极端情况下带来的性能损耗也不可小觑,如果将此现成的按键枚举作为 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 |
注意事项
- 当按住某个按键持续一段时间时,原生的 keydown 事件会不断触发, Oasis 已经考虑并过滤了此情形,所以开发者无需做任何额外处理。
- 某些状态按键的原生事件表现可能比较怪异,甚至在 FireFox 和 Chrome 上触发事件的表现都不一致。(如 Caps Lock)
如何进一步了解我们
Oasis 开源社区群 (钉钉):
Oasis 开源社区群管理员 (微信):
网站
官网地址
oasisengine.cn
Git 源码地址
github.com/oasis-engin…