🛖UE 角色移动笔记
00 分钟
2024-12-27
2025-1-5
type
status
date
slug
summary
tags
category
icon
password
最近在看 UE 角色移动相关的代码,流程相当复杂,而且涉及的东西很多,过程中参考了很多文章,这里总结了一个结合自己看代码理解的笔记。文章里的图基本都是盗的别的文章的,主要是方便理解和记录,如果文章内容有纰漏,请轻喷(疯狂叠甲)。

USceneComponent

USceneComponent 是带有 Transform 并且允许相互挂载的 UActorComponent,常见的 UPrimitiveComponent、UCapsuleComponent 等都是其子类。
  • 关于移动,我们只关心 MoveComponent 和 MoveComponentImpl 两个方法。MoveComponent 最终会调用到 MoveComponentImpl,用于实现基于物理的移动。
  • MoveComponentImpl 是一个虚函数,由子类进行实现,默认实现下直接根据 Delta 传送到目标位置,不执行任何物理检测,而 UPrimitiveComponent 的实现版本则会实现基于物理的移动。
 
所谓基于物理的移动(Sweep),是在物体移动路径上做碰撞检测,判断是否碰撞或穿透到其他物体,如果是的话,需要做出反应,如碰撞了需要停在碰撞物前,或者开始时就已经跟别的物体产生了穿透,则直接停止。示意图:
notion image
notion image
 
要注意的是 Sweep 检测在任何情况下都需要返回一个 FHitResult 结果供后续处理,里面保存了以下关键字段:
  • TraceStart:Sweep 检测起点
  • TraceEnd:Sweep 检测终点
  • Time:0-1,表示移动最终停止在路径上的百分比,如运动一半后停下就是 0.5
  • Distance:最终的移动距离
  • bBlockingHit:是否产生碰撞
  • bStartPenetrating:是否一开始就跟其他物体产生了穿透
  • PenetrationDepth:穿透深度
  • Location:最终位置
  • ImpactPoint:碰撞点位置
  • Normal:自身碰撞面法线
  • ImpactNormal:碰撞物碰撞面法线
 
Sweep 是角色移动的基础,前面说到只有 UPrimitiveComponent::MoveComponentImpl() 中才实现了 Sweep,UActorComponent::MoveComponentImpl() 中则只是简单传送。UPrimitiveComponent::MoveComponentImpl() 会调用 UWorld::ComponentSweepMulti() 来执行真正的 Sweep,而 UWorld::ComponentSweepMulti() 里则会调用物理引擎进行真正的 Sweep 查询,UE5 是使用 Chaos,UE4 则使用 PhysX,具体的物理引擎 Sweep 算法有兴趣可以去了解,附录链接里有具体内容。
 
UWorld::ComponentSweepMulti() 会返回 Sweep 路径上产生的所有碰撞结果,但最终我们只需要选取一个碰撞结果作为 Sweep 查询的结果,此时分为两种情况:
  • 如果不是一开始就与别的物体产生了穿透,那么直接选取最近的碰撞结果
  • 如果是一开始就与别的物体产生了穿透,那么选取碰撞面 Normal 与当前运行反向最相反的碰撞结果
notion image
 
至此我们已经有基础的基于物理的移动支持了。

UMovementComponent

移动组件继承自 UActorComponent,负责处理 Actor 的移动。
  • UMovementComponent 需要绑定更新目标组件并保存到 UpdateComponent 和 UpdatePrimitive(可以通过 SetUpdatedComponent() 方法),在默认情况下,UpdateComponent 会自动绑定所挂载的 Actor 的 RootComponent(如 ACharacter 则是一个 UCapsuleComponent),UpdatePrimitive 则是 UpdateComponent 强转成 UPrimitiveComponent 的结果。对于角色移动场景,我们可以认为这 UpdateComponent 和 UpdatePrimitive 都指向一个胶囊体组件。
  • Velocity 属性不会直接被用于更新位置,只做存储用途,因为任何形式的移动都需要速度,所以在 UMovementComponent 这个基类里统一定义。
  • MoveUpdatedComponent() 方法可以用于去更新 UpdateComponent 的位置,最终会调用到上面说的 USceneComponent::MoveComponent() 来实现基于物理的移动
  • SafeMoveUpdatedComponent() 则基于 MoveUpdatedComponent() 再包装了一层,在 MoveUpdatedComponent() 单纯的 Sweep 之上再添加了出发即与其他物体穿透的处理,如果发生这种情况,会调用 ResolvePenetration() 实现从碰撞物中挤出并移动。
 
