角色移动随笔
前言
角色移动相关的林林总总
QA
- Q: 角色移动来自哪里
- A:
- 按键输入
- 物理力(Force)
- Launch
- 动画RootMotion
- RootMotionSource
- RequestVelocity
- Gravity
- Q: 角色的Z方向是速度是哪里处理的?
- A: MaintainHorizontalGroundVelocity, 而且不止调用了一次; Z速度比较复杂, 地面移动的Z速度不能通过重写这个函数来找回
- Q:刹车是怎么处理的
- A:刹车/制动
- Q:怎么判断角色处于Falling
- A:空中判断
- Q:怎么处理角色处于边缘的情况
- A:14.1.9 边缘检查
- Q:速度和加速度是哪里 计算的
- A:14.1.3 CalcVelocity
- Q:有没有处理撞墙(Hit)事件的逻辑
- A:相比于监听Capsule的Hit回调事件, 移动逻辑里有一部分相关代码也是类似情形, 大致在这里
PerformMovement
角色移动的核心就是这里, 从这里展开
1.清理过期数据

这个阶段判断是否处于不能移动或者物理状态, 如果是那么清理
如果条件允许同时处于RootMotion状态, 那么Tick一次Pose然后清理RootMotionParams
如果有激活的RootMotionSource(下简称RMS), 那么清理RMS数据
清理Force
2.处理RMS的LastPreAdditiveVelocity
1 | const FVector Adjustment = (Velocity - LastUpdateVelocity); |
根据当前速度与上一帧的Velocity差, 补偿到LastPreAdditiveVelocity
3.MaybeUpdateBasedMovement
这里主要处理了如果角色站在了动态地面(即Moveable的模型)上的变化, 不过角色的速度不会得加动态物体的速度
4.清理过期RMS

过期的RMS即被标记为MarkedForRemoval或者Finished的RMS, 在这一步清理掉
5.应用力
1 | ApplyAccumulatedForces(DeltaSeconds); |
我们对角色调用的AddImpulse等接口添加的力在这一步被换算成Velocity
如果有Z速度, Mode可能会被设置成Falling
6.UpdateCharacterStateBeforeMovement
这里刷新一次Crouch/Uncrouch状态
7.TryToLeaveNavWalking
如果是MOVE_NavWalking, 这里尝试离开这个状态
8.HandlePendingLaunch
处理LaunchCharacter产生的推力, 把推力换算成Velocity, 这里一定会把Mode改成Falling
9.清理所有力
1 | ClearAccumulatedForces(); |
清理第5和第8步的力
10.再次处理RMS的LastPreAdditiveVelocity
1 | const FVector Adjustment = (Velocity - OldVelocity); |
因为上面几步与第2步之间有力的作用, 所以这里通过前后Velocity的变化, 把这个插值记录到LastPreAdditiveVelocity中
11.预处理RootMotion

这里预处理了两种RootMotion, 包括动画RootMotion(下简称ARM)和RMS
如果是动画RootMotion, 那么这里会TickCharacterPose(DeltaSeconds);, 这个会产生RootMotion数据RootMotionParams,然后保存客户端的ClientRootMotionParams
如果是RMS, 这里会执行CurrentRootMotion.PrepareRootMotion(), 这个函数就是通过各种不同的RMS算法把速度记录到RootMotionParams
12.处理RootMotion
同11步, 同样处理了两者
12.1 ARM

TickPose产生的RootMotionParams转换成世界空间, , 然后转化成Velocity
UE5插件
MotionWarping监听的就是ConvertLocalRootMotionToWorld()当中的事件
我们知道, 如果保持正常的移动Mode, ARM的Z速度经常是没有的, 但是这里的Velocity还是有Z速度的, Z速度会在后面被处理掉
12.1.1 CalcAnimRootMotionVelocity

把RootMotionParams中的偏移转换成速度
12.1.2 ConstrainAnimRootMotionVelocity

如果处于Falling 那么速度用的是本身的速度, RootMotion中的Z速度会被替换
12.2 RMS

