相机的控制无非移动和旋转,每种操作各3个轴6个方向,一共12种方式。在某些需要快速验证的项目或Demo里常常需要丝滑的控制相机调试效果。相机控制虽然不是什么高深的技术,但是要写的好用还是很磨人的。
锁定Z轴的旋转
一个自由的相机可以绕 X,Y,Z 轴旋转,正常情况下用6个按键加上 transform.Rotate
api 就可以搞定了。这里要注意的是要使用本地坐标系,transform.Rotate
默认就是本地坐标系。比如我们可以用上下左右方向键和鼠标左键来控制相机的旋转。
void Update()
{if (Input.GetMouseButton(0)){var axis_x = Input.GetAxis("Mouse X") * 10;var axis_y = Input.GetAxis("Mouse Y") * 10;transform.localRotation *= Quaternion.Euler(-axis_y, axis_x, 0);}if (Input.GetKey(KeyCode.UpArrow)){transform.Rotate(Vector3.left, Time.deltaTime * 100);}else if (Input.GetKey(KeyCode.DownArrow)){transform.Rotate(Vector3.right, Time.deltaTime * 100);}if (Input.GetKey(KeyCode.LeftArrow)){transform.Rotate(Vector3.down, Time.deltaTime * 100);}else if (Input.GetKey(KeyCode.RightArrow)){transform.Rotate(Vector3.up, Time.deltaTime * 100);}
}
但是在某些情况下我希望实现一种类似第一人称的视角,既相机可以左右看,上下看,但是不能歪头,也就是要锁定 Z 轴的旋转。即便上面我们没有 Z 方向的旋转,但是实际上 X 轴和 Y 轴的旋转也会引入 Z 轴的旋转,让人感觉相机极难控制,在不添加 Z 轴旋转的情况下,相机很容易就歪了,还很难正回来。比如我们按上左下右的顺序旋转相机,当相机回到原点时,镜头已经歪到姥姥家了。
锁定 Z 轴旋转就是把 Z 向角度设置为0,我们添加一个锁定 Z 轴的函数。
private void LockZRotate()
{var euler = transform.eulerAngles;euler.z = 0;transform.eulerAngles = euler;
}
然后在 Update
的最后调用 LockZRotate
即可。但是这样也会有问题,绕 X 轴的旋转在 ±90° 范围内是正常的,一旦到达 90°,Z 轴向正上或正下,再继续转就转不动了,视角会向电风扇一样疯转旋转。
虽然我们没有旋转 Z 轴,但是 Unity 会根据旋转重新解算欧拉角,这种情况下继续旋转,经过 Unity 的解算,Z 轴上的角度就不是 0 了,但是我们又立刻将 Z 轴的角度置 0 了,导致 Unity 无法继续旋转,最终变成了直升机效果。
要迈过这道坎我们可以用世界坐标系去旋转。也就是给 Rotate
函数加上 Space.World
参数,对于鼠标旋转的情况,只需要将四元数的乘法顺序调换一下就可以了。
void Update()
{if (Input.GetMouseButton(0)){var axis_x = Input.GetAxis("Mouse X") * 10;var axis_y = Input.GetAxis("Mouse Y") * 10;transform.rotation = Quaternion.Euler(-axis_y, axis_x, 0) * transform.rotation;}if (Input.GetKey(KeyCode.UpArrow)){transform.Rotate(Vector3.left, Time.deltaTime * 100, Space.World);}else if (Input.GetKey(KeyCode.DownArrow)){transform.Rotate(Vector3.right, Time.deltaTime * 100, Space.World);}if (Input.GetKey(KeyCode.LeftArrow)){transform.Rotate(Vector3.down, Time.deltaTime * 100, Space.World);}else if (Input.GetKey(KeyCode.RightArrow)){transform.Rotate(Vector3.up, Time.deltaTime * 100, Space.World);}LockZRotate();
}
Rotate
函数 Space.World
参数是指定旋转轴的坐标空间的,Vector3.up
+ Space.World
相当于 transform.up
+ Space.Self
。
可以看到可以正常绕 X 轴旋转超过 90°,而且相机始终是正的,天空始终在画面上面。似乎是正常了,严格来说是当相机的 X 轴和世界的 X 轴重合的时候是正常的,也就是说其实还是不对。
当我们先绕 Y 轴旋转 90° 后,此时相机的 Z 轴与世界的 X 轴重合,此时当我们再想绕 X 轴旋转时,但实际上面的代码变成了绕相机的 Z 轴旋转,但是 Z 轴的旋转被我们锁定了,根本转不动,于是相机 X 方向的旋转就被锁死在这里了。这只是最极端的情况,事实上当相机的 X 轴偏离世界的 X 轴时,X 方向的旋转就都不正常了。
有一种办法是把 X 轴的旋转限制在 ±90° 范围内,也就是不让人“倒立”。可是妥协不是我想要的,我想要倒立,倒立过去之后还要保持镜头是正的。
回到最初按上左下右顺序旋转相机的例子,当我们在编辑模式下的 Inspector 面板中重复这个操作时,一切却很正常,相机回到了原点,镜头也没有歪。
唉?什么情况?
这并不是什么玄学,问题还是那个问题,Unity 会重新解算欧拉角。当我们在 Inspector 面板里面操作时,转哪个轴就只转那一个轴,不会重新解算,也不会动到其他轴,井水不犯河水。
这就意味这我们也可以模拟这个过程,手动记录下相机的初始欧拉角,然后转哪个轴就加减哪个角,最后将欧拉角赋值给相机就可以了。让我们重新写一个函数来专门负责旋转。
public Vector3 euler;
private void RotateTransformAngle(float x = 0, float y = 0, float z = 0)
{euler.x = (euler.x + x) % 360;euler.y = (euler.y + y) % 360;euler.z = 0;transform.localEulerAngles = euler;
}
然后将旋转也替换成这个函数,RotateTransformAngle
函数已经锁定了 Z 轴,所以 LockZRotate
函数也不用再调用了。
void Update()
{if (Input.GetMouseButton(0)){var axis_x = Input.GetAxis("Mouse X") * 10;var axis_y = Input.GetAxis("Mouse Y") * 10;RotateTransformAngle(-axis_y, axis_x);}if (Input.GetKey(KeyCode.UpArrow)){RotateTransformAngle(x: -Time.deltaTime * 100);}else if (Input.GetKey(KeyCode.DownArrow)){RotateTransformAngle(x: Time.deltaTime * 100);}if (Input.GetKey(KeyCode.LeftArrow)){RotateTransformAngle(y: -Time.deltaTime * 100);}else if (Input.GetKey(KeyCode.RightArrow)){RotateTransformAngle(y: Time.deltaTime * 100);}//LockZRotate();
}
现在上左下右确实没问题了,镜头不会再歪了,但是新的问题也出现了。当相机绕 X 轴旋转 180° 时,我们真的“倒立”了,不能说没有歪,简直歪到极点了。
要让相机镜头始终是正的,实际上等价于让相机的 Y 轴始终朝上,可以把 Y 轴想象成人的头,所谓的“正”,也就是人头冲上。有什么东西是始终朝上的吗?当然有,那就是世界的 Y 轴。我们可以加一个判断,当相机的 Y 轴和世界的 Y 轴反向时,将相机的 Y 轴反转,我们可以使用点乘来实现这个判断。
private void RotateTransformAngle(float x = 0, float y = 0, float z = 0)
{euler.x = (euler.x + x) % 360;euler.y = (euler.y + y) % 360;euler.z = 0;transform.localEulerAngles = euler;if (Vector3.Dot(transform.up, Vector3.up) < 0){transform.rotation = Quaternion.LookRotation(transform.forward, -transform.up);}
}
嗯,现在我们会翻跟斗,但是不会倒立了。如果我们始终锁定 Z 轴,到这里其实就可以结束了。但问题是并不是所有情况下我们都应该锁定 Z 轴,万一需要 Z 轴的旋转呢?我们可以加一个开关来控制 Z 轴是否锁定。
public bool lockz;
private void RotateTransformAngle(float x = 0, float y = 0, float z = 0)
{euler.x = (euler.x + x) % 360;euler.y = (euler.y + y) % 360;if (lockz){euler.z = 0;}else{euler.z = (euler.z + z) % 360;}transform.localEulerAngles = euler;if (lockz && (Vector3.Dot(transform.up, Vector3.up) < 0)){transform.rotation = Quaternion.LookRotation(transform.forward, -transform.up);}
}
然后我们还需要再添加两个按键 Q
和 W
来旋转 Z 轴,并在旋转 Z 轴时,自动解锁 Z 轴旋转,顺便加一个按键 z
来重新锁定 Z 轴。
void Update() {...if (Input.GetKey(KeyCode.Q)){lockz = false;RotateTransformAngle(z: -Time.deltaTime * 100);}if (Input.GetKey(KeyCode.W)){lockz = false;RotateTransformAngle(z: Time.deltaTime * 100);}if (Input.GetKeyDown(KeyCode.Z)){lockz = true;}
}
这下总没问题了,吧?当我们先绕 X 轴旋转 180°,然后再转动 Z 轴时,神奇的事情发生了,瞬间天地倒转,又倒立了。
这个问题的原因很简单,因为当我们绕 X 轴旋转超过 90° 时,Y 轴发生了一次反转,也就是相机绕 Z 轴旋转了 180°,但是这个信息并未被记录到我们手动管理的欧拉角中。此时当我们绕 Z 轴旋转时,其实是基于未反转的 Z 方向角度在修改,所以镜头会突然倒转。
当锁定 Z 轴时,Z 方向的欧拉角只有可能是 0° 或 180°,要解决这个问题,我们需要一个只有 0 和 1 两种状态的变量来记录相机 Y 轴的翻转状态。1 bit 二进制数就刚好满足我们的需求,只需要不断的加一,它就会在 0 和 1 之间不断翻转。之所以要记录下这个状态,是因为当我们重新锁定相机时,需要将 Z 向欧拉角恢复到解锁前的状态,而不是简单的直接置 0。
public bool lockz;
public byte flipz;
private void RotateTransformAngle(float x = 0, float y = 0, float z = 0)
{euler.x = (euler.x + x) % 360;euler.y = (euler.y + y) % 360;euler.z = 0;if (lockz){euler.z = (flipz & 0x1) * 180;}else{euler.z = (euler.z + z) % 360;}transform.localEulerAngles = euler;if (lockz && (Vector3.Dot(transform.up, Vector3.up) < 0)){transform.rotation = Quaternion.LookRotation(transform.forward, -transform.up);euler.z = (flipz++ & 0x1) * 180;}
}
好了,这次是真的没有问题了。
移动速度加成
相机移动相比于旋转要简单的多,直接使用 transform.Translate
函数就可以了,而且每个方向都可以自由移动。
与旋转不同的是移动的范围要更广阔,对于旋转,每个轴的旋转角度只会在 0 ~ 360° 之间,但是移动的范围几乎是无限的。这就带来了一个问题,当我们想移动很远的距离时,要“走”很久才能到。
简单,走快点就好了。但是也不能一开始就走很快,因为我们并不能确定当用户按下移动键时,是想去很远的地方,还是只想凑近一点。因此启动速度不能太快,否则我们很难准确控制相机到达想去的地方。
解决这个问题我们可以记录下用户按下移动键的时长,然后根据按键按下的时间计算一个移动速度加成。刚开始时没有任何加成,如果用户一直按着键盘不撒手,那就逐渐给一个更大加成,让相机移动的越来越快。最后当加成到达一个上限时,保持住不在变大。
你可能已经想到了,这不就是一个分段函数吗?的确,我们需要的确实是一个分段函数。
y={1x<axa<x<b10x>by = \begin{cases} 1 & x < a \\ x & a < x < b \\ 10 & x > b \end{cases} y=⎩⎨⎧1x10x<aa<x<bx>b
但是分段函数是不平滑的,而且我们还想让变化有一些非线性。有这么一个函数,在 x=0x=0x=0 附近函数值为 111,随着 xxx 的增大,函数值逐渐增大,最后在 +∞+\infty+∞ 处趋于 111。
y=e−1x2y=e^{-\frac{1}{x^2}} y=e−x21
因为函数值在 [0,1][0,1][0,1] 范围内,因此我们很容易把他缩放到 [1,M][1, M][1,M] 范围内。同时我们还可以加一些参数来调整函数的增长速度和底部宽度。
y=1+(M−1)e−txry=1+(M-1)e^{-\frac{t}{x^r}} y=1+(M−1)e−xrt
除了键盘操作,用鼠标滚轮来前后移动相机也是实用的操作,此时我们在算加成时,不是用滚动时间,而是用滚轮连续同方向滚动的距离。
goto与环绕
除了移动与旋转,我们还可以实现一些快捷操作,比如用鼠标点击一个点,让相机看向并移动到这个点“前面”,或者移动相机变成环绕这个点移动。这些功能的实现并不难,使用 LookAt
和 RotateAround
就能实现了。需要注意的是要让相机平滑的看向并移动到目标点,需要进行插值,否则镜头会生硬的跳过去。
最终我们会实现下面的功能:
鼠标 | 按键 | 功能 |
---|---|---|
左键 | 上下左右旋转相机 | |
↑ ↓ ← → | 上下左右旋转相机 | |
滚轮 | X Y Z | 绕指定轴旋转相机 |
左键 | A D L-Shift Space | 相机上下左右环绕点击的点 |
W S A D L-Shift Space | 相机前后左右上下移动 | |
滚轮 | 相机前后移动 | |
中键 | 相机上下左右移动 | |
右键 | 相机Goto点击的点 | |
Z | 锁定 Z 轴旋转 |
源码
源码以 .unitypackage
的形式放到了CSDN,可以直接导入使用。
https://download.csdn.net/download/puss0/91565511