【UEGamePlay】- 3C篇(二) : Character(一)Move Rotation
本文在博客园原创,转载请标注出处
前言
上一篇文章介绍了贯穿UE的Input框架,接下来几篇文章将会介绍3C中最重要也是最复杂的框架 - 角色
本篇文章优先拆解与角色移动/旋转相关的大体框架和前置知识,文章只涉及到源码Cpp,同时文中不提供引擎源码(除非标注,请一律按照伪码处理),引擎版本为5.3.且分析大部分源码逻辑时笔者只会给出逻辑顺序,请自行对照源码(此部分源码特别庞大,现有的笔记结构无法支撑笔者顺利在源码基础上还要进行逻辑的标注)
Q1.Runtime下的移动,最重要的是什么
A1.是坐标和移动坐标的方法
组件架构
UE中的角色框架中的Move功能围绕USceneCompoent/UPrimitiveCompoent + UMovementComponent三大组件及其子类构成,基于物理的角色移动算法(collide and slide算法,通过精细的算法模拟角色运动中的物理影响)
UCharacterMovementComponent继承链

UActorCompoent - 负责组件的生命周期管理、激活/停用、与Actor的绑定等
USceneCompoent - 具有变换并支持附件(组件依附),但没有渲染或碰撞功能。//(核心基类组件)
UPrimitiveCompoent - 具有渲染和物理信息,可以实现Overlap //(核心基类组件)
UMovementComponent - 具备基本移动功能和接口 //(核心基类组件)
UProjectileMovementComponent - 抛射物移动
UNavMovementComponent - 路径寻找和导航功能,可实现代理AI移动
UPawnMovementComponent - 提供输入累积,Owner和移动相关接口
UCharacterMovementComponent - 提供了丰富的角色移动功能
UFloatingPawnMovement - 运动组件,为Pawn提供简单运动
核心移动组件:

USceneComponent
USceneComponent:具有变换功能并支持附件功能,但没有渲染或碰撞检测功能。
- 带有Transform 并且允许相互挂载
- 提供核心移动方法 :MoveComponent()(默认实现根据 Delta 传送到目标位置,不执行任何物理检测)
- MoveComponent() 内部封装虚函数 MoveComponentImpl()
提供FTransform(坐标),以及提供更新组件坐标的移动方法。构建了底层核心移动方法:
「USceneComponent::MoveComponent()」
USceneComponent::MoveComponent()
MoveComponentImpl() //虚函数转发
ConditionalUpdateComponentToWorld()
UpdateComponentToWorld()
UpdateComponentToWorldWithParent()
更新变量:+ComponentToWorld
InternalSetWorldLocationAndRotation
UpdateComponentToWorldWithParent()
更新变量:+ComponentToWorld
UPrimitiveCompoent
UPrimitiveCompoent 继承USceneComponent并提供基本的物理碰撞信息,用于处理移动时产生的阻挡/穿透等问题
- 提供基于物理的移动方法 : 重载 MoveComponentImpl() ,提供基于物理的移动的实现
「UPrimitiveCompoent::MoveComponentImpl()」(基于物理的移动(Sweep))
MoveComponentImpl() 是 UPrimitiveComponent 的核心位移函数,根据 bSweep 决定是否在移动路径上做碰撞检测,处理阻挡命中(blocking hits)和重叠事件(overlaps),支持延迟移动更新(ScopedMovementUpdate),并通过 OutHit 将命中信息回传。
// Delta:位移向量(从当前到目标的偏移)
// NewRotationQuat:目标旋转(四元数)
// bSweep:是否在移动路径上做碰撞检测(sweep)
// OutHit:可选输出,若发生阻挡碰撞会写入命中信息
// MoveFlags:控制行为的标志(例如跳过物理更新或禁用某些事件)
// Teleport:是否以瞬移方式移动(影响物理/子组件行为)。
virtual bool MoveComponentImpl(const FVector& Delta, const FQuat& NewRotation, bool bSweep, FHitResult* OutHit = NULL, EMoveComponentFlags MoveFlags = MOVECOMP_NoFlags, ETeleportType Teleport = ETeleportType::None) override;
核心逻辑:
- 早期检查:组件无效或静态移动违规则初始化 OutHit(若有)并返回 false
- 更新组件到世界变换的缓存(ConditionalUpdateComponentToWorld())
- 计算起点 TraceStart、终点 TraceEnd、移动距离的平方 DeltaSizeSq;若距离非常小且旋转也没变化,则直接返回 true
- bSweep 分支:
- 不扫掠(!bSweep):
- 直接调用 InternalSetWorldLocationAndRotation 设置新变换(可能跳过物理)
- 记录是否纯旋转(bRotationOnly)和是否包含重叠在结束时的判断
- 扫掠(bSweep):
- 若启用查询碰撞且距离>0,使用 ComponentSweepMulti 在路径上采集所有命中(hits)
- 对命中结果做筛选:忽略应当忽略的命中,挑选优先的阻挡命中(包括处理开始穿透 bStartPenetrating 的情况),同时收集待处理的重叠(PendingOverlaps)
- 根据第一个有效阻挡命中计算最终能到达的新位置 NewLocation(若阻挡导致几乎没有移动,则把移动退回起点并丢弃后续重叠)
- 调用 InternalSetWorldLocationAndRotation(NewLocation, ...) 进行实际位置/旋转设置(会影响子组件、可能是 teleport/非 sweep)
- 不扫掠(!bSweep):
- 重叠(overlap)处理(需bMoved):
- 是否处于延迟移动更新模式 IsDeferringMovementUpdates():
- 延迟移动更新模式 : 将 PendingOverlaps 或后续重叠信息追加到当前 FScopedMovementUpdate,由后续结束时统一处理
- 非延迟移动更新模式 :将 PendingOverlaps(必要时先把“旋转产生的重叠”转换为当前重叠)传给 UpdateOverlaps 来触发重叠事件(Begin/End/Touch 等)
- 是否处于延迟移动更新模式 IsDeferringMovementUpdates():
- 阻挡(Blocking)处理。(若发生有效阻挡且允许派发(未被标志禁止)):
- 是否处于延迟移动更新模式 IsDeferringMovementUpdates():
- 延迟移动更新模式 :阻挡命中记录在 FScopedMovementUpdate 里以便稍后处理
- 非延迟移动更新模式 :直接 DispatchBlockingHit 通知拥有者/相关系统(例如角色移动要响应的撞击事件)
- 是否处于延迟移动更新模式 IsDeferringMovementUpdates():
- 输出OutHit :bFilledHitResult 为 true 时才包含完整命中信息,否则仅被 Init(TraceStart, TraceEnd) 填充
- 返回bMoved :是否实际改变了组件的世界变换
实现细节:
- 微小距离/旋转:函数避开非常微小的移动(UE_KINDA_SMALL_NUMBER),避免无意义的 sweep 或把物体推入表面
- 起始穿透处理:若一开始就与别的碰撞体重叠,会选择与运动方向最“相反”的法线作为阻挡优先项
- 碰撞禁用路径:若查询碰撞被禁用但有位移,则直接把位置 += Delta,不会进行 sweep
- 延迟移动更新:允许将多个移动累积并延迟触发 overlap/hit 回调,用于原子性或性能优化场景(比如角色移动批量处理,后续会在PerformMovement范围更新移动代码块中见到)
- OutHit可靠性:bFilledHitResult 为 true 时才包含完整命中信息,否则仅被 Init(TraceStart, TraceEnd) 填充
UMovementComponent
UMovementComponent 是一个抽象组件类,定义了在每个 tick 中移动 PrimitiveComponent(具有物理碰撞特性的组件) 的功能。
提供上层使用的基于物理的移动的核心方法:SafeMoveUpdatedComponent()是Character(基于collide and slide算法)移动的根基。UCharacterMovementComponent中胶囊体的移动最终都会调用到此函数。
「UMovementComponent::SafeMoveUpdatedComponent()」
UMovementComponent::SafeMoveUpdatedComponent()
bMoveResult = UMovementComponent::MoveUpdatedComponent() //第一次移动尝试
if OutHit.bStartPenetrating //如果移动中发现碰撞
ResolvePenetration(GetPenetrationAdjustment(),OutHit) //计算移动调整量,获取尝试脱离穿透的位置
bMoveResult = UMovementComponent::MoveUpdatedComponent() //脱离穿透移动
- 任何形式的移动都需要Velocity,但是不会被直接被用于更新位置,只做存储用途(真正用于更新位置的是FVector& Delta)。
- UMovementComponent 挂载在Actor时需要绑定更新目标组件并保存到 UpdateComponent 和 UpdatePrimitive ,默认情况下,Actor 的 RootComponent会默认绑定至 UpdateComponent,UpdatePrimitive 则是 UpdateComponent 强转成 UPrimitiveComponent 的结果。对于角色移动场景,通常 UpdateComponent 和 UpdatePrimitive 都指向一个胶囊体组件。
- SafeMoveUpdatedComponent()基于MoveUpdatedComponent()上做了一层包裹,意图在原有Sweep基础上添加了对初始穿透的处理,如果发生初始穿透则使用ResolvePenetration()多次尝试回退移出穿透范围
「UMovementComponent::MoveUpdatedComponent()」
bool UMovementComponent::MoveUpdatedComponent(const FVector& Delta, const FQuat& NewRotation, bool bSweep, FHitResult* OutHit, ETeleportType Teleport)
{
return MoveUpdatedComponentImpl(Delta, NewRotation, bSweep, OutHit, Teleport);
}
virtual bool UMovementComponent::MoveUpdatedComponentImpl( const FVector& Delta, const FQuat& NewRotation, bool bSweep, FHitResult* OutHit, ETeleportType Teleport)
{
if (UpdatedComponent)
{
const FVector NewDelta = ConstrainDirectionToPlane(Delta); //将Delta约束至平面
return UpdatedComponent->MoveComponent(NewDelta, NewRotation, bSweep, OutHit, MoveComponentFlags, Teleport); //实则调用为UPrimitiveCompoent::MoveComponent
}
return false;
}
- MoveUpdatedComponent()是对上层调用的表层函数,内部包裹MoveUpdatedComponentImpl进行虚函数分发
- UpdatedComponent->MoveComponent 实则调用为UPrimitiveCompoent::MoveComponentImpl()
「UMovementComponent::ResolvePenetration()」穿透处理
尝试把被“穿透”(penetrating)在其它碰撞体内的 UpdatedPrimitive 推出重叠体外:先做约束后的简单位移检测(并尽量瞬移),若不行则尝试带 sweep 的移动、最小平移距离(MTD)修正、以及把原始移动方向组合进来,直到成功或放弃,返回是否成功走出穿透。
bool UMovementComponent::ResolvePenetration(const FVector& Adjustment, const FHitResult& Hit, const FQuat& NewRotation)
{
return ResolvePenetrationImpl(Adjustment, Hit, NewRotation);
}
virtual bool UMovementComponent::ResolvePenetrationImpl(const FVector& ProposedAdjustment, const FHitResult& Hit, const FQuat& NewRotationQuat);
核心逻辑:
- 早期检查:ProposedAdjustment 投影约束到允许平面并检查数值合法性,以及判断UpdatedPrimitive的有效性
- 严格重叠检测(OverlapTest),使用 PenetrationOverlapCheckInflation 做形状膨胀,确保精度差异或者重叠测试和扫描测试之间的差异不会导致我们陷入另一个重叠状态
- 若检测不重叠 :不需要使用Sweep直接移动,并返回 true。
- 若仍然重叠 :
- 先尝试使用更远的Sweep进行脱离穿透。
- 若未能移动并且 SweepOutHit.bStartPenetrating(仍处于穿透),计算第二个 MTD:SecondMTD = GetPenetrationAdjustment(SweepOutHit),用两次 MTD 的合成向量 CombinedMTD = Adjustment + SecondMTD 再尝试一次 sweep
- 若仍然失败,尝试把 原始移动方向 的分量 MoveDelta = ConstrainDirectionToPlane(Hit.TraceEnd - Hit.TraceStart) 和 Adjustment 组合:Adjustment + MoveDelta 做 sweep(有时对多表面穿透有效)。
- 如果上述也失败且 MoveDelta 与 Adjustment 同向(点积>0),再尝试仅用 MoveDelta 做一次 sweep(这是最后一次尝试,可能只部分退出但仍有改进)。
- 返回最终是否成功(bMoved)。函数在每一步都有 verbose 日志记录尝试结果,便于诊断。
实现细节:
- 严格的重叠检测 使用 PenetrationOverlapCheckInflation 做形状膨胀,避免因数值误差或检测方法差异而立刻把物体放入另一个重叠状态。
- 优先瞬移(TeleportPhysics):当安全时优先瞬移以避免物理插值副作用;但若需要“退出”穿透则会用 sweep 来确保碰撞约束被正确运作。
- 临时禁用 MOVECOMP_NeverIgnoreBlockingOverlaps:某些移动路径会设置该标志以避免忽略阻挡重叠,但在尝试解穿透时必须允许“忽略阻挡重叠”以便 sweep 能实际把物体移出。TGuardValue 保证调用结束后恢复原始标志。
- MTD(Minimum Translation Distance)策略:通过两次 MTD 组合处理多面穿透情形(例如夹在两块几何体之间),改善单次 MTD 无法完全退出的情况。
- 组合尝试(Adjustment + MoveDelta):把原始运动方向纳入尝试是为了应对复杂几何和多个相交体的情形,常能取得更好结果。
- 日志与调试:函数对每个尝试都输出 verbose 日志,方便Runime下追踪为什么穿透没有被修复。
「UMovementComponent::SlideAlongSurface()」滑动处理
SlideAlongSurface 在角色/物体移动被阻挡时执行“物理上合理的滑动”:先计算沿撞击面的滑动向量并尝试移动(可能触发第一次碰撞回调),若仍被阻挡则用 TwoWallAdjust 处理“撞两面墙”的情况(交线滑动或沿新墙滑动),必要时再做第二次滑动并返回总共消耗的时间比例。
//Delta:原始尝试位移向量(完整帧/时间段内的位移)。
//Time:尝试移动对应的时间量(在 HandleImpact 中报告时间)。
//Normal:初始命中法线(传入用于计算滑动向量)。
//Hit:首次阻挡命中;函数会在后续 SafeMoveUpdatedComponent 中更新 Hit(可能变成第二次命中)。
//bHandleImpact:是否调用 HandleImpact(通知外部第 1/2 次碰撞)。
virtual float UMovementComponent::SlideAlongSurface(const FVector& Delta, float Time, const FVector& Normal, FHitResult& Hit, bool bHandleImpact)
核心逻辑:
- 早期检查:判定是否发生阻挡
- 计算滑动量:SlideDelta = ComputeSlideVector(Delta, Time, Normal, Hit)
- 滑动向量和原始位移向量方向一致性:
- 第一次 SafeMoveUpdatedComponent(Sweep) 尝试沿滑动方向移动。此时会修改Hit(如果有新命中则会更新),并且记录第一次的命中时间
- 第一次滑动后仍有新命中
- 如果bHandleImpact 则进行碰撞响应回调 HandleImpact()
- 调用TwoWallAdjust(SlideDelta, Hit, OldHitNormal) 计算撞击多个表面时的新滑动法线
- 若计算后的滑动向量变化量较大且仍然与位移向量正向,进行第二次移动
SlideAlongSurface()中有两个需要注意的核心计算工具函数ComputeSlideVector(),TwoWallAdjust()
- ComputeSlideVector 给出单面上沿切线移动的向量
- TwoWallAdjust 在遇到多面阻挡时会进一步把这个向量调整为沿交线或沿新墙面再计算一次 ComputeSlideVector(或置零)
「UMovementComponent::HandleImpact()」碰撞响应
UMovementComponent组件原生并未实现HandleImpact内部,只留做接口进行占空
基础角色类:
Pawn && PawnMovementComponent
PawnMovementComponent的设计上用于为 Pawn 提供更新移动的能力(接口/辅助),主要目的还是为了子组件铺垫所提供一些Pawn其关联的移动的基本功能。提供了一种通用的方式来累积和读取方向输入(组件本身并不实现具体的移动行为);

