第三人称游戏的相机控制

第三人称游戏的相机控制

Unity已经有了Cinemachine这一强大的插件来辅助开发者更容易地控制相机运动,但我觉得学习一下相机控制背后的原理还是挺有益的,没准哪天就你像定制某种相机控制的功能,又觉得Cinemachine难调呢!

本文学习自 Jasper Flick 大神的 运动系列教程

相机的旋转控制

  1. 相机会根据设备输入进行旋转,给人一种扭头的感觉。它可以是鼠标的移动、某些按键组合等,总之就是一种二维向量信息。再给定一个旋转的速度来调节灵敏度,此外相机也不需要太敏感,可以忽视微小量的输入

    //是否有操控相机旋转
    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;
    }
    
  2. 通常我们还会限制其俯仰角 (避免底裤被看穿,在游戏引擎中,就是限制相机绕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;
        }
    }
    
  3. 我们通常还需要将相机的朝向与角色的移动、转向等相结合,最关键的一点就是提取相机朝向对于角色而言有用的分量:可以通过将相机的坐标投影当前角色运动的平面(需要法线)来获取。

相机聚焦

第三人称视角的相机要「紧盯」目标,但不建议将相机作为观测对象子物体的形式来实现这一目标。通常是让相机与角色保持一定距离,控制相机旋转时呈球面运动。在已知相机朝向的情况下,与观测点逆向计算就可以得到位置:

为了不让相机移动显得太僵硬,和相机旋转类似,我们对小范围内的移动并不进行跟踪。只有玩家超出那个范围时相机才会跟踪(为方便称呼就叫它「死区半径」)。

可以通过记录上一次玩家超出死区半径时的位置来做到:只有玩家当前位置与那个位置之间的距离再次超出死区半径时,相机才进行跟踪并更新那个位置的值,以便下次判断。

//更新聚焦点
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 是从1减少到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 是从1减少到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;

热门相关:退婚后我嫁给了前任他叔   暴君他偏要宠我   废材逆袭:冰山王爷倾城妃   Hello,校草大人!   至尊九千岁