游戏世界的主摄像机就是我们在游戏里的“眼睛”,为了让“眼睛”能够与手持 iPad 的我们保持协调,跟随着我们自己转身而转动,好像我们自己就站在游戏世界里用自己的眼睛观察游戏世界一样,这里就需要解决几个数学问题。
既然要旋转主摄像机,那么首先需要找到旋转的参照,因为我们旋转主摄像机的目的是为了观察游戏世界,那么这个参照自然选取游戏世界坐标系。主摄像机的 +Z 轴朝向就是我们通过 iPad 屏幕观察游戏世界时“眼睛”的观察方向,因此,只要我们能够找到主摄像机 +Z 轴方向向量与游戏世界坐标系下某个固定方向向量之间的角度关系,我们就可以通过 Quaternion(四元数,Unity 3D 里用四元数来描述空间旋转)来将主摄像机旋转到我们所期望的任意方位。
上一节里说到重力感应的方向分量,iPad 接受到的重力输入是永远指向真实世界重力方向的,那么,假如我们创造的是一个正常的游戏世界的话,重力输入的方向其实就是游戏世界的 -Y 坐标轴方向,那么其反方向就是 +Y 轴方向。顺理成章,我们旋转主摄像机所需要的“游戏世界坐标系下某个固定方向向量”就是这个重力输入方向了。但是在计算主摄像机 +Z 轴方向与重力输入方向之间角度关系之前,还需要一个小换算,因为重力输入方向是以 iPad 屏幕坐标系度量的,而 iPad 屏幕坐标系与主摄像机自身坐标系之间 Z 轴是反向的,所以需要进行一次坐标变换,将重力输入方向向量从 iPad 屏幕坐标系变换到主摄像机坐标系。变换方法很简单:
Vector3 g = new Vector3(Input.acceleration.x, Input.acceleration.y, -Input.acceleration.z);
现在我们可以计算主摄像机 +Z 轴方向向量(0, 0, 1)与 g 之间的夹角,进而计算出主摄像机 +Z 轴方向向量变换到游戏世界坐标系下时的方向向量。这一扒拉计算需要用到球坐标系,寻找球坐标系坐标(r, θ, φ)对应直角坐标系下坐标点的步骤为:从原点出发,向 +Z 方向前进 r,然后依据右手定则,大拇指指向 +Y 轴方向,向其他四指的握转方向旋转 θ 角度,再依据右手定则,大拇指指向 +Z 轴方向,向其他四指握转方向旋转 φ 角度。如下图:
主摄像机 +Z 轴方向向量与 g 的夹角就是 π-θ,换言之 θ=π-夹角。现在这里出现了一个问题:θ 是知道了,那 φ 在哪?非常遗憾,如果我们用来定位的依据只有重力输入方向这一项数据的话,无法确定 φ。其实 θ 就是“天顶角”,也就是俯仰角,φ 是“方位角”,重力方向只能帮助我们确定俯仰角,方位角它是无能为力的,这需要借助罗盘或者陀螺仪。这在后文中会进一步解说,现在我们只需要先给 φ 定一个定值即可,比如 240度。
仔细观察上图中的坐标系,又会发现一个问题:这个坐标系与我们使用的游戏世界坐标系长的不一样啊?没错,这个坐标系的 +Z 轴是游戏世界坐标系的 +Y 轴,它的 +Y 轴是游戏世界坐标系的 +Z 轴,刚好 +Z 和 +Y 轴调了一个个儿。没有关系,只需要将 x,y,z 的计算公式调整一下就行:
x = r sinθ cosφ y = r cosθ z = r sinθ sinφ |
代码也很简单:
using UnityEngine; using System.Collections; using System; public class GSensorContoller : MonoBehaviour { private Transform myTransform = null; private float x = 0.0f; private float y = 0.0f; private float z = 0.0f; private Vector3 g; private float theta = 0.0f; private float phi = 0.0f; void Awake() { myTransform = transform; } // Use this for initialization void Start() { } // Update is called once per frame void Update() { Vector3 g = new Vector3(Input.acceleration.x, Input.acceleration.y, -Input.acceleration.z); theta = Mathf.PI - Mathf.PI * Vector3.Angle(g, Vector3.forward) / 180.0f; phi = Mathf.PI * 240.0f / 180.0f; x = Mathf.Sin(theta) * Mathf.Cos(phi); y = Mathf.Cos(theta); z = Mathf.Sin(theta) * Mathf.Sin(phi); Quaternion targetRotation = Quaternion.LookRotation(new Vector3(x, y, z), Vector3.up); myTransform.rotation = targetRotation; } void OnGUI() { GUI.Box(new Rect(5, 5, 500, 25), String.Format("G x:{0:0.000},y:{1:0.000},z:{2:0.000}", g.x, g.y, g.z)); GUI.Box(new Rect(5, 35, 500, 25), String.Format("theta:{0:0.000},phi:{1:0.000}", theta, phi)); GUI.Box(new Rect(5, 65, 500, 25), String.Format("Camera face x:{0:0.000},y:{1:0.000},z:{2:0.000}", x, y, z)); GUI.Box(new Rect(5, 95, 500, 25), String.Format("Camera rotate x:{0:0.000},y:{1:0.000},z:{2:0.000}", myTransform.rotation.eulerAngles.x, myTransform.rotation.eulerAngles.y, myTransform.rotation.eulerAngles.z)); } }
在这段代码的作用下,已经能够让游戏里的主摄像机响应 iPad 的方位变化,虽然只是俯仰方向。但是在折腾 iPad 的时候会发现,只有抬起和放下 iPad 让俯仰角发生变化时,游戏画面会有响应,而像手握方向盘那样旋转 iPad 时,游戏画面保持不变,这看上去就比较怪异。我们期望的是:像手握方向盘那样旋转 iPad 时,画面向反方向旋转,不管 iPad 转成什么样,画面里的游戏世界始终与手持 iPad 的人保持相对静止。这一点,即便是只有重力输入,也能做到,只需要在 iPad 旋转时,将主摄像机向反方向绕 Z 轴旋转即可,空间中绕 Z 轴的旋转被称为 Roll,所以——
首先需要知道的是 iPad 到底旋转了多少角度,这同样需要找一个相对固定不变的参照。我们同样可以利用重力输入方向向量,只不过这一次需要的不只是一个向量,而是重力输入向量与屏幕坐标系 Z 轴形成的平面。因为这里处理的情况是 iPad 绕屏幕坐标系的 Z 轴旋转,因此 Z 轴方向和重力输入方向都可以认为是恒定不变的,也就是说由这两个方向向量决定的平面 GOZ 是不变的。那么 iPad 旋转的角度就是屏幕坐标系 Z 轴方向向量和 Y 轴方向向量决定的平面 YOZ 与参照平面 GOZ 的夹角。
然后我们需要做的就是数学。希望看到这里的时候你还能够回想起高中和大学学过的空间解释几何。两个平面之间的夹角大小等于两个平面法向量之间的夹角大小。一个直角坐标方程为 ax + by + cz + d = 0 的平面,其法向量之一就是 (a, b, c)。经过不共线三点 (x0, y0, z0) (x1, y1. z1) (x2, y2. z2) 的平面的直角坐标方程可以通过下图的行列式求得:
至于如何求两向量的夹角,可以利用 Unity 3D 提供的函数:Vector3.Angle():
Vector3.Anglestatic function Angle (from : Vector3, to : Vector3) : floatDescriptionReturns the angle in degrees between from and to. |
也可以自己用下图的公式求解:
平面 GOZ 可以认为它经过三个点 Z(0, 0, 1),O(0, 0, 0),G(x1, y1, z1),通过行列式计算得到平面的直角坐标方程 y1x - x1y = 0,其法向量为 (y1, -x1, 0);平面 YOZ 可以认为它经过三个点 Z(0, 0, 1),O(0, 0, 0),Y(0, 1, 0),通过行列式计算得到平面的直角坐标方程 x = 0,其法向量为 (1, 0, 0)。进一步算得 cosα = y1 / sqrt(y1 * y1 + (-x1) * (-x1))。这里 α 的取值范围是 [0, π],而我们求两个平面的夹角取值范围应该是 [0, π/2],所以我们想要的是 α = arccos(|y1 / sqrt(y1*y1 + x1 * x1)|)。接下来转换为代码:
using UnityEngine; using System.Collections; using System; public class GSensorContoller : MonoBehaviour { private Transform myTransform = null; private float x = 0.0f; private float y = 0.0f; private float z = 0.0f; private Vector3 g; private float theta = 0.0f; private float phi = 0.0f; private float turnAngle = 0.0f; void Awake() { myTransform = transform; } // Use this for initialization void Start() { } // Update is called once per frame void Update() { Vector3 g = new Vector3(Input.acceleration.x, Input.acceleration.y, -Input.acceleration.z); theta = Mathf.PI - Mathf.PI * Vector3.Angle(g, Vector3.forward) / 180.0f; phi = Mathf.PI * 240.0f / 180.0f; x = Mathf.Sin(theta) * Mathf.Cos(phi); y = Mathf.Cos(theta); z = Mathf.Sin(theta) * Mathf.Sin(phi); Quaternion targetRotation = Quaternion.LookRotation(new Vector3(x, y, z), Vector3.up); float temp = Mathf.Sqrt(g.x * g.x + g.y * g.y); if (temp != 0) turnAngle = Mathf.Acos(Mathf.Abs(g.y) / temp); else turnAngle = 0.0f; Quaternion targetTurn = Quaternion.AngleAxis(- turnAngle, Vector3.forward); myTransform.rotation = targetRotation * targetTurn; } void OnGUI() { GUI.Box(new Rect(5, 5, 500, 25), String.Format("G x:{0:0.000},y:{1:0.000},z:{2:0.000}", g.x, g.y, g.z)); GUI.Box(new Rect(5, 35, 500, 25), String.Format("theta:{0:0.000},phi:{1:0.000},turn:{2:0.000}", theta, phi, turnAngle)); GUI.Box(new Rect(5, 65, 500, 25), String.Format("Camera face x:{0:0.000},y:{1:0.000},z:{2:0.000}", x, y, z)); GUI.Box(new Rect(5, 95, 500, 25), String.Format("Camera rotate x:{0:0.000},y:{1:0.000},z:{2:0.000}", myTransform.rotation.eulerAngles.x, myTransform.rotation.eulerAngles.y, myTransform.rotation.eulerAngles.z)); } }
上面这段代码当我们手持 iPad 在屏幕坐标系 +X 轴一侧旋转时一切正常,但到了 -X 轴一侧时却出现了问题——摄像机转向反了。这是因为主摄像机的转向也是有方向的,虽然在 -X 侧也是 GOZ 面和 YOZ 面夹角增大,但转向却与 +X 侧是相反的。要解决这个问题有多种办法,可以在计算两平面夹角时采用转角而不是夹角,更简单的办法是在 -X 侧和 +X 侧时分别对角度的正负做修正。代码调整如下:
using UnityEngine; using System.Collections; using System; public class GSensorContoller : MonoBehaviour { private Transform myTransform = null; private float x = 0.0f; private float y = 0.0f; private float z = 0.0f; private Vector3 g; private float theta = 0.0f; private float phi = 0.0f; private float turnAngle = 0.0f; void Awake() { myTransform = transform; } // Use this for initialization void Start() { } // Update is called once per frame void Update() { Vector3 g = new Vector3(Input.acceleration.x, Input.acceleration.y, -Input.acceleration.z); theta = Mathf.PI - Mathf.PI * Vector3.Angle(g, Vector3.forward) / 180.0f; phi = Mathf.PI * 240.0f / 180.0f; x = Mathf.Sin(theta) * Mathf.Cos(phi); y = Mathf.Cos(theta); z = Mathf.Sin(theta) * Mathf.Sin(phi); Quaternion targetRotation = Quaternion.LookRotation(new Vector3(x, y, z), Vector3.up); float temp = Mathf.Sqrt(g.x * g.x + g.y * g.y); if (temp != 0) turnAngle = Mathf.Acos(Mathf.Abs(g.y) / temp); else turnAngle = 0.0f; turnAngle = turnAngle * 180.0f / Mathf.PI * (g.x > 0.0f ? 1.0f : -1.0f); Quaternion targetTurn = Quaternion.AngleAxis(- turnAngle, Vector3.forward); myTransform.rotation = targetRotation * targetTurn; } void OnGUI() { GUI.Box(new Rect(5, 5, 500, 25), String.Format("G x:{0:0.000},y:{1:0.000},z:{2:0.000}", g.x, g.y, g.z)); GUI.Box(new Rect(5, 35, 500, 25), String.Format("theta:{0:0.000},phi:{1:0.000},turn:{2:0.000}", theta, phi, turnAngle)); GUI.Box(new Rect(5, 65, 500, 25), String.Format("Camera face x:{0:0.000},y:{1:0.000},z:{2:0.000}", x, y, z)); GUI.Box(new Rect(5, 95, 500, 25), String.Format("Camera rotate x:{0:0.000},y:{1:0.000},z:{2:0.000}", myTransform.rotation.eulerAngles.x, myTransform.rotation.eulerAngles.y, myTransform.rotation.eulerAngles.z)); } }
转向基本上正确了,但是画面抖动得很厉害。仔细观察重力输入的数值可以发现,数值在小数点后第二位之后很不稳定,即便是将 iPad 静置数值的变化也不会停止,这就导致后续计算的转角也频繁发生变化,摄像机抖动。要解决这个问题可以从两个方向出发,第一是降低对重力输入取值的精度,只有当重力输入数值该变量达到一定程度时才更新重力方向数值;第二是在摄像机旋转过程中加入线性插值,减缓摄像机旋转的速度,虽然这样会导致摄像机的反应延时,但同时能平稳摄像机的旋转。这两种方法互补干扰,可以同时采用:
using UnityEngine; using System.Collections; using System; public class GSensorContoller : MonoBehaviour { public float RotateSpeed = 1.0f; private Transform myTransform = null; private float x = 0.0f; private float y = 0.0f; private float z = 0.0f; private Vector3 g; private float theta = 0.0f; private float phi = 0.0f; private float turnAngle = 0.0f; void Awake() { myTransform = transform; } // Use this for initialization void Start() { } // Update is called once per frame void Update() { Vector3 g1 = new Vector3(Input.acceleration.x, Input.acceleration.y, -Input.acceleration.z); if (Vector3.Angle(g, g1) > 1.0f) g = g1; theta = Mathf.PI - Mathf.PI * Vector3.Angle(g, Vector3.forward) / 180.0f; phi = Mathf.PI * 240.0f / 180.0f; x = Mathf.Sin(theta) * Mathf.Cos(phi); y = Mathf.Cos(theta); z = Mathf.Sin(theta) * Mathf.Sin(phi); Quaternion targetRotation = Quaternion.LookRotation(new Vector3(x, y, z), Vector3.up); float temp = Mathf.Sqrt(g.x * g.x + g.y * g.y); if (temp != 0) turnAngle = Mathf.Acos(- g.y / temp); else turnAngle = 0.0f; turnAngle = turnAngle * 180.0f / Mathf.PI * (g.x > 0.0f ? 1.0f : -1.0f); Quaternion targetTurn = Quaternion.AngleAxis(- turnAngle, Vector3.forward); myTransform.rotation = Quaternion.Slerp(myTransform.rotation, targetRotation * targetTurn, Time.deltaTime * rotateSpeed); } void OnGUI() { GUI.Box(new Rect(5, 5, 500, 25), String.Format("G x:{0:0.000},y:{1:0.000},z:{2:0.000}", g.x, g.y, g.z)); GUI.Box(new Rect(5, 35, 500, 25), String.Format("theta:{0:0.000},phi:{1:0.000},turn:{2:0.000}", theta, phi, turnAngle)); GUI.Box(new Rect(5, 65, 500, 25), String.Format("Camera face x:{0:0.000},y:{1:0.000},z:{2:0.000}", x, y, z)); GUI.Box(new Rect(5, 95, 500, 25), String.Format("Camera rotate x:{0:0.000},y:{1:0.000},z:{2:0.000}", myTransform.rotation.eulerAngles.x, myTransform.rotation.eulerAngles.y, myTransform.rotation.eulerAngles.z)); } }
上面代码中判断当两次重力输入方向的夹角超过 1 度时才更新重力输入数据,而且通过球形插值来平滑摄像机的转动。现在感觉就好多了,虽然当 iPad 转过天顶和脚底这两个特殊位置时摄像机的旋转与预想的有出入,但基本上达成了我们的目的。更细致的调整可以放在加入了罗盘数据输入之后由罗盘数据替代重力感应数据完成转角的计算。