- AddMovementInput()用于收集和存储移动输入。主要用于改变Pawn中的ControlInputVector以及LastControlInputVector。本身不会直接导致Pawn移动,而是将输入量存累计在ControlInputVector中供移动组件在每帧更新时处理。
- ControlInputVector,LastControlInputVector 保存了玩家输入的移动方向和大小,这些输入量通常在对应的移动组件中进行处理。
- 基础Pawn类中并没有直接附加UPawnMovementComponent组件。需要自行添加
- UPawnMovementComponent组件并不实现具体的移动行为,其组件含义为提供输入累积,Owner 关联和一些移动相关的工具函数
- ControlInputVector->LastControlInputVector(目的是防止在帧之间控制输入的累积)
Pawn中不实现具体的移动逻辑,但是存在已经实现的旋转逻辑
旋转流程:
APlayerController::TickActor
PlayerTick
UpdateRotation
APawn::FaceRotation
如果启用以下设置中任意一项
+bUseControllerRotationPitch //使用控制器旋转
+bUseControllerRotationRoll //使用控制器旋转
+bUseControllerRotationYaw //使用控制器旋转
在APawn::FaceRotation中Pawn的旋转会被启用的对应控制器旋转控制(直接SetActorRotation())
如果不是自由相机,尽可能不要使用这个方式,尤其是在Character这种精细化的角色子类。
DefaultPawn(Pawn衍生/模板)
DefaultPawn作为基本实现应用类的简易角色类,可以看作精细化角色类的模板
DefaultPawn会自动处理此输入并移动。由输入导致的最终更新位于TickComponent::SafeMoveUpdatedComponent()中;

- 旋转由父类Pawn的FaceRotation提供,默认启用三项控制器旋转设置
- UFloatingPawnMovement::TickComponent
- ApplyControlInputToVelocity() 消费输入转换为 Velocity ;
- FVector Delta = Velocity * DeltaTime;
- Delta用于驱动SafeMoveUpdatedComponent()实现实际的移动
- HandleImpact()和SlideAlongSurface()处理滑动以及物理阻挡
- UpdateComponentVelocity() 更新组件速度
根据其处理其实我们能够得到通用的移动模式的更新:
- 消费输入转化为Velocity
- 利用Velocity计算出Delta
- Delta驱动SafeMoveUpdatedComponent()实现移动并且返回移动过程中的命中信息
- 根据命中信息决定如何处理滑动/阻挡/穿透等
Character (包含基础运动学的网络同步角色)

核心重载
UMovementComponent章节中详解了一些关键函数,而在UCharacterMovementComponent中对部分核心函数进行了重载,以补充接下来需要的逻辑
「UCharacterMovementComponent::SlideAlongSurface()」
由UMovementComponen::SlideAlongSurface()纯几何碰撞滑动 扩展为 受重力/地面状态/可行走面约束的角色运动滑动
核心重载变化:
- 用RotateWorldToGravity 转换InNormal到重力相对坐标
- 地面移动时的判断(IsMovingOnGround()):
- 如果命中法线 Z > 0(向上分量),但该表面 不可行走(!IsWalkable(Hit)),则把 Normal 仅保留 XY 分量(GetSafeNormal2D()),避免将角色推上不可行走表面,遵循可行走面约束。
- 如果法线 Z < 0(向下命中上层胶囊),并且当前地面距离非常小(CurrentFloor.FloorDist < MIN_FLOOR_DIST && CurrentFloor.bBlockingHit):
- 计算当前地面法线 FloorNormal(重力相对),如果地面法线方向与运动方向相反且不是水平(bFloorOpposedToMovement),则把 Normal 替换为地面法线;
- 最后对 Normal 做 GetSafeNormal2D(),防止胶囊体因为上部挤压下压至地面
- 把处理后的 Normal 再转换回世界空间 RotateGravityToWorld(Normal) 并调用 Super::SlideAlongSurface(...) 完成原始滑动/二次滑动流程。
扩展作用:
添加对于地面状态下约束的处理,根据上下受力分为防止推上不可行走表面以及防止由于冲击力作用于上部时胶囊体压入地面。
同时针对两个核心工具计算函数也做了重载:
- UCharacterMovementComponent::TwoWallAdjust():
- 先调用 Super::TwoWallAdjust,再在 重力相对空间 进行角色特化处理
- 角色可以沿可走表面“上踏”小台阶(受 MaxStepHeight 限制),但不会被不可走表面推上或被向下推入当前地面。
- UCharacterMovementComponent::ComputeSlideVector():
- IsFalling()状态下,调用 HandleSlopeBoosting(...) 对结果做修正,防止下落时沿坡面产生不合理的“坡面加速/抬升”(即 slope boosting)。
「UCharacterMovementComponent::HandleImpact()」
当角色移动被阻挡(发生碰撞)时,这个函数负责把事件通知给角色、路径跟随器和被撞到的 Pawn,并把物理冲击力施加到碰撞对象上(bEnablePhysicsInteraction可选)。
Input-Move流程
Input-Move流程下会在代码中使用以下函数用来移动角色:
- AddMovementInput //添加移动向量
- 移动输入向量被转换为加速度向量,后续在各类动力学模式中被处理为速度进行移动
- 通常根据ControlRotation转换移动向量 //例如攀爬等方向为特殊需要单独处理
根据上方代码添加向量后,角色的移动栈帧为(正常在地面上的流程栈帧):

