UE4 Movement原理(及源码分析)
首先,UE4移动Actor的方式
Actor->SetActorLocation()
Actor->AddActorWorldOffset(), Actor->AddActorLocalOffset()
ACharacter->GetCharacterMovement()->Velocity += FVector(5.f, 5.f, 0.f);
APawn->AddMovementInput( FVector WorldDirection, float ScaleValue = 1.0f, bool bForce = false )
UCharacterMovementComponent->AddImpulse( FVector Impulse, bool bVelocityChange )
UCharacterMovementComponent->AddForce( FVector Force ) Every Frame
AI
AController->Possess(Pawn), Controller->MoveTo()
GetWorld()->GetNavigationSystem()->SimpleMoveToLocation(Controller, DestLocation);
组件移动
UKismetSystemLibrary->MoveComponentTo
CharacterMovement
为Capsule定制的主角移动组件
UPROPERTY
GravityScale: 代表这个CharacterMovement受重力影响的系数。而Gravity是在WorldSetting/ProjectSetting设置的
MaxStepHeight: 可踩上的最大高度
JumpZVelocity: 跳跃时的初始速度(瞬时垂直加速度)
JumpOffJumpZFactor: 当角色自动从一个不可落地的位置"跳下来"时的JumpZVelocity的分数
MovementMode
CustomMovementMode
NetworkSmoothingMode
GroundFriction
MaxWalkSpeed: 同时也是falling时的最大横向速度
MaxWalkSpeedCrouched: 行走和蹲伏时的最大地面速度
MaxSwimSpeed
MaxFlySpeed
MaxCustomMovementSpeed
MaxAcceleration
MinAnalogWalkSpeed: 当以最小模拟杆倾斜行走时,我们应该加速到的地面速度
BrakingFrictionFactor: 用于乘以Brake时使用的实际摩擦值的系数
AirControl: 下落时,角色可用的横向移动控制量。0=无控制,1=在MaxWalkSpeed的最大速度下完全控制。0-1。
AirControlBoostMultiplier: 当横向速度小于阈值时,AirControl会乘上的系数(设为0关闭AircontrolBoosting,最终值不会超过1)
AirControlBoostVelocityThreshold: 阈值,设为0表示关闭Boost
FallingLateralFriction: 横向阻力
CrouchedHalfHeight:
总结:
- Walking里面默认只有水平方向的移动,只有遇到斜面的时候才会根据斜面角度产生Z轴方向的速度
- Falling里会处理横向加速度和纵向(重力)加速度
- Flying里是自驱动6向移动
- Custom里的CalcVelocity+SafeMoveUpdatedComponent+SlideAlongSurface只能处理无重力的定制移动,并计算Fraction和BrakeDecceleration
AddMovementInput过程中发生了什么?我们没有调用ConsumeMovementInputVector它也能移动
首先看看定义 AddMovementInput(WorldDirection, ScaleValue, bForce),最后一个参数表示是否忽略IsMoveInputIgnored设置强制AddInput
该函数会调用AddInputVector(WorldDirection* ScaleValue),其内部再调用Internal_AddMovementInput,执行ControlInputVector += WorldAccel;
当然这只是默认实现,AddInputVector是一个虚函数,我们可以自定义它,从而对输入做进一步处理(如将速度放大或缩小,改变方向等等)
接下来到ConsumeMovementInputVector,它会调用Internal_ConsumeMovementInputVector,在其中将ControlInputVector归0,赋为LastControlInputVector
这些都是Pawn里面的实现,实际上还有PawnMovement里的实现,也就是AddInputVector、GetPendingInputVector、GetLastInputVector和ConsumeInputVector;像FloatingPawnMovement里就在TickComponent里,通过FloatingPawnMovement::ApplyControlInputToVelocity(DeltaTime)函数,它将ControlInputVector转为ControlAcceleration,接下来计算ControlAcceleration到Velocity,最后ConsumeInputVector()消耗掉输入向量。
而CharacterMovement里也是TickComponent中,调用ConsumeInputVector()返回值得到InputVector,然后传给ControlledCharacterMove,调用ScaleInputAcceleration来计算出加速度:
Acceleration = ScaleInputAcceleration(ConstrainInputAcceleration(InputVector)); // 这个函数里将acceleration clamp到1然后乘maxAccelAnalogInputModifier = ComputeAnalogInputModifier(); // 这里是Clamp Max Sizeif (CharacterOwner->GetLocalRole() == ROLE_Authority){PerformMovement(DeltaSeconds);}else if (CharacterOwner->GetLocalRole() == ROLE_AutonomousProxy && IsNetMode(NM_Client)){ReplicateMoveToServer(DeltaSeconds, Acceleration);}
所以,好像无论给多大的InputVector,最后都会被当作1来计算?然后乘MaxAcceleration
(但是特么无论我怎么改,accel还是speed还是代码里改计算的值 通过乘,最后对它的速度都没任何影响,而且用PhysParachuting计算还有问题,用纯Falling就没问题,放弃了,直接改NewFallVelocity)
最后调用
FHitResult Hit(1.f);SafeMoveUpdatedComponent(Delta, Rotation, true, Hit);
来实现移动
最终,里面执行bMoved = InternalSetWorldLocationAndRotation(TraceEnd, NewRotationQuat, bSkipPhysicsMove, Teleport);
Movement
Normally, CapsuleComp is the Root component, and CharMove will set it as a UpdateComponent when Initialized.

UMovementComp: It implemented some move functions like MoveUpdatedComponent in the base class.
bool UMovementComponent::MoveUpdatedComponentImpl( const FVector& Delta, const FQuat& NewRotation, bool bSweep, FHitResult* OutHit, ETeleportType Teleport){if (UpdatedComponent){const FVector NewDelta = ConstrainDirectionToPlane(Delta);return UpdatedComponent->MoveComponent(NewDelta, NewRotation, bSweep, OutHit, MoveComponentFlags, Teleport);}return false;}
UNavMovementComp: This component is more to provide AI with the ability to find the way, and also includes the basic movement status, such as whether it can swim, whether it can fly, etc.
UPawnMovementComponent: Pawn.Input→Pawn.AddMovementInput()→PawnMovement.AddInputVector()→Pawn.ConsumeMovementInputVector()
UCharacterMovementComponent:
- Ground Detection: FFindFloorResult, FBasedMovementInfo
- Water Detection: GetPhysicsVolume(), OverlapEvent
- Different Physical State: PhysSwimming, PhysWalking
- Obstacle Handle: MoveAlongFloor()
- Navigation: FNavAgentProperties()
- Replication: FRepMovement, ClientUpdatePositionAfterServerUpdate
Tick: MovementMode and Processing

Walking: FindFloor(Sweep)→CurrentFloor

Falling: Get Input Velocity and Gravity.
剖析PhysFalling
玩家在空中即进入Falling状态(无论是跳起还是下落),首先它计算横向加速度(是否受AirControl限制),随后它开始计算受重力影响的纵向加速度。
有个特点是为了表现的更平滑流畅,Phys都是支持Iterations将一个Tick切成N段时间来处理的(每段的时间不能超过MaxSimulationTimeStep)。在处理每段时,首先计算玩家通过输入控制的水平速度,随后,获取重力计算速度。重力的获取有点意思,你会发现他是经过Volume体积获取的(GetGravityZ→GetPhysicsVolume()->GetGravityZ * GravityScale→MyWorld->GetGravityZ() / UPhysicsSettings::Get()->DefaultGravityZ),最终拿到的是World里设置的GravityZ或者PhysicsSetting里的DefaultGravityZ

接下来处理Force驱动的跳跃:如果设置了ApplyGravityWhileJumping=false,要把Jump force time的部分从重力计算的GravityTime中移去
通过获取到的Gravity计算出当前新的FallSpeed(NewFallVelocity里面计算,计算很简单,就是单纯的用当前速度+Gravity*deltaTime),再限制一下Don't exceed terminal velocity。
随后再根据当前以及上一帧的速度计算出位移并进行移动,公式如下
FVector Adjusted = 0.5f*(OldVelocity + Velocity) * timeTick;SafeMoveUpdatedComponent( Adjusted, PawnRotation, true, Hit);
修改PhysicsVolume或参数配置
前面可以看到PhysMovement中许多物理参数是和PhysicsVolume绑定的,比如最大速度、Fluid Friction、是否是水体等
如果没有在场景中设置PhysicsVolume或玩家没有在其中,就会使用DefaultPhysicsVolume
Default的TerminalVelocity是4000,如果要实现跳伞就要改大。这可以通过代码中覆写(virtual override)或者修改配置来实现
前面我们计算完速度并移动玩家后,也一样要考虑到移动碰撞问题。
第一种情况就是正常落地,如果玩家计算后发现碰撞到一个可以站立的地形,那直接调用ProcessLanded进行落地操作(这个判断主要是根据碰撞点的高度来的,可以筛选掉墙面)。
第二种情况就是跳的过程中遇到一个平台,然后检测玩家的坐标与当前碰撞点是否在一个可接受的范围(IsWithinEdgeTolerance),是的话就执行FindFloor重新检测一遍地面,检测到的话就执行落地流程。
第三种情况是就是墙面等一些不可踩上去的,下落过程如果碰到障碍,首先会执行HandleImpact给碰到的对象一个力。随后调用ComputeSlideVector计算一下滑动的位移,由于碰撞到障碍后,玩家的速度会有变化,这时候重新计算一下速度,再次调整玩家的位置与方向。如果玩家这时候有水平方向上的位移,还会通过LimitAirControl来限制玩家的速度,毕竟玩家在空中是无法自由控制角色的。对第三种情况做进一步的延伸,可能会出现碰撞调整后又碰到了另一个墙面,这里Falling的处理可以让玩家在两个墙面找到一个合适的位置。但是仍然不能解决玩家被夹在两个斜面但是却无法落地的情况(或者在Waling和Falling中不断切换)。如果有时间,我们后面可以尝试解决这个问题,解决思路可以从FindFloor下的ComputeFloorDist函数入手,目的就是让这个情况下玩家可以找到一个可行走的地面。
首先要明确CharMove主要是自己算的,并不是Physics驱动
CalcVelocity
将其他地方输入的移动速度、加速度计算出来,得到Character最终的移动速度
其实就是Clamp,再模拟一个Brake,Friction,FluidFriction(Friction是1秒内会减少的速度百分比,同时它也会阻止我们轻易转向)
还有PathFollowing这种RequestMove AI模拟
将加速度附加到速度
始终检查是否超过最大速度
UpdatedComponent->GetPhysicsVolume()->GetGravityZ()
即使我们没有处在任何一个体积里面,他也会给我们的UpdateComponent绑定一个默认的DefaultVolume。那为什么要有一个DefaultVolume?因为在很多逻辑处理上都需要获取DefaultVolume以及里面的相关的数据。比如,DefaultVolume有一个TerminalLimit,在通过重力计算下降速度的时候不可以超过这个设置的速度,我们可以通过修改该值来改变速度的限制。默认情况下,DefaultVolume里面的很多属性都是通过ProjectSetting里面的Physics相关配置来初始化的。
PhysWalking
地面模拟 FFindFloorResult CurrentFloor
在游戏一开始的时候,移动组件会根据配置设置默认的MovementMode,如果是Walking,就会通过FindFloor操作来找到当前的地面
FindFloor本质上就是通过胶囊体的Sweep检测来找到脚下的地面,所以地面必须要有物理数据,而且通道类型要设置与玩家的Pawn有Block响应。
这里还有一些小的细节,比如我们在寻找地面的时候,只考虑脚下位置附近的,而忽略掉腰部附近的物体;Sweep用的是胶囊体而不是射线检测,方便处理斜面移动,计算可站立半径等(参考图3-3,HitResult里面的Normal与ImpactNormal在胶囊体Sweep检测时不一定相同)。
找到地面玩家就可以站立住么?不一定。这又涉及到一个新的概念PerchRadiusThreshold,我称他为可栖息范围半径,也就是可站立半径。默认这个值为0,移动组件会忽略这个可站立半径的相关计算,一旦这个值大于0.15,就会做进一步的判断看看当前的地面空间是否足够让玩家站立在上面。
前面的准备工作完成了,现在正式进入Walking的位移计算,这一段代码都是在PhysWalking里面计算的。为了表现的更为平滑流畅,UE4把一个Tick的移动分成了N段处理(每段的时间不能超过MaxSimulationTimeStep)。在处理每段时,首先把当前的位置信息,地面信息记录下来。在TickComponent的时候根据玩家的按键时长,计算出当前的加速度。随后在CalcVelocity()根据加速度计算速度,同时还会考虑地面摩擦,是否在水中等情况。
// apply input to accelerationAcceleration = ScaleInputAcceleration(ConstrainInputAcceleration(InputVector));
算出速度之后,调用函数MoveAlongFloor()改变当前对象的坐标位置。在真正调用移动接口SafeMoveUpdatedComponent()前还会简单处理一种特殊的情况——玩家沿着斜面行走。正常在walking状态下,玩家只会前后左右移动,不会有Z方向的移动速度。如果遇到斜坡怎么办?如果这个斜坡可以行走,就会调用ComputeGroundMovementDelta()函数去根据当前的水平速度计算出一个新的平行与斜面的速度,这样可以简单模拟一个沿着斜面行走的效果,而且一般来说上坡的时候玩家的水平速度应该减小,通过设置bMaintainHorizontalGroundVelocity为false可以自动处理这种情况。
现在看起来我们已经可以比较完美的模拟一个移动的流程了,不过仔细想一下还有一种情况没考虑到。那就是遇到障碍的情况怎么处理?根据我们平时游戏经验,遇到障碍肯定是移动失败,还可能沿着墙面滑动一点。UE里面确实也就是这么处理的,在角色移动的过程中(SafeMoveUpdatedComponent),会有一个碰撞检测流程。由于UPrimitiveComponent组件才拥有物理数据,所以这个操作是在函数UPrimitiveComponent::MoveComponentImpl里面处理的。下面的代码会检测移动过程中是否遇到了障碍,如果遇到了障碍会把HitResult返回。
FComponentQueryParams Params(PrimitiveComponentStatics::MoveComponentName, Actor);FCollisionResponseParams ResponseParam;InitSweepCollisionParams(Params, ResponseParam);bool const bHadBlockingHit = MyWorld->ComponentSweepMulti(Hits, this, TraceStart, TraceEnd, InitialRotationQuat, Params);
在接收到SafeMoveUpdatedComponent()返回的HitResult后,会在下面的代码里面处理碰撞障碍的情况。
- 如果Hit.Normal在Z方向上有值而且还可以行走,那说明这是一个可以移动上去的斜面,随后让玩家沿着斜面移动
- 判断当前的碰撞体是否可以踩上去,如果可以的话就试着踩上去,如果过程中发现没有踩上去,也会调用SlideAlongSurface()沿着碰撞滑动。
基本上的移动处理就完成了,移动后还会立刻判断玩家是否进入水中,或者进入Falling状态,如果是的话立刻切换到新的状态。由于玩家在一帧里面可能会从Walking,Swiming,Falling的等状态不断的切换,所以在每次执行移动前都会有一个iteration记录当前帧的移动次数,如果超过限制就会取消本次的移动模拟行为。
PhysFalling
玩家在空中即进入Falling状态(无论是跳起还是下落),首先它计算横向加速度(是否受AirControl限制),随后它开始计算受重力影响的纵向加速度。
有个特点是为了表现的更平滑流畅,Phys都是支持Iterations将一个Tick切成N段时间来处理的(每段的时间不能超过MaxSimulationTimeStep)。在处理每段时,首先计算玩家通过输入控制的水平速度,随后,获取重力计算速度。重力的获取有点意思,你会发现他是经过Volume体积获取的(GetGravityZ→GetPhysicsVolume()->GetGravityZ * GravityScale→MyWorld->GetGravityZ() / UPhysicsSettings::Get()->DefaultGravityZ),最终拿到的是World里设置的GravityZ或者PhysicsSetting里的DefaultGravityZ
接下来处理Force驱动的跳跃:如果设置了ApplyGravityWhileJumping=false,要把Jump force time的部分从重力计算的GravityTime中移去
通过获取到的Gravity计算出当前新的FallSpeed(NewFallVelocity里面计算,计算很简单,就是单纯的用当前速度+Gravity*deltaTime),再限制一下Don't exceed terminal velocity。
随后再根据当前以及上一帧的速度计算出位移并进行移动,公式如下
FVector Adjusted = 0.5f*(OldVelocity + Velocity) * timeTick;
SafeMoveUpdatedComponent( Adjusted, PawnRotation, true, Hit);
PhysCustom
PhysLadder:
SafeMoveUpdatedComponent:
Calls MoveUpdatedComponent(), handling initial penetrations by calling ResolvePenetration(). If this adjustment succeeds, the original movement will be attempted again.
SlideAlongSurface:
由于SafeMove会传入HitResult来处理初始穿透,那么当Hit.Time<1时,说明发生了穿透,要交给SlideAlongSurface来处理成表面滑动效果
PhysWalking: 只是检查当玩家输入wantsToSprint+Crouch时会变成滑铲,即SetMovementMode→Sliding,其他还是默认。
PhysParachuting
Friction:在不加速时或在与加速相反的方向上的摩擦系数。
Braking Deceleration:无加速或超过最大速度时施加的减速。
所以我们在Falling切Parachuting后,它速度会接近0(通过Friction)
那么我们在速度-1000的时候,改Friction为0,这时,它的速度应该就是恒定的
UE4是封装好了很多功能,你只需看它的文档或示例来使用,不需要知道它的底层,但是它的底层是复杂和晦涩难懂的,如果不看和修改底层就能实现是最好的,但如果绕不开就还是要改底层
Fall是在PhysFalling里将Gravity加速度计算到Velocity中了的,Custom里没有处理,所以Z速度最后会趋近0。Custom里我们只用了CalcVelocity,它基于当前状态更新"速度"和"加速度",不施加重力。
一开始是Falling,开伞变Parachuting,这时给一个与重力反向的加速度,该加速度随速度的减小而减小,最后等于重力加速度
同步:
Tick-PerformMovement (according to current MovementMode and APlayerController.ControlInput vector to compute the needed Acceleration)
PerformMovement 函数负责让角色在游戏世界中的移动符合物理原理,它在Server和Client都会调用 (本地或rpc)
PerformMovement 负责处理以下内容:
- 施加外部物理效果,如冲量、力和重力。
- 根据动画根骨骼运动和 根骨骼运动源 计算移动。
- 调用 StartNewPhysics ,它根据角色使用的移动模式选择 Phys* 函数。
每种运动模式都有自己的 Phys* 函数,负责计算速度和加速度。例如,PhysWalking 决定了角色在地面上移动时的移动物理效果,而 PhysFalling 决定了角色在空中的行为方式。要想调试这些行为的细节,你可以查看每个函数的具体内容。
如果移动模式在函数更新期间发生变化,例如当角色开始下落或与对象碰撞时,Phys* 函数会再次调用 StartNewPhysics,继续让角色在新移动模式下运动。StartNewPhysics 和 Phys* 函数会各自传递已发生的 StartNewPhysics 的迭代次数。参数 MaxSimulationIterations 是此递归的最大允许次数。
对于AutonomousProxy (主端)
所属客户端将在本地控制自主代理。PerformMovement 将运行移动组件的物理移动逻辑。
代理将构建 FSavedMove_Character ,其中包含角色刚刚的移动数据,然后将其排入 SavedMoves 队列。
类似的 FSavedMove 条目将组合在一起。自主代理将使用 ServerMove RPC 将其精简版数据发送到服务器。
如果客户端收到ClientAdjustPosition,它会复制服务器的移动并使用 SavedMoves 队列重新追踪其进程,获取新的最终位置。成功处理动作后,客户端会从队列中移除已存动作。
对于Authority授权Actor (服务器)
服务器将接收ServerMove并使用 PerformMovement 再现客户端的移动。
服务器将检查它在ServerMove之后的位置是否与客户端报告的结束位置一致。
如果服务器和客户端的最终位置一致,服务器会向客户端发回信号,表明移动有效。否则,它会使用 ClientAdjustPosition RPC发送校正。
服务器将复制 ReplicatedMovement 结构,从而将其位置、旋转和当前状态发送到其他已连接客户端上的模拟代理。
对于SimulatedProxy (第三方)
模拟的代理将直接应用复制的移动信息。网络平滑 会为最终运动提供视觉效果清理。