Unity相机控制

相机的控制无非移动和旋转,每种操作各3个轴6个方向,一共12种方式。在某些需要快速验证的项目或Demo里常常需要丝滑的控制相机调试效果。相机控制虽然不是什么高深的技术,但是要写的好用还是很磨人的。

锁定Z轴的旋转

一个自由的相机可以绕 X,Y,Z 轴旋转,正常情况下用6个按键加上 transform.Rotate api 就可以搞定了。这里要注意的是要使用本地坐标系,transform.Rotate 默认就是本地坐标系。比如我们可以用上下左右方向键和鼠标左键来控制相机的旋转。

csharp 复制代码
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 轴的函数。

csharp 复制代码
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 参数,对于鼠标旋转的情况,只需要将四元数的乘法顺序调换一下就可以了。

csharp 复制代码
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 面板里面操作时,转哪个轴就只转那一个轴,不会重新解算,也不会动到其他轴,井水不犯河水。

这就意味这我们也可以模拟这个过程,手动记录下相机的初始欧拉角,然后转哪个轴就加减哪个角,最后将欧拉角赋值给相机就可以了。让我们重新写一个函数来专门负责旋转。

csharp 复制代码
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 函数也不用再调用了。

csharp 复制代码
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 轴反转,我们可以使用点乘来实现这个判断。

csharp 复制代码
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 轴是否锁定。

csharp 复制代码
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);
    }
}

然后我们还需要再添加两个按键 QW 来旋转 Z 轴,并在旋转 Z 轴时,自动解锁 Z 轴旋转,顺便加一个按键 z 来重新锁定 Z 轴。

csharp 复制代码
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。

csharp 复制代码
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 = { 1 x < a x a < x < b 10 x > b y = \begin{cases} 1 & x < a \\ x & a < x < b \\ 10 & x > b \end{cases} y=⎩ ⎨ ⎧1x10x<aa<x<bx>b

但是分段函数是不平滑的,而且我们还想让变化有一些非线性。有这么一个函数,在 x = 0 x=0 x=0 附近函数值为 1 1 1,随着 x x x 的增大,函数值逐渐增大,最后在 + ∞ +\infty +∞ 处趋于 1 1 1。
y = e − 1 x 2 y=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 − t x r y=1+(M-1)e^{-\frac{t}{x^r}} y=1+(M−1)e−xrt

除了键盘操作,用鼠标滚轮来前后移动相机也是实用的操作,此时我们在算加成时,不是用滚动时间,而是用滚轮连续同方向滚动的距离。

goto与环绕

除了移动与旋转,我们还可以实现一些快捷操作,比如用鼠标点击一个点,让相机看向并移动到这个点"前面",或者移动相机变成环绕这个点移动。这些功能的实现并不难,使用 LookAtRotateAround 就能实现了。需要注意的是要让相机平滑的看向并移动到目标点,需要进行插值,否则镜头会生硬的跳过去。

最终我们会实现下面的功能:

鼠标 按键 功能
左键 上下左右旋转相机
上下左右旋转相机
滚轮 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

相关推荐
nnsix8 小时前
Unity PicoVR开发 实时预览Unity场景 在Pico设备中(串流)
unity·游戏引擎
一只一只14 小时前
Unity之UGUI Button按钮组件详细使用教程
unity·游戏引擎·ugui·button·ugui button
WarPigs17 小时前
Unity阴影
unity·游戏引擎
一只一只18 小时前
Unity之Invoke
unity·游戏引擎·invoke
tealcwu21 小时前
【Unity踩坑】Simulate Touch Input From Mouse or Pen 导致检测不到鼠标点击和滚轮
unity·计算机外设·游戏引擎
ThreePointsHeat21 小时前
Unity WebGL打包后启动方法,部署本地服务器
unity·游戏引擎·webgl
迪普阳光开朗很健康21 小时前
UnityScrcpy 可以让你在unity面板里玩手机的插件
unity·游戏引擎
陈言必行2 天前
Unity 之 设备性能分级与游戏画质设置与设备自动适配指南
游戏·unity·游戏引擎
CreasyChan2 天前
Unity DOTS技术栈详解
unity·c#·游戏引擎
在路上看风景2 天前
1.1 Unity资源生命周期管理与内存机制
unity