UCharacterMovementComponent::TickComponent的中的整体流程依旧是前边提到的通用的移动模式的细化版本,在其原有的流程上添加了移动模式(状态机),子步,网络等步骤。
UCharacterMovementComponent::TickComponent()
InputVector = ConsumeInputVector() //消费输入
ControlledCharacterMove(InputVector, DeltaTime)
Acceleration = ScaleInputAcceleration(ConstrainInputAcceleration(InputVector));//计算加速度
PerformMovement() //完整的移动处理流程
FScopedMovementUpdate // 延迟更新
StartNewPhysics() //在这里处理不同状态下的移动逻辑
switch (MovementMode){ PhysWalking PhysNavWalking PhysFalling PhyFlying PhysSwimming PhysCustom }
CalcVelocity() //根据加速度计算速度
SafeMoveUpdatedComponent() //核心移动逻辑(每个分支均会包括)
PhysicsRotation()//核心旋转逻辑。
ComputeOrientToMovementRotation()
// 根据当前运动计算目标旋转,当使用bOrientRotationToMovement时,基于加速度计算旋转
void UCharacterMovementComponent::ControlledCharacterMove(const FVector& InputVector, float DeltaSeconds)
{
{
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(DeltaSeconds);
// apply input to acceleration
Acceleration = ScaleInputAcceleration(ConstrainInputAcceleration(InputVector));
AnalogInputModifier = ComputeAnalogInputModifier();
}
if (CharacterOwner->GetLocalRole() == ROLE_Authority)
{
PerformMovement(DeltaSeconds);
}
else if (CharacterOwner->GetLocalRole() == ROLE_AutonomousProxy && IsNetMode(NM_Client))
{
ReplicateMoveToServer(DeltaSeconds, Acceleration);
}
}
void UCharacterMovementComponent::PerformMovement(float DeltaSeconds)
- 计时/前置验证
- `SCOPE_CYCLE_COUNTER`(性能统计)
- `HasValidData()` / `GetWorld()` 前置有效性检查
- 瞬移检测
- 更新 bTeleportedSinceLastUpdate 用于在地面上瞬移后强制检测地面
- 早退(不能移动的情况)
- 若 `MovementMode == MOVE_None` 或 `UpdatedComponent` 不可动或在物理仿真中:
- 处理 root motion 的消耗(若不是客户端更新且不忽略根运动),清空/消费 root motion,清除累计力,然后返回。
- 基于移动的地面检查准备
- `bForceNextFloorCheck |= (IsMovingOnGround() && bTeleportedSinceLastUpdate);` 如果我们在地上且发生瞬移,强制下一帧做地面检查。
- root motion 的增量调整
- `CurrentRootMotion.LastPreAdditiveVelocity += Adjustment;`
- 保存旧状态(调试用)
- 开始范围移动更新(性能/一致性保障)
- `FScopedCapsuleMovementUpdate ScopedMovementUpdate(UpdatedComponent, bEnableScopedMovementUpdates);`减少重复性碰撞检测
- 根据基座运动更新或者延迟更新位置
- `MaybeUpdateBasedMovement(DeltaSeconds)`
- `UpdateBasedMovement(DeltaSeconds)`
- `UpdateBasedRotation(FinalRotation, PawnDeltaRotation.Rotator());`
- 清理无效的 RootMotion Source
- `CurrentRootMotion.CleanUpInvalidRootMotion(DeltaSeconds, *CharacterOwner, *this);`
- 应用累计外力
- `ApplyAccumulatedForces(DeltaSeconds)`:把外部施加到 character 的力(launch、external forces)转换为速度/影响。
- 更新角色状态(移动前)
- `UpdateCharacterStateBeforeMovement(DeltaSeconds);`移动前修正状态 //源码中为蹲伏状态的切换
- 检查MOVE_NavWalking
- `TryToLeaveNavWalking();` 查询是否需要做离开导航步行状态的转换
- 处理延迟的 Launch
- `HandlePendingLaunch() Character::LaunchCharacter()` 被延迟执行时在此生效
- `ClearAccumulatedForces` 清除累计力
- 准备/收集 Root Motion(在物理前)
- 存在根运动且不由客户端更新:
- `IsPlayingRootMotion()
- `TickCharacterPose()`,并转换动画 local->world。
- `CurrentRootMotion.PrepareRootMotion(...):准备合成/累积来自非动画来源的 root motion。
- 应用 Root Motion 到 Velocity
- `HasAnimRootMotion()` animation root motion 转成世界空间速度,覆盖 `Velocity`
- `RootMotionParams = ConvertLocalRootMotionToWorld()`
- `AnimRootMotionVelocity = CalcAnimRootMotionVelocity()`
- `Velocity = ConstrainAnimRootMotionVelocity()`
- `else`
- 若 `HasOverrideVelocity`,合成 override velocity 到 `Velocity`
- NaN 校验
- 宏 `ensureMsgf(!Velocity.ContainsNaN()` 确保速度合法,避免灾难性崩溃
- 清除跳跃输入与计数
- `CharacterOwner->ClearJumpInput(DeltaSeconds)`
- `NumJumpApexAttempts = 0`
- 主要物理移动调用
- `StartNewPhysics(DeltaSeconds, 0)` 真正移动角色地方,存在基本的动力学
- 再次验证有效性
- 移动后更新角色
- `UpdateCharacterStateAfterMovement(DeltaSeconds);`移动后修正状态 //源码中为蹲伏状态的切换
- 旋转处理
- `PhysicsRotation(DeltaSeconds);`
- 应用 Root Motion 的旋转
- `HasAnimRootMotion()`
- `MoveUpdatedComponent(FVector::ZeroVector, NewActorRotationQuat, true)}`
- `CurrentRootMotion.HasActiveRootMotionSources()`
- `MoveUpdatedComponent(FVector::ZeroVector, NewActorRotationQuat, true);`
- 消费路径跟随请求速度
- `LastUpdateRequestedVelocity = bHasRequestedVelocity ? RequestedVelocity : FVector::ZeroVector;`
- `bHasRequestedVelocity = false;`
- 触发移动更新回调
- `OnMovementUpdated(DeltaSeconds, OldLocation, OldVelocity);`
- 结束范围移动更新
- 退出 `FScopedCapsuleMovementUpdate`,让变换/碰撞状态最终生效。
- 外部事件回调
- `CallMovementUpdateDelegate(DeltaSeconds, OldLocation, OldVelocity)`:广播外部监听者(在 scoped update 之外,以便事件能看到最终 Overlaps 等)。
- 保存/更新基座位置选项
- 根据 CVar 选择 `SaveBaseLocation()` 或 `MaybeSaveBaseLocation()`(修复/优化相关行为)。
- 更新组件速度缓存
- `UpdateComponentVelocity()`:把 movement component 的 `Velocity` 同步到 UpdatedComponent 的 velocity 等。
- 网络相关:尽早取消自适应频率 Throttle
- 在服务器权威下,如果 actor 移动了并且网络更新在被节流,可能会调用 `NetDriver->CancelAdaptiveReplication(CharacterOwner)` 来确保快速同步。
- 计算/保存本帧最终位置/旋转并在服务端处理 timestamp
- `NewLocation = UpdatedComponent->GetComponentLocation()`,`NewRotation = UpdatedComponent->GetComponentQuat()`。若 server 且 transform 改变,保存 `ServerLastTransformUpdateTimeStamp`(有条件使用客户端时间戳)。
- 保存 LastUpdateLocation/Rotation/Velocity
- `LastUpdateLocation = NewLocation; LastUpdateRotation = NewRotation; LastUpdateVelocity = Velocity;` —— 用于下一帧比较/差分和网络预测。
在初步分析Input-Move流程中,将核心通用逻辑精简为几点进行分析:
- 根据输入,外力等综合计算加速度,通过加速度计算 Velocity
- 利用Velocity,dt计算出位移向量Delta
- SafeMoveUpdatedComponent(Delta,&OutHit)实现移动并且返回移动过程中的命中信息
- 根据命中信息决定如何处理滑动/阻挡/穿透等
加速度计算
处理加速度的流程位于ControlledCharacterMove中(转化输入为加速度,是构成速度计算的核心),移动的核心逻辑都在PerformMovement中.
首先调用上层接口用于更新输入方向:
APawn::AddMovementInput
MovementComponent->AddInputVector(WorldDirection * ScaleValue, bForce);
//内部更新 成员变量 FVector ControlInputVector
在进入核心PerformMovement前消费输入转化为加速度:
InputVector = ConsumeInputVector() //消费输入
ControlledCharacterMove(InputVector, DeltaTime)
Acceleration = ScaleInputAcceleration(ConstrainInputAcceleration(InputVector));//计算加速度
Tips:「加速度向量」和「最大加速度值,输入比例,输入方向」三者相关
速度计算
速度计算核心由UCharacterMovementComponent::CalcVelocity()承担,最终更新变量:UCharacterMovementComponent::Velocity;
virtual void CalcVelocity(float DeltaTime, float Friction, bool bFluid, float BrakingDeceleration);
核心逻辑:
- 早期返回检查(root motion / simulated proxy / delta time)
- 准备基本参数(Friction/MaxAccel/MaxSpeed)
- 处理路径跟随请求(RequestedAcceleration/RequestedSpeed)
- 处理 bForceMaxAccel(强制最大加速)
- 计算 MaxInputSpeed,并合成最终 MaxSpeed(考虑 RequestedSpeed)
- 加速/降速(制动)计算
- 若制动:调用 ApplyVelocityBraking 并可能做超速保留修正
- 若非制动且有加速:用插值使速度方向逼近加速方向
- 若制动:调用 ApplyVelocityBraking 并可能做超速保留修正
- 若非制动且有加速:用插值使方向逼近加速方向
- 若处在流体中:按比例缩放速度(流体阻尼)
- 应用玩家输入加速度(并 clamp)
- 应用路径请求加速度(并 clamp)
- RVO 避让(若启用)
Tips:CalcVelocity不处理重力
加速/降速(制动)计算
const bool bZeroAcceleration = Acceleration.IsZero();
const bool bVelocityOverMax = IsExceedingMaxSpeed(MaxSpeed);
if ((bZeroAcceleration && bZeroRequestedAcceleration) || bVelocityOverMax)
{
// ApplyVelocityBraking 当没有任何加速度时/当前速度超过最大值 进行制动
}
else if (!bZeroAcceleration)
{
const FVector AccelDir = Acceleration.GetSafeNormal();
const float VelSize = Velocity.Size();
Velocity = Velocity - (Velocity - AccelDir * VelSize) * FMath::Min(DeltaTime * Friction, 1.f);
}
加速计算
- 在有输入加速度的情况下,先做一个方向性摩擦处理,把 velocity 的方向逐渐向 AccelDir 对齐,逐步改变速度大小。使速度方向过渡平滑。
- 核心数学模型:Velocity = Lerp(Velocity, AccelDir * VelSize, min(DeltaTime * Friction, 1))
降速/制动
- 当没有任何加速度输入(玩家/请求都为零),或者当前速度超过 MaxSpeed(需要降速),选择“制动路径” —— 调用 ApplyVelocityBraking,这是朝着减速/停止的方向作用(会考虑 BrakingFriction 或 Friction)。
- 核心数学模型:Velocity = Velocity + ((-Friction) * Velocity + RevAccel) * dt
流体中按比例缩放速度
if (bFluid)
{
Velocity = Velocity * (1.f - FMath::Min(Friction * DeltaTime, 1.f));
}
采用常见的阻尼模型进行速度的缩放
应用输入限制
// Apply input acceleration
if (!bZeroAcceleration)
{
const float NewMaxInputSpeed = IsExceedingMaxSpeed(MaxInputSpeed) ? Velocity.Size() : MaxInputSpeed;
Velocity += Acceleration * DeltaTime;
Velocity = Velocity.GetClampedToMaxSize(NewMaxInputSpeed);
}
允许当前超速继续存在,但阻止进一步超速
StartNewPhysics()
StartNewPhysics()是Input-Move流程的Move核心,其中根据当前状态不同分为不同的运动模式,每个运动模式中包含特有的运算逻辑。其中PhysWalking和PhysWalking是构成角色模拟物理运动的核心,90%的情况下角色都处于这两种模式。
PhysWalking
PhysWalking:基于物理/地面检测,和当前地面信息来移动,能够处理步升,窄缝,可行走坡面检查等几何交互。
- 子步与复杂碰撞处理:把 deltaTime 切成多个 timeTick 子步,逐步调用 MoveAlongFloor,Findfloor用于处理复杂场景
- 性能:由玩家角色通常使用,更昂贵但能更精确地处理真实碰撞
- 速度/位移更新:每个子步结束后会基于实际移动重新计算速度,根据是否存在特殊状态(根运动等)进行合理计算
- 碰撞处理:遇阻会尝试寻找地面或者寻求步升,并可能分配时间片继续处理。同时会主动解决穿透问题
先介绍PhysWalking中存在的模式专用的核心函数
FindFloor() + ComputeFloorDist() + ComputePerchResult()
FindFloor()提供胶囊体当前所处的地面信息,更新FFindFloorResult,根据其当前的状态决定如何对地面信息进行检测(使用正常扫描和栖息扫描两种查询方式)
ComputeFloorDist()则负责FindFloor()的实际物理查询(使用胶囊Sweep/线段Trance两种查询方式),并填写OutFloorResult。其中Sweep的胶囊体较原有胶囊体更矮,其中线段Trance检测长度要小于胶囊Sweep
ComputePerchResult()则对ComputeFloorDist()进行包裹,重新调整sweep胶囊体的大小,在原有sweep的基础上缩小胶囊体半径,检测半径与可栖息半径一致,检测半高比胶囊体半高稍小

- Step1.调用ComputeFloorDist(),如果命中结果为【碰撞处法线满足「可行走坡度」,且离地距离小于TraceDist】或【未产生任何碰撞以及初始穿透】。满足两种条件中的任意一种不会进入Step2模式,直接由Step1结果离开函数;
- 图左:【碰撞处法线满足「可行走坡度」,且离地距离小于TraceDist】此时在Step1模式下的ComputeFloorDist()只会采用胶囊Sweep查询方式。最终结果查询成功
- 图中:【未产生任何碰撞以及初始穿透】此时在Step1模式下的ComputeFloorDist()只会采用胶囊Sweep查询方式。最终结果查询失败
- 图右:【发生碰撞但是【离地距离不满足或地面法线不满足「可行走坡度」】】此时在在Step1模式下的ComputeFloorDist()采用胶囊Sweep查询+线段Trance查询方式。最终结果查询成功

