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(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(""),
			   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 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已经做好了,剩下的主要就是客户端和服务器对移动的仿真问题,这个按照项目需求来就行了,适合于有钱有人有时间的项目组。

你可能感兴趣的:(UE4的Character、Movement组件分析及优化)