Web方向传感器的正确使用姿势

少女dtysky

世界Skill

时刻2018.06.25

现在移动设备普遍提供了重力传感器和方向传感器来测量设备方向,HTML5标准也提供了标准的API来进行方向信息的获取,获取方式是通过两个事件,他们分别是“orientationchange”和“deviceorientation”。

规范可以参考DeviceOrientation事件规范

DEMO + NPM

我封装了一个模块用以方便得使用重力传感器和陀螺仪。

DEMO在:PanoramaImageViewer

NPM包:DeviceOrientationManager

Orientation Change

监听此事件可以获得设备当前的屏幕方向,通过window.orientation获取,正常竖屏为0,按顺时针渲染依次为:0 -> 90 -> 180 -> -90。

Device Orientation

监听此事件可以获取当前的设备的三个偏转角alphabetagamma

基础

本质上重力传感器和方向传感器都是派生传感器。重力传感器基于加速度传感器;方向传感器主要基于陀螺仪和磁力计,但主要还是基于陀螺仪。

陀螺仪本质上是用于测量角速度的仪器,其原理和我们日常生活中的陀螺基本一致——在陀螺高速旋转时,若没有外加力矩,其总是围绕一个固定转轴稳定转动(转动惯量大),这被称为定轴性,这也是陀螺仪最重要的一个特性,其基于角动量守恒原理。日常中的陀螺其实是一个具有两个自由度的偏轴陀螺仪,而在实际运用中的则是多为三个自由度的定轴陀螺仪,我们只要给不同的轴框不同的力,就可以使得对应的轴框转动,从而测出不同的姿态角:

undefined

移动设备上的陀螺仪基本都是MEMS(微机械)陀螺仪。对于HTML5标准,设备方向官方定义的坐标系为:

undefined

这其实是一个Z-UP的左手坐标系,而其中三个角度的定义为绕三个轴旋转的欧拉角:

  1. 围绕 Z 轴的旋转。当设备的顶部指向正北时 alpha 值为 0°。 当设备逆时针旋转时,alpha 值增加。

undefined

  1. 围绕 X 轴的旋转。当设备的顶部和底部与地球表面等距时 beta 值为 0°。 当设备的顶部倾向地球表面时,此值增加。

undefined

  1. 围绕 Y 轴的旋转。当设备的左右边缘与地球表面等距时 gamma 值为 0°。 当设备的右侧倾向地球表面时,此值增加。

undefined

实践

从W3C标准来看,alpha的角度应该是 0 ~ 360,beta是 -180 ~ 180,gamma是 -90 ~ 90,并且这个变化应当是平滑的,但真实情况确实如此吗?

以下符号->表示逐渐变化,比如0 -> 180表示从0度逐渐变化到180度,|>表示瞬变,比如360 |> 0表示从360瞬变0。

alpha:0 -> 360 >| 0 度,设备顶部朝向正北时为0度,注意在X轴旋转到竖屏的边界时会有强烈抖动。 beta:其值的变化和手机顶部朝向有关。顶部背向用户时,屏幕水平向上为0度,面向用户旋转每九十度变化为 0 -> 180 |> -180 -> 0;顶部面向用户时,面向用户旋转每九十度变化为 0 -> -180 |> 180 -> 0。 gamma:其值的变化和手机顶部朝向有关。顶部背向用户时,水平向上时为0度,顺时针旋转每九十度变化为0 -> 90 |> -90 -> 0 -> 90 |> -90 > 0度;顶部面向用户时,水平 0 -> -90 |> 90 -> 0 -> -90 |> 90 -> 0。

Note: 这个是三星S9 Chrome实机测试结果,不同设备可能各有差异,比如实机测试和chrome sensors功能模拟的结果中alpha和gamma的变化就有所不同,这个非常坑爹,后面会详细说道。

实际换算

然而一般的实时渲染引擎(尤其是游戏引擎)使用的坐标系与以上坐标系不同,它们中的大多数都是使用Y-UP右手坐标系的,所以以下为基准,即:

屏幕朝向用户,横向为x轴、右侧为正,纵向为y轴、上侧为正,屏幕朝向方向为z轴、朝向屏幕外侧为正。

不仅如此,在需要使用到方向传感器的应用中(VR、AR),我们一般是使用其去控制一个场景中观察世界的相机,相机自身的坐标系的一般初始化为以屏幕正向面对用户时的Y-UP右手坐标系

undefined

此时设备纵向旋转绕X轴(俯仰角pitch),横向旋转绕Y轴(偏航角yaw),剩下一个维度即为Z轴(翻滚角roll):

undefined

这么一对比,不难发现当设备在不同朝向时摄像机的坐标系和方向传感器定义的坐标系其实是有一些差异的,他们存在一些对应关系。在下面,我将用pitchyawroll为三个偏转角,给出它们与alphabetagamma的关系。

window.orientation = 0

渲染场景竖屏,设备顶部朝上。