UMovementComponent::SafeMoveUpdatedComponent() 是 UE 角色移动的根基,可以认为在 CharacterMovementComponent 中所有胶囊体的移动最终都会调用到这个函数。

UCharacterMovementComponent

UCharacterMovementComponent(CMC)最终继承自 UMovementComponent,负责消费用户输入,控制角色移动。
  • CMC 实现了基于物理的角色移动算法,业内又常常称为 Collide And Slide,即在角色移动的过程中考虑物理的影响,例如在平面上移动、产生碰撞后沿碰撞面滑动、上楼梯等。
  • CMC 是一个状态机,根据 MovementMode 同时管理了各种移动模式并可以自由切换,常见的模式有 Walking、Fall 等。
  • CMC 内还实现了网络同步逻辑,可以在多人游戏中进行移动的同步。
  • 提供了大量可配置参数,只调参数就能定制角色移动的方方面面,如果需要深度定制,则需要自己重载部分函数。

纯单机移动

我们先讨论纯单机游戏中的移动。首先我们要知道的是,不同的移动模式下需要处理的状况各不相同,但是 CMC 最终控制移动,都是计算一个位移然后调用 SafeMoveUpdatedComponent() 执行胶囊体的 Sweep 移动。
 
CMC 所有逻辑的处理都放在 TickComponent() 之中,在纯单机模式下最终会调用到 PerformMovement() 执行移动,我们只需要关注这个函数即可。
 
PerformMovement() 中又会调用 StartNewPhysics(),其中会根据当前运动模式的不同分别调用不同的具体运动模式的处理函数,如 PhysWalking()、PhysFalling() 等。对不同的模式,这里都稍微过一下流程。
 

PhysWalking

多次迭代直到移动到最佳位置,每次迭代:
  • CalcVelocity():根据当前的 InputVector、摩擦力、重力等参数计算并更新速度
  • MoveAlongFloor():尝试沿着当前地面移动
  • FindFloor():寻找并更新当前地面,如果没有找到可行走的地面,则进入 Falling 模式,如果找到了可行走的地面,则更新当前 z 坐标以适配地面
 
可以看出来 PhysWalking 主要就是尽可能沿着地面进行移动,最重要的两个方法是 MoveAlongFloor() 和 FindFloor()。
 
MoveAlongFoor() 中首先会尝试根据当前地面坡度(图中面1)去调整移动向量,然后尝试 Sweep 移动,如果没有阻挡则直接移动成功,如果有阻挡则要再进行一次调整,在碰撞位置再根据新的坡面(图中面2)再调整一次移动向量,然后继续 Sweep,此时如果 Sweep 成功则是上坡流程成功。
notion image
如果在第二次 Sweep 中发现面2 过于陡峭或者 Sweep 仍然阻挡,则要改 StepUp(上楼梯流程)。
notion image
StepUp 实际上会拆分成 3 次 Sweep,即 Step Up → Step Fwd → Step Down,实际上 StepUp 很可能会失败,例如面2 的高度大小角色最大步高、移动过程中检测到碰撞等。一旦 StepUp 流程失败,就要回归到 SlideAlongSurface(),只不过不再沿着面2 往上爬,而是认为面2 为一堵墙,执行“贴墙走流程”。
notion image
贴墙走流程如下,其实就是把爬坡倒过来,本质是一样的,就是根据遮挡面去调整方向向量然后执行 Sweep 而已:
notion image
有的文章说在贴墙走过程中如果发现面3,还会执行 TwoWallAdjust() 来消耗剩余向量沿着面3 Sweep,但是我没有找到对应代码,按照我的理解,这里如果出现面3,也只需要在下一次迭代中正常执行 SlideAlongSurface() 即可,至于是对于面3 是爬坡还是贴墙走,SlideAlongSurface() 里本身就有处理。
 
