1. 程式人生 > >Unreal Engine 4 —— 在UE4中實現真實第一人稱相機

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);
}
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13
  • 14
  • 15
  • 16

需要注意的是,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;
}
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13
  • 14
  • 15
  • 16
  • 17
  • 18
  • 19
  • 20
  • 21
  • 22

函式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;
}
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13
  • 14
  • 15
  • 16
  • 17
  • 18
  • 19
  • 20
  • 21
  • 22
  • 23
  • 24
  • 25
  • 26
  • 27
  • 28
  • 29
  • 30
  • 31
  • 32
  • 33
  • 34
  • 35
  • 36
  • 37

(譯者按:使用尤拉角真的沒問題嗎?永珍的話該怎麼辦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);
    }
}
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13
  • 14
  • 15
  • 16
  • 17
  • 18
  • 19
  • 20
  • 21
  • 22
  • 23
  • 24
  • 25
  • 26
  • 27
  • 28
  • 29
  • 30
  • 31
  • 32
  • 33
  • 34
  • 35
  • 36
  • 37

這段程式碼做的事是取消玩家的輸入,然後播放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 );
    }
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13
  • 14
  • 15

以上的程式碼只是在下落過程中,在本地相機的旋轉值計算之前,將其Pitch值通過RInterpConstantTo()函式逐漸設為0.以下是最終效果:

Fall_Good

相比來講好多了。在此之外,可以再做一個在Montage結尾的時候,將其設回最初的Rotation,但是這個在這個專案中並不太重要。

防止運動眩暈的方法

最後一點,當使用全身動畫時,需要注意那些針對頭部的運動操作。不停點頭、快速轉身之類的快速動畫容易使得玩家感到噁心。因此跑步和走路的動畫需要儘可能的穩定。這一點和VR中的眩暈很類似——產生這種眩暈的原因是玩家的感覺和看到的東西並不一致。

在我的專案中,我針對了大部分的重複動畫(例如跑步)使用了一個方法——將玩家的角色進行約束,讓其總是看著很遠處的一個固定點。這樣的方法能夠使得頭部儘量聚焦於一點,從而穩定相機。 Animation Constraint

在AnimationBP的這一層之後,你可以使用一些額外的處理來進行身體動畫的操作。這樣做的好處是可以很好的進行狀態之間的切換,並且減少眩暈感。

<全文完>