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