Unity 基于Cinemachine计算透视摄像机在地图中的移动范围
Unity中Cinemachine的基础功能介绍可详见之前写的博客:
https://www.cnblogs.com/koshio0219/p/11820654.html
本篇的重点是讨论,在给定规则地图的长宽和中心点坐标的情况下,如何动态生成一个透视摄像机的碰撞盒子以限定摄像机的视野永远不会超出地图的边界。
例如,下面这种规则地图:(或者其他用程序生成的单位块地图)
在输入一些参数后:
可以自动创建形如:
这样的摄像机运动范围,且输出的范围能够适配到屏幕的分辨率,考虑到相机绕某一轴向的旋转等问题。
其实基本都是纯粹的数学运算,开始之前,必须先弄清楚透视摄像机的一些基本原理,它的视窗大小和屏幕分辨率之间到底是什么关系:
1.FOV:这是透视摄像机区别于正交摄像机最重要的一个特性——视口大小,它表示的是当前摄像机视野范围的开口角度,也因该角度大小的不同,使得透视摄像机的近裁剪平面和远裁剪平面大小不一,从而产生三维空间中近大远小的特点。
2.Aspect:当前摄像机的宽高比。为什么要设置这样一个东西呢?理由就是屏幕有不同的分辨率,而相机映照出来的画面最终是要在屏幕当中显示的,当我们的屏幕分辨率发生变化时,相机的视口面积也会对应的发生变化,这时,仅仅只有一个FOV没办法满足不同类型的屏幕分辨率,于是就需要额外设置相机的宽高比来对最终呈现的摄像机视口大小进行辅助调整。
在Unity中,是以视口的高为基准进行计算的,也就是说,Unity中的透视摄像机的Fov角度其实是按照屏幕分辩率的高度进行对应的,而宽度对应的Fov则随着Aspect的变化而变化,不是面板设置的Fov大小。
试比较下面两张图,分别是摄像机的宽和高的Fov:
设置的Fov为40度,当前的屏幕分辨率为2960*1440:
很显然,只有高度对应的Fov为面板中显示的值,而宽度对应的Fov明显大于40度。实际宽的的Fov应该是82度左右(40*2960/1440)。
知道了上面这些后我们才能更愉快的进行接下来的计算,不然只会计算出许多错误也搞不清是什么原因。
在Cinemachine中,一般会设置一个跟随目标,且跟踪该目标的距离是一个常量,可以从面板中取得:
我们先分析摄像机的左右运动范围是如何计算的:(本例中的摄像机只在X轴向上存在旋转值,一般斜向的摄像机也只需要旋转一个轴即可,左右看上去一般追求对称性)
观察上图,假设现在摄像机位于空中的P点,已知AB为地图的边缘围墙高度,BC为角色的高度,CP为跟踪的摄像机到角色的距离,现在我们需要求出摄像机所在的X轴向的坐标,关键就是要求出AD的距离。
我们还知道一个数据就是摄像机的Fov,但是由于该Fov并非高度对应的值,所以我们先要进行一次转换,以得到摄像机宽度视口的Fov角度。以下均为弧度计算:
1 //计算的角度均为弧度值,传入纵向的(高)Fov的一半得到横向的(宽)Fov的一半 2 public float GetHorizontalFovHalf(float vhfov, float aspect) 3 { 4 return Mathf.Atan(Mathf.Tan(vhfov) * aspect); 5 }
上面已经讲过原理了这里就不在进行过多叙述了,简单来说就是利用摄像机的深度值进行了一次转换,因为无论是纵向还是横向的Fov,它们的深度值都是相同的,读者可以自行画图或脑补一下。
通过上面的方法我们就可以求得∠DPA的大小了,它正好就是横向Fov的一半,那个∠α的大小就可以轻易求出,现在问题的关键就是要求出边AP的长度,AP的长度得出的话,就可以利用∠α余弦求得AD,DP等。
利用正弦定理可以非常快速的解决上面的问题,当然你也可以设未知数利用勾股定律解一元二次方程,但当你写程序的时候你可能会有想吐的冲动:
1 //计算轴向偏移值 2 private float GetSizeOffse(float fbangel, float distance, float wh, float followy) 3 { 4 //直角弧度值 5 var rightangel = 90 * Mathf.Deg2Rad; 6 //∠PAC 7 var disangel = fbangel + rightangel; 8 //求出正弦定理的比值 9 var sin = distance / Mathf.Sin(disangel); 10 //求∠APC的正弦值 11 var angelo = (wh - followy) / sin; 12 //三角形内角和求∠ACP 13 var angel = rightangel * 2 - Mathf.Asin(angelo) - disangel; 14 //计算AP利用α余弦返回AD 15 return sin * angel * Mathf.Cos(fbangel); 16 }
fbangel即为上图中的∠α,distance即为上图中的CP,wh即为上图中的AB,followy即为上图中的CB。
X轴向的偏移计算完毕后,Z轴的偏移也是类似的,只不过需要考虑旋转值而已,接下来就是摄像机的高度(注意摄像机的高度是一个变量),这个很容易计算。下面给出生成摄像机运动区域的参考:
1 //计算并生成透视摄像机的运动区域 2 public void GenZone() 3 { 4 Camera = Camera.main; 5 6 //计算从地图中心到边缘的向量 7 var toedge = WidthHeight * UnitLength * .5f; 8 //左后 9 var lb = CenterPoint - toedge; 10 //右前 11 var rf = CenterPoint + toedge; 12 //墙高 13 var wh = WallHeight; 14 15 zone = new GameObject("CameraZone"); 16 17 var box = zone.AddComponent<BoxCollider>(); 18 var cvc = GetComponent<CinemachineVirtualCamera>(); 19 var cft = cvc.GetCinemachineComponent<CinemachineFramingTransposer>(); 20 21 var cvcs = cvc.m_Lens; 22 //摄像机跟踪目标的高度 23 var followy = cvc.m_Follow.position.y; 24 //跟踪距离 25 var distance = cft.m_CameraDistance; 26 //屏幕高对应的Fov的一半(真实Fov) 27 var hfov = cvcs.FieldOfView * .5f * Mathf.Deg2Rad; 28 //摄像机视口宽高比 29 var aspect = Camera.aspect; 30 //摄像机轴向旋转值 31 var rotation = Camera.transform.eulerAngles.x * Mathf.Deg2Rad; 32 var rightangel = 90 * Mathf.Deg2Rad; 33 //屏幕宽对应的Fov的一半(转化后的Fov) 34 var whfov = GetHorizontalFovHalf(hfov, aspect); 35 36 //摄像机当前高度 37 var height = Mathf.Sin(rotation) * distance + followy; 38 39 //计算左右偏移(对称) 40 var lrangel = rightangel - whfov; 41 var widthh = GetSizeOffse(lrangel, distance, wh, followy); 42 var left = lb.x + widthh; 43 var right = rf.x - widthh; 44 var sizex = Mathf.Abs(left - right); 45 46 //计算前后偏移(带旋转值,非对称) 47 var fangel = rotation - hfov; 48 var front = rf.y - GetSizeOffse(fangel, distance, wh, followy); 49 50 var bangel = rotation + hfov; 51 var back = lb.y - GetSizeOffse(bangel, distance, wh, followy); 52 53 var sizez = Mathf.Abs(front - back); 54 55 //设置摄像机运动范围的大小,因为在XZ平面上,盒子的高度可以为一个常量 56 box.size = new Vector3(sizex, 5, sizez); 57 zone.transform.position = new Vector3((left + right) * .5f, height, (front + back) * .5f); 58 59 CC.m_BoundingVolume = zone.GetComponent<BoxCollider>(); 60 }
生成该盒子后,只需要将它赋值给CinemachineConfiner的BoundingVolume属性即可:
为了更方便的进行测试和调试,可以写一个Editor脚本在编辑器模式下生成:
1 using UnityEditor; 2 using UnityEngine; 3 4 [CustomEditor(typeof(CameraZoneCtrl))] 5 public class CameraZoneEditor : Editor 6 { 7 public override void OnInspectorGUI() 8 { 9 DrawDefaultInspector(); 10 CameraZoneCtrl ctrl = (CameraZoneCtrl)target; 11 if (GUILayout.Button("创建摄像机范围")) 12 { 13 ctrl.GenZone(); 14 } 15 } 16 }
2020年7月9日更新:
摄像机带y轴旋转的处理方法
如上,摄像机不仅x轴有旋转值,y轴也有旋转,这时整个摄像机的运动范围盒位置和大小都将发生变化,可以分别进行讨论:
1.大小的变化
不再是以房间的边缘为极限位置参考,而是改为以四个顶点;那是不是一定要从头到尾重新计算一遍才行呢?
这里有一个更简单的处理方式——计算原始矩形的旋转包围盒大小
如上图,假设某矩形旋转了α度,那么根据三角函数可以很快计算出变化后的包围盒长宽:
x'=x*cos(α)+y*sin(α);
y'=y*cos(α)+x*sin(α);
用函数则是如下表示:
1 private Vector2 GetAxialRotation(Vector2 axial, float angle) 2 { 3 var x = axial.x * Mathf.Cos(angle) + axial.y * Mathf.Sin(angle); 4 var y = axial.y * Mathf.Cos(angle) + axial.x * Mathf.Sin(angle); 5 return new Vector2(x, y); 6 }
注意角度为弧度值。
2.旋转后的位置值变化
本来应该是一个空间立方体在以前的基础上绕房间中点的Y轴旋转α度,但之前已经计算了大小值得变化,故此处只用计算中心点的前后旋转值变换。
更进一步简化则为,计算平面中任意一点绕另一点旋转α度后的坐标:(逆时针)
1 public static Vector2 RotateByPos(this Vector2 pos, Vector2 rPos, float angle) 2 { 3 var x = (pos.x - rPos.x) * Mathf.Cos(angle) - (pos.y - rPos.y) * Mathf.Sin(angle) + rPos.x; 4 var y = (pos.y - rPos.y) * Mathf.Cos(angle) + (pos.x - rPos.x) * Mathf.Sin(angle) + rPos.y; 5 return new Vector2(x, y); 6 }
这里就不具体解释证明过程了,如感兴趣可见:
https://jingyan.baidu.com/article/2c8c281dfbf3dd0009252a7b.html?spm=0.0.0.0.YhLCCP
1 private void RotateYByPos(Vector2 r, Transform t, float angle) 2 { 3 var tempv2 = new Vector2(t.position.x, t.position.z); 4 var result = tempv2.RotateByPos(r, -angle); 5 t.position = new Vector3(result.x, t.position.y, result.y); 6 t.SetEulerAnglesY(angle * Mathf.Rad2Deg); 7 }
修改后的函数:
1 //计算并生成透视摄像机的运动区域 2 public void GenZone() 3 { 4 Camera = Camera.main; 5 //摄像机轴向旋转值 6 var rotation = Camera.transform.eulerAngles.x * Mathf.Deg2Rad; 7 var sizeup = Camera.transform.eulerAngles.y * Mathf.Deg2Rad; 8 9 //计算从地图中心到边缘的向量 10 var toedge = WidthHeight * UnitLength * .5f; 11 12 //旋转后的大小值变化(添加内容) 13 toedge= GetAxialRotation(new Vector2(toedge.x, toedge.y), sizeup); 14 15 //左后 16 var lb = CenterPoint - toedge; 17 //右前 18 var rf = CenterPoint + toedge; 19 //墙高 20 var wh = WallHeight; 21 22 zone = new GameObject("CameraZone"); 23 24 var box = zone.AddComponent<BoxCollider>(); 25 var cvc = GetComponent<CinemachineVirtualCamera>(); 26 var cft = cvc.GetCinemachineComponent<CinemachineFramingTransposer>(); 27 28 var cvcs = cvc.m_Lens; 29 //摄像机跟踪目标的高度 30 var followy = cvc.m_Follow.position.y; 31 //跟踪距离 32 var distance = cft.m_CameraDistance; 33 //屏幕高对应的Fov一半(真实Fov) 34 var hfov = cvcs.FieldOfView * .5f * Mathf.Deg2Rad; 35 //摄像机视口宽高比 36 var aspect = Camera.aspect; 37 var rightangle = 90 * Mathf.Deg2Rad; 38 //屏幕宽对应的Fov一半(转化后的Fov) 39 var whfov = GetHorizontalFovHalf(hfov, aspect); 40 41 //摄像机当前高度 42 var height = Mathf.Sin(rotation) * distance + followy; 43 44 //计算左右偏移(对称) 45 var lrangle = rightangle - whfov; 46 var widthh = GetSizeOffse(lrangle, distance, wh, followy); 47 var left = lb.x + widthh; 48 var right = rf.x - widthh; 49 var sizex = Mathf.Abs(left - right); 50 51 //计算前后偏移(带旋转值,非对称) 52 var fangle = rotation - hfov; 53 var front = rf.y - GetSizeOffse(fangle, distance, wh, followy); 54 55 var bangle = rotation + hfov; 56 var back = lb.y - GetSizeOffse(bangle, distance, wh, followy); 57 58 var sizez = Mathf.Abs(front - back); 59 60 //设置摄像机运动范围的大小,因为在XZ平面上,盒子的高度可以为一个常量 61 box.size = new Vector3(sizex, 5, sizez); 62 zone.transform.position = new Vector3((left + right) * .5f, height, (front + back) * .5f); 63 //位置值变化设置(添加内容) 64 RotateYByPos(CenterPoint, zone.transform, sizeup); 65 66 CC.m_BoundingVolume = zone.GetComponent<BoxCollider>(); 67 }