至此 SlideAlongSurface() 流程就结束了,下面看看 FindFloor(),FindFloor() 本质上是进行向下的 Sweep 或者 LineTrace 来找寻碰撞的碰撞的物体。下图是 Sweep 和 LineTrace 的示意图:
notion image
notion image
 
FindFloor() 分为两个阶段:正常检测阶段和栖息检测阶段,正常检测失败的情况下可能会进入栖息检测。每一个阶段都会先尝试进行 Sweep Test,如果失败了再尝试 LineTrace Test,所以最坏情况可能会进行 4 次碰撞测试。
 
Sweep Test 失败之后还要补一次 LineTrace Test 的原因是按照胶囊体 Sweep 去进行阻挡测试有可能会把一些小物件(如碎石)误认为地面,再补一次 LineTrace 才能进行确定。
 
进入栖息检测阶段的条件是 Sweep Test 检测到阻挡并且 Line Trace 没有找到平面。主要是为了处理站在平面边上的情况,简单来说就是缩小胶囊体再做一次检测。
notion image
至此 PhysWalking 流程就已经差不多了。类似的模式还有 PhysFalling、PhysSwimming、PhysFlying、PhysNavFalling 等。每一个都是一大堆的 Corner Case,有需要的时候再单独看即可。

DedicatedServer 模式下角色移动流程

联机模式下移动组件会自动进行网络同步,我们首先要搞清楚的是 ENetRole:
  • ROLE_SimulatedProxy:Client 模拟端,如本机看到的其他玩家,AI 等
  • ROLE_AutonomousProxy:Client 主端,玩家自己
  • ROLE_Authority:Server 权威端,一切都以它为准
 
每个角色移动组件都有自己的 LocalRole 和 RemoteRole,表示本地的 ENetRole 和远端的 ENetRole,例如:
  • Client 端玩家控制的角色,LocalRole=ROLE_AutonomousProxy, RemoteRole=ROLE_Authority
  • Client 端其他玩家,LocalRole=ROLE_SimulatedProxy, RemoteRole=ROLE_Authority
  • Client 端 AI,LocalRole=ROLE_SimulatedProxy, RemoteRole=ROLE_Authority
  • Server 端玩家,LocalRole=ROLE_Authority, RemoteRole=ROLE_AutonomousProxy
  • Server 端 AI,LocalRole=ROLE_Authority, RemoteRole=ROLE_SimulatedProxy
 
在代码里可以看到各种 GetLocalRole()、GetRemoteRole() 的判断,实际上就是客户端服务器以及是否是玩家主控要做差异化判断。

主端流程

要注意的是虽然 Client 主端拥有角色的控制权,但是为了防止作弊,本地进行模拟的同时,需要将移动数据发送给 Server,Server 收到移动数据之后会进行模拟,之后与 Client 发送的模拟结果进行比对,如果移动有效,则完成移动,移动无效则需要对 Client 的移动数据做矫正。
 
具体流程可以参考 ReplicateMoveToServer() 函数,函数在 Client 主端被调用,下面是这个函数的具体流程:
  • 创建同步数据
    • 找到上一次没有被 Server 接受但比较重要的 FSavedMove_Character 数据 OldMove
    • 创建新的 FSavedMove_Character 数据 NewMove,如果上一步有 OldMove 产生则合并到 NewMove
  • 本地执行移动模拟 PerformMovement(),这里这个函数的工作与单机模式下完全相同
  • 保存模拟后的关键结果到 NewMove 中,将 NewMove 暂存到 ClientData->SavedMoves 队列中等待发送给 Server
  • 调用 CallServerMove() 打包 NewMove 数据并发起 ServerMove() 函数的 RPC,如果这里有 OldMove 还会额外进行一次 ServerMoveOld() 的 RPC,防止关键移动数据没有被服务器模拟导致问题
 