pitch = beta;
yaw = alpha;
roll = -gamma;

window.orientation = 90

渲染场景横屏,设备顶部朝左。

在0度基础上,将整个物体坐标系绕Z轴旋转-90度。

window.orientation = 180

渲染场景竖屏,设备顶部朝下。

在0度基础上,将整个物体坐标系绕Z轴旋转-180度。

window.orientation = -90

渲染场景横屏,设备顶部朝右。

在0度基础上,将整个物体坐标系绕Z轴旋转90度。

设备差异

原则上来讲,以上的换算非常完美,但这是建立在所有角度都正常,也就是一个循环一个正常的周期(0 -> 360 -> 0)的情况下的。当然,工程实践和理想状况总是有巨大的差异,有差异怎么办?还能怎么办,解决呗......

首先,经过测试后,发现设备普遍差异化为两种,第一种就是一开始的实机测试,而第二种(比如Chrome模拟器)中,alpha的变化是 0 -> -180 |> 180 -> 0,gamma变化是 0 -> 90 -> 0 -> -90 -> 0。这就导致在有的设备下alpha的值很完美,有的设备下gamma的值比较好,这就很尴尬了。

这就导致我要做一些兼容,首先让我们看看我们期望的三个偏转角和设备实机偏转(以Z-UP坐标系为准)的函数图像,以及它们实际的函数图像:

  1. 第一种设备:

undefined

  1. 第二种设备:

undefined

是不是发现都有挺大的很大差异?但其实也没什么差异,因为在正常使用中,方向传感器返回的三个欧拉角是互有关联的,比如第一种设备虽然看起来有很大的突变,但其实其突变的画风是这样的:

{alpha: -10, beta: 40, gamma: -90} -> {alpha: 170, beta: 170, gamma: 90}

这两对欧拉角在三维旋转中其实是等价的。

滤波去抖

真实世界中总是会有抖动的,所以我们需要一定的滤波算法来去抖。当然,不仅是alpha,其他两个角度也可以用同样的算法使得更加平滑,在这里我们一般使用LPF(低通滤波器,这里其实是一个拥有一定大小窗口的FIR实现的,学过信号处理的同学是不是很熟悉www)来滤除,用于滤除高频噪声(突变),其本质是加权平均滤波,会有一定的迟滞,窗口越大迟滞越大,但在一定范围内可以接受,同时注意smoothing参数,其越大表明当前值对结果影响越大。

const result = buffer.reduce(
  (last, current) => smoothing * current + (1 - smoothing) * last,
  removed
);

理想状况下,只需要对原始角度进行单个数字的滤波即可,即窗口中存的是[alpha1, alpha2, alpha3, alpha4, ...],然而考虑前面的分析,所有角度都可能出现瞬变 360 <-> 0,虽然在角度层面上 360 和 0度是等价的,但对于滤波则不是。在滤波过程中,由于本方案涉及到加权平均值的计算,会导致当前值和实际值有一定的迟滞,所以在360 -> 0 度这种情境下,计算值看起来像是出现了几次的插值,比如 360 -> 280 -> ... -> 0,这会严重影响实际使用。解决方案也不是没有,考虑相隔一个周期(2π)两个角度,它们的sin(angle)cos(angle)的值完全相等且这两个值可以确定在一个周期的定义域内的唯一角度。所以我们不直接对角度进行滤波,而是先变换为sin和cos进行滤波,最后再通过atan2(y, x)这个值域为-180 ~ 180的函数进行反变换即可。以下是atan2(cos(angle), sin(angle))这个函数的图像(弧度):

undefined

atan2的值域是-180 ~ 180度,正好在一个周期,所以结果就是将alphabeta归一化到了-180 ~ 180度内,而我们实际使用时一般会用正弦和预先函数处理,所以完全不影响使用。而对于gamma,其值域和原来保持一致。

突变注意

以上滤波可以解决大多数情况下的抖动,但对于《设备差异》一节最后的反转补角突变,它也无能为力,我们可以设定低一些的获取频率、或者调高当前值得权重来解决这个问题。如果要求再高一些,建议关闭上面的滤波,自己去对四元数进行平滑。

和Camera结合

对于应用最为广泛的方向传感器 + 3D摄像头组合,直接将pitchyawroll运用到就上旋转举矩阵或者四元数上即可:

const zee = new Vector3(0, 0, 1);
const screenTransform = new Quaternion();
const worldTransform = new Quaternion(-Math.sqrt(0.5), 0, 0, Math.sqrt(0.5));

const euler = new Euler();
// order = 'YXZ'
euler.order = order;
euler.set(pitch, yaw, roll, order);

camera.quaternion.setFromEuler(euler);
camera.quaternion.multiply(worldTransform);
camera.quaternion.multiply(screenTransform.setFromAxisAngle(zee, -orientation));

如果不是自己的创作,少女是会标识出来的,所以要告诉别人是少女写的哦。