Unreal Engine 4 —— 在UE4中实现真实第一人称相机

这篇博客来自于Fabrice Piquet,翻译工作已获得作者授权,原文传送门


我决定分享一下我在当前项目中处理真实第一人称相机的方法。针对真实第一人称视角,目前没有太多相关的文档。因此研究一段时间过后,尤其是当前项目中我花了不少时间去解决一些存在的难题过后,我决定写一篇相关的文章。项目中最终的效果如下:

FirstPersonCamera

真实第一人称视角?

何为真实第一人称(True First Person,TFP)呢?在某些场景下,它也被称为“身体意识(Body Awareness)”。相对于仅仅是一个悬浮的相机来说,它是一个真实运动的,模拟角色真实身体运动的身体的第一人称视角。拥有这类视角的游戏有:

超世纪战警:暗黑雅典娜
The Chronicles of Riddick: Assault on Dark Athena
暴力辛迪加
Syndicate
镜之边缘
Mirror's Edge
Mirror's Edge2

我避免的东西:分开手臂和身体

在一个手臂和身体分离的系统中,角色的两只手和身体是分开的,从而直接将手臂attach到相机上。这样可以在确保手是跟随相机进行运动的同时,还能够对手臂进行操作。身体的剩下的部分通常也是分开的,它们通常也会有自己的动画系统。

这个系统的问题在于在做一个全身动画(例个重力缓冲效果)的时候,它要求这两个独立的动画系统进行严格的同步(这对动画的制作以及在引擎中的逻辑都有着对应的要求)。有时候游戏使用一个只对操纵玩家可见的模型来模拟,全身的模型用于渲染角色的阴影(以及在多人游戏中对于其他玩家显示,在最近的使命召唤系列游戏中,这种方法运用的比较多)。

如果对于优化以及特殊表现有着比较高的要求,那么这种方法是适合的。但是如果游戏追求可信度和沉浸感,那么我并不推荐这种方式。由于这并不是我需要的方法,因此针对于这种方式我并不准备介绍过多。再说了现在互联网上有很多很多相关的教程,这里就不再赘述了。

Arms
Arms_Weapon

全身模型的设置

针对于全身模型,我们不使用隔离的动画系统。相反,我们使用一整个的全身模型来表现角色。对应的相机attach在头部,这也就意味着由你的身体动画来驱动它。我们不直接进行相机的位置或朝向的数值修改,最终,整个类的架构如下:

PlayerControler
  -> Character
     -> Mesh
        -> AnimBlueprint
        -> Camera
        

针对PlayerController,其实没什么好说的,在UE中它总是在Character或者Pawn之上。Character有一个表示身体的Mesh,而这个Mesh有一个针对全身骨骼进行操作的AnimBlueprint。最后,我们有一个在Constructor中attach到头上的相机。

那么现在相机已经attach到头上了,我们完成了吗?当然没有。因为相机是由骨骼驱动的,我们需要实现基本的相机操作:向上下左右看。可以通过使用Additive animation来制作。所谓的Additive animation是一帧的动画,用于把各个骨骼的offset给apply上去。总体来说,我是用了10个动画,当然你可以使用更多的pose,但是我发现更多的动画就不再必要了。

在我们的项目中,我设置当玩家向左/右看时,整个人的身体也会向左/右转(就像上面的镜之边缘的gif图一样)。此外,还有一个专门为角色idle设置的additional animation,这层动画在这些动画层级之上。效果如下:

Aim offset

当这些动画被成功导入引擎中后,我们需要设置一些东西。首先起一个好名字,来确保自己日后能够找到它。在我们的项目中,我将其命名为“anim_idle_additive_base”。针对其他的pose动画,我将其进行Additive Setting。具体来讲就是将Additive Anim Type参数设定为Mesh Space,并且将Base Pose Type设定为Selected Animation。最后,将Base Pose Animation设定好即可。针对每个Pose重复以上过程即可。

Additive Settings

将动画资源准备好后,就可以创建Aim Offset了。Aim Offset指的是允许开发者依据输入的参数,在多个动画中进行平滑Blending操作的东西。针对更多的内容,可以参考官方的文档:Aim Offset。当设定完毕后,效果如下:
Aim Offset

我自己的Aim Offset使用两个参数进行驱动:Pitch和Yaw。这两个数值在代码内进行逻辑更新,细节如下:
Aim input
Aim Graph

更新动画的Blending

我们需要将玩家针对相机的输入转化为驱动Aim Offset的值,我通过下面三步来进行处理:

  1. PlayerController里将游戏输入转化为旋转值
  2. Character中将世界空间下的旋转值转化为本地空间
  3. 根据本地空间的旋转值来驱动Anim Blueprint

