UE4的Character、Movement組件分析及優化



同步

部分同步原理,有必要了解。

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已經做好了,剩下的主要就是客戶端和服務器對移動的仿真問題,這個按照項目需求來就行了,適合於有錢有人有時間的項目組。


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM