距离上一次发表《UE网络通信》系列的文章已经过去了一年多。这段时间,UE5.0在2022年4月发布;UE5.1在2022年11月发布。好在新版本,引擎在同步方面尚未做大的变更;之前立的关于RPC,底层协议的写作flag,还是可以继续进行。
这段时间,本人工作中,陆续接触了一些Unreal移动同步相关的Bug,同时对移动同步的代码做了进一步的研究, 写了一些随笔。本文整理之前随笔的内容,同时增加移动同步的基础RPC相关内容,按如下顺序展开:
RPC(远程过程调用)和属性同步是Unreal网络同步的两大手段。二者的实现都依赖于Unreal类型系统的反射机制。
如果单独讲Unreal的反射实现,都完全可以开一篇新的文章。针对本文内容,读者可以粗略了解Unreal类型系统和反射机制的原理即可:
有了对反射的理解,我们以移动的RPC为例,介绍下远程过程调用的全过程。对于RPC相对比较熟悉的同学可以自行跳过。
所有的RPC都需要声明为UFUNCTION。在Unreal内部有三种RPC机制,
移动的RPC调用显然属于第二种,它的声明如下:
UFUNCTION(unreliable, server, WithValidation)
void ServerMovePacked(const FCharacterServerMovePackedBits& PackedBits);
反射系统会生成ServerMovePacked的函数体,不需要开发者自己实现。
static FName NAME_ACharacter_ServerMovePacked = FName(TEXT("ServerMovePacked"));
void ACharacter::ServerMovePacked(FCharacterServerMovePackedBits const& PackedBits)
{
Character_eventServerMovePacked_Parms Parms;
Parms.PackedBits=PackedBits;
ProcessEvent(FindFunctionChecked(NAME_ACharacter_ServerMovePacked),&Parms);
}
调用该函数,将执行如下流程:
在UNetDriver::ProcessRemoteFunction函数中,在调用InternalProcessRemoteFunction前有如下判断
<...>
Connection = Actor->GetNetConnection();
if (Connection)
{
InternalProcessRemoteFunction(Actor, SubObject, Connection, Function, Parameters, OutParms, Stack, bIsServer);
}
else
{
UE_LOG(LogNet, Warning, TEXT("UNetDriver::ProcessRemoteFunction: No owning connection for actor %s. Function %s will not be processed."), *Actor->GetName(), *Function->GetName());
}
<...>
Actor及其Owner的对应实现如下:
UNetConnection* AActor::GetNetConnection() const
{
return Owner ? Owner->GetNetConnection() : nullptr;
}
UNetConnection* APlayerController::GetNetConnection() const
{
// A controller without a player has no "owner"
return (Player != NULL) ? NetConnection : NULL;
}
无论哪种RPC的通信,都依赖于连接(UNetConnection)创建的通信信道(UChannel)。而且其自身或者其Owner的GetNetConnection返回应非空。也就是说客户端连接应该对该对象有所有权,一般来说,必须为PlayerController所拥有。引用官方文档的RPC前置条件的原文如下:
1 They must be called from Actors.
2 The Actor must be replicated.
3 If the RPC is being called from server to be executed on a client, only the client who actually owns that Actor will execute the function.
4 If the RPC is being called from client to be executed on the server, the client must own the Actor that the RPC is being called on.
5 Multicast RPCs are an exception:
RPC的后续处理和属性同步类似,都是使用利用反射系统构造的FRepLayout进行编码构造Bunch包,然后通过网络发送给对端。
UNetDriver::ProcessRemoteFunctionForChannelPrivate
void FRepLayout::SendPropertiesForRPC
FRepLayout::SerializeProperties_r
Cmd.Property->NetSerializeItem(Ar, Map, (Data + Cmd).Data))
Ch->WriteFieldHeaderAndPayload(TempBlockWriter, ClassCache, FieldCache, NetFieldExportGroup, TempWriter);
Bunch.WriteIntWrapped( FieldCache->FieldNetIndex, MaxFieldNetIndex );
Bunch.SerializeIntPacked( NumPayloadBits );
Bunch.SerializeBits( Payload.GetData(), NumPayloadBits );
HeaderBits = Ch->WriteContentBlockPayload(TargetObj, Bunch, false, TempBlockWriter);
WriteContentBlockHeader( Obj, Bunch, bHasRepLayout );
Bunch.SerializeIntPacked( NumPayloadBits );
Bunch.SerializeBits( Payload.GetData(), Payload.GetNumBits() );
这里仍然有几个小细节,可以强调下:
上面介绍了移动同步依赖的ServerMovePacked RPC接口,这一节我们更加整体的看下移动同步的细节。
Unreal移动及其同步的流程如下。该流程假定,使用了DS(Dedicated Server);有两个客户端,X和Y,登录了游戏;并且客户端X,控制了角色A。
以下是角色A移动的实现流程:
对于一个Unreal功能,我们可以用精炼的语言概述其功能,但如果要深度掌握,就需要精读其代码,里面有茫茫多的细节。移动的输入也不例外。
移动输入,用精炼的语言表达该功能可以概述为:在PlayerController的Tick函数中,收集设备输入,并通过一系列的矩阵变化转换为一个方向向量。输入本质上是模拟一个“力”,这个力会产生一个加速度供后续的物理模拟使用。
现在来看下细节,首先是收集输入的函数执行层级:
APlayerController::TickActor
APlayerController::PlayerTick
APlayerController::TickPlayerInput
APlayerController::ProcessPlayerInput
UPlayerInput::ProcessInputStack
FInputAxisUnifiedDelegate::Execute
<绑定的按键响应函数>
APawn::AddMovementInput
UPawnMovementComponent::AddInputVector
APawn::Internal_AddMovementInput
在函数调用的顶层栈是APawn::Internal_AddMovementInput, 它的实现很简单.
void APawn::Internal_AddMovementInput(FVector WorldAccel, bool bForce)
{
if (bForce || !IsMoveInputIgnored())
{
ControlInputVector += WorldAccel;
}
}
WorldAccel是当帧输入转化为的一个向量,可以理解为力或者加速度的方向。ControlInputVector是Pawn的一个成员变量,记录了未被处理的上次输入。 这两个变量的使用向量加法(平行四边形法)进行合并。
关于该函数的输入,WorldAccel,它是由用户输入,结合以下配置,最后再经过矩阵变换(矩阵的构造又利用了三角函数, 三角函数又利用了弧度角度转换,……),最后利用反平方根单位化,计算出来的。
小结下这部分,该步骤的输出是保存在APawn中的ControlInputVector变量。它累积了未被处理的玩家输入,它代表了驱动角色移动的“力”。
上一步收集输入,是在PlayerController的Tick中进行。PlayerController的TickGroup是TG_PrePhysics, 而MovmentComponent的TickGroup是TG_PostPhysics。所以理论上每帧都是先执行输入收集,再执行移动的物理模拟。
物理模拟的整体调用堆栈如下(在步骤3会口冲该步骤):
UCharacterMovementComponent::TickComponent
UPawnMovementComponent::ConsumeInputVector()
ControlledCharacterMove
ScaleInputAcceleration
ReplicateMoveToServer
PerformMovement
StartNewPhysics(DeltaSeconds, 0);
PhysWalking
CalcVelocity
MoveAlongFloor
ComputeGroundMovementDelta
SafeMoveUpdatedComponent
MoveUpdatedComponent
ResolvePenetration // 解决穿透
if need: SlideAlongSurface
在玩家开始真正的物理模拟前, 会获取之前缓存在ControlInputVector的输入数据,并将其置0。
{
LastControlInputVector = ControlInputVector;
ControlInputVector = FVector::ZeroVector;
return LastControlInputVector;
}
将上步骤获取的输入向量传入到ControlledCharacterMove函数, 及后续ScaleInputAcceleration函数。用于计算物理模拟过程中的加速度。
ScaleInputAcceleration的实现也比较简单, 如果输入的向量长度大于1, 则标准化为单位向量(前面一节已经提过); 否则则采用原始值。
FVector UCharacterMovementComponent::ScaleInputAcceleration(const FVector& InputAcceleration) const
{
return GetMaxAcceleration() * InputAcceleration.GetClampedToMaxSize(1.0f);
}
FORCEINLINE FVector FVector::GetClampedToMaxSize(float MaxSize) const
{
if (MaxSize < KINDA_SMALL_NUMBER)
{
return FVector::ZeroVector;
}
const float VSq = SizeSquared();
if (VSq > FMath::Square(MaxSize))
{
const float Scale = MaxSize * FMath::InvSqrt(VSq);
return FVector(X*Scale, Y*Scale, Z*Scale);
如果在读者的游戏里,角色的移动更为复杂,可以覆写GetMaxAcceleration函数,根据玩家状态得到合理的加速度。
由于每帧的输入的InputAcceleration都是固定的,在原生实现中,GetMaxAcceleration也是固定的,所以得到的加速度也是固定的。所以角色移动的物理模拟,使用的是初级物理的知识:匀加速运动。 特别的,在单帧内,加速度的方向也不变,所以单帧内,未达到速度上限前,可以认为是匀加速直线运动。
关于匀加速直线运动,罗列下相关公式:
瞬时速度公式 v=v0+at;
位移公式 x=v0t+½at²;
平均速度 v=x/t=(v0+v)/2
导出公式 v²-v0²=2ax
加速度 a=(v-v0)/t
引擎主要是利用公式1,计算出瞬时速度,进而算出位移。
在随后的PhysWalking函数中,会将tick对应的delta time分解为更小的时间段。在这个更小的时间段内进行物理模拟。时间分段逻辑如下:
FMath::Min(MaxSimulationTimeStep, RemainingTime * 0.5f);
每次Tick,
当没有用户输入时,角色会受到摩檫力的影响做匀减速运动。过程也是如上。
由此,我们计算出了玩家当帧的移动状态(加速度,位置,朝向等)。这个移动状态会通过RPC上报给服务器。
移动上报的逻辑位于上面的移动物理模拟函数PerformMovement之后,主要在CallServerMovePacked中实现的。同时在ReplicateMoveToServer函数中包含了重要的对时逻辑。
以下是在步骤2的基础上,进一步扩充的移动实现的调用层级:
UCharacterMovementComponent::TickComponent
UPawnMovementComponent::ConsumeInputVector()
ControlledCharacterMove
ScaleInputAcceleration
ReplicateMoveToServer
FNetworkPredictionData_Client_Character* ClientData = GetPredictionData_Client_Character();
ClientData->UpdateTimeStampAndDeltaTime
CurrentTimeStamp += DeltaTime;
FSavedMove_Character::SetMoveFor
SetInitialPosition
TimeStamp = ClientData.CurrentTimeStamp;
CanCombile // 加速度夹角小于5度(0加速度,和任意加速度夹角可以认为是90度)
Combine With Pending // 二次模拟
PerformMovement
FSavedMove_Character::PostUpdate
CallServerMovePacked
FCharacterNetworkMoveData::ClientFillNetworkMoveData // FSavedMove_Character-->FCharacterNetworkMoveData
CallServerMovePacked函数还有一个Non Packed版本,CallServerMove,决定于项目本身的配置。
前一步骤产生的新的移动状态,会赋值给FCharacterNetworkMoveData,最终编码到ServerMovePacked函数的FCharacterServerMovePackedBits类型的参数中,发送给DS。
虽然物理模拟过程中用到的数据很多,但需要同步给DS的只有如下这些。服务器记录了角色上次的位置,旋转,加速度等信息,在网络不丢包的情况下,只需要上传本次移动的结果即可。
struct ENGINE_API FCharacterNetworkMoveData
{
ENetworkMoveType NetworkMoveType;
float TimeStamp;
FVector_NetQuantize10 Acceleration;
FVector_NetQuantize100 Location; // Either world location or relative to MovementBase if that is set.
FRotator ControlRotation;
uint8 CompressedMoveFlags;
class UPrimitiveComponent* MovementBase;
FName MovementBaseBoneName;
uint8 MovementMode;
};
CompressedMoveFlags比较关键,包含了移动的具体状态,比如是否是蹲,爬……;MovementBase是角色移动的场景base,比如大地,电梯;MovementMode则是walking, swimming,falling……。
TimeStamp是客户端自开局以来的时间戳。针对这个变量,我们需要展开讲讲Unreal移动同步的对时机制。
使用DS后,角色移动要保证时间的一致性。对时并不是修改客户端本地时间。
为了了解对时的原理,我们从上面的数据结构入手。
struct ENGINE_API FCharacterNetworkMoveData
{
<...>
float TimeStamp;
<...>
};
void FCharacterNetworkMoveData::ClientFillNetworkMoveData(const FSavedMove_Character& ClientMove, FCharacterNetworkMoveData::ENetworkMoveType MoveType)
{
<...>
TimeStamp = ClientMove.TimeStamp;
<...>
}
结构体的定义位于,CharacterMovementReplication.h文件中。 上报DS的时间戳,其实就是MovementComponent中保存的FSavedMove_Character结构体记录的TimeStamp。
FCharacterNetworkMoveData是客户端和服务器通信用的结构体,FSavedMove_Character则是客户端保存的未被服务器确认的移动信息。相比于同步的信息,FSavedMove_Character的成员变量更多,结构体也更复杂;但在本小节,我们也仅关注TimeStamp成员变量。
struct ENGINE_API FSavedMove_Character
{
<...>
float TimeStamp;
<...>
};
void FSavedMove_Character::SetMoveFor(ACharacter* Character, float InDeltaTime, FVector const& NewAccel, class FNetworkPredictionData_Client_Character & ClientData)
{
<...>
TimeStamp = ClientData.CurrentTimeStamp;
}
可以看到,FSavedMove_Character TimeStamp成员变量,使用新出现的FNetworkPredictionData_Client_Character结构体的CurrentTimeStamp的值。由于客户端的本地移动并没有在DS实现,所以本地的移动相关数据叫做PredictionData;该结构体保存了一次移动的物理模拟中使用的各种数据。FSavedMove_Character在客户端可能会有很多个实例,但FNetworkPredictionData_Client_Character在客户端仅有一个实例。
float FNetworkPredictionData_Client_Character::UpdateTimeStampAndDeltaTime(float DeltaTime, class ACharacter & CharacterOwner, class UCharacterMovementComponent & CharacterMovementComponent)
{
// Reset TimeStamp regularly to combat float accuracy decreasing over time.
if( CurrentTimeStamp > CharacterMovementComponent.MinTimeBetweenTimeStampResets )
{
UE_LOG(LogNetPlayerMovement, Log, TEXT("Resetting Client's TimeStamp %f"), Current
<...>
}
// Update Current TimeStamp.
CurrentTimeStamp += DeltaTime;
<...>
}
通过上面CurrentTimeStamp赋值逻辑可以看到,移动中的对时使用的时间戳,其实是对象首次同步后游戏运行的相对时间,基于引擎Tick循环的DeltaTime进行计时。
这个时间戳,在服务器和客户端并不完全一致。所以DS实现移动的物理模拟时,首先会判断客户端上报的时间戳是否合法。
具体的处理层级如下:
ServerMove_PerformMovement
VerifyClientTimeStamp
IsClientTimeStampValid
if Valid:
ProcessClientTimeStampForTimeDiscrepancy // Discrepancy 差异
if (...) ServerData.bForceClientUpdate = true
以上就是移动同步的对时机制,服务器客户端分别记录各自移动物理模拟的时间戳,服务器同时会记录客户端上次移动模拟时间戳。通过对比之间的差异,保证移动包的合法性。
上面提到的引擎的计时逻辑是对时的基础,主要在如下函数实现。感兴趣的同学可以自行阅读,如果只对移动同步感兴趣,可以自行跳到后续小节。
void UEngine::UpdateTimeAndHandleMaxTickRate()
{
<...>
static double LastRealTime = FPlatformTime::Seconds() - 0.0001;
<...>
// Updates logical time to real time, this may be changed by fixed frame rate below
double CurrentRealTime = FPlatformTime::Seconds();
FApp::SetCurrentTime(CurrentRealTime);
<...>
// Calculate delta time, this is in real time seconds
float DeltaRealTime = CurrentRealTime - LastRealTime;
<...>
}
void UWorld::Tick( ELevelTick TickType, float DeltaSeconds )
{
<...>
// Update time.
RealTimeSeconds += DeltaSeconds;
<...>
}
FORCEINLINE_DEBUGGABLE float UWorld::GetRealTimeSeconds() const
{
checkSlow(!IsInActualRenderingThread());
return RealTimeSeconds;
}
引擎会调用操作系统的时钟函数,获取运行时间。FPlatformTime是一个宏, 在Linux平台使用的是,FUnixTime。
static FORCEINLINE double Seconds()
{
struct timespec ts;
clock_gettime(ClockSource, &ts);
return static_cast<double>(ts.tv_sec) + static_cast<double>(ts.tv_nsec) / 1e9;
}
ClockSource会在启动后,测试如下备选项的性能。CLOCK_REALTIME是实时时钟,返回的是Epoch( 00:00:00 UTC on 1 January 1970)以来的时间。CLOCK_MONOTONIC则一般是,机器启动以来的单调时钟。
Clocks[] =
{
{ CLOCK_REALTIME, "CLOCK_REALTIME", 0 },
{ CLOCK_MONOTONIC, "CLOCK_MONOTONIC", 0 },
{ CLOCK_MONOTONIC_RAW, "CLOCK_MONOTONIC_RAW", 0 },
{ CLOCK_MONOTONIC_COARSE, "CLOCK_MONOTONIC_COARSE", 0 }
};
FApp会记录这个CurrentTime,UWorld则会根据Delta记录距离首次Tick以来的相对时间,移动时间戳和此类似,记录的是首次移动同步以来的相对时间。
由于移动包高频,且只追求最终结果的一致性。所以引擎将移动RPC定义为unreliable。但从上面小节的内容看,在客户端引入了FSavedMove_Character去保证了移动的可靠性,或者说最终状态一致性。
这部分内容,我们在步骤5,DS的回包阶段做进一步的展开。
对于战术类射击游戏来说,一般来说客户端的帧率会高于DS,所以允许RPC请求在客户端分帧进行上报。这里的移动上报频率,会受到一系列配置项的影响,主要的配置项是,ClientNetSendMoveDeltaTime。
如果在单帧未进行上报,则会将当前构造的FSavedMove_Character缓存在PendingMove中。 PendingMove在下一次Tick会考虑和NewMove进行合并,合并的条件也比较苛刻,包括朝向,MoveMode是否变化。如果可以合并,则会从PendingMove的起始时间戳开始,重新进行移动的物理模拟。如果不能合并,则会在一次RPC中,将PendingMove和NewMove都发送给DS。
服务器的物理模拟过程和客户端的物理模拟过程一致。只是输入使用的是上次记录在服务器的结果,以及RPC中的输入信息。
如果输入信息都一致,物理场景和计算方法都一致,那么理论上物理模拟的结果也应该一致。
此过程的整体的调用层级如下:
ServerMove_PerformMovement
VerifyClientTimeStamp
IsClientTimeStampValid
if Valid:
ProcessClientTimeStampForTimeDiscrepancy // Discrepancy 差异
if (...) ServerData.bForceClientUpdate = true
MoveAutonomous
PerformMovement
ServerMoveHandleClientError when NewMove
ServerData->bForceClientUpdate || ServerCheckClientError
ServerExceedsAllowablePositionError
ServerShouldUseAuthoritativePosition
DS处理角色移动的流程和客户端类似。不同之处主要是两点:
在进行服务器的模拟前,会进行时间戳的校验,这部分第二小节专门做了介绍。这里介绍下服务器对错误的处理。
虽然有一致性的假设,但从上述调用层级,可以看到服务器允许部分误差的存在。如果时间戳检测没有问题,DS会调用ServerCheckClientError来检测是否发生了某种错误,该函数又会调用ServerExceedsAllowablePositionError,判断客户端的最终位置有没有超过指定的阈值。
如果没有超过, 进一步的会根据配置,决定是否使用客户端上传的最终位置。
如果超过阈值,则会标记本次请求是一个Bad request,并通知客户端进行调整。
上面DS执行移动逻辑后,会把客户端RPC请求标记为Good或者Bad。
并在对象的属性同步前,调用PlayerController的SendClientAdjustment函数,通过如下调用层级发送给客户端。
SendClientAdjustment
ServerData->PendingAdjustment.bAckGoodMove
ServerSendMoveResponse(ServerData->PendingAdjustment);
!ServerData->PendingAdjustment.bAckGoodMove
ServerSendMoveResponse
MoveResponsePacked_ServerSend
Character::ClientMoveResponsePacked
MoveResponsePacked_ClientReceive
ClientHandleMoveResponse
if good
ClientAckGoodMove_Implementation
ClientData->AckMove
if bad
ClientAdjustPosition_Implementation
ClientData->AckMove
OnClientCorrectionReceived
ClientData->bUpdatePosition
对于Packed方式,无论是否Good,都直接使用ServerSendMoveResponse将PendingAdjustment打包,然后通过客户端RPC,ClientMoveResponsePacked发送给客户端。对于非Packed方式,需要构造不同的参数,实现略有区别。
为了保证移动同步的最终结果一致性,客户端会将未确认的移动请求保存在FSavedMove_Character数组中。
如果返回的是Good包,处理很简单,就是将该包对应时间戳之前的所有缓存删除即可。
如果是Bad包,则会调用ClientAdjustPosition_Implementation完成位置的调整, 同时也会清除该包对应时间戳之前的所有缓存。并标记bUpdatePosition,在后一tick,根据此标记调整表现(ClientUpdatePositionAfterServerUpdate实现)。此时就会发生我们所谓的拉扯问题。
步骤6属于属性同步的范畴,在前篇文章已经初步介绍,不做赘述。同步的属性并不在UCharacterMovementComponent内,而是Actor内的
struct FRepMovement ReplicatedMovement;
从UE4到UE5,该结构体从EngineTypes.h迁移到了ReplicatedState.h。 并添加了一些新的成员。 但整体逻辑并没有太多变化。在3P客户端内,根据同步过来的位置等移动状态,做表现向的处理。
在没有开启移动预测的客户端,一般的实现逻辑是先更新胶囊体位置,然后做mesh的表现(动画)趋近胶囊体位置;到达胶囊体位置后,如果没有新的同步包,则位置不会再变化。如果开启了移动预测,在未收到服务器新的包前,仍会继续向前移动。
网络游戏中,移动同步的常见两类问题是拉扯和卡顿。
1. 拉扯是指玩家位置从位置A拉到新的位置B, 或者从新的位置被拖拽回老的位置。拉扯在比较严重的情况下会表现为瞬移。
2. 卡顿,更多是性能表现向问题。客户端的一帧运行时间超长,导致画面在某一帧停留较久,出现明显的顿挫感。
卡顿和拉扯经常会被相提并论,而且第一视角的拉扯和卡顿都会产生画面的顿挫感,Bug表现类似。所以处理拉扯卡顿问题的首要任务是要分清楚到底是移动拉扯导致还是性能卡顿的影响。
开发者一般可以较容易的区分3P的拉扯问题和卡顿问题。因为在主控角色的客户端,3P只是画面的一部分,可以通过画面其他部分的表现判断是否产生了卡顿。
本人定位过的3P拉扯问题主要有两个
1. 下行流量满, 导致3P同步不及时。
2. 开启移动预测后,3P在停止后会拉回一小段距离。
大部分3P客户端都会做平滑处理,只有在上次位置信息和当前位置信息差距比较大的时候才会发生所谓的拉扯。而问题1的情况,发生在某些同步相对比较频繁的场景,某些reliable或者优先级较高的包挤占了流量,导致移动信息迟迟没有同步。解决方法,一是提高玩家角色的同步优先级,二是提高带宽。再有就是做好流量监控,对不合理的流量做优化。
二则属于机制性问题。因为移动预测会导致玩家的距离领先于服务器,在服务器的停止包到来前,位移超前部分会被拉回。整体来看,虽然表现层面并不是很明显(几cm),但解决需要优化Unreal移动预测机制。
1P的拉扯发生,一般会有画面的抖动。可以借助性能分析工具和卡顿做下区分。卡顿一般会带来fps的降低,而拉扯不会。
本人定位过的1P拉扯问题主要有如下:
Unreal移动同步的核心是1P主导,DS同步模拟。1P主动移动后,会通过RPC把自己的各种中间状态告诉服务器。如果开发者的实现有DS主动驱动角色的逻辑,哪怕所谓的客户端同步实现,但由于两方执行时机的问题,往往会导致卡顿的发生。
1.2和4都属于此类。1.2简单的讲,就是某些逻辑问题,使客户端和服务器计算出的最大速度不一致。最终导致了,二者计算的位置不一致。4则是构建流程的问题,ds和客户端level本身不一致,客户端没有阻挡的地方,ds产生了阻挡,ds认为客户端的位置不合法,于是发生了拉扯。
这个是我们在开发期发生的一个现象。当时stream level集中在一点被加载,客户端于是频繁发送相关level加载的reliable rpc, 致使带宽占满,非reliable的移动包发生丢包。最新包到达ds后,服务器认为客户端还在丢包之前的位置,于是拉回老位置。
这个也是比较典型的案例。 当ds卡顿时间超过1s,会出现客户端频繁在原地拉扯的现象。
这个偶现过一次,由于没有实锤,最终只能通过代码逻辑试图解释了下bug的成因。游戏客户端的某些时钟异常,可能触发了DS的校时逻辑的错误分支,角色在DS上的运行时间追赶上客户端后才结束本地的抽搐。
以上就是Unreal角色移动实现的一些细节,希望对尝试这块学习和研究的同学有所帮助。