如果Step1中【发生碰撞且碰撞接触点位移栖息合法范围外(由ShouldComputePerchResult(Hit)判定)】,则会进入栖息检测模式。目的是处理行走时可能需要的连贯性,避免平台边缘掉落得太突然导致在台阶型地形时可能会出现步行/下落的反复跳转。
Step2.栖息检测模式调用ComputePerchResult(),重新调整Step1中Sweep胶囊体的尺寸(在Step1基础上缩小半径)再次进行Sweep。目的是处理【可能存在的下方细小落脚点】
- 图左:【碰撞处法线满足「可行走坡度」,【Step2小型Sweep扫描成功且栖息离地距离小于TraceDist】】此时在Step2模式下的ComputeFloorDist()只会采用胶囊Sweep查询方式。最终结果查询成功
- 图中:【发生碰撞但是【Step2小型Sweep扫描失败】】此时在在Step2模式下的ComputeFloorDist()只会采用胶囊Sweep查询.最终结果查询失败
- 图右:【发生碰撞但是【离地距离满足但是不满足可行走坡度】】此时在Step2模式下的ComputeFloorDist()采用胶囊Sweep查询+线段Trance查询方式。最终结果查询成功(但是非常极限,如果再多移动则会直接进入下落模式)
整体逻辑
void UCharacterMovementComponent::FindFloor(const FVector& CapsuleLocation, FFindFloorResult& OutFloorResult, bool bCanUseCachedLocation, const FHitResult* DownwardSweepResult) const
- 早期检查:
没有碰撞查询权限或数据不完整退出。
- 参数准备与高度调整
根据当前是否在地面上调整地面检测范围并计算计算 HeightCheckAdjust,如果当前已经在地面上(IsMovingOnGround()),地面检测范围 FloorSweepTraceDist 会稍微扩大(+ MAX_FLOOR_DIST + small),以避免之后的高度调整让之前的检测结果失效。否则缩小检测高度(-MAX_FLOOR_DIST)。
FloorSweepTraceDist = max(MAX_FLOOR_DIST, MaxStepHeight + HeightCheckAdjust)。FloorLineTraceDist 同值。
bNeedToValidateFloor = true 是否应该做实际的查询(缓存/强制检查)
- Step1.正常检测
满足任意条件时会会清空 bForceNextFloorCheck 并调用 ComputeFloorDist(...)进行实际的物理查询
不满足条件时会检查当前“站立基底”(MovementBase):
如果 MovementBase 存在且它的查询碰撞开启且对我们碰撞通道是阻挡(Block),且不是动态基底(或 actor 未被标记为 pending kill),那么直接复用 CurrentFloor。
否则仍然清空 bForceNextFloorCheck 并调用 ComputeFloorDist(...)进行实际的物理查询
- step2.栖息尝试 (bNeedToValidateFloor且OutFloorResult的结果来自非简单线段扫描)
ShouldComputePerchResult(基于 Hit 的法线、角度等)判断是否有必要做额外的栖息尝试 计算。
计算 MaxPerchFloorDist,并在已在地面时增加 PerchAdditionalHeight
调用 ComputePerchResult(...) 得到 PerchFloorResult。
若 ComputePerchResult 返回 true(能挂靠/站上去),则:
可能把 OutFloorResult.FloorDist 调整为平均值以避免“抬得太高”导致下次掉落。
如果原 OutFloorResult 表示不可行走(unwalkable),但 PerchFloorResult 可行走,则用 SetFromLineTrace 用 Perch 的 Hit 替换原结果,使其变为可站立的。
若 ComputePerchResult 返回 false,则标记 OutFloorResult.bWalkableFloor = false(无有效地面 => 会开始下落)。
void UCharacterMovementComponent::ComputeFloorDist(const FVector& CapsuleLocation, float LineDistance, float SweepDistance, FFindFloorResult& OutFloorResult, float SweepRadius, const FHitResult* DownwardSweepResult)
- 初始化以及早期检查
初始化 OutFloorResult。获取角色胶囊半径 PawnRadius 与半高 PawnHalfHeight
DownwardSweepResult如果有效(传入的先前命中结果),对先前结果进行判定
条件一:Sweep结果必须垂直向下/完全接近垂直
条件二:剔除位胶囊体边缘上的阻挡命中(防止靠在墙体而误判为站地),并且通过IsWalkable()检查
如果条件一二均达成,直接使用先前命中结果并设置到OutFloorResult中
- 距离一致性检查
确保 SweepDistance >= LineDistance。因为 sweep 的 Hit 最后作为 sweep 结果来解释。
- 配置查询参数
创建 FCollisionQueryParams(带 Trace tag、忽略自身/拥有者等),并初始化响应参数(InitCollisionParams),选取碰撞通道(UpdatedComponent->GetCollisionObjectType())。
- Step1.Sweep(胶囊体)
设定 ShrinkScale、ShrinkScaleOverlap 用于缩短胶囊高度,并且构建缩短胶囊体。避免从如果初始情况胶囊体正好与表面重叠导致sweep奇怪结果,同时允许从穿透中拉出角色。
bBlockingHit = FloorSweepTest()
如果产生命中,判定命中结果
如果 Hit.bStartPenetrating 或命中点在胶囊边缘 IsWithinEdgeTolerance 内,认为是“邻接”或边缘命中:
尝试用更小的半径/短一点的高度再次bBlockingHit = FloorSweepTest()以避开“相邻物体”误判。
计算SweepResult:将Sweep命中转为胶囊体从底部到命中点的距离,同时允许距离为负数。
调用SetFromSweep(Hit)返回最终有效Hit,判定其IsValidBlockingHit() && IsWalkable(Hit)且检测距离合法,设置OutFloorResult.bWalkableFloor = true并返回。此时找到了可站立的地面
- 当Step1未产生有效命中时,执行退出(既没有阻挡也没有穿透,同时设置OutFloorResult.FloorDist = SweepDistance;
- Step2.Trance(当 LineDistance > 0)
猜测胶囊体因为穿透或者被边缘物干扰做一个从胶囊中心开始向下的线段 trace
若命中且 Hit.Time > 0:计算 LineResult = Hit.Time * TraceDist - ShrinkHeight(并裁剪到 -MaxPenetrationAdjust)。
如果射线命中且检测点合法(LineResult <= LineDistance && IsWalkable(Hit)):采用结果并且返回
Tips:
- 在Step1中列举了一个斜壁+悬崖的窄缝,如果这个变为悬崖+悬崖的窄缝则会触发bug,被判为“阻挡命中但不可站立” 同时处于胶囊体会出现穿透/拉出修正的反复行为
- 胶囊体Sweep模式:优先用胶囊 sweep 检测全面接触(考虑宽度),但要剔除“侧面/邻接物”与穿透情况下的误判;允许 negative distance 以便把角色从穿透中“拉出”。
- 如果Sweep模式中扫掠没有命中(并且不是因为穿透)。函数会直接退出,不再做线段检测
- 如果Step1,2均未返回有效结果,则OutFloorResult.bWalkableFloor = false(无可站立地面)。
- FindFloor()中的MovementBase查询分支优化了性能,如果我们靠在一个静态、安全的基底上且没什么变化,就复用先前结果,避免频繁 expensive 的 trace。
MoveAlongFloor() + StepUp()
MoveAlongFloor()承担PhysWalking的核心移动功能,会尝试沿当前地面移动,其设计的主要目的是为了解决以下三种情况
MoveAlongFloor阶段发生碰撞但是不满足「可行走坡度」的情况下,会先执行步升流程。步升失败的情况下才会进入滑动流程。

StepUp()(步升流程)为了解决地面微小起伏和低矮台阶,使其胶囊体能够顺利爬上此类阻挡物

CanStepUp()判定碰撞位置,碰撞位置必须位于垂直面(及下半部分,可以近似看作只有下方半胶囊体)
StepUp核心过程会拆分成 3 次 Sweep,即 Step Up -> Step Fwd -> Step Down.
步升高度 = 最大步高 - 离地距离
步降高度 = 最大步高 + 离地偏移
整体流程
void UCharacterMovementComponent::MoveAlongFloor(const FVector& InVelocity, float DeltaSeconds, FStepDownResult* OutStepDownResult)
- 早期检查:(只在可走地面上运行):若 !CurrentFloor.IsWalkableFloor() 立即 return。
- 计算水平位移 Delta(沿地面):
将 InVelocity 转到重力相对空间 RotateWorldToGravity,把 Z 分量清为 0(* FVector(1,1,0)),再转回世界空间并乘以 DeltaSeconds:Delta = RotateGravityToWorld(RotateWorldToGravity(InVelocity) * (1,1,0)) * DeltaSeconds;
目的是只沿“地面平面”移动,不直接把竖直分量用于此处位移(竖直由 floor/step 逻辑处理)。
- 计算实际地面移动向量(可能受坡/碰撞修正):
RampVector = ComputeGroundMovementDelta(Delta, CurrentFloor.HitResult, CurrentFloor.bLineTrace);
ComputeGroundMovementDelta 会把位移按当前地面坡度/法线调整(例如沿坡度移动、处理小台阶等)。
- 尝试安全移动(sweep):
SafeMoveUpdatedComponent(RampVector, UpdatedComponent->GetComponentQuat(), true, Hit);
Hit 会报告碰撞详情与 Hit.Time(已应用时间比例)。
LastMoveTimeSlice = DeltaSeconds 为后续 HandleImpact/SlideAlongSurface 提供时间语义。
- 若开始处就穿透(bStartPenetrating):
先 HandleImpact(Hit)(允许游戏逻辑/物理反应)。
调用 SlideAlongSurface(Delta, 1.f, Hit.Normal, Hit, true) 试图沿表面滑动以脱离穿透。
若仍 bStartPenetrating,触发 OnCharacterStuckInGeometry(&Hit)(告警/处理)。
- 若发生有效阻挡(IsValidBlockingHit):
取 PercentTimeApplied = Hit.Time(表示本次尝试已应用的比例)。
若命中的是另一个可走坡面(Hit.Normal.Z > small && IsWalkable(Hit) && Hit.Time > 0):
计算剩余时间内的移动:InitialPercentRemaining = 1 - PercentTimeApplied,并调用 ComputeGroundMovementDelta(Delta * InitialPercentRemaining, Hit, false) 得到 RampVector,更新 LastMoveTimeSlice 并再次 SafeMoveUpdatedComponent(尝试在新坡面上继续移动)。
计算并累加第二次命中时间占比:SecondHitPercent = Hit.Time * InitialPercentRemaining 并合并到 PercentTimeApplied。
如果仍有阻挡(Hit.IsValidBlockingHit):进入台阶/滑动/碰撞处理:
如果 CanStepUp(Hit) 或命中的是当前 movement base(Hit.HitObjectHandle == MovementBase->GetOwner()),尝试 StepUp(GravDir, Delta * (1 - PercentTimeApplied), Hit, OutStepDownResult):
StepUp 成功:记录 PreStepUpLocation,若 !bMaintainHorizontalGroundVelocity,则把上台阶导致的高度变化也计入 Velocity(bJustTeleported=true,并以 StepUpTimeSlice 重新计算并把垂直分量投影到水平面)——保证后续步的速度语义正确。
StepUp 失败:记录日志、HandleImpact(Hit, LastMoveTimeSlice, RampVector),然后 SlideAlongSurface(Delta, 1.f - PercentTimeApplied, Hit.Normal, Hit, true)。
否则若击中的组件显式阻止 step up(!CanCharacterStepUp),直接 HandleImpact(...) 并 SlideAlongSurface(...)。
AdjustFloorHeight()
AdjustFloorHeight()通常用于确定位移之后,对胶囊体进行地面高度适配,防止下一帧运动前直接穿透地表,将胶囊体始终和地面保持一定离地偏移

整体流程
void UCharacterMovementComponent::PhysWalking(float deltaTime, int32 Iterations)
- 基础检查与快速返回
若 deltaTime < MIN_TICK_TIME 直接返回。
若 CharacterOwner不存在或满足一系列“不应运行物理”的条件,则清零 Acceleration/Velocity 并返回。
若 UpdatedComponent->IsQueryCollisionEnabled()为关闭,将模式设为 MOVE_Walking 并返回(避免在无查询碰撞时做复杂地面逻辑)。
- 循环分片模拟(子步)
用 while 在 remainingTime 里按小步推进,直到时间耗尽或超过 MaxSimulationIterations。
每次迭代用 GetSimulationTimeStep(remainingTime, Iterations) 得到 timeTick(保证稳定的步长),并从 remainingTime 扣除它。
- 迭代前状态保存
- 记录 OldBase、PreviousBaseLocation、OldLocation、OldFloor,便于位移失败时回滚或重计算位移。
- 依据当前状态先行计算速度 //核心
RestorePreAdditiveRootMotionVelocity()(解除临时 root motion 调整)。
MaintainHorizontalGroundVelocity() 确保地面速度在重力方向/约束方向上为零。
将 Acceleration 投影到水平面上(VectorPlaneProject 与 -GravityDirection)。
- 如果没有动画/根运动覆盖,调用 CalcVelocity(timeTick, GroundFriction, false, GetMaxBrakingDeceleration()) 计算速度。(速度计算核心)
- ApplyRootMotionToVelocity(timeTick) 把根运动整合入速度(内部判断是否存在根运动)
- MoveAlongFloor(MoveVelocity, timeTick, &StepDownResult)(地面移动核心)。
- 更新地面信息(FindFloor)
如果 StepDownResult.bComputedFloor 为真,使用它;否则调用 FindFloor 在当前位置重新检测地面(CurrentFloor)。
- 悬崖 / ledge 处理
若配置为不允许走下悬崖(!CanWalkOffLedges())且 CurrentFloor 不是 walkable:
尝试 GetLedgeMove 得到替代位移(一次尝试,bTriedLedgeMove 防止重复)。若可行,RevertMove 回退并用新位移重试(continue)。
否则调用 CheckFall(或判断是否必须跳)判断是否应进入下落(StartFalling)或直接回滚并结束本次 PhysWalking。
- 合法地面分支
若 CurrentFloor.IsWalkableFloor():
若应“catch air”(ShouldCatchAir 返回 true),调用 HandleWalkingOffLedge 并可能转换到 StartFalling。
否则 AdjustFloorHeight() 并 SetBase(...) 将脚底基座设为当前 floor。
若 CurrentFloor.HitResult.bStartPenetrating 且 remainingTime <= 0,尝试用 ResolvePenetration 将脚从穿透中弹出,并设置 bForceNextFloorCheck = true。
- 检测是否进入水中
在 MoveAlongFloor 以及 FindFloor 后再次检查检测 IsSwimming(),若进入水中则调用 StartSwimming(...) 切换状态并交出剩余时间。
- 允许overlap/事件改变速度
如果仍在地面且这次迭代有移动,若没有 teleport/root motion 覆盖,基于实际从 OldLocation 到 UpdatedComponent->GetComponentLocation() 的位移来重新设置 Velocity = deltaPos / timeTick,从而反映碰撞或 overlaps 导致的速度变化;随后再 MaintainHorizontalGroundVelocity()。
- 终止条件
若本迭代没有移动(位置与 OldLocation 相同),把 remainingTime = 0 并跳出循环;最后在函数尾部再次 MaintainHorizontalGroundVelocity()。
串联并且整理PhysWalking整个流程:
- 前期速度计算:依据在PerformMovement()中前期计算的加速度,在进入移动前处理成具有平面约束的加速度。随后调用CalcVelocity(),ApplyRootMotionToVelocity()整合移动前需要的最终速度
- 中期核心移动计算:移动计算主体由MoveAlongFloor()承担,其中会将原始的水平位移Delta需要转化成按照地板法线调整后的位移Delta,以便适应起伏地形
- 后期重计算模式以及更新变量:FindFloor()进行地面信息的更新,随后依据新的地面信息判定是否进行转换模式。在末尾发送回调且判定子步的终止条件
优缺点:
整个PhysWalking的流程能够体现UE的基本MOVE逻辑,先尝试移动,失败后调整再次尝试,一般经过多次尝试最后确定最终移动且尝试过程中在严苛情况下可达最多4次Sweep,并且会在时间子步中重复多次。这种方式保守且安全,但是带来的即是超高的性能负载,尤其是在同屏大批量下Character下,性能帧会急速降低,虽然在PerformMovement()有针对碰撞的优化检查,但是这种负担对于移动端来说几乎是毁灭性的
PhysNavWalking
PhysNavWalking:基于 NavMesh(导航数据)来决定目标位置并把位置投影到 NavMesh 上,优先满足导航一致性。且保证 NPC/AI 贴在可导航表面上移动
- 子步与复杂碰撞处理:通常在单个步骤内完成,没有PhysWalking中复杂的地面判断逻辑,导航特化模式。
- 投影与容错:bProjectNavMeshWalking 与 ProjectLocationFromNavMesh 控制按胶囊高度比例向上/向下投影的行为,避免把角色投影到不合适的高度或穿过障碍。
- 数据依赖:依赖 NavMesh 的覆盖质量;NavMesh 好则行为更稳定,NavMesh 不包含部分几何时会退化(触发掉落)。
- 性能:更便捷 / 便于 AI 路径跟随(更节省开销、对导航一致性更友好),适用于 AI 代理在 baking 好的 navmesh 上行走。
- 速度/位移更新:在整体移动后以 (newFeetLocation - oldFeetLocation) / deltaTime 直接重写速度。并且直接调用SafeMoveUpdatedComponent()不做包装。
- 碰撞处理:采用 Nav 投影与一次 SafeMove,碰撞主要由 SafeMoveUpdatedComponent 处理;不会像 PhysWalking 那样细粒度尝试步升或者复位。
整体流程:
void UCharacterMovementComponent::PhysNavWalking(float deltaTime, int32 Iterations)
- 早期检查
- 保留旧数据:
保存起始 movement mode;
RestorePreAdditiveRootMotionVelocity()
MaintainHorizontalGroundVelocity();
把 Acceleration.Z=0(只在水平面加速)。
- 计算速度
若没有 root motion 覆盖则调用CalcVelocity()计算速度
- 模式切换判定
若 root motion 在计算中把 movement mode 改变,直接 StartNewPhysics(交由新模式处理)。
- 更新 DesiredMove 和目标点数据
DesiredMove = Velocity 且 DesiredMove.Z = 0(只考虑水平分量)。
OldLocation = GetActorFeetLocation(); DeltaMove = DesiredMove * deltaTime;AdjustedDest = OldLocation + DeltaMove。
- NavLocation 缓存判断 (目的 更新bSameNavLocation)
bProjectNavMeshWalking 启用时,会用 2D 距离与 Z 距离阈值 bSameNavLocation(基于胶囊高度和 NavMeshProjectionHeightScaleUp/Down)来判定是否仍处于相同导航点。
bProjectNavMeshWalking 停用时,根据旧位置验证当前移动距离是否极小来更新bSameNavLocation
- 查询Nav点
bDeltaMoveNearlyZero && bSameNavLocation时直接复用缓存跳过查找 DestNavLocation = CachedNavLocation。
如果缓存无效则查找 Nav 点(FindNavFloor)
- Nav若失败,SetMovementMode(MOVE_Walking) 并返回(退出 NavWalking)。
成功时把 CachedNavLocation = DestNavLocation。
- 导航位置有效则更新位置,将目标高度/位置映射回世界
初步把 nav 高度用于 Z
bProjectNavMeshWalking启用时,利用ProjectLocationFromNavMesh()更稳妥地把位置投影回可行走表面(偏移基于胶囊体数据)
位移量若有效则调用SafeMoveUpdatedComponent()进行位移
更新组件速度:Velocity = (GetActorFeetLocation() - OldLocation) / deltaTime(并 MaintainHorizontalGroundVelocity())。
- 导航位置无效则调用StartFalling()
PhysFalling
PhysFalling:处理自由落体运动,按子步计算受重力与空气控制的速以及若干跳跃和根运动的特殊情况。
- 子步与复杂碰撞处理:多子步 + 中点积分,专门处理空中动力学:跳跃顶点、重力分段、空中受控与受限空中控制的碰撞后恢复。
- 性能:性能与用途:用于准确的空中物理与可预测跳跃体验
- 数据依赖:小幅依赖输入,主要依赖重力方向和重力加速度大小
- 速度/位移更新:利用重力替换原有加速度,并且大幅缩减输入对于横向加速度的作用。同时使用不包装的SafeMoveUpdatedComponent()进行更新位移
- 碰撞处理:碰撞以 deflection 为主,可能在碰撞时做TwoWallAdjust以及Findfloor检查着陆,极端情况下会出现使用跳跃脱离卡死
整体逻辑:
void UCharacterMovementComponent::PhysFalling(float deltaTime, int32 Iterations)
- 早期检查
检查剩余子步时间
- 数据准备:
计算横向空中加速度 FallAcceleration:并调用 ShouldLimitAirControl() 决定是否需要对后续空中控制做限制(bHasLimitedAirControl)
保存状态 / root motion 处理
记录 OldLocation, PawnRotation
记录 OldVelocityWithRootMotion = Velocity 和 OldVelocity(非根运动下)。
RestorePreAdditiveRootMotionVelocity() 恢复之前临时变更的速度。
- 应用输入(横向空气控制)
如果没有动画根运动覆盖,通过TGuardValue<FVector> RestoreAcceleration(Acceleration, FallAcceleration)后调用 CalcVelocity(),将其中 Acceleration 临时替换为 FallAcceleration用于计算横向速度变化(表现为横向输入变得十分微小,目的是在空中允许受限的横向控制,但不改变垂直分量(除非 root motion /特殊处理))
- 处理跳跃
若 JumpForceTimeRemaining > 0(当跳跃还在持续中),则只在剩余部分时间对其后段应用重力。同时减少 JumpForceTimeRemaining 并在结束时 ResetJumpState()。
- 应用重力(NewFallVelocity)与 root motion
用 Velocity = NewFallVelocity(Velocity, Gravity, GravityTime) 计算垂直分量变化(重力方向,如果需要处理不同重力方向时需要重载重力相关函数:GetGravityDirection(),GetGravityZ())。
ApplyRootMotionToVelocity(timeTick)
DecayFormerBaseVelocity(timeTick)
- 精确分布:使用精准子步一判定跳跃是否达到顶点,避免帧率变化导致的截断
- 更新位移
中点法:Adjusted = 0.5 * (OldVelocityWithRootMotion + Velocity) * timeTick。因为跳跃力刚结束且期间没应用重力,要按非重力段 + 重力段分别积分以更精确地计算位置。
SafeMoveUpdatedComponent(Adjusted, PawnRotation, true, Hit);
- 模式检查
如果进入水体则退回剩余子步且切换模式
如果阻挡命中则进入碰撞处理
- 碰撞处理
计算Adjusted = Velocity * timeTick,后续可以根据偏移量重新计算速度并且保证碰撞时期包含完整重力
调用 ShouldCheckForValidLandingSpot(),并且 FindFloor 尝试是否能落地。
HandleImpact碰撞响应
重新计算输入速度,并根据当前输入加速度决定是否依据输入进行撞击的偏转
计算沿碰撞平面的滑动向量。Delta = ComputeSlideVector(Adjusted, 1.f - Hit.Time, OldHitNormal, Hit)
同时考虑碰撞物体的基座速度(UseTargetVelocityOnImpact CVar)来计算接触框架下的新速度。
当仍有剩余子步时:
第二次 SafeMoveUpdatedComponent();第二次的move尝试不一定会触发,如果不存在剩余子步则直接更新速度
再次被阻挡时
记录 OldHitNormal、OldHitImpactNormal,做 TwoWallAdjust(Delta, Hit, OldHitNormal)
存在空气限制时合并输入进入Delta,计算偏转后的速度
再次更新位移SafeMoveUpdatedComponent(Delta);
判定是否存在落脚点,以及是否处于两个斜面夹住胶囊体使其无法下落的状态
若卡住(Hit.Time==0)尝试侧移救援,再次移动并检查。
如果合法则ProcessLanded()
如果仍然卡住,则判定为少见bug情况,基于胶囊体随机弹跳尝试脱离卡死
- 再次更新速度:
抑制很小的横向速度
同时清零非常小的横向分量(SizeSquared2D() 小于阈值),避免漂移。
PhysFalling中对于速度的计算有一个特别的处理,PhysFalling中仍需要输入对对其横向产生一定作用力(即使非常微小),这是因为我们如果在Falling途中遭遇碰撞,我们可能需要输入(玩家意图)进行偏转。但是由于CalcVelocity()中实际应用了输入转换后的加速度,所以源码中采用了RestoreAcceleration(Acceleration, FallAcceleration)将坠落加速度在作用域内替换原有加速度,随后加速度在NewFallVelocity中合并重力计算最后为最终速度.
Tips:由于角色90%的情况下都在PhysWalking和PhysFalling中,而两者的过渡经常因为地形出现各种奇奇怪怪的bug,而在PhysFalling中针对下图中在测试期间经常出现的bug做了处理(实际工程中此处理几乎没用),位于此BUG的触发关键在于阻挡命中但不可站立,于此同时胶囊体会尝试进入PhysFalling模式,在PhysFalling下落阻挡命中存在判定此BUG状态的代码块,代码块会尝试将胶囊体采用向上跳跃的方式以脱离当前卡死的状态,当遇见这种情况,特别是开放世界项目,最推荐的做法是提供“脱离卡死”按钮,以及提供BUG报告进行地形修复。如果引入新的机制判定此状态,有可能解决一次bug的情况下引发更多的bug。因此最好把这个处理代码块当作一个事件通知

PhysFlying
PhysFlying 是飞行模式下每帧的移动实现 —— 它按一次完整步长把速度积分为位置(SafeMoveUpdatedComponent),处理简单的碰撞响应(尝试 step-up,或 HandleImpact + SlideAlongSurface),最后基于实际移动重写速度(除非有 root motion 覆盖)。总体比 Walking/Falling 简单
- 子步与复杂碰撞处理:无子步处理,一帧进行一次移动
- 数据依赖:输入依赖,由输入或根运动提供速度
- 速度/位移更新:使用不包装的SafeMoveUpdatedComponent()进行更新位移
- 碰撞处理:支持有限步升,通常使用滑动解决碰撞
void UCharacterMovementComponent::PhysFlying(float deltaTime, int32 Iterations)
- 早期检查
- 数据准备
恢复 root motion 前状态:RestorePreAdditiveRootMotionVelocity()。
- 速度计算(若无 root motion 覆盖)
作弊飞行模式下无输入就清空速度 bCheatFlying 且 Acceleration.IsZero():Velocity = zero;
计算阻力(包含空气阻力):Friction = 0.5f * GetPhysicsVolume()->FluidFriction。
CalcVelocity() 更新速度。
合并 root motion 到速度:ApplyRootMotionToVelocity(deltaTime)。
- 更新位移
计算位移差量 const FVector Adjusted = Velocity * deltaTime;
SafeMoveUpdatedComponent(Adjusted)
- 碰撞处理
若 Hit.Time < 1.f(发生阻挡):
计算向上/向下分量 尝试是否步升。
如果步升成功更新偏移 OldLocation.Z ,以保证速度计算一致
如果步升失败转入滑动:HandleImpact() + SlideAlongSurface() 做滑动修正/二次移动。
- 末尾更新速度
若未 teleport、且无动画 root motion、且无 root-motion 覆盖:Velocity = (UpdatedComponent->GetComponentLocation() - OldLocation) / deltaTime(用实际位移恢复速度)。
PhysFlying的逻辑非常简洁基础,可以看作通用移动的模板
PhysSwimming
PhysSwimming 处理在水体中运动(物理体积:水),包含水体动力学的基本算法(浮力、浸入深度、在表面附近限制上升加速),计算受流体阻力、浮力与输入影响的速度,处理撞墙、上台阶、出水(切换运动模式)以及根据实际位移重写速度。
- 子步与复杂碰撞处理:通常一次 Swim() 即完成位移并返回剩余时间
- 数据依赖:依赖物理体积(水),同时受浮力、流体摩擦、普通重力的数值影响
- 速度/位移更新:移动后直接基于实际位移重写速度(类似 NavWalking),但保留 Z 分量以支持出水跳跃。
- 碰撞处理:碰撞后优先尝试利用步升 或 SlideAlongSurface,另有 HandleSwimmingWallHit 专用回调以处理水中特有的墙面交互。
整体逻辑
void UCharacterMovementComponent::PhysSwimming(float deltaTime, int32 Iterations)
- 早期检查:
- 数据准备:计算浮力 / 深度 / 上升加速限制
深度计算 :Depth = ImmersionDepth()(0..1),NetBuoyancy = Buoyancy * Depth。
若速度向上且接近离开水面并且有浮力:抑制离开水面的速度 Velocity.Z = max(0.33*MaxSwimSpeed, Velocity.Z * Depth*Depth)
浅水处限制玩家的向上输入加速 : 若 Depth < 0.65(接近水面),把 Acceleration.Z 限制到 <= 0.1(bLimitedUpAccel = true 记录),
- 计算速度
Iterations++、保存 OldLocation、置 bJustTeleported=false。
若无 root motion 覆盖:
计算流体摩擦(与深度相关):Friction = 0.5 * FluidFriction * Depth。
CalcVelocity(bFluid): bFluid=true 参数(影响阻尼/摩擦模型)。
浮力/重力合并到 Z(浮力按 NetBuoyancy 抵消一部分重力)。: Velocity.Z += GetGravityZ() * deltaTime * (1 - NetBuoyancy)
ApplyRootMotionToVelocity(deltaTime) 合并 root motion 影响(若存在)。
- 更新位移 Swim()
Adjusted = Velocity * deltaTime,然后 remainingTime = deltaTime * Swim(Adjusted, Hit)。
Swim 负责实际位移(通常带 sweep);它返回一个分数/比例(源码里乘以 deltaTime 得到 remainingTime),表示剩余未用时间或时间比例(调用处以此判断是否离水等)。
- 模式切换检查:如果 !IsSwimming()(离开水,可能脚本或触发切换)则把剩余时间转给 StartNewPhysics 并退出。
- 碰撞处理(Hit):若 Hit.Time < 1.f(发生碰撞):
HandleSwimmingWallHit(Hit, deltaTime):通知/特化处理。
若之前因接近水面限制了上升输入 (bLimitedUpAccel) 且现在 Velocity.Z >= 0,允许在被障碍顶住时再补偿一段上升力(把 OriginalAccelZ 加回),并用剩余时间再做一次 Swim。这是为了让玩家在贴着墙或障碍物时仍有机会跃出水面。
否则计算是否能 StepUp(基于命中法线和 UpDown = GravDir | VelDir 的条件);若 CanStepUp(Hit),尝试 StepUp(有个 Velocity.Z = 1 的 hack 以促使上移),若成功并离水则切换模式并返回;若失败则恢复 Velocity 并继续下述流程。
若没上台阶:HandleImpact + SlideAlongSurface(把剩余时间用来沿表面滑动)。
- 结束后重写速度
若进行了实际移动(deltaTime - remainingTime > small)且没有 teleport/root motion 干扰,使用 Velocity = (NewFeetLocation - OldLocation) / (deltaTime - remainingTime) 来反映真实位移。若刚离水(bWaterJump)会保留先前的 velZ。
- 水域边界处理
如果当前物理体积不再是水域但仍 IsSwimming(),强制 SetMovementMode(MOVE_Falling)(例如触发器没有脚本把模式改为 falling)。
最后如果 !IsSwimming()(已离水),把剩余时间交给 StartNewPhysics 继续处理。
PhysSwimming模式中的逻辑没有像PhysWalking或PhysFalling进行复杂的特化位移,而是在基础的自由移动模式逻辑加上流体中的阻力计算,并且限制模式在水体内开启。
Input-Rotation流程
Rotation的流程在Input-Move流程之中。其核心可以分为两种,控制器主导(Input-Rotation):与Input-Move的输入核心类似,,根据选项Character利用控制器旋转进行更新自身旋转。动画主导(RootMotion):使用动画带来的旋转.
Input-Rotation流程下会在代码中使用以下函数用来添加控制器旋转:
- AddControllerPitch/Yaw/RollInput //添加对应的控制器旋转
- 更新ControlRotation
Pawn中提供了利用控制器旋转来更新自身旋转的方法(此系列选项会覆盖之后所有的旋转更新,如果是Character这种精细化角色子类慎用)
virtual void FaceRotation(FRotator NewControlRotation, float DeltaTime = 0.f);//FaceRotation()会根据三个设置进行单独的更新
uint32 bUseControllerRotationPitch:1;
uint32 bUseControllerRotationYaw:1;
uint32 bUseControllerRotationRoll:1;
此方法的旋转更新流程在APlayerController中:
void APlayerController::PlayerTick( float DeltaTime )
void APlayerController::UpdateRotation( float DeltaTime )
APawn* const P = GetPawnOrSpectator();
P->FaceRotation(ViewRotation, DeltaTime);
由于Character是Pawn的衍生子类,所以除了Pawn中提供的FaceRotation(),Character自身提供了基于UCharacterMovementComponent中提供的旋转更新的选项,同时提供一组核心变量
void UCharacterMovementComponent::PhysicsRotation(float DeltaTime)
uint8 bIgnoreBaseRotation:1;
uint8 bUseControllerDesiredRotation:1;
uint8 bOrientRotationToMovement:1;
UpdateBasedMovement() + 旋转基座
由于UCharacterMovementComponent中的更新主体是胶囊体,且具有地面信息,因此UCharacterMovementComponent::UpdateBasedMovement()中会提前更新基座信息,包括基座是否移动,旋转等。如果当前胶囊体位于一个旋转的基座上,如果不忽略基础旋转时,基座的旋转会补偿到UpdateComponent中,并且同步到Controller中
void UCharacterMovementComponent::PerformMovement(float DeltaSeconds)
...
- 根据基座运动更新或者延迟更新位置
MaybeUpdateBasedMovement(DeltaSeconds)
UpdateBasedMovement(DeltaSeconds)
UpdateBasedRotation(FinalRotation, PawnDeltaRotation.Rotator());
...
void UCharacterMovementComponent::UpdateBasedMovement(float DeltaSeconds) //根据基座运动更新或者延迟更新位置
...
if (bRotationChanged && !bIgnoreBaseRotation)
const FQuat TargetQuat = DeltaQuat * FinalQuat;
FRotator TargetRotator(TargetQuat);
CharacterOwner->FaceRotation(TargetRotator, 0.f);
UpdateBasedRotation(FinalRotation, PawnDeltaRotation.Rotator())
...
void UCharacterMovementComponent::UpdateBasedRotation(FRotator& FinalRotation, const FRotator& ReducedRotation)
...
if ((Controller != nullptr) && !bIgnoreBaseRotation)
Controller->SetControlRotation(ControllerRot + ReducedRotation); //不忽略基础旋转,那么由旋转基座导致的UpdateComponent旋转会同步到Controller中
...
PhysicsRotation()
当bUseControllerDesiredRotation或bOrientRotationToMovement启用时,会使用PhysicsRotation()进行旋转的更新,此时会优化旋转的逻辑
void UCharacterMovementComponent::PhysicsRotation(float DeltaTime)
- 早期检查
如果既不 bOrientRotationToMovement 也不 bUseControllerDesiredRotation,函数直接退出
如果没有有效数据或既无 Controller 且不允许无 controller 运行(bRunPhysicsWithNoController),也直接返回。
- 获取当前旋转CurrentRotation = UpdatedComponent->GetComponentRotation())。
- 计算本帧允许的最大旋转量(按轴),通常基于角色的旋转速率与 DeltaTime 。DeltaRot = GetDeltaRotation(DeltaTime)
- 计算 DesiredRotation (根据设置选取不同计算方式)
若 bOrientRotationToMovement(旋转朝向运动方向): DesiredRotation = ComputeOrientToMovementRotation(通常把朝向设为移动方向/velocity 方向,兼顾平滑)。
若 bUseControllerDesiredRotation 且有 Controller:用 Controller->GetDesiredRotation()(由 AI/玩家控制器提供期望朝向)。
若 bUseControllerDesiredRotation 且 bRunPhysicsWithNoController的情况下(但是无控制器Owner),会尝试提取Owenr重新转换朝向
都不满足则退出函数。
- 竖直检查(ShouldRemainVertical)
如果“保持竖直”(通常角色不能翻滚/俯仰,只改变 yaw)
若自定义重力方向则把 DesiredRotation 转入重力相对空间(GravityToWorldTransform * quat),在该坐标系中把 Pitch/Roll 设为 0,归一化 Yaw,再变回世界空间。
否(普通重力):直接把 Pitch/Roll 设为 0 并归一化 Yaw。
否则对 DesiredRotation 做 Normalize()(确保角度在合理范围)。
- 按轴平滑/限速旋转(FixedTurn)
计算两角最短差值(考虑 360° wrap)
若差值比最大可旋转角度小:直接到达
否则按 MaxDelta 的方向推进一步,不允许越过目标
- 应用旋转
经 DiagnosticCheckNaN 校验后,调用 MoveUpdatedComponent(FVector::ZeroVector, DesiredRotation, /*bSweep*/ false) 把组件旋转到新的朝向(不 sweep,不做碰撞测试)。
ShouldRemainVertical()竖直检查,在UE中,角色的胶囊体默认情况下是永远平行于世界中的Z轴(垂直于世界)。
FixedTurn() 的作用:保证不会超速旋转也不会抖动,是平滑旋转的关键。
PhysicsRotation必须在使用bOrientRotationToMovement或者bUseControllerDesiredRotation的时候才能进入逻辑
UpdateBasedMovement()中会先更新具有“旋转基座”的补正旋转数据,随后具有补正的旋转信息会进入PhysicsRotation()更新(前提是启用PhysicsRotation()的更新选项)
总结
Character是文章中讨论的UE中实现3C中完整的“角色”本体,其在UE3中就已存在,结合了输入,运动学,网络,动画等。其位于整个Gameplay架构核心位置。
但是由于其设计在引擎早期,且功能庞大,处理角色移动表现相关的逻辑更是散落在各处,同时又强耦合了几乎所有关联组件。角色相关的文章会分段式的拆解角色的大部分主要功能逻辑。
在面对大型开放世界的项目要求中,特别是存在非常多地形交互+场景交互并且对交互有苛刻要求(涉水,游泳,泥沼,攀岩,跑酷。对重力,摩檫力敏感等),这种需求下即使对UE的角色框架进行小规模扩展/修改也不足以应对,请在积重难返前从Pawn出手对项目进行独立的角色开发。所以文章更倾向于解剖逻辑,适配大部分人的生产环境。同时博主更推荐mover2.0的框架结构,对于角色来说是下一代版本的新角色框架体系

浙公网安备 33010602011771号