前言
關於UE4的移動元件,我寫了一篇非常詳細的分析文件。由於篇幅比較大,我將其拆分成三個部分。分別從移動框架與實現原理,移動的網路同步,移動元件的優化與改造三個方面來寫。這三篇文件中難免有問題和漏洞,所以我也會在發現問題時及時更新和修改,也希望大家能給出一些建議。

一.深刻理解移動元件的意義

在大部分遊戲中,玩家的移動是最最核心的一個基本操作。UE提供的GamePlay框架就給開發者提供了一個比較完美的移動解決方案。由於UE採用了元件化的設計思路,所以這個移動解決方案的核心功能就都交給了移動元件來完成。移動可能根據遊戲的複雜程度有不同的處理,如果是一個簡單的俯視視角RTS型別的遊戲,可能只提供基本的座標移動就可以了;而對於第一人稱的RPG遊戲,玩家可能上天入地,潛水飛行,那需要的移動就要更復雜一些。但是不管是哪一種,UE都基本上幫我們實現了,這也得益於其早期的FPS遊戲的開發經驗。

然而,引擎提供的基本移動並不一定能完成我們的目標,我們也不應該因此侷限我們的設計。比如輕功的飛檐走壁,魔法飛船的超重力,彈簧鞋,噴氣揹包飛行控制,這些效果都需要我們自己去進一步的處理移動邏輯,我們可以在其基礎上修改,也可以自定義自己的移動模式。不管怎麼樣,這些操作都需要對移動元件進行細緻入微的調整,所以我們就必須要深刻理解移動元件的實現原理。

再者,在一個網路遊戲中,我們對移動的處理會更加的複雜。如何讓不同客戶端的玩家都體驗到流暢的移動表現?如何保證角色不會由於一點點的延遲而產生“瞬移”?UE對這方面的處理都值得我們去學習與思考。

移動元件看起來只是一個和移動相關的元件,但其本身涉及到狀態機,同步解決方案,物理模組,不同移動狀態的細節處理,動畫以及與其他元件(Actor)之間的呼叫關係等相關內容,足夠花上一段時間去好好研究。這篇文章會從移動的基本原理,移動狀態的細節處理,移動同步的解決方案几個角度儘可能詳細的分析其實現原理,然後幫助大家快速理解並更好的使用移動元件。最後,給出幾個特殊移動模式的實現思路供大家參考。

二.移動實現的基本原理

2.1 移動元件與玩家角色

角色的移動本質上就是合理的改變座標位置,在UE裡面角色移動的本質就是修改某個特定元件的座標位置。圖2-1是我們常見的一個Character的元件構成情況,可以看到我們通常將CapsuleComponent(膠囊體)作為自己的根元件,而Character的座標本質上就是其RootComponent的座標,Mesh網格等其他元件都會跟隨膠囊體而移動。移動元件在初始化的時候會把膠囊體設定為移動基礎元件UpdateComponent,隨後的操作都是在計算UpdateComponent的位置。

這裡寫圖片描述
圖2-1 一個預設Character的元件構成

當然,我們也並不是一定要設定膠囊體為UpdateComponent,對於DefaultPawn(觀察者)會把他的SphereComponent作為UpdateComponent,對於交通工具物件AWheeledVehicle會預設把他的Mesh網格元件作為UpdateComponent。你可以自己定義你的UpdateComponent,但是你的自定義元件必須要繼承USceneComponent(換句話說就是元件得有世界座標資訊),這樣他才能正常的實現其移動的邏輯。

2.2 移動元件繼承樹

移動元件類並不是只有一個,他通過一個繼承樹,逐漸擴充套件了移動元件的能力。從最簡單的提供移動功能,到可以正確模擬不同移動狀態的移動效果。如圖2-2所示
這裡寫圖片描述
圖2-2 移動元件繼承關係類圖

移動元件類一共四個。首先是UMovementComponent,作為移動元件的基類實現了基本的移動介面SafeMovementUpdatedComponent(),可以呼叫UpdateComponent元件的介面函式來更新其位置。

bool UMovementComponent::MoveUpdatedComponentImpl( const FVector& Delta, const FQuat& NewRotation, bool bSweep, FHitResult* OutHit, ETeleportType Teleport)
{
    if (UpdatedComponent)
    {
        const FVector NewDelta = ConstrainDirectionToPlane(Delta);
        return UpdatedComponent->MoveComponent(NewDelta, NewRotation, bSweep, OutHit, MoveComponentFlags, Teleport);
    }

    return false;
}