1. PlayerController Input

当玩家移动鼠标或者手柄摇杆时,我需要将这些值在PlayerController中接收,并通过重写UpdateRotation()函数转化为对应的旋转值。

void AExedrePlayerController::UpdateRotation(float DeltaTime)
{
    if( !IsCameraInputEnabled() )
        return;

    float Time = DeltaTime * (1 / GetActorTimeDilation());
    FRotator DeltaRot(0,0,0);

    DeltaRot.Yaw    = GetPlayerCameraInput().X * (ViewYawSpeed * Time);
    DeltaRot.Pitch  = GetPlayerCameraInput().Y * (ViewPitchSpeed * Time);
    DeltaRot.Roll   = 0.0f;

    RotationInput = DeltaRot;

    Super::UpdateRotation(DeltaTime);
}

需要注意的是,UpdateRotation方法在PlayerController类中每帧都会调用一次。我考虑了GetActorTimeDilation()函数,因此当使用slomo方法时,相机转动的速度不会变动。

2. 在Character中的相机控制

我的Character类中有一个PreUpdateCamera()函数,该函数如下:

void AExedreCharacter::PreUpdateCamera( float DeltaTime )
{
    if( !FirstPersonCameraComponent || !EPC || !EMC )
        return;
    //-------------------------------------------------------
    // Compute rotation for Mesh AIM Offset
    //-------------------------------------------------------
    FRotator ControllerRotation = EPC->GetControlRotation();
    FRotator NewRotation        = ControllerRotation;

    // Get current controller rotation and process it to match the Character
    NewRotation.Yaw             = CameraProcessYaw( ControllerRotation.Yaw );
    NewRotation.Pitch           = CameraProcessPitch( ControllerRotation.Pitch + RecoilOffset );
    NewRotation.Normalize();

    // Clamp new rotation
    NewRotation.Pitch   = FMath::Clamp( NewRotation.Pitch, -90.0f + CameraTreshold, 90.0f - CameraTreshold);
    NewRotation.Yaw     = FMath::Clamp( NewRotation.Yaw, -91.0f, 91.0f);

    //Update loca variable, will be retrived by AnimBlueprint
    CameraLocalRotation = NewRotation;
}

函数CameraProcessYaw()CameraProcessPitch()Controller的世界坐标系旋转值转化为本地坐标系下的旋转值。这两个函数如下:

float AExedreCharacter::CameraProcessPitch( float Input )
{
    //Recenter value
    if( Input > 269.99f )
    {
        Input -= 270.0f;
        Input = 90.0f - Input;
        Input *= -1.0f;
    }

    return Input;
}

float AExedreCharacter::CameraProcessYaw( float Input )
{
    //Get direction vector from Controller and Character
    FVector Direction1 = GetActorRotation().Vector();
    FVector Direction2 = FRotator(0.0f, Input, 0.0f).Vector();

    //Compute the Angle difference between the two dirrection
    float Angle = FMath::Acos( FVector::DotProduct(Direction1, Direction2) );
    Angle = FMath::RadiansToDegrees( Angle );

    //Find on which side is the angle difference (left or right)
    FRotator Temp = GetActorRotation() - FRotator(0.0f, 90.0f, 0.0f);
    FVector Direction3 = Temp.Vector();

    float Dot   = FVector::DotProduct( Direction3, Direction2 );

    //Invert angle to switch side
    if( Dot > 0.0f )
    {
        Angle *= -1;
    }

    return Angle;
}

(译者按:使用欧拉角真的没问题吗?万象的话该怎么办orz)

3. AnimBlueprint 更新逻辑

最后一步也是最简单的一步,我通过Event Blueprint Update Animation节点来获取上述的值,并且将其作为Aim Offset的控制变量:

AnimationBP_Update
AnimationBP_Anim

如何避免帧延迟

这个问题有时很多人并不重视,但是这的确是个问题。如果你是按照上面的设置走下来的并且你不是太清楚Tick()函数在UE中是怎么运作的,你会遇到这个问题:有一帧的延迟。

这一帧的延迟会很蛋疼,而且有可能会造成很糟糕的游戏体验——基本上来讲这一帧的相机总是会基于上一帧的数据。这意味着如果你快速移动鼠标然后突然停止,那么实际上你会在下一帧才停止。无论你的帧率是多少,这个问题都会存在。

解决这个问题的方案需要对Tick函数有一些理解,在默认状况下,Tick函数执行顺序如下:

_ _ _ _ _ UpdateTimeAndHandleMaxTickRate (Engine function)
_ _ _ _ _ Tick_PlayerController
_ _ _ _ _ Tick_SkeletalMeshComponent
_ _ _ _ _ Tick_AnimInstance
_ _ _ _ _ Tick_GameMode
_ _ _ _ _ Tick_Character
_ _ _ _ _ Tick_Camera

那么在这里发生了什么事呢?可以看见Character类的Tick顺序是在AnimBlueprint之后的,这意味着在这一帧的AnimBlueprint更新时,对应的Character还没更新。

为了解决这个问题,我并没有在CharacterTick函数中执行PreUpdateCamera()方法,我将这个方法的调用放在PlayerControllerTick函数中。通过这样的方法,我确保了对应的值是实时最新的。

播放Montages

整体来讲,这个系统已经可以工作了。下一步就是去播放一个可以作用于整个身体的动画。为了做到这一点,我们使用AnimMontage。在这个项目中,我需要让人物在落地后,播放一个重力缓冲的动画。该动画如下:

Fall_Overview

代码很简单,可能在Blueprint中更简单:

void AExedreCharacter::PlayAnimLanding()
{
    if( MeshBody != nullptr )
    {
        if( EPC != nullptr )
        {
            EPC->SetMovementInputEnabled( false );
            EPC->SetCameraInputEnabled( false );
            EPC->ResetFallingTime();
        }

        //Snap mesh
        FRotator TargetRotation = FRotator::ZeroRotator;

        if( EPC != nullptr )
        {
            TargetRotation.Yaw = EPC->GetControlRotation().Yaw;
        }
        else
        {
            TargetRotation.Yaw = GetActorRotation().Yaw;
        }

        SetActorRotation( TargetRotation );


        //Start anim
        SetPerformingMontage(true);

        TotalMontageDuration = MeshBody->AnimScriptInstance->Montage_Play(AnmMtgLandingFall, 1.0f);
        LatestMontageDuration = TotalMontageDuration;

        //Set Timer to the end of the duration
        FTimerHandle TimeHandler;
        this->GetWorldTimerManager().SetTimer(TimeHandler, this, &AExedreCharacter::PlayAnimLandingExit, TotalMontageDuration - 0.01f, false);
    }
}

这段代码做的事是取消玩家的输入,然后播放Montage。我设定了一个Timer,从而在动画结束的时候重新开启输入。如果你是这么做的,那么你会获得这样的结果:
Fall_Wrong

这并不是我们想要的效果。发生这种情况的原因是Anim slot先于Anim Offset节点就被设置了。因此当播放全身动画时,这个aim offset就直接被加上去了。因此如果玩家看着地面再播放这个动画,那么这个偏移就会变成双份。

那么我们为什么要将Aim offset放在之后进行计算呢?实际上这只是为了在状态之间进行更顺滑的切换。如果在Aim offset之后再进行montage的播放,那么整个的切换会非常尖锐。

为了解决这个问题,我将Camera Rotation值进行了一次重置。我在PreUpdateCamera函数中加入了如下代码:

    //-------------------------------------------------------
    // Blend Pitch to 0.0 if we are performing a montage (input are disabled)
    //-------------------------------------------------------
    if( IsPerformingMontage() )
    {
        //Reset camera rotation to 0 for when the Montage finish
        FRotator TargetControl = EPC->GetControlRotation();
        TargetControl.Pitch = 0.0f;

        float BlenSpeed = 300.0f;

        TargetControl = FMath::RInterpConstantTo( EPC->GetControlRotation(), TargetControl, DeltaTime, BlenSpeed);

        EPC->SetControlRotation( TargetControl );
    }

以上的代码只是在下落过程中,在本地相机的旋转值计算之前,将其Pitch值通过RInterpConstantTo()函数逐渐设为0.以下是最终效果:

Fall_Good

相比来讲好多了。在此之外,可以再做一个在Montage结尾的时候,将其设回最初的Rotation,但是这个在这个项目中并不太重要。

防止运动眩晕的方法

最后一点,当使用全身动画时,需要注意那些针对头部的运动操作。不停点头、快速转身之类的快速动画容易使得玩家感到恶心。因此跑步和走路的动画需要尽可能的稳定。这一点和VR中的眩晕很类似——产生这种眩晕的原因是玩家的感觉和看到的东西并不一致。

在我的项目中,我针对了大部分的重复动画(例如跑步)使用了一个方法——将玩家的角色进行约束,让其总是看着很远处的一个固定点。这样的方法能够使得头部尽量聚焦于一点,从而稳定相机。
Animation Constraint

在AnimationBP的这一层之后,你可以使用一些额外的处理来进行身体动画的操作。这样做的好处是可以很好的进行状态之间的切换,并且减少眩晕感。

<全文完>

  • 13
    点赞
  • 55
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值