这一步就是把把RMS中的数据换算成Velocity
13.清理Jump输入

Jump是可以有HoldTime的, 这里就是处理这个
14.StartNewPhysics *
这里内容非常庞大, 也是移动输入的核心场所, 分条展开
14.1 PhysWalking *
正常的地面移动都在这里处理
14.1.1 RestorePreAdditiveRootMotionVelocity
如果RMS有有叠加的, 那么这一步会把第2和10步处理的LastPreAdditiveVelocity设置成Velocity
14.1.2 MaintainHorizontalGroundVelocity

两种方式, 要么单纯把Z速度变成0但是总速度大小会变(其实后面还会补上来), 要么总速度大小不变的情况下把Z速度去掉
其实这一步本质上区别不大
14.1.3 CalcVelocity *
这个函数非常重要, 核心的速度计算都在这里.
进入这里有几个条件, 需要不是ARM或者RMS状态
14.1.3.1 计算RequestedMove

先计算Requested速度, 这个是一个比较特殊的速度, 是作为额外一个叠加速度存在的, 通过下面2个UCharacterMovementComponent的接口设置(目前未暴露给蓝图)
1 | virtual void RequestDirectMove(const FVector& MoveVelocity, bool bForceMaxSpeed) override; |

上图中有个需要注意的开关bRequestedMoveUseAcceleration, 这个在蓝图中是可编辑的
如果true那么之类会计算出来一个加速度, 等后续的时候加到速度上去, 否则就直接会把当前速度替换成RequestedVelocity
另外的细节可以看[RequestVelocity](#Request Velocity)
14.1.3.2 处理加速度
如果bForceMaxAccel为true, 那么在这里会把加速度大小强制设置成最大的
14.1.3.3 制动流程
这里会有几个判断, 制动流程的进入条件是(bZeroAcceleration && bZeroRequestedAcceleration) || bVelocityOverMax
翻译一下是 没有加速度同时没有Requested速度, 或者是当前速度大于了最大速度. 后者比较容易被忽略
制动流程可以看↑制动流程
然后如果速度还是大于最大速度, 这里有个保护, 这里把速度大小设置成最大速度
14.1.3.4 阻力对速度的影响

阻力参数Friction(需要区别BrakingFriction)对由加速度造成的速度变化是有影响的, 阻力越大, 加速和切换方向会慢
14.1.3.5 流体对加速的影响

然后是处理流体速度的影响
14.1.3.6 输入对速度的加速

如果有按键输入, 那么这里通过加速度对速度进行加速
14.1.3.7 处理Requested速度

然后是处理前面产生的Request速度, 如果bRequestedMoveUseAcceleration为true, 那么这里会对速度进行一个增量, 如果为false, 那么这里可以忽略
14.1.3.8 处理RVO
涉及到RVO的内容比较多, 这里先不展开
14.1.4 ApplyRootMotionToVelocity
这个函数的字面意思跟实际用途略有差别, 这里主要处理的是RootMotion(包含ARM和RMS)+Falling状态下的DecayingFormerBaseVelocity的处理
DecayingFormerBaseVelocity会在站在动态地面上时才会有变换的速度值
14.1.5 处理如果是Fallinig

这一步如果是Falling了, 那么重新执行StartNewPhysics()
14.1.6 MoveAlongFloor *
在地面上的移动处理, 这个函数也比较庞大, 返回一个脚步数据FStepDownResult, 这个数据包含了我们经常用的CurrentFloor
14.1.6.1 ComputeGroundMovementDelta

这里处理的是斜坡上的速度, 传入的Delta是水平速度, 经过Dot计算得到了Z的速度, 这里也有bMaintainHorizontalGroundVelocity的判断, 但是返回值都是带Z速度的(目的是什么?)
14.1.6.2 SafeMoveUpdatedComponent
这个函数在很多地方都会调用, CharacterMovementComponent没有重写任何东西, 核心内容在UPrimitiveComponent::MoveComponentImpl()中
里面一大堆的数据初始化, 最核心的就是下面这里

之后就是调用物理接口了
所以就是利用前面算出来的delta速度, 对capsule进行一个短距离的Multi的Sweap操作,获取了Hit数据, 如果有必要就移动
如果bStartPenetrating(出生点跟其他物体有重叠/穿透), 那么会需要解决穿透问题, 这个就是角色如果出生在模型内然后被挤出模型的操作, 如果要特化这个操作可以重写ResolvePenetrationImpl()
14.1.6.3 处理穿透

如果出生的时候与其他物体有穿透, 那么其实前面的SafeMoveUpdatedComponent()已经处理了这个问题, 一般走不进这里, 感觉这个是做了一层保护, 因为前面的SafeMoveUpdatedComponent是可以重写的
14.1.6.4 处理Hit事件

撞墙和撞台阶都会走到这里, 需要判定是否能上台阶
bMaintainHorizontalGroundVelocity在这里又发挥了奇怪的作用, 在判断是否可以StepUp的过程中有查询地面数据, 该数据包含在FStepDownResult中
14.1.7 再次处理Falling/Swimming
同14.1.5一样的处理, 经过上一步的处理以后如果进入了这两个状态, 那么就切换
14.1.8 记录或者查询Floor

如果有step查询, 那么这里就已经有了floor数据, 否则就要通过FindFloor查询floor数据
14.1.8.1 FindFloor
这里通过了一个Sweap操作获取地面数据, 主要计算在ComputeFloorDist()中, 大部分内容可以看是否在空中判断
14.1.9 边缘检查

移动组件有这么两个参数, 如果false, 那么角色不能从边缘通过走路掉下去(可以通过Jump)

这里就限制了角色下落
如果可以下落, 那么接下来就是适配角色的高度, 因为角色的胶囊体底部是圆形的, 所以默认的就是保持胶囊体底部跟地面是接触的, 在边缘会出现这样的情况

如果要自定义这个效果, 可以重写ShouldCatchAir()(这个默认直接false)和HandleWalkingOffLedge()做处理, 也可以重写AdjustFloorHeight()进行补充
14.1.9.1 Swimming检查

可能到这一步从边缘掉进水里, 就进入Swimming逻辑
14.1.9.2 进入Falling
正常情况, 到这一步就进入了Falling, 然后直接return
14.1.10 最后处理地面速度

到这里如果在地面上, 那么通过这时候的组件的位置与PhysWalking刚开始的位置偏差计算得到最终的Velocity
如果撞墙了, 那么这里的速度只有墙面切向方向的速度, 而且大小没有变化
比如Velocity(100,20,0)撞到了切线是Y方向的墙面, 这里的速度是Velocity(0,20,0)
15.UpdateCharacterStateAfterMovement
参考第6步, 也是更新了Crouch状态
16.PhysicsRotation
这里有几个前提
bAllowPhysicsRotationDuringAnimRootMotion(默认关闭)或处于非RootMotion- 需要开启bOrientRotationToMovement, 同时关闭bUseControllerDesiredRotation
其实就是小白人默认的移动模式就可以走到这里, 角色永远会面朝速度方向, 你转动镜头的时候角色通过一定速度转向速度方向
17.处理RootMotion的旋转

前面第12步处理了ARM和RMS的Velocity, 这一步单独处理两者的旋转
发出疑问, 所以RMS是可以有旋转的, 虽然引擎自带的API和案例没有进行这个计算
18.OnMovementUpdated
虚函数, 无实现, 到这一步需要处理的事情可以重写这个函数实现
19.CallMovementUpdateDelegate

虽然命名是Call***Delegate, 但是这里刷新了组件的速度
20.根据动态地面保存位置

如果站在动态地面上, 那么这一步会更新对于动态地面的相对位置和旋转信息
21. UpdateComponentVelocity()

所以, 为什么这里又刷新一次, 前面CallMovementUpdateDelegate中已经刷新了一次
22.处理若干网络数据

23.保存旧数据

把当前最终的数据保存为Last版本
AddMovementInput

这个接口是玩家移动输入的入口
通过一个方向和一个缩放值计算得到是一个加速度方向, 我们看下面分析

Tick中每一帧消耗掉上一次的输入信息并赋值给InputVector, 看下图, 主要内容就在这里面

然后Tick流程继续, 在下面判定并执行了ControlledCharacterMove(InputVector, DeltaTime);

这个函数把InputVector转变成加速度Acceleration(带长度的), 然后就进入大名鼎鼎的PerformMovement(), 一路走到
PhysWalking()中的CalcVelocity()函数中, 正常情况下会判定是否是制动流程, 如果不是就进入速度与摩擦力的计算

上图可以看到关键计算代码, 跟参数Friction和Delta值有很大关系, 如果最终得到的值大于1, 那么速度瞬间改变, 否则就是做插值过渡, 那么就意味着GroundFriction参数越大, 移动速度改变越快
接下来会计算流体的速度影响, 这里跳过
然后再根据加速度和delta计算速度

后面的是RequestedAcceleration和RVO的相关的计算
制动/减速
角色在两种情况下会执行制动流程
- 没有加速度,即没有移动输入
- 当前速度大于最大速度
减速相关的主要的配置属性如下

- bUseSeparateBrakingFriction: 如果为true, 那么单独设置阻力, 否则就使用GroundFriction或者其他外部的阻力
- BrakingFrictionFactor: 阻力系数
- BrakingFriction: 阻力, 计算的时候一般就是
BrakingFrictionFactor * BrakingFriction - BrakingSubStepTime: 计算阻力的时候分段模拟的时间, 会clamp在 1/75与1/20之间,数值小能更平滑, 一般也不用设置
- BrakingDecelerationWalking: 移动制动力, 还有类似的有falling,flying等等
角色移动的减速流程就在下面ApplyVelocityBraking代码当中
1 | void UCharacterMovementComponent::ApplyVelocityBraking(float DeltaTime, float Friction, float BrakingDeceleration) |
概括一下减速流程分2部分, 一个是阻力, 一个是制动力;
阻力是一个变速减速运动, 制动力是匀减速运动
所以如果把阻力设为0, 把制动力设置为当前速度一致, 那么得到的结果就是1秒内(会有大约一帧的误差)减速到0
Request Velocity
我们直接通过设置角色或者移动组件的速度是不符合UE设计的, 因为他有自己的一套计算流程, 比如下面这样

事实上UE提供了一个接口, 让我们可以手动的设置速度(叠加速度)
1 | virtual void RequestDirectMove(const FVector& MoveVelocity, bool bForceMaxSpeed) override; |
在计算整体速度的时候会用到这个速度


上面图中可以看到, 在计算完普通的速度以后, 在最后叠加上了这个RequestVelocity, 同时也保证了总速度大小保持一致
是否在空中的判定
角色是否在空中的判定主要来自当前地面数据CurrentFloor, 通过CurrentFloor.IsWalkableFloor()来判定
满足条件下就开始执行 StartFalling()
那就需要看CurrentFloor是如何判断是否在地面的
从角色移动组件的UCharacterMovementComponent::ComputeFloorDist()开始, 里面有下图这一段

可以理解, 角色胶囊体会进行基于形状的向下的sweap检测, 检测距离跟宽高差有关系, 意味着越细长这个检测距离越长, 距离范围是 20 + [0, 半高/10)

一般情况下这样就够了, 但是有一些特殊情况还需要再进行sweap检测, 比如初始就有重叠等情况
还有就是遇到超过能走路的斜坡这种, 虽然block了但是不是walkable, 那就还需要进行一次射线检测来判断是否是站在地面上
胶囊体与地面的距离

如上图所示, 我们从角色往下打射线检测到的地面的点 与我们角色脚底位置的点实际上是有偏差的, 这个偏差可以从移动组件里面获取到,就是下图这个东西

这个距离怎么来的呢?
实际上就是角色在计算地面数据的sweap过程中产生的, 大概理解为sweap前后的距离差,具体代码在UCharacterMovementComponent::ComputeFloorDist()中
大致如下
1 | { |
这个SweepResult就是我们要的Dist
orm