通過上圖可以看到UpdateComponent的型別是UScenceComponent,UScenceComponent型別的元件提供了基本的位置資訊——ComponentToWorld,同時也提供了改變自身以及其子元件的位置的介面InternalSetWorldLocationAndRotation()。而UPrimitiveComponent又繼承於UScenceComponent,增加了渲染以及物理方面的資訊。我們常見的Mesh元件以及膠囊體都是繼承自UPrimitiveComponent,因為想要實現一個真實的移動效果,我們時刻都可能與物理世界的某一個Actor接觸著,而且移動的同時還需要渲染出我們移動的動畫來表現給玩家看。

下一個元件是UNavMovementComponent,該元件更多的是提供給AI尋路的能力,同時包括基本的移動狀態,比如是否能游泳,是否能飛行等。

UPawnMovementComponent元件開始變得可以和玩家互動了,前面都是基本的移動介面,不手動呼叫根本無法實現玩家操作。UPawnMovementComponent提供了AddInputVector(),可以實現接收玩家的輸入並根據輸入值修改所控制Pawn的位置。要注意的是,在UE中,Pawn是一個可控制的遊戲角色(也可以是被AI控制),他的移動必須與UPawnMovementComponent配合才行,所以這也是名字的由來吧。一般的操作流程是,玩家通過InputComponent元件繫結一個按鍵操作,然後在按鍵響應時呼叫Pawn的AddMovementInput介面,進而呼叫移動元件的AddInputVector(),呼叫結束後會通過ConsumeMovementInputVector()介面消耗掉該次操作的輸入數值,完成一次移動操作。

最後到了移動元件的重頭了UCharacterMovementComponent,該元件可以說是Epic做了多年遊戲的經驗集成了,裡面非常精確的處理了各種常見的移動狀態細節,實現了比較流暢的同步解決方案。各種位置校正,平滑處理才達到了目前的移動效果,而且我們不需要自己寫程式碼就會使用這個完成度的相當高的移動元件,可以說確實很適合做第一,第三人稱的RPG遊戲了。

其實還有一個比較常用的移動元件,UProjectileMovementComponent ,一般用來模擬弓箭,子彈等拋射物的運動狀態。不過,這篇文件不將重點放在這裡。

2.3 移動元件相關類關係簡析

前面主要針對移動元件本身進行了分析,這裡更全面的概括一下移動的整個框架。(參考圖2-3)
這裡寫圖片描述
圖2-3 移動框架相關類圖

在一個普通的三維空間裡,最簡單的移動就是直接修改角色的座標。所以,我們的角色只要有一個包含座標資訊的元件,就可以通過基本的移動元件完成移動。但是隨著遊戲世界的複雜程度加深,我們在遊戲裡面添加了可行走的地面,可以探索的海洋。我們發現移動就變得複雜起來,玩家的腳下有地面才能行走,那就需要不停的檢測地面碰撞資訊(FFindFloorResult,FBasedMovementInfo);玩家想進入水中游泳,那就需要檢測到水的體積(GetPhysicsVolume(),Overlap事件,同樣需要物理);水中的速度與效果與陸地上差別很大,那就把兩個狀態分開寫(PhysSwimming,PhysWalking);移動的時候動畫動作得匹配上啊,那就在更新位置的時候,更新動畫(TickCharacterPose);移動的時候碰到障礙物怎麼辦,被其他玩家推怎麼處理(MoveAlongFloor裡有相關處理);遊戲內容太少,想增加一些可以自己尋路的NPC,又需要設定導航網格(涉及到FNavAgentProperties);一個玩家太無聊,那就讓大家一起聯機玩(模擬移動同步FRepMovement,客戶端移動修正ClientUpdatePositionAfterServerUpdate)。

這麼一看,做一個優秀移動元件還真不簡單。但是不管怎麼樣,UE基本上都幫你實現了。通過上面的描述,你現在也大體上了解了移動元件在各個方面的處理,不過遇到具體的問題也許還是無從下手,所以咱們繼續往下分析。

三.各個移動狀態的細節處理

