同步
部分同步原理,有必要了解。
NetRole
/** The network role of an actor on a local/remote network context */ UENUM() enum ENetRole { /** No role at all. */ ROLE_None, /** Locally simulated proxy of this actor. */ ROLE_SimulatedProxy, /** Locally autonomous proxy of this actor. */ ROLE_AutonomousProxy, /** Authoritative control over the actor. */ ROLE_Authority, ROLE_MAX, };
NetRole主要分三種,一個是SimulatedProxy,這個是根據服務器下發的數據進行仿真的,第二個是AutonomousProxy,就是玩家控制的角色之類,可以同服務器進行交互,第三個是Authority,指的是誰擁有這個Actor,一般來說是服務器,有時候客戶端自己創建的Actor也可是是Authority。
數據同步和RPC
數據同步是單向的,由服務器向客戶端,有同步間隔(延遲),但是可以更改。
RPC可以是雙向的,可靠或者不可靠,比數據同步快。
INetworkPredictionInterface
class ENGINE_API INetworkPredictionInterface { GENERATED_IINTERFACE_BODY() //-------------------------------- // Server hooks //-------------------------------- /** (Server) Send position to client if necessary, or just ack good moves. */ virtual void SendClientAdjustment() PURE_VIRTUAL(INetworkPredictionInterface::SendClientAdjustment,); /** (Server) Trigger a position update on clients, if the server hasn't heard from them in a while. @return Whether movement is performed. */ virtual bool ForcePositionUpdate(float DeltaTime) PURE_VIRTUAL(INetworkPredictionInterface::ForcePositionUpdate, return false;); //-------------------------------- // Client hooks //-------------------------------- /** (Client) After receiving a network update of position, allow some custom smoothing, given the old transform before the correction and new transform from the update. */ virtual void SmoothCorrection(const FVector& OldLocation, const FQuat& OldRotation, const FVector& NewLocation, const FQuat& NewRotation) PURE_VIRTUAL(INetworkPredictionInterface::SmoothCorrection,); //-------------------------------- // Other //-------------------------------- /** @return FNetworkPredictionData_Client instance used for network prediction. */ virtual class FNetworkPredictionData_Client* GetPredictionData_Client() const PURE_VIRTUAL(INetworkPredictionInterface::GetPredictionData_Client, return NULL;); /** @return FNetworkPredictionData_Server instance used for network prediction. */ virtual class FNetworkPredictionData_Server* GetPredictionData_Server() const PURE_VIRTUAL(INetworkPredictionInterface::GetPredictionData_Server, return NULL;); /** Accessor to check if there is already client data, without potentially allocating it on demand.*/ virtual bool HasPredictionData_Client() const PURE_VIRTUAL(INetworkPredictionInterface::HasPredictionData_Client, return false;); /** Accessor to check if there is already server data, without potentially allocating it on demand.*/ virtual bool HasPredictionData_Server() const PURE_VIRTUAL(INetworkPredictionInterface::HasPredictionData_Server, return false;); /** Resets client prediction data. */ virtual void ResetPredictionData_Client() PURE_VIRTUAL(INetworkPredictionInterface::ResetPredictionData_Client,); /** Resets server prediction data. */ virtual void ResetPredictionData_Server() PURE_VIRTUAL(INetworkPredictionInterface::ResetPredictionData_Server,); };
這個是網絡預測和糾正的接口,CharacterMovement繼承這個接口,服務器和客戶端通過這個接口進行Transform變換。
Actor的同步
Actor的Transform實際上是根組件的Transform,因此對Actor的同步實際上就是對根組件的同步。
在客戶端和服務器的連接后,會在連接的Socket上面建立一個個Channel,服務器上面的每一個對象都對應着一個ActorChannel,通過這個Channel,客戶端和服務器的Actor建立通信通道,然后會進行數據同步和RPC調用,以及發起屬性通知。
在Actor中,有一個標記bReplicateMovement被用來標記Actor是否同步,這個屬性在藍圖的屬性面板上面也有,如果標記為True,那么會進行相關的同步操作。
/** * If true, replicate movement/location related properties. * Actor must also be set to replicate. * @see SetReplicates() * @see https://docs.unrealengine.com/latest/INT/Gameplay/Networking/Replication/ */ UPROPERTY(ReplicatedUsing=OnRep_ReplicateMovement, Category=Replication, EditDefaultsOnly) uint8 bReplicateMovement:1;
Actor的Transform屬性是通過一個特殊的結構體ReplicatedMovement來進行傳遞的,里面包含了相關的需要同步的屬性,在ReplicatedMovement中的屬性值發生改變的時候,會調用OnRep_ReplicatedMovement進行事件通知。
/** Used for replication of our RootComponent's position and velocity */ UPROPERTY(EditDefaultsOnly, ReplicatedUsing=OnRep_ReplicatedMovement, Category=Replication, AdvancedDisplay) struct FRepMovement ReplicatedMovement; /** Replicated movement data of our RootComponent. * Struct used for efficient replication as velocity and location are generally replicated together (this saves a repindex) * and velocity.Z is commonly zero (most position replications are for walking pawns). */ USTRUCT() struct ENGINE_API FRepMovement { GENERATED_BODY() /** Velocity of component in world space */ UPROPERTY(Transient) FVector LinearVelocity; /** Velocity of rotation for component */ UPROPERTY(Transient) FVector AngularVelocity; /** Location in world space */ UPROPERTY(Transient) FVector Location; /** Current rotation */ UPROPERTY(Transient) FRotator Rotation; /** If set, RootComponent should be sleeping. */ UPROPERTY(Transient) uint8 bSimulatedPhysicSleep : 1; /** If set, additional physic data (angular velocity) will be replicated. */ UPROPERTY(Transient) uint8 bRepPhysics : 1; /** Allows tuning the compression level for the replicated location vector. You should only need to change this from the default if you see visual artifacts. */ UPROPERTY(EditDefaultsOnly, Category=Replication, AdvancedDisplay) EVectorQuantization LocationQuantizationLevel; /** Allows tuning the compression level for the replicated velocity vectors. You should only need to change this from the default if you see visual artifacts. */ UPROPERTY(EditDefaultsOnly, Category=Replication, AdvancedDisplay) EVectorQuantization VelocityQuantizationLevel; /** Allows tuning the compression level for replicated rotation. You should only need to change this from the default if you see visual artifacts. */ UPROPERTY(EditDefaultsOnly, Category=Replication, AdvancedDisplay) ERotatorQuantization RotationQuantizationLevel; }
ReplicatedMovement 僅僅是一個用來同步的中間值,並不是Actor的原始數據,對Actor的Transform操作並不會直接作用於 ReplicatedMovement,那么Actor的真實數據是怎么同步到 ReplicatedMovement然后再同步到客戶端的呢?
在服務器對Actor進行同步的時候,會調用PreReplication事件,在這個事件中會使用GatherCurrentMovement函數從當前的Actor信息填充ReplicatedMovement結構體。
void AActor::PreReplication( IRepChangedPropertyTracker & ChangedPropertyTracker ) { // Attachment replication gets filled in by GatherCurrentMovement(), but in the case of a detached root we need to trigger remote detachment. AttachmentReplication.AttachParent = nullptr; AttachmentReplication.AttachComponent = nullptr; GatherCurrentMovement(); DOREPLIFETIME_ACTIVE_OVERRIDE( AActor, ReplicatedMovement, bReplicateMovement ); // Don't need to replicate AttachmentReplication if the root component replicates, because it already handles it. DOREPLIFETIME_ACTIVE_OVERRIDE( AActor, AttachmentReplication, RootComponent && !RootComponent->GetIsReplicated() ); UBlueprintGeneratedClass* BPClass = Cast<UBlueprintGeneratedClass>(GetClass()); if (BPClass != nullptr) { BPClass->InstancePreReplication(this, ChangedPropertyTracker); } }
ReplicatedMovement同步到客戶端之后,會調用OnRep_ReplicatedMovement事件通知,在這個事件中,通過PostNetReceiveVelocity和PostNetReceiveLocationAndRotation來設置位置、旋轉和速度。
Character的同步
Character繼承Actor,同步方式也很類似,不過和 CharacterMovementComponent的聯系很多,而這個Movement組件在大量角色存在的情況下往往形成瓶頸。
Character另外還多同步了一些變量。
void ACharacter::GetLifetimeReplicatedProps( TArray< FLifetimeProperty > & OutLifetimeProps ) const { Super::GetLifetimeReplicatedProps( OutLifetimeProps ); DISABLE_REPLICATED_PROPERTY(ACharacter, JumpMaxHoldTime); DISABLE_REPLICATED_PROPERTY(ACharacter, JumpMaxCount); DOREPLIFETIME_CONDITION( ACharacter, RepRootMotion, COND_SimulatedOnly ); DOREPLIFETIME_CONDITION( ACharacter, ReplicatedBasedMovement, COND_SimulatedOnly ); DOREPLIFETIME_CONDITION( ACharacter, ReplicatedServerLastTransformUpdateTimeStamp, COND_SimulatedOnlyNoReplay ); DOREPLIFETIME_CONDITION( ACharacter, ReplicatedMovementMode, COND_SimulatedOnly ); DOREPLIFETIME_CONDITION( ACharacter, bIsCrouched, COND_SimulatedOnly ); DOREPLIFETIME_CONDITION( ACharacter, bProxyIsJumpForceApplied, COND_SimulatedOnly ); DOREPLIFETIME_CONDITION( ACharacter, AnimRootMotionTranslationScale, COND_SimulatedOnly ); DOREPLIFETIME_CONDITION( ACharacter, ReplayLastTransformUpdateTimeStamp, COND_ReplayOnly ); }
ReplicatedBasedMovement是用來同步Base的,Character會檢測當前所在的Base。
在UCharacterMovementComponent::TickComponent中,會根據當前Actor的NetRole,采取對應的措施來進行更新。
移動
這部分關於Character移動及位置更新的東西。
流程
主要看下玩家操作角色,同步到DS,然后再同步到客戶端的過程。(從APawn::AddMovementInput看就行了,很清晰。另外要注意在DS,Host,Client等模式下的NetRole問題,這對理解哪塊代碼實在哪個地方運行的很重要。)
玩家操作角色,產生ControlInputVector,保存在Pawn中,然后MovementComponent會ConsumeInputVector,把這個input轉換為Acceleration,然后ReplicateMoveToServer發送到服務器。
ReplicateMoveToServer 在發送移動請求到服務器之前,會將Move保存為FSavedMovePtr,然后本地PerformMovement(會產生大量的檢測),接着會把NewMove增加到移動列表中,然后發起RPC調用,經過一系列調用之后最終調用到服務器上面的UCharacterMovementComponent::ServerMove_Implementation。這兒也可以進行優化,主要是網絡上面的同步頻率。
// Decide whether to hold off on move const float NetMoveDelta = FMath::Clamp(GetClientNetSendDeltaTime(PC, ClientData, NewMovePtr), 1.f/120.f, 1.f/5.f); if ((MyWorld->TimeSeconds - ClientData->ClientUpdateTime) * MyWorld->GetWorldSettings()->GetEffectiveTimeDilation() < NetMoveDelta) { // Delay sending this move. ClientData->PendingMove = NewMovePtr; return; }
在服務器上,會調用MoveAutonomous,然后調用PerformMovement進行移動,如果必要的話再通過INetworkPredictionInterface接口進行位置修正。
對於其他客戶端,會執行SimulatedTick,結合Actor和Character中的同步變量進行位置更新,而更新的操作是通過PerformMovement或者SimulateMovement來完成的。
通過上面的分析可以知道,CharacterMovement的優化應主要集中於 PerformMovement和SimulateMovement上面。
MovementComponent的部分代碼
void UCharacterMovementComponent::TickComponent(float DeltaTime, enum ELevelTick TickType, FActorComponentTickFunction *ThisTickFunction) { SCOPED_NAMED_EVENT(UCharacterMovementComponent_TickComponent, FColor::Yellow); SCOPE_CYCLE_COUNTER(STAT_CharacterMovement); SCOPE_CYCLE_COUNTER(STAT_CharacterMovementTick); CSV_SCOPED_TIMING_STAT_EXCLUSIVE(CharacterMovement); const FVector InputVector = ConsumeInputVector(); if (!HasValidData() || ShouldSkipUpdate(DeltaTime)) { return; } Super::TickComponent(DeltaTime, TickType, ThisTickFunction); // Super tick may destroy/invalidate CharacterOwner or UpdatedComponent, so we need to re-check. if (!HasValidData()) { return; } // See if we fell out of the world. const bool bIsSimulatingPhysics = UpdatedComponent->IsSimulatingPhysics(); if (CharacterOwner->GetLocalRole() == ROLE_Authority && (!bCheatFlying || bIsSimulatingPhysics) && !CharacterOwner->CheckStillInWorld()) { return; } // We don't update if simulating physics (eg ragdolls). if (bIsSimulatingPhysics) { // Update camera to ensure client gets updates even when physics move him far away from point where simulation started if (CharacterOwner->GetLocalRole() == ROLE_AutonomousProxy && IsNetMode(NM_Client)) { MarkForClientCameraUpdate(); } ClearAccumulatedForces(); return; } AvoidanceLockTimer -= DeltaTime; if (CharacterOwner->GetLocalRole() > ROLE_SimulatedProxy) { SCOPE_CYCLE_COUNTER(STAT_CharacterMovementNonSimulated); // If we are a client we might have received an update from the server. const bool bIsClient = (CharacterOwner->GetLocalRole() == ROLE_AutonomousProxy && IsNetMode(NM_Client)); if (bIsClient) { FNetworkPredictionData_Client_Character* ClientData = GetPredictionData_Client_Character(); if (ClientData && ClientData->bUpdatePosition) { ClientUpdatePositionAfterServerUpdate(); } } // Allow root motion to move characters that have no controller. if( CharacterOwner->IsLocallyControlled() || (!CharacterOwner->Controller && bRunPhysicsWithNoController) || (!CharacterOwner->Controller && CharacterOwner->IsPlayingRootMotion()) ) { { SCOPE_CYCLE_COUNTER(STAT_CharUpdateAcceleration); // We need to check the jump state before adjusting input acceleration, to minimize latency // and to make sure acceleration respects our potentially new falling state. CharacterOwner->CheckJumpInput(DeltaTime); // apply input to acceleration Acceleration = ScaleInputAcceleration(ConstrainInputAcceleration(InputVector)); AnalogInputModifier = ComputeAnalogInputModifier(); } if (CharacterOwner->GetLocalRole() == ROLE_Authority) { PerformMovement(DeltaTime); } else if (bIsClient) { ReplicateMoveToServer(DeltaTime, Acceleration); } } else if (CharacterOwner->GetRemoteRole() == ROLE_AutonomousProxy) { // Server ticking for remote client. // Between net updates from the client we need to update position if based on another object, // otherwise the object will move on intermediate frames and we won't follow it. MaybeUpdateBasedMovement(DeltaTime); MaybeSaveBaseLocation(); // Smooth on listen server for local view of remote clients. We may receive updates at a rate different than our own tick rate. if (CharacterMovementCVars::NetEnableListenServerSmoothing && !bNetworkSmoothingComplete && IsNetMode(NM_ListenServer)) { SmoothClientPosition(DeltaTime); } } } else if (CharacterOwner->GetLocalRole() == ROLE_SimulatedProxy) { if (bShrinkProxyCapsule) { AdjustProxyCapsuleSize(); } SimulatedTick(DeltaTime); } if (bUseRVOAvoidance) { UpdateDefaultAvoidance(); } if (bEnablePhysicsInteraction) { SCOPE_CYCLE_COUNTER(STAT_CharPhysicsInteraction); ApplyDownwardForce(DeltaTime); ApplyRepulsionForce(DeltaTime); } #if !(UE_BUILD_SHIPPING || UE_BUILD_TEST) const bool bVisualizeMovement = CharacterMovementCVars::VisualizeMovement > 0; if (bVisualizeMovement) { VisualizeMovement(); } #endif // !(UE_BUILD_SHIPPING || UE_BUILD_TEST) }
PerformMovement
UE4的服務器、客戶端模式下面的Character同步效果很好,但這是以犧牲性能為代價的,原因在於移動和同步操作進行了大量的操作。根據之前的經驗,客戶端在30-50人的情況下就會產生性能問題,打開Profile往往都是一堆的Movement相關問題。
PerformMovement主要做了下面一些工作
- 更新Pose(RootMotion和動畫相關)
- 更新Base(Base是Actor所處下方的組件,Base可帶着Actor移動、旋轉)
- 更新MovementMode
- 根據MovementMode執行對應的移動操作
- 更新最終的位置姿態
由於MovementMode較多,這里簡要分析Walking的邏輯
Walking會根據移動的參數,比如加速度,速度,進行移動。移動不是直接移動到目標位置,而是會多次迭代。移動時,會檢測floor和行走的坡度,還會處理移動前后的碰撞,穿透問題。很明顯,這里面會有大量的數學計算,在角色數量多的情況下可能會造成過多的CPU占用。
SimulateMovement
SimulateMovement主要是用來模擬其他客戶端的移動的操作。這個操作和 PerformMovement 中的操作有些不通,其中最大的不同是使用MoveSmooth替代了具體的移動操作。相對來說比AutonomousProxy要簡單不少,但是floor檢測和穿透處理等等操作都還在,仍然會引起性能問題。
(Profile就不搞了,打開編輯器扔幾十個Character就能看到了。)
下面是穿透的處理,角色多的時候被彈飛就是下面代碼引起的。
bool UMovementComponent::ResolvePenetrationImpl(const FVector& ProposedAdjustment, const FHitResult& Hit, const FQuat& NewRotationQuat)
{
// SceneComponent can't be in penetration, so this function really only applies to PrimitiveComponent.
const FVector Adjustment = ConstrainDirectionToPlane(ProposedAdjustment);
if (!Adjustment.IsZero() && UpdatedPrimitive)
{
QUICK_SCOPE_CYCLE_COUNTER(STAT_MovementComponent_ResolvePenetration);
// See if we can fit at the adjusted location without overlapping anything.
AActor* ActorOwner = UpdatedComponent->GetOwner();
if (!ActorOwner)
{
return false;
}
UE_LOG(LogMovement, Verbose, TEXT("ResolvePenetration: %s.%s at location %s inside %s.%s at location %s by %.3f (netmode: %d)"),
*ActorOwner->GetName(),
*UpdatedComponent->GetName(),
*UpdatedComponent->GetComponentLocation().ToString(),
*GetNameSafe(Hit.GetActor()),
*GetNameSafe(Hit.GetComponent()),
Hit.Component.IsValid() ? *Hit.GetComponent()->GetComponentLocation().ToString() : TEXT("<unknown>"),
Hit.PenetrationDepth,
(uint32)GetNetMode());
// We really want to make sure that precision differences or differences between the overlap test and sweep tests don't put us into another overlap,
// so make the overlap test a bit more restrictive.
const float OverlapInflation = MovementComponentCVars::PenetrationOverlapCheckInflation;
bool bEncroached = OverlapTest(Hit.TraceStart + Adjustment, NewRotationQuat, UpdatedPrimitive->GetCollisionObjectType(), UpdatedPrimitive->GetCollisionShape(OverlapInflation), ActorOwner);
if (!bEncroached)
{
// Move without sweeping.
MoveUpdatedComponent(Adjustment, NewRotationQuat, false, nullptr, ETeleportType::TeleportPhysics);
UE_LOG(LogMovement, Verbose, TEXT("ResolvePenetration: teleport by %s"), *Adjustment.ToString());
return true;
}
else
{
// Disable MOVECOMP_NeverIgnoreBlockingOverlaps if it is enabled, otherwise we wouldn't be able to sweep out of the object to fix the penetration.
TGuardValue<EMoveComponentFlags> ScopedFlagRestore(MoveComponentFlags, EMoveComponentFlags(MoveComponentFlags & (~MOVECOMP_NeverIgnoreBlockingOverlaps)));
// Try sweeping as far as possible...
FHitResult SweepOutHit(1.f);
bool bMoved = MoveUpdatedComponent(Adjustment, NewRotationQuat, true, &SweepOutHit, ETeleportType::TeleportPhysics);
UE_LOG(LogMovement, Verbose, TEXT("ResolvePenetration: sweep by %s (success = %d)"), *Adjustment.ToString(), bMoved);
// Still stuck?
if (!bMoved && SweepOutHit.bStartPenetrating)
{
// Combine two MTD results to get a new direction that gets out of multiple surfaces.
const FVector SecondMTD = GetPenetrationAdjustment(SweepOutHit);
const FVector CombinedMTD = Adjustment + SecondMTD;
if (SecondMTD != Adjustment && !CombinedMTD.IsZero())
{
bMoved = MoveUpdatedComponent(CombinedMTD, NewRotationQuat, true, nullptr, ETeleportType::TeleportPhysics);
UE_LOG(LogMovement, Verbose, TEXT("ResolvePenetration: sweep by %s (MTD combo success = %d)"), *CombinedMTD.ToString(), bMoved);
}
}
// Still stuck?
if (!bMoved)
{
// Try moving the proposed adjustment plus the attempted move direction. This can sometimes get out of penetrations with multiple objects
const FVector MoveDelta = ConstrainDirectionToPlane(Hit.TraceEnd - Hit.TraceStart);
if (!MoveDelta.IsZero())
{
bMoved = MoveUpdatedComponent(Adjustment + MoveDelta, NewRotationQuat, true, nullptr, ETeleportType::TeleportPhysics);
UE_LOG(LogMovement, Verbose, TEXT("ResolvePenetration: sweep by %s (adjusted attempt success = %d)"), *(Adjustment + MoveDelta).ToString(), bMoved);
// Finally, try the original move without MTD adjustments, but allowing depenetration along the MTD normal.
// This was blocked because MOVECOMP_NeverIgnoreBlockingOverlaps was true for the original move to try a better depenetration normal, but we might be running in to other geometry in the attempt.
// This won't necessarily get us all the way out of penetration, but can in some cases and does make progress in exiting the penetration.
if (!bMoved && FVector::DotProduct(MoveDelta, Adjustment) > 0.f)
{
bMoved = MoveUpdatedComponent(MoveDelta, NewRotationQuat, true, nullptr, ETeleportType::TeleportPhysics);
UE_LOG(LogMovement, Verbose, TEXT("ResolvePenetration: sweep by %s (Original move, attempt success = %d)"), *(MoveDelta).ToString(), bMoved);
}
}
}
return bMoved;
}
}
return false;
}
優化
配置相關
比如同步的頻率,迭代次數,floor check,服務器的Pose更新等等,這些直接在編輯器中點點或者或者修改下配置就行了,分分鍾的事情。
簡化移動
由於在組件位置移動的時候會進行大量的碰撞檢測,因此這一部分也可以考慮優化,比如簡化碰撞模型及算法,或者重寫對應的膠囊體移動操作。(見UPrimitiveComponent::MoveComponentImpl)
考慮到性能和效果之間的平衡,有必要根據不同的NetRole給定不同的處理策略。
可見性
更改可見性和設置網絡相關性,控制客戶端的角色數量。
對於近處角色,可以采用效果好的方式,遠處采用簡化版的移動。
Custom
自定義Pawn和Capsule組件。優點在於可定制性強,但是要做到比較好的效果可能要花費比較多的時間和精力。
其實客戶端和服務器的數據同步接口UE4已經做好了,剩下的主要就是客戶端和服務器對移動的仿真問題,這個按照項目需求來就行了,適合於有錢有人有時間的項目組。