第三人称游戏的相机控制
第三人称游戏的相机控制
Unity已经有了Cinemachine这一强大的插件来辅助开发者更容易地控制相机运动,但我觉得学习一下相机控制背后的原理还是挺有益的,没准哪天你就想定制某种相机控制的功能,又觉得Cinemachine难调呢!
本文学习自 Jasper Flick 大神的 运动系列教程
相机的旋转控制
-
相机会根据设备输入进行旋转,给人一种扭头的感觉。它可以是鼠标的移动、某些按键组合等,总之就是一种二维向量信息。再给定一个旋转的速度来调节灵敏度,此外相机也不需要太敏感,可以忽视微小量的输入。
//是否有操控相机旋转 private bool IsManualRotation(Vector2 cameraInput) { if(Mathf.Abs(cameraInput.x) > minInputValue || Mathf.Abs(cameraInput.y) > minInputValue) { OrbitAngles += rotationSpeed * Time.unscaledDeltaTime * cameraInput; return true; } return false; }
-
通常我们还会限制其俯仰角
(避免底裤被看穿,在游戏引擎中,就是限制相机绕x轴旋转的角度;至于水平面的旋转(偏航角),一般是不受限制,但为了配合其它相机运动的工作,会将这个值限定在 0 ~ 360 度。//约束角度 private void ConstrainAngles () { //限制俯仰角 OrbitAngles.x = Mathf.Clamp(OrbitAngles.x, MinVerticalAngle, MaxVerticalAngle); //规范偏航角 if(OrbitAngles.y >= 360f) { OrbitAngles.y -= 360f; } else if(OrbitAngles.y < 0f) { OrbitAngles.y += 360f; } }
-
我们通常还需要将相机的朝向与角色的移动、转向等相结合,最关键的一点就是提取相机朝向对于角色而言有用的分量:可以通过将相机的坐标投影当前角色运动的平面(需要法线)来获取。
相机聚焦
第三人称视角的相机要「紧盯」目标,但不建议将相机作为观测对象子物体的形式来实现这一目标。通常是让相机与角色保持一定距离,控制相机旋转时呈球面运动。在已知相机朝向的情况下,与观测点逆向计算就可以得到位置:
为了不让相机移动显得太僵硬,和相机旋转类似,我们对小范围内的移动并不进行跟踪。只有玩家超出那个范围时相机才会跟踪(为方便称呼就叫它「死区半径」)。
可以通过记录上一次玩家超出死区半径时的位置来做到:只有玩家当前位置与那个位置之间的距离再次超出死区半径时,相机才进行跟踪并更新那个位置的值,以便下次判断。
//更新聚焦点
private void UpdateFocusPoint()
{
prevFocusPoint = focusPoint; //获取上次的聚焦点
var curFocusPoint = Focus.position; //获取观察对象的位置
if(FocusRadius > 0) //如果有设置死区半径
{
var curDis = Vector3.Distance(curFocusPoint, prevFocusPoint);
if(curDis > FocusRadius)
{
focusPoint = curFocusPoint;
}
}
else
{
focusPoint = curFocusPoint;
}
}
当然,这样的处理会导致相机运动十分僵硬,画面几乎是抖动的。利用插值可以解决:
if(curDis > FocusRadius)
{
float lerpT = FocusRadius / curDis;
//选择「当前」->「以前」插值,因为 focusRaduis / curDis 会逐渐减少到0
focusPoint = Vector3.Lerp(curFocusPoint, prevFocusPoint, lerpT);
}
可这就不能保证相机与观察对象的距离是期望值了,毕竟插值计算只发生在超出死区半径的时候。所以我们在通常情况下,也让相机缓缓向观察对象处靠近,同样是利用插值:
float lerpT = 1.0f;
if(curDis > 0.01f && FocusCentering > 0) //缓慢将聚焦点移到观察对象位置处
{
lerpT = Mathf.Pow(FocusCentering, Time.unscaledDeltaTime);
}
if(curDis > FocusRadius) //超出死区半径时
{
lerpT = Mathf.Min(lerpT, FocusRadius / curDis);
}
//选择「当前」->「过去」插值,因为 focusRaduis / curDis 会逐渐减少到0
focusPoint = Vector3.Lerp(curFocusPoint, prevFocusPoint, lerpT);
FocusCentering为值在0~1之间的小数,这个值越小,向观察对象处靠近就会越慢,反之越快。
为了让两种聚焦更好的融合,在超出死区半径时,我们选用二者的最小值。下面是对比,左边为取最小值;右边是不取最小值,直接用原本的方案:
注意右边未采用最小值的情况下,在停止时会有明显跟随速度的变化,像是镜头被人往前推了一把。(因为gif帧率的原因,可能看不太出来)
相机碰撞
现在的相机只是个幽灵一样的摄影师,我们希望它能更聪明点。比如,在相机与玩家之间隔了一堵墙时,我们希望它能越过那堵墙来拍摄角色,而不是严格保持着设置的距离、盯着墙壁或是卡在墙里拍摄角色。
这可以通过调整 相机的近裁剪面 做到,从观测点向相机的近裁剪面处进行物理碰撞检测,一旦发现碰撞点,就调整相机的位置,保证近裁剪面处于这个碰撞点的位置。
需要注意的就是,近裁剪面位置不等于相机位置,以Unity为例,默认近裁剪面都会在相机前方0.3单位距离处,所以调整相机本体位置时,要考虑这部分的偏差。
//更新相机碰撞检测
private void UpdateCameraCollision()
{
//nearClipPlane可以获取近裁剪面与相机的距离
Vector3 rectOffset = lookDirection * camera.nearClipPlane; //近裁剪面与相机的偏差向量
Vector3 rectPosition = lookPosition + rectOffset; //相机近裁剪面位置
Vector3 castFrom = Focus.position; //因为是反向投射检测,所以聚焦点是起始点
Vector3 castVector = rectPosition - castFrom; //起始点指向近裁剪面的向量
float castDistance = castVector.magnitude; //记录该向量长度
//记录该线段方向(已知长度可以直接除,等同于归一化)
Vector3 castDirection = castVector / castDistance;
//利用上述信息,进行盒状投影检测,判断近裁剪面与观察对象间有障碍
if(Physics.BoxCast(castFrom, CameraHalfExtends, castDirection, out RaycastHit hitInfo,
lookRotation, castDistance, ObstructionMask))
{
//移动到该碰撞点
rectPosition = castFrom + castDirection * hitInfo.distance;
//将该碰撞点位置减去近裁剪面,得到相机应该在的位置
lookPosition = rectPosition - rectOffset;
}
}
自动对齐
当相机在达到一定时间没被操控时,相机会自动对齐玩家前进的方向,这也是第三人称视角游戏常有的功能。 (这似乎能提高游戏体验,但我没想过这是为什么
这个功能的重点是对齐的实现,首先,这里的对齐是指在世界坐标的XZ平面能与玩家运动保持一致,也就是说让相机世界坐标的y轴旋转实现的。这样才能保证相机的俯仰角不变。
我们可以记录相机上一时刻聚焦的点,然后让现在聚焦的点与之对比,便能求出运动向量,根据这个向量便能求出它对应的世界坐标Z轴的角度:
但要注意,用反三角函数求出来的这个角度要人为加以区分(例如通过其在x轴的分量正负号)。例如上图的两种情况,它们用反三角函数求出的角度是一样的,不加以区分可能转反。
private static float GetAngle(Vector2 direction)
{
var angle = Mathf.Acos(direction.y) * Mathf.Rad2Deg;
return direction.x < 0 ? 360f - angle : angle;
}
这样一来就保证所有角度都是顺时针而言的,所以上述第一种情况就是这样的角度:
那就这样把相机绕顺时针旋过去,未免有点“舍近求远”了吧?所以在实际旋转之前,也要判断一下怎么旋角度变化比较小:
//是否需要自动对齐
private bool IsAutoRotation()
{
if(Time.unscaledTime - lastManualRotateTime> attributes.AlignDelay)
{
//根据之前聚焦的位置和当前聚焦的位置,判断观察方向的变化
var alignDelta = focusPoint - prevFocusPoint;
var movement = new Vector2(alignDelta.x, alignDelta.z);
//不开根号是因为很多时候不用对齐,需要对齐时再开根号,省些计算量
var movementDeltaSqr = movement.sqrMagnitude;
if(movementDeltaSqr < 0.0001f) //角度变化很小就不用对齐了
{
return false;
}
//否则就算出该变化的角度
movement /= Mathf.Sqrt(movementDeltaSqr); //归一化
var headingAngle = GetAngle(movement); //计算新朝向的角度
//得到从当前相机世界坐标偏航角变化到上述角度的差值绝对值
var deltaAbs = Mathf.Abs(Mathf.DeltaAngle(OrbitAngles.y, headingAngle));
float rotationChange = RotationSpeed * Time.unscaledDeltaTime;
//以最小的旋转角度旋转过去,故顺时针方向和逆时针方向都判断一遍
if(deltaAbs < AlignSmoothRange)
{
rotationChange *= deltaAbs / AlignSmoothRange;
}
else if(180 - deltaAbs < AlignSmoothRange)
{
rotationChange *= (180 - deltaAbs) / AlignSmoothRange;
}
//插值变化角度,以求平滑过渡
OrbitAngles.y = Mathf.MoveTowardsAngle(OrbitAngles.y, headingAngle, rotationChange);
return true;
}
return false;
}
在原文中,作者还设计了一种特殊情况——在重力方向可变化的空间,这时相机该如何对齐?
很明显,要在常规对齐的基础上额外考虑重力作用下Up轴的变化。思路其实很相似,通过上一时刻Up轴与当前Up轴之间的角度,来插值变化:
//更新重力对齐
private void UpdateGravityAlignment()
{
//gravityAlignment为四元数,fromUp = 将up旋转gravityAlignment之后的位置
//因为gravityAlignment记录重力旋转后的结果,故在未更新前,可认为是「上一帧的Up轴」
var fromUp = gravityAlignment * Vector3.up;
var toUp = CustomGravity.GetUpAxis(focusPoint);//当前重力下的up轴
var dot = Mathf.Clamp(Vector3.Dot(fromUp, toUp), -1, 1); //防止误差而得到Nan结果
var angle = Mathf.Acos(dot) * Mathf.Rad2Deg;//获取从fromUp与toUp间的夹角
var maxAngle = UpAlignmentSpeed * Time.deltaTime;
//新Up轴对齐四元数 = 新重力对齐旋转 + 原本up轴
var newAlignment = Quaternion.FromToRotation(fromUp, toUp) * gravityAlignment;
if(angle <= maxAngle) //如果夹角在单帧变化的最大夹角限度内,直接应用变化
{
gravityAlignment = newAlignment;
}
else //否则插值变化
{
gravityAlignment = Quaternion.SlerpUnclamped(gravityAlignment, newAlignment, maxAngle / angle);
}
}
但这样一来,在原本偏航角的对齐时,要排除掉重力翻转的影响,不然会干扰对齐结果:
var alignDelta = Quaternion.Inverse(gravityAlignment) * (focusPoint - prevFocusPoint);
最后再一并算上:
orbitRotation = Quaternion.Euler(OrbitAngles);
//相机的旋转由两部分组成:重力轴对齐产生的旋转和通常情况下对齐的旋转
lookRotation = gravityAlignment * orbitRotation;