這一節我們把焦點集中在UCharacterMovementComponent元件上,來詳細的分析一下他是如何處理各種移動狀態下的玩家角色的。首先肯定是從Tick開始,每幀都要進行狀態的檢測與處理,狀態通過一個移動模式MovementMode來區分,在合適的時候修改為正確的移動模式。移動模式預設有6種,基本常用的模式有行走、游泳、下落、飛行四種,有一種給AI代理提供的行走模式,最後還有一個自定義移動模式。
這裡寫圖片描述
圖3-1 單機模式下的移動處理流程

3.1 Walking

行走模式可以說是所有移動模式的基礎,也是各個移動模式裡面最為複雜的一個。為了模擬出出真實世界的移動效果,玩家的腳下必須要有一個可以支撐不會掉落的物理物件,就好像地面一樣。在移動元件裡面,這個地面通過成員變數FFindFloorResult CurrentFloor來記錄。在遊戲一開始的時候,移動元件就會根據配置設定預設的MovementMode,如果是Walking,就會通過FindFloor操作來找到當前的地面,CurrentFloor的初始化堆疊如下圖3-2(Character Restart()的會覆蓋Pawn的Restart()):
這裡寫圖片描述
圖3-2

下面先分析一下FindFloor的流程,FindFloor本質上就是通過膠囊體的Sweep檢測來找到腳下的地面,所以地面必須要有物理資料,而且通道型別要設定與玩家的Pawn有Block響應。這裡還有一些小的細節,比如我們在尋找地面的時候,只考慮腳下位置附近的,而忽略掉腰部附近的物體;Sweep用的是膠囊體而不是射線檢測,方便處理斜面移動,計算可站立半徑等(參考圖3-3,HitResult裡面的Normal與ImpactNormal在膠囊體Sweep檢測時不一定相同)。另外,目前Character的移動是基於膠囊體實現的,所以一個不帶膠囊體元件的Actor是無法正常使用UCharacterMovementComponent的。
這裡寫圖片描述
圖3-3

找到了地面玩家就可以站立住麼?不一定。這裡又涉及到一個新的概念PerchRadiusThreshold,我稱他為可棲息範圍半徑,也就是可站立半徑。預設這個值為0,移動元件會忽略這個可站立半徑的相關計算,一旦這個值大於0.15,就會做進一步的判斷看看當前的地面空間是否足夠讓玩家站立在上面。

前面的準備工作完成了,現在正式進入Walking的位移計算,這一段程式碼都是在PhysWalking裡面計算的。為了表現的更為平滑流暢,UE4把一個Tick的移動分成了N段處理(每段的時間不能超過MaxSimulationTimeStep)。在處理每段時,首先把當前的位置資訊,地面資訊記錄下來。在TickComponent的時候根據玩家的按鍵時長,計算出當前的加速度。隨後在CalcVelocity()根據加速度計算速度,同時還會考慮地面摩擦,是否在水中等情況。

// apply input to acceleration
Acceleration = ScaleInputAcceleration(ConstrainInputAcceleration(InputVector));

算出速度之後,呼叫函式MoveAlongFloor()改變當前物件的座標位置。在真正呼叫移動介面SafeMoveUpdatedComponent()前還會簡單處理一種特殊的情況——玩家沿著斜面行走。正常在walking狀態下,玩家只會前後左右移動,不會有Z方向的移動速度。如果遇到斜坡怎麼辦?如果這個斜坡可以行走,就會呼叫ComputeGroundMovementDelta()函式去根據當前的水平速度計算出一個新的平行與斜面的速度,這樣可以簡單模擬一個沿著斜面行走的效果,而且一般來說上坡的時候玩家的水平速度應該減小,通過設定bMaintainHorizontalGroundVelocity為false可以自動處理這種情況。

現在看起來我們已經可以比較完美的模擬一個移動的流程了,不過仔細想一下還有一種情況沒考慮到。那就是遇到障礙的情況怎麼處理?根據我們平時遊戲經驗,遇到障礙肯定是移動失敗,還可能沿著牆面滑動一點。UE裡面確實也就是這麼處理的,在角色移動的過程中(SafeMoveUpdatedComponent),會有一個碰撞檢測流程。由於UPrimitiveComponent元件才擁有物理資料,所以這個操作是在函式UPrimitiveComponent::MoveComponentImpl裡面處理的。下面的程式碼會檢測移動過程中是否遇到了障礙,如果遇到了障礙會把HitResult返回。