之后 Server 会收到 RPC 调用,从 ServerMove_Implementation() 开始执行模拟校验工作,具体代码在 ServerMove_HandleMoveData() 中可以找到,下面简单说一下 Server 端收到 RPC 之后流程:
  • 调用 MoveAutonomous() 执行 Server 端移动模拟,内部还是会走 PerformMovement() 这个关键函数
  • 调用 ServerMoveHandleClientError() 来比对 Client 模拟和 Server 模拟的结果,将结果暂存到 ServerData->PendingAdjustment() 中
  • Server 会在 UNetDriver::ServerReplicateActors() 或 UReplicationGraph::ServerReplicateActors() 去调用移动组件的 SendClientAdjustment() 函数
    • 如果 Client 移动合法则发起 ClientAckGoodMove() 函数的 RPC 调用
    • 如果 Client 移动非法则发起 ClientAdjustPosition() 函数的 RPC 调用
 
之后 Client 有可能收到 ClientAckGoodMove() 或 ClientAdjustPosition() RPC,如果是 ClientAckGoodMove() 则在 ClientData->SavedMoves 队列中找到所有已经没有的数据并删除,并完成此次移动同步。ClientData->SavedMoves 队列的作用其实也很简单,就是减少发送的数据量,因为本地模拟的频率与网络同步的频率并不一致,所以需要将本地模拟的结果持续地缓存起来,直到服务器确认再将其一次性删除。
 
如果 Client 收到了 ClientAdjustPosition() RPC,则需要使用服务器的模拟结果覆盖本地,之后在服务器的模拟结果基础上进行后续的操作。至此 Client 主端的一次移动模拟就结束了。

模拟端流程

Client 模拟端相对来说更简单,因为不需要将“控制”同步给 Server,只需要不断使用 Server 的模拟结果对本地进行覆盖即可,因为 Server 帧率远远比 Client 帧率低,在没有收到同步数据的情况下,Client 直接使用历史速度等进行预测即可。
 
每当 Actor 的 ReplicatedMovement 属性被复制时,AActor::OnRep_ReplicatedMovement() 会被调用,接着会通过 APawn::PostNetReceiveVelocity() 去更新 UMovementComponent 的速度,然后通过 APawn::PostNetReceiveLocationAndRotation() 设置 Actor 的位置旋转等。这就是 Server 端数据的暴力覆盖,最后还会调用 UCharacterMovementComponent::SmoothCorrection() 设置 bNetworkSmoothingComplete,之后这个 Flag 会在 Client 端模拟移动时有用。
 
除开 Server 端暴力覆盖外,Client 端还会在每一次 Tick 中去做一个简单的移动模拟,就是为了处理没有数据包的情况下,使用历史速度进行移动,这个流程代码在 SimulatedTick() 中可以找到,下面简单说一下具体流程:
  • SimulateMovement()
    • 如果收到了数据包,需要顺便更新下 MovementMode()、执行 FindFloor() 等
    • 没有收到数据包的情况下调用 MoveSmooth(),使用历史速度等进行一个简化版的物理移动,要注意的是这里只是移动胶囊体,Mesh 并不会跟随移动
  • 通过 !bNetworkSmoothingComplete 判断是否需要进行 Mesh 的平滑,如果是的话,调用 SmoothClientPosition() 执行 Mesh 平滑
 
至此模拟端的流程也走完了。

附录

上一篇
利用 C++ Concepts 做编译期检查
下一篇
VulkanSDK 在未安装情况下使用的一些踩坑

评论
Loading...