FComponentQueryParams Params(PrimitiveComponentStatics::MoveComponentName, Actor);
FCollisionResponseParams ResponseParam;
InitSweepCollisionParams(Params, ResponseParam);
bool const bHadBlockingHit = MyWorld->ComponentSweepMulti(Hits, this, TraceStart, TraceEnd, InitialRotationQuat, Params);

在接收到SafeMoveUpdatedComponent()返回的HitResult後,會在下面的程式碼裡面處理碰撞障礙的情況。
1. 如果Hit.Normal在Z方向上有值而且還可以行走,那說明這是一個可以移動上去的斜面,隨後讓玩家沿著斜面移動
2. 判斷當前的碰撞體是否可以踩上去,如果可以的話就試著踩上去,如果過程中發現沒有踩上去,也會呼叫SlideAlongSurface()沿著碰撞滑動。

// UCharacterMovementComponent::PhysWalking
else if (Hit.IsValidBlockingHit())
{
    // We impacted something (most likely another ramp, but possibly a barrier).
    float PercentTimeApplied = Hit.Time;
    if ((Hit.Time > 0.f) && (Hit.Normal.Z > KINDA_SMALL_NUMBER) && IsWalkable(Hit))
    {
        // Another walkable ramp.
        const float InitialPercentRemaining = 1.f - PercentTimeApplied;
        RampVector = ComputeGroundMovementDelta(Delta * InitialPercentRemaining, Hit, false);
        LastMoveTimeSlice = InitialPercentRemaining * LastMoveTimeSlice;
        SafeMoveUpdatedComponent(RampVector, UpdatedComponent->GetComponentQuat(), true, Hit);
        const float SecondHitPercent = Hit.Time * InitialPercentRemaining;
        PercentTimeApplied = FMath::Clamp(PercentTimeApplied + SecondHitPercent, 0.f, 1.f);
    }

    if (Hit.IsValidBlockingHit())
    {
        if (CanStepUp(Hit) || (CharacterOwner->GetMovementBase() != NULL && CharacterOwner->GetMovementBase()->GetOwner() == Hit.GetActor()))
        {
            // hit a barrier, try to step up
            const FVector GravDir(0.f, 0.f, -1.f);
            if (!StepUp(GravDir, Delta * (1.f - PercentTimeApplied), Hit, OutStepDownResult))
            {
                UE_LOG(LogCharacterMovement, Verbose, TEXT("- StepUp (ImpactNormal %s, Normal %s"), *Hit.ImpactNormal.ToString(), *Hit.Normal.ToString());
                HandleImpact(Hit, LastMoveTimeSlice, RampVector);
                SlideAlongSurface(Delta, 1.f - PercentTimeApplied, Hit.Normal, Hit, true);
            }
            else
            {
                // Don't recalculate velocity based on this height adjustment, if considering vertical adjustments.
                UE_LOG(LogCharacterMovement, Verbose, TEXT("+ StepUp (ImpactNormal %s, Normal %s"), *Hit.ImpactNormal.ToString(), *Hit.Normal.ToString());
                bJustTeleported |= !bMaintainHorizontalGroundVelocity;
            }
        }
        else if ( Hit.Component.IsValid() && !Hit.Component.Get()->CanCharacterStepUp(CharacterOwner) )
        {
            HandleImpact(Hit, LastMoveTimeSlice, RampVector);
            SlideAlongSurface(Delta, 1.f - PercentTimeApplied, Hit.Normal, Hit, true);
        }
    }
}

基本上的移動處理就完成了,移動後還會立刻判斷玩家是否進入水中,或者進入Falling狀態,如果是的話立刻切換到新的狀態。
由於玩家在一幀裡面可能會從Walking,Swiming,Falling的等狀態不斷的切換,所以在每次執行移動前都會有一個iteration記錄當前幀的移動次數,如果超過限制就會取消本次的移動模擬行為。

3.2 Falling

Falling狀態也算是處理Walking以外最常見的狀態,只要玩家在空中(無論是跳起還是下落),玩家都會處於Falling狀態。與Walking相似,為了表現的更為平滑流暢,Falling的計算也把一個Tick的移動分成了N段處理(每段的時間不能超過MaxSimulationTimeStep)。在處理每段時,首先計算玩家通過輸入控制的水平速度,因為玩家在空中也可以受到玩家控制的影響。隨後,獲取重力計算速度。重力的獲取有點意思,你會發現他是通過Volume體積獲取的,

float UMovementComponent::GetGravityZ() const
{
    return GetPhysicsVolume()->GetGravityZ();
}
APhysicsVolume* UMovementComponent::GetPhysicsVolume() const
{
    if (UpdatedComponent)
    {
        return UpdatedComponent->GetPhysicsVolume();
    }
    return GetWorld()->GetDefaultPhysicsVolume();
}

Volume裡面會取WorldSetting裡面的GlobalGravityZ,這裡給我們一個提示,我們可以通過修改程式碼實現不同Volume的重力不同,實現自定義的玩法。注意,即使我們沒有處在任何一個體積裡面,他也會給我們的UpdateComponent繫結一個預設的DefaultVolume。那為什麼要有一個DefaultVolume?因為在很多邏輯處理上都需要獲取DefaultVolume以及裡面的相關的資料。比如,DefaultVolume有一個TerminalLimit,在通過重力計算下降速度的時候不可以超過這個設定的速度,我們可以通過修改該值來改變速度的限制。預設情況下,DefaultVolume裡面的很多屬性都是通過ProjectSetting裡面的Physics相關配置來初始化的。參考圖3-4
這裡寫圖片描述
圖3-4

通過獲取到的Gravity計算出當前新的FallSpeed(NewFallVelocity裡面計算,計算規則很簡單,就是單純的用當前速度-Gravity*deltaTime)。隨後再根據當前以及上一幀的速度計算出位移並進行移動,公式如下

FVector Adjusted = 0.5f*(OldVelocity + Velocity) * timeTick;
SafeMoveUpdatedComponent( Adjusted, PawnRotation, true, Hit);

前面我們計算完速度並移動玩家後,也一樣要考慮到移動碰撞問題。
第一種情況就是正常落地,如果玩家計算後發現碰撞到一個可以站立的地形,那直接呼叫ProcessLanded進行落地操作(這個判斷主要是根據碰撞點的高度來的,可以篩選掉牆面)。

第二種情況就是跳的過程中遇到一個平臺,然後檢測玩家的座標與當前碰撞點是否在一個可接受的範圍(IsWithinEdgeTolerance),是的話就執行FindFloor重新檢測一遍地面,檢測到的話就執行落地流程。

第三種情況是就是牆面等一些不可踩上去的,下落過程如果碰到障礙,首先會執行HandleImpact給碰到的物件一個力。隨後呼叫ComputeSlideVector計算一下滑動的位移,由於碰撞到障礙後,玩家的速度會有變化,這時候重新計算一下速度,再次調整玩家的位置與方向。如果玩家這時候有水平方向上的位移,還會通過LimitAirControl來限制玩家的速度,畢竟玩家在空中是無法自由控制角色的。對第三種情況做進一步的延伸,可能會出現碰撞調整後又碰到了另一個牆面,這裡Falling的處理可以讓玩家在兩個牆面找到一個合適的位置。但是仍然不能解決玩家被夾在兩個斜面但是卻無法落地的情況(或者在Waling和Falling中不斷切換)。如果有時間,我們後面可以嘗試解決這個問題,解決思路可以從FindFloor下的ComputeFloorDist函式入手,目的就是讓這個情況下玩家可以找到一個可行走的地面。
這裡寫圖片描述
圖3-5 夾在縫隙導致不停的切換狀態

3.2.1 Jump

提到Falling,不得不提跳躍這一基本操作。下面大致描述了跳躍響應的基本流程,
1. 繫結觸發響應事件

void APrimalCharacter::SetupPlayerInputComponent(class UInputComponent* PlayerInputComponent)
{
    // Set up gameplay key bindings
    check(PlayerInputComponent);
    PlayerInputComponent->BindAction("Jump", IE_Pressed, this, &ACharacter::Jump);
    PlayerInputComponent->BindAction("Jump", IE_Released, this, &ACharacter::StopJumping);
}
void ACharacter::Jump()
{
    bPressedJump = true;
    JumpKeyHoldTime = 0.0f;
}

void ACharacter::StopJumping()
{
    bPressedJump = false;
    ResetJumpState();
}

2.一旦按鍵響應立刻設定bPressedJump為true。TickComponent的幀迴圈呼叫ACharacter::CheckJumpInput來立刻檢測到是否執行跳躍操作

  • ①執行CanJump()函式,處理藍圖裡面的相關限制邏輯。如果藍圖裡面不重寫該函式,就會預設執行ACharacter::CanJumpInternal_Implementation()。這裡面是控制玩家能否跳躍的依據,比如蹲伏狀態不能跳躍,游泳狀態不能跳躍。另外,有一個JumpMaxHoldTime表示玩家按鍵超過這個值後不會觸發跳躍。JumpMaxCount表示玩家可以執行跳躍的段數。(比如二段跳)
  • ②執行CharacterMovement->DoJump(bClientUpdating)函式,執行跳躍操作,進入Falling,設定跳躍速度為JumpZVelocity,這個值不能小於0。
  • ③ 判斷const bool bDidJump = canJump && CharacterMovement &&
    DoJump;是否為真。做一些其他的相關操作。
const bool bDidJump = CanJump() && CharacterMovement->DoJump(bClientUpdating);
if (!bWasJumping && bDidJump)
{
    JumpCurrentCount++;
    OnJumped();
}

3.在一次PerformMovement結束後,就會執行ClearJumpInput,設定設定bPressedJump為false。但是不會清除JumpCurrentCount這樣可以繼續處理多段跳。

4.玩家鬆開按鍵p也會設定bPressedJump為false,清空相關狀態。如果玩家仍在空中,那也不會清除JumpCurrentCount。一旦bPressedJump為false,就不會處理任何跳躍操作了。

5.如果玩家在空中按下跳躍鍵,他也會進入ACharacter::CheckJumpInput,如果JumpCurrentCount小於JumpMaxCount,玩家就可以繼續執行跳躍操作了。
這裡寫圖片描述
圖3-6

3.3 Swiming

各個狀態的差異本質有三個點:

1.速度的不同
2.受重力影響的程度
3.慣性大小

游泳狀態表現上來看是一個有移動慣性(鬆手後不會立刻停止),受重力影響小(在水中會慢慢下落或者不動),移動速度比平時慢(表現水有阻力)的狀態。而玩家是否在水中的預設檢測邏輯也比較簡單,就是判斷當前的updateComponent所在的Volume是否是WaterVolume。(在編輯器裡面拉一個PhysicsVolume,修改屬性WaterVolume即可)

CharacterMovement元件裡面有浮力大小配置Buoyancy,根據玩家潛入水中的程度(ImmersionDepth返回0-1)可計算最終的浮力。隨後,開始要計算速度了,這時候我們需要獲取Volume裡面的摩擦力Friction,然後傳入CalcVelocity裡面,這體現出玩家在水中移動變慢的效果。隨後在Z方向通過計算浮力大小該計算該方向的速度,隨著玩家潛水的程度,你會發現玩家在Z方向的速度越來越小,一旦全身都浸入了水中,在Z軸方向的重力速度就會被完全忽略。

// UCharacterMovementComponent::PhysSwimming
const float Friction = 0.5f * GetPhysicsVolume()->FluidFriction * Depth;
CalcVelocity(deltaTime, Friction, true, BrakingDecelerationSwimming);
Velocity.Z += GetGravityZ() * deltaTime * (1.f - NetBuoyancy);

// UCharacterMovementComponent::CalcVelocity Apply fluid friction
if (bFluid)
{
    Velocity = Velocity * (1.f - FMath::Min(Friction * DeltaTime, 1.f));
}

這裡寫圖片描述
圖3-7 角色在水體積中飄浮

速度計算後,玩家就可以移動了。這裡UE單獨寫了一個介面Swim來執行移動操作,同時他考慮到如果移動後玩家離開了水體積而且超出水面過大,他機會強制把玩家調整到水面位置,表現會更好一些。

接下來還要什麼,那大家可能也猜出來了,就是處理移動中檢測到碰撞障礙的情況。基本上和之前的邏輯差不多,如果可以踩上去(StepUp())就調整玩家位置踩上去,如果踩不上去就給障礙一個力,然後順著障礙表面滑動一段距離(HandleImpact,SlideAlongSurface)。

那水中移動的慣性表現是怎麼處理的呢?其實並不是水中做了什麼特殊處理,而是計算速度時有兩個傳入的引數與Walking不同。一個是Friction表示摩擦力,另一個是BrakingDeceleration表示剎車的反向速度。
在加速度為0的時候(表示玩家的輸入已經被清空),水中的傳入的摩擦力要遠比地面摩擦裡小(0.15:8),而剎車速度為0(Walking為2048),所以ApplyVelocityBraking在處理的時候在Walking表現的好像立刻剎車一樣,而在Swim和fly等情況下就好像有移動慣性一樣。

// Only apply braking if there is no acceleration, or we are over our max speed and need to slow down to it.
if ((bZeroAcceleration && bZeroRequestedAcceleration) || bVelocityOverMax)
{
    const FVector OldVelocity = Velocity;

    const float ActualBrakingFriction = (bUseSeparateBrakingFriction ? BrakingFriction : Friction);
    ApplyVelocityBraking(DeltaTime, ActualBrakingFriction, BrakingDeceleration);

    //Don't allow braking to lower us below max speed if we started above it.   
    if (bVelocityOverMax && Velocity.SizeSquared() < FMath::Square(MaxSpeed) && FVector::DotProduct(Acceleration, OldVelocity) > 0.0f)
    {
        Velocity = OldVelocity.GetSafeNormal() * MaxSpeed;
    }
}

3.4 Flying

終於講到了最後一個移動狀態了,如果你想除錯該狀態的話,在角色的移動元件裡面修改DefaultLandMovementMode為Flying即可。
Flying和其他狀態套路差不多,而且相對更簡單一些,首先根據前面輸入計算Acceleration,然後根據摩擦力開始計算當前的速度。速度計算後呼叫SafeMoveUpdatedComponent進行移動。如果碰到障礙,就先看能不能踩上去,不能的話處理碰撞,沿著障礙表面滑動。

//UCharacterMovementComponent::PhysFlying
//RootMotion Relative
RestorePreAdditiveRootMotionVelocity();

if( !HasAnimRootMotion() && !CurrentRootMotion.HasOverrideVelocity() )
{
    if( bCheatFlying && Acceleration.IsZero() )
    {
        Velocity = FVector::ZeroVector;
    }
    const float Friction = 0.5f * GetPhysicsVolume()->FluidFriction;
    CalcVelocity(deltaTime, Friction, true, BrakingDecelerationFlying);
}
//RootMotion Relative
ApplyRootMotionToVelocity(deltaTime);

有一個關於Flying狀態的現象大家可能會產生疑問,當我設定預設移動方式為Flying的時候,玩家可以在鬆開鍵盤後進行滑行一段距離(有慣性)。但是使用GM命令的時候,為什麼就像Walking狀態一樣,鬆開按鍵後立刻停止?
其實時程式碼對cheat Flying做了特殊處理,玩家鬆開按鍵後,加速度變為0,這時候強制設定玩家速度為0。所以使用GM的表現與實際上的不太一樣。

3.5 FScopedMovementUpdate延遲更新

FScopedMovementUpdate並不是一種狀態,而是一種優化移動方案。因為大家在檢視引擎程式碼時,可能會看到在執行移動前會有下面這樣的程式碼:

// Scoped updates can improve performance of multiple MoveComponent calls.
{
    FScopedMovementUpdate ScopedMovementUpdate(UpdatedComponent, bEnableScopedMovementUpdates ? EScopedUpdate::DeferredUpdates : EScopedUpdate::ImmediateUpdates);

    MaybeUpdateBasedMovement(DeltaSeconds);

    //......其他邏輯處理,這裡不給出具體程式碼

    // Clear jump input now, to allow movement events to trigger it for next update.
    CharacterOwner->ClearJumpInput();
    // change position
    StartNewPhysics(DeltaSeconds, 0);

    //......其他邏輯處理,這裡不給出具體程式碼

    OnMovementUpdated(DeltaSeconds, OldLocation, OldVelocity);
} // End scoped movement update

為什麼要把移動的程式碼放到這個大括號裡面,FScopedMovementUpdate又是什麼東西?仔細回想一下我們前面具體的移動處理邏輯,在一個幀裡面,我們由於移動的不合法,碰到障礙等可能會多次重置或者修改我們的移動。如果只是簡單修改膠囊體的位置,其實沒什麼,不過實際上我們還要同時修改子元件的位置,更新物理體積,更新物理位置等等,而計算過程中的那些移動資料其實是沒有用的,我們只需要最後的那個移動資料。

因此使用FScopedMovementUpdate可以在其作用域範圍內,先鎖定不更新物理等物件的移動,等這次移動真正的完成後再去更新。(等到FScopedMovementUpdate析